diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index cfaf530d..5449c25f 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -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` diff --git a/.claude/rules/keymap.md b/.claude/rules/keymap.md index 6315d3f5..f73a6762 100644 --- a/.claude/rules/keymap.md +++ b/.claude/rules/keymap.md @@ -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 diff --git a/.claude/rules/menu-actions.md b/.claude/rules/menu-actions.md index 30ce8c18..70ebc8a8 100644 --- a/.claude/rules/menu-actions.md +++ b/.claude/rules/menu-actions.md @@ -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 diff --git a/.claude/rules/windows.md b/.claude/rules/windows.md index cb2760ab..72fb8e3b 100644 --- a/.claude/rules/windows.md +++ b/.claude/rules/windows.md @@ -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. diff --git a/agterm/AppActions+Navigation.swift b/agterm/AppActions+Navigation.swift new file mode 100644 index 00000000..fe1bc247 --- /dev/null +++ b/agterm/AppActions+Navigation.swift @@ -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) + } +} diff --git a/agterm/AppActions+Palette.swift b/agterm/AppActions+Palette.swift index 55d2be1f..f3719239 100644 --- a/agterm/AppActions+Palette.swift +++ b/agterm/AppActions+Palette.swift @@ -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, @@ -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() diff --git a/agterm/AppActions.swift b/agterm/AppActions.swift index 551af556..84697354 100644 --- a/agterm/AppActions.swift +++ b/agterm/AppActions.swift @@ -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 diff --git a/agterm/Control/ControlServer+WindowCommands.swift b/agterm/Control/ControlServer+WindowCommands.swift index 414a0535..c182bac3 100644 --- a/agterm/Control/ControlServer+WindowCommands.swift +++ b/agterm/Control/ControlServer+WindowCommands.swift @@ -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. diff --git a/agterm/Control/ControlServer.swift b/agterm/Control/ControlServer.swift index d328eb91..9ff1407f 100644 --- a/agterm/Control/ControlServer.swift +++ b/agterm/Control/ControlServer.swift @@ -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, diff --git a/agterm/agtermApp+Menus.swift b/agterm/agtermApp+Menus.swift index c75dbb59..374e2078 100644 --- a/agterm/agtermApp+Menus.swift +++ b/agterm/agtermApp+Menus.swift @@ -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: { diff --git a/agtermCore/Sources/agtermCore/BuiltinAction.swift b/agtermCore/Sources/agtermCore/BuiltinAction.swift index 0d85b043..902c3e41 100644 --- a/agtermCore/Sources/agtermCore/BuiltinAction.swift +++ b/agtermCore/Sources/agtermCore/BuiltinAction.swift @@ -4,6 +4,7 @@ /// `.commands`; `defaultChord` is the single source of truth for those shortcuts, read via `equivalent(for:)`. public enum BuiltinAction: String, CaseIterable, Sendable { case newWindow = "new_window", renameWindow = "rename_window", deleteWindow = "delete_window" + case previousWindow = "previous_window", nextWindow = "next_window" case newWorkspace = "new_workspace", renameWorkspace = "rename_workspace", deleteWorkspace = "delete_workspace" case newSession = "new_session", openDirectory = "open_directory", renameSession = "rename_session" case duplicateSession = "duplicate_session" @@ -63,7 +64,8 @@ public enum BuiltinAction: String, CaseIterable, Sendable { case .nextAttentionSession: return Chord(mods: [.control, .option], key: "down") case .renameWindow, .deleteWindow, .renameWorkspace, .deleteWorkspace, .renameSession, .duplicateSession, .clearStatus, .firstSession, .lastSession, .selectTheme, .toggleFlaggedView, .focusWorkspace, - .toggleWorkspaceFilter, .previousWorkspace, .nextWorkspace, .toggleWorkspaceCollapse: + .toggleWorkspaceFilter, .previousWorkspace, .nextWorkspace, .toggleWorkspaceCollapse, + .previousWindow, .nextWindow: return nil } } diff --git a/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift b/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift index 44d5fbda..d3a21caa 100644 --- a/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift +++ b/agtermCore/Sources/agtermCore/ControlActionsDefaults.swift @@ -76,6 +76,10 @@ public extension ControlActions { ControlResponse(ok: false, error: ControlActionsUnsupported.message("session.context")) } + func windowGo(direction _: WorkspaceNavigation) -> ControlResponse { + ControlResponse(ok: false, error: ControlActionsUnsupported.message("window.go")) + } + /// `agterm-linux` may implement the original session-wide HUD methods. New dispatchers preserve that /// behavior when the host has not adopted pane placement yet. func openHud(_ target: String?, window: String?, spec: HudSpec, diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher.swift b/agtermCore/Sources/agtermCore/ControlDispatcher.swift index 6a3fd88b..0ad4c69d 100644 --- a/agtermCore/Sources/agtermCore/ControlDispatcher.swift +++ b/agtermCore/Sources/agtermCore/ControlDispatcher.swift @@ -116,6 +116,7 @@ public protocol ControlActions { func windowNew(name: String?, minimized: Bool) async -> ControlResponse func windowList() -> ControlResponse func windowSelect(_ target: String?) async -> ControlResponse + func windowGo(direction: WorkspaceNavigation) -> ControlResponse func windowClose(_ target: String?) async -> ControlResponse func windowRename(_ target: String?, name: String) -> ControlResponse func windowDelete(_ target: String?) -> ControlResponse @@ -204,7 +205,7 @@ public struct ControlDispatcher { return await dispatchZmxCommand(request) case .quickType, .quickText: return await dispatchQuickCommand(request) - case .windowNew, .windowList, .windowSelect, .windowClose, .windowRename, + case .windowNew, .windowList, .windowSelect, .windowGo, .windowClose, .windowRename, .windowDelete, .windowResize, .windowMove, .windowZoom, .windowFullscreen, .windowMinimize: return await dispatchWindowCommand(request) case .dashboard: @@ -895,6 +896,11 @@ public struct ControlDispatcher { return actions.windowList() case .windowSelect: return await actions.windowSelect(request.target) + case .windowGo: + guard let dir = (request.args?.to).flatMap(WorkspaceNavigation.init(wire:)) else { + return ControlResponse(ok: false, error: "window.go requires --to next|prev") + } + return actions.windowGo(direction: dir) case .windowClose: return await actions.windowClose(request.target) case .windowRename: diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift index a45fff63..68cff935 100644 --- a/agtermCore/Sources/agtermCore/ControlProtocol.swift +++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift @@ -66,6 +66,7 @@ public enum Command: String, Codable, Sendable { case windowNew = "window.new" case windowList = "window.list" case windowSelect = "window.select" + case windowGo = "window.go" case windowClose = "window.close" case windowRename = "window.rename" case windowDelete = "window.delete" diff --git a/agtermCore/Sources/agtermCore/PaletteCatalog.swift b/agtermCore/Sources/agtermCore/PaletteCatalog.swift index 04ab124e..9548fec0 100644 --- a/agtermCore/Sources/agtermCore/PaletteCatalog.swift +++ b/agtermCore/Sources/agtermCore/PaletteCatalog.swift @@ -21,6 +21,10 @@ public struct PaletteContext: Sendable, Equatable { /// `navigateWorkspace` no-ops below that, so without this term the menu item and the mapped key stay /// live while provably doing nothing. public let canStepWorkspaces: Bool + /// Whether more than one window is OPEN, i.e. whether a window step has anywhere to go. `WindowLibrary`, + /// not the store: window stepping is app-global, so this is the one term here that outlives the frontmost + /// window's own state. + public let canStepWindows: Bool public let activeSessionHasSplit: Bool public let activeSplitAxis: SplitAxis? public let hasPendingClose: Bool @@ -47,6 +51,7 @@ public struct PaletteContext: Sendable, Equatable { activeWorkspaceMarked: Bool = false, activeWorkspaceCollapsed: Bool = false, canStepWorkspaces: Bool = false, + canStepWindows: Bool = false, activeSessionHasSplit: Bool = false, activeSplitAxis: SplitAxis? = nil, hasPendingClose: Bool = false, @@ -65,6 +70,7 @@ public struct PaletteContext: Sendable, Equatable { self.activeWorkspaceMarked = activeWorkspaceMarked self.activeWorkspaceCollapsed = activeWorkspaceCollapsed self.canStepWorkspaces = canStepWorkspaces + self.canStepWindows = canStepWindows self.activeSessionHasSplit = activeSessionHasSplit self.activeSplitAxis = activeSplitAxis self.hasPendingClose = hasPendingClose @@ -83,6 +89,7 @@ public enum PaletteCommand: String, CaseIterable, Sendable { case renameSession, duplicateSession, renameWorkspace, closeSession, reopenRecent, undoClose, clearStatus case previousSession, nextSession, previousAttentionSession, nextAttentionSession case previousWorkspace, nextWorkspace + case previousWindow, nextWindow case firstSession, lastSession, showAttention case toggleSplit, toggleHorizontalSplit, closeSplit, swapPanes, toggleScratch, toggleTerminalZoom case toggleSidebar, toggleFlag, focusWorkspace @@ -112,6 +119,9 @@ public enum PaletteCommand: String, CaseIterable, Sendable { case .previousWorkspace, .nextWorkspace: // a step needs somewhere to go, so a lone visible workspace disables rather than no-ops return context.hasCurrentWorkspace && context.canStepWorkspaces + case .previousWindow, .nextWindow: + // same rule one level up: a single open window has nowhere to step to + return context.canStepWindows default: return true } @@ -198,6 +208,8 @@ public enum PaletteCommand: String, CaseIterable, Sendable { case .nextAttentionSession: return "Next Attention Session" case .previousWorkspace: return "Previous Workspace" case .nextWorkspace: return "Next Workspace" + case .previousWindow: return "Previous Window" + case .nextWindow: return "Next Window" case .firstSession: return "First Session" case .lastSession: return "Last Session" case .showAttention: return "Show Attention" @@ -253,6 +265,8 @@ public enum PaletteCommand: String, CaseIterable, Sendable { case .previousAttentionSession: return .previousAttentionSession case .nextAttentionSession: return .nextAttentionSession case .previousWorkspace: return .previousWorkspace + case .previousWindow: return .previousWindow + case .nextWindow: return .nextWindow case .nextWorkspace: return .nextWorkspace case .toggleWorkspaceCollapse: return .toggleWorkspaceCollapse case .firstSession: return .firstSession diff --git a/agtermCore/Sources/agtermCore/WindowLibrary.swift b/agtermCore/Sources/agtermCore/WindowLibrary.swift index f861e199..18261b0f 100644 --- a/agtermCore/Sources/agtermCore/WindowLibrary.swift +++ b/agtermCore/Sources/agtermCore/WindowLibrary.swift @@ -230,6 +230,22 @@ public final class WindowLibrary { windows.map(\.id).filter { stores[$0] != nil } } + /// Whether more than one window is open, i.e. whether a window step has anywhere to go. Closed entries + /// are not candidates — a step must not silently open a window the way `window.select` does. + public var canStepWindows: Bool { + openIDs().count > 1 + } + + /// The next/previous OPEN window in library order, WRAPPING from `activeWindowID`; the caller raises it. + /// Closed entries are skipped for the reason `canStepWindows` gives. Nil below two open windows, where a + /// step would only re-raise the one it is on. Backs `next_window`/`previous_window` and `window.go`. + public func navigateWindow(_ direction: WorkspaceNavigation) -> WindowInfo.ID? { + let ids = openIDs() + guard ids.count > 1, let current = activeWindowID, let i = ids.firstIndex(of: current) else { return nil } + let step = direction == .next ? 1 : -1 + return ids[((i + step) % ids.count + ids.count) % ids.count] + } + /// Every session across all open windows, flattened — the walk the per-session sweeps share /// (restore-running-command capture + `restore.clear`). public func allOpenSessions() -> [Session] { diff --git a/agtermCore/Sources/agtermctlKit/WindowCommands.swift b/agtermCore/Sources/agtermctlKit/WindowCommands.swift index 653eb24b..ac7c2762 100644 --- a/agtermCore/Sources/agtermctlKit/WindowCommands.swift +++ b/agtermCore/Sources/agtermctlKit/WindowCommands.swift @@ -6,8 +6,8 @@ import agtermCore struct Window: ParsableCommand { static let configuration = CommandConfiguration( abstract: "Window commands.", - subcommands: [New.self, List.self, Select.self, Close.self, Rename.self, Delete.self, Resize.self, Move.self, - Zoom.self, Fullscreen.self, Minimize.self] + subcommands: [New.self, List.self, Select.self, Go.self, Close.self, Rename.self, Delete.self, Resize.self, + Move.self, Zoom.self, Fullscreen.self, Minimize.self] ) struct New: RequestCommand { @@ -38,6 +38,18 @@ struct Window: ParsableCommand { func makeRequest() throws -> ControlRequest { ControlRequest(cmd: .windowSelect, target: id) } } + /// `agtermctl window go --to next|prev` — raises the next/previous OPEN window, wrapping. Deliberately + /// no id argument: it is relative to the active window, the shape `workspace go` takes, and a closed + /// entry is not a step candidate — `select` is the verb that opens one. + struct Go: RequestCommand { + static let configuration = CommandConfiguration(commandName: "go", + abstract: "Navigate open windows: next|prev.") + @Option(name: .long, help: "Direction: next or prev.") var to: String + @OptionGroup var options: BasicOptions + + func makeRequest() throws -> ControlRequest { ControlRequest(cmd: .windowGo, args: ControlArgs(to: to)) } + } + struct Close: RequestCommand { static let configuration = CommandConfiguration(abstract: "Close a window (its bundle is kept).") @Argument(help: "Window id, unique prefix, or 'active'.") var id: String = "active" diff --git a/agtermCore/Tests/agtermCoreTests/BuiltinActionTests.swift b/agtermCore/Tests/agtermCoreTests/BuiltinActionTests.swift index cb818a6a..89cff640 100644 --- a/agtermCore/Tests/agtermCoreTests/BuiltinActionTests.swift +++ b/agtermCore/Tests/agtermCoreTests/BuiltinActionTests.swift @@ -33,7 +33,9 @@ struct BuiltinActionTests { #expect(BuiltinAction.previousWorkspace.rawValue == "previous_workspace") #expect(BuiltinAction.nextWorkspace.rawValue == "next_workspace") #expect(BuiltinAction.toggleWorkspaceCollapse.rawValue == "toggle_workspace_collapse") - #expect(BuiltinAction.allCases.count == 46) + #expect(BuiltinAction.previousWindow.rawValue == "previous_window") + #expect(BuiltinAction.nextWindow.rawValue == "next_window") + #expect(BuiltinAction.allCases.count == 48) } @Test func rejectsUnknownName() { @@ -109,6 +111,8 @@ struct BuiltinActionTests { .previousWorkspace: nil, .nextWorkspace: nil, .toggleWorkspaceCollapse: nil, + .previousWindow: nil, + .nextWindow: nil, .focusLeftPane: Chord(mods: [.command, .option], key: "left"), .focusRightPane: Chord(mods: [.command, .option], key: "right"), .previousSession: Chord(mods: [.command, .option], key: "up"), @@ -180,6 +184,7 @@ struct BuiltinActionTests { .renameWindow, .deleteWindow, .renameWorkspace, .deleteWorkspace, .renameSession, .duplicateSession, .clearStatus, .firstSession, .lastSession, .selectTheme, .toggleFlaggedView, .focusWorkspace, .toggleWorkspaceFilter, .previousWorkspace, .nextWorkspace, .toggleWorkspaceCollapse, + .previousWindow, .nextWindow, ] for action in keyless { #expect(action.defaultChord == nil, "expected nil default for \(action.rawValue)") diff --git a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift index 1960ce6d..80c4feef 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift @@ -1580,6 +1580,40 @@ struct ControlDispatcherTests { ]) } + @Test func windowGoRoutesBothDirectionsThroughActions() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + actions.nextWindowGoResponse = ControlResponse(ok: true, result: ControlResult(id: "win-b")) + + let next = await dispatcher.dispatch(ControlRequest(cmd: .windowGo, args: ControlArgs(to: "next"))) + let prev = await dispatcher.dispatch(ControlRequest(cmd: .windowGo, args: ControlArgs(to: "prev"))) + let spelled = await dispatcher.dispatch(ControlRequest(cmd: .windowGo, args: ControlArgs(to: "previous"))) + + #expect(next == ControlResponse(ok: true, result: ControlResult(id: "win-b"))) + #expect(prev == next) + #expect(spelled == next) + #expect(actions.calls == [.windowGo(.next), .windowGo(.previous), .windowGo(.previous)]) + } + + @Test func windowGoIgnoresAWindowArgumentAndRejectsABadDirection() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + // app-global: there is no per-window scope to carry, so `--window` cannot narrow the step + let scoped = await dispatcher.dispatch(ControlRequest(cmd: .windowGo, args: ControlArgs(window: "win", to: "next"))) + #expect(actions.calls == [.windowGo(.next)]) + + let missing = await dispatcher.dispatch(ControlRequest(cmd: .windowGo)) + let unknown = await dispatcher.dispatch(ControlRequest(cmd: .windowGo, args: ControlArgs(to: "sideways"))) + let sessionOnly = await dispatcher.dispatch(ControlRequest(cmd: .windowGo, args: ControlArgs(to: "first"))) + + #expect(scoped == ControlResponse(ok: true)) + #expect(missing == ControlResponse(ok: false, error: "window.go requires --to next|prev")) + #expect(unknown == missing) + #expect(sessionOnly == missing) + #expect(actions.calls == [.windowGo(.next)]) + } + @Test func windowCommandsRouteParsedInputsAndKeepActionResponses() async { let actions = MockControlActions() let dispatcher = ControlDispatcher(actions: actions) diff --git a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift index d26b0ad7..d8712f19 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift @@ -1233,83 +1233,6 @@ struct ControlProtocolTests { #expect(decoded.sidebarVisible == nil) } - @Test func windowNodeRoundTripsWithPerWindowFields() throws { - let node = ControlWindowNode(id: "w1", name: "work", open: true, active: true, autoFollowMs: 5000, - sidebarVisible: true) - let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) - let decoded = try roundTrip(response) - #expect(decoded == response) - #expect(decoded.result?.windows?.first?.autoFollowMs == 5000) - #expect(decoded.result?.windows?.first?.sidebarVisible == true) - } - - @Test func windowNodeOmitsPerWindowFieldsWhenNil() throws { - let node = ControlWindowNode(id: "w1", name: "work", open: true, active: false) - let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" - #expect(!json.contains("autoFollowMs"), "a nil autoFollowMs must be omitted from the JSON; got \(json)") - #expect(!json.contains("sidebarVisible"), "a nil sidebarVisible must be omitted from the JSON; got \(json)") - let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) - #expect(decoded.autoFollowMs == nil) - #expect(decoded.sidebarVisible == nil) - } - - @Test func windowNodeRoundTripsWithGeometry() throws { - // the frame fields match the CLI's --x/--y/--width/--height, so a read-back restores verbatim. - let node = ControlWindowNode(id: "w1", name: "work", open: true, active: true, - geometry: ControlWindowFrame(x: 100, y: 40, width: 1200, height: 800, display: 1)) - let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) - let decoded = try roundTrip(response) - #expect(decoded == response) - let frame = try #require(decoded.result?.windows?.first?.geometry) - #expect(frame == ControlWindowFrame(x: 100, y: 40, width: 1200, height: 800, display: 1)) - } - - @Test func windowNodeOmitsGeometryWhenNil() throws { - let node = ControlWindowNode(id: "w1", name: "work", open: false, active: false) - let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" - #expect(!json.contains("geometry"), "a nil geometry must be omitted from the JSON; got \(json)") - let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) - #expect(decoded.geometry == nil) - } - - @Test func windowNodeRoundTripsWithFullscreenAndZoom() throws { - let node = ControlWindowNode(id: "w1", name: "work", open: true, active: true, fullscreen: true, zoomed: false) - let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) - let decoded = try roundTrip(response) - #expect(decoded == response) - #expect(decoded.result?.windows?.first?.fullscreen == true) - #expect(decoded.result?.windows?.first?.zoomed == false) - } - - @Test func windowNodeOmitsFullscreenAndZoomWhenNil() throws { - let node = ControlWindowNode(id: "w1", name: "work", open: false, active: false) - let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" - #expect(!json.contains("fullscreen"), "a nil fullscreen must be omitted from the JSON; got \(json)") - #expect(!json.contains("zoomed"), "a nil zoomed must be omitted from the JSON; got \(json)") - let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) - #expect(decoded.fullscreen == nil) - #expect(decoded.zoomed == nil) - } - - @Test func windowNodeRoundTripsWithMinimized() throws { - let frame = ControlWindowFrame(x: 100, y: 50, width: 900, height: 600, display: 0) - let node = ControlWindowNode(id: "w1", name: "work", open: true, active: false, - geometry: frame, minimized: true) - let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) - let decoded = try roundTrip(response) - #expect(decoded == response) - #expect(decoded.result?.windows?.first?.minimized == true) - #expect(decoded.result?.windows?.first?.geometry == frame) - } - - @Test func windowNodeOmitsMinimizedWhenNil() throws { - let node = ControlWindowNode(id: "w1", name: "work", open: false, active: false) - let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" - #expect(!json.contains("minimized"), "a nil minimized must be omitted from the JSON; got \(json)") - let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) - #expect(decoded.minimized == nil) - } - @Test func workspaceNodeRoundTripsWithFocused() throws { // `focused` (a member of the sidebar focus set) is distinct from `active` (the selected one). let ws = ControlWorkspaceNode(id: "w1", name: "work", active: true, focused: true, sessions: []) @@ -1697,25 +1620,6 @@ struct ControlProtocolTests { } } - @Test func windowCommandsRoundTrip() throws { - let cases: [ControlRequest] = [ - ControlRequest(cmd: .windowNew, args: ControlArgs(name: "work")), - ControlRequest(cmd: .windowNew, args: ControlArgs(name: "parked", minimized: true)), - ControlRequest(cmd: .windowList), - ControlRequest(cmd: .windowSelect, target: "9f3c"), - ControlRequest(cmd: .windowClose, target: "9f3c"), - ControlRequest(cmd: .windowRename, target: "active", args: ControlArgs(name: "renamed")), - ControlRequest(cmd: .windowDelete, target: "9f3c"), - ControlRequest(cmd: .windowZoom, target: "9f3c"), - ControlRequest(cmd: .windowFullscreen, target: "9f3c"), - ControlRequest(cmd: .windowMinimize, target: "9f3c", args: ControlArgs(mode: "on")), - ControlRequest(cmd: .windowMinimize, target: "active"), - ] - for request in cases { - #expect(try roundTrip(request) == request) - } - } - @Test func keymapReloadRequestRoundTrips() throws { let request = ControlRequest(cmd: .keymapReload) let decoded = try roundTrip(request) @@ -1883,32 +1787,6 @@ struct ControlProtocolTests { #expect(decoded.result?.exitCode == 10) } - @Test func responseOkWithWindowsRoundTrips() throws { - let windows = [ - ControlWindowNode(id: "w1", name: "work", open: true, active: true), - ControlWindowNode(id: "w2", name: "personal", open: false, active: false), - ] - let response = ControlResponse(ok: true, result: ControlResult(windows: windows)) - let decoded = try roundTrip(response) - #expect(decoded == response) - #expect(decoded.result?.windows?.count == 2) - #expect(decoded.result?.windows?.first?.name == "work") - #expect(decoded.result?.windows?.first?.open == true) - #expect(decoded.result?.windows?.first?.active == true) - #expect(decoded.result?.windows?.last?.open == false) - } - - @Test func windowsResultUsesExpectedWireFieldNames() throws { - let windows = [ControlWindowNode(id: "w1", name: "work", open: true, active: false)] - let response = ControlResponse(ok: true, result: ControlResult(windows: windows)) - let json = try #require(String(data: JSONEncoder().encode(response), encoding: .utf8)) - #expect(json.contains("\"windows\":")) - #expect(json.contains("\"id\":\"w1\"")) - #expect(json.contains("\"name\":\"work\"")) - #expect(json.contains("\"open\":true")) - #expect(json.contains("\"active\":false")) - } - @Test func responseErrorRoundTrips() throws { let response = ControlResponse(ok: false, error: "ambiguous prefix '9f'") let decoded = try roundTrip(response) diff --git a/agtermCore/Tests/agtermCoreTests/ControlWindowProtocolTests.swift b/agtermCore/Tests/agtermCoreTests/ControlWindowProtocolTests.swift new file mode 100644 index 00000000..9cc0b06a --- /dev/null +++ b/agtermCore/Tests/agtermCoreTests/ControlWindowProtocolTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import agtermCore + +/// Wire format for the window commands and `ControlWindowNode`: what each field round-trips as and which +/// ones are omitted when nil. Split out of `ControlProtocolTests` for the swiftlint file limit. +struct ControlWindowProtocolTests { + private func roundTrip(_ request: ControlRequest) throws -> ControlRequest { + let data = try JSONEncoder().encode(request) + return try JSONDecoder().decode(ControlRequest.self, from: data) + } + + private func roundTrip(_ response: ControlResponse) throws -> ControlResponse { + let data = try JSONEncoder().encode(response) + return try JSONDecoder().decode(ControlResponse.self, from: data) + } + + @Test func windowNodeRoundTripsWithPerWindowFields() throws { + let node = ControlWindowNode(id: "w1", name: "work", open: true, active: true, autoFollowMs: 5000, + sidebarVisible: true) + let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) + let decoded = try roundTrip(response) + #expect(decoded == response) + #expect(decoded.result?.windows?.first?.autoFollowMs == 5000) + #expect(decoded.result?.windows?.first?.sidebarVisible == true) + } + + @Test func windowNodeOmitsPerWindowFieldsWhenNil() throws { + let node = ControlWindowNode(id: "w1", name: "work", open: true, active: false) + let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" + #expect(!json.contains("autoFollowMs"), "a nil autoFollowMs must be omitted from the JSON; got \(json)") + #expect(!json.contains("sidebarVisible"), "a nil sidebarVisible must be omitted from the JSON; got \(json)") + let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) + #expect(decoded.autoFollowMs == nil) + #expect(decoded.sidebarVisible == nil) + } + + @Test func windowNodeRoundTripsWithGeometry() throws { + // the frame fields match the CLI's --x/--y/--width/--height, so a read-back restores verbatim. + let node = ControlWindowNode(id: "w1", name: "work", open: true, active: true, + geometry: ControlWindowFrame(x: 100, y: 40, width: 1200, height: 800, display: 1)) + let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) + let decoded = try roundTrip(response) + #expect(decoded == response) + let frame = try #require(decoded.result?.windows?.first?.geometry) + #expect(frame == ControlWindowFrame(x: 100, y: 40, width: 1200, height: 800, display: 1)) + } + + @Test func windowNodeOmitsGeometryWhenNil() throws { + let node = ControlWindowNode(id: "w1", name: "work", open: false, active: false) + let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" + #expect(!json.contains("geometry"), "a nil geometry must be omitted from the JSON; got \(json)") + let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) + #expect(decoded.geometry == nil) + } + + @Test func windowNodeRoundTripsWithFullscreenAndZoom() throws { + let node = ControlWindowNode(id: "w1", name: "work", open: true, active: true, fullscreen: true, zoomed: false) + let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) + let decoded = try roundTrip(response) + #expect(decoded == response) + #expect(decoded.result?.windows?.first?.fullscreen == true) + #expect(decoded.result?.windows?.first?.zoomed == false) + } + + @Test func windowNodeOmitsFullscreenAndZoomWhenNil() throws { + let node = ControlWindowNode(id: "w1", name: "work", open: false, active: false) + let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" + #expect(!json.contains("fullscreen"), "a nil fullscreen must be omitted from the JSON; got \(json)") + #expect(!json.contains("zoomed"), "a nil zoomed must be omitted from the JSON; got \(json)") + let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) + #expect(decoded.fullscreen == nil) + #expect(decoded.zoomed == nil) + } + + @Test func windowNodeRoundTripsWithMinimized() throws { + let frame = ControlWindowFrame(x: 100, y: 50, width: 900, height: 600, display: 0) + let node = ControlWindowNode(id: "w1", name: "work", open: true, active: false, + geometry: frame, minimized: true) + let response = ControlResponse(ok: true, result: ControlResult(windows: [node])) + let decoded = try roundTrip(response) + #expect(decoded == response) + #expect(decoded.result?.windows?.first?.minimized == true) + #expect(decoded.result?.windows?.first?.geometry == frame) + } + + @Test func windowNodeOmitsMinimizedWhenNil() throws { + let node = ControlWindowNode(id: "w1", name: "work", open: false, active: false) + let json = String(data: try JSONEncoder().encode(node), encoding: .utf8) ?? "" + #expect(!json.contains("minimized"), "a nil minimized must be omitted from the JSON; got \(json)") + let decoded = try JSONDecoder().decode(ControlWindowNode.self, from: Data(json.utf8)) + #expect(decoded.minimized == nil) + } + + @Test func windowGoRoundTripsWithDirection() throws { + let request = ControlRequest(cmd: .windowGo, args: ControlArgs(to: "prev")) + let decoded = try roundTrip(request) + #expect(decoded == request) + #expect(decoded.cmd == .windowGo) + #expect(decoded.cmd.rawValue == "window.go") + #expect(WorkspaceNavigation(wire: decoded.args!.to!) == .previous) + } + + @Test func windowCommandsRoundTrip() throws { + let cases: [ControlRequest] = [ + ControlRequest(cmd: .windowNew, args: ControlArgs(name: "work")), + ControlRequest(cmd: .windowNew, args: ControlArgs(name: "parked", minimized: true)), + ControlRequest(cmd: .windowList), + ControlRequest(cmd: .windowSelect, target: "9f3c"), + ControlRequest(cmd: .windowClose, target: "9f3c"), + ControlRequest(cmd: .windowRename, target: "active", args: ControlArgs(name: "renamed")), + ControlRequest(cmd: .windowDelete, target: "9f3c"), + ControlRequest(cmd: .windowZoom, target: "9f3c"), + ControlRequest(cmd: .windowFullscreen, target: "9f3c"), + ControlRequest(cmd: .windowMinimize, target: "9f3c", args: ControlArgs(mode: "on")), + ControlRequest(cmd: .windowMinimize, target: "active"), + ] + for request in cases { + #expect(try roundTrip(request) == request) + } + } + + @Test func responseOkWithWindowsRoundTrips() throws { + let windows = [ + ControlWindowNode(id: "w1", name: "work", open: true, active: true), + ControlWindowNode(id: "w2", name: "personal", open: false, active: false), + ] + let response = ControlResponse(ok: true, result: ControlResult(windows: windows)) + let decoded = try roundTrip(response) + #expect(decoded == response) + #expect(decoded.result?.windows?.count == 2) + #expect(decoded.result?.windows?.first?.name == "work") + #expect(decoded.result?.windows?.first?.open == true) + #expect(decoded.result?.windows?.first?.active == true) + #expect(decoded.result?.windows?.last?.open == false) + } + + @Test func windowsResultUsesExpectedWireFieldNames() throws { + let windows = [ControlWindowNode(id: "w1", name: "work", open: true, active: false)] + let response = ControlResponse(ok: true, result: ControlResult(windows: windows)) + let json = try #require(String(data: JSONEncoder().encode(response), encoding: .utf8)) + #expect(json.contains("\"windows\":")) + #expect(json.contains("\"id\":\"w1\"")) + #expect(json.contains("\"name\":\"work\"")) + #expect(json.contains("\"open\":true")) + #expect(json.contains("\"active\":false")) + } +} diff --git a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift index cc91f2a1..f46ae4d6 100644 --- a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift +++ b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift @@ -87,6 +87,7 @@ final class MockControlActions: ControlActions { case windowNew(String?, minimized: Bool) case windowList case windowSelect(target: String?) + case windowGo(WorkspaceNavigation) case windowClose(target: String?) case windowRename(target: String?, String) case windowDelete(target: String?) @@ -166,6 +167,7 @@ final class MockControlActions: ControlActions { var nextWindowNewResponse = ControlResponse(ok: true) var nextWindowListResponse = ControlResponse(ok: true) var nextWindowSelectResponse = ControlResponse(ok: true) + var nextWindowGoResponse = ControlResponse(ok: true) var nextWindowCloseResponse = ControlResponse(ok: true) var nextWindowRenameResponse = ControlResponse(ok: true) var nextWindowDeleteResponse = ControlResponse(ok: true) @@ -608,6 +610,11 @@ final class MockControlActions: ControlActions { return nextWindowSelectResponse } + func windowGo(direction: WorkspaceNavigation) -> ControlResponse { + calls.append(.windowGo(direction)) + return nextWindowGoResponse + } + func windowClose(_ target: String?) async -> ControlResponse { calls.append(.windowClose(target: target)) return nextWindowCloseResponse diff --git a/agtermCore/Tests/agtermCoreTests/PaletteCatalogTests.swift b/agtermCore/Tests/agtermCoreTests/PaletteCatalogTests.swift index 99b0d883..e3e9fd1b 100644 --- a/agtermCore/Tests/agtermCoreTests/PaletteCatalogTests.swift +++ b/agtermCore/Tests/agtermCoreTests/PaletteCatalogTests.swift @@ -20,6 +20,8 @@ struct PaletteCatalogTests { "Next Attention Session", "Previous Workspace", "Next Workspace", + "Previous Window", + "Next Window", "First Session", "Last Session", "Show Attention", @@ -59,7 +61,7 @@ struct PaletteCatalogTests { } @Test func catalogHasTheExpectedStaticCommandCount() { - #expect(PaletteCommand.allCases.count == 51) + #expect(PaletteCommand.allCases.count == 53) } @Test func idsRoundTripThroughRawValue() { @@ -115,6 +117,23 @@ struct PaletteCatalogTests { #expect(PaletteCommand.toggleWorkspaceCollapse.isEnabled(in: alone)) } + // the same rule one level up, on the LIBRARY rather than the store: one open window has nowhere to step. + // unlike the workspace pair these stay live in flagged mode, windows having no sidebar rows to render. + @Test func windowStepsDisableWithOneOpenWindow() { + let alone = PaletteContext(canStepWindows: false) + let several = PaletteContext(canStepWindows: true) + for command in [PaletteCommand.previousWindow, .nextWindow] { + #expect(!command.isEnabled(in: alone)) + #expect(command.isEnabled(in: several)) + #expect(command.isVisible(in: alone), "still listed, just inert") + #expect(command.isEnabled(in: PaletteContext(canStepWindows: true, hasActiveSession: false, + hasCurrentWorkspace: false)), + "app-global: no session or workspace of its own to require") + #expect(!command.isEnabled(in: PaletteContext(canStepWindows: true, terminalZoomActive: true)), + "an ordinary modal cover still blocks it") + } + } + @Test func workspaceAndSplitCommandsFollowTheirPredicates() { #expect(!PaletteCommand.deleteWorkspace.isVisible(in: PaletteContext(canRemoveWorkspace: false))) #expect(PaletteCommand.deleteWorkspace.isVisible(in: PaletteContext(canRemoveWorkspace: true))) @@ -160,6 +179,7 @@ struct PaletteCatalogTests { private static let live = PaletteContext(canRemoveWorkspace: true, hasFlaggedSessions: true, sidebarShowsWorkspaceTree: true, hasMarkedWorkspaces: true, canStepWorkspaces: true, + canStepWindows: true, activeSessionHasSplit: true, hasPendingClose: true, hasRecentClosed: true, hasActiveSession: true, hasCurrentWorkspace: true) @@ -187,6 +207,7 @@ struct PaletteCatalogTests { let context = PaletteContext(canRemoveWorkspace: true, hasFlaggedSessions: true, sidebarShowsWorkspaceTree: true, hasMarkedWorkspaces: true, canStepWorkspaces: true, + canStepWindows: true, activeSessionHasSplit: true, hasPendingClose: true, hasRecentClosed: true, hasActiveSession: false, hasCurrentWorkspace: true) @@ -199,6 +220,7 @@ struct PaletteCatalogTests { let context = PaletteContext(canRemoveWorkspace: true, hasFlaggedSessions: true, sidebarShowsWorkspaceTree: true, hasMarkedWorkspaces: true, canStepWorkspaces: true, + canStepWindows: true, activeSessionHasSplit: true, hasPendingClose: true, hasRecentClosed: true, hasActiveSession: true, hasCurrentWorkspace: false) @@ -213,6 +235,7 @@ struct PaletteCatalogTests { let context = PaletteContext(canRemoveWorkspace: true, hasFlaggedSessions: true, sidebarShowsWorkspaceTree: true, hasMarkedWorkspaces: true, canStepWorkspaces: true, + canStepWindows: true, activeSessionHasSplit: true, hasPendingClose: true, hasRecentClosed: true, hasActiveSession: true, hasCurrentWorkspace: true, terminalZoomActive: true) @@ -226,6 +249,7 @@ struct PaletteCatalogTests { let context = PaletteContext(canRemoveWorkspace: true, hasFlaggedSessions: true, sidebarShowsWorkspaceTree: true, hasMarkedWorkspaces: true, canStepWorkspaces: true, + canStepWindows: true, activeSessionHasSplit: true, hasPendingClose: true, hasRecentClosed: true, hasActiveSession: true, hasCurrentWorkspace: true, pickerActive: true) @@ -241,6 +265,7 @@ struct PaletteCatalogTests { let context = PaletteContext(canRemoveWorkspace: true, hasFlaggedSessions: true, sidebarShowsWorkspaceTree: true, hasMarkedWorkspaces: true, canStepWorkspaces: true, + canStepWindows: true, activeSessionHasSplit: true, hasPendingClose: true, hasRecentClosed: true, hasActiveSession: true, hasCurrentWorkspace: true, pickerActive: controller.modalPending) @@ -257,6 +282,7 @@ struct PaletteCatalogTests { let context = PaletteContext(canRemoveWorkspace: true, hasFlaggedSessions: true, sidebarShowsWorkspaceTree: true, hasMarkedWorkspaces: true, canStepWorkspaces: true, + canStepWindows: true, activeSessionHasSplit: true, hasPendingClose: true, hasRecentClosed: true, hasActiveSession: true, hasCurrentWorkspace: true, dashboardOpen: true) diff --git a/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift b/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift index ad90968f..6c635292 100644 --- a/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift +++ b/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift @@ -834,6 +834,56 @@ final class WindowLibraryTests { #expect(library.openIDs() == [library.windows[0].id]) } + @Test func windowStepWrapsBothWaysInLibraryOrder() { + let library = WindowLibrary(directory: directory) + let first = library.windows[0].id + let second = library.newWindow(name: "second").id + let third = library.newWindow(name: "third").id + #expect(library.canStepWindows) + + library.frontmostWindowID = first + #expect(library.navigateWindow(.next) == second) + #expect(library.navigateWindow(.previous) == third) + library.frontmostWindowID = third + #expect(library.navigateWindow(.next) == first) + #expect(library.navigateWindow(.previous) == second) + } + + @Test func windowStepSkipsClosedEntries() { + let library = WindowLibrary(directory: directory) + let first = library.windows[0].id + let middle = library.newWindow(name: "middle").id + let last = library.newWindow(name: "last").id + library.closeWindow(middle) + + library.frontmostWindowID = first + #expect(library.navigateWindow(.next) == last) + #expect(library.navigateWindow(.previous) == last) + } + + @Test func windowStepIsNilWithOneOpenWindow() { + let library = WindowLibrary(directory: directory) + let extra = library.newWindow(name: "extra").id + library.closeWindow(extra) + #expect(!library.canStepWindows) + #expect(library.navigateWindow(.next) == nil) + #expect(library.navigateWindow(.previous) == nil) + } + + @Test func windowStepStartsFromTheResolvedActiveWindowWhenFrontmostIsClosed() { + // `activeWindowID` falls back to the first OPEN window, so a step after the frontmost closed + // leaves from that survivor rather than returning nil. + let library = WindowLibrary(directory: directory) + let first = library.windows[0].id + let second = library.newWindow(name: "second").id + let third = library.newWindow(name: "third").id + library.frontmostWindowID = third + library.closeWindow(third) + + #expect(library.activeWindowID == first) + #expect(library.navigateWindow(.next) == second) + } + @Test func closeUnknownWindowIsNoOp() { let library = WindowLibrary(directory: directory) let before = library.openIDs() diff --git a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift index 275c76d4..3f9d16f5 100644 --- a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift @@ -1676,126 +1676,6 @@ struct CommandsTests { #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["theme", "set", "Nord", "--window", "w1"]) } } - // MARK: - window subcommands - - @Test func windowNewWithName() throws { - #expect(try request(["window", "new", "Work"]) == ControlRequest(cmd: .windowNew, args: ControlArgs(name: "Work"))) - } - - @Test func windowNewWithoutName() throws { - #expect(try request(["window", "new"]) == ControlRequest(cmd: .windowNew, args: ControlArgs(name: nil))) - } - - @Test func windowNewMinimized() throws { - #expect(try request(["window", "new", "Work", "--minimized"]) - == ControlRequest(cmd: .windowNew, args: ControlArgs(name: "Work", minimized: true))) - // omitted rather than false, so an un-flagged create stays byte-identical on the wire - #expect(try request(["window", "new", "Work"]) - == ControlRequest(cmd: .windowNew, args: ControlArgs(name: "Work", minimized: nil))) - } - - @Test func windowList() throws { - #expect(try request(["window", "list"]) == ControlRequest(cmd: .windowList)) - } - - @Test func windowSelect() throws { - #expect(try request(["window", "select", "9f3c"]) == ControlRequest(cmd: .windowSelect, target: "9f3c")) - } - - @Test func windowSelectDefaultsActive() throws { - #expect(try request(["window", "select"]) == ControlRequest(cmd: .windowSelect, target: "active")) - } - - @Test func windowClose() throws { - #expect(try request(["window", "close", "ab"]) == ControlRequest(cmd: .windowClose, target: "ab")) - } - - @Test func windowRename() throws { - let expected = ControlRequest(cmd: .windowRename, target: "9f3c", args: ControlArgs(name: "Renamed")) - #expect(try request(["window", "rename", "9f3c", "Renamed"]) == expected) - } - - @Test func windowDelete() throws { - #expect(try request(["window", "delete", "9f3c"]) == ControlRequest(cmd: .windowDelete, target: "9f3c")) - } - - @Test func windowResize() throws { - let expected = ControlRequest(cmd: .windowResize, target: "9f3c", args: ControlArgs(width: 1200, height: 800)) - #expect(try request(["window", "resize", "9f3c", "--width", "1200", "--height", "800"]) == expected) - } - - @Test func windowResizeDefaultsToActive() throws { - let expected = ControlRequest(cmd: .windowResize, target: "active", args: ControlArgs(width: 1000, height: 700)) - #expect(try request(["window", "resize", "--width", "1000", "--height", "700"]) == expected) - } - - @Test func windowMoveWithDisplay() throws { - let expected = ControlRequest(cmd: .windowMove, target: "9f3c", args: ControlArgs(x: 100, y: 50, display: 1)) - #expect(try request(["window", "move", "9f3c", "--x", "100", "--y", "50", "--display", "1"]) == expected) - } - - @Test func windowMoveDefaultsActiveAndCurrentDisplay() throws { - let expected = ControlRequest(cmd: .windowMove, target: "active", args: ControlArgs(x: 100, y: 50)) - #expect(try request(["window", "move", "--x", "100", "--y", "50"]) == expected) - } - - @Test func windowZoom() throws { - #expect(try request(["window", "zoom", "9f3c"]) == ControlRequest(cmd: .windowZoom, target: "9f3c")) - } - - @Test func windowFullscreen() throws { - #expect(try request(["window", "fullscreen", "9f3c"]) == ControlRequest(cmd: .windowFullscreen, target: "9f3c")) - } - - @Test func windowFullscreenDefaultsActive() throws { - #expect(try request(["window", "fullscreen"]) == ControlRequest(cmd: .windowFullscreen, target: "active")) - } - - @Test func windowMinimize() throws { - #expect(try request(["window", "minimize", "9f3c", "on"]) - == ControlRequest(cmd: .windowMinimize, target: "9f3c", args: ControlArgs(mode: "on"))) - #expect(try request(["window", "minimize", "9f3c", "off"]) - == ControlRequest(cmd: .windowMinimize, target: "9f3c", args: ControlArgs(mode: "off"))) - } - - @Test func windowMinimizeDefaultsActiveAndToggle() throws { - #expect(try request(["window", "minimize"]) - == ControlRequest(cmd: .windowMinimize, target: "active", args: ControlArgs(mode: "toggle"))) - } - - @Test func windowMinimizeBareModeTargetsActive() throws { - // both positionals are optional, so a bare mode word would otherwise bind to the id; a window - // address is a hex prefix or `active`, never a mode word, so the recovery can't misfire. - #expect(try request(["window", "minimize", "on"]) - == ControlRequest(cmd: .windowMinimize, target: "active", args: ControlArgs(mode: "on"))) - #expect(try request(["window", "minimize", "toggle"]) - == ControlRequest(cmd: .windowMinimize, target: "active", args: ControlArgs(mode: "toggle"))) - // an id that merely looks like a mode word is still an id (hex `0ff`, not the word `off`) - #expect(try request(["window", "minimize", "0ff"]) - == ControlRequest(cmd: .windowMinimize, target: "0ff", args: ControlArgs(mode: "toggle"))) - } - - @Test func windowDeleteDefaultsActive() throws { - #expect(try request(["window", "delete"]) == ControlRequest(cmd: .windowDelete, target: "active")) - } - - @Test func windowRenameRequiresBothArgsFails() { - #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "rename", "9f3c"]) } - } - - @Test func windowCommandsRejectWindowSelector() { - #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "list", "--window", "w1"]) } - #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "select", "9f3c", "--window", "w1"]) } - #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["quick", "--window", "w1"]) } - } - - @Test func windowCommandsKeepSocketAndJSON() throws { - let parsed = try Agtermctl.parseAsRoot(["window", "list", "--socket", "/tmp/x.sock", "--json"]) - let command = try #require(parsed as? Window.List) - #expect(command.options.json) - #expect(command.options.socketPath(env: [:]) == "/tmp/x.sock") - } - // MARK: - global --window selector @Test func sessionNewWithWindow() throws { diff --git a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift index 7f8ac633..c090e65b 100644 --- a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift @@ -234,6 +234,8 @@ struct SocketClientTests { new_window cmd+opt+n rename_window - delete_window - + previous_window - + next_window - new_workspace cmd+shift+n rename_workspace - delete_workspace - diff --git a/agtermCore/Tests/agtermctlKitTests/WindowCommandsTests.swift b/agtermCore/Tests/agtermctlKitTests/WindowCommandsTests.swift new file mode 100644 index 00000000..73378062 --- /dev/null +++ b/agtermCore/Tests/agtermctlKitTests/WindowCommandsTests.swift @@ -0,0 +1,147 @@ +import ArgumentParser +import Foundation +import Testing +import agtermCore +@testable import agtermctlKit + +/// `agtermctl window …` argv parsing: which subcommand each spelling reaches and the `ControlRequest` it +/// builds. Split out of `CommandsTests` for the swiftlint file limit. +struct WindowCommandsTests { + /// Parse argv into a subcommand and build its `ControlRequest`. Throws if parsing or request-building fails. + private func request(_ argv: [String]) throws -> ControlRequest { + let parsed = try Agtermctl.parseAsRoot(argv) + guard let command = parsed as? any RequestCommand else { + throw SocketClientError("parsed \(argv) is not a RequestCommand") + } + return try command.makeRequest() + } + + @Test func windowNewWithName() throws { + #expect(try request(["window", "new", "Work"]) == ControlRequest(cmd: .windowNew, args: ControlArgs(name: "Work"))) + } + + @Test func windowNewWithoutName() throws { + #expect(try request(["window", "new"]) == ControlRequest(cmd: .windowNew, args: ControlArgs(name: nil))) + } + + @Test func windowNewMinimized() throws { + #expect(try request(["window", "new", "Work", "--minimized"]) + == ControlRequest(cmd: .windowNew, args: ControlArgs(name: "Work", minimized: true))) + // omitted rather than false, so an un-flagged create stays byte-identical on the wire + #expect(try request(["window", "new", "Work"]) + == ControlRequest(cmd: .windowNew, args: ControlArgs(name: "Work", minimized: nil))) + } + + @Test func windowList() throws { + #expect(try request(["window", "list"]) == ControlRequest(cmd: .windowList)) + } + + @Test func windowSelect() throws { + #expect(try request(["window", "select", "9f3c"]) == ControlRequest(cmd: .windowSelect, target: "9f3c")) + } + + @Test func windowSelectDefaultsActive() throws { + #expect(try request(["window", "select"]) == ControlRequest(cmd: .windowSelect, target: "active")) + } + + @Test func windowClose() throws { + #expect(try request(["window", "close", "ab"]) == ControlRequest(cmd: .windowClose, target: "ab")) + } + + @Test func windowRename() throws { + let expected = ControlRequest(cmd: .windowRename, target: "9f3c", args: ControlArgs(name: "Renamed")) + #expect(try request(["window", "rename", "9f3c", "Renamed"]) == expected) + } + + @Test func windowDelete() throws { + #expect(try request(["window", "delete", "9f3c"]) == ControlRequest(cmd: .windowDelete, target: "9f3c")) + } + + @Test func windowResize() throws { + let expected = ControlRequest(cmd: .windowResize, target: "9f3c", args: ControlArgs(width: 1200, height: 800)) + #expect(try request(["window", "resize", "9f3c", "--width", "1200", "--height", "800"]) == expected) + } + + @Test func windowResizeDefaultsToActive() throws { + let expected = ControlRequest(cmd: .windowResize, target: "active", args: ControlArgs(width: 1000, height: 700)) + #expect(try request(["window", "resize", "--width", "1000", "--height", "700"]) == expected) + } + + @Test func windowMoveWithDisplay() throws { + let expected = ControlRequest(cmd: .windowMove, target: "9f3c", args: ControlArgs(x: 100, y: 50, display: 1)) + #expect(try request(["window", "move", "9f3c", "--x", "100", "--y", "50", "--display", "1"]) == expected) + } + + @Test func windowMoveDefaultsActiveAndCurrentDisplay() throws { + let expected = ControlRequest(cmd: .windowMove, target: "active", args: ControlArgs(x: 100, y: 50)) + #expect(try request(["window", "move", "--x", "100", "--y", "50"]) == expected) + } + + @Test func windowZoom() throws { + #expect(try request(["window", "zoom", "9f3c"]) == ControlRequest(cmd: .windowZoom, target: "9f3c")) + } + + @Test func windowFullscreen() throws { + #expect(try request(["window", "fullscreen", "9f3c"]) == ControlRequest(cmd: .windowFullscreen, target: "9f3c")) + } + + @Test func windowFullscreenDefaultsActive() throws { + #expect(try request(["window", "fullscreen"]) == ControlRequest(cmd: .windowFullscreen, target: "active")) + } + + @Test func windowMinimize() throws { + #expect(try request(["window", "minimize", "9f3c", "on"]) + == ControlRequest(cmd: .windowMinimize, target: "9f3c", args: ControlArgs(mode: "on"))) + #expect(try request(["window", "minimize", "9f3c", "off"]) + == ControlRequest(cmd: .windowMinimize, target: "9f3c", args: ControlArgs(mode: "off"))) + } + + @Test func windowMinimizeDefaultsActiveAndToggle() throws { + #expect(try request(["window", "minimize"]) + == ControlRequest(cmd: .windowMinimize, target: "active", args: ControlArgs(mode: "toggle"))) + } + + @Test func windowMinimizeBareModeTargetsActive() throws { + // both positionals are optional, so a bare mode word would otherwise bind to the id; a window + // address is a hex prefix or `active`, never a mode word, so the recovery can't misfire. + #expect(try request(["window", "minimize", "on"]) + == ControlRequest(cmd: .windowMinimize, target: "active", args: ControlArgs(mode: "on"))) + #expect(try request(["window", "minimize", "toggle"]) + == ControlRequest(cmd: .windowMinimize, target: "active", args: ControlArgs(mode: "toggle"))) + // an id that merely looks like a mode word is still an id (hex `0ff`, not the word `off`) + #expect(try request(["window", "minimize", "0ff"]) + == ControlRequest(cmd: .windowMinimize, target: "0ff", args: ControlArgs(mode: "toggle"))) + } + + @Test func windowDeleteDefaultsActive() throws { + #expect(try request(["window", "delete"]) == ControlRequest(cmd: .windowDelete, target: "active")) + } + + @Test func windowRenameRequiresBothArgsFails() { + #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "rename", "9f3c"]) } + } + + @Test func windowCommandsRejectWindowSelector() { + #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "list", "--window", "w1"]) } + #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "select", "9f3c", "--window", "w1"]) } + #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["quick", "--window", "w1"]) } + } + + @Test func windowCommandsKeepSocketAndJSON() throws { + let parsed = try Agtermctl.parseAsRoot(["window", "list", "--socket", "/tmp/x.sock", "--json"]) + let command = try #require(parsed as? Window.List) + #expect(command.options.json) + #expect(command.options.socketPath(env: [:]) == "/tmp/x.sock") + } + + @Test func windowGo() throws { + #expect(try request(["window", "go", "--to", "next"]) == ControlRequest(cmd: .windowGo, args: ControlArgs(to: "next"))) + #expect(try request(["window", "go", "--to", "prev"]) == ControlRequest(cmd: .windowGo, args: ControlArgs(to: "prev"))) + } + + @Test func windowGoTakesNoIdAndRequiresADirection() { + // the other window subcommands take an id first; this one is relative, so a bare word is not a target + #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "go", "active"]) } + #expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["window", "go"]) } + } +} diff --git a/agtermTests/AppActionsTests.swift b/agtermTests/AppActionsTests.swift index e8da49d0..855a6ceb 100644 --- a/agtermTests/AppActionsTests.swift +++ b/agtermTests/AppActionsTests.swift @@ -34,6 +34,23 @@ final class AppActionsTests: XCTestCase { try await super.tearDown() } + // a window step must never reach the `openWindow` hub: for a target still attaching its raise fails, the + // hub falls back to enqueueClaim + a fresh scene, and one store ends up with two windows. No NSWindow is + // registered under XCTest, so every raise here fails — the state the guard exists for. + func testWindowStepNeverOpensASceneForAnUnattachedTarget() throws { + _ = library.newWindow(name: "second") + XCTAssertTrue(library.canStepWindows, "two open windows are needed for a step to have a target") + let before = library.frontmostWindowID + var opened: [WindowInfo.ID] = [] + actions.openWindow = { opened.append($0) } + + actions.selectNextWindow() + actions.selectPreviousWindow() + + XCTAssertEqual(opened, [], "an unraisable step must drop, not spawn a second scene for the store") + XCTAssertEqual(library.frontmostWindowID, before, "a step that did not raise must not move frontmost") + } + private var home: String { FileManager.default.homeDirectoryForCurrentUser.path } private func remoteActiveSession(reportedCwd: String) throws -> Session { diff --git a/agtermTests/SessionHostClientTests.swift b/agtermTests/SessionHostClientTests.swift index 49a0f49e..835ef898 100644 --- a/agtermTests/SessionHostClientTests.swift +++ b/agtermTests/SessionHostClientTests.swift @@ -192,9 +192,20 @@ final class SessionHostClientTests: XCTestCase { clients.append(pid) } + /// The host's pid, waiting for it to appear. The host writes the pidfile from its own process after + /// the client that started it returns, so a bare read races it — `waitForLeaders` proves the zmx + /// daemons are up, which is a different event. Unloaded the file is already there; under a full-suite + /// run it is not, and the read failed with ENOENT rather than waiting. func hostPID() throws -> Int32 { - let value = try String(contentsOfFile: paths.pidfile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) - return try XCTUnwrap(Int32(value)) + let deadline = Date().addingTimeInterval(10) + while true { + if let value = try? String(contentsOfFile: paths.pidfile, encoding: .utf8), + let pid = Int32(value.trimmingCharacters(in: .whitespacesAndNewlines)) { + return pid + } + guard Date() < deadline else { throw POSIXError(.ETIMEDOUT) } + Thread.sleep(forTimeInterval: 0.01) + } } func connectRaw() throws -> Int32 { diff --git a/agtermUITests/ControlWindowUITests.swift b/agtermUITests/ControlWindowUITests.swift index 208c76b8..6172a64d 100644 --- a/agtermUITests/ControlWindowUITests.swift +++ b/agtermUITests/ControlWindowUITests.swift @@ -1,7 +1,7 @@ import Darwin import XCTest -/// Control-channel e2e for the window commands (window.new/list/select/close/resize/move/zoom) and +/// Control-channel e2e for the window commands (window.new/list/select/go/close/resize/move/zoom) and /// the title-bar double-click / drag gestures, plus the window-scoped `tree`/list oracles. Subclass /// of `ControlAPITestCase`. @MainActor @@ -495,6 +495,42 @@ final class ControlWindowUITests: ControlAPITestCase { XCTAssertTrue(settled, "the remaining open window should become the single active window after closing the frontmost") } + // the step arithmetic (three windows, wrapping, skipping closed entries) is pinned host-free in + // `WindowLibraryTests`; this proves the wiring — the target is really raised and reads back active. + func testWindowGoRaisesTheOtherOpenWindow() throws { + let created = try sendCommand(#"{"cmd":"window.new","args":{"name":"go-peer"}}"#) + let windowB = try XCTUnwrap((created["result"] as? [String: Any])?["id"] as? String, "window.new should return the new id") + XCTAssertTrue(pollWindowList(timeout: 10) { $0.count == 2 }, "the second window should appear") + XCTAssertTrue(selectWindowUntilActive(windowB, timeout: 30), "the created window should become active") + + let stepped = try sendCommand(#"{"cmd":"window.go","args":{"to":"next"}}"#) + XCTAssertEqual(stepped["ok"] as? Bool, true, "window.go should succeed with two windows open: \(stepped)") + let landed = try XCTUnwrap((stepped["result"] as? [String: Any])?["id"] as? String, + "window.go should return the window it landed on") + XCTAssertNotEqual(landed.lowercased(), windowB.lowercased(), "a step must leave the window it started on") + XCTAssertTrue(pollWindowList(timeout: 10) { list in + list.first(where: { ($0["id"] as? String)?.lowercased() == landed.lowercased() })?["active"] as? Bool == true + }, "the window it landed on should read back active") + + let back = try sendCommand(#"{"cmd":"window.go","args":{"to":"prev"}}"#) + XCTAssertEqual(((back["result"] as? [String: Any])?["id"] as? String)?.lowercased(), windowB.lowercased(), + "stepping back should return to the window it came from: \(back)") + } + + func testWindowGoRefusesWithOneWindowAndOnABadDirection() throws { + XCTAssertTrue(pollWindowList(timeout: 10) { $0.filter { ($0["open"] as? Bool) == true }.count == 1 }, + "should start with the one seeded window open") + + let alone = try sendCommand(#"{"cmd":"window.go","args":{"to":"next"}}"#) + XCTAssertEqual(alone["ok"] as? Bool, false, "one open window has nowhere to step: \(alone)") + XCTAssertEqual(alone["error"] as? String, "no other open window to navigate to") + + let sideways = try sendCommand(#"{"cmd":"window.go","args":{"to":"sideways"}}"#) + XCTAssertEqual(sideways["error"] as? String, "window.go requires --to next|prev", "\(sideways)") + let missing = try sendCommand(#"{"cmd":"window.go"}"#) + XCTAssertEqual(missing["error"] as? String, "window.go requires --to next|prev", "\(missing)") + } + // MARK: - Window oracles /// Sends `window.list` and returns the windows array. diff --git a/plugins/agterm/skills/agterm/SKILL.md b/plugins/agterm/skills/agterm/SKILL.md index 2c8b3945..184c9724 100644 --- a/plugins/agterm/skills/agterm/SKILL.md +++ b/plugins/agterm/skills/agterm/SKILL.md @@ -479,7 +479,10 @@ omitted when expanded). and `surface zoom` will not address it. `session hud update`/`session hud close` with none up answer `no hud`. Read it back from the tree node's `hud` object; nothing announces it as an event, so poll `tree`. -**window** — `window new [name] [--minimized]` · `window list` · `window select ` · `window close ` · +**window** — `window new [name] [--minimized]` · `window list` · `window select ` · +`window go --to next|prev` (raise the next/previous OPEN window, wrapping; relative, so it takes no id, and a +closed bundle is not a stop — `window select` opens one. Errors with one window open. GUI twins: Navigate ▸ +Previous/Next Window and the keyless `previous_window`/`next_window` keymap actions) · `window close ` · `window rename ` · `window delete ` · `window resize --width W --height H` · `window move --x X --y Y [--display N]` · `window zoom ` (maximize-to-screen toggle, the double-click-header gesture; a plain green-button click does full screen) · diff --git a/plugins/agterm/skills/agterm/examples.md b/plugins/agterm/skills/agterm/examples.md index 15d2853b..cd3f0b5b 100644 --- a/plugins/agterm/skills/agterm/examples.md +++ b/plugins/agterm/skills/agterm/examples.md @@ -1037,6 +1037,7 @@ agtermctl window zoom "$w" # maximize-to-screen toggle (call aga agtermctl window fullscreen "$w" # native macOS full screen toggle (⌃⌘F / green button) agtermctl window minimize "$w" on # park it in the Dock (off restores, toggle flips) agtermctl window select "$w" # raise it, un-minimizing if it was parked +agtermctl window go --to next # raise the next OPEN window, wrapping (next|prev) ``` `window new` returns only once the window is really on screen, so the `window resize` above works on the diff --git a/plugins/agterm/skills/agterm/reference.md b/plugins/agterm/skills/agterm/reference.md index a3acb6f1..1b2f4ffd 100644 --- a/plugins/agterm/skills/agterm/reference.md +++ b/plugins/agterm/skills/agterm/reference.md @@ -101,6 +101,7 @@ SIGTERM use normal process behavior. tree to act on; default is the frontmost. With `--window` set, that window must be open. Without it, an id/prefix session target is matched across all open windows. - `window.*` commands take the window selector as a positional argument, default `active` (frontmost). + `window go` is the exception: it is relative to the active window and takes no selector. - A window need not be open to be a `window.*` target (e.g. `window select` opens a closed one). ## tree @@ -858,6 +859,12 @@ shell (no controlling terminal — `/dev/tty` errors). See examples.md for usage still reflects the last cache refresh, since a settings change is rare; and unlike `tree`, `window.list` does NOT carry `idleMs` — the live idle metric would freeze in the cache.) - `window select ` — raise it if open, else open it. +- `window go --to next|prev` — raise the next/previous OPEN window in library order, wrapping. Relative + to the active window, so it takes no id and no `--window`. Only open windows are stepped through: a + closed bundle is not a stop on the way round, and `window select` is what opens one. Returns the id it + landed on; errors `no other open window to navigate to` with a single window open. The GUI twins are + Navigate ▸ Previous/Next Window and the `previous_window`/`next_window` keymap actions, which ship + keyless. - `window close ` — close the on-screen window (the bundle is kept; reopen with select). - `window rename `. - `window delete ` — keep-at-least-one; deleting the last errors. @@ -1303,7 +1310,7 @@ Built-in action names for `map` include: `new_window`, `new_workspace`, `new_ses `focus_workspace`, `toggle_workspace_filter`, `quick_terminal`, `session_palette`, `command_palette`, `custom_command_palette`, `dashboard`, and the navigation actions (`previous_session`, `next_session`, `first_session`, `last_session`, `previous_attention_session`, `next_attention_session`, -`focus_left_pane`, `focus_right_pane`, `select_theme`). Editing the keymap from a terminal: open +`previous_window`, `next_window`, `focus_left_pane`, `focus_right_pane`, `select_theme`). Editing the keymap from a terminal: open `keymap.conf` in `$EDITOR`, then `agtermctl keymap reload`. ## config diff --git a/site/commands.html b/site/commands.html index e1fcbad0..61c634c2 100644 --- a/site/commands.html +++ b/site/commands.html @@ -1938,7 +1938,8 @@

