From add21d6273408e8ebb67dd39995aaa51b73223f3 Mon Sep 17 00:00:00 2001 From: Lee Rosen <96027741+tsconfigdotjson@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:19:21 -0400 Subject: [PATCH 1/6] Add popout buttons on main page and menu bar running indicator Add pop-out glyph to each terminal block on the process card list view so users can pop out any output without navigating to the detail view. Show a small dot on the menu bar icon when any process is running. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/devtail/DevtailApp.swift | 11 ++++++++++- Sources/devtail/ProcessCardView.swift | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Sources/devtail/DevtailApp.swift b/Sources/devtail/DevtailApp.swift index 842e445..0e5eb22 100644 --- a/Sources/devtail/DevtailApp.swift +++ b/Sources/devtail/DevtailApp.swift @@ -6,12 +6,21 @@ struct DevtailApp: App { @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate var body: some Scene { - MenuBarExtra("devtail", systemImage: "terminal") { + MenuBarExtra { ContentView(store: store) .onAppear { appDelegate.store = store AppNotifications.requestPermission() } + } label: { + Image(systemName: "terminal") + .overlay(alignment: .topTrailing) { + if store.processes.contains(where: \.isRunning) { + Circle() + .frame(width: 5, height: 5) + .offset(x: 2, y: -2) + } + } } .menuBarExtraStyle(.window) } diff --git a/Sources/devtail/ProcessCardView.swift b/Sources/devtail/ProcessCardView.swift index 236fafe..429d370 100644 --- a/Sources/devtail/ProcessCardView.swift +++ b/Sources/devtail/ProcessCardView.swift @@ -9,6 +9,20 @@ struct ProcessCardView: View { @State private var isHovered = false + private func popOutButton(buffer: TerminalBuffer, title: String) -> some View { + Button { + PopOutWindowManager.shared.openWindow(buffer: buffer, title: title) + } label: { + Image(systemName: "arrow.up.forward.app") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.tertiary) + .padding(4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Pop out") + } + var body: some View { Button(action: onSelect) { VStack(alignment: .leading, spacing: 8) { @@ -30,6 +44,9 @@ struct ProcessCardView: View { fontSize: 10 ) } + .overlay(alignment: .topTrailing) { + popOutButton(buffer: process.buffer, title: process.name) + } } ForEach(process.auxiliaryCommands) { aux in @@ -54,6 +71,12 @@ struct ProcessCardView: View { .frame(maxWidth: .infinity, alignment: .leading) } } + .overlay(alignment: .topTrailing) { + popOutButton( + buffer: auxBuf, + title: "\(process.name) — \(aux.name)" + ) + } } } } From 6e56c1aff14a71666c517d78bddfe8af94764d63 Mon Sep 17 00:00:00 2001 From: Lee Rosen <96027741+tsconfigdotjson@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:21:58 -0400 Subject: [PATCH 2/6] Drop unnecessary Swift install and use SwiftLint action in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macos-26 runner already has Swift 6.2 via Xcode — no need to install it separately (was adding ~40s). Switch from brew install swiftlint to the dedicated GitHub Action for faster runs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b702c9..7506fde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,13 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Swift 6.2 - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.2' - - name: Swift Format run: swift format lint --strict --recursive Sources/ - name: SwiftLint - run: brew install swiftlint && swiftlint lint --strict + uses: norio-nomura/action-swiftlint@3.2.1 + with: + args: --strict - name: Build run: swift build From b24fdfbd8d85304d13ac6d017c48da2b633b0cbb Mon Sep 17 00:00:00 2001 From: Lee Rosen <96027741+tsconfigdotjson@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:22:37 -0400 Subject: [PATCH 3/6] Revert SwiftLint action to brew install (container actions need Linux) The norio-nomura/action-swiftlint is a container action which only runs on Linux, not macOS. Revert to brew install for now. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7506fde..cf840d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,9 +18,7 @@ jobs: run: swift format lint --strict --recursive Sources/ - name: SwiftLint - uses: norio-nomura/action-swiftlint@3.2.1 - with: - args: --strict + run: brew install swiftlint && swiftlint lint --strict - name: Build run: swift build From 6da58d8d66cf7d45a39e5c8ff926a49abc3508a6 Mon Sep 17 00:00:00 2001 From: Lee Rosen <96027741+tsconfigdotjson@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:23:50 -0400 Subject: [PATCH 4/6] Drop unnecessary Swift install from deploy workflow too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as CI — macos-26 runner already has Swift 6.2 via Xcode. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 800f249..cf79136 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,11 +20,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Swift 6.2 - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.2' - - name: Import code signing certificate env: DEVELOPER_CERT_P12: ${{ secrets.DEVELOPER_CERT_P12 }} From ddc447a83dfaa05609abdc2fb728afa6eb50a3a0 Mon Sep 17 00:00:00 2001 From: Lee Rosen <96027741+tsconfigdotjson@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:33:41 -0400 Subject: [PATCH 5/6] Refactor menu bar to NSStatusItem for crisp icon and running dot Replace MenuBarExtra with NSStatusItem + NSPopover managed by AppDelegate. This gives full control over the menu bar icon and renders it crisply via composited NSImage. A small dot appears next to the terminal icon when any process is running. Also bumps popout glyph size on cards from 9pt to 11pt. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/devtail/AppDelegate.swift | 60 ++++++++++++++++++++++++++- Sources/devtail/DevtailApp.swift | 19 +-------- Sources/devtail/ProcessCardView.swift | 2 +- Sources/devtail/ProcessStore.swift | 11 ++++- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/Sources/devtail/AppDelegate.swift b/Sources/devtail/AppDelegate.swift index 1283dd9..cf1dae2 100644 --- a/Sources/devtail/AppDelegate.swift +++ b/Sources/devtail/AppDelegate.swift @@ -1,12 +1,34 @@ import AppKit +import SwiftUI @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { - var store: ProcessStore? + var store: ProcessStore! + private var statusItem: NSStatusItem! + private var popover: NSPopover! private var signalSource: DispatchSourceSignal? func applicationDidFinishLaunching(_ notification: Notification) { + store = ProcessStore() + store.onIconChange = { [weak self] in self?.updateMenuBarIcon() } + + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + updateMenuBarIcon() + + if let button = statusItem.button { + button.action = #selector(togglePopover) + button.target = self + } + + popover = NSPopover() + popover.contentSize = NSSize(width: 360, height: 500) + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: ContentView(store: store) + .onAppear { AppNotifications.requestPermission() } + ) + signal(SIGTERM, SIG_IGN) let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) source.setEventHandler { [weak self] in @@ -25,6 +47,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + @objc private func togglePopover() { + if popover.isShown { + popover.close() + } else if let button = statusItem.button { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate() + } + } + + private func updateMenuBarIcon() { + let anyRunning = store.processes.contains { $0.isRunning } + guard let baseImage = NSImage( + systemSymbolName: "terminal", + accessibilityDescription: "devtail" + ) else { return } + + let dotSize: CGFloat = 4.5 + let dotGap: CGFloat = 1.5 + let totalWidth = anyRunning ? baseImage.size.width + dotGap + dotSize : baseImage.size.width + let composited = NSImage( + size: NSSize(width: totalWidth, height: baseImage.size.height), + flipped: false + ) { _ in + baseImage.draw(in: NSRect(origin: .zero, size: baseImage.size)) + if anyRunning { + let dotY = (baseImage.size.height - dotSize) / 2 + let dotRect = NSRect(x: baseImage.size.width + dotGap, y: dotY, width: dotSize, height: dotSize) + NSColor.black.setFill() + NSBezierPath(ovalIn: dotRect).fill() + } + return true + } + composited.isTemplate = true + statusItem.button?.image = composited + } + private func performCleanup() { PopOutWindowManager.shared.closeAll() store?.stopAllForQuit() diff --git a/Sources/devtail/DevtailApp.swift b/Sources/devtail/DevtailApp.swift index 0e5eb22..767683f 100644 --- a/Sources/devtail/DevtailApp.swift +++ b/Sources/devtail/DevtailApp.swift @@ -2,26 +2,9 @@ import SwiftUI @main struct DevtailApp: App { - @State private var store = ProcessStore() @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate var body: some Scene { - MenuBarExtra { - ContentView(store: store) - .onAppear { - appDelegate.store = store - AppNotifications.requestPermission() - } - } label: { - Image(systemName: "terminal") - .overlay(alignment: .topTrailing) { - if store.processes.contains(where: \.isRunning) { - Circle() - .frame(width: 5, height: 5) - .offset(x: 2, y: -2) - } - } - } - .menuBarExtraStyle(.window) + Settings { EmptyView() } } } diff --git a/Sources/devtail/ProcessCardView.swift b/Sources/devtail/ProcessCardView.swift index 429d370..730d11e 100644 --- a/Sources/devtail/ProcessCardView.swift +++ b/Sources/devtail/ProcessCardView.swift @@ -14,7 +14,7 @@ struct ProcessCardView: View { PopOutWindowManager.shared.openWindow(buffer: buffer, title: title) } label: { Image(systemName: "arrow.up.forward.app") - .font(.system(size: 9, weight: .medium)) + .font(.system(size: 11, weight: .medium)) .foregroundStyle(.tertiary) .padding(4) .contentShape(Rectangle()) diff --git a/Sources/devtail/ProcessStore.swift b/Sources/devtail/ProcessStore.swift index 059c6f6..8b52808 100644 --- a/Sources/devtail/ProcessStore.swift +++ b/Sources/devtail/ProcessStore.swift @@ -5,6 +5,7 @@ import SwiftUI @Observable final class ProcessStore { var processes: [DevProcess] + var onIconChange: (() -> Void)? private var isQuitting = false init() { @@ -22,7 +23,10 @@ final class ProcessStore { } for process in processes { - process.onStateChange = { [weak self] in self?.save() } + process.onStateChange = { [weak self] in + self?.save() + self?.onIconChange?() + } } let autoStartIDs = Set(saved.filter(\.wasRunning).map(\.id)) @@ -44,7 +48,10 @@ final class ProcessStore { workingDirectory: workingDirectory, auxiliaryCommands: auxiliaryCommands ) - process.onStateChange = { [weak self] in self?.save() } + process.onStateChange = { [weak self] in + self?.save() + self?.onIconChange?() + } withAnimation(.spring(duration: 0.35, bounce: 0.2)) { processes.insert(process, at: 0) } From 58a92de325751f8529875a64cc5df64e05935311 Mon Sep 17 00:00:00 2001 From: Lee Rosen <96027741+tsconfigdotjson@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:34:29 -0400 Subject: [PATCH 6/6] Fix swift-format lint violation in AppDelegate Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/devtail/AppDelegate.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/devtail/AppDelegate.swift b/Sources/devtail/AppDelegate.swift index cf1dae2..18aebde 100644 --- a/Sources/devtail/AppDelegate.swift +++ b/Sources/devtail/AppDelegate.swift @@ -58,10 +58,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func updateMenuBarIcon() { let anyRunning = store.processes.contains { $0.isRunning } - guard let baseImage = NSImage( - systemSymbolName: "terminal", - accessibilityDescription: "devtail" - ) else { return } + guard + let baseImage = NSImage( + systemSymbolName: "terminal", + accessibilityDescription: "devtail" + ) + else { return } let dotSize: CGFloat = 4.5 let dotGap: CGFloat = 1.5