diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 142cd49e..6f50fbcb 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -324,6 +324,12 @@ struct SupacodeApp: App { canvasFocusedWorktreeID: { terminalManager.canvasFocusedWorktreeID }, + selectedSurfaceID: { worktreeID in + guard let state = terminalManager.stateIfExists(for: worktreeID), + let tabID = state.tabManager.selectedTabId + else { return nil } + return state.activeSurfaceID(for: tabID) + }, latestUnreadNotification: { terminalManager.latestUnreadNotificationLocation() }, diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index e7b86806..cd2136ff 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -5,6 +5,10 @@ struct TerminalClient { var send: @MainActor @Sendable (Command) -> Void var events: @MainActor @Sendable () -> AsyncStream var canvasFocusedWorktreeID: @MainActor @Sendable () -> Worktree.ID? + /// Active surface in the selected tab. Lets the reducer capture the target + /// synchronously before an async dispatch races against AppKit focus reshuffle + /// (e.g. when a palette dismisses and the leftmost pane reclaims first responder). + var selectedSurfaceID: @MainActor @Sendable (Worktree.ID) -> UUID? var latestUnreadNotification: @MainActor @Sendable () -> NotificationLocation? var focusSurface: @MainActor @Sendable (Worktree.ID, UUID) -> Bool var markNotificationRead: @MainActor @Sendable (Worktree.ID, UUID) -> Void @@ -36,6 +40,7 @@ struct TerminalClient { case closeFocusedTab(Worktree) case closeFocusedSurface(Worktree) case performBindingAction(Worktree, action: String) + case performBindingActionOnSurface(Worktree, surfaceID: UUID, action: String) case startSearch(Worktree) case searchSelection(Worktree) case navigateSearchNext(Worktree) @@ -77,6 +82,7 @@ extension TerminalClient: DependencyKey { send: { _ in fatalError("TerminalClient.send not configured") }, events: { fatalError("TerminalClient.events not configured") }, canvasFocusedWorktreeID: { nil }, + selectedSurfaceID: { _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, markNotificationRead: { _, _ in }, @@ -87,6 +93,7 @@ extension TerminalClient: DependencyKey { send: { _ in }, events: { AsyncStream { $0.finish() } }, canvasFocusedWorktreeID: { nil }, + selectedSurfaceID: { _ in nil }, latestUnreadNotification: { nil }, focusSurface: { _, _ in false }, markNotificationRead: { _, _ in }, diff --git a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift index 2523a08d..c7fc339a 100644 --- a/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift +++ b/supacode/Features/App/Reducer/AppFeature+CommandPalette.swift @@ -218,8 +218,17 @@ extension AppFeature { guard let worktree = actionTargetWorktree(repositories: state.repositories) else { return .none } + // Capture the target surface synchronously: the async effect below races + // AppKit's post-dismiss focus reshuffle, which can hand first responder + // to a different pane before the binding action runs. + let command: TerminalClient.Command + if let surfaceID = terminalClient.selectedSurfaceID(worktree.id) { + command = .performBindingActionOnSurface(worktree, surfaceID: surfaceID, action: action) + } else { + command = .performBindingAction(worktree, action: action) + } return .run { _ in - await terminalClient.send(.performBindingAction(worktree, action: action)) + await terminalClient.send(command) } case .changeFocusedTabIcon(let worktreeID): diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 0a6c0683..264f76ac 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -140,6 +140,8 @@ final class WorktreeTerminalManager { switch command { case .performBindingAction(let worktree, let action): state(for: worktree).performBindingActionOnFocusedSurface(action) + case .performBindingActionOnSurface(let worktree, let surfaceID, let action): + state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) default: return false } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 16bd116a..2385a55c 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -525,6 +525,13 @@ final class WorktreeTerminalState { return true } + @discardableResult + func performBindingAction(_ action: String, onSurfaceID surfaceID: UUID) -> Bool { + guard let surface = surfaces[surfaceID] else { return false } + surface.performBindingAction(action) + return true + } + @discardableResult func navigateSearchOnFocusedSurface(_ direction: GhosttySearchDirection) -> Bool { guard let tabId = tabManager.selectedTabId, diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 3053fcf4..bb10cd56 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -425,12 +425,98 @@ struct AppFeatureCommandPaletteTests { await store.finish() // Two effects run in parallel (.merge) — assert both fire without - // depending on dispatch order. + // depending on dispatch order. With no selected surface available the + // dispatch falls back to the focused-surface command. #expect(sent.value.count == 2) #expect(sent.value.contains(.performBindingAction(worktree, action: "goto_split:right"))) #expect(sent.value.contains(.focusSelectedTab(worktree))) } + @Test(.dependencies) func ghosttyCommandTargetsSelectedSurfaceWhenAvailable() async { + let worktree = makeWorktree( + id: "/tmp/repo-ghostty/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-ghostty" + ) + let repository = makeRepository(id: "/tmp/repo-ghostty", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let surfaceID = UUID() + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + $0.terminalClient.selectedSurfaceID = { _ in surfaceID } + } + + await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) + await store.finish() + + #expect(sent.value.count == 2) + #expect( + sent.value.contains( + .performBindingActionOnSurface(worktree, surfaceID: surfaceID, action: "goto_split:right") + ) + ) + #expect(sent.value.contains(.focusSelectedTab(worktree))) + } + + @Test(.dependencies) func ghosttyCommandCapturesSelectedSurfaceBeforeAsyncDispatch() async { + let worktree = makeWorktree( + id: "/tmp/repo-ghostty/wt-1", + name: "wt-1", + repoRoot: "/tmp/repo-ghostty" + ) + let repository = makeRepository(id: "/tmp/repo-ghostty", worktrees: [worktree]) + var repositoriesState = RepositoriesFeature.State() + repositoriesState.repositories = [repository] + repositoriesState.selection = .worktree(worktree.id) + let firstSurface = UUID() + let secondSurface = UUID() + let currentSurface = LockIsolated(firstSurface) + let sent = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: repositoriesState, + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + sent.withValue { $0.append(command) } + } + $0.terminalClient.selectedSurfaceID = { _ in currentSurface.value } + } + + let task = await store.send(.commandPalette(.delegate(.ghosttyCommand("toggle_split_zoom")))) + // Simulates the palette-dismiss focus drift: by the time the async dispatch + // resolves, `selectedSurfaceID` would already point at the leftmost surface. + currentSurface.setValue(secondSurface) + await task.finish() + await store.finish() + + #expect( + sent.value.contains( + .performBindingActionOnSurface(worktree, surfaceID: firstSurface, action: "toggle_split_zoom") + ) + ) + #expect( + !sent.value.contains( + .performBindingActionOnSurface(worktree, surfaceID: secondSurface, action: "toggle_split_zoom") + ) + ) + } + @Test(.dependencies) func viewToggleDelegateRestoresTerminalFocusByDefault() async { let worktree = makeWorktree( id: "/tmp/repo-view-toggle/wt-1", -- 2.51.2 From c1877d2c88b2bb847dca1200b1b6d3c52dac0686 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 22:46:09 +0900 Subject: [PATCH 2/4] Bind split zoom to cmd+opt+shift+F Ghostty's default toggle_split_zoom key (cmd+shift+enter) is claimed by the Shelf toggle, whose unbind argument also removed the Ghostty binding, leaving zoom unreachable from the keyboard. Register a managed toggle_split_zoom binding on cmd+opt+shift+F instead: cmd+ctrl+F stays the system fullscreen toggle and cmd+shift+F remains reserved for a future focus mode. Refs onevcat/Prowl#369. --- docs/reference/keyboard-shortcuts.md | 1 + supacode/App/AppShortcuts.swift | 13 +++++++++++++ .../Settings/Views/ShortcutsSettingsView.swift | 5 ++++- supacodeTests/AppShortcutsTests.swift | 1 + 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 94afac63..5c52ed55 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -86,6 +86,7 @@ pane has focus. The Ghostty action each maps to is shown for reference. | Select Pane Down | ⌘⌥↓ | `select_terminal_pane_down` | `goto_split:down` | | Select Pane Left | ⌘⌥← | `select_terminal_pane_left` | `goto_split:left` | | Select Pane Right | ⌘⌥→ | `select_terminal_pane_right` | `goto_split:right` | +| Toggle Split Zoom | ⌘⌥⇧F | `toggle_split_zoom` | `toggle_split_zoom` | ## Terminal engine (Ghostty-managed) diff --git a/supacode/App/AppShortcuts.swift b/supacode/App/AppShortcuts.swift index 77bb1665..8dd149fd 100644 --- a/supacode/App/AppShortcuts.swift +++ b/supacode/App/AppShortcuts.swift @@ -151,6 +151,7 @@ enum AppShortcuts { static let selectTerminalPaneDown = "select_terminal_pane_down" static let selectTerminalPaneLeft = "select_terminal_pane_left" static let selectTerminalPaneRight = "select_terminal_pane_right" + static let toggleSplitZoom = "toggle_split_zoom" } enum Scope: String { @@ -287,6 +288,9 @@ enum AppShortcuts { static let selectTerminalPaneRight = AppShortcut( keyEquivalent: .rightArrow, ghosttyKeyName: "arrow_right", modifiers: [.command, .option] ) + // ⌘⌃F is the system fullscreen toggle and ⌘⇧F is reserved for a future + // focus mode, so split zoom takes the heavier chord on purpose. + static let toggleSplitZoom = AppShortcut(key: "f", modifiers: [.command, .option, .shift]) static let renameBranch = AppShortcut(key: "m", modifiers: [.command, .shift]) static let selectAllCanvasCards = AppShortcut(key: "a", modifiers: [.command, .option]) static let arrangeCanvasCards = AppShortcut(key: "r", modifiers: [.command, .option]) @@ -370,6 +374,7 @@ enum AppShortcuts { .init(actionTitle: "Select Pane Down", shortcut: selectTerminalPaneDown), .init(actionTitle: "Select Pane Left", shortcut: selectTerminalPaneLeft), .init(actionTitle: "Select Pane Right", shortcut: selectTerminalPaneRight), + .init(actionTitle: "Toggle Split Zoom", shortcut: toggleSplitZoom), ] static let bindings: [Binding] = [ @@ -733,6 +738,12 @@ enum AppShortcuts { scope: .configurableAppAction, shortcut: selectTerminalPaneRight ), + .init( + id: CommandID.toggleSplitZoom, + title: "Toggle Split Zoom", + scope: .configurableAppAction, + shortcut: toggleSplitZoom + ), .init( id: CommandID.commandPalette, title: "Command Palette", @@ -884,6 +895,7 @@ enum AppShortcuts { (CommandID.selectTerminalPaneDown, "goto_split:down"), (CommandID.selectTerminalPaneLeft, "goto_split:left"), (CommandID.selectTerminalPaneRight, "goto_split:right"), + (CommandID.toggleSplitZoom, "toggle_split_zoom"), ] static func ghosttyCLIKeybindArguments(from resolvedKeybindings: ResolvedKeybindingMap) -> [String] { @@ -981,6 +993,7 @@ enum AppShortcuts { selectTerminalPaneDown, selectTerminalPaneLeft, selectTerminalPaneRight, + toggleSplitZoom, ] } diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index 319e89f6..37e236e9 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -407,6 +407,8 @@ struct ShortcutsSettingsView: View { return 4 case AppShortcuts.CommandID.selectTerminalPaneRight: return 5 + case AppShortcuts.CommandID.toggleSplitZoom: + return 6 default: return nil } @@ -958,7 +960,8 @@ private enum ShortcutGroup: String, CaseIterable, Identifiable { AppShortcuts.CommandID.selectTerminalPaneUp, AppShortcuts.CommandID.selectTerminalPaneDown, AppShortcuts.CommandID.selectTerminalPaneLeft, - AppShortcuts.CommandID.selectTerminalPaneRight: + AppShortcuts.CommandID.selectTerminalPaneRight, + AppShortcuts.CommandID.toggleSplitZoom: return .terminal default: diff --git a/supacodeTests/AppShortcutsTests.swift b/supacodeTests/AppShortcutsTests.swift index 750b7e98..ee79b157 100644 --- a/supacodeTests/AppShortcutsTests.swift +++ b/supacodeTests/AppShortcutsTests.swift @@ -282,6 +282,7 @@ struct AppShortcutsTests { "--keybind=alt+super+arrow_down=goto_split:down", "--keybind=alt+super+arrow_left=goto_split:left", "--keybind=alt+super+arrow_right=goto_split:right", + "--keybind=alt+shift+super+f=toggle_split_zoom", ] { #expect(arguments.contains(argument)) } -- 2.51.2 From 3a6b1b72581e4ea484fd9c4057a3920240d0263c Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 22:46:10 +0900 Subject: [PATCH 3/4] Add per-pane zoom buttons to split panes Hovering a split's drag handle reveals a zoom button in that pane's top-right corner; a zoomed pane keeps a persistent exit-zoom button in the same spot so it's always clear the pane is zoomed and how to leave. The button routes through the existing toggleSplitZoom split action, which also restores focus to the target surface. Closes onevcat/Prowl#369 (pane-zoom part; focus mode is tracked separately). --- docs/components/terminal.md | 11 +++- .../WorktreeTerminalState+Surfaces.swift | 3 + .../Views/TerminalSplitTreeView.swift | 55 ++++++++++++++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 8c920355..86a9f3a2 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -49,12 +49,17 @@ Splitting is handled by Ghostty actions (bind/keys in your Ghostty config): | Focus adjacent pane | `goto_split:left/right/up/down`, or the app shortcuts below | | Resize split | `resize_split::` | | Equalize splits | `equalize_splits` | -| Zoom / maximize a pane | `toggle_split_zoom` | +| Zoom / maximize a pane | `toggle_split_zoom` (app default `⌘⌥⇧F`) | | Close a pane | `close_surface` (Terminal menu → **Close Terminal**) | App-level pane navigation (works inside the terminal too): `⌘[` / `⌘]` previous / -next pane, `⌘⌥↑/↓/←/→` for directional pane focus. Shelf spines also expose -**split vertical / split horizontal** buttons on the open book. +next pane, `⌘⌥↑/↓/←/→` for directional pane focus, `⌘⌥⇧F` to zoom / unzoom the +focused pane. Shelf spines also expose **split vertical / split horizontal** +buttons on the open book. + +Zoom also has a mouse affordance: hovering a pane's top drag handle reveals a +zoom button in the pane's top-right corner, and a zoomed pane keeps a persistent +exit-zoom button in the same spot so it's always clear how to leave zoom. ## Tab titles — important caveat diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 7644ac2a..9107c58f 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -298,6 +298,9 @@ extension WorktreeTerminalState { case .equalize: updateTree(tree.equalized(), for: tabId) + + case .toggleZoom(let surfaceId): + _ = performSplitAction(.toggleSplitZoom, for: surfaceId) } } diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 9bed4550..ac9b1e51 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -31,6 +31,7 @@ struct TerminalSplitTreeView: View { SubtreeView( node: node, isRoot: node == tree.root, + zoomedNode: tree.zoomed, pinnedSize: pinnedSize, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, @@ -46,11 +47,13 @@ struct TerminalSplitTreeView: View { case resize(node: SplitTree.Node, ratio: Double) case drop(payloadId: UUID, destinationId: UUID, zone: DropZone) case equalize + case toggleZoom(surfaceId: UUID) } struct SubtreeView: View { let node: SplitTree.Node var isRoot: Bool = false + var zoomedNode: SplitTree.Node? var pinnedSize: CGSize? var activeSurfaceID: UUID? var unfocusedSplitOverlay: (fill: Color?, opacity: Double) @@ -64,6 +67,7 @@ struct TerminalSplitTreeView: View { LeafView( surfaceView: leafView, isSplit: !isRoot, + isZoomed: zoomedNode == node, isFocused: leafView.id == activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, hasNotification: hasNotification(leafView.id), @@ -95,6 +99,7 @@ struct TerminalSplitTreeView: View { left: { SubtreeView( node: split.left, + zoomedNode: zoomedNode, pinnedSize: leftPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, @@ -106,6 +111,7 @@ struct TerminalSplitTreeView: View { right: { SubtreeView( node: split.right, + zoomedNode: zoomedNode, pinnedSize: rightPinned, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, @@ -136,6 +142,7 @@ struct TerminalSplitTreeView: View { struct LeafView: View { let surfaceView: GhosttySurfaceView let isSplit: Bool + var isZoomed: Bool = false var isFocused: Bool = true var unfocusedSplitOverlay: (fill: Color?, opacity: Double) let hasNotification: Bool @@ -143,6 +150,8 @@ struct TerminalSplitTreeView: View { let action: (Operation) -> Void @State private var dropState: DropState = .idle + @State private var isHandleHovering = false + @State private var isZoomButtonHovering = false @Shared(.settingsFile) private var settingsFile: SettingsFile private var shouldDim: Bool { @@ -180,7 +189,20 @@ struct TerminalSplitTreeView: View { } .overlay(alignment: .top) { if isSplit { - DragHandle(surfaceView: surfaceView) + DragHandle(surfaceView: surfaceView, isHovering: $isHandleHovering) + } + } + .overlay(alignment: .topTrailing) { + // The zoomed pane keeps a persistent exit button; other panes only + // reveal the zoom affordance while the drag handle (or the button + // itself, to survive the cursor hand-off) is hovered. + if isSplit, isZoomed || isHandleHovering || isZoomButtonHovering { + SplitZoomButton(isZoomed: isZoomed) { + action(.toggleZoom(surfaceId: surfaceView.id)) + } + .onHover { isZoomButtonHovering = $0 } + .onDisappear { isZoomButtonHovering = false } + .padding(6) } } .background { @@ -206,10 +228,39 @@ struct TerminalSplitTreeView: View { } + struct SplitZoomButton: View { + let isZoomed: Bool + let action: () -> Void + @Environment(\.resolvedKeybindings) private var resolvedKeybindings + + var body: some View { + Button(action: action) { + Image( + systemName: isZoomed + ? "arrow.down.right.and.arrow.up.left" + : "arrow.up.left.and.arrow.down.right" + ) + .font(.callout.weight(.semibold)) + .foregroundStyle(.primary) + .padding(5) + .background(.regularMaterial, in: .circle) + } + .buttonStyle(.plain) + .help( + AppShortcuts.helpText( + title: isZoomed ? "Exit Split Zoom" : "Zoom Split", + commandID: AppShortcuts.CommandID.toggleSplitZoom, + in: resolvedKeybindings + ) + ) + .accessibilityLabel(isZoomed ? "Exit split zoom" : "Zoom split") + } + } + struct DragHandle: View { let surfaceView: GhosttySurfaceView + @Binding var isHovering: Bool private let handleHeight: CGFloat = 10 - @State private var isHovering = false var body: some View { Rectangle() -- 2.51.2 From 958be6bec687347e2e3f3005443658fded2558fa Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 10 Jun 2026 23:17:14 +0900 Subject: [PATCH 4/4] Use a rounded-rect background for the pane zoom button --- supacode/Features/Terminal/Views/TerminalSplitTreeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index ac9b1e51..7a0b0be7 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -243,7 +243,7 @@ struct TerminalSplitTreeView: View { .font(.callout.weight(.semibold)) .foregroundStyle(.primary) .padding(5) - .background(.regularMaterial, in: .circle) + .background(.regularMaterial, in: .rect(cornerRadius: 6)) } .buttonStyle(.plain) .help(