Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/rules/control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ renumbering. Do not reintroduce a count anywhere.
- `quick`, `quick.type`, `quick.text`
- `sidebar`, `sidebar.mode`, `sidebar.expand`, `sidebar.collapse`, `sidebar.width`, `notify`
- `font.inc`, `font.dec`, `font.reset`
- `window.new`, `.list`, `.select`, `.close`, `.rename`, `.delete`, `.resize`, `.move`, `.zoom`,
- `window.new`, `.list`, `.select`, `.go`, `.close`, `.rename`, `.delete`, `.resize`, `.move`, `.zoom`,
`.fullscreen`, `.minimize`
- `keymap.reload`, `keymap.list`, `config.reload`, `theme.set`, `theme.list`, `restore.capture`,
`restore.clear`, `restore.mode`, `version`
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ paths:
alternative, `alternative skipped`/`alternative dropped` with more), pinned by
`KeymapTests.pipeFreeKeymapParsesExactlyAsItDidBeforeAlternatives`.
- Pure types live in `Keybind.swift`, `KeybindMatcher`, `CustomCommand`/`CommandContext`,
`BuiltinAction` (46 cases, pinned by `BuiltinActionTests`), `Keymap`, and `ConfigPaths`.
`BuiltinAction` (48 cases, pinned by `BuiltinActionTests`), `Keymap`, and `ConfigPaths`.
`CommandContext` owns the shared expansion/environment token table.
- Built-ins use AppKit menu key equivalents from `keymap.equivalent(for:)`; apply only non-nil
`KeyboardShortcut`s. SwiftUI rebuilds menu shortcuts on the next activation, not immediately after
Expand Down
7 changes: 7 additions & 0 deletions .claude/rules/menu-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,13 @@ paths:
captured indicator exactly as plain session nav does. **Collapse is not a navigation filter** —
`navigableSessions` and `navigateWorkspace` both ignore `isExpanded`, and adding a term to either would
silently rewrite where every existing keystroke, `session.go` call and Ctrl-Tab candidate lands.
- Previous/Next Window are the level above THAT, and the only navigation pair keyed on the library rather
than a store: `WindowLibrary.navigateWindow` steps the open windows in library order, wrapping, and raises
the target. Keyless, and live in either sidebar mode — a window has no sidebar row for flagged mode to
hide. `PaletteContext.canStepWindows` is the enablement term, so one open window disables rather than
no-ops. Menu, palette and `window.go` share the one step. The raise and the frontmost publication follow
[[windows]]: `WindowRegistry.raise` directly, never the `openWindow` hub, and `takeFrontmost` explicitly,
because the key monitor fires this from the quick terminal with agterm inactive.
- When selection moves, GUI callers reveal a captured blocked/completed pane; unchanged plain navigation
only refocuses, preventing a one-item wrap from resetting split focus. Modal focus guards still apply.
- Attention navigation defaults to Control-Option-Up/Down, includes blocked/completed only, wraps, and
Expand Down
13 changes: 12 additions & 1 deletion .claude/rules/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,24 @@ session drag are out of scope.

## Control catalog

- Commands are `window.new`, `window.list`, `window.select`, `window.close`, `window.rename`,
- Commands are `window.new`, `window.list`, `window.select`, `window.go`, `window.close`, `window.rename`,
`window.delete`, `window.resize`, `window.move`, `window.zoom`, `window.fullscreen`, and
`window.minimize`. Keep their protocol cases, dispatch/actions, CLI mappings, and tests synchronized
per the repository-wide control contract.
- `window.list` returns ID/name/open/active plus open-store auto-follow/sidebar state and live
geometry/fullscreen/zoom/minimize. Closed-window live fields are omitted. Geometry is top-left,
display-relative, y-down, matching move/resize.
- `window.go --to next|prev` steps the OPEN windows in library order, wrapping, through host-free
`WindowLibrary.navigateWindow`, which the `previous_window`/`next_window` built-ins share. A CLOSED entry
is not a candidate: `window.select` is the verb that opens one, and a step that silently opened a window
would make the wrap length depend on the library rather than on what is on screen. It takes no target and
no `--window`, being app-global, and errors `no other open window to navigate to` below two open windows.
BOTH it and the GUI twins raise through `WindowRegistry.raise`, never `AppActions.openWindow`: that hub
falls back to `enqueueClaim` plus a fresh scene when a raise fails, and the failure case for a step is an
OPEN window still attaching, so one store would get two scenes. `enqueueClaim` dedups only PENDING claims,
so a popped claim does not protect it. Control refuses out loud; the GUI drops the step. Both then publish
frontmost themselves — `WindowAccessor.reportFrontmost` rides `didBecomeKey`, which never arrives while the
app is inactive, the state a step from the quick terminal raises in. Read back `window.list`'s `active`.
- Delete enforces at least one library entry without GUI confirmation. `window.select` raises or opens.
Window ID resolution accepts active, exact ID, unique prefix, ambiguity, and not found; most library
commands can address closed entries.
Expand Down
94 changes: 94 additions & 0 deletions agterm/AppActions+Navigation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import agtermCore
import AppKit