These take the window selector as a positional argument (default active, the frontmost). A window need not be open - to be a target. + to be a target. window go is the + exception: it is relative to the active window and takes no selector.

@@ -1999,6 +2000,24 @@

Raise the window if open, else open it.

+
+
+ agtermctl window go --to next|prev +
+
window.go
+

+ Raise the next or previous open window, wrapping at both ends. Relative to the active window, so unlike its + neighbours it takes no window selector. Returns the id of the window it landed on. +

+

+ Only open windows are stepped through — a closed bundle is not a stop on the way round, and + window select is what opens one. With a single window open it + errors with no other open window to navigate to. The GUI twins are Navigate ▸ + Previous/Next Window and the previous_window/next_window + keymap actions, which ship with no key of their own. +

+
+
agtermctl window close <id> diff --git a/site/docs.html b/site/docs.html index 5f4aaee9..92a40f5e 100644 --- a/site/docs.html +++ b/site/docs.html @@ -1669,7 +1669,7 @@ color: #cbc6bc; " > - new_window   rename_window   delete_window
new_workspace   rename_workspace   delete_workspace
new_session   open_directory   rename_session   duplicate_session
close_session   reopen_recent   undo_close   clear_status
increase_font_size   decrease_font_size   reset_font_size
toggle_split   toggle_horizontal_split   toggle_scratch   toggle_search
toggle_sidebar   toggle_flag   toggle_flagged_view
focus_left_pane   focus_right_pane   focus_workspace   toggle_workspace_filter
toggle_workspace_collapse
previous_session   next_session   first_session   last_session
previous_workspace   next_workspace
previous_attention_session   next_attention_session
quick_terminal   session_palette   command_palette
custom_command_palette   show_attention
select_theme   toggle_fullscreen   toggle_terminal_zoom
dashboard + new_window   rename_window   delete_window
new_workspace   rename_workspace   delete_workspace
new_session   open_directory   rename_session   duplicate_session
close_session   reopen_recent   undo_close   clear_status
increase_font_size   decrease_font_size   reset_font_size
toggle_split   toggle_horizontal_split   toggle_scratch   toggle_search
toggle_sidebar   toggle_flag   toggle_flagged_view
focus_left_pane   focus_right_pane   focus_workspace   toggle_workspace_filter
toggle_workspace_collapse
previous_session   next_session   first_session   last_session
previous_workspace   next_workspace
previous_window   next_window
previous_attention_session   next_attention_session
quick_terminal   session_palette   command_palette
custom_command_palette   show_attention
select_theme   toggle_fullscreen   toggle_terminal_zoom
dashboard

toggle_fullscreen is the one @@ -2425,6 +2425,8 @@ --minimized  # create one, parked in the Dock
agtermctl window select "$w"  # raise it (opening if closed)
agtermctl window go --to next +     # raise the next open window, wrapping (next|prev)
agtermctl window rename "$w" personal
agtermctl window minimize "$w" on  # park it in the Dock (off restores)