diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index e3b52b0c..bd89567c 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -21,7 +21,7 @@ Command Palette → "Toggle Active Agents Panel". ``` [icon] AgentName · RepositoryName [status pill] - tab title or branch (secondary) + pane title or branch (secondary) ``` - **Icon** — the detected command/agent icon (for example `omp` keeps the OMP @@ -29,7 +29,9 @@ Command Palette → "Toggle Active Agents Panel". agent icon, then a sparkle). - **Title** — detected command/agent name + repository (repo color-coded); command aliases such as `omp` are shown directly. -- **Subtitle** — the tab title (if `showActiveAgentTabTitles`) or branch name. +- **Subtitle** — the agent's pane title (if `showActiveAgentTabTitles`) or branch + name. The pane title is the surface's own terminal title, falling back to the + tab title, so agents in different splits of one tab keep distinct subtitles. - **Status pill** — one of: | Status | Meaning | Look | @@ -58,7 +60,8 @@ When nothing is running: "New agents will appear here". ## Settings - `autoShowActiveAgentsPanel` — pop the panel open when an agent appears. -- `showActiveAgentTabTitles` — show each agent's tab title instead of its branch. +- `showActiveAgentTabTitles` — show each agent's pane title (surface title, + falling back to the tab title) instead of its branch. - `showActiveAgentStatusInShelf` — show detected agent status markers on Shelf tab icons. - Panel height and hidden/shown state are persisted automatically. diff --git a/docs/components/settings.md b/docs/components/settings.md index 7a7bac2a..3ea4cb24 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -16,7 +16,7 @@ window is a sidebar of tabs plus a detail pane. | Tab | Controls | |-----|----------| -| **General** | Appearance (system/light/dark), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & tab titles. | +| **General** | Appearance (system/light/dark), default app for opening worktrees, diff tool, confirm-before-quit, default view mode, window chrome tint, toolbar buttons (Run / Open-in-editor), dim unfocused splits, Active Agents panel auto-show & terminal titles. | | **Notifications** | In-app alerts, notification sound picker (Never / system sounds / Prowl Classic), macOS system notifications, move-notified-to-top, command-finished notification + threshold, Dock badge & bounce. → [notifications](notifications.md) | | **Shortcuts** | Remap app keyboard shortcuts; view defaults; resolve conflicts. → [keyboard-shortcuts](../reference/keyboard-shortcuts.md) | | **Worktree** | Worktree creation/deletion defaults: prompt on create, fetch before create, base directory, copy ignored/untracked files, delete-branch-on-delete, merged-worktree action, archived auto-delete period. | diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index c4221ded..277938ba 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -54,7 +54,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `canvasDefaultLayout` | enum (`uniform`/`tile`) | `tile` | Initial Canvas layout: `uniform` packs same-size cards, `tile` resizes cards to fill the screen. | | `dimUnfocusedSplits` | Bool | `true` | Dim panes that aren't focused. | | `autoShowActiveAgentsPanel` | Bool | `false` | Auto-open the Active Agents panel on a new agent. | -| `showActiveAgentTabTitles` | Bool | `false` | Show tab titles (vs. branch) in the agents panel. | +| `showActiveAgentTabTitles` | Bool | `false` | Show pane titles (surface title, falling back to the tab title; vs. branch) in the agents panel. | | `showActiveAgentStatusInShelf` | Bool | `true` | Show agent status markers on Shelf tab icons. | | `windowTintMode` | enum (`none`/`repositoryColor`/`custom`) | `repositoryColor` | How the window chrome is tinted. | | `windowTintCustomColor` | color | default | The custom tint color (when `windowTintMode = custom`). | diff --git a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift index a018bae5..eb2ec48e 100644 --- a/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/supacode/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -13,7 +13,10 @@ struct ActiveAgentEntry: Identifiable, Equatable, Sendable { /// display falls back to `worktreeID`/`worktreeName`. let workingDirectory: URL? let tabID: TerminalTabID - let tabTitle: String + /// The title of the agent's own pane: the surface's live title when it has one, + /// falling back to the tab's display title. Kept per-pane so agents in different + /// splits of one tab don't all mirror the focused pane's title. + let paneTitle: String let surfaceID: UUID let paneIndex: Int /// Command/process token used for row icon lookup. This can be more specific than diff --git a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift index a44fc20e..2f007295 100644 --- a/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift +++ b/supacode/Features/ActiveAgents/Views/ActiveAgentsPanel.swift @@ -166,7 +166,7 @@ struct ActiveAgentsPanel: View { branchName: String, showTabTitles: Bool ) -> String { - showTabTitles ? tabTitle(for: entry) : branchName + showTabTitles ? paneTitle(for: entry) : branchName } static func helpText( @@ -174,11 +174,11 @@ struct ActiveAgentsPanel: View { branchName: String, showTabTitles: Bool ) -> String { - showTabTitles ? branchName : tabTitle(for: entry) + showTabTitles ? branchName : paneTitle(for: entry) } - static func tabTitle(for entry: ActiveAgentEntry) -> String { - let trimmed = entry.tabTitle.trimmingCharacters(in: .whitespacesAndNewlines) + static func paneTitle(for entry: ActiveAgentEntry) -> String { + let trimmed = entry.paneTitle.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled tab" : trimmed } diff --git a/supacode/Features/Settings/Views/AppearanceSettingsView.swift b/supacode/Features/Settings/Views/AppearanceSettingsView.swift index ceee012a..44753518 100644 --- a/supacode/Features/Settings/Views/AppearanceSettingsView.swift +++ b/supacode/Features/Settings/Views/AppearanceSettingsView.swift @@ -98,10 +98,10 @@ struct AppearanceSettingsView: View { .foregroundStyle(.secondary) .font(.callout) Toggle( - "Show tab titles in agent rows", + "Show terminal titles in agent rows", isOn: $store.showActiveAgentTabTitles ) - .help("Display each agent's tab title in the row and show the branch name on hover.") + .help("Display each agent's own terminal title in the row and show the branch name on hover.") Toggle( "Show agent status in Shelf tabs", isOn: $store.showActiveAgentStatusInShelf diff --git a/supacode/Features/Shelf/Views/ShelfSpineView.swift b/supacode/Features/Shelf/Views/ShelfSpineView.swift index 2928d828..bebb6840 100644 --- a/supacode/Features/Shelf/Views/ShelfSpineView.swift +++ b/supacode/Features/Shelf/Views/ShelfSpineView.swift @@ -619,8 +619,8 @@ private struct ShelfSpineTabSlot: View { private var helpText: String { guard let activeAgentEntry else { return tab.displayTitle } - let tabTitle = ActiveAgentsPanel.tabTitle(for: activeAgentEntry) - return "Jump to \(activeAgentEntry.displayName): \(activeAgentEntry.displayState.label) - \(tabTitle)" + let paneTitle = ActiveAgentsPanel.paneTitle(for: activeAgentEntry) + return "Jump to \(activeAgentEntry.displayName): \(activeAgentEntry.displayState.label) - \(paneTitle)" } private var accessibilityLabel: String { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 9b8580be..6bdd4510 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -227,20 +227,27 @@ extension WorktreeTerminalState { } /// Re-emit Active Agents entries for every pane in `tabId` so the panel picks - /// up a fresh tab-title snapshot. Title changes (OSC-2, focus sync, manual + /// up a fresh title snapshot. Title changes (OSC-2, focus sync, manual /// rename) don't move agent detection state, so without this nudge the - /// subtitle only refreshes on the next agent state transition. + /// subtitle only refreshes on the next agent state transition. Used when the + /// tab's display title changes, since it is every pane's title fallback. func refreshAgentEntriesForTitleChange(in tabId: TerminalTabID) { let surfaceIDs = trees[tabId]?.leaves().map(\.id) ?? [] for surfaceID in surfaceIDs { - guard let state = surfaceAgentStates[surfaceID], - state.detectedAgent != nil, - state.state != .unknown - else { continue } - emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) + refreshAgentEntryForTitleChange(surfaceID: surfaceID, in: tabId) } } + /// Single-pane variant for a surface whose own title changed without moving + /// the tab title (e.g. an unfocused split's OSC-2 update). + func refreshAgentEntryForTitleChange(surfaceID: UUID, in tabId: TerminalTabID) { + guard let state = surfaceAgentStates[surfaceID], + state.detectedAgent != nil, + state.state != .unknown + else { return } + emitAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) + } + func emitAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) { guard let entry = activeAgentEntry(surfaceID: surfaceID, tabId: tabId, state: state) else { onAgentEntryRemoved?(surfaceID) @@ -261,7 +268,7 @@ extension WorktreeTerminalState { worktreeName: worktree.name, workingDirectory: workingDirectory, tabID: tabId, - tabTitle: tabTitle, + paneTitle: paneTitle(surfaceID: surfaceID, fallbackTabTitle: tabTitle), surfaceID: surfaceID, paneIndex: paneIndex, iconLookupToken: state.iconLookupToken ?? agent.iconLookupToken, diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index d35aa839..564da7cb 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -368,10 +368,16 @@ extension WorktreeTerminalState { func configureBridgeCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { view.bridge.onTitleChange = { [weak self, weak view] title in guard let self, let view else { return } - if self.focusedSurfaceIdByTab[tabId] == view.id { - if self.tabManager.updateTitle(tabId, title: title) { - self.refreshAgentEntriesForTitleChange(in: tabId) - } + if self.focusedSurfaceIdByTab[tabId] == view.id, + self.tabManager.updateTitle(tabId, title: title) + { + // The tab's display title moved: refresh the whole tab because it is + // the title fallback for panes without their own title. + self.refreshAgentEntriesForTitleChange(in: tabId) + } else { + // Unfocused pane, or the tab title didn't visibly change (custom title + // mask / no-op): only this pane's own title moved. + self.refreshAgentEntryForTitleChange(surfaceID: view.id, in: tabId) } self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) } diff --git a/supacodeTests/ActiveAgentEntryPaneTitleTests.swift b/supacodeTests/ActiveAgentEntryPaneTitleTests.swift new file mode 100644 index 00000000..aba2e796 --- /dev/null +++ b/supacodeTests/ActiveAgentEntryPaneTitleTests.swift @@ -0,0 +1,134 @@ +import AppKit +import Foundation +import GhosttyKit +import Testing + +@testable import supacode + +@MainActor +struct ActiveAgentEntryPaneTitleTests { + @Test func entryUsesItsOwnSurfaceTitle() throws { + let fixture = try makeSplitFixture() + fixture.focusedPane.bridge.state.title = "title 1" + fixture.unfocusedPane.bridge.state.title = "title 2" + // Mirrors onTitleChange: only the focused pane's title reaches the tab. + fixture.state.tabManager.updateTitle(fixture.tabId, title: "title 1") + + let focusedEntry = try #require( + fixture.state.activeAgentEntry( + surfaceID: fixture.focusedPane.id, + tabId: fixture.tabId, + state: PaneAgentState(detectedAgent: .claude, state: .working) + ) + ) + let unfocusedEntry = try #require( + fixture.state.activeAgentEntry( + surfaceID: fixture.unfocusedPane.id, + tabId: fixture.tabId, + state: PaneAgentState(detectedAgent: .claude, state: .working) + ) + ) + + #expect(focusedEntry.paneTitle == "title 1") + #expect(unfocusedEntry.paneTitle == "title 2") + } + + @Test func entryFallsBackToTabDisplayTitleWithoutSurfaceTitle() throws { + let fixture = try makeSplitFixture() + fixture.state.tabManager.updateTitle(fixture.tabId, title: "tab title") + + let entry = try #require( + fixture.state.activeAgentEntry( + surfaceID: fixture.unfocusedPane.id, + tabId: fixture.tabId, + state: PaneAgentState(detectedAgent: .claude, state: .working) + ) + ) + + #expect(entry.paneTitle == "tab title") + } + + @Test func unfocusedPaneTitleChangeReemitsItsEntry() throws { + let fixture = try makeSplitFixture() + fixture.state.surfaceAgentStates[fixture.unfocusedPane.id] = PaneAgentState( + detectedAgent: .claude, + state: .working + ) + fixture.state.configureBridgeCallbacks(for: fixture.unfocusedPane, tabId: fixture.tabId) + var received: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { received.append($0) } + + // The bridge writes state.title before invoking onTitleChange. + fixture.unfocusedPane.bridge.state.title = "title 2" + fixture.unfocusedPane.bridge.onTitleChange?("title 2") + + #expect(received.map(\.paneTitle) == ["title 2"]) + } + + @Test func focusedPaneTitleChangeRefreshesAllEntriesInTab() throws { + let fixture = try makeSplitFixture() + fixture.state.surfaceAgentStates[fixture.focusedPane.id] = PaneAgentState( + detectedAgent: .claude, + state: .working + ) + fixture.state.surfaceAgentStates[fixture.unfocusedPane.id] = PaneAgentState( + detectedAgent: .claude, + state: .working + ) + fixture.unfocusedPane.bridge.state.title = "title 2" + fixture.state.configureBridgeCallbacks(for: fixture.focusedPane, tabId: fixture.tabId) + var received: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { received.append($0) } + + fixture.focusedPane.bridge.state.title = "title 1" + fixture.focusedPane.bridge.onTitleChange?("title 1") + + // Both panes re-emit (the tab's fallback title changed), each keeping its own title. + #expect(received.count == 2) + #expect(Set(received.map(\.paneTitle)) == ["title 1", "title 2"]) + } + + private struct Fixture { + let state: WorktreeTerminalState + let tabId: TerminalTabID + let focusedPane: GhosttySurfaceView + let unfocusedPane: GhosttySurfaceView + } + + private func makeSplitFixture() throws -> Fixture { + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: Worktree( + id: "/tmp/repo/worktree", + name: "worktree", + detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktree"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + ) + let focusedPane = makeSurface(state: state) + let unfocusedPane = makeSurface(state: state) + let tabId = state.tabManager.createTab(title: "worktree 1", icon: "terminal") + state.surfaces[focusedPane.id] = focusedPane + state.surfaces[unfocusedPane.id] = unfocusedPane + state.trees[tabId] = try SplitTree(view: focusedPane) + .inserting(view: unfocusedPane, at: focusedPane, direction: .right) + state.focusedSurfaceIdByTab[tabId] = focusedPane.id + return Fixture( + state: state, + tabId: tabId, + focusedPane: focusedPane, + unfocusedPane: unfocusedPane + ) + } + + private func makeSurface(state: WorktreeTerminalState) -> GhosttySurfaceView { + GhosttySurfaceView( + runtime: state.runtime, + workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktree", isDirectory: true), + fontSize: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true + ) + } +} diff --git a/supacodeTests/ActiveAgentsFeatureTests.swift b/supacodeTests/ActiveAgentsFeatureTests.swift index 8f92f259..d6b4620c 100644 --- a/supacodeTests/ActiveAgentsFeatureTests.swift +++ b/supacodeTests/ActiveAgentsFeatureTests.swift @@ -188,8 +188,8 @@ struct ActiveAgentsFeatureTests { } } - @Test func panelSubtitleAndHelpSwapTabTitleAndBranchWhenEnabled() { - let entry = entry(id: UUID(0), tabTitle: "Review issue 385", state: .idle, changedAt: Date()) + @Test func panelSubtitleAndHelpSwapPaneTitleAndBranchWhenEnabled() { + let entry = entry(id: UUID(0), paneTitle: "Review issue 385", state: .idle, changedAt: Date()) #expect( ActiveAgentsPanel.subtitle(for: entry, branchName: "main", showTabTitles: false) @@ -209,10 +209,10 @@ struct ActiveAgentsFeatureTests { ) } - @Test func panelTabTitleFallsBackForEmptyTitles() { - let entry = entry(id: UUID(0), tabTitle: " ", state: .idle, changedAt: Date()) + @Test func panelPaneTitleFallsBackForEmptyTitles() { + let entry = entry(id: UUID(0), paneTitle: " ", state: .idle, changedAt: Date()) - #expect(ActiveAgentsPanel.tabTitle(for: entry) == "Untitled tab") + #expect(ActiveAgentsPanel.paneTitle(for: entry) == "Untitled tab") } private func sampleEntries() -> IdentifiedArrayOf { @@ -226,7 +226,7 @@ struct ActiveAgentsFeatureTests { private func entry( id: UUID, - tabTitle: String = "1", + paneTitle: String = "1", state: AgentDisplayState, changedAt: Date, agent: DetectedAgent = .codex, @@ -238,7 +238,7 @@ struct ActiveAgentsFeatureTests { worktreeName: "wt", workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), - tabTitle: tabTitle, + paneTitle: paneTitle, surfaceID: id, paneIndex: 1, iconLookupToken: iconLookupToken ?? agent.iconLookupToken, diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 34badd4d..592c8bb0 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -175,7 +175,7 @@ struct AppFeatureSettingsChangedTests { worktreeName: "wt", workingDirectory: nil, tabID: TerminalTabID(rawValue: fixedUUID(1)), - tabTitle: "codex", + paneTitle: "codex", surfaceID: fixedUUID(0), paneIndex: 1, iconLookupToken: DetectedAgent.codex.iconLookupToken, diff --git a/supacodeTests/CLIAgentsCommandHandlerTests.swift b/supacodeTests/CLIAgentsCommandHandlerTests.swift index 1162c999..f06a7b1e 100644 --- a/supacodeTests/CLIAgentsCommandHandlerTests.swift +++ b/supacodeTests/CLIAgentsCommandHandlerTests.swift @@ -170,7 +170,7 @@ struct CLIAgentsCommandHandlerTests { worktreeName: input.worktree.name, workingDirectory: input.workingDirectory, tabID: TerminalTabID(rawValue: input.tabID), - tabTitle: "issue 330", + paneTitle: "issue 330", surfaceID: input.paneID, paneIndex: input.paneIndex, iconLookupToken: input.iconLookupToken, diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index e13fb157..171c162b 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -2569,7 +2569,7 @@ struct RepositoriesFeatureTests { worktreeName: worktree.name, workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), - tabTitle: "agent", + paneTitle: "agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: DetectedAgent.codex.iconLookupToken, @@ -2624,7 +2624,7 @@ struct RepositoriesFeatureTests { worktreeName: repository.name, workingDirectory: nil, tabID: TerminalTabID(rawValue: UUID()), - tabTitle: "agent", + paneTitle: "agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: DetectedAgent.codex.iconLookupToken, @@ -2822,7 +2822,7 @@ struct RepositoriesFeatureTests { worktreeName: worktree.name, workingDirectory: nil, tabID: tabID, - tabTitle: "agent", + paneTitle: "agent", surfaceID: surfaceID, paneIndex: 0, iconLookupToken: DetectedAgent.codex.iconLookupToken, diff --git a/supacodeTests/RepositorySectionViewTests.swift b/supacodeTests/RepositorySectionViewTests.swift index 0d247608..93dd058a 100644 --- a/supacodeTests/RepositorySectionViewTests.swift +++ b/supacodeTests/RepositorySectionViewTests.swift @@ -302,7 +302,7 @@ struct RepositorySectionViewTests { worktreeName: worktreeName, workingDirectory: workingDirectory, tabID: TerminalTabID(rawValue: UUID()), - tabTitle: "agent", + paneTitle: "agent", surfaceID: UUID(), paneIndex: 1, iconLookupToken: DetectedAgent.codex.iconLookupToken,