/// `AppActions` navigation: stepping the selection, the current workspace, and the open windows, plus the
/// attention-only session walk. Split out for the swiftlint size limit. Each level delegates its arithmetic
/// to the host-free step its control twin also calls, so menu, palette and `agtermctl` cannot drift.
extension AppActions {
/// Step the selection prev/next/first/last in the sidebar's flattened visual order, through shared
/// `navigateSession` so GUI, palette and control can't drift, then `selectSession`
/// (recency/badge/persist/workspace) and first responder into the moved-to session's focused pane. Notes
/// the manual nav as user activity for the full idle grace against auto-follow; control `session.go`
/// drives `navigateSession` directly and stays silent. A step landing on the ALREADY-selected session only
/// re-focuses (next/previous wrap inside the filtered set, first/last repeat at that end): `selectSession`
/// still returns an indicator for a same-target select, and revealing on it would clear `splitFocused` and
/// yank first responder onto the primary pane, off the split being typed in. Attention nav DOES reveal.
private func navigatePlain(_ direction: SessionNavigation) {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
let before = store?.selectedSessionID
// no live-indicator fallback (unlike attention nav): a plain direction returns nil only when
// `navigableSessions` is EMPTY, and then nothing was selected, which the moved-check below catches.
let indicator = store?.navigateSession(direction)
guard store?.selectedSessionID != before else { focusActiveSession(); return }
revealActiveBlockedPane(captured: indicator)
}

func selectNextSession() { navigatePlain(.next) }
func selectPreviousSession() { navigatePlain(.previous) }
func selectFirstSession() { navigatePlain(.first) }
func selectLastSession() { navigatePlain(.last) }

/// Step the CURRENT workspace prev/next through the sidebar's visible order and select its first session,
/// through shared `navigateWorkspace` so the menu, the palette and `workspace.go` can't drift. Notes the
/// step as user activity like session nav, then routes pane reveal off the step's captured indicator —
/// the same treatment plain session nav gives, so where focus lands does not depend on which keystroke
/// got you there. A step with nowhere to go (flagged mode, one visible workspace) leaves focus alone.
private func navigateWorkspace(_ direction: WorkspaceNavigation) {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
guard let step = store?.navigateWorkspace(direction) else { return }
revealActiveBlockedPane(captured: step.indicator)
}

func selectNextWorkspace() { navigateWorkspace(.next) }
func selectPreviousWorkspace() { navigateWorkspace(.previous) }

/// Step to the next/previous OPEN window in library order and raise it, through shared
/// `library.navigateWindow` so the menu, the palette and `window.go` can't drift. `WindowRegistry.raise`,
/// never the `openWindow` hub: on a failed raise that hub enqueues a claim and opens a fresh scene, and
/// the failure here is an open window still attaching, which would give one store two scenes. The step is
/// dropped in that sub-second gap instead.
private func navigateWindow(_ direction: WorkspaceNavigation) {
guard uiActionsEnabled else { return }
guard let target = library.navigateWindow(direction), WindowRegistry.shared.raise(target) else { return }
takeFrontmost(target)
}

func selectNextWindow() { navigateWindow(.next) }
func selectPreviousWindow() { navigateWindow(.previous) }

/// Publish `id` as frontmost after an imperative raise, since `WindowAccessor.reportFrontmost` fires on
/// `didBecomeKey` and a step from the quick terminal raises with agterm INACTIVE — leaving the id stale,
/// so every later step recomputes from the same origin. The control twin omits the post below, its
/// dispatch refreshing that cache inline.
private func takeFrontmost(_ id: WindowInfo.ID) {
guard library.frontmostWindowID != id else { return }
library.frontmostWindowID = id
library.saveIndex()
if GhosttyApp.shared.autoHideSidebarInactiveWindows { library.applyInactiveWindowSidebarHiding() }
NotificationCenter.default.post(name: .agtermWindowFrontmostChanged, object: nil)
}

/// Step to the next/previous session needing attention (`blocked`/`completed`), wrapping and skipping
/// idle/active, through `navigateSession` shared with the palette and `session.go next-attention|prev-attention`.
/// Notes user activity like plain nav, then `revealActiveBlockedPane` focuses the split/scratch pane that
/// SET the status. Unlike plain nav this DOES reveal on a selection no-op, and only the
/// `?? activeSession?.agentIndicator` fallback makes it: `attentionTarget` EXCLUDES the current session,
/// so when the sole session needing attention is the selected one, `navigateSession` selects nothing.
/// Without the fallback the reveal degrades to plain `focusActiveSession` and ⌃⌥↑/↓ stops landing on that
/// session's tagged pane — constant for an agent, since a pane-scoped block is not cleared by typing in
/// the OTHER pane. Keep it.
func selectNextAttentionSession() {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
let indicator = store?.navigateSession(.nextAttention) ?? store?.activeSession?.agentIndicator
revealActiveBlockedPane(captured: indicator)
}
func selectPreviousAttentionSession() {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
let indicator = store?.navigateSession(.previousAttention) ?? store?.activeSession?.agentIndicator
revealActiveBlockedPane(captured: indicator)
}
}
3 changes: 3 additions & 0 deletions agterm/AppActions+Palette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ extension AppActions {
activeWorkspaceMarked: activeStore?.isCurrentWorkspaceFocusMember == true,
activeWorkspaceCollapsed: activeStore?.isCurrentWorkspaceCollapsed == true,
canStepWorkspaces: activeStore?.canStepWorkspaces == true,
canStepWindows: library.canStepWindows,
activeSessionHasSplit: activeStore?.activeSession?.hasSplit == true,
activeSplitAxis: activeStore?.activeSession?.splitAxis,
hasPendingClose: activeStore?.pendingCloseSummary != nil,
Expand Down Expand Up @@ -81,6 +82,8 @@ extension AppActions {
case .nextAttentionSession: selectNextAttentionSession()
case .previousWorkspace: selectPreviousWorkspace()
case .nextWorkspace: selectNextWorkspace()
case .previousWindow: selectPreviousWindow()
case .nextWindow: selectNextWindow()
case .firstSession: selectFirstSession()
case .lastSession: selectLastSession()
case .showAttention: openAttentionPalette()
Expand Down
61 changes: 0 additions & 61 deletions agterm/AppActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -407,67 +407,6 @@ final class AppActions {
reloadGhosttyConfig()
}

/// Step the selection prev/next/first/last in the sidebar's flattened visual order, through shared
/// `navigateSession` so GUI, palette and control can't drift, then `selectSession`
/// (recency/badge/persist/workspace) and first responder into the moved-to session's focused pane. Notes
/// the manual nav as user activity for the full idle grace against auto-follow; control `session.go`
/// drives `navigateSession` directly and stays silent. A step landing on the ALREADY-selected session only
/// re-focuses (next/previous wrap inside the filtered set, first/last repeat at that end): `selectSession`
/// still returns an indicator for a same-target select, and revealing on it would clear `splitFocused` and
/// yank first responder onto the primary pane, off the split being typed in. Attention nav DOES reveal.
private func navigatePlain(_ direction: SessionNavigation) {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
let before = store?.selectedSessionID
// no live-indicator fallback (unlike attention nav): a plain direction returns nil only when
// `navigableSessions` is EMPTY, and then nothing was selected, which the moved-check below catches.
let indicator = store?.navigateSession(direction)
guard store?.selectedSessionID != before else { focusActiveSession(); return }
revealActiveBlockedPane(captured: indicator)
}

func selectNextSession() { navigatePlain(.next) }
func selectPreviousSession() { navigatePlain(.previous) }
func selectFirstSession() { navigatePlain(.first) }
func selectLastSession() { navigatePlain(.last) }

/// Step the CURRENT workspace prev/next through the sidebar's visible order and select its first session,
/// through shared `navigateWorkspace` so the menu, the palette and `workspace.go` can't drift. Notes the
/// step as user activity like session nav, then routes pane reveal off the step's captured indicator —
/// the same treatment plain session nav gives, so where focus lands does not depend on which keystroke
/// got you there. A step with nowhere to go (flagged mode, one visible workspace) leaves focus alone.
private func navigateWorkspace(_ direction: WorkspaceNavigation) {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
guard let step = store?.navigateWorkspace(direction) else { return }
revealActiveBlockedPane(captured: step.indicator)
}

func selectNextWorkspace() { navigateWorkspace(.next) }
func selectPreviousWorkspace() { navigateWorkspace(.previous) }

/// Step to the next/previous session needing attention (`blocked`/`completed`), wrapping and skipping
/// idle/active, through `navigateSession` shared with the palette and `session.go next-attention|prev-attention`.
/// Notes user activity like plain nav, then `revealActiveBlockedPane` focuses the split/scratch pane that
/// SET the status. Unlike plain nav this DOES reveal on a selection no-op, and only the
/// `?? activeSession?.agentIndicator` fallback makes it: `attentionTarget` EXCLUDES the current session,
/// so when the sole session needing attention is the selected one, `navigateSession` selects nothing.
/// Without the fallback the reveal degrades to plain `focusActiveSession` and ⌃⌥↑/↓ stops landing on that
/// session's tagged pane — constant for an agent, since a pane-scoped block is not cleared by typing in
/// the OTHER pane. Keep it.
func selectNextAttentionSession() {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
let indicator = store?.navigateSession(.nextAttention) ?? store?.activeSession?.agentIndicator
revealActiveBlockedPane(captured: indicator)
}
func selectPreviousAttentionSession() {
guard uiActionsEnabled else { return }
store?.noteUserActivity()
let indicator = store?.navigateSession(.previousAttention) ?? store?.activeSession?.agentIndicator
revealActiveBlockedPane(captured: indicator)
}

/// Delete a workspace and all its sessions from `store`'s window. Confirms while it still has sessions
/// (the delete ends their shells), no prompt when empty, no-op when only one workspace remains — one is
/// always kept. The row's "Delete Workspace" passes its OWN window-local store: the frontmost one would
Expand Down
16 changes: 16 additions & 0 deletions agterm/Control/ControlServer+WindowCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ extension ControlServer {
}
}

/// Raise the next/previous OPEN window, wrapping, through the `library.navigateWindow` the menu and the
/// palette share. Errors rather than silently no-opping with one window open, as `workspace.go` does with
/// one workspace. `raise` directly, NOT the hub's opener `window.select` uses: the step target is open by
/// construction, and the opener would spawn a second scene window for a store whose NSWindow is still
/// attaching. `takeFrontmost` is explicit because an inactive app receives no AppKit key handoff.
func windowGo(direction: WorkspaceNavigation) -> ControlResponse {
guard let id = library.navigateWindow(direction) else {
return ControlResponse(ok: false, error: "no other open window to navigate to")
}
guard WindowRegistry.shared.raise(id) else {
return ControlResponse(ok: false, error: "window not on screen yet — retry")
}
takeFrontmost(id)
return ControlResponse(ok: true, result: ControlResult(id: id.uuidString))
}

/// Resolve a window id and close its on-screen window (the registry's `performClose` runs the standard
/// teardown + `closeWindow` path, asynchronously). Bounded-polls for the library to mark it closed, so an
/// immediate follow-up sees it closed. An already-closed window still reports ok. Returns the id.
Expand Down
2 changes: 1 addition & 1 deletion agterm/Control/ControlServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,7 @@ final class ControlServer {
.sessionSearch, .sessionOverlayOpen, .sessionOverlayClose, .sessionOverlayResize,
.sessionOverlayResult, .sessionOverlayCopy, .sessionOverlayText,
.sessionBackground, .sessionText, .quick, .quickType, .quickText,
.windowNew, .windowList, .windowSelect,
.windowNew, .windowList, .windowSelect, .windowGo,
.windowClose, .windowRename, .windowDelete, .windowResize, .windowMove, .windowZoom,
.windowFullscreen, .windowMinimize,
.restoreClear, .restoreCapture, .restoreMode, .zmxList, .zmxPrune, .zmxKill, .zmxReset, .zmxTree,
Expand Down
14 changes: 14 additions & 0 deletions agterm/agtermApp+Menus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,20 @@ extension agtermApp {
}
.keyboardShortcut(shortcut(for: .nextWorkspace))
.disabled(!PaletteCommand.nextWorkspace.isEnabled(in: context))
// step between OPEN windows, wrapping and raising each in turn — a CLOSED entry is not a
// candidate, File > Open Window being the surface that opens one. keyless, rebindable via
// previous_window/next_window; control window.go. horizontal chevrons, since the vertical
// ones are taken by the two levels inside a window.
Button { actions.selectPreviousWindow() } label: {
Label("Previous Window", systemImage: "chevron.left.2")
}
.keyboardShortcut(shortcut(for: .previousWindow))
.disabled(!PaletteCommand.previousWindow.isEnabled(in: context))
Button { actions.selectNextWindow() } label: {
Label("Next Window", systemImage: "chevron.right.2")
}
.keyboardShortcut(shortcut(for: .nextWindow))
.disabled(!PaletteCommand.nextWindow.isEnabled(in: context))
Divider()
let topBottom = library.activeStore?.activeSession?.splitAxis == .topBottom
Button { actions.focusPane(.main) } label: {
Expand Down
Loading
Loading