diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md index 426cadf8a..4ab56ffef 100644 --- a/.claude/rules/control-api.md +++ b/.claude/rules/control-api.md @@ -109,7 +109,8 @@ paths: `refused` clears on a later successful acquire, since `start()` re-runs per window scene and the owner may have quit. Its `stop()` returns early without unlinking, leaving the owner's socket intact. - One newline-delimited JSON request and response uses each connection, capped at 1 MiB. Unknown commands - return structured errors. Mutations may return `result.id`; trees use `result.tree`. + return structured errors. Mutations may return `result.id`; trees use `result.tree`, or `result.trees` + for the `--all-windows` fan-out, which leaves the singular field nil. A decode failure reports the `DecodingError`'s CONTEXT `debugDescription`, not `localizedDescription`, so the error NAMES the rejected `cmd`. That is the only signal a caller gets that its agterm predates its agtermctl, and the reason a new command needs no version handshake. Read the context, never the error: @@ -161,8 +162,17 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi - `--to up|down|top|bottom` reorders one session in its workspace. - workspace relocates and appends. - `--after`/`--before` resolves an anchor across the store, carrying destination workspace. + - `--to-window` moves it to another OPEN window, optionally naming a workspace INSIDE that window. Relative placement uses host-free `SidebarDrop.resolveRelative`; batches use tree-order remove-first `resolveSessions`. Reject batch `--to`. Count only actual moves. A one-member batch uses singular behavior. +- `--to-window` is the DESTINATION; `--window` keeps meaning "where `--target` is searched" here and on + every other command, which is why the destination needed a second flag rather than a reused one. + It rejects `--to` (reorder is same-workspace) and `--after`/`--before` (anchors resolve within one store), + and it is the only form accepting `--select`, since the destination's selection belongs to another + store — every other form rejects it with `session.move --select requires --to-window` rather than + silently dropping it. + A closed destination errors with `window not open — window.select it first`; [[windows]] owns the move + itself, whose form parsing lives in `ControlDispatcher+SessionMove`. - Sidebar batch Flag computes one uniform value: flag all unless all are already flagged. This is not equivalent to repeated toggle; scripts read state then loop on/off. Batch Clear Status is equivalent to repeated `session.status idle` and needs no batch command. @@ -598,7 +608,17 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi `AGT_*` context only). That is why a recipe preflight uses `agtermctl version` rather than the variable. - Session nodes include foreground/split foreground argv, background spec, overlay size, pane overlays, - split axis, split ratio, split focus, status fields, flag, unseen, restore pins, surfaces, and `realized`. + split axis, split ratio, split focus, status fields, flag, unseen, restore pins, surfaces, `realized`, + and the `windowId`/`workspaceId` ownership stamp. +- `windowId`/`workspaceId` on a session node, and `windowId`/`windowName` on the tree top level, are one + ALL-OR-NOTHING stamp: `AppStore.controlTree` takes the window id from the app target, and a host-free + projection with none omits every one rather than answering half of "who owns this session". They exist + because `AGTERM_WINDOW_ID`/`AGTERM_WORKSPACE_ID` are spawn-time snapshots that any move makes stale, and + nothing can rewrite a live process's environ. +- `tree --all-windows` returns `result.trees`, one tree per OPEN window, and leaves `result.tree` nil; + it is rejected together with `--window`, which names a single one. The fan-out is host-free + `WindowLibrary.openTrees`. This is the one call that answers ownership for a session in ANY window, + which is why `window.list` — cached, refreshed on events — is the wrong home for it. - `realized` reports the MAIN pane's `TerminalSurface.isRealized`, populated host-free in `AppStore.controlTree` (no app closure — `isRealized` is on the protocol) and false for an empty slot, so only a server predating the field omits it. It exists because `session.new` answers `ok` for a model diff --git a/.claude/rules/notifications.md b/.claude/rules/notifications.md index 0942184bf..0f4a796b1 100644 --- a/.claude/rules/notifications.md +++ b/.claude/rules/notifications.md @@ -14,7 +14,29 @@ paths: `UNUserNotificationCenterDelegate`. It resolves `Session` and `PaneRole` by surface identity, applies suppression, always increments `unseenCount`, and posts only when `bannersEnabled`. Authorization is best-effort; request `[.alert, .badge, .sound]` from the scene task. `willPresent` returns - `[.banner, .list, .sound]`. `clearDelivered` removes all three pane IDs on focus. + `[.banner, .list, .sound]`, except for one `TerminalNotification.isStale` rejects — delivered after its + session changed windows — which is dropped and removed instead. +- A cross-window move retires the session's banners through `retireBanners(forMovedSession:destinationWindowID:)`: + they carry the source window's id, and a click on one left behind would reopen the window the session left. + Both that sweep and focus's `clearDelivered` match the delivered set by session id, never by rebuilding + identifiers from the current window, which would match none of them. The delivered set is queried + asynchronously, so `TerminalNotification.shouldSweep` spares what the sweep's own window still owns, + anything delivered after the sweep started, and any identity re-posted after it — else a move's sweep + overtaken by a later move, or a focus clear, takes a banner that arrived after it, or removes by + identifier the newer banner that replaced one its query named. `lastPostedAt` records each submission and + the query's result is filtered back on the main actor against it; the map clears once no sweep is in + flight, since only one can be spared by a record. The move also records the destination per session, + which is what `windowID(forSession:)` stops answering once that window closes. + `openWindowID(forSession:)` is where a live window contradicts a record, so every caller seeing an open + owner — `notify` and `send` included — drops it while a window still can, and `currentWindowID(forSession:)` + falls back to the record only when none does. The sweep is the records' garbage collection: + `TerminalNotification.retainedMoveRecords` keeps those with a delivered banner left to retarget plus the + `unsettledSessions` — every session whose submission or sweep is still outstanding, including concurrent + moves, whose banners no snapshot names yet — so moved-then-closed sessions cannot accumulate. + The sweep sees only what is already delivered (`add` confirms scheduling, not delivery), so three + gaps close elsewhere against that: `post(identity:content:sessionID:)` retires its own request when the add + was still in flight, `willPresent` drops one delivered after the sweep, and `didReceive` — the only hook a + background delivery reaches — reveals the session's current window rather than the one its identity names. - `send(toSession:)`, used by `notify`, shares badge, banner, bounce, sound, and identity behavior but deliberately skips focus suppression and attributes the request to `.main`. - Log every post and suppression at `.notice`, including the focus and banners-off gates (#286). @@ -26,8 +48,8 @@ paths: - Suppress only when `TerminalNotification.shouldDeliver` sees both an active app and the firing surface as the key window's first responder. Do not use `AppActions.focusedSurface()`: its active-session fallback mistakes sidebar focus for viewing the pane. -- `TerminalNotification.identity` encodes `":"`, coalescing repeats and carrying the - click target without `userInfo`. `didReceive` activates the app and calls `AppActions.reveal`: select +- `TerminalNotification.identity` encodes `"::"`, coalescing repeats and + carrying the click target without `userInfo`. `didReceive` activates the app and calls `AppActions.reveal`: select the session, clear its badge, derive its workspace, focus the pane, and raise its window through `WindowRegistry.raise`, which deminiaturizes first. Unknown sessions only activate; a missing split falls back to primary. Activation and first-responder changes do not order a background window front. diff --git a/.claude/rules/sidebar.md b/.claude/rules/sidebar.md index ecfba7953..a45c8a2ab 100644 --- a/.claude/rules/sidebar.md +++ b/.claude/rules/sidebar.md @@ -38,6 +38,10 @@ paths: - The footer provides workspace creation and New Session/Open Directory. Workspace row menus repeat the session actions. Hover shows `workspace-add-session` only when `InterfaceElement.workspaceAddSession` is enabled. +- The session row menu carries "Move to Window" beside "Move to": one item per OTHER open window from + `WindowLibrary.moveDestinations`, absent entirely when there is none. It reuses `SessionBatchRequest` so a + multi-row selection moves as one block, and routes through `AppActions`, not the window-local store, + because the move spans two stores. [[windows]] owns what the move itself guarantees. - A workspace-row click toggles expansion through the outline action, excluding the disclosure frame. Defer by `NSEvent.doubleClickInterval` and cancel on double-click so rename does not flicker through a toggle. This click routing is keep-in-sync exempt. `GhosttyApp.workspaceRowClickExpands` (default on) diff --git a/.claude/rules/windows.md b/.claude/rules/windows.md index 336bd5178..9910d0fe2 100644 --- a/.claude/rules/windows.md +++ b/.claude/rules/windows.md @@ -19,7 +19,8 @@ paths: A window is a named, persisted workspace/session bundle rendered in exactly one macOS window. One bundle never appears in two windows, and one window never holds two bundles. Shared live state and cross-window -session drag are out of scope. +session DRAG are out of scope. A cross-window MOVE is supported and keeps 1:1: the session leaves one +bundle and joins another, carrying its live shell. - The Dock menu snapshots the last-active store and strongly retains item targets because `NSMenuItem.target` is weak. Invalidate previous targets on rebuild. Every item except New Window keeps its captured scope, @@ -48,6 +49,30 @@ session drag are out of scope. `window N`, all opened, and the first made frontmost. Missing/corrupt window snapshots open with a default workspace/session. The library is never empty after launch. +## Cross-window session move + +- `WindowLibrary.moveSession(_:toWindow:workspace:select:)` transfers one live `Session` INSTANCE between + two stores through `AppStore.detachSession`/`adoptSession`, so its surface and shell survive. + A same-window call delegates to `AppStore.moveSession`, keeping the single-window path unforked. +- The destination must be OPEN. A closed window has no mounted deck and scene IDs come from a FIFO queue, + so the moved surface would land with no host; refuse with `window not open — window.select it first`. +- Evict the SOURCE window's `TerminalZoomRegistry` and `DashboardControllerRegistry` entries for the + session before detaching, and refuse while its `PickRegistry` pick is pending — otherwise the source + window keeps a zoom target or grid cell pointing at an NSView another window now hosts. +- The move neither selects nor raises; `select:` opts into selecting it in the destination. + `moveDestinations(excluding:)` is the shared "other open window" gate behind the sidebar submenu and the + palette rows, so one window open means no entry point rather than an empty one. +- A successful cross-window adopt MUST run `rebindAdoptedSession`, the app-set hook re-pointing the moved + session's surfaces at the destination store. No surface factory re-runs (the instance and its views + survive), so every callback still holds the SOURCE store and would resolve the session to nil there: + shell exit, overlay teardown and exit status, unseen/status clears, search and font size all no-op. +- No AppKit work is involved: `dismantleNSView` is a no-op, `makeNSView` reuses `session.surface`, and + `viewDidMoveToWindow` re-pushes scale and size, so a re-host re-rasterizes at the destination's scale. + A blank or mis-scaled pane is a deck mount-order bug, never a reason to add teardown. +- `openTrees(_:)` projects one tree per open window, in library order, for `tree --all-windows`. +- A moved shell keeps its spawn-time `AGTERM_WINDOW_ID`/`AGTERM_WORKSPACE_ID`: nothing can rewrite a live + process's environ. Ownership is read from the tree instead; see [[control-api]]. + ## Scene lifecycle - Use plain `WindowGroup(id: "terminal")`, not value-based `WindowGroup(for:)`: with restoration off, diff --git a/CLAUDE.md b/CLAUDE.md index 4440bc9be..7ef195ebd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,7 +227,8 @@ spans intact, and format long catalogs as lists. - `sidebar.md`: outline, reorder, flagged/focus views, scoped navigation, reconciliation, persistence. - `menu-actions.md`: actions, menus, split panes, navigation, palettes, MRU, rename, search. -- `windows.md`: window library, restoration, quick terminal, active-store resolution, quit, controls. +- `windows.md`: window library, restoration, quick terminal, active-store resolution, quit, controls, + cross-window session move. - `control-api.md`: protocol layers, catalog, addressing, CLI/hooks/skill installers. - `settings.md`: settings model/UI, Ghostty config emission, translucency. - `theme-picker.md`: preview/commit/cancel and seeded default. diff --git a/agterm/AppActions+Batch.swift b/agterm/AppActions+Batch.swift index e7cc48a76..16bbb6d8a 100644 --- a/agterm/AppActions+Batch.swift +++ b/agterm/AppActions+Batch.swift @@ -39,6 +39,23 @@ extension AppActions { return alert.runModal() == .alertFirstButtonReturn } + /// Move one session to another OPEN window, carrying its live shell. Cross-window, so it goes through + /// the library rather than a store, and the modal gate reads the SOURCE window — a background sidebar's + /// menu must not be judged by whatever the frontmost window has up. The destination's selection is left + /// alone, matching the control API's plain `--to-window`. + @discardableResult + func moveSession(_ sessionID: UUID, toWindow windowID: WindowInfo.ID) -> Bool { + guard uiActionsEnabled(for: library.windowID(forSession: sessionID)) else { return false } + return library.moveSession(sessionID, toWindow: windowID) + } + + /// Batch form for a multi-row sidebar selection; returns how many moved. Order is preserved because each + /// session appends to the destination workspace in turn. + @discardableResult + func moveSessions(_ sessionIDs: [UUID], toWindow windowID: WindowInfo.ID) -> Int { + sessionIDs.reduce(0) { moved, id in moved + (moveSession(id, toWindow: windowID) ? 1 : 0) } + } + /// sidebar context menus pass their own store so a background window never routes through the /// frontmost store by accident. func toggleFlags(_ sessionIDs: [UUID], in store: AppStore) { diff --git a/agterm/AppActions+Palette.swift b/agterm/AppActions+Palette.swift index acac64ddb..790f23f32 100644 --- a/agterm/AppActions+Palette.swift +++ b/agterm/AppActions+Palette.swift @@ -186,6 +186,14 @@ extension AppActions { self?.moveSession(sessionID, toWorkspace: target) }) } + // one "Move Session to Window: " per OTHER open window; with a single window there is + // no destination and the palette lists none, matching the sidebar row's absent submenu. + for window in library.moveDestinations(excluding: library.windowID(for: store)) { + let target = window.id + items.append(PaletteItem(id: "move-window-\(target)", title: "Move Session to Window: \(window.name)") { [weak self] in + self?.moveSession(sessionID, toWindow: target) + }) + } } items.append(contentsOf: customCommandItems(badge: "custom")) return items diff --git a/agterm/Control/ControlServer+AppCommands.swift b/agterm/Control/ControlServer+AppCommands.swift index c0f98bcb7..11e39d94c 100644 --- a/agterm/Control/ControlServer+AppCommands.swift +++ b/agterm/Control/ControlServer+AppCommands.swift @@ -7,8 +7,14 @@ import agtermCore /// reload, theme slots, the app-wide quick terminal. Split out of the session-, workspace- and surface-scoped /// `ControlServer+SessionActions.swift` for the file size limit. extension ControlServer { - func controlTree(window: String?) -> ControlResponse { - resolver.resolvePlacementStore(window) { store in + /// `--all-windows` projects every OPEN window into `trees` (leaving `tree` nil) instead of resolving one; + /// the dispatcher has already refused it alongside `--window`. + func controlTree(window: String?, allWindows: Bool) -> ControlResponse { + if allWindows { + return ControlResponse(ok: true, + result: ControlResult(trees: library.openTrees { buildTree(in: $0) })) + } + return resolver.resolvePlacementStore(window) { store in ControlResponse(ok: true, result: ControlResult(tree: buildTree(in: store))) } } diff --git a/agterm/Control/ControlServer+SessionActions.swift b/agterm/Control/ControlServer+SessionActions.swift index 9981ee783..1d1fc1525 100644 --- a/agterm/Control/ControlServer+SessionActions.swift +++ b/agterm/Control/ControlServer+SessionActions.swift @@ -505,8 +505,10 @@ extension ControlServer: ControlActions { /// Mode-bearing `session.move`: `to` (`up`|`down`|`top`|`bottom`) reorders within the session's own /// workspace, `workspace` relocates to another one (appending), `place` relocates + positions against an - /// anchor session (which carries its own workspace). Exactly one form, enforced in the dispatcher. - func moveSession(_ target: String?, window: String?, move: ControlSessionMove) -> ControlResponse { + /// anchor session (which carries its own workspace), `window` transfers the live session to another + /// OPEN window. Exactly one form, enforced in the dispatcher. + func moveSession(_ target: String?, window: String?, move: ControlSessionMove, + select: Bool) -> ControlResponse { switch move { case .reorder(let dir): return resolver.resolveSession(target, window: window) { store, id in @@ -524,10 +526,17 @@ extension ControlServer: ControlActions { } case .place(let anchor, let after): return placeSession(target, window: window, anchor: anchor, after: after) + case .window(let destination, let workspace): + return resolver.resolveSession(target, window: window) { _, sessionID in + moveToWindow([sessionID], destination: destination, workspace: workspace, select: select) { + ControlResponse(ok: true, result: ControlResult(id: sessionID.uuidString)) + } + } } } - func moveSessions(_ targets: [String], window: String?, move: ControlSessionMove) -> ControlResponse { + func moveSessions(_ targets: [String], window: String?, move: ControlSessionMove, + select: Bool) -> ControlResponse { switch move { case .reorder: return ControlResponse(ok: false, error: "session.move --target can be repeated only with a workspace or --after/--before") @@ -541,9 +550,45 @@ extension ControlServer: ControlActions { } case .place(let anchor, let after): return placeSessions(targets, window: window, anchor: anchor, after: after) + case .window(let destination, let workspace): + return resolveBatchSessions(targets, window: window) { _, ids in + moveToWindow(ids, destination: destination, workspace: workspace, select: select) { + ControlResponse(ok: true, result: ControlResult(affected: ids.count)) + } + } } } + /// Cross-window transfer of an already-resolved block, in target order. `window` stayed the SEARCH + /// scope for the targets; the destination workspace resolves against the DESTINATION store, the only + /// one that has it. `select` follows the LAST moved session, matching the in-store selection rule. + private func moveToWindow(_ ids: [UUID], destination: String, workspace: String?, select: Bool, + _ success: () -> ControlResponse) -> ControlResponse { + resolver.resolveWindowID(destination) { destinationID in + guard let store = library.store(for: destinationID) else { + return ControlResponse(ok: false, error: "window not open — window.select it first") + } + return resolveDestinationWorkspace(workspace, in: store) { workspaceID in + for (index, id) in ids.enumerated() { + let last = index == ids.count - 1 + guard library.moveSession(id, toWindow: destinationID, workspace: workspaceID, + select: select && last) else { + return ControlResponse(ok: false, error: "cannot move session to that window") + } + } + return success() + } + } + } + + /// The destination workspace inside the destination store, or nil for its current one. + private func resolveDestinationWorkspace(_ workspace: String?, in store: AppStore, + _ body: (UUID?) -> ControlResponse) -> ControlResponse { + guard let workspace else { return body(nil) } + return resolver.resolve(workspace, candidates: store.workspaces.map(\.id), + active: store.currentWorkspaceID, noun: "workspace") { body($0) } + } + /// Resolve the moved session and its anchor in one store, then relocate + position via the host-free /// `SidebarDrop.resolveRelative` math. A nil resolution (anchor==self, already in place) is a no-op. private func placeSession(_ target: String?, window: String?, anchor: String, after: Bool) -> ControlResponse { diff --git a/agterm/Control/ControlServer.swift b/agterm/Control/ControlServer.swift index c8d0d2641..f1acddc52 100644 --- a/agterm/Control/ControlServer.swift +++ b/agterm/Control/ControlServer.swift @@ -691,7 +691,9 @@ final class ControlServer { case .untouched: return "untouched" } }, - app: identity + app: identity, + windowID: windowID?.uuidString, + windowName: windowID.map { library.windowName(for: $0) } ) } diff --git a/agterm/Notifications/NotificationManager.swift b/agterm/Notifications/NotificationManager.swift index d52e8a7df..9f2d30cc0 100644 --- a/agterm/Notifications/NotificationManager.swift +++ b/agterm/Notifications/NotificationManager.swift @@ -30,6 +30,24 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen /// no-op while agterm is the active app, so a bounce only fires for one arriving in the background. var dockBounce: DockBounce = .off + /// The window each moved session landed in, keyed by session. `windowID(forSession:)` answers only for + /// open windows, so this is what still tells a move from a plain window close once the destination has + /// closed too. Dropped once an open window other than the recorded one owns the session, and swept with + /// the banners it exists to retarget, so a session closed after its move leaves nothing behind. + private var movedSessionWindows: [UUID: UUID] = [:] + + /// When each banner identity was last submitted, and how many sweeps are still waiting on their + /// delivered-set query. An identity is reusable, so a banner posted after a sweep's query can replace + /// one that query named; removing by identifier would then take the newer banner. Only an in-flight + /// sweep can be spared by a record, so the map holds only what was posted while one was in flight. + private var lastPostedAt: [String: Date] = [:] + private var sweepsInFlight = 0 + + /// Sessions with a banner submission or a sweep still outstanding, counted so concurrent ones nest. No + /// delivered set names their banners yet an `add` completion or a click can still ask where the session + /// lives, so a sweep must keep their move records however empty its own snapshot looks. + private var unsettledSessions: [UUID: Int] = [:] + /// Name of the system sound attached to a delivered notification (the Notifications settings picker, /// default nil = silent, set by `SettingsModel`). Attached as `UNNotificationSound` on the banner content, /// NOT played directly, so it follows the banner: gated by `bannersEnabled` and the macOS notification @@ -55,7 +73,7 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen guard let session = surface.session else { return } let pane = paneRole(of: surface, in: session) // the firing surface is always in an open window at fire time, so its window id is known. - guard let windowID = library?.windowID(forSession: session.id) else { + guard let windowID = openWindowID(forSession: session.id) else { logger.notice("notify: no open window owns session \(session.id, privacy: .public); dropping") return } @@ -89,9 +107,7 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen // the request identifier is the identity (`::`): it coalesces repeats from // the same pane and carries the target a click decodes. let identity = TerminalNotification.identity(windowID: windowID, sessionID: session.id, pane: pane) - UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: identity, content: content, trigger: nil)) { error in - if let error { logger.error("add failed: \(error.localizedDescription, privacy: .public)") } - } + post(identity: identity, content: content, sessionID: session.id) } /// Post a desktop notification for a session via the control `notify` command rather than a terminal OSC. @@ -100,7 +116,7 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen /// nothing sent, when no open window owns the session (no click-reveal identity to build). @discardableResult func send(toSession session: Session, title: String, body: String) -> Bool { - guard let windowID = library?.windowID(forSession: session.id) else { return false } + guard let windowID = openWindowID(forSession: session.id) else { return false } guard let effectiveTitle = library?.store(forSession: session.id)?.recordNotificationEvent( forSession: session.id, title: title, body: body ) else { return false } @@ -119,21 +135,132 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen content.body = body content.sound = notificationSound let identity = TerminalNotification.identity(windowID: windowID, sessionID: session.id, pane: .main) - UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: identity, content: content, trigger: nil)) { error in - if let error { logger.error("send failed: \(error.localizedDescription, privacy: .public)") } - } + post(identity: identity, content: content, sessionID: session.id) return true } + /// Submit the banner request, then retire it if the session changed windows while the add was in flight. + /// `retireBanners` on a move only sees what is already delivered, so a request submitted a beat earlier + /// would survive it and send a click to the window the session just left. + private func post(identity: String, content: UNNotificationContent, sessionID: UUID) { + // a sweep starting later has a cutoff past this post, so with none in flight no record can spare + // anything: drop them here rather than let notification traffic alone grow the map. + if sweepsInFlight == 0 { lastPostedAt.removeAll() } + lastPostedAt[identity] = Date() + beginUnsettled(sessionID) + let request = UNNotificationRequest(identifier: identity, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) { error in + DispatchQueue.main.async { + NotificationManager.shared.finishPost(identity: identity, sessionID: sessionID, error: error) + } + } + } + + /// Release the submission's hold, then retire the banner if the session changed windows while the add + /// was in flight. + private func finishPost(identity: String, sessionID: UUID, error: (any Error)?) { + endUnsettled(sessionID) + if let error { + logger.error("banner add failed: \(error.localizedDescription, privacy: .public)") + return + } + let current = currentWindowID(forSession: sessionID) + guard TerminalNotification.isStale(identity: identity, currentWindowID: current) else { return } + let center = UNUserNotificationCenter.current() + center.removeDeliveredNotifications(withIdentifiers: [identity]) + center.removePendingNotificationRequests(withIdentifiers: [identity]) + } + + private func beginUnsettled(_ sessionID: UUID) { + unsettledSessions[sessionID, default: 0] += 1 + } + + private func endUnsettled(_ sessionID: UUID) { + guard let count = unsettledSessions[sessionID] else { return } + if count > 1 { unsettledSessions[sessionID] = count - 1 } else { unsettledSessions.removeValue(forKey: sessionID) } + } + + /// Retire the moved session's banners that predate the move: they carry the SOURCE window's identity, so a + /// click would reopen the window the session just left. Recording the destination lets an `add` still in + /// flight, a later delivery, and a click all retarget the session even after that destination closes. + func retireBanners(forMovedSession sessionID: UUID, destinationWindowID: UUID) { + movedSessionWindows[sessionID] = destinationWindowID + removeDelivered(sessionID: sessionID, staleRelativeTo: destinationWindowID) + } + + /// The open window owning a session, and the one place a stale move record is dropped: an open owner + /// other than the recorded destination means the session reached it without a move (Open Recent, + /// restore). Every caller that observes an owner comes through here, so the record goes while a window + /// still contradicts it rather than once both have closed and only the record answers. + private func openWindowID(forSession sessionID: UUID) -> UUID? { + guard let open = library?.windowID(forSession: sessionID) else { return nil } + if movedSessionWindows[sessionID] != open { movedSessionWindows.removeValue(forKey: sessionID) } + return open + } + + /// The window hosting a session now: its open owner, else the window it was last moved into. Nil when + /// neither answers — a session whose window merely closed, whose banner still reopens that window. + private func currentWindowID(forSession sessionID: UUID) -> UUID? { + guard let open = openWindowID(forSession: sessionID) else { + // the recorded destination is open yet no longer holds the session, so the session left it by a + // route that never records one (closed, reopened elsewhere): the record is answering for a + // window it no longer knows, and nil — "cannot tell" — is the honest answer. + if let recorded = movedSessionWindows[sessionID], library?.isOpen(recorded) == true { + movedSessionWindows.removeValue(forKey: sessionID) + return nil + } + return movedSessionWindows[sessionID] + } + return open + } + /// Remove a session's delivered banners from Notification Center on focus, so one you navigated to doesn't - /// linger. Removes every pane's identifier. No-op when the session's window isn't open. + /// linger. func clearDelivered(sessionID: UUID) { - guard let windowID = library?.windowID(forSession: sessionID) else { - logger.debug("clearDelivered: no open window owns session \(sessionID, privacy: .public); nothing to clear") - return + removeDelivered(sessionID: sessionID, staleRelativeTo: nil) + } + + /// Remove a session's delivered banners, matched by session id rather than by rebuilding identifiers from + /// the current window: a cross-window move leaves banners keyed to the SOURCE window, which the + /// destination's identifiers would never match. `staleRelativeTo` spares the ones that window still owns, + /// and the `cutoff` everything delivered after this sweep started; `TerminalNotification.shouldSweep` + /// owns both rules. + private func removeDelivered(sessionID: UUID, staleRelativeTo windowID: UUID?) { + let cutoff = Date() + sweepsInFlight += 1 + beginUnsettled(sessionID) + UNUserNotificationCenter.current().getDeliveredNotifications { delivered in + let entries = delivered.map { (identity: $0.request.identifier, deliveredAt: $0.date) } + DispatchQueue.main.async { + let manager = NotificationManager.shared + let identifiers = manager.sweepable(entries, sessionID: sessionID, staleRelativeTo: windowID, + cutoff: cutoff) + guard !identifiers.isEmpty else { return } + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + } + } + } + + /// The delivered banners this sweep may take, judged against `lastPostedAt` on the main actor so a + /// banner posted since the query cannot be removed by the identifier it reused. Also the move records' + /// only garbage collection — the delivered set plus the unsettled sessions is what says which are still + /// reachable. Ends the sweep's in-flight window: with none left, no record can spare anything again. + private func sweepable(_ delivered: [(identity: String, deliveredAt: Date)], sessionID: UUID, + staleRelativeTo windowID: UUID?, cutoff: Date) -> [String] { + defer { + endUnsettled(sessionID) + sweepsInFlight -= 1 + if sweepsInFlight == 0 { lastPostedAt.removeAll() } } - let identifiers = PaneRole.allCases.map { TerminalNotification.identity(windowID: windowID, sessionID: sessionID, pane: $0) } - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + movedSessionWindows = TerminalNotification.retainedMoveRecords( + movedSessionWindows, delivered: delivered.map(\.identity), unsettled: Set(unsettledSessions.keys) + ) + return delivered.map { + TerminalNotification.DeliveredBanner(identity: $0.identity, deliveredAt: $0.deliveredAt, + lastPostedAt: lastPostedAt[$0.identity]) + }.filter { + TerminalNotification.shouldSweep($0, sessionID: sessionID, staleRelativeTo: windowID, cutoff: cutoff) + }.map(\.identity) } /// Post a failure banner for a custom command that exited non-zero or failed to spawn. Not tied to a @@ -210,8 +337,19 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen /// Present banners, with their attached sound, even while agterm is active — the focused-pane case is /// dropped before delivery. Without `.sound` a foreground banner is silent, so a session you are NOT /// looking at would only ding while backgrounded. - func userNotificationCenter(_: UNUserNotificationCenter, willPresent _: UNNotification, + func userNotificationCenter(_: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + // delivery lands after `add` reports success, so a banner can arrive once its session already moved: + // drop it here rather than send a click to the window it names. + let identifier = notification.request.identifier + let current = TerminalNotification.parseIdentity(identifier).flatMap { currentWindowID(forSession: $0.sessionID) } + if TerminalNotification.isStale(identity: identifier, currentWindowID: current) { + completionHandler([]) + DispatchQueue.main.async { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [identifier]) + } + return + } completionHandler([.banner, .list, .sound]) } @@ -222,6 +360,9 @@ final class NotificationManager: NSObject, @preconcurrency UNUserNotificationCen defer { completionHandler() } NSApp.activate(ignoringOtherApps: true) guard let target = TerminalNotification.parseIdentity(response.notification.request.identifier) else { return } - actions?.reveal(windowID: target.windowID, sessionID: target.sessionID, pane: target.pane) + // a banner delivered while agterm was in the background misses `willPresent`, so it can still name the + // window a moved session left: click through to wherever that session lives now. + let windowID = currentWindowID(forSession: target.sessionID) ?? target.windowID + actions?.reveal(windowID: windowID, sessionID: target.sessionID, pane: target.pane) } } diff --git a/agterm/Views/WorkspaceSidebar+ContextMenu.swift b/agterm/Views/WorkspaceSidebar+ContextMenu.swift index 527284f9b..e27a5be7a 100644 --- a/agterm/Views/WorkspaceSidebar+ContextMenu.swift +++ b/agterm/Views/WorkspaceSidebar+ContextMenu.swift @@ -114,6 +114,21 @@ extension WorkspaceSidebar.Coordinator { moveTo.submenu = submenu menu.addItem(moveTo) } + // "Move to Window" carries the live shell into another OPEN window; a closed one has no deck + // to host the surface, and with no other window open the submenu is absent rather than empty. + let windowTargets = actions.library.moveDestinations(excluding: actions.library.windowID(for: store)) + if !windowTargets.isEmpty { + let moveToWindow = NSMenuItem(title: "Move to Window", action: nil, keyEquivalent: "") + let submenu = NSMenu() + for target in windowTargets { + let item = NSMenuItem(title: target.name, action: #selector(menuMoveToWindow(_:)), keyEquivalent: "") + item.target = self + item.representedObject = SessionBatchRequest(sessionIDs: sessionTargets, targetID: target.id) + submenu.addItem(item) + } + moveToWindow.submenu = submenu + menu.addItem(moveToWindow) + } // "Flag"/"Unflag" toggles flagged working-set membership; the label reflects the current state. let allFlagged = !sessionTargets.isEmpty && sessionTargets.allSatisfy { store.session(withID: $0)?.flagged == true } let flagTitle: String @@ -214,6 +229,12 @@ extension WorkspaceSidebar.Coordinator { store.moveSessions(request.sessionIDs, toWorkspace: targetID) } + @objc private func menuMoveToWindow(_ sender: NSMenuItem) { + guard let request = sender.representedObject as? SessionBatchRequest, let targetID = request.targetID else { return } + // cross-window, so this goes through `actions` rather than this sidebar's window-local store. + actions.moveSessions(request.sessionIDs, toWindow: targetID) + } + @objc private func menuClose(_ sender: NSMenuItem) { guard let request = sender.representedObject as? SessionBatchRequest else { return } // pass THIS sidebar's window-local store — a background window's Close must target its own diff --git a/agterm/agtermApp.swift b/agterm/agtermApp.swift index b350f075d..999bc94b5 100644 --- a/agterm/agtermApp.swift +++ b/agterm/agtermApp.swift @@ -142,6 +142,18 @@ struct agtermApp: App { // bind the app-level quick terminal (idempotent); its env needs the bound socket, so // this follows `start()` like the surface environment does. wireQuickTerminal(library: library) + // a cross-window move keeps the Session instance and its surfaces, so no factory + // re-runs in the destination: hand the library the app-side rebind (idempotent). + library.rebindAdoptedSession = { [weak library] session, store in + guard let library else { return } + Self.rebindSurfaces(of: session, to: store, library: library) + // banners fired before the move carry the SOURCE window's identity, so a later + // click would reopen the window the session just left; retire them. + if let destination = library.windowID(for: store) { + NotificationManager.shared.retireBanners(forMovedSession: session.id, + destinationWindowID: destination) + } + } // Ctrl-Tab session-switcher key monitors (idempotent). sessionSwitcher.start() // Ctrl-1/Ctrl-2 direct pane-focus key monitor (idempotent). @@ -257,14 +269,25 @@ struct agtermApp: App { command: plan.command, initialInput: plan.initialInput, waitAfterCommand: session.commandWait, env: env) view.session = session - let sessionID = session.id + Self.wirePane(view, store: store, sessionID: session.id, library: library, persistsFontSize: true) + return view + } + + /// Every store-bound callback of a main or split pane, in one place so `rebindSurfaces` can re-apply the + /// exact set after a cross-window move. `persistsFontSize` marks the main slot: `makeSplitSurface` omits + /// `onFontSizeChange` because only the primary persists its cmd +/-. + @MainActor + private static func wirePane(_ view: GhosttySurfaceView, store: AppStore, sessionID: UUID, + library: WindowLibrary, persistsFontSize: Bool) { view.onExit = { [weak view] in guard let view else { return } Self.handlePaneExit(view, store: store, sessionID: sessionID, library: library) } - view.onFocusChange = { focused in + view.onFocusChange = { [weak view] focused in guard focused else { return } - store.session(withID: sessionID)?.splitFocused = false + // read the LIVE role: a promoted survivor keeps this closure with `isSplitPane` cleared and as the + // main pane must not re-raise `splitFocused`, which masks its migrated title and mis-routes focus. + store.session(withID: sessionID)?.splitFocused = view?.isSplitPane ?? false // focusing a pane means you've seen the session: clear the badge and any delivered banners. store.clearUnseen(sessionID) NotificationManager.shared.clearDelivered(sessionID: sessionID) @@ -277,9 +300,38 @@ struct agtermApp: App { } Self.wireStatusClear(view, store: store, sessionID: sessionID) view.onUserInput = { store.noteUserActivity() } - view.onFontSizeChange = { store.setFontSize(sessionID, $0) } + if persistsFontSize { view.onFontSizeChange = { store.setFontSize(sessionID, $0) } } Self.wireSearchCallbacks(view, store: store, sessionID: sessionID, library: library) - return view + } + + /// Re-points a moved session's live surfaces at the store that now owns it. `WindowLibrary.moveSession` + /// keeps the `Session` instance so the shells survive, and `TerminalView` reuses the cached surface, so + /// no factory re-runs in the destination window — without this every callback would keep resolving the + /// session against the source store, find nothing, and silently no-op (shell exit, overlay teardown and + /// exit status, unseen/status clears, search, font size). + /// Also drops any transient dashboard font override: the source window's sweep runs over its OWN store + /// after the session already left it, so nothing else would ever restore the session's real font. + @MainActor + static func rebindSurfaces(of session: Session, to store: AppStore, library: WindowLibrary) { + let sessionID = session.id + if let view = session.surface as? GhosttySurfaceView { + view.dashboardFontOverride = nil + wirePane(view, store: store, sessionID: sessionID, library: library, persistsFontSize: true) + } + if let view = session.splitSurface as? GhosttySurfaceView { + view.dashboardFontOverride = nil + wirePane(view, store: store, sessionID: sessionID, library: library, persistsFontSize: false) + } + if let view = session.overlaySurface as? GhosttySurfaceView { + wireOverlay(view, store: store, sessionID: sessionID, pane: nil, isHud: session.hudActive) + } + for pane in OverlayPane.allCases { + guard let view = session.paneOverlaySurface(pane) as? GhosttySurfaceView else { continue } + wireOverlay(view, store: store, sessionID: sessionID, pane: pane, isHud: false) + } + if let view = session.scratchSurface as? GhosttySurfaceView { + wireScratch(view, store: store, sessionID: sessionID, library: library) + } } /// Shell-exit handler for BOTH pane factories, dispatched on the surface's CURRENT role, not the factory that @@ -409,27 +461,7 @@ struct agtermApp: App { fontSize: session.fontSize.map(Float.init), initialInput: restoreInput, env: env) view.session = session view.isSplitPane = true - let sessionID = session.id - view.onExit = { [weak view] in - guard let view else { return } - Self.handlePaneExit(view, store: store, sessionID: sessionID, library: library) - } - view.onFocusChange = { [weak view] focused in - guard focused else { return } - // a promoted survivor keeps this closure with `isSplitPane` cleared: as the main pane it must not - // re-raise `splitFocused`, which masks its migrated title and mis-routes focus after a re-split. - store.session(withID: sessionID)?.splitFocused = view?.isSplitPane ?? false - store.clearUnseen(sessionID) - NotificationManager.shared.clearDelivered(sessionID: sessionID) - } - // the focus-free half of the clear above, for the zoom-hosted case (see makeSurface). - view.onClearUnseen = { - store.clearUnseen(sessionID) - NotificationManager.shared.clearDelivered(sessionID: sessionID) - } - Self.wireStatusClear(view, store: store, sessionID: sessionID) - view.onUserInput = { store.noteUserActivity() } - Self.wireSearchCallbacks(view, store: store, sessionID: sessionID, library: library) + Self.wirePane(view, store: store, sessionID: session.id, library: library, persistsFontSize: false) return view } @@ -469,15 +501,25 @@ struct agtermApp: App { // the overlay's own background color (`session.overlay.open --background-color`), applied in // createSurface — the overlay is sessionless, so it can't read it off the session there. view.overlayBackgroundColorHex = spec.backgroundColor - // record the exit status on teardown (always via destroySurface), so it survives a `session.overlay.close` - // that bypasses onExit; a force-close removes the session first and no-ops here, where it is unqueryable. - // - // the pane arm's callbacks re-resolve their pane from the slot the surface CURRENTLY occupies, never - // the captured `pane`: `closePrimaryPane` MOVES a right-pane overlay into the left slot without - // rebuilding the view (`TerminalView.makeNSView` reuses a non-nil slot), so a captured `.right` would - // close nothing, record the status where `session.overlay.result --pane left` can't read it, and leave - // the promoted pane under a dead overlay forever. The captured value is the pre-realization fallback, - // for the window between the open and the slot holding this surface. + Self.wireOverlay(view, store: store, sessionID: sessionID, pane: pane, isHud: isHud) + return view + } + + /// Every store-bound callback of an overlay surface, in one place so `rebindSurfaces` can re-apply the + /// exact set after a cross-window move. + /// + /// Records the exit status on teardown (always via destroySurface), so it survives a `session.overlay.close` + /// that bypasses onExit; a force-close removes the session first and no-ops here, where it is unqueryable. + /// + /// The pane arm's callbacks re-resolve their pane from the slot the surface CURRENTLY occupies, never + /// the captured `pane`: `closePrimaryPane` MOVES a right-pane overlay into the left slot without + /// rebuilding the view (`TerminalView.makeNSView` reuses a non-nil slot), so a captured `.right` would + /// close nothing, record the status where `session.overlay.result --pane left` can't read it, and leave + /// the promoted pane under a dead overlay forever. The captured value is the pre-realization fallback, + /// for the window between the open and the slot holding this surface. + @MainActor + private static func wireOverlay(_ view: GhosttySurfaceView, store: AppStore, sessionID: UUID, + pane: OverlayPane?, isHud: Bool) { if let pane { let livePane: @MainActor () -> OverlayPane = { [weak view] in guard let view else { return pane } @@ -504,7 +546,6 @@ struct agtermApp: App { // typing is user activity: resets the auto-follow idle timer so an idle fire can't change the selection // (vanishing the overlay) mid-typing. destroySurface nils this, breaking the store->surface->closure cycle. view.onUserInput = { store.noteUserActivity() } - return view } /// The four fields the overlay factory reads, from the session-wide slot (`pane == nil`) or that pane's @@ -538,7 +579,15 @@ struct agtermApp: App { command: command, autoFocus: !suppressAutoFocus, env: env) view.watermarkSession = session - let sessionID = session.id + Self.wireScratch(view, store: store, sessionID: session.id, library: library) + return view + } + + /// Every store-bound callback of a scratch surface, in one place so `rebindSurfaces` can re-apply the + /// exact set after a cross-window move. + @MainActor + private static func wireScratch(_ view: GhosttySurfaceView, store: AppStore, sessionID: UUID, + library: WindowLibrary) { view.onExit = { store.closeScratch(sessionID) } Self.wireStatusClear(view, store: store, sessionID: sessionID, fixedPane: .scratch) // same idle-timer reset as the overlay: an idle auto-follow fire must not hide the scratch mid-typing. @@ -546,7 +595,6 @@ struct agtermApp: App { // the scratch is searchable (⌘F), pinned to the same session as the panes: unlike the overlay/quick // terminal it stays alive across hides, so a bar over it is safe. Self.wireSearchCallbacks(view, store: store, sessionID: sessionID, library: library) - return view } /// The environment a tree surface (main / split / overlay / scratch) exposes to its shell: the `AGTERM_*` diff --git a/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift new file mode 100644 index 000000000..fdf0a5477 --- /dev/null +++ b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift @@ -0,0 +1,117 @@ +import Foundation + +// MARK: - Control tree projection + +/// The `tree` response projection. Split out of `AppStore.swift` for the file size limit. +extension AppStore { + /// Projects this store's workspace/session model into the control-channel `tree` payload. Foreground + /// command lookup is supplied by the host because live process inspection is platform-specific. + /// `windowID` names the projected window and is stamped on the tree and on every session node; only the + /// host knows which window owns a store, so a host-free projection leaves it nil. + /// `windowName` rides along for `tree --all-windows`'s section headers and is dropped without a `windowID`. + public func controlTree(foreground: (Session) -> [String]? = { _ in nil }, + splitForeground: (Session) -> [String]? = { _ in nil }, + fontSize: (Session) -> Double? = { _ in nil }, + splitFontSize: (Session) -> Double? = { _ in nil }, + scratchFontSize: (Session) -> Double? = { _ in nil }, + quickVisible: () -> Bool? = { nil }, + zoomedSurface: () -> String? = { nil }, + pickPending: () -> String? = { nil }, + dashboardMembers: () -> [String]? = { nil }, + dashboardHighlighted: () -> String? = { nil }, + dashboardFontSize: () -> Double? = { nil }, + dashboardFontMode: () -> String? = { nil }, app: AppIdentity? = nil, + windowID: String? = nil, windowName: String? = nil) -> ControlTree { + let activeID = selectedSessionID + // `currentWorkspaceID`, not the selected session's owner: an EMPTY destination selects nothing, so + // deriving this from the selection alone made `tree` name the workspace `workspace.go` just left. + let activeWorkspaceID = currentWorkspaceID + let nodes = workspaces.map { workspace in + // one ownership stamp, all or nothing: a projection that names no window cannot say who owns a + // session, and half an answer is the one a caller would act on wrongly. + let ownerWorkspaceID = windowID.map { _ in workspace.id.uuidString } + let sessions = workspace.sessions.map { session in + let idle = session.agentIndicator.status == .idle + let status = idle ? nil : session.agentIndicator.status.rawValue + let statusPane = idle ? nil : session.agentIndicator.statusPane?.rawValue + let surfaces = TerminalZoomSurface.allCases.compactMap { surface -> ControlSurfaceNode? in + guard surface.isAvailable(in: session) else { return nil } + let id = TerminalSurfaceID(sessionID: session.id, surface: surface).rawValue + return ControlSurfaceNode(id: id, kind: surface.rawValue, + active: surface.isActive(in: session), + visible: surface.isVisible(in: session)) + } + return ControlSessionNode(id: session.id.uuidString, name: session.displayName, + cwd: session.effectiveCwd, title: session.oscTitle, + active: session.id == activeID, + split: session.isSplit, + hasSplit: session.hasSplit ? true : nil, + splitAxis: session.hasSplit ? session.splitAxis.rawValue : nil, + splitRatio: session.hasSplit ? session.splitRatio : nil, + splitFocused: session.hasSplit ? session.splitFocused : nil, + overlay: session.programOverlayActive, + overlaySizePercent: session.programOverlayActive + ? session.overlaySizePercent : nil, + paneOverlays: paneOverlays(session), + hud: hudNode(session), + scratch: session.scratchActive, flagged: session.flagged, + commandWait: (session.initialCommand != nil && session.commandWait) ? true : nil, + foreground: foreground(session), + splitForeground: splitForeground(session), + // the PERSISTED overrides, not the transient pending payloads, so + // a read after one fired still reports what stays pinned. + restoreCommand: session.restoreCommand, + splitRestoreCommand: session.splitRestoreCommand, status: status, + statusPane: statusPane, + statusBlink: idle ? nil : (session.agentIndicator.blink ? true : nil), + statusColor: idle ? nil : session.agentIndicator.color, + statusShape: idle ? nil : session.agentIndicator.shape?.rawValue, + statusChangedAt: idle ? nil : session.statusChangedAt?.timeIntervalSince1970, + background: session.backgroundWatermark, + unseen: session.unseenCount > 0 ? session.unseenCount : nil, + fontSize: fontSize(session), + splitFontSize: splitFontSize(session), + scratchFontSize: scratchFontSize(session), + surfaces: surfaces, + // host-free: `isRealized` is on `TerminalSurface`, so this needs + // no app-side closure like the font sizes above. An empty slot is + // false, not omitted — "no terminal" either way to a caller. + realized: session.surface?.isRealized ?? false, + windowId: windowID, + workspaceId: ownerWorkspaceID) + } + return ControlWorkspaceNode(id: workspace.id.uuidString, name: workspace.name, + active: workspace.id == activeWorkspaceID, + focused: focusedWorkspaceIDs.contains(workspace.id) ? true : nil, + collapsed: workspace.isExpanded ? nil : true, + sessions: sessions) + } + return ControlTree(workspaces: nodes, idleMs: idleMs(), autoFollowMs: autoFollowMs, + sidebarVisible: sidebarVisible, sidebarMode: sidebarMode.rawValue, + workspaceFilter: focusEnabled, + quickVisible: quickVisible(), zoomedSurface: zoomedSurface(), + dashboardMembers: dashboardMembers(), + dashboardHighlighted: dashboardHighlighted(), + dashboardFontSize: dashboardFontSize(), + dashboardFontMode: dashboardFontMode(), + pickPending: pickPending(), app: app, windowId: windowID, + windowName: windowID == nil ? nil : windowName) + } + + /// The tree's `paneOverlays`: the panes covered by their own overlay, omitted when neither is. + private func paneOverlays(_ session: Session) -> [String]? { + let panes = session.openPaneOverlays.map(\.rawValue) + return panes.isEmpty ? nil : panes + } + + /// The tree's `hud`: the live panel's spec carrying the slot's EFFECTIVE size on BOTH axes and the + /// effective position, omitted when no HUD occupies the slot. + private func hudNode(_ session: Session) -> ControlHudNode? { + guard session.hudActive, let spec = session.hudSpec else { return nil } + return ControlHudNode(message: spec.message, detail: spec.detail, + spinner: spec.spinner?.rawValue ?? HudSpinner.noneName, + backgroundColor: spec.backgroundColor, textColor: spec.textColor, + sizePercent: session.overlaySizePercent, + heightPercent: session.hudHeightPercent, position: spec.position.rawValue) + } +} diff --git a/agtermCore/Sources/agtermCore/AppStore+Transfer.swift b/agtermCore/Sources/agtermCore/AppStore+Transfer.swift new file mode 100644 index 000000000..1dae0e506 --- /dev/null +++ b/agtermCore/Sources/agtermCore/AppStore+Transfer.swift @@ -0,0 +1,50 @@ +import Foundation + +// MARK: - Cross-store session transfer + +/// The two halves of moving a live session between windows. Each store owns its own persisted snapshot, so +/// a transfer is detach-then-adopt rather than one mutation; `WindowLibrary` composes them. +extension AppStore { + /// Removes a session from this store and hands the **instance** back with every surface intact, so the + /// caller can insert it into another store's tree and keep the live shell. Unlike `closeSession` there is + /// no teardown, no `sessionClosed` event and no Reopen Closed Item record — the session is not gone, it + /// is leaving this window. Reselects through the close path when it was active, prunes recency and the + /// sidebar selection. Nil for an unknown id. + public func detachSession(_ sessionID: UUID) -> Session? { + guard let location = location(ofSession: sessionID) else { return nil } + let wasActive = selectedSessionID == sessionID + let session = workspaces[location.workspaceIndex].sessions.remove(at: location.sessionIndex) + removeFromRecency(sessionID) + if wasActive { + selectedSessionID = closeReselectionTarget(after: location) + replaceSidebarSelection(with: selectedSessionID) + disableFocusIfSelectionOutsideSet(selectedSessionID) + recordRecency() + } else { + pruneSidebarSelection() + } + scheduleTreeChanged() + save() + return session + } + + /// Inserts a session instance detached from another store. A nil workspace lands in `currentWorkspaceID` + /// (which already falls back to the last workspace); `index` nil appends, else inserts at the clamped + /// position. `select` goes through `selectSession`, so an adopted session that arrives carrying an unseen + /// badge or a `completed` flash lands cleared like any other session you switch to. False when + /// the workspace is unknown or an equal id is already here — adopting a duplicate would fork identity + /// across two stores. + @discardableResult + public func adoptSession(_ session: Session, toWorkspace workspaceID: UUID? = nil, at index: Int? = nil, + select: Bool = false) -> Bool { + guard self.session(withID: session.id) == nil else { return false } + guard let targetID = workspaceID ?? currentWorkspaceID, + let wsIndex = workspaces.firstIndex(where: { $0.id == targetID }) else { return false } + let count = workspaces[wsIndex].sessions.count + workspaces[wsIndex].sessions.insert(session, at: max(0, min(index ?? count, count))) + if select { selectSession(session.id) } + scheduleTreeChanged() + save() + return true + } +} diff --git a/agtermCore/Sources/agtermCore/AppStore.swift b/agtermCore/Sources/agtermCore/AppStore.swift index cb73d438d..b692a7e8c 100644 --- a/agtermCore/Sources/agtermCore/AppStore.swift +++ b/agtermCore/Sources/agtermCore/AppStore.swift @@ -252,107 +252,6 @@ public final class AppStore { "workspace \(workspaces.count + 1)" } - /// Projects this store's workspace/session model into the control-channel `tree` payload. Foreground - /// command lookup is supplied by the host because live process inspection is platform-specific. - public func controlTree(foreground: (Session) -> [String]? = { _ in nil }, - splitForeground: (Session) -> [String]? = { _ in nil }, - fontSize: (Session) -> Double? = { _ in nil }, - splitFontSize: (Session) -> Double? = { _ in nil }, - scratchFontSize: (Session) -> Double? = { _ in nil }, - quickVisible: () -> Bool? = { nil }, - zoomedSurface: () -> String? = { nil }, - pickPending: () -> String? = { nil }, - dashboardMembers: () -> [String]? = { nil }, - dashboardHighlighted: () -> String? = { nil }, - dashboardFontSize: () -> Double? = { nil }, - dashboardFontMode: () -> String? = { nil }, app: AppIdentity? = nil) -> ControlTree { - let activeID = selectedSessionID - // `currentWorkspaceID`, not the selected session's owner: an EMPTY destination selects nothing, so - // deriving this from the selection alone made `tree` name the workspace `workspace.go` just left. - let activeWorkspaceID = currentWorkspaceID - let nodes = workspaces.map { workspace in - let sessions = workspace.sessions.map { session in - let idle = session.agentIndicator.status == .idle - let status = idle ? nil : session.agentIndicator.status.rawValue - let statusPane = idle ? nil : session.agentIndicator.statusPane?.rawValue - let surfaces = TerminalZoomSurface.allCases.compactMap { surface -> ControlSurfaceNode? in - guard surface.isAvailable(in: session) else { return nil } - let id = TerminalSurfaceID(sessionID: session.id, surface: surface).rawValue - return ControlSurfaceNode(id: id, kind: surface.rawValue, - active: surface.isActive(in: session), - visible: surface.isVisible(in: session)) - } - return ControlSessionNode(id: session.id.uuidString, name: session.displayName, - cwd: session.effectiveCwd, title: session.oscTitle, - active: session.id == activeID, - split: session.isSplit, - hasSplit: session.hasSplit ? true : nil, - splitAxis: session.hasSplit ? session.splitAxis.rawValue : nil, - splitRatio: session.hasSplit ? session.splitRatio : nil, - splitFocused: session.hasSplit ? session.splitFocused : nil, - overlay: session.programOverlayActive, - overlaySizePercent: session.programOverlayActive - ? session.overlaySizePercent : nil, - paneOverlays: paneOverlays(session), - hud: hudNode(session), - scratch: session.scratchActive, flagged: session.flagged, - commandWait: (session.initialCommand != nil && session.commandWait) ? true : nil, - foreground: foreground(session), - splitForeground: splitForeground(session), - // the PERSISTED overrides, not the transient pending payloads, so - // a read after one fired still reports what stays pinned. - restoreCommand: session.restoreCommand, - splitRestoreCommand: session.splitRestoreCommand, status: status, - statusPane: statusPane, - statusBlink: idle ? nil : (session.agentIndicator.blink ? true : nil), - statusColor: idle ? nil : session.agentIndicator.color, - statusShape: idle ? nil : session.agentIndicator.shape?.rawValue, - statusChangedAt: idle ? nil : session.statusChangedAt?.timeIntervalSince1970, - background: session.backgroundWatermark, - unseen: session.unseenCount > 0 ? session.unseenCount : nil, - fontSize: fontSize(session), - splitFontSize: splitFontSize(session), - scratchFontSize: scratchFontSize(session), - surfaces: surfaces, - // host-free: `isRealized` is on `TerminalSurface`, so this needs - // no app-side closure like the font sizes above. An empty slot is - // false, not omitted — "no terminal" either way to a caller. - realized: session.surface?.isRealized ?? false) - } - return ControlWorkspaceNode(id: workspace.id.uuidString, name: workspace.name, - active: workspace.id == activeWorkspaceID, - focused: focusedWorkspaceIDs.contains(workspace.id) ? true : nil, - collapsed: workspace.isExpanded ? nil : true, - sessions: sessions) - } - return ControlTree(workspaces: nodes, idleMs: idleMs(), autoFollowMs: autoFollowMs, - sidebarVisible: sidebarVisible, sidebarMode: sidebarMode.rawValue, - workspaceFilter: focusEnabled, - quickVisible: quickVisible(), zoomedSurface: zoomedSurface(), - dashboardMembers: dashboardMembers(), - dashboardHighlighted: dashboardHighlighted(), - dashboardFontSize: dashboardFontSize(), - dashboardFontMode: dashboardFontMode(), - pickPending: pickPending(), app: app) - } - - /// The tree's `paneOverlays`: the panes covered by their own overlay, omitted when neither is. - private func paneOverlays(_ session: Session) -> [String]? { - let panes = session.openPaneOverlays.map(\.rawValue) - return panes.isEmpty ? nil : panes - } - - /// The tree's `hud`: the live panel's spec carrying the slot's EFFECTIVE size on BOTH axes and the - /// effective position, omitted when no HUD occupies the slot. - private func hudNode(_ session: Session) -> ControlHudNode? { - guard session.hudActive, let spec = session.hudSpec else { return nil } - return ControlHudNode(message: spec.message, detail: spec.detail, - spinner: spec.spinner?.rawValue ?? HudSpinner.noneName, - backgroundColor: spec.backgroundColor, textColor: spec.textColor, - sizePercent: session.overlaySizePercent, - heightPercent: session.hudHeightPercent, position: spec.position.rawValue) - } - /// Creates a workspace and appends it. With `revealNewWorkspace` (the default) and the filter ON, the new /// workspace JOINS the marked set so it is immediately visible — the auto-reveal contract, like /// `addSession`; widening rather than clearing keeps the rest filtered. `false` leaves the filter diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher+SessionMove.swift b/agtermCore/Sources/agtermCore/ControlDispatcher+SessionMove.swift new file mode 100644 index 000000000..87d62797a --- /dev/null +++ b/agtermCore/Sources/agtermCore/ControlDispatcher+SessionMove.swift @@ -0,0 +1,81 @@ +import Foundation + +extension ControlDispatcher { + /// The `session.move` form parser: exactly one placement intent out of a destination window, an + /// anchor, a reorder direction, or a workspace. Extracted from the command switch, which the four + /// mutually exclusive forms would otherwise dominate. + func dispatchSessionMove(_ request: ControlRequest) -> ControlResponse { + let args = request.args + if args?.after != nil, args?.before != nil { + return ControlResponse(ok: false, error: "use either --after or --before, not both") + } + // only the cross-window form has a foreign selection to set; accepting it elsewhere would drop it. + if args?.select == true, args?.toWindow == nil { + return ControlResponse(ok: false, error: "session.move --select requires --to-window") + } + // Cross-window mode: the destination is another store, where neither a reorder direction nor an + // anchor's index means anything. A workspace parameter DOES, naming one inside that window. + if let toWindow = args?.toWindow { + if args?.to != nil { + return ControlResponse(ok: false, error: "session.move takes --to-window or --to, not both") + } + if args?.after != nil || args?.before != nil { + return ControlResponse(ok: false, + error: "session.move takes --to-window or --after/--before, not both") + } + let move = ControlSessionMove.window(window: toWindow, workspace: args?.workspace) + let select = args?.select ?? false + if let targets = args?.targets { + return dispatchSessionMove(targets: targets, window: args?.window, move: move, select: select) + } + return actions.moveSession(request.target, window: args?.window, move: move, select: select) + } + // Placement mode: the anchor sid self-identifies the destination workspace, so it's + // mutually exclusive with --to and with a workspace parameter. + if let anchor = args?.after ?? args?.before { + if args?.to != nil { + return ControlResponse(ok: false, error: "session.move takes --after/--before or --to, not both") + } + if args?.workspace != nil { + return ControlResponse(ok: false, error: "session.move takes --after/--before or a workspace, not both") + } + let move = ControlSessionMove.place(anchor: anchor, after: args?.after != nil) + if let targets = args?.targets { + return dispatchSessionMove(targets: targets, window: args?.window, move: move, select: false) + } + return actions.moveSession(request.target, window: args?.window, move: move, select: false) + } + if args?.to != nil && args?.workspace != nil { + return ControlResponse(ok: false, error: "session.move takes either --to or a workspace, not both") + } + if let to = args?.to { + guard let direction = ReorderDirection(rawValue: to) else { + return ControlResponse(ok: false, error: "session.move --to must be up|down|top|bottom") + } + if args?.targets != nil { + return ControlResponse(ok: false, error: "session.move --target can be repeated only with a workspace or --after/--before") + } + return actions.moveSession(request.target, window: args?.window, move: .reorder(direction), + select: false) + } + guard let workspace = args?.workspace else { + return ControlResponse(ok: false, error: "session.move requires --to or a workspace") + } + let move = ControlSessionMove.workspace(workspace) + if let targets = args?.targets { + return dispatchSessionMove(targets: targets, window: args?.window, move: move, select: false) + } + return actions.moveSession(request.target, window: args?.window, move: move, select: false) + } + + private func dispatchSessionMove(targets: [String], window: String?, move: ControlSessionMove, + select: Bool) -> ControlResponse { + guard let first = targets.first else { + return ControlResponse(ok: false, error: "session.move requires at least one --target") + } + if targets.count == 1 { + return actions.moveSession(first, window: window, move: move, select: select) + } + return actions.moveSessions(targets, window: window, move: move, select: select) + } +} diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher.swift b/agtermCore/Sources/agtermCore/ControlDispatcher.swift index be37c00da..96f9b49d8 100644 --- a/agtermCore/Sources/agtermCore/ControlDispatcher.swift +++ b/agtermCore/Sources/agtermCore/ControlDispatcher.swift @@ -5,7 +5,9 @@ import Foundation /// platform-specific side effects. @MainActor public protocol ControlActions { - func controlTree(window: String?) -> ControlResponse + /// `allWindows` projects every OPEN window into `result.trees` instead of one into `result.tree`; the + /// dispatcher rejects it alongside `window`, so a host sees at most one of them set. + func controlTree(window: String?, allWindows: Bool) -> ControlResponse func readEvents(_ options: ControlEventReadOptions) -> ControlResponse func createSession(_ options: ControlSessionCreateOptions) -> ControlResponse func duplicateSession(_ target: String?, window: String?) -> ControlResponse @@ -23,8 +25,11 @@ public protocol ControlActions { func goWorkspace(window: String?, direction: WorkspaceNavigation) -> ControlResponse func renameWorkspace(_ target: String?, window: String?, name: String) -> ControlResponse func deleteWorkspace(_ target: String?, window: String?) -> ControlResponse - func moveSession(_ target: String?, window: String?, move: ControlSessionMove) -> ControlResponse - func moveSessions(_ targets: [String], window: String?, move: ControlSessionMove) -> ControlResponse + /// `select` applies only to the cross-window `.window` form, whose destination is a SEPARATE store; the + /// in-store forms never touch selection and ignore it. + func moveSession(_ target: String?, window: String?, move: ControlSessionMove, select: Bool) -> ControlResponse + func moveSessions(_ targets: [String], window: String?, move: ControlSessionMove, + select: Bool) -> ControlResponse func moveWorkspace(_ target: String?, window: String?, direction: ReorderDirection) -> ControlResponse func focusWorkspace(_ target: String?, window: String?, mode: ControlWorkspaceFocusMode) -> ControlResponse /// Turn a window's workspace focus filter on/off WITHOUT touching the marked set. Window-scoped, so @@ -214,7 +219,7 @@ public struct ControlDispatcher { public func dispatch(_ request: ControlRequest) async -> ControlResponse? { switch request.cmd { case .tree: - return actions.controlTree(window: request.args?.window) + return dispatchTree(request) case .eventsRead: return dispatchEventsRead(request) case .sessionNew, .sessionDuplicate, .sessionSelect, .sessionGo, .sessionClose, .sessionRename, @@ -251,6 +256,18 @@ public struct ControlDispatcher { } } + /// `tree`: one window's projection, or every open one with `--all-windows`. The two selectors contradict + /// each other — one names a single window, the other means all of them — so a caller passing both is + /// rejected rather than silently served one of the two. + private func dispatchTree(_ request: ControlRequest) -> ControlResponse { + let allWindows = request.args?.allWindows ?? false + let window = request.args?.window + if allWindows, window?.isEmpty == false { + return ControlResponse(ok: false, error: "tree takes --all-windows or --window, not both") + } + return actions.controlTree(window: window, allWindows: allWindows) + } + private func dispatchEventsRead(_ request: ControlRequest) -> ControlResponse { let args = request.args let cursor: ControlEventCursor? @@ -349,45 +366,7 @@ public struct ControlDispatcher { case .sessionReveal: return actions.revealSession(request.target, window: request.args?.window) case .sessionMove: - let args = request.args - if args?.after != nil, args?.before != nil { - return ControlResponse(ok: false, error: "use either --after or --before, not both") - } - // Placement mode: the anchor sid self-identifies the destination workspace, so it's - // mutually exclusive with --to and with a workspace parameter. - if let anchor = args?.after ?? args?.before { - if args?.to != nil { - return ControlResponse(ok: false, error: "session.move takes --after/--before or --to, not both") - } - if args?.workspace != nil { - return ControlResponse(ok: false, error: "session.move takes --after/--before or a workspace, not both") - } - let move = ControlSessionMove.place(anchor: anchor, after: args?.after != nil) - if let targets = args?.targets { - return dispatchSessionMove(targets: targets, window: args?.window, move: move) - } - return actions.moveSession(request.target, window: args?.window, move: move) - } - if args?.to != nil && args?.workspace != nil { - return ControlResponse(ok: false, error: "session.move takes either --to or a workspace, not both") - } - if let to = args?.to { - guard let direction = ReorderDirection(rawValue: to) else { - return ControlResponse(ok: false, error: "session.move --to must be up|down|top|bottom") - } - if args?.targets != nil { - return ControlResponse(ok: false, error: "session.move --target can be repeated only with a workspace or --after/--before") - } - return actions.moveSession(request.target, window: args?.window, move: .reorder(direction)) - } - guard let workspace = args?.workspace else { - return ControlResponse(ok: false, error: "session.move requires --to or a workspace") - } - let move = ControlSessionMove.workspace(workspace) - if let targets = args?.targets { - return dispatchSessionMove(targets: targets, window: args?.window, move: move) - } - return actions.moveSession(request.target, window: args?.window, move: move) + return dispatchSessionMove(request) case .sessionFlag: return actions.setSessionFlag(request.target, window: request.args?.window, mode: request.args?.mode) case .sessionSeen: @@ -502,16 +481,6 @@ public struct ControlDispatcher { parsePane(raw, error: PaneOverlayError.invalidPane) { OverlayPane(controlName: $0) } } - private func dispatchSessionMove(targets: [String], window: String?, move: ControlSessionMove) -> ControlResponse { - guard let first = targets.first else { - return ControlResponse(ok: false, error: "session.move requires at least one --target") - } - if targets.count == 1 { - return actions.moveSession(first, window: window, move: move) - } - return actions.moveSessions(targets, window: window, move: move) - } - private func dispatchWorkspaceCommand(_ request: ControlRequest) -> ControlResponse { switch request.cmd { case .workspaceNew: diff --git a/agtermCore/Sources/agtermCore/ControlModes.swift b/agtermCore/Sources/agtermCore/ControlModes.swift index 2d2090223..c47ac23e3 100644 --- a/agtermCore/Sources/agtermCore/ControlModes.swift +++ b/agtermCore/Sources/agtermCore/ControlModes.swift @@ -114,6 +114,10 @@ public enum ControlSessionMove: Equatable, Sendable { /// Relocate relative to an anchor session (id / prefix / `active`); `after == false` places before it. /// The anchor carries its own workspace, so this form never reads the workspace parameter. case place(anchor: String, after: Bool) + /// Transfer the live session to ANOTHER OPEN window, optionally naming a workspace inside it + /// (nil = that window's current one). The destination window is a separate store, so this form + /// cannot combine with the reorder/anchor ones, whose positions only resolve within one store. + case window(window: String, workspace: String?) } /// Parsed resize request for `session.resize`. diff --git a/agtermCore/Sources/agtermCore/ControlProtocol+Nodes.swift b/agtermCore/Sources/agtermCore/ControlProtocol+Nodes.swift new file mode 100644 index 000000000..7ca51c7de --- /dev/null +++ b/agtermCore/Sources/agtermCore/ControlProtocol+Nodes.swift @@ -0,0 +1,451 @@ +import Foundation + +// MARK: - Tree and window projection nodes + +/// The read-side node types the `tree` and `window.list` responses are built from. Split out of +/// `ControlProtocol.swift` for the file size limit. + +/// A terminal surface as projected into the `tree` response. `id` is the stable control address for +/// `surface.zoom`; `kind` the user-facing name (`left`, `right`, `scratch`, `overlay`). `active`/`visible` +/// derive from the session's own flags (overlay/scratch/splitFocused), NOT from terminal zoom, and `visible` +/// reads false for a pane behind a FLOATING overlay though it is visually on screen (any open overlay counts +/// as covering). Address by `id`/`kind`, not these flags; the zoom state is the top-level `zoomedSurface`. +public struct ControlSurfaceNode: Codable, Sendable, Equatable { + public let id: String + public let kind: String + public let active: Bool + public let visible: Bool + + public init(id: String, kind: String, active: Bool, visible: Bool) { + self.id = id + self.kind = kind + self.active = active + self.visible = visible + } +} + +/// The HUD panel occupying a session's overlay slot, as projected into the `tree` response. Present only +/// while a HUD is up, and the session node's `overlay` reads FALSE beside it, so a script polling "is a +/// program covering this session" can never mistake a message for a running program. The read side of +/// `session.hud.open`/`.update`; HUD state is poll-only, no event announces it. +public struct ControlHudNode: Codable, Sendable, Equatable { + public let message: String + /// The dim second line; nil/omitted when the caller set none. + public let detail: String? + /// The EFFECTIVE spinner style, a `HudSpinner` raw value or `HudSpinner.noneName`. Always present, the + /// static case included, so a caller reads one field rather than inferring absence. + public let spinner: String + /// The panel's own `#rrggbb` background; nil/omitted when it keeps the session's terminal background. The + /// color the open set, which survives every `session.hud.update` — the surface reads it once at creation, + /// so this always names what the panel paints. + public let backgroundColor: String? + /// The panel's `#rrggbb` TEXT color; nil/omitted when it keeps the terminal foreground. Unlike + /// `backgroundColor` this tracks the LATEST `session.hud.update`, the header the helper re-reads being + /// what paints it. + public let textColor: String? + /// The EFFECTIVE share of the pane's WIDTH the panel occupies — the app's measurement, or the caller's + /// `sizePercent` override, either way bounded by `HudLayout.clampSizePercent`, so a requested 100 reads + /// back as the maximum a HUD may take. Reported here because the node's `overlaySizePercent` stays + /// omitted for a HUD. Optional because it projects the slot's optional percent, but no supported path + /// leaves a live HUD sizeless: `openHud` always sets one and `overlay.resize --full` is refused. + public let sizePercent: Int? + /// The EFFECTIVE share of the pane's HEIGHT, always measured from the message (`HudLayout.heightPercent`) + /// because no command sets it. Reported beside `sizePercent` so a caller polling the panel's geometry + /// reads both axes rather than assuming one square. + public let heightPercent: Int? + /// The EFFECTIVE placement, a CANONICAL `HudPosition` raw value — one of the nine anchors, never one of + /// the accepted `top`/`bottom` aliases, so a caller reads one spelling whichever he sent. Always present, + /// including the `center` default, so a caller who omitted it never has to know what the default is. + public let position: String + + public init(message: String, detail: String? = nil, spinner: String = HudSpinner.noneName, + backgroundColor: String? = nil, textColor: String? = nil, + sizePercent: Int? = nil, heightPercent: Int? = nil, position: String) { + self.message = message + self.detail = detail + self.spinner = spinner + self.backgroundColor = backgroundColor + self.textColor = textColor + self.sizePercent = sizePercent + self.heightPercent = heightPercent + self.position = position + } +} + +/// A session as projected into the `tree` response. +public struct ControlSessionNode: Codable, Sendable, Equatable { + public let id: String + public let name: String + public let cwd: String + /// The raw terminal title from the latest OSC 0/1/2 (a remote host over SSH, a shell `PROMPT_COMMAND`); + /// nil/omitted when none reported. The unprocessed `Session.oscTitle`, distinct from `name` (the derived + /// sidebar label, which uses it as one fallback); a remote session's local `cwd` goes stale, this does not. + public let title: String? + public let active: Bool + /// Whether the split is SHOWN side by side, the read side of `session.split on|off`. A split hidden with + /// ⌘D reports `false` while its pane stays alive, so a caller asking "is there a second pane" must read + /// `hasSplit`, not this. + public let split: Bool + /// Whether the session HAS a split pane at all, shown or hidden; nil/omitted when it has none. Present + /// exactly when `splitRatio`/`splitFocused` can be, which is what makes those two readable without + /// second-guessing `split`. The sidebar icon, the dashboard's second cell and Focus Left/Right Pane all + /// follow this, not `split`. + public let hasSplit: Bool? + /// Divider direction for a live split (`vertical`=left/right, `horizontal`=top/bottom); nil without one. + public let splitAxis: String? + /// The primary-pane fraction (0.05...0.95) of a session that HAS a split (shown or hidden); nil with no + /// split OR when the ratio was never explicitly set (via `session.resize` or a divider drag), the divider + /// then sitting at the default 0.5. The read side of `session.resize`, otherwise echoed only on that call. + public let splitRatio: Double? + /// For a session that HAS a split (shown or hidden), which pane holds keyboard focus: `true` = split + /// (right), `false` = main (left); nil/omitted with no split. The read side of `session.focus`. + public let splitFocused: Bool? + /// Whether a caller's PROGRAM occupies the session-wide overlay slot. False while a HUD holds it — the + /// HUD is a message, not a running program, and it reports itself in `hud` instead. + public let overlay: Bool + /// An OPEN overlay's size (`overlay == true`): nil/omitted = FULL-pane, else the floating panel's percent + /// of the pane (1...100); absent with no overlay AND while a HUD holds the slot, whose size is `hud`'s. + /// The read side of `session.overlay.resize`. + public let overlaySizePercent: Int? + /// The panes covered by their OWN overlay, ordered left then right (`["left"]`, `["right"]`, + /// `["left","right"]`); nil/omitted when neither has one. Independent of `overlay`, the session-wide + /// one — both kinds can be up at once. The read side of `session.overlay.open --pane`; those overlays + /// are always full-pane, so there is no per-pane size to report. + public let paneOverlays: [String]? + /// The HUD panel occupying the session-wide overlay slot; nil/omitted when none is up. Mutually exclusive + /// with `overlay` — one slot, and whichever holds it is the one that reports. + public let hud: ControlHudNode? + public let scratch: Bool + public let flagged: Bool + /// For a `--command` session, whether it HOLDS its surface after the command exits (`session.new + /// --command … --wait`) instead of closing; nil/omitted for a plain or non-holding session. The read + /// side of `session.new --wait`; it persists across restart, unlike an overlay's live-only wait. + public let commandWait: Bool? + /// The LIVE foreground process command (full argv) in the main pane; nil/omitted at the shell prompt — + /// the same capture restore-running-command uses. + public let foreground: [String]? + /// The split (right) pane's live foreground command (full argv), the split analogue of `foreground`. + public let splitForeground: [String]? + /// The main pane's PERSISTED restore-command override, the read side of `session.restore`. Tri-state: + /// omitted = no override (auto-capture), `""` = pinned to nothing (a plain shell), a command = that shell + /// line runs on the next launch. Read from persisted state, so it still reports the pin after the + /// override fired. Unrelated to `foreground`, the LIVE process. + public let restoreCommand: String? + /// The split (right) pane's persisted restore-command override, the split analogue of `restoreCommand` + /// (the read side of `session.restore --pane right`). + public let splitRestoreCommand: String? + /// The session's agent status (`active`/`completed`/`blocked`) as the `AgentStatus` raw value; + /// nil/omitted when idle. The read side of `session.status`. + public let status: String? + /// Which pane set the agent status (`"left"|"right"|"scratch"`, `left`=main, `right`=split); nil/omitted + /// when idle or unspecified. The read side of `session.status --pane`. + public let statusPane: String? + /// Whether the agent-status glyph blinks (pulses for attention); nil/omitted when idle or not blinking. + /// The read side of `session.status --blink`. + public let statusBlink: Bool? + /// The per-call `#rrggbb` glyph-tint override; nil/omitted when idle or using the Settings status color. + /// The read side of `session.status --color`. + public let statusColor: String? + /// The per-call glyph-silhouette override (a `StatusShape` raw value); nil/omitted when idle or drawing + /// the Settings shape / the default plain circle. The read side of `session.status --shape` — the + /// PER-CALL override only, exactly like `statusColor`. + public let statusShape: String? + /// When the agent status was last SET, as epoch seconds on the `ControlEvent.ts` clock (so the two + /// compare directly); nil/omitted when idle. Stamped on EVERY non-idle `session.status`, not only on a + /// change of state, so a hook re-pushing `active` refreshes it and "now minus this" reads as how long ago + /// the status was last WRITTEN — normally the agent's own push, though a pane promotion re-tags the + /// indicator and counts too. Ephemeral like `status` and `unseen` — never persisted. + public let statusChangedAt: Double? + /// The session's background watermark spec; nil/omitted when none is set. The read side of + /// `session.background`. + public let background: BackgroundWatermark? + /// The session's unseen-notification badge count; nil/omitted when zero. `notify` (and terminal OSC + /// 9/777) raise it, `session.seen` clears it. Ephemeral like `status` — never persisted, resets on restart. + public let unseen: Int? + /// The default/left pane's live font size in points via `addressableSurface`: the main pane, or the + /// promoted split survivor once the primary exited (the pane `font --pane left`, and the default, writes); + /// nil/omitted when unrealized. The live cmd +/- value, persisted for the main pane but live-only for a + /// promoted survivor. + public let fontSize: Double? + /// The split (right) pane's live font size in points; nil/omitted with no realized split pane. The read + /// side of `font --pane right`, otherwise unobservable — live-only, not persisted. + public let splitFontSize: Double? + /// The scratch terminal's live font size in points, or nil when no scratch surface is realized (omitted). + /// The read side of `font --pane scratch` (also live-only). + public let scratchFontSize: Double? + /// Addressable terminal surfaces owned by this session; nil/omitted against a server predating + /// `surface.zoom`. Hidden-but-alive surfaces are included, so a client can zoom them without unhiding. + public let surfaces: [ControlSurfaceNode]? + /// Whether the MAIN pane's terminal exists — the libghostty surface created and its program spawned — + /// as opposed to the session merely being in the model. False means `session.type`/`session.text` will + /// report `session not realized` and a `--command` has not run yet. nil/omitted only against a server + /// predating the field; this one always reports it. + /// + /// `session.new` answers `ok` for a model insert, which is honest but says nothing about the terminal: + /// libghostty refuses to create a surface while the display is asleep, so a session a scheduled job + /// creates overnight sits unrealized until the displays wake (#416). This is the field that tells them + /// apart. It reports the main pane because that is what `--command` spawns on and what the input/read + /// commands address by default; per-pane liveness is `fontSize`/`splitFontSize`/`scratchFontSize`, + /// each omitted when its pane is unrealized. + public let realized: Bool? + /// The id of the WINDOW that owns this session; nil/omitted in a host-produced tree that projects no + /// window. The spawned shell's `AGTERM_WINDOW_ID` is a spawn-time snapshot that goes stale on a + /// `session.move --to-window`, so this — rebuilt per request — is the source of truth. + public let windowId: String? + /// The id of the WORKSPACE that owns this session, the workspace node it is nested under. Stamped on the + /// node so a caller who found a session by id knows its owner without walking back up the tree; + /// `AGTERM_WORKSPACE_ID` goes stale on any `session.move` the same way `AGTERM_WINDOW_ID` does. Present + /// exactly when `windowId` is — one ownership stamp, not two independent ones. + public let workspaceId: String? + + public init(id: String, name: String, cwd: String, title: String? = nil, active: Bool, split: Bool, + hasSplit: Bool? = nil, splitAxis: String? = nil, + splitRatio: Double? = nil, splitFocused: Bool? = nil, + overlay: Bool = false, overlaySizePercent: Int? = nil, paneOverlays: [String]? = nil, + hud: ControlHudNode? = nil, scratch: Bool = false, flagged: Bool = false, + commandWait: Bool? = nil, + foreground: [String]? = nil, splitForeground: [String]? = nil, + restoreCommand: String? = nil, splitRestoreCommand: String? = nil, status: String? = nil, + statusPane: String? = nil, statusBlink: Bool? = nil, statusColor: String? = nil, + statusShape: String? = nil, statusChangedAt: Double? = nil, + background: BackgroundWatermark? = nil, unseen: Int? = nil, + fontSize: Double? = nil, splitFontSize: Double? = nil, scratchFontSize: Double? = nil, + surfaces: [ControlSurfaceNode]? = nil, realized: Bool? = nil, + windowId: String? = nil, workspaceId: String? = nil) { + self.id = id + self.name = name + self.cwd = cwd + self.title = title + self.active = active + self.split = split + self.hasSplit = hasSplit + self.splitAxis = splitAxis + self.splitRatio = splitRatio + self.splitFocused = splitFocused + self.overlay = overlay + self.overlaySizePercent = overlaySizePercent + self.paneOverlays = paneOverlays + self.hud = hud + self.scratch = scratch + self.flagged = flagged + self.commandWait = commandWait + self.foreground = foreground + self.splitForeground = splitForeground + self.restoreCommand = restoreCommand + self.splitRestoreCommand = splitRestoreCommand + self.status = status + self.statusPane = statusPane + self.statusBlink = statusBlink + self.statusColor = statusColor + self.statusShape = statusShape + self.statusChangedAt = statusChangedAt + self.background = background + self.unseen = unseen + self.fontSize = fontSize + self.splitFontSize = splitFontSize + self.scratchFontSize = scratchFontSize + self.surfaces = surfaces + self.realized = realized + self.windowId = windowId + self.workspaceId = workspaceId + } +} + +/// A workspace and its sessions as projected into the `tree` response. +public struct ControlWorkspaceNode: Codable, Sendable, Equatable { + public let id: String + public let name: String + public let active: Bool + /// Whether this workspace is a MEMBER of the sidebar's focus set; nil/omitted when not. Reported + /// INDEPENDENTLY of whether the filter is applied (that flag is the tree top-level `workspaceFilter`), so + /// a marked-but-not-filtering set reads back. Distinct from `active` (the CURRENT workspace — what + /// `--target active` resolves to, which an empty or foreground-created destination makes current while + /// the selected session stays behind in another one). The read + /// side of the write-only `workspace.focus`/`workspace.filter`. + /// + /// A workspace ROW is VISIBLE iff `tree.sidebarVisible && tree.sidebarMode == "tree" && + /// (!tree.workspaceFilter || focused)`, every term on the same `tree` response — no second call needed. + /// Both shorter forms are wrong: `focused && workspaceFilter` reports nothing visible while the filter is + /// off, and a bare `!workspaceFilter || focused` reports rows behind a hidden sidebar and in `"flagged"` + /// mode, which renders a FLAT flagged-session list with NO workspace rows whatever membership says. The + /// filter-ON term is exact because enabled-with-an-empty-set is unrepresentable (enabling an empty set is + /// refused; restore prunes stale ids then disables when it empties), so an applied filter always has at + /// least one visible member. + public let focused: Bool? + /// Whether this workspace is COLLAPSED in the sidebar tree; nil when expanded (the default), so an + /// all-expanded tree omits it, matching the persisted `WorkspaceSnapshot.collapsed`. The read side of + /// `workspace.collapse`/`workspace.expand` and `workspace.new --collapsed`. Reports the persisted + /// `!isExpanded`, independent of a transient focus force-reveal. + public let collapsed: Bool? + public let sessions: [ControlSessionNode] + + public init(id: String, name: String, active: Bool, focused: Bool? = nil, + collapsed: Bool? = nil, sessions: [ControlSessionNode]) { + self.id = id + self.name = name + self.active = active + self.focused = focused + self.collapsed = collapsed + self.sessions = sessions + } +} + +/// The whole workspace tree, the payload of a `tree` response. +public struct ControlTree: Codable, Sendable, Equatable { + public let workspaces: [ControlWorkspaceNode] + /// Milliseconds since the last user input in the projected window; nil/omitted before any activity. A + /// LIVE, continuously-growing delta — `tree`-only, since the tree is built fresh per request on the main + /// actor while cache-served `window.list` would freeze it between commands. The auto-follow idle metric. + public let idleMs: Int? + /// The window's auto-follow-blocked timeout in milliseconds, or nil when the feature is disabled + /// (omitted from the JSON). The read side of the GUI-only Auto-follow setting. + public let autoFollowMs: Int? + /// Whether the projected window's sidebar is visible. LIVE, built fresh from the window's store per + /// request — the read side of the write-only `sidebar` command. Always present on a `tree` response (the + /// producer passes a non-optional `Bool`), unlike `idleMs`/`autoFollowMs`; the `window.list` copy omits + /// it for a closed window. + public let sidebarVisible: Bool? + /// The projected window's sidebar VIEW mode — `SidebarMode.rawValue` (`tree` = the workspace tree, + /// `flagged` = the flat flagged working-set list). LIVE and always populated on an app-produced `tree`; + /// optional at the protocol level (like the other `tree` fields) for version skew. The read side of the + /// write-only `sidebar.mode`. `tree`-only, as every field below is: a GUI toggle bypasses the command + /// path, so a cached `window.list` copy would go stale. + public let sidebarMode: String? + /// Whether the projected window's workspace focus FILTER is applied — the flag half of the focus set, + /// whose member half is each workspace node's `focused`. Only ONE term of the row-visibility predicate; + /// see `focused`. LIVE and `tree`-only (the bottom-bar toggle and the row menu flip it outside the + /// command path). The read side of the write-only `workspace.filter`. nil in a host-produced tree that + /// projects no window. + public let workspaceFilter: Bool? + /// Whether the projected window's quick terminal is visible. LIVE, resolved app-side per request from the + /// window's `QuickTerminalController`, so the `quick` toggle can be made idempotent. The read side of the + /// write-only `quick` command; `tree`-only (the GUI ⌃` toggle). nil in a host-produced tree with no app + /// closure. + public let quickVisible: Bool? + /// The control id of the surface terminal zoom fills the projected window with — + /// `surface::`, or `quick` for the quick terminal — nil/omitted when nothing is zoomed. + /// LIVE, resolved app-side per request from the window's `TerminalZoomController`: the read side of the + /// write-only `surface.zoom`; `tree`-only. + public let zoomedSurface: String? + /// The open dashboard's cells as pane refs in grid order (`:left` primary, + /// `:right` split), so a split session appears as TWO refs; nil/omitted with no dashboard. + /// LIVE, resolved app-side per request from the projected window's `DashboardController` — the read side + /// of the write-only `dashboard` command; `tree`-only. nil in a host-produced tree with no app closure. + public let dashboardMembers: [String]? + /// The pane ref (`:left`/`:right`) of the dashboard's highlighted cell — the one Enter jumps + /// into, focusing that exact pane; nil/omitted with no dashboard. LIVE from the window's + /// `DashboardController`, the read side of the keyboard highlight nav. + public let dashboardHighlighted: String? + /// The absolute font size in points applied to the dashboard cells; nil/omitted with no dashboard OR an + /// untouched font (the members keep their own size). LIVE from the window's `DashboardController`, the + /// read side of `dashboard --font-size`/`--auto-size`. + public let dashboardFontSize: Double? + /// The dashboard's font mode — `auto` (`--auto-size`), `fixed` (`--font-size`), `untouched`; nil/omitted + /// with no dashboard. LIVE from the window's `DashboardController`, the read side of the font flags. + public let dashboardFontMode: String? + /// The id of the picker currently awaiting a choice, or nil when no picker is open. + public let pickPending: String? + /// The app serving this socket. Constant rather than live like every field above it, and present so an + /// agent already reading the tree gets its version floor without a second round-trip; `version` answers + /// the same question for a caller that has no tree, no window, and no JSON parser. + public let app: AppIdentity? + /// The id of the window this tree projects. The tree has always been a SINGLE-window projection without + /// saying which one, which `tree --all-windows` makes unreadable; nil in a host-produced tree that + /// projects no window. + public let windowId: String? + /// The projected window's name, so `tree --all-windows` can head each section with something a human + /// picked; nil wherever `windowId` is. + public let windowName: String? + + public init(workspaces: [ControlWorkspaceNode], idleMs: Int? = nil, autoFollowMs: Int? = nil, + sidebarVisible: Bool? = nil, sidebarMode: String? = nil, workspaceFilter: Bool? = nil, + quickVisible: Bool? = nil, + zoomedSurface: String? = nil, dashboardMembers: [String]? = nil, + dashboardHighlighted: String? = nil, dashboardFontSize: Double? = nil, + dashboardFontMode: String? = nil, pickPending: String? = nil, + app: AppIdentity? = nil, windowId: String? = nil, windowName: String? = nil) { + self.workspaces = workspaces + self.idleMs = idleMs + self.autoFollowMs = autoFollowMs + self.sidebarVisible = sidebarVisible + self.sidebarMode = sidebarMode + self.workspaceFilter = workspaceFilter + self.quickVisible = quickVisible + self.zoomedSurface = zoomedSurface + self.dashboardMembers = dashboardMembers + self.dashboardHighlighted = dashboardHighlighted + self.dashboardFontSize = dashboardFontSize + self.dashboardFontMode = dashboardFontMode + self.pickPending = pickPending + self.app = app + self.windowId = windowId + self.windowName = windowName + } +} + +/// An open window's on-screen frame — the read side of write-only `window.move`/`window.resize`, in the +/// SAME coordinate system those accept so a read-then-restore round-trips: `x`/`y` the top-left relative to +/// `display`'s top-left (y down), `width`/`height` the frame size in points, `display` a screen-list index. +public struct ControlWindowFrame: Codable, Sendable, Equatable { + public let x: Int + public let y: Int + public let width: Int + public let height: Int + public let display: Int + + public init(x: Int, y: Int, width: Int, height: Int, display: Int) { + self.x = x + self.y = y + self.width = width + self.height = height + self.display = display + } +} + +/// A window as projected into the `window.list` response. `open` is whether its on-screen window is +/// up; `active` is whether it is the frontmost window. +public struct ControlWindowNode: Codable, Sendable, Equatable { + public let id: String + public let name: String + public let open: Bool + public let active: Bool + /// The window's auto-follow-blocked timeout in milliseconds; nil/omitted when disabled. As of the last + /// cache refresh — `window.list` answers from a nonisolated fast path, so a just-changed setting lags + /// until the next command; the live `idleMs` is kept off `window.list` (tree-only) for that reason. + public let autoFollowMs: Int? + /// Whether this window's sidebar is visible; nil/omitted for a CLOSED window with no live store. Read + /// from the open window's store, mirroring `autoFollowMs`. The read side of `sidebar`, per window. + public let sidebarVisible: Bool? + /// The window's on-screen frame (position + size + display); nil/omitted for a CLOSED window with no live + /// NSWindow. The read side of `window.move`/`window.resize`. Read live app-side on the window cache, + /// refreshed on move/resize/zoom/fullscreen (`ControlServer` observes the NSWindow notifications), so a + /// hand-drag or GUI toggle shows up without another command. + public let geometry: ControlWindowFrame? + /// Whether the window is in native macOS full screen; nil/omitted for a CLOSED window. The read side of + /// the write-only `window.fullscreen` toggle, so it can be made idempotent. Read live app-side; like + /// `geometry` it rides the cache. + public let fullscreen: Bool? + /// Whether the window is zoomed (maximized-to-screen, NOT full screen), or nil for a CLOSED window + /// (omitted from the JSON). The read side of the write-only `window.zoom` toggle. Read live app-side. + public let zoomed: Bool? + /// Whether the window is minimized to the Dock; nil/omitted for a CLOSED window. The read side of + /// `window.minimize`. Live app-side on the cache, refreshed on the NSWindow miniaturize/deminiaturize + /// notifications so ⌘M or a Dock click shows too. A minimized window still reports its `geometry` (where + /// it comes back to). + public let minimized: Bool? + + public init(id: String, name: String, open: Bool, active: Bool, autoFollowMs: Int? = nil, + sidebarVisible: Bool? = nil, geometry: ControlWindowFrame? = nil, + fullscreen: Bool? = nil, zoomed: Bool? = nil, minimized: Bool? = nil) { + self.id = id + self.name = name + self.open = open + self.active = active + self.autoFollowMs = autoFollowMs + self.sidebarVisible = sidebarVisible + self.geometry = geometry + self.fullscreen = fullscreen + self.zoomed = zoomed + self.minimized = minimized + } +} diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift index 4e6b41111..5984c5b35 100644 --- a/agtermCore/Sources/agtermCore/ControlProtocol.swift +++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift @@ -267,6 +267,12 @@ public struct ControlArgs: Codable, Sendable, Equatable { /// Target window whose tree a session/workspace/tree/font command operates on: id / prefix / `active` /// (= frontmost). public var window: String? + /// DESTINATION window for `session.move`: id / prefix / `active`. Distinct from `window`, which stays a + /// search scope for `target`, and must name an OPEN window. + public var toWindow: String? + /// For `tree`: project EVERY open window instead of one, answering into `result.trees`. Mutually + /// exclusive with `window`, which names a single one. + public var allWindows: Bool? /// New window frame width/height in points for `window.resize`. public var width: Int? public var height: Int? @@ -314,6 +320,7 @@ public struct ControlArgs: Codable, Sendable, Equatable { follow: Bool? = nil, message: String? = nil, detail: String? = nil, spinner: String? = nil, items: [ControlPickItem]? = nil, prompt: String? = nil, query: String? = nil, allowCustom: Bool? = nil, window: String? = nil, + toWindow: String? = nil, allWindows: Bool? = nil, pane: String? = nil, paneID: String? = nil, to: String? = nil, after: String? = nil, before: String? = nil, run: String? = nil, kinds: [String]? = nil, limit: Int? = nil, @@ -352,6 +359,8 @@ public struct ControlArgs: Codable, Sendable, Equatable { self.query = query self.allowCustom = allowCustom self.window = window + self.toWindow = toWindow + self.allWindows = allWindows self.pane = pane self.paneID = paneID self.to = to @@ -414,435 +423,14 @@ public struct ControlRequest: Codable, Sendable, Equatable { } } -/// A terminal surface as projected into the `tree` response. `id` is the stable control address for -/// `surface.zoom`; `kind` the user-facing name (`left`, `right`, `scratch`, `overlay`). `active`/`visible` -/// derive from the session's own flags (overlay/scratch/splitFocused), NOT from terminal zoom, and `visible` -/// reads false for a pane behind a FLOATING overlay though it is visually on screen (any open overlay counts -/// as covering). Address by `id`/`kind`, not these flags; the zoom state is the top-level `zoomedSurface`. -public struct ControlSurfaceNode: Codable, Sendable, Equatable { - public let id: String - public let kind: String - public let active: Bool - public let visible: Bool - - public init(id: String, kind: String, active: Bool, visible: Bool) { - self.id = id - self.kind = kind - self.active = active - self.visible = visible - } -} - -/// The HUD panel occupying a session's overlay slot, as projected into the `tree` response. Present only -/// while a HUD is up, and the session node's `overlay` reads FALSE beside it, so a script polling "is a -/// program covering this session" can never mistake a message for a running program. The read side of -/// `session.hud.open`/`.update`; HUD state is poll-only, no event announces it. -public struct ControlHudNode: Codable, Sendable, Equatable { - public let message: String - /// The dim second line; nil/omitted when the caller set none. - public let detail: String? - /// The EFFECTIVE spinner style, a `HudSpinner` raw value or `HudSpinner.noneName`. Always present, the - /// static case included, so a caller reads one field rather than inferring absence. - public let spinner: String - /// The panel's own `#rrggbb` background; nil/omitted when it keeps the session's terminal background. The - /// color the open set, which survives every `session.hud.update` — the surface reads it once at creation, - /// so this always names what the panel paints. - public let backgroundColor: String? - /// The panel's `#rrggbb` TEXT color; nil/omitted when it keeps the terminal foreground. Unlike - /// `backgroundColor` this tracks the LATEST `session.hud.update`, the header the helper re-reads being - /// what paints it. - public let textColor: String? - /// The EFFECTIVE share of the pane's WIDTH the panel occupies — the app's measurement, or the caller's - /// `sizePercent` override, either way bounded by `HudLayout.clampSizePercent`, so a requested 100 reads - /// back as the maximum a HUD may take. Reported here because the node's `overlaySizePercent` stays - /// omitted for a HUD. Optional because it projects the slot's optional percent, but no supported path - /// leaves a live HUD sizeless: `openHud` always sets one and `overlay.resize --full` is refused. - public let sizePercent: Int? - /// The EFFECTIVE share of the pane's HEIGHT, always measured from the message (`HudLayout.heightPercent`) - /// because no command sets it. Reported beside `sizePercent` so a caller polling the panel's geometry - /// reads both axes rather than assuming one square. - public let heightPercent: Int? - /// The EFFECTIVE placement, a CANONICAL `HudPosition` raw value — one of the nine anchors, never one of - /// the accepted `top`/`bottom` aliases, so a caller reads one spelling whichever he sent. Always present, - /// including the `center` default, so a caller who omitted it never has to know what the default is. - public let position: String - - public init(message: String, detail: String? = nil, spinner: String = HudSpinner.noneName, - backgroundColor: String? = nil, textColor: String? = nil, - sizePercent: Int? = nil, heightPercent: Int? = nil, position: String) { - self.message = message - self.detail = detail - self.spinner = spinner - self.backgroundColor = backgroundColor - self.textColor = textColor - self.sizePercent = sizePercent - self.heightPercent = heightPercent - self.position = position - } -} - -/// A session as projected into the `tree` response. -public struct ControlSessionNode: Codable, Sendable, Equatable { - public let id: String - public let name: String - public let cwd: String - /// The raw terminal title from the latest OSC 0/1/2 (a remote host over SSH, a shell `PROMPT_COMMAND`); - /// nil/omitted when none reported. The unprocessed `Session.oscTitle`, distinct from `name` (the derived - /// sidebar label, which uses it as one fallback); a remote session's local `cwd` goes stale, this does not. - public let title: String? - public let active: Bool - /// Whether the split is SHOWN side by side, the read side of `session.split on|off`. A split hidden with - /// ⌘D reports `false` while its pane stays alive, so a caller asking "is there a second pane" must read - /// `hasSplit`, not this. - public let split: Bool - /// Whether the session HAS a split pane at all, shown or hidden; nil/omitted when it has none. Present - /// exactly when `splitRatio`/`splitFocused` can be, which is what makes those two readable without - /// second-guessing `split`. The sidebar icon, the dashboard's second cell and Focus Left/Right Pane all - /// follow this, not `split`. - public let hasSplit: Bool? - /// Divider direction for a live split (`vertical`=left/right, `horizontal`=top/bottom); nil without one. - public let splitAxis: String? - /// The primary-pane fraction (0.05...0.95) of a session that HAS a split (shown or hidden); nil with no - /// split OR when the ratio was never explicitly set (via `session.resize` or a divider drag), the divider - /// then sitting at the default 0.5. The read side of `session.resize`, otherwise echoed only on that call. - public let splitRatio: Double? - /// For a session that HAS a split (shown or hidden), which pane holds keyboard focus: `true` = split - /// (right), `false` = main (left); nil/omitted with no split. The read side of `session.focus`. - public let splitFocused: Bool? - /// Whether a caller's PROGRAM occupies the session-wide overlay slot. False while a HUD holds it — the - /// HUD is a message, not a running program, and it reports itself in `hud` instead. - public let overlay: Bool - /// An OPEN overlay's size (`overlay == true`): nil/omitted = FULL-pane, else the floating panel's percent - /// of the pane (1...100); absent with no overlay AND while a HUD holds the slot, whose size is `hud`'s. - /// The read side of `session.overlay.resize`. - public let overlaySizePercent: Int? - /// The panes covered by their OWN overlay, ordered left then right (`["left"]`, `["right"]`, - /// `["left","right"]`); nil/omitted when neither has one. Independent of `overlay`, the session-wide - /// one — both kinds can be up at once. The read side of `session.overlay.open --pane`; those overlays - /// are always full-pane, so there is no per-pane size to report. - public let paneOverlays: [String]? - /// The HUD panel occupying the session-wide overlay slot; nil/omitted when none is up. Mutually exclusive - /// with `overlay` — one slot, and whichever holds it is the one that reports. - public let hud: ControlHudNode? - public let scratch: Bool - public let flagged: Bool - /// For a `--command` session, whether it HOLDS its surface after the command exits (`session.new - /// --command … --wait`) instead of closing; nil/omitted for a plain or non-holding session. The read - /// side of `session.new --wait`; it persists across restart, unlike an overlay's live-only wait. - public let commandWait: Bool? - /// The LIVE foreground process command (full argv) in the main pane; nil/omitted at the shell prompt — - /// the same capture restore-running-command uses. - public let foreground: [String]? - /// The split (right) pane's live foreground command (full argv), the split analogue of `foreground`. - public let splitForeground: [String]? - /// The main pane's PERSISTED restore-command override, the read side of `session.restore`. Tri-state: - /// omitted = no override (auto-capture), `""` = pinned to nothing (a plain shell), a command = that shell - /// line runs on the next launch. Read from persisted state, so it still reports the pin after the - /// override fired. Unrelated to `foreground`, the LIVE process. - public let restoreCommand: String? - /// The split (right) pane's persisted restore-command override, the split analogue of `restoreCommand` - /// (the read side of `session.restore --pane right`). - public let splitRestoreCommand: String? - /// The session's agent status (`active`/`completed`/`blocked`) as the `AgentStatus` raw value; - /// nil/omitted when idle. The read side of `session.status`. - public let status: String? - /// Which pane set the agent status (`"left"|"right"|"scratch"`, `left`=main, `right`=split); nil/omitted - /// when idle or unspecified. The read side of `session.status --pane`. - public let statusPane: String? - /// Whether the agent-status glyph blinks (pulses for attention); nil/omitted when idle or not blinking. - /// The read side of `session.status --blink`. - public let statusBlink: Bool? - /// The per-call `#rrggbb` glyph-tint override; nil/omitted when idle or using the Settings status color. - /// The read side of `session.status --color`. - public let statusColor: String? - /// The per-call glyph-silhouette override (a `StatusShape` raw value); nil/omitted when idle or drawing - /// the Settings shape / the default plain circle. The read side of `session.status --shape` — the - /// PER-CALL override only, exactly like `statusColor`. - public let statusShape: String? - /// When the agent status was last SET, as epoch seconds on the `ControlEvent.ts` clock (so the two - /// compare directly); nil/omitted when idle. Stamped on EVERY non-idle `session.status`, not only on a - /// change of state, so a hook re-pushing `active` refreshes it and "now minus this" reads as how long ago - /// the status was last WRITTEN — normally the agent's own push, though a pane promotion re-tags the - /// indicator and counts too. Ephemeral like `status` and `unseen` — never persisted. - public let statusChangedAt: Double? - /// The session's background watermark spec; nil/omitted when none is set. The read side of - /// `session.background`. - public let background: BackgroundWatermark? - /// The session's unseen-notification badge count; nil/omitted when zero. `notify` (and terminal OSC - /// 9/777) raise it, `session.seen` clears it. Ephemeral like `status` — never persisted, resets on restart. - public let unseen: Int? - /// The default/left pane's live font size in points via `addressableSurface`: the main pane, or the - /// promoted split survivor once the primary exited (the pane `font --pane left`, and the default, writes); - /// nil/omitted when unrealized. The live cmd +/- value, persisted for the main pane but live-only for a - /// promoted survivor. - public let fontSize: Double? - /// The split (right) pane's live font size in points; nil/omitted with no realized split pane. The read - /// side of `font --pane right`, otherwise unobservable — live-only, not persisted. - public let splitFontSize: Double? - /// The scratch terminal's live font size in points, or nil when no scratch surface is realized (omitted). - /// The read side of `font --pane scratch` (also live-only). - public let scratchFontSize: Double? - /// Addressable terminal surfaces owned by this session; nil/omitted against a server predating - /// `surface.zoom`. Hidden-but-alive surfaces are included, so a client can zoom them without unhiding. - public let surfaces: [ControlSurfaceNode]? - /// Whether the MAIN pane's terminal exists — the libghostty surface created and its program spawned — - /// as opposed to the session merely being in the model. False means `session.type`/`session.text` will - /// report `session not realized` and a `--command` has not run yet. nil/omitted only against a server - /// predating the field; this one always reports it. - /// - /// `session.new` answers `ok` for a model insert, which is honest but says nothing about the terminal: - /// libghostty refuses to create a surface while the display is asleep, so a session a scheduled job - /// creates overnight sits unrealized until the displays wake (#416). This is the field that tells them - /// apart. It reports the main pane because that is what `--command` spawns on and what the input/read - /// commands address by default; per-pane liveness is `fontSize`/`splitFontSize`/`scratchFontSize`, - /// each omitted when its pane is unrealized. - public let realized: Bool? - - public init(id: String, name: String, cwd: String, title: String? = nil, active: Bool, split: Bool, - hasSplit: Bool? = nil, splitAxis: String? = nil, - splitRatio: Double? = nil, splitFocused: Bool? = nil, - overlay: Bool = false, overlaySizePercent: Int? = nil, paneOverlays: [String]? = nil, - hud: ControlHudNode? = nil, scratch: Bool = false, flagged: Bool = false, - commandWait: Bool? = nil, - foreground: [String]? = nil, splitForeground: [String]? = nil, - restoreCommand: String? = nil, splitRestoreCommand: String? = nil, status: String? = nil, - statusPane: String? = nil, statusBlink: Bool? = nil, statusColor: String? = nil, - statusShape: String? = nil, statusChangedAt: Double? = nil, - background: BackgroundWatermark? = nil, unseen: Int? = nil, - fontSize: Double? = nil, splitFontSize: Double? = nil, scratchFontSize: Double? = nil, - surfaces: [ControlSurfaceNode]? = nil, realized: Bool? = nil) { - self.id = id - self.name = name - self.cwd = cwd - self.title = title - self.active = active - self.split = split - self.hasSplit = hasSplit - self.splitAxis = splitAxis - self.splitRatio = splitRatio - self.splitFocused = splitFocused - self.overlay = overlay - self.overlaySizePercent = overlaySizePercent - self.paneOverlays = paneOverlays - self.hud = hud - self.scratch = scratch - self.flagged = flagged - self.commandWait = commandWait - self.foreground = foreground - self.splitForeground = splitForeground - self.restoreCommand = restoreCommand - self.splitRestoreCommand = splitRestoreCommand - self.status = status - self.statusPane = statusPane - self.statusBlink = statusBlink - self.statusColor = statusColor - self.statusShape = statusShape - self.statusChangedAt = statusChangedAt - self.background = background - self.unseen = unseen - self.fontSize = fontSize - self.splitFontSize = splitFontSize - self.scratchFontSize = scratchFontSize - self.surfaces = surfaces - self.realized = realized - } -} - -/// A workspace and its sessions as projected into the `tree` response. -public struct ControlWorkspaceNode: Codable, Sendable, Equatable { - public let id: String - public let name: String - public let active: Bool - /// Whether this workspace is a MEMBER of the sidebar's focus set; nil/omitted when not. Reported - /// INDEPENDENTLY of whether the filter is applied (that flag is the tree top-level `workspaceFilter`), so - /// a marked-but-not-filtering set reads back. Distinct from `active` (the CURRENT workspace — what - /// `--target active` resolves to, which an empty or foreground-created destination makes current while - /// the selected session stays behind in another one). The read - /// side of the write-only `workspace.focus`/`workspace.filter`. - /// - /// A workspace ROW is VISIBLE iff `tree.sidebarVisible && tree.sidebarMode == "tree" && - /// (!tree.workspaceFilter || focused)`, every term on the same `tree` response — no second call needed. - /// Both shorter forms are wrong: `focused && workspaceFilter` reports nothing visible while the filter is - /// off, and a bare `!workspaceFilter || focused` reports rows behind a hidden sidebar and in `"flagged"` - /// mode, which renders a FLAT flagged-session list with NO workspace rows whatever membership says. The - /// filter-ON term is exact because enabled-with-an-empty-set is unrepresentable (enabling an empty set is - /// refused; restore prunes stale ids then disables when it empties), so an applied filter always has at - /// least one visible member. - public let focused: Bool? - /// Whether this workspace is COLLAPSED in the sidebar tree; nil when expanded (the default), so an - /// all-expanded tree omits it, matching the persisted `WorkspaceSnapshot.collapsed`. The read side of - /// `workspace.collapse`/`workspace.expand` and `workspace.new --collapsed`. Reports the persisted - /// `!isExpanded`, independent of a transient focus force-reveal. - public let collapsed: Bool? - public let sessions: [ControlSessionNode] - - public init(id: String, name: String, active: Bool, focused: Bool? = nil, - collapsed: Bool? = nil, sessions: [ControlSessionNode]) { - self.id = id - self.name = name - self.active = active - self.focused = focused - self.collapsed = collapsed - self.sessions = sessions - } -} - -/// The whole workspace tree, the payload of a `tree` response. -public struct ControlTree: Codable, Sendable, Equatable { - public let workspaces: [ControlWorkspaceNode] - /// Milliseconds since the last user input in the projected window; nil/omitted before any activity. A - /// LIVE, continuously-growing delta — `tree`-only, since the tree is built fresh per request on the main - /// actor while cache-served `window.list` would freeze it between commands. The auto-follow idle metric. - public let idleMs: Int? - /// The window's auto-follow-blocked timeout in milliseconds, or nil when the feature is disabled - /// (omitted from the JSON). The read side of the GUI-only Auto-follow setting. - public let autoFollowMs: Int? - /// Whether the projected window's sidebar is visible. LIVE, built fresh from the window's store per - /// request — the read side of the write-only `sidebar` command. Always present on a `tree` response (the - /// producer passes a non-optional `Bool`), unlike `idleMs`/`autoFollowMs`; the `window.list` copy omits - /// it for a closed window. - public let sidebarVisible: Bool? - /// The projected window's sidebar VIEW mode — `SidebarMode.rawValue` (`tree` = the workspace tree, - /// `flagged` = the flat flagged working-set list). LIVE and always populated on an app-produced `tree`; - /// optional at the protocol level (like the other `tree` fields) for version skew. The read side of the - /// write-only `sidebar.mode`. `tree`-only, as every field below is: a GUI toggle bypasses the command - /// path, so a cached `window.list` copy would go stale. - public let sidebarMode: String? - /// Whether the projected window's workspace focus FILTER is applied — the flag half of the focus set, - /// whose member half is each workspace node's `focused`. Only ONE term of the row-visibility predicate; - /// see `focused`. LIVE and `tree`-only (the bottom-bar toggle and the row menu flip it outside the - /// command path). The read side of the write-only `workspace.filter`. nil in a host-produced tree that - /// projects no window. - public let workspaceFilter: Bool? - /// Whether the projected window's quick terminal is visible. LIVE, resolved app-side per request from the - /// window's `QuickTerminalController`, so the `quick` toggle can be made idempotent. The read side of the - /// write-only `quick` command; `tree`-only (the GUI ⌃` toggle). nil in a host-produced tree with no app - /// closure. - public let quickVisible: Bool? - /// The control id of the surface terminal zoom fills the projected window with — - /// `surface::`, or `quick` for the quick terminal — nil/omitted when nothing is zoomed. - /// LIVE, resolved app-side per request from the window's `TerminalZoomController`: the read side of the - /// write-only `surface.zoom`; `tree`-only. - public let zoomedSurface: String? - /// The open dashboard's cells as pane refs in grid order (`:left` primary, - /// `:right` split), so a split session appears as TWO refs; nil/omitted with no dashboard. - /// LIVE, resolved app-side per request from the projected window's `DashboardController` — the read side - /// of the write-only `dashboard` command; `tree`-only. nil in a host-produced tree with no app closure. - public let dashboardMembers: [String]? - /// The pane ref (`:left`/`:right`) of the dashboard's highlighted cell — the one Enter jumps - /// into, focusing that exact pane; nil/omitted with no dashboard. LIVE from the window's - /// `DashboardController`, the read side of the keyboard highlight nav. - public let dashboardHighlighted: String? - /// The absolute font size in points applied to the dashboard cells; nil/omitted with no dashboard OR an - /// untouched font (the members keep their own size). LIVE from the window's `DashboardController`, the - /// read side of `dashboard --font-size`/`--auto-size`. - public let dashboardFontSize: Double? - /// The dashboard's font mode — `auto` (`--auto-size`), `fixed` (`--font-size`), `untouched`; nil/omitted - /// with no dashboard. LIVE from the window's `DashboardController`, the read side of the font flags. - public let dashboardFontMode: String? - /// The id of the picker currently awaiting a choice, or nil when no picker is open. - public let pickPending: String? - /// The app serving this socket. Constant rather than live like every field above it, and present so an - /// agent already reading the tree gets its version floor without a second round-trip; `version` answers - /// the same question for a caller that has no tree, no window, and no JSON parser. - public let app: AppIdentity? - - public init(workspaces: [ControlWorkspaceNode], idleMs: Int? = nil, autoFollowMs: Int? = nil, - sidebarVisible: Bool? = nil, sidebarMode: String? = nil, workspaceFilter: Bool? = nil, - quickVisible: Bool? = nil, - zoomedSurface: String? = nil, dashboardMembers: [String]? = nil, - dashboardHighlighted: String? = nil, dashboardFontSize: Double? = nil, - dashboardFontMode: String? = nil, pickPending: String? = nil, - app: AppIdentity? = nil) { - self.workspaces = workspaces - self.idleMs = idleMs - self.autoFollowMs = autoFollowMs - self.sidebarVisible = sidebarVisible - self.sidebarMode = sidebarMode - self.workspaceFilter = workspaceFilter - self.quickVisible = quickVisible - self.zoomedSurface = zoomedSurface - self.dashboardMembers = dashboardMembers - self.dashboardHighlighted = dashboardHighlighted - self.dashboardFontSize = dashboardFontSize - self.dashboardFontMode = dashboardFontMode - self.pickPending = pickPending - self.app = app - } -} - -/// An open window's on-screen frame — the read side of write-only `window.move`/`window.resize`, in the -/// SAME coordinate system those accept so a read-then-restore round-trips: `x`/`y` the top-left relative to -/// `display`'s top-left (y down), `width`/`height` the frame size in points, `display` a screen-list index. -public struct ControlWindowFrame: Codable, Sendable, Equatable { - public let x: Int - public let y: Int - public let width: Int - public let height: Int - public let display: Int - - public init(x: Int, y: Int, width: Int, height: Int, display: Int) { - self.x = x - self.y = y - self.width = width - self.height = height - self.display = display - } -} - -/// A window as projected into the `window.list` response. `open` is whether its on-screen window is -/// up; `active` is whether it is the frontmost window. -public struct ControlWindowNode: Codable, Sendable, Equatable { - public let id: String - public let name: String - public let open: Bool - public let active: Bool - /// The window's auto-follow-blocked timeout in milliseconds; nil/omitted when disabled. As of the last - /// cache refresh — `window.list` answers from a nonisolated fast path, so a just-changed setting lags - /// until the next command; the live `idleMs` is kept off `window.list` (tree-only) for that reason. - public let autoFollowMs: Int? - /// Whether this window's sidebar is visible; nil/omitted for a CLOSED window with no live store. Read - /// from the open window's store, mirroring `autoFollowMs`. The read side of `sidebar`, per window. - public let sidebarVisible: Bool? - /// The window's on-screen frame (position + size + display); nil/omitted for a CLOSED window with no live - /// NSWindow. The read side of `window.move`/`window.resize`. Read live app-side on the window cache, - /// refreshed on move/resize/zoom/fullscreen (`ControlServer` observes the NSWindow notifications), so a - /// hand-drag or GUI toggle shows up without another command. - public let geometry: ControlWindowFrame? - /// Whether the window is in native macOS full screen; nil/omitted for a CLOSED window. The read side of - /// the write-only `window.fullscreen` toggle, so it can be made idempotent. Read live app-side; like - /// `geometry` it rides the cache. - public let fullscreen: Bool? - /// Whether the window is zoomed (maximized-to-screen, NOT full screen), or nil for a CLOSED window - /// (omitted from the JSON). The read side of the write-only `window.zoom` toggle. Read live app-side. - public let zoomed: Bool? - /// Whether the window is minimized to the Dock; nil/omitted for a CLOSED window. The read side of - /// `window.minimize`. Live app-side on the cache, refreshed on the NSWindow miniaturize/deminiaturize - /// notifications so ⌘M or a Dock click shows too. A minimized window still reports its `geometry` (where - /// it comes back to). - public let minimized: Bool? - - public init(id: String, name: String, open: Bool, active: Bool, autoFollowMs: Int? = nil, - sidebarVisible: Bool? = nil, geometry: ControlWindowFrame? = nil, - fullscreen: Bool? = nil, zoomed: Bool? = nil, minimized: Bool? = nil) { - self.id = id - self.name = name - self.open = open - self.active = active - self.autoFollowMs = autoFollowMs - self.sidebarVisible = sidebarVisible - self.geometry = geometry - self.fullscreen = fullscreen - self.zoomed = zoomed - self.minimized = minimized - } -} - /// The successful payload: a new/affected id for mutating commands, a tree for `tree`, the selected text /// for `session.copy`. All optional. public struct ControlResult: Codable, Sendable, Equatable { public var id: String? public var tree: ControlTree? + /// One tree per OPEN window for `tree --all-windows`, each tagged with its own `windowId`; `tree` stays + /// nil then, so a caller reads exactly one of the two. + public var trees: [ControlTree]? public var text: String? public var windows: [ControlWindowNode]? /// The overlay program's exit status for `session.overlay.result` (nil until the program exits). @@ -879,7 +467,8 @@ public struct ControlResult: Codable, Sendable, Equatable { /// The app serving this socket, for `version`. The same value `tree` carries. public var app: AppIdentity? - public init(id: String? = nil, tree: ControlTree? = nil, text: String? = nil, + public init(id: String? = nil, tree: ControlTree? = nil, trees: [ControlTree]? = nil, + text: String? = nil, windows: [ControlWindowNode]? = nil, exitCode: Int? = nil, count: Int? = nil, affected: Int? = nil, theme: String? = nil, themes: [String]? = nil, ratio: Double? = nil, @@ -889,6 +478,7 @@ public struct ControlResult: Codable, Sendable, Equatable { app: AppIdentity? = nil) { self.id = id self.tree = tree + self.trees = trees self.text = text self.windows = windows self.exitCode = exitCode diff --git a/agtermCore/Sources/agtermCore/Notifications.swift b/agtermCore/Sources/agtermCore/Notifications.swift index 8162a5ccd..0fc81f8e3 100644 --- a/agtermCore/Sources/agtermCore/Notifications.swift +++ b/agtermCore/Sources/agtermCore/Notifications.swift @@ -31,6 +31,60 @@ public enum TerminalNotification { return (windowID, sessionID, pane) } + /// Whether a banner's identity names a window that no longer hosts its session, so a click on it would + /// reopen the window the session left. `currentWindowID` is where that session lives now; nil means the + /// caller cannot tell (a plain window close), which keeps the banner and its reopen click intact. + public static func isStale(identity: String, currentWindowID: UUID?) -> Bool { + guard let target = parseIdentity(identity), let currentWindowID else { return false } + return currentWindowID != target.windowID + } + + /// A delivered banner as a sweep sees it: its identity, when the OS delivered it, and when that identity + /// was last submitted, nil for one this process never posted (an earlier launch). + public struct DeliveredBanner: Sendable { + public let identity: String + public let deliveredAt: Date + public let lastPostedAt: Date? + + public init(identity: String, deliveredAt: Date, lastPostedAt: Date?) { + self.identity = identity + self.deliveredAt = deliveredAt + self.lastPostedAt = lastPostedAt + } + + /// The later of delivery and last submission: when this banner last became something a sweep could + /// see, and what `cutoff` is judged against. + var touchedAt: Date { max(deliveredAt, lastPostedAt ?? .distantPast) } + } + + /// Whether a delivered banner is one this sweep should remove: it must belong to `sessionID`, and have + /// been neither delivered nor re-posted after `cutoff`, the moment the sweep started. The delivered set + /// is queried asynchronously and an identity is reusable, so what the query returns can have arrived — + /// or since been replaced by a newer banner reusing its identifier — after the sweep started, and + /// neither is this sweep's to take. A nil `staleRelativeTo` takes every one of the session's banners + /// (a focus clear); otherwise it spares the ones that window still owns. + public static func shouldSweep(_ banner: DeliveredBanner, sessionID: UUID, staleRelativeTo windowID: UUID?, + cutoff: Date) -> Bool { + guard let target = parseIdentity(banner.identity), target.sessionID == sessionID else { return false } + guard banner.touchedAt <= cutoff else { return false } + guard let windowID else { return true } + return windowID != target.windowID + } + + /// The move records a sweep leaves behind. A record exists only to retarget a banner of its own + /// session, so one whose session has neither a delivered banner nor unsettled work — closed since its + /// move, most often — can never be consulted again. `unsettled` names the sessions with a banner + /// submission or a concurrent sweep still outstanding, the sweeping session among them: no snapshot + /// shows their banners yet an `add` completion or a click can still ask where they live. + public static func retainedMoveRecords(_ records: [UUID: UUID], delivered: [String], + unsettled: Set) -> [UUID: UUID] { + var live = unsettled + for identity in delivered { + if let target = parseIdentity(identity) { live.insert(target.sessionID) } + } + return records.filter { live.contains($0.key) } + } + /// Whether a notification should be delivered (banner + badge). Suppressed only when the firing /// pane is currently focused AND agterm is the active app — you are already looking at it. public static func shouldDeliver(firingIsFocused: Bool, appActive: Bool) -> Bool { diff --git a/agtermCore/Sources/agtermCore/WindowLibrary.swift b/agtermCore/Sources/agtermCore/WindowLibrary.swift index 406b2af58..1359ab1d8 100644 --- a/agtermCore/Sources/agtermCore/WindowLibrary.swift +++ b/agtermCore/Sources/agtermCore/WindowLibrary.swift @@ -105,6 +105,11 @@ public final class WindowLibrary { /// next launch's reopen-all instead of being zeroed as each window tears down. @ObservationIgnored public var isTerminating = false + /// Re-points a moved session's live surfaces at the store that now owns it, called by `moveSession` after + /// a cross-window adopt. Set by the app target, which built those surfaces: their callbacks captured the + /// SOURCE store and resolve the session by id there, so without this every one of them silently no-ops. + @ObservationIgnored public var rebindAdoptedSession: ((Session, AppStore) -> Void)? + private static let indexFileName = "windows.json" private static let windowsSubdirectory = "windows" private static let legacyFileName = "workspaces.json" @@ -303,6 +308,12 @@ public final class WindowLibrary { openIDs().first { stores[$0] === store } } + /// Project every OPEN window in window order through `build` — the `tree --all-windows` fan-out. A + /// closed window has no store and is simply absent, so no caller filters for it. + public func openTrees(_ build: (AppStore) -> ControlTree) -> [ControlTree] { + openIDs().compactMap { store(for: $0).map(build) } + } + /// The window's display name, "" for a nil or unknown id — the name half of the /// `{AGT_WINDOW_NAME}`/`$AGT_WINDOW_NAME` command context. public func windowName(for id: UUID?) -> String { @@ -425,6 +436,61 @@ public final class WindowLibrary { refreshRecentClosedItems() } + /// Moves a session into another OPEN window, keeping the **same** `Session` instance so its surface and + /// live shell survive the re-host. A nil `workspace` lands in the destination's `currentWorkspaceID`; + /// `select` makes it the destination's active session, so a plain move leaves both windows' selections + /// alone and a background session stays background. A same-window call delegates to + /// `AppStore.moveSession`, keeping single-window behavior and the wire contract identical. + /// + /// False when the session is unknown, the destination window is closed (nothing would host the surface), + /// the destination workspace is unknown, or the source window has a picker pending — that picker's answer + /// is addressed to the window, mirroring the `pick pending` guard on the other surface commands. + @discardableResult + public func moveSession(_ sessionID: UUID, toWindow destinationID: UUID, workspace: UUID? = nil, + select: Bool = false) -> Bool { + guard let sourceID = windowID(forSession: sessionID), let source = stores[sourceID], + let destination = stores[destinationID] else { return false } + guard let targetWorkspace = workspace ?? destination.currentWorkspaceID, + destination.workspaces.contains(where: { $0.id == targetWorkspace }) else { return false } + guard PickRegistry.shared.controller(for: sourceID)?.pending == nil else { return false } + + if sourceID == destinationID { + source.moveSession(sessionID, toWorkspace: targetWorkspace) + if select { source.selectSession(sessionID) } + return true + } + evictWindowControllers(sessionID, in: sourceID) + let origin = source.workspace(forSession: sessionID)?.id + guard let session = source.detachSession(sessionID) else { return false } + guard destination.adoptSession(session, toWorkspace: targetWorkspace, select: select) else { + // unreachable after the guards above, but a live shell must never be dropped on the floor. + source.adoptSession(session, toWorkspace: origin) + return false + } + rebindAdoptedSession?(session, destination) + return true + } + + /// The OPEN windows a session hosted by `sourceID` can move to, in window order. Empty when that is the + /// only open window, which is what drops the sidebar's "Move to Window" submenu and its palette rows + /// instead of offering a destination that would be a no-op. + public func moveDestinations(excluding sourceID: UUID?) -> [WindowInfo] { + windows.filter { $0.id != sourceID && stores[$0.id] != nil } + } + + /// Drops the moving session from the SOURCE window's zoom target and dashboard grid: both point at an + /// NSView about to be hosted by another window. + private func evictWindowControllers(_ sessionID: UUID, in windowID: UUID) { + if let zoom = TerminalZoomRegistry.shared.controller(for: windowID), + case let .session(target, _)? = zoom.target, target == sessionID { + zoom.clear() + } + if let dashboard = DashboardControllerRegistry.shared.controller(for: windowID), + dashboard.members.contains(where: { $0.session == sessionID }) { + dashboard.close() + } + } + /// Closes a window: drops its store and persists the index. The app-target caller tears down the /// window's surfaces first. No-op for an unknown/closed id, or while terminating (see `isTerminating`). public func closeWindow(_ id: UUID) { diff --git a/agtermCore/Sources/agtermctlKit/Commands.swift b/agtermCore/Sources/agtermctlKit/Commands.swift index bcd6ef43b..1f5b0a905 100644 --- a/agtermCore/Sources/agtermctlKit/Commands.swift +++ b/agtermCore/Sources/agtermctlKit/Commands.swift @@ -130,7 +130,12 @@ struct Tree: RequestCommand { static let configuration = CommandConfiguration(abstract: "Print the workspace/session tree.") @OptionGroup var options: ClientOptions + /// Rejected server-side alongside `--window`, which names a single window; the error string lives there + /// so both the CLI and a raw protocol caller get the same one. + @Flag(name: .long, help: "Print every open window's tree, each tagged with its window id.") + var allWindows = false + func makeRequest() throws -> ControlRequest { - ControlRequest(cmd: .tree, args: options.withWindow()) + ControlRequest(cmd: .tree, args: options.withWindow(allWindows ? ControlArgs(allWindows: true) : nil)) } } diff --git a/agtermCore/Sources/agtermctlKit/SessionCommands+Overlay.swift b/agtermCore/Sources/agtermctlKit/SessionCommands+Overlay.swift new file mode 100644 index 000000000..26cddf016 --- /dev/null +++ b/agtermCore/Sources/agtermctlKit/SessionCommands+Overlay.swift @@ -0,0 +1,196 @@ +import ArgumentParser +import Foundation +import agtermCore + +// MARK: - session overlay + +/// The `session overlay` subcommand tree. Split out of `SessionCommands.swift` for the type size limit. +extension Session { + struct Overlay: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Open, read, resize, or close an ephemeral overlay terminal on a session.", + subcommands: [Open.self, Close.self, Resize.self, Result.self, Copy.self, Text.self] + ) + + /// `--pane` validation for the overlay commands: the two pane roles only, deliberately NOT the shared + /// `validatePaneArgument`, which also accepts `scratch` — there is no scratch pane to cover, and + /// reusing it would send `scratch` to the socket instead of failing as a usage error. + static func validatePane(_ pane: String?) throws { + if let pane, OverlayPane(controlName: pane) == nil { + throw ValidationError("--pane must be left or right") + } + } + + struct Open: RequestCommand { + static let configuration = CommandConfiguration(abstract: "Open an overlay running COMMAND; it closes when COMMAND exits.") + @Argument(help: "Program to run in the overlay (e.g. revdiff).") var command: String + @Option(name: .long, help: "Working directory (default: the session's current directory).") var cwd: String? + @Flag(name: .long, help: "Keep the overlay open after COMMAND exits (press any key to close).") var wait = false + @Flag(name: .long, help: "Block until COMMAND exits and exit with its status (the program renders normally; capture its output via the program's own output file).") var block = false + @Flag(name: .long, help: "Select (switch to) the target session after opening the overlay (default: open without switching).") var follow = false + @Option(name: .long, help: "Render a floating, framed panel at PERCENT (1-100) of the pane instead of full-size.") var sizePercent: Int? + @Option(name: .long, help: "Solid background color (#rrggbb) for the overlay pane, independent of the session's own.") var backgroundColor: String? + @Option(name: .long, help: """ + Scope the overlay to ONE split pane (primary/left/top or split/right/bottom), leaving the sibling pane live and \ + visible; omit for the session-wide overlay. A pane overlay is always full-pane, so this \ + cannot be combined with --size-percent. + """) + var pane: String? + @OptionGroup var target: TargetOptions + @OptionGroup var options: ClientOptions + + // reject the mutually-exclusive combos + a malformed color at parse time (before any connection), + // so it's a clean usage error and is unit-testable without a socket. + func validate() throws { + if block && wait { throw ValidationError("--block cannot be combined with --wait") } + if let backgroundColor, !WatermarkConfig.isValidColorHex(backgroundColor) { + throw ValidationError("background-color must be a #rrggbb hex value") + } + try Overlay.validatePane(pane) + if pane != nil, sizePercent != nil { + throw ValidationError("--pane cannot be combined with --size-percent (pane overlays are always full)") + } + } + + func makeRequest() throws -> ControlRequest { + ControlRequest(cmd: .sessionOverlayOpen, target: target.target, + args: options.withWindow(ControlArgs(cwd: cwd, command: command, wait: wait ? true : nil, + sizePercent: sizePercent, follow: follow ? true : nil, + pane: pane, color: backgroundColor))) + } + + /// The `--block` poll request. Extracted from `run()` so the `--pane` forwarding is assertable + /// without a live socket: polling a pane overlay with no pane reads the session-wide slot and + /// blocks forever. No window scope — the returned id is globally unique and resolves cross-window, + /// so a frontmost-window change during the run cannot make the poll miss the session. + func resultRequest(id: String) -> ControlRequest { + ControlRequest(cmd: .sessionOverlayResult, target: id, args: pane.map { ControlArgs(pane: $0) }) + } + + func run() throws { + guard block else { try defaultRun(); return } + let client = SocketClient(path: options.socketPath()) + // open via the same `makeRequest()` as the non-block path: in block mode `validate()` guarantees + // `!wait`, so its `wait` is nil, and the floating `--size-percent` rides that single source + // instead of a duplicated ControlArgs. + let opened = try client.send(makeRequest()) + guard opened.ok, let id = opened.result?.id else { + SocketClient.printResponse(opened, json: options.json) + throw ExitCode.failure + } + while true { + let res = try client.send(resultRequest(id: id)) + if res.ok { + if options.json { SocketClient.printResponse(res, json: true) } + // a successful result must carry the status; its absence is a protocol violation, not success. + guard let code = res.result?.exitCode else { + FileHandle.standardError.write(Data("error: result missing exit code\n".utf8)) + throw ExitCode.failure + } + throw ExitCode(rawValue: Int32(code)) + } + if res.error == OverlayResultError.stillRunning { + Thread.sleep(forTimeInterval: 0.1) + continue + } + SocketClient.printResponse(res, json: options.json) + throw ExitCode.failure + } + } + } + + struct Close: RequestCommand { + static let configuration = CommandConfiguration(abstract: "Close the overlay terminal (destroys it).") + @Option(name: .long, help: "Close that split pane's overlay (primary/left/top or split/right/bottom); omit for the session-wide overlay.") + var pane: String? + @OptionGroup var target: TargetOptions + @OptionGroup var options: ClientOptions + + func validate() throws { try Overlay.validatePane(pane) } + + func makeRequest() throws -> ControlRequest { + ControlRequest(cmd: .sessionOverlayClose, target: target.target, + args: options.withWindow(pane.map { ControlArgs(pane: $0) })) + } + } + + struct Resize: RequestCommand { + static let configuration = CommandConfiguration(abstract: "Resize an open overlay: floating at a percent, or back to full-pane.") + @Option(name: .long, help: "Resize to a floating, framed panel at PERCENT (1-100) of the pane.") var sizePercent: Int? + @Flag(name: .long, help: "Resize to full-pane (translucent, hides the session).") var full = false + @OptionGroup var target: TargetOptions + @OptionGroup var options: ClientOptions + + // require exactly one of --size-percent / --full at parse time (before any connection), so it is a + // clean usage error and unit-testable without a socket; the dispatcher re-checks the same rules. + func validate() throws { + if full && sizePercent != nil { throw ValidationError("--full cannot be combined with --size-percent") } + if !full && sizePercent == nil { throw ValidationError("provide --size-percent PERCENT or --full") } + if let sizePercent, !(1...100).contains(sizePercent) { + throw ValidationError("--size-percent must be between 1 and 100") + } + } + + func makeRequest() throws -> ControlRequest { + ControlRequest(cmd: .sessionOverlayResize, target: target.target, + args: options.withWindow(ControlArgs(sizePercent: sizePercent, full: full ? true : nil))) + } + } + + struct Result: RequestCommand { + static let configuration = CommandConfiguration(abstract: "Print the overlay program's exit status (errors if it is still running or never ran).") + @Option(name: .long, help: "Read that split pane's overlay status (primary/left/top or split/right/bottom); omit for the session-wide overlay.") + var pane: String? + @OptionGroup var target: TargetOptions + @OptionGroup var options: ClientOptions + + func validate() throws { try Overlay.validatePane(pane) } + + func makeRequest() throws -> ControlRequest { + ControlRequest(cmd: .sessionOverlayResult, target: target.target, + args: options.withWindow(pane.map { ControlArgs(pane: $0) })) + } + } + + struct Copy: RequestCommand { + static let configuration = CommandConfiguration(abstract: "Print the selection made INSIDE the overlay (session copy reads the pane underneath).") + @Option(name: .long, help: "Read that split pane's overlay (primary/left/top or split/right/bottom); omit for the session-wide overlay.") + var pane: String? + @OptionGroup var target: TargetOptions + @OptionGroup var options: ClientOptions + + func validate() throws { try Overlay.validatePane(pane) } + + func makeRequest() throws -> ControlRequest { + ControlRequest(cmd: .sessionOverlayCopy, target: target.target, + args: options.withWindow(pane.map { ControlArgs(pane: $0) })) + } + } + + struct Text: RequestCommand { + static let configuration = CommandConfiguration(abstract: "Print the overlay's terminal buffer as plain text (a TUI's drawn screen, wrapped as rendered).") + @Flag(name: .long, help: "Read the full screen + scrollback instead of just the visible screen.") var all = false + @Option(name: .long, help: "Keep only the last N lines of the full buffer.") var lines: Int? + @Option(name: .long, help: "Read that split pane's overlay (primary/left/top or split/right/bottom); omit for the session-wide overlay.") + var pane: String? + @OptionGroup var target: TargetOptions + @OptionGroup var options: ClientOptions + + // same order as the dispatcher, so the CLI and the socket reject the same call the same way. + func validate() throws { + if all, lines != nil { + throw ValidationError("use either --all or --lines, not both") + } + if let lines, lines <= 0 { + throw ValidationError("--lines must be greater than 0") + } + try Overlay.validatePane(pane) + } + + func makeRequest() throws -> ControlRequest { + ControlRequest(cmd: .sessionOverlayText, target: target.target, + args: options.withWindow(ControlArgs(pane: pane, all: all ? true : nil, lines: lines))) + } + } + } +} diff --git a/agtermCore/Sources/agtermctlKit/SessionCommands.swift b/agtermCore/Sources/agtermctlKit/SessionCommands.swift index 09166dda1..4f4e779bb 100644 --- a/agtermCore/Sources/agtermctlKit/SessionCommands.swift +++ b/agtermCore/Sources/agtermctlKit/SessionCommands.swift @@ -133,21 +133,36 @@ struct Session: ParsableCommand { struct Move: RequestCommand { static let configuration = CommandConfiguration( - abstract: "Move a session: to another workspace, reorder with --to, or place relative to an anchor with --after/--before.") - @Argument(help: "Destination workspace id/prefix (relocate). Omit with --to or --after/--before.") var workspace: String? + abstract: "Move a session: to another workspace or window, reorder with --to, or place relative to an anchor with --after/--before.") + @Argument(help: "Destination workspace id/prefix (relocate); with --to-window, a workspace INSIDE that window. Omit with --to or --after/--before.") var workspace: String? + @Option(name: .long, help: "DESTINATION window id/prefix/active to move the session to; it must be open. Unlike --window, which only scopes where --target is searched.") var toWindow: String? + @Flag(name: .long, help: "With --to-window, select the session in the destination window (it stays in the background otherwise).") var select = false @Option(name: .long, help: "Reorder within the workspace: up, down, top, or bottom.") var to: String? @Option(name: .long, help: "Place right AFTER this anchor session (id/prefix/active); the anchor carries its own workspace (relocates + positions in one shot).") var after: String? @Option(name: .long, help: "Place right BEFORE this anchor session (id/prefix/active); mirror of --after.") var before: String? @OptionGroup var target: BatchTargetOptions @OptionGroup var options: ClientOptions - // exactly one placement intent among {workspace positional (relocate), --to (reorder), --after/--before - // (anchor-relative)}; reject empty/conflicting cases at parse time as a clean usage error, unit-testable - // without a socket. the anchor carries its own workspace, so placement excludes --to and a workspace. + // exactly one placement intent among {--to-window (cross-window), workspace positional (relocate), + // --to (reorder), --after/--before (anchor-relative)}; reject empty/conflicting cases at parse time as a + // clean usage error, unit-testable without a socket. the anchor carries its own workspace, so placement + // excludes --to and a workspace. a workspace positional composes with --to-window, naming one there. func validate() throws { if after != nil, before != nil { throw ValidationError("use either --after or --before, not both") } + if select, toWindow == nil { + throw ValidationError("session.move --select requires --to-window") + } + if toWindow != nil { + if to != nil { + throw ValidationError("session.move takes --to-window or --to, not both") + } + if after != nil || before != nil { + throw ValidationError("session.move takes --to-window or --after/--before, not both") + } + return + } if after != nil || before != nil { if to != nil { throw ValidationError("session.move takes --after/--before or --to, not both") @@ -169,7 +184,9 @@ struct Session: ParsableCommand { func makeRequest() throws -> ControlRequest { let args: ControlArgs - if let after { + if let toWindow { + args = ControlArgs(workspace: workspace, select: select, toWindow: toWindow) + } else if let after { args = ControlArgs(after: after) } else if let before { args = ControlArgs(before: before) @@ -633,194 +650,6 @@ struct Session: ParsableCommand { } } - struct Overlay: ParsableCommand { - static let configuration = CommandConfiguration( - abstract: "Open, read, resize, or close an ephemeral overlay terminal on a session.", - subcommands: [Open.self, Close.self, Resize.self, Result.self, Copy.self, Text.self] - ) - - /// `--pane` validation for the overlay commands: the two pane roles only, deliberately NOT the shared - /// `validatePaneArgument`, which also accepts `scratch` — there is no scratch pane to cover, and - /// reusing it would send `scratch` to the socket instead of failing as a usage error. - static func validatePane(_ pane: String?) throws { - if let pane, OverlayPane(controlName: pane) == nil { - throw ValidationError("--pane must be left or right") - } - } - - struct Open: RequestCommand { - static let configuration = CommandConfiguration(abstract: "Open an overlay running COMMAND; it closes when COMMAND exits.") - @Argument(help: "Program to run in the overlay (e.g. revdiff).") var command: String - @Option(name: .long, help: "Working directory (default: the session's current directory).") var cwd: String? - @Flag(name: .long, help: "Keep the overlay open after COMMAND exits (press any key to close).") var wait = false - @Flag(name: .long, help: "Block until COMMAND exits and exit with its status (the program renders normally; capture its output via the program's own output file).") var block = false - @Flag(name: .long, help: "Select (switch to) the target session after opening the overlay (default: open without switching).") var follow = false - @Option(name: .long, help: "Render a floating, framed panel at PERCENT (1-100) of the pane instead of full-size.") var sizePercent: Int? - @Option(name: .long, help: "Solid background color (#rrggbb) for the overlay pane, independent of the session's own.") var backgroundColor: String? - @Option(name: .long, help: """ - Scope the overlay to ONE split pane (primary/left/top or split/right/bottom), leaving the sibling pane live and \ - visible; omit for the session-wide overlay. A pane overlay is always full-pane, so this \ - cannot be combined with --size-percent. - """) - var pane: String? - @OptionGroup var target: TargetOptions - @OptionGroup var options: ClientOptions - - // reject the mutually-exclusive combos + a malformed color at parse time (before any connection), - // so it's a clean usage error and is unit-testable without a socket. - func validate() throws { - if block && wait { throw ValidationError("--block cannot be combined with --wait") } - if let backgroundColor, !WatermarkConfig.isValidColorHex(backgroundColor) { - throw ValidationError("background-color must be a #rrggbb hex value") - } - try Overlay.validatePane(pane) - if pane != nil, sizePercent != nil { - throw ValidationError("--pane cannot be combined with --size-percent (pane overlays are always full)") - } - } - - func makeRequest() throws -> ControlRequest { - ControlRequest(cmd: .sessionOverlayOpen, target: target.target, - args: options.withWindow(ControlArgs(cwd: cwd, command: command, wait: wait ? true : nil, - sizePercent: sizePercent, follow: follow ? true : nil, - pane: pane, color: backgroundColor))) - } - - /// The `--block` poll request. Extracted from `run()` so the `--pane` forwarding is assertable - /// without a live socket: polling a pane overlay with no pane reads the session-wide slot and - /// blocks forever. No window scope — the returned id is globally unique and resolves cross-window, - /// so a frontmost-window change during the run cannot make the poll miss the session. - func resultRequest(id: String) -> ControlRequest { - ControlRequest(cmd: .sessionOverlayResult, target: id, args: pane.map { ControlArgs(pane: $0) }) - } - - func run() throws { - guard block else { try defaultRun(); return } - let client = SocketClient(path: options.socketPath()) - // open via the same `makeRequest()` as the non-block path: in block mode `validate()` guarantees - // `!wait`, so its `wait` is nil, and the floating `--size-percent` rides that single source - // instead of a duplicated ControlArgs. - let opened = try client.send(makeRequest()) - guard opened.ok, let id = opened.result?.id else { - SocketClient.printResponse(opened, json: options.json) - throw ExitCode.failure - } - while true { - let res = try client.send(resultRequest(id: id)) - if res.ok { - if options.json { SocketClient.printResponse(res, json: true) } - // a successful result must carry the status; its absence is a protocol violation, not success. - guard let code = res.result?.exitCode else { - FileHandle.standardError.write(Data("error: result missing exit code\n".utf8)) - throw ExitCode.failure - } - throw ExitCode(rawValue: Int32(code)) - } - if res.error == OverlayResultError.stillRunning { - Thread.sleep(forTimeInterval: 0.1) - continue - } - SocketClient.printResponse(res, json: options.json) - throw ExitCode.failure - } - } - } - - struct Close: RequestCommand { - static let configuration = CommandConfiguration(abstract: "Close the overlay terminal (destroys it).") - @Option(name: .long, help: "Close that split pane's overlay (primary/left/top or split/right/bottom); omit for the session-wide overlay.") - var pane: String? - @OptionGroup var target: TargetOptions - @OptionGroup var options: ClientOptions - - func validate() throws { try Overlay.validatePane(pane) } - - func makeRequest() throws -> ControlRequest { - ControlRequest(cmd: .sessionOverlayClose, target: target.target, - args: options.withWindow(pane.map { ControlArgs(pane: $0) })) - } - } - - struct Resize: RequestCommand { - static let configuration = CommandConfiguration(abstract: "Resize an open overlay: floating at a percent, or back to full-pane.") - @Option(name: .long, help: "Resize to a floating, framed panel at PERCENT (1-100) of the pane.") var sizePercent: Int? - @Flag(name: .long, help: "Resize to full-pane (translucent, hides the session).") var full = false - @OptionGroup var target: TargetOptions - @OptionGroup var options: ClientOptions - - // require exactly one of --size-percent / --full at parse time (before any connection), so it is a - // clean usage error and unit-testable without a socket; the dispatcher re-checks the same rules. - func validate() throws { - if full && sizePercent != nil { throw ValidationError("--full cannot be combined with --size-percent") } - if !full && sizePercent == nil { throw ValidationError("provide --size-percent PERCENT or --full") } - if let sizePercent, !(1...100).contains(sizePercent) { - throw ValidationError("--size-percent must be between 1 and 100") - } - } - - func makeRequest() throws -> ControlRequest { - ControlRequest(cmd: .sessionOverlayResize, target: target.target, - args: options.withWindow(ControlArgs(sizePercent: sizePercent, full: full ? true : nil))) - } - } - - struct Result: RequestCommand { - static let configuration = CommandConfiguration(abstract: "Print the overlay program's exit status (errors if it is still running or never ran).") - @Option(name: .long, help: "Read that split pane's overlay status (primary/left/top or split/right/bottom); omit for the session-wide overlay.") - var pane: String? - @OptionGroup var target: TargetOptions - @OptionGroup var options: ClientOptions - - func validate() throws { try Overlay.validatePane(pane) } - - func makeRequest() throws -> ControlRequest { - ControlRequest(cmd: .sessionOverlayResult, target: target.target, - args: options.withWindow(pane.map { ControlArgs(pane: $0) })) - } - } - - struct Copy: RequestCommand { - static let configuration = CommandConfiguration(abstract: "Print the selection made INSIDE the overlay (session copy reads the pane underneath).") - @Option(name: .long, help: "Read that split pane's overlay (primary/left/top or split/right/bottom); omit for the session-wide overlay.") - var pane: String? - @OptionGroup var target: TargetOptions - @OptionGroup var options: ClientOptions - - func validate() throws { try Overlay.validatePane(pane) } - - func makeRequest() throws -> ControlRequest { - ControlRequest(cmd: .sessionOverlayCopy, target: target.target, - args: options.withWindow(pane.map { ControlArgs(pane: $0) })) - } - } - - struct Text: RequestCommand { - static let configuration = CommandConfiguration(abstract: "Print the overlay's terminal buffer as plain text (a TUI's drawn screen, wrapped as rendered).") - @Flag(name: .long, help: "Read the full screen + scrollback instead of just the visible screen.") var all = false - @Option(name: .long, help: "Keep only the last N lines of the full buffer.") var lines: Int? - @Option(name: .long, help: "Read that split pane's overlay (primary/left/top or split/right/bottom); omit for the session-wide overlay.") - var pane: String? - @OptionGroup var target: TargetOptions - @OptionGroup var options: ClientOptions - - // same order as the dispatcher, so the CLI and the socket reject the same call the same way. - func validate() throws { - if all, lines != nil { - throw ValidationError("use either --all or --lines, not both") - } - if let lines, lines <= 0 { - throw ValidationError("--lines must be greater than 0") - } - try Overlay.validatePane(pane) - } - - func makeRequest() throws -> ControlRequest { - ControlRequest(cmd: .sessionOverlayText, target: target.target, - args: options.withWindow(ControlArgs(pane: pane, all: all ? true : nil, lines: lines))) - } - } - } - /// The passive message panel. `Open` is the default subcommand, so posting one is /// `agtermctl session hud "gathering options…"`; a message that is literally `update` or `close` needs /// the explicit `hud open` verb. Message length and control characters are the dispatcher's to reject — diff --git a/agtermCore/Sources/agtermctlKit/SocketClient.swift b/agtermCore/Sources/agtermctlKit/SocketClient.swift index f9a22fae7..61f4ce7cf 100644 --- a/agtermCore/Sources/agtermctlKit/SocketClient.swift +++ b/agtermCore/Sources/agtermctlKit/SocketClient.swift @@ -183,6 +183,9 @@ struct SocketClient { if let tree = response.result?.tree { return formatTree(tree) } + if let trees = response.result?.trees { + return trees.map(formatTree).joined(separator: "\n") + } if let windows = response.result?.windows { return formatWindows(windows) } @@ -289,9 +292,15 @@ struct SocketClient { }.joined(separator: "\n") } - /// Render a tree as an indented workspace → session listing (no trailing newline). + /// Render a tree as an indented workspace → session listing (no trailing newline), under a header naming + /// the projected window. Every session node repeats that window id and its workspace id, which the + /// indentation already shows — only `--json` reads them per row. private static func formatTree(_ tree: ControlTree) -> String { var lines: [String] = [] + if let windowId = tree.windowId { + let name = tree.windowName.flatMap { $0.isEmpty ? nil : "\($0) " } ?? "" + lines.append("window \(name)[\(windowId)]") + } for workspace in tree.workspaces { let mark = workspace.active ? "*" : " " lines.append("\(mark) \(workspace.name) [\(workspace.id)]") diff --git a/agtermCore/Tests/agtermCoreTests/AppStoreControlTreeTests.swift b/agtermCore/Tests/agtermCoreTests/AppStoreControlTreeTests.swift new file mode 100644 index 000000000..ba79b4584 --- /dev/null +++ b/agtermCore/Tests/agtermCoreTests/AppStoreControlTreeTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import agtermCore + +@MainActor +struct AppStoreControlTreeTests { + @Test func controlTreeStampsOwnershipOnEveryNode() throws { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let side = store.addWorkspace(name: "side") + let first = try #require(store.addSession(toWorkspace: work.id, cwd: "/repo")) + let second = try #require(store.addSession(toWorkspace: work.id, cwd: "/repo")) + let third = try #require(store.addSession(toWorkspace: side.id, cwd: "/tmp")) + + let tree = store.controlTree(windowID: "win-1") + + #expect(tree.windowId == "win-1") + let nodes = tree.workspaces.flatMap(\.sessions) + #expect(nodes.allSatisfy { $0.windowId == "win-1" }) + let owners = Dictionary(uniqueKeysWithValues: nodes.map { ($0.id, $0.workspaceId) }) + #expect(owners[first.id.uuidString] == work.id.uuidString) + #expect(owners[second.id.uuidString] == work.id.uuidString) + #expect(owners[third.id.uuidString] == side.id.uuidString) + } + + @Test func controlTreeOmitsOwnershipWithoutAWindow() throws { + let store = makeStore() + let work = store.addWorkspace(name: "work") + _ = try #require(store.addSession(toWorkspace: work.id, cwd: "/repo")) + + let tree = store.controlTree() + + #expect(tree.windowId == nil) + let node = try #require(tree.workspaces.flatMap(\.sessions).first) + #expect(node.windowId == nil) + #expect(node.workspaceId == nil) + } + + @Test func controlTreeFollowsASessionToItsNewWorkspace() throws { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let side = store.addWorkspace(name: "side") + let session = try #require(store.addSession(toWorkspace: work.id, cwd: "/repo")) + + store.moveSession(session.id, toWorkspace: side.id) + + let node = try #require(store.controlTree(windowID: "win-1") + .workspaces.flatMap(\.sessions).first { $0.id == session.id.uuidString }) + #expect(node.workspaceId == side.id.uuidString) + } +} diff --git a/agtermCore/Tests/agtermCoreTests/AppStoreTransferTests.swift b/agtermCore/Tests/agtermCoreTests/AppStoreTransferTests.swift new file mode 100644 index 000000000..6cf7c6c08 --- /dev/null +++ b/agtermCore/Tests/agtermCoreTests/AppStoreTransferTests.swift @@ -0,0 +1,235 @@ +import Foundation +import Testing +@testable import agtermCore + +@MainActor +struct AppStoreTransferTests { + @Test func detachReturnsInstanceWithSurfaceIntact() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: work.id, cwd: "/a")! + let surface = SpySurface() + session.surface = surface + let detached = store.detachSession(session.id) + #expect(detached === session) + #expect(detached?.surface === surface) + #expect(surface.teardownCount == 0) + #expect(store.workspaces[0].sessions.isEmpty) + #expect(store.session(withID: session.id) == nil) + } + + @Test func detachOfSelectedSessionReselects() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let stay = store.addSession(toWorkspace: work.id, cwd: "/stay")! + let leaving = store.addSession(toWorkspace: work.id, cwd: "/leaving")! + #expect(store.selectedSessionID == leaving.id) + store.detachSession(leaving.id) + #expect(store.selectedSessionID == stay.id) + #expect(store.sidebarSelectionIDs == [stay.id]) + } + + @Test func detachOfUnselectedSessionKeepsSelection() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let leaving = store.addSession(toWorkspace: work.id, cwd: "/leaving")! + let selected = store.addSession(toWorkspace: work.id, cwd: "/selected")! + store.detachSession(leaving.id) + #expect(store.selectedSessionID == selected.id) + } + + @Test func detachPrunesRecency() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let first = store.addSession(toWorkspace: work.id, cwd: "/first")! + let second = store.addSession(toWorkspace: work.id, cwd: "/second")! + store.detachSession(first.id) + #expect(!store.sessionRecency.items.contains(first.id)) + #expect(store.sessionRecency.items.contains(second.id)) + } + + @Test func detachOfLastSessionEmptiesStore() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let only = store.addSession(toWorkspace: work.id, cwd: "/only")! + store.detachSession(only.id) + #expect(store.selectedSessionID == nil) + #expect(store.sidebarSelectionIDs.isEmpty) + #expect(store.workspaces.count == 1) + #expect(store.workspaces[0].sessions.isEmpty) + } + + @Test func detachReselectsAcrossWorkspacesWhenOwnEmpties() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let personal = store.addWorkspace(name: "personal") + let other = store.addSession(toWorkspace: work.id, cwd: "/other")! + let only = store.addSession(toWorkspace: personal.id, cwd: "/only")! + store.detachSession(only.id) + #expect(store.selectedSessionID == other.id) + #expect(store.workspaces[1].sessions.isEmpty) + } + + @Test func detachUnknownSessionReturnsNil() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: work.id, cwd: "/a")! + #expect(store.detachSession(UUID()) == nil) + #expect(store.workspaces[0].sessions.map(\.id) == [session.id]) + } + + @Test func detachEmitsTreeChanged() { + let events = EventCollector() + let store = makeStore(sink: events) + let work = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: work.id, cwd: "/a")! + events.kinds.removeAll() + store.detachSession(session.id) + #expect(events.kinds == [.treeChanged]) + } + + @Test func adoptLandsInCurrentWorkspaceByDefault() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let session = source.addSession(toWorkspace: sourceWork.id, cwd: "/a")! + let detached = source.detachSession(session.id)! + + let destination = makeStore() + _ = destination.addWorkspace(name: "first") + let second = destination.addWorkspace(name: "second") + #expect(destination.adoptSession(detached)) + #expect(destination.workspaces[1].id == second.id) + #expect(destination.workspaces[1].sessions.map(\.id) == [session.id]) + #expect(destination.session(withID: session.id) === session) + } + + @Test func adoptHonorsExplicitWorkspaceAndIndex() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let moving = source.detachSession(source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")!.id)! + + let destination = makeStore() + let first = destination.addWorkspace(name: "first") + _ = destination.addWorkspace(name: "second") + let existing = destination.addSession(toWorkspace: first.id, cwd: "/existing")! + #expect(destination.adoptSession(moving, toWorkspace: first.id, at: 0)) + #expect(destination.workspaces[0].sessions.map(\.id) == [moving.id, existing.id]) + } + + @Test func adoptClampsOutOfRangeIndex() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let moving = source.detachSession(source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")!.id)! + + let destination = makeStore() + let work = destination.addWorkspace(name: "work") + let existing = destination.addSession(toWorkspace: work.id, cwd: "/existing")! + #expect(destination.adoptSession(moving, toWorkspace: work.id, at: 99)) + #expect(destination.workspaces[0].sessions.map(\.id) == [existing.id, moving.id]) + } + + @Test func adoptWithSelectMakesItActive() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let moving = source.detachSession(source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")!.id)! + + let destination = makeStore() + let work = destination.addWorkspace(name: "work") + _ = destination.addSession(toWorkspace: work.id, cwd: "/existing")! + #expect(destination.adoptSession(moving, toWorkspace: work.id, select: true)) + #expect(destination.selectedSessionID == moving.id) + #expect(destination.sidebarSelectionIDs == [moving.id]) + #expect(destination.sessionRecency.items.first == moving.id) + } + + @Test func adoptWithSelectClearsUnseenAndAutoResetIndicator() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let session = source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")! + session.unseenCount = 3 + source.setAgentIndicator(AgentIndicator(status: .completed, autoReset: true), forSession: session.id) + let moving = source.detachSession(session.id)! + + let destination = makeStore() + let work = destination.addWorkspace(name: "work") + #expect(destination.adoptSession(moving, toWorkspace: work.id, select: true)) + #expect(moving.unseenCount == 0) + #expect(moving.agentIndicator.status == .idle) + } + + @Test func adoptWithoutSelectKeepsUnseenBadge() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let session = source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")! + session.unseenCount = 3 + let moving = source.detachSession(session.id)! + + let destination = makeStore() + let work = destination.addWorkspace(name: "work") + #expect(destination.adoptSession(moving, toWorkspace: work.id, select: false)) + #expect(moving.unseenCount == 3) + } + + @Test func adoptWithoutSelectLeavesSelectionAlone() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let moving = source.detachSession(source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")!.id)! + + let destination = makeStore() + let work = destination.addWorkspace(name: "work") + let existing = destination.addSession(toWorkspace: work.id, cwd: "/existing")! + #expect(destination.adoptSession(moving, toWorkspace: work.id, select: false)) + #expect(destination.selectedSessionID == existing.id) + #expect(!destination.sessionRecency.items.contains(moving.id)) + } + + @Test func adoptRejectsUnknownWorkspace() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let moving = source.detachSession(source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")!.id)! + + let destination = makeStore() + _ = destination.addWorkspace(name: "work") + #expect(!destination.adoptSession(moving, toWorkspace: UUID())) + #expect(destination.workspaces[0].sessions.isEmpty) + } + + @Test func adoptRejectsEmptyStoreWithNoWorkspace() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let moving = source.detachSession(source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")!.id)! + #expect(!makeStore().adoptSession(moving)) + } + + @Test func adoptRejectsDuplicateID() { + let store = makeStore() + let work = store.addWorkspace(name: "work") + let session = store.addSession(toWorkspace: work.id, cwd: "/a")! + #expect(!store.adoptSession(session, toWorkspace: work.id)) + #expect(store.workspaces[0].sessions.map(\.id) == [session.id]) + } + + @Test func adoptEmitsTreeChanged() { + let source = makeStore() + let sourceWork = source.addWorkspace(name: "work") + let moving = source.detachSession(source.addSession(toWorkspace: sourceWork.id, cwd: "/moving")!.id)! + + let events = EventCollector() + let destination = makeStore(sink: events) + let work = destination.addWorkspace(name: "work") + events.kinds.removeAll() + #expect(destination.adoptSession(moving, toWorkspace: work.id)) + #expect(events.kinds == [.treeChanged]) + } +} + +@MainActor +private final class EventCollector { + var kinds: [ControlEventKind] = [] +} + +@MainActor private func makeStore(sink: EventCollector) -> AppStore { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("agterm-tests-\(UUID().uuidString)") + return AppStore(persistence: PersistenceStore(directory: dir), + controlEventSink: { [sink] draft in sink.kinds.append(draft.kind) }) +} diff --git a/agtermCore/Tests/agtermCoreTests/ControlDispatcherSessionMoveTests.swift b/agtermCore/Tests/agtermCoreTests/ControlDispatcherSessionMoveTests.swift new file mode 100644 index 000000000..ce759c13e --- /dev/null +++ b/agtermCore/Tests/agtermCoreTests/ControlDispatcherSessionMoveTests.swift @@ -0,0 +1,105 @@ +import Testing +@testable import agtermCore + +/// `session.move`'s cross-window form: routing the destination window (and its optional workspace) and +/// refusing the in-store placement flags, whose positions only resolve within one store. +@MainActor +struct ControlDispatcherSessionMoveTests { + @Test func sessionMoveRoutesCrossWindowForm() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + let bare = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "session", + args: ControlArgs(toWindow: "other") + )) + let workspaced = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "session", + args: ControlArgs(workspace: "dest", select: true, window: "win", toWindow: "other") + )) + let batch = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + args: ControlArgs(targets: ["a", "b"], toWindow: "other") + )) + + #expect(bare == ControlResponse(ok: true)) + #expect(workspaced == ControlResponse(ok: true)) + #expect(batch == ControlResponse(ok: true)) + #expect(actions.calls == [ + .sessionMove(target: "session", window: nil, .window(window: "other", workspace: nil), select: false), + .sessionMove(target: "session", window: "win", .window(window: "other", workspace: "dest"), select: true), + .sessionMoveBatch(targets: ["a", "b"], window: nil, .window(window: "other", workspace: nil), + select: false), + ]) + } + + @Test func sessionMoveRejectsCrossWindowWithInStorePlacement() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + let withTo = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "active", + args: ControlArgs(toWindow: "other", to: "up") + )) + let withAfter = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "active", + args: ControlArgs(toWindow: "other", after: "anchor") + )) + let withBefore = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "active", + args: ControlArgs(toWindow: "other", before: "anchor") + )) + + #expect(withTo == ControlResponse(ok: false, error: "session.move takes --to-window or --to, not both")) + #expect(withAfter == ControlResponse( + ok: false, error: "session.move takes --to-window or --after/--before, not both")) + #expect(withBefore == ControlResponse( + ok: false, error: "session.move takes --to-window or --after/--before, not both")) + #expect(actions.calls.isEmpty) + } + + @Test func sessionMoveRejectsSelectWithoutADestinationWindow() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + let workspaced = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "session", + args: ControlArgs(workspace: "dest", select: true) + )) + let reordered = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "session", + args: ControlArgs(select: true, to: "up") + )) + + #expect(workspaced == ControlResponse(ok: false, error: "session.move --select requires --to-window")) + #expect(reordered == ControlResponse(ok: false, error: "session.move --select requires --to-window")) + #expect(actions.calls.isEmpty) + } + + // destination resolution belongs to the host, which alone knows the open window set: the dispatcher + // must hand every spelling over untouched instead of pre-judging it. + @Test func sessionMoveForwardsUnresolvedDestinationSpellings() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + for destination in ["active", "7b33", "no-such-window"] { + let response = await dispatcher.dispatch(ControlRequest( + cmd: .sessionMove, + target: "session", + args: ControlArgs(toWindow: destination) + )) + #expect(response == ControlResponse(ok: true)) + } + + #expect(actions.calls == ["active", "7b33", "no-such-window"].map { + .sessionMove(target: "session", window: nil, .window(window: $0, workspace: nil), select: false) + }) + } +} diff --git a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift index 830fbd0ce..9e87fe9dd 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift @@ -15,7 +15,40 @@ struct ControlDispatcherTests { let response = await dispatcher.dispatch(ControlRequest(cmd: .tree, args: ControlArgs(window: "abc"))) #expect(response == ControlResponse(ok: true, result: ControlResult(tree: tree))) - #expect(actions.calls == [.tree(window: "abc")]) + #expect(actions.calls == [.tree(window: "abc", allWindows: false)]) + } + + @Test func treeRoutesAllWindowsThrough() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + let trees = [ControlTree(workspaces: [], windowId: "win-1"), + ControlTree(workspaces: [], windowId: "win-2")] + actions.nextTreeResponse = ControlResponse(ok: true, result: ControlResult(trees: trees)) + + let response = await dispatcher.dispatch(ControlRequest(cmd: .tree, args: ControlArgs(allWindows: true))) + + #expect(response?.result?.trees == trees) + #expect(actions.calls == [.tree(window: nil, allWindows: true)]) + } + + @Test func treeWithoutArgsRequestsTheSingleWindowProjection() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + _ = await dispatcher.dispatch(ControlRequest(cmd: .tree)) + + #expect(actions.calls == [.tree(window: nil, allWindows: false)]) + } + + @Test func treeRejectsAllWindowsWithAWindow() async { + let actions = MockControlActions() + let dispatcher = ControlDispatcher(actions: actions) + + let request = ControlRequest(cmd: .tree, args: ControlArgs(window: "w1", allWindows: true)) + let response = await dispatcher.dispatch(request) + + #expect(response == ControlResponse(ok: false, error: "tree takes --all-windows or --window, not both")) + #expect(actions.calls.isEmpty) } @Test func sidebarVisibilityParsesModesAndKeepsExactResponse() async { @@ -271,8 +304,8 @@ struct ControlDispatcherTests { #expect(after == ControlResponse(ok: true)) #expect(before == ControlResponse(ok: true)) #expect(actions.calls == [ - .sessionMove(target: "session", window: "win", .place(anchor: "anchor", after: true)), - .sessionMove(target: "session", window: nil, .place(anchor: "anchor", after: false)) + .sessionMove(target: "session", window: "win", .place(anchor: "anchor", after: true), select: false), + .sessionMove(target: "session", window: nil, .place(anchor: "anchor", after: false), select: false) ]) } @@ -485,8 +518,8 @@ struct ControlDispatcherTests { #expect(reorder == ControlResponse(ok: true)) #expect(workspace == ControlResponse(ok: true)) #expect(actions.calls == [ - .sessionMove(target: "session", window: "win", .reorder(.top)), - .sessionMove(target: "session", window: nil, .workspace("dest")) + .sessionMove(target: "session", window: "win", .reorder(.top), select: false), + .sessionMove(target: "session", window: nil, .workspace("dest"), select: false) ]) } @@ -514,8 +547,8 @@ struct ControlDispatcherTests { error: "session.move --target can be repeated only with a workspace or --after/--before" )) #expect(actions.calls == [ - .sessionMoveBatch(targets: ["a", "b"], window: "win", .workspace("dest")), - .sessionMoveBatch(targets: ["a", "b"], window: nil, .place(anchor: "anchor", after: true)) + .sessionMoveBatch(targets: ["a", "b"], window: "win", .workspace("dest"), select: false), + .sessionMoveBatch(targets: ["a", "b"], window: nil, .place(anchor: "anchor", after: true), select: false) ]) } @@ -535,8 +568,8 @@ struct ControlDispatcherTests { #expect(workspace == ControlResponse(ok: true)) #expect(after == ControlResponse(ok: true)) #expect(actions.calls == [ - .sessionMove(target: "a", window: "win", .workspace("dest")), - .sessionMove(target: "b", window: nil, .place(anchor: "anchor", after: true)), + .sessionMove(target: "a", window: "win", .workspace("dest"), select: false), + .sessionMove(target: "b", window: nil, .place(anchor: "anchor", after: true), select: false), ]) } diff --git a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift index 6ba58af72..4f5c8bebe 100644 --- a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift +++ b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift @@ -585,6 +585,76 @@ struct ControlProtocolTests { #expect(decoded.result?.tree?.workspaces.first?.sessions.first?.flagged == true) } + @Test func treeRoundTripsWithOwnershipIds() throws { + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false, + windowId: "w-1", workspaceId: "ws-1") + let response = ControlResponse(ok: true, result: ControlResult(tree: ControlTree( + workspaces: [ControlWorkspaceNode(id: "ws-1", name: "work", active: true, sessions: [session])], + windowId: "w-1"))) + let decoded = try roundTrip(response) + #expect(decoded == response) + #expect(decoded.result?.tree?.windowId == "w-1") + let node = try #require(decoded.result?.tree?.workspaces.first?.sessions.first) + #expect(node.windowId == "w-1") + #expect(node.workspaceId == "ws-1") + } + + @Test func treeOmitsOwnershipIdsWhenUnset() throws { + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false) + let tree = ControlTree(workspaces: [ControlWorkspaceNode(id: "ws-1", name: "work", active: true, + sessions: [session])]) + let json = String(decoding: try JSONEncoder().encode(tree), as: UTF8.self) + #expect(!json.contains("windowId")) + #expect(!json.contains("workspaceId")) + } + + @Test func treeDecodesLegacyPayloadWithoutOwnershipIds() throws { + let session = #"{"id":"s1","name":"shell","cwd":"/tmp","active":true,"split":false,"# + + #""overlay":false,"scratch":false,"flagged":false}"# + let json = #"{"workspaces":[{"id":"ws-1","name":"work","active":true,"sessions":["# + session + "]}]}" + let tree = try JSONDecoder().decode(ControlTree.self, from: Data(json.utf8)) + #expect(tree.windowId == nil) + let node = try #require(tree.workspaces.first?.sessions.first) + #expect(node.windowId == nil) + #expect(node.workspaceId == nil) + } + + @Test func allWindowsResultRoundTripsEveryTree() throws { + let trees = ["win-1": "left", "win-2": "right"].sorted { $0.key < $1.key }.map { id, name in + ControlTree(workspaces: [ControlWorkspaceNode(id: "ws-" + id, name: "work", active: true, + sessions: [])], + windowId: id, windowName: name) + } + let response = ControlResponse(ok: true, result: ControlResult(trees: trees)) + let decoded = try roundTrip(response) + #expect(decoded == response) + #expect(decoded.result?.tree == nil) + #expect(decoded.result?.trees?.map(\.windowId) == ["win-1", "win-2"]) + #expect(decoded.result?.trees?.map(\.windowName) == ["left", "right"]) + } + + @Test func allWindowsArgRoundTrips() throws { + let request = ControlRequest(cmd: .tree, args: ControlArgs(allWindows: true)) + let decoded = try JSONDecoder().decode(ControlRequest.self, from: JSONEncoder().encode(request)) + #expect(decoded == request) + #expect(decoded.args?.allWindows == true) + } + + @Test func resultOmitsTreesAndWindowNameWhenUnset() throws { + let tree = ControlTree(workspaces: [], windowId: "w-1") + let json = String(decoding: try JSONEncoder().encode(ControlResult(tree: tree)), as: UTF8.self) + #expect(!json.contains("trees")) + #expect(!json.contains("windowName")) + } + + @Test func resultDecodesLegacyPayloadWithoutTrees() throws { + let json = #"{"tree":{"workspaces":[],"windowId":"w-1"}}"# + let result = try JSONDecoder().decode(ControlResult.self, from: Data(json.utf8)) + #expect(result.trees == nil) + #expect(result.tree?.windowId == "w-1") + #expect(result.tree?.windowName == nil) + } + @Test func treeSessionNodeRoundTripsWithTitle() throws { let session = ControlSessionNode(id: "s1", name: "build", cwd: "/tmp", title: "user@web1: ~", active: true, split: false) @@ -1398,6 +1468,32 @@ struct ControlProtocolTests { #expect(decoded.args?.after == nil) } + @Test func sessionMoveRoundTripsWithDestinationWindow() throws { + let request = ControlRequest(cmd: .sessionMove, target: "9f3c", + args: ControlArgs(workspace: "dest", select: true, + window: "src", toWindow: "1a2b")) + let decoded = try roundTrip(request) + #expect(decoded == request) + #expect(decoded.args?.toWindow == "1a2b") + #expect(decoded.args?.window == "src") + #expect(decoded.args?.workspace == "dest") + #expect(decoded.args?.select == true) + } + + @Test func sessionMoveOmitsDestinationWindowWhenUnset() throws { + let request = ControlRequest(cmd: .sessionMove, target: "9f3c", args: ControlArgs(workspace: "dest")) + let json = String(decoding: try JSONEncoder().encode(request), as: UTF8.self) + #expect(!json.contains("toWindow")) + #expect(try roundTrip(request).args?.toWindow == nil) + } + + @Test func sessionMoveDecodesLegacyPayloadWithoutDestinationWindow() throws { + let json = #"{"cmd":"session.move","target":"9f3c","args":{"workspace":"dest"}}"# + let decoded = try JSONDecoder().decode(ControlRequest.self, from: Data(json.utf8)) + #expect(decoded.args?.workspace == "dest") + #expect(decoded.args?.toWindow == nil) + } + @Test func sessionNewRoundTripsWithAfterAnchor() throws { let request = ControlRequest(cmd: .sessionNew, args: ControlArgs(after: "active")) let decoded = try roundTrip(request) diff --git a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift index f438b874b..d1997fdee 100644 --- a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift +++ b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift @@ -9,7 +9,7 @@ import Testing @MainActor final class MockControlActions: ControlActions { enum Call: Equatable { - case tree(window: String?) + case tree(window: String?, allWindows: Bool) case eventsRead(ControlEventReadOptions) case sessionNew(ControlSessionCreateOptions) case sessionDuplicate(target: String?, window: String?) @@ -24,8 +24,8 @@ final class MockControlActions: ControlActions { case workspaceGo(window: String?, WorkspaceNavigation) case workspaceRename(target: String?, window: String?, String) case workspaceDelete(target: String?, window: String?) - case sessionMove(target: String?, window: String?, ControlSessionMove) - case sessionMoveBatch(targets: [String], window: String?, ControlSessionMove) + case sessionMove(target: String?, window: String?, ControlSessionMove, select: Bool) + case sessionMoveBatch(targets: [String], window: String?, ControlSessionMove, select: Bool) case workspaceMove(target: String?, window: String?, ReorderDirection) case workspaceFocus(target: String?, window: String?, ControlWorkspaceFocusMode) case workspaceFilter(window: String?, ControlToggleMode) @@ -159,8 +159,8 @@ final class MockControlActions: ControlActions { var nextRestoreCaptureResponse = ControlResponse(ok: true) var nextSessionRestoreResponse = ControlResponse(ok: true) - func controlTree(window: String?) -> ControlResponse { - calls.append(.tree(window: window)) + func controlTree(window: String?, allWindows: Bool) -> ControlResponse { + calls.append(.tree(window: window, allWindows: allWindows)) return nextTreeResponse } @@ -234,13 +234,15 @@ final class MockControlActions: ControlActions { return ControlResponse(ok: true) } - func moveSession(_ target: String?, window: String?, move: ControlSessionMove) -> ControlResponse { - calls.append(.sessionMove(target: target, window: window, move)) + func moveSession(_ target: String?, window: String?, move: ControlSessionMove, + select: Bool) -> ControlResponse { + calls.append(.sessionMove(target: target, window: window, move, select: select)) return ControlResponse(ok: true) } - func moveSessions(_ targets: [String], window: String?, move: ControlSessionMove) -> ControlResponse { - calls.append(.sessionMoveBatch(targets: targets, window: window, move)) + func moveSessions(_ targets: [String], window: String?, move: ControlSessionMove, + select: Bool) -> ControlResponse { + calls.append(.sessionMoveBatch(targets: targets, window: window, move, select: select)) return ControlResponse(ok: true) } diff --git a/agtermCore/Tests/agtermCoreTests/NotificationsTests.swift b/agtermCore/Tests/agtermCoreTests/NotificationsTests.swift index 3f5acb77b..17612f9d6 100644 --- a/agtermCore/Tests/agtermCoreTests/NotificationsTests.swift +++ b/agtermCore/Tests/agtermCoreTests/NotificationsTests.swift @@ -33,6 +33,90 @@ struct NotificationsTests { #expect(TerminalNotification.parseIdentity("") == nil) } + @Test func isStaleTracksTheWindowHostingTheSessionNow() { + let source = UUID() + let destination = UUID() + let identity = TerminalNotification.identity(windowID: source, sessionID: UUID(), pane: .main) + #expect(TerminalNotification.isStale(identity: identity, currentWindowID: source) == false) + #expect(TerminalNotification.isStale(identity: identity, currentWindowID: destination) == true) + // unknown host: a plain window close, whose banner still reopens the window it names + #expect(TerminalNotification.isStale(identity: identity, currentWindowID: nil) == false) + } + + @Test func isStaleIgnoresIdentifiersThatAreNotSessionBanners() { + #expect(TerminalNotification.isStale(identity: "keymap-diagnostics", currentWindowID: UUID()) == false) + #expect(TerminalNotification.isStale(identity: "command-failure:build", currentWindowID: UUID()) == false) + } + + @Test func shouldSweepTakesOnlyTheSessionsBannersDeliveredBeforeTheSweep() { + let session = UUID() + let source = UUID() + let destination = UUID() + let cutoff = Date() + let before = cutoff.addingTimeInterval(-1) + let after = cutoff.addingTimeInterval(1) + let stale = TerminalNotification.identity(windowID: source, sessionID: session, pane: .main) + let owned = TerminalNotification.identity(windowID: destination, sessionID: session, pane: .main) + let other = TerminalNotification.identity(windowID: source, sessionID: UUID(), pane: .main) + func sweep(_ identity: String, deliveredAt: Date, lastPostedAt: Date?) -> Bool { + let banner = TerminalNotification.DeliveredBanner(identity: identity, deliveredAt: deliveredAt, + lastPostedAt: lastPostedAt) + return TerminalNotification.shouldSweep(banner, sessionID: session, staleRelativeTo: destination, + cutoff: cutoff) + } + #expect(sweep(stale, deliveredAt: before, lastPostedAt: before)) + #expect(sweep(owned, deliveredAt: before, lastPostedAt: before) == false) + #expect(sweep(other, deliveredAt: before, lastPostedAt: before) == false) + // a banner the sweep's async query picked up after it started, e.g. the destination of a later move + #expect(sweep(stale, deliveredAt: after, lastPostedAt: after) == false) + // the query named an older banner, but the identity was reused after the sweep started: removing it + // by identifier would take the newer banner that replaced it + #expect(sweep(stale, deliveredAt: before, lastPostedAt: after) == false) + // never posted by this launch, so only the delivery date decides + #expect(sweep(stale, deliveredAt: before, lastPostedAt: nil)) + } + + @Test func shouldSweepWithoutAWindowTakesEveryEarlierBannerOfTheSession() { + let session = UUID() + let cutoff = Date() + let identity = TerminalNotification.identity(windowID: UUID(), sessionID: session, pane: .split) + func sweep(_ identity: String, deliveredAt: Date) -> Bool { + let banner = TerminalNotification.DeliveredBanner(identity: identity, deliveredAt: deliveredAt, + lastPostedAt: nil) + return TerminalNotification.shouldSweep(banner, sessionID: session, staleRelativeTo: nil, cutoff: cutoff) + } + #expect(sweep(identity, deliveredAt: cutoff.addingTimeInterval(-1))) + #expect(sweep(identity, deliveredAt: cutoff.addingTimeInterval(1)) == false) + #expect(sweep("keymap-diagnostics", deliveredAt: cutoff) == false) + } + + @Test func retainedMoveRecordsKeepsOnlySessionsABannerCanStillReach() { + let swept = UUID(), withBanner = UUID(), closed = UUID() + let destination = UUID() + let delivered = [ + TerminalNotification.identity(windowID: UUID(), sessionID: withBanner, pane: .main), + "keymap-diagnostics", + ] + let records = [swept: destination, withBanner: destination, closed: destination] + let kept = TerminalNotification.retainedMoveRecords(records, delivered: delivered, unsettled: [swept]) + #expect(kept == [swept: destination, withBanner: destination]) + } + + @Test func retainedMoveRecordsDropsEverythingWhenNothingIsDelivered() { + let session = UUID() + let kept = TerminalNotification.retainedMoveRecords([session: UUID()], delivered: [], unsettled: [UUID()]) + #expect(kept.isEmpty) + } + + @Test func retainedMoveRecordsKeepsSessionsWithASubmissionOrSweepStillOutstanding() { + let swept = UUID(), concurrent = UUID(), posting = UUID(), closed = UUID() + let destination = UUID() + let records = [swept: destination, concurrent: destination, posting: destination, closed: destination] + let kept = TerminalNotification.retainedMoveRecords(records, delivered: [], + unsettled: [swept, concurrent, posting]) + #expect(kept == [swept: destination, concurrent: destination, posting: destination]) + } + @Test func shouldDeliverSuppressesOnlyTheFocusedActivePane() { #expect(TerminalNotification.shouldDeliver(firingIsFocused: true, appActive: true) == false) #expect(TerminalNotification.shouldDeliver(firingIsFocused: true, appActive: false) == true) diff --git a/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift b/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift index 83e896824..c5bdbf333 100644 --- a/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift +++ b/agtermCore/Tests/agtermCoreTests/WindowLibraryTests.swift @@ -56,6 +56,32 @@ final class WindowLibraryTests { #expect(library.windowName(for: UUID()) == "") } + @Test func openTreesProjectsEveryOpenWindowInOrder() { + let library = WindowLibrary(directory: directory) + let second = library.newWindow(name: "work") + + let trees = library.openTrees { store in + let id = library.windowID(for: store) + return store.controlTree(windowID: id?.uuidString, windowName: library.windowName(for: id)) + } + + #expect(trees.map(\.windowId) == [library.windows[0].id.uuidString, second.id.uuidString]) + #expect(trees.map(\.windowName) == ["window 1", "work"]) + #expect(trees.allSatisfy { !$0.workspaces.isEmpty }) + } + + @Test func openTreesOmitsClosedWindows() { + let library = WindowLibrary(directory: directory) + let second = library.newWindow(name: "work") + library.closeWindow(second.id) + + let trees = library.openTrees { store in + store.controlTree(windowID: library.windowID(for: store)?.uuidString) + } + + #expect(trees.map(\.windowId) == [library.windows[0].id.uuidString]) + } + @Test func allOpenSessionsFlattensEverySessionAcrossWindows() { let library = WindowLibrary(directory: directory) #expect(library.allOpenSessions().count == 1) @@ -1459,6 +1485,7 @@ final class WindowLibraryTests { #expect(reloadedSession.initialCwd == "/changed") _ = ws } + /// `restore.capture` answers `ok` with a pane count, which is a claim that the argv is on disk, so the /// library has to REPORT a failed flush rather than swallow it. An unwritable windows directory is the /// same lever the stale-file test above uses. @@ -1475,4 +1502,328 @@ final class WindowLibraryTests { #expect(!library.saveAllOpenChecked()) } + // MARK: - Cross-window move + + private struct TwoWindows { + let source: AppStore + let destination: AppStore + let sourceID: UUID + let destinationID: UUID + } + + private func makeTwoWindows(_ library: WindowLibrary) throws -> TwoWindows { + let sourceID = library.windows[0].id + let destinationID = library.newWindow(name: "work").id + return TwoWindows(source: try #require(library.store(for: sourceID)), + destination: try #require(library.store(for: destinationID)), + sourceID: sourceID, destinationID: destinationID) + } + + @Test func moveSessionAcrossWindowsKeepsTheInstanceAndSurface() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + let surface = SpySurface() + session.surface = surface + + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(windows.source.session(withID: session.id) == nil) + #expect(windows.destination.session(withID: session.id) === session) + #expect(session.surface === surface) + #expect(surface.teardownCount == 0) + #expect(library.windowID(forSession: session.id) == windows.destinationID) + } + + @Test func moveSessionCarriesSplitOverlayAndScratchState() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + session.isSplit = true + session.hasSplit = true + session.splitRatio = 0.35 + let split = SpySurface() + let overlay = SpySurface() + let scratch = SpySurface() + session.splitSurface = split + session.overlayActive = true + session.overlaySurface = overlay + session.scratchActive = true + session.scratchSurface = scratch + + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + let moved = try #require(windows.destination.session(withID: session.id)) + #expect(moved.isSplit) + #expect(moved.splitRatio == 0.35) + #expect(moved.splitSurface === split) + #expect(moved.overlayActive) + #expect(moved.overlaySurface === overlay) + #expect(moved.scratchActive) + #expect(moved.scratchSurface === scratch) + #expect(split.teardownCount == 0) + #expect(overlay.teardownCount == 0) + #expect(scratch.teardownCount == 0) + } + + @Test func moveSessionLandsInDestinationCurrentWorkspaceByDefault() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let later = windows.destination.addWorkspace(name: "later") + let session = try #require(windows.source.workspaces.first?.sessions.first) + + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(windows.destination.workspace(forSession: session.id)?.id == later.id) + } + + @Test func moveSessionHonorsAnExplicitDestinationWorkspace() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let first = try #require(windows.destination.workspaces.first) + _ = windows.destination.addWorkspace(name: "later") + let session = try #require(windows.source.workspaces.first?.sessions.first) + + #expect(library.moveSession(session.id, toWindow: windows.destinationID, workspace: first.id)) + #expect(windows.destination.workspace(forSession: session.id)?.id == first.id) + } + + @Test func moveSessionSelectsOnlyWhenAsked() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let destinationWorkspace = try #require(windows.destination.workspaces.first) + let resident = try #require(destinationWorkspace.sessions.first) + let session = try #require(windows.source.workspaces.first?.sessions.first) + + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(windows.destination.selectedSessionID == resident.id) + + let second = try #require(windows.destination.addSession(toWorkspace: destinationWorkspace.id, cwd: "/tmp")) + #expect(library.moveSession(second.id, toWindow: windows.sourceID, select: true)) + #expect(windows.source.selectedSessionID == second.id) + } + + @Test func moveSessionPersistsBothWindows() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + + let reloaded = WindowLibrary(directory: directory) + #expect(reloaded.store(for: windows.sourceID)?.session(withID: session.id) == nil) + #expect(reloaded.store(for: windows.destinationID)?.session(withID: session.id) != nil) + } + + @Test func moveSessionReselectsInTheSourceWindow() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let workspace = try #require(windows.source.workspaces.first) + let staying = try #require(workspace.sessions.first) + let leaving = try #require(windows.source.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + #expect(windows.source.selectedSessionID == leaving.id) + + #expect(library.moveSession(leaving.id, toWindow: windows.destinationID)) + #expect(windows.source.selectedSessionID == staying.id) + } + + @Test func moveSessionEmptiesTheSourceWindowWithoutSeedingAReplacement() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(windows.source.workspaces.count == 1) + #expect(windows.source.workspaces[0].sessions.isEmpty) + #expect(windows.source.selectedSessionID == nil) + } + + @Test func moveSessionToTheSameWindowDelegatesToTheStore() throws { + let library = WindowLibrary(directory: directory) + let sourceID = library.windows[0].id + let store = try #require(library.store(for: sourceID)) + let session = try #require(store.workspaces.first?.sessions.first) + let other = store.addWorkspace(name: "other") + + #expect(library.moveSession(session.id, toWindow: sourceID, workspace: other.id)) + #expect(store.session(withID: session.id) === session) + #expect(store.workspace(forSession: session.id)?.id == other.id) + #expect(store.workspaces[0].sessions.isEmpty) + } + + @Test func moveSessionRefusesAClosedDestination() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + library.closeWindow(windows.destinationID) + + #expect(!library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(windows.source.session(withID: session.id) === session) + } + + // the app arm resolves the destination through `resolveWindow`, which keeps CLOSED windows as + // candidates — the refusal has to come from the missing store, not from a failed resolution. + @Test func closedDestinationResolvesButStillRefusesTheMove() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + library.closeWindow(windows.destinationID) + + #expect(library.resolveWindow(windows.destinationID.uuidString) == .resolved(windows.destinationID)) + #expect(library.store(for: windows.destinationID) == nil) + #expect(!library.moveSession(session.id, toWindow: windows.destinationID)) + } + + @Test func destinationWorkspaceIsNotResolvedAgainstTheSourceWindow() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let sourceWorkspace = try #require(windows.source.workspaces.first) + let session = try #require(sourceWorkspace.sessions.first) + + #expect(!library.moveSession(session.id, toWindow: windows.destinationID, workspace: sourceWorkspace.id)) + #expect(windows.source.session(withID: session.id) === session) + #expect(windows.destination.session(withID: session.id) == nil) + } + + @Test func moveSessionRefusesUnknownSessionAndWindow() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + + #expect(!library.moveSession(UUID(), toWindow: windows.destinationID)) + #expect(!library.moveSession(session.id, toWindow: UUID())) + #expect(windows.source.session(withID: session.id) === session) + } + + @Test func moveSessionRefusesUnknownDestinationWorkspace() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + + #expect(!library.moveSession(session.id, toWindow: windows.destinationID, workspace: UUID())) + #expect(windows.source.session(withID: session.id) === session) + #expect(windows.destination.session(withID: session.id) == nil) + } + + @Test func moveSessionRefusesWhileTheSourceWindowHasAPickPending() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + let pick = PickController() + PickRegistry.shared.register(windows.sourceID, controller: pick) + defer { PickRegistry.shared.unregister(windows.sourceID) } + pick.open(PendingPick(id: "p1", items: [ControlPickItem(id: "one", label: "one")])) + + #expect(!library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(windows.source.session(withID: session.id) === session) + + pick.cancel() + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + } + + @Test func moveSessionClearsTheSourceWindowZoom() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + let zoom = TerminalZoomController() + TerminalZoomRegistry.shared.register(windows.sourceID, controller: zoom) + defer { TerminalZoomRegistry.shared.unregister(windows.sourceID) } + zoom.set(.on, target: .session(session.id, .primary)) + + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(zoom.target == nil) + } + + @Test func moveSessionLeavesAZoomTargetingAnotherSession() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let workspace = try #require(windows.source.workspaces.first) + let staying = try #require(workspace.sessions.first) + let leaving = try #require(windows.source.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + let zoom = TerminalZoomController() + TerminalZoomRegistry.shared.register(windows.sourceID, controller: zoom) + defer { TerminalZoomRegistry.shared.unregister(windows.sourceID) } + zoom.set(.on, target: .session(staying.id, .primary)) + + #expect(library.moveSession(leaving.id, toWindow: windows.destinationID)) + #expect(zoom.target == .session(staying.id, .primary)) + } + + @Test func moveSessionClosesTheSourceWindowDashboardHostingIt() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let workspace = try #require(windows.source.workspaces.first) + let staying = try #require(workspace.sessions.first) + let leaving = try #require(windows.source.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + let dashboard = DashboardController() + DashboardControllerRegistry.shared.register(windows.sourceID, controller: dashboard) + defer { DashboardControllerRegistry.shared.unregister(windows.sourceID) } + dashboard.open(members: [DashboardMember(session: staying.id, surface: .primary), + DashboardMember(session: leaving.id, surface: .primary)]) + + #expect(library.moveSession(leaving.id, toWindow: windows.destinationID)) + #expect(!dashboard.isOpen) + } + + @Test func moveSessionLeavesADashboardWithoutTheMovedSession() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let workspace = try #require(windows.source.workspaces.first) + let staying = try #require(workspace.sessions.first) + let leaving = try #require(windows.source.addSession(toWorkspace: workspace.id, cwd: "/tmp")) + let dashboard = DashboardController() + DashboardControllerRegistry.shared.register(windows.sourceID, controller: dashboard) + defer { DashboardControllerRegistry.shared.unregister(windows.sourceID) } + dashboard.open(members: [DashboardMember(session: staying.id, surface: .primary)]) + + #expect(library.moveSession(leaving.id, toWindow: windows.destinationID)) + #expect(dashboard.isOpen) + } + + // MARK: - Adopted-session rebind + + @Test func moveSessionRebindsTheAdoptedSessionToTheDestinationStore() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + var rebound: [(UUID, ObjectIdentifier)] = [] + library.rebindAdoptedSession = { rebound.append(($0.id, ObjectIdentifier($1))) } + + #expect(library.moveSession(session.id, toWindow: windows.destinationID)) + #expect(rebound.map(\.0) == [session.id]) + #expect(rebound.map(\.1) == [ObjectIdentifier(windows.destination)]) + } + + @Test func moveSessionSkipsTheRebindWhenItRefusesOrStaysInTheWindow() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + let session = try #require(windows.source.workspaces.first?.sessions.first) + let other = windows.source.addWorkspace(name: "other") + var rebinds = 0 + library.rebindAdoptedSession = { _, _ in rebinds += 1 } + + #expect(library.moveSession(session.id, toWindow: windows.sourceID, workspace: other.id)) + #expect(!library.moveSession(session.id, toWindow: windows.destinationID, workspace: UUID())) + #expect(rebinds == 0) + } + + // MARK: - Move destinations + + @Test func moveDestinationsAreEmptyWithOneOpenWindow() { + let library = WindowLibrary(directory: directory) + #expect(library.moveDestinations(excluding: library.windows[0].id).isEmpty) + } + + @Test func moveDestinationsListEveryOtherOpenWindowInOrder() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + + #expect(library.moveDestinations(excluding: windows.sourceID).map(\.id) == [windows.destinationID]) + #expect(library.moveDestinations(excluding: windows.destinationID).map(\.id) == [windows.sourceID]) + #expect(library.moveDestinations(excluding: nil).map(\.id) == [windows.sourceID, windows.destinationID]) + } + + @Test func moveDestinationsOmitAClosedWindow() throws { + let library = WindowLibrary(directory: directory) + let windows = try makeTwoWindows(library) + library.closeWindow(windows.destinationID) + + #expect(library.moveDestinations(excluding: windows.sourceID).isEmpty) + } } diff --git a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift index 482ddf8a3..db580840c 100644 --- a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift @@ -299,6 +299,57 @@ struct CommandsTests { == "use either --after or --before, not both") } + @Test func sessionMoveToWindow() throws { + let expected = ControlRequest(cmd: .sessionMove, target: "s1", + args: ControlArgs(select: false, toWindow: "w2")) + #expect(try request(["session", "move", "--to-window", "w2", "--target", "s1"]) == expected) + } + + @Test func sessionMoveToWindowWithWorkspaceAndSelect() throws { + let expected = ControlRequest(cmd: .sessionMove, target: "s1", + args: ControlArgs(workspace: "ws2", select: true, toWindow: "w2")) + #expect(try request(["session", "move", "ws2", "--to-window", "w2", "--select", "--target", "s1"]) == expected) + } + + @Test func sessionMoveToWindowMultipleTargets() throws { + let expected = ControlRequest(cmd: .sessionMove, target: "s1", + args: ControlArgs(targets: ["s1", "s2"], select: false, toWindow: "w2")) + #expect(try request(["session", "move", "--to-window", "w2", "--target", "s1", "--target", "s2"]) == expected) + } + + @Test func sessionMoveToWindowKeepsWindowAsSearchScope() throws { + let expected = ControlRequest(cmd: .sessionMove, target: "s1", + args: ControlArgs(select: false, window: "w1", toWindow: "w2")) + #expect(try request(["session", "move", "--to-window", "w2", "--window", "w1", "--target", "s1"]) == expected) + } + + @Test func sessionMoveRejectsToWindowWithTo() { + #expect(validationMessage(["session", "move", "--to-window", "w2", "--to", "up"]) + == "session.move takes --to-window or --to, not both") + } + + @Test func sessionMoveRejectsToWindowWithAfter() { + #expect(validationMessage(["session", "move", "--to-window", "w2", "--after", "s2"]) + == "session.move takes --to-window or --after/--before, not both") + } + + @Test func sessionMoveRejectsToWindowWithBefore() { + #expect(validationMessage(["session", "move", "--to-window", "w2", "--before", "s2"]) + == "session.move takes --to-window or --after/--before, not both") + } + + @Test func sessionMoveRejectsSelectWithoutToWindow() { + #expect(validationMessage(["session", "move", "ws2", "--select"]) + == "session.move --select requires --to-window") + #expect(validationMessage(["session", "move", "--to", "up", "--select"]) + == "session.move --select requires --to-window") + } + + @Test func sessionMoveWindowAloneIsNotADestination() { + #expect(validationMessage(["session", "move", "--window", "w1"]) + == "provide a destination workspace, --to, or --after/--before") + } + @Test func sessionMoveRejectsAfterAndTo() { #expect(validationMessage(["session", "move", "--after", "a", "--to", "up"]) == "session.move takes --after/--before or --to, not both") @@ -1703,6 +1754,16 @@ struct CommandsTests { #expect(try request(["tree"]) == ControlRequest(cmd: .tree)) } + @Test func treeAllWindows() throws { + #expect(try request(["tree", "--all-windows"]) == ControlRequest(cmd: .tree, + args: ControlArgs(allWindows: true))) + } + + @Test func treeAllWindowsKeepsTheWindowSelectorForTheServerToReject() throws { + let expected = ControlRequest(cmd: .tree, args: ControlArgs(window: "w1", allWindows: true)) + #expect(try request(["tree", "--all-windows", "--window", "w1"]) == expected) + } + // --window folds into the command's existing args bag rather than replacing it. @Test func sessionTypeWithWindow() throws { diff --git a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift index 992dea4c5..88b9787c4 100644 --- a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift +++ b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift @@ -823,6 +823,44 @@ struct SocketClientTests { #expect(lines[1] == " * shell (split) [s1] /tmp") } + @Test func formatTreeHeadsTheListingWithTheProjectedWindow() { + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false, + windowId: "w-1", workspaceId: "w1") + let workspace = ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session]) + let tree = ControlTree(workspaces: [workspace], windowId: "w-1") + let out = SocketClient.formatResponse(ControlResponse(ok: true, result: ControlResult(tree: tree)), json: false) + let lines = out.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + #expect(lines == ["window [w-1]", "* work [w1]", " * shell [s1] /tmp"]) + } + + @Test func formatTreeNamesTheWindowInTheHeader() { + let workspace = ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: []) + let tree = ControlTree(workspaces: [workspace], windowId: "w-1", windowName: "left") + let out = SocketClient.formatResponse(ControlResponse(ok: true, result: ControlResult(tree: tree)), json: false) + #expect(out.split(separator: "\n").first == "window left [w-1]") + } + + @Test func formatResponseAllWindowsPrintsOneSectionPerWindow() { + let first = ControlTree(workspaces: [ControlWorkspaceNode(id: "w1", name: "work", active: true, + sessions: [])], + windowId: "win-1", windowName: "left") + let second = ControlTree(workspaces: [ControlWorkspaceNode(id: "w2", name: "side", active: true, + sessions: [])], + windowId: "win-2", windowName: "right") + let response = ControlResponse(ok: true, result: ControlResult(trees: [first, second])) + let lines = SocketClient.formatResponse(response, json: false) + .split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + #expect(lines == ["window left [win-1]", "* work [w1]", "window right [win-2]", "* side [w2]"]) + } + + @Test func formatTreeDropsTheHeaderWithoutAWindow() { + let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false) + let workspace = ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session]) + let tree = ControlTree(workspaces: [workspace]) + let out = SocketClient.formatResponse(ControlResponse(ok: true, result: ControlResult(tree: tree)), json: false) + #expect(out.split(separator: "\n").first == "* work [w1]") + } + @Test func formatTreeTagsHiddenSplit() { let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false, hasSplit: true) diff --git a/agtermTests/AdoptedSurfaceRebindTests.swift b/agtermTests/AdoptedSurfaceRebindTests.swift new file mode 100644 index 000000000..b74a0429b --- /dev/null +++ b/agtermTests/AdoptedSurfaceRebindTests.swift @@ -0,0 +1,62 @@ +import AppKit +import XCTest +@testable import agterm +import agtermCore + +/// The transient dashboard font override is swept per-window over that window's OWN store, so a session +/// that leaves before the sweep runs would keep the grid font forever in its new window. +@MainActor +final class AdoptedSurfaceRebindTests: XCTestCase { + private var stateDir: URL! + private var library: WindowLibrary! + private var window: NSWindow! + + override func setUp() async throws { + try await super.setUp() + await MainActor.run { + stateDir = FileManager.default.temporaryDirectory + .appendingPathComponent("agterm-adopt-rebind-tests-\(UUID().uuidString)", isDirectory: true) + library = WindowLibrary(directory: stateDir) + window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 320, height: 200), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + } + } + + override func tearDown() async throws { + await MainActor.run { + window.orderOut(nil) + window = nil + library = nil + try? FileManager.default.removeItem(at: stateDir) + stateDir = nil + } + try await super.tearDown() + } + + /// Both surfaces stay at their zero init frame, so `viewDidMoveToWindow` parks in + /// `pendingSurfaceCreation` instead of spawning a libghostty surface and a shell. + private func makeSurface() -> GhosttySurfaceView { + let surface = GhosttySurfaceView(workingDirectory: NSTemporaryDirectory()) + window.contentView?.addSubview(surface) + return surface + } + + func testRebindClearsTheDashboardFontOverrideOnBothPanes() throws { + let store = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(store.currentWorkspaceID) + let session = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + let main = makeSurface() + let split = makeSurface() + split.isSplitPane = true + session.surface = main + session.splitSurface = split + main.dashboardFontOverride = 9 + split.dashboardFontOverride = 9 + + agtermApp.rebindSurfaces(of: session, to: store, library: library) + + XCTAssertNil(main.dashboardFontOverride) + XCTAssertNil(split.dashboardFontOverride) + } +} diff --git a/agtermTests/AppActionsPaletteTests.swift b/agtermTests/AppActionsPaletteTests.swift index ba626b10d..d33524641 100644 --- a/agtermTests/AppActionsPaletteTests.swift +++ b/agtermTests/AppActionsPaletteTests.swift @@ -34,7 +34,11 @@ final class AppActionsPaletteTests: XCTestCase { } private func moveDestinationIDs() -> [String] { - actions.paletteActions().map(\.id).filter { $0.hasPrefix("move-") } + actions.paletteActions().map(\.id).filter { $0.hasPrefix("move-") && !$0.hasPrefix("move-window-") } + } + + private func moveWindowDestinationIDs() -> [String] { + actions.paletteActions().map(\.id).filter { $0.hasPrefix("move-window-") } } // a freshly created workspace is `currentWorkspaceID` while the selection still sits elsewhere, so @@ -61,6 +65,86 @@ final class AppActionsPaletteTests: XCTestCase { XCTAssertTrue(moveDestinationIDs().isEmpty, "there is no session to move") } + func testNoMoveToWindowDestinationsWithASingleOpenWindow() throws { + let store = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(store.currentWorkspaceID) + let session = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + store.selectSession(session.id) + + XCTAssertTrue(moveWindowDestinationIDs().isEmpty, "the only open window is not a destination") + } + + func testMoveToWindowListsTheOtherOpenWindowAndMovesTheSessionThere() throws { + let store = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(store.currentWorkspaceID) + let session = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + store.selectSession(session.id) + let sourceID = try XCTUnwrap(library.activeWindowID) + let other = library.newWindow(name: "work") + let destination = try XCTUnwrap(library.store(for: other.id)) + library.frontmostWindowID = sourceID // the palette is driven from the window the session is in + + let row = try XCTUnwrap(actions.paletteActions().first { $0.id == "move-window-\(other.id)" }) + XCTAssertEqual(row.title, "Move Session to Window: work") + XCTAssertEqual(moveWindowDestinationIDs(), ["move-window-\(other.id)"]) + row.run() + + XCTAssertNil(store.session(withID: session.id), "the source window gave the session up") + XCTAssertTrue(destination.session(withID: session.id) === session, "the same instance, with its shell") + } + + func testMoveSessionsToWindowMovesAWholeSidebarSelection() throws { + let store = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(store.currentWorkspaceID) + let first = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + let second = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + let other = library.newWindow(name: "work") + let destination = try XCTUnwrap(library.store(for: other.id)) + + XCTAssertEqual(actions.moveSessions([first.id, second.id], toWindow: other.id), 2) + + let moved = try XCTUnwrap(destination.workspaces.first).sessions.map(\.id) + XCTAssertEqual(moved.suffix(2), [first.id, second.id], "the selection keeps its order") + XCTAssertNil(store.session(withID: first.id)) + XCTAssertNil(store.session(withID: second.id)) + } + + // the gate reads the SOURCE window: a background sidebar's Move must not be judged by whatever modal + // the frontmost window happens to have up. + func testMoveToWindowIsGatedOnTheSourceWindowNotTheFrontmostOne() throws { + let store = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(store.currentWorkspaceID) + let session = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + let sourceID = try XCTUnwrap(library.windowID(forSession: session.id)) + let other = library.newWindow(name: "work") + let destination = try XCTUnwrap(library.store(for: other.id)) + library.frontmostWindowID = other.id + + let zoom = TerminalZoomController() + TerminalZoomRegistry.shared.register(other.id, controller: zoom) + defer { TerminalZoomRegistry.shared.unregister(other.id) } + zoom.set(.on, target: .session(session.id, .primary)) + + XCTAssertTrue(actions.moveSession(session.id, toWindow: other.id), + "the frontmost window's zoom is not the gate") + XCTAssertTrue(destination.session(withID: session.id) === session) + + XCTAssertFalse(actions.moveSession(session.id, toWindow: sourceID), + "the session's own window is the zoomed one now, so the move back is refused") + XCTAssertNil(store.session(withID: session.id)) + } + + func testMoveSessionToAClosedWindowIsRefused() throws { + let store = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(store.currentWorkspaceID) + let session = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + let other = library.newWindow(name: "work") + library.closeWindow(other.id) + + XCTAssertFalse(actions.moveSession(session.id, toWindow: other.id)) + XCTAssertTrue(store.session(withID: session.id) === session) + } + private func actionRow(_ command: PaletteCommand) throws -> PaletteItem { let title = command.title(in: actions.paletteContext) return try XCTUnwrap(actions.paletteActions().first { $0.title == title }) diff --git a/agtermTests/ControlServerSessionActionsTests.swift b/agtermTests/ControlServerSessionActionsTests.swift index cadda9294..fcedba5ef 100644 --- a/agtermTests/ControlServerSessionActionsTests.swift +++ b/agtermTests/ControlServerSessionActionsTests.swift @@ -652,4 +652,93 @@ final class ControlServerSessionActionsTests: XCTestCase { XCTAssertTrue(resized.ok, resized.error ?? "") XCTAssertEqual(bodyText(session), body, "a resize must rewrite the header the helper reads") } + + // MARK: - Cross-window move + + private struct MoveFixture { + let source: AppStore + let destination: AppStore + let destinationID: UUID + let session: Session + } + + private func makeMoveFixture() throws -> MoveFixture { + let source = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(source.currentWorkspaceID) + let session = try XCTUnwrap(source.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + let other = library.newWindow(name: "work") + return MoveFixture(source: source, destination: try XCTUnwrap(library.store(for: other.id)), + destinationID: other.id, session: session) + } + + // the destination workspace names one inside the DESTINATION store, the only store that has it, while + // `--window` stays the search scope for the target. + func testMoveToWindowResolvesTheWorkspaceInTheDestinationStore() throws { + let fixture = try makeMoveFixture() + let target = fixture.destination.addWorkspace(name: "landing") + + let response = server.moveSession(fixture.session.id.uuidString, window: nil, + move: .window(window: fixture.destinationID.uuidString, + workspace: target.id.uuidString), + select: false) + + XCTAssertTrue(response.ok, response.error ?? "") + XCTAssertEqual(response.result?.id, fixture.session.id.uuidString) + XCTAssertNil(fixture.source.session(withID: fixture.session.id)) + XCTAssertEqual(fixture.destination.workspace(forSession: fixture.session.id)?.id, target.id) + XCTAssertNotEqual(fixture.destination.selectedSessionID, fixture.session.id, "a plain move stays background") + } + + func testMoveToWindowWithSelectMakesItActiveInTheDestination() throws { + let fixture = try makeMoveFixture() + + let response = server.moveSession(fixture.session.id.uuidString, window: nil, + move: .window(window: fixture.destinationID.uuidString, workspace: nil), + select: true) + + XCTAssertTrue(response.ok, response.error ?? "") + XCTAssertEqual(fixture.destination.selectedSessionID, fixture.session.id) + } + + func testMoveToWindowReportsAClosedDestinationRatherThanOpeningIt() throws { + let fixture = try makeMoveFixture() + library.closeWindow(fixture.destinationID) + + let response = server.moveSession(fixture.session.id.uuidString, window: nil, + move: .window(window: fixture.destinationID.uuidString, workspace: nil), + select: false) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error, "window not open — window.select it first") + XCTAssertTrue(fixture.source.session(withID: fixture.session.id) === fixture.session) + } + + // a workspace of the SOURCE window must not resolve: the destination store is where it has to exist. + func testMoveToWindowRejectsAWorkspaceFromTheSourceWindow() throws { + let fixture = try makeMoveFixture() + let sourceOnly = fixture.source.addWorkspace(name: "source-only") + + let response = server.moveSession(fixture.session.id.uuidString, window: nil, + move: .window(window: fixture.destinationID.uuidString, + workspace: sourceOnly.id.uuidString), + select: false) + + XCTAssertFalse(response.ok) + XCTAssertTrue(fixture.source.session(withID: fixture.session.id) === fixture.session) + } + + func testMoveToWindowBatchMovesEveryTargetInOrder() throws { + let fixture = try makeMoveFixture() + let owner = try XCTUnwrap(fixture.source.currentWorkspaceID) + let second = try XCTUnwrap(fixture.source.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + + let response = server.moveSessions([fixture.session.id.uuidString, second.id.uuidString], window: nil, + move: .window(window: fixture.destinationID.uuidString, workspace: nil), + select: false) + + XCTAssertTrue(response.ok, response.error ?? "") + XCTAssertEqual(response.result?.affected, 2) + let landed = try XCTUnwrap(fixture.destination.workspaces.first).sessions.map(\.id) + XCTAssertEqual(landed.suffix(2), [fixture.session.id, second.id]) + } } diff --git a/agtermTests/ControlServerWorkspaceCommandsTests.swift b/agtermTests/ControlServerWorkspaceCommandsTests.swift index 63700da82..343ddcbf4 100644 --- a/agtermTests/ControlServerWorkspaceCommandsTests.swift +++ b/agtermTests/ControlServerWorkspaceCommandsTests.swift @@ -66,11 +66,34 @@ final class ControlServerWorkspaceCommandsTests: XCTestCase { XCTAssertTrue(version.ok, version.error ?? "") XCTAssertEqual(version.result?.app, injected) - let tree = server.controlTree(window: nil) + let tree = server.controlTree(window: nil, allWindows: false) XCTAssertTrue(tree.ok, tree.error ?? "") XCTAssertEqual(tree.result?.tree?.app, injected) } + // the only projection that spans windows, so a caller can locate a session it moved out of its own one + func testAllWindowsTreeProjectsEveryOpenWindowAndOmitsClosedOnes() throws { + let store = try XCTUnwrap(library.activeStore) + let owner = try XCTUnwrap(store.currentWorkspaceID) + let session = try XCTUnwrap(store.addSession(toWorkspace: owner, cwd: NSHomeDirectory())) + let sourceID = try XCTUnwrap(library.windowID(forSession: session.id)) + let other = library.newWindow(name: "work") + + let response = server.controlTree(window: nil, allWindows: true) + + XCTAssertTrue(response.ok, response.error ?? "") + XCTAssertNil(response.result?.tree, "--all-windows answers in `trees` instead") + let trees = try XCTUnwrap(response.result?.trees) + XCTAssertEqual(trees.map(\.windowId), [sourceID.uuidString, other.id.uuidString]) + XCTAssertEqual(trees.first?.windowName, library.windowName(for: sourceID)) + let owning = trees.first { $0.workspaces.contains { $0.sessions.contains { $0.id == session.id.uuidString } } } + XCTAssertEqual(owning?.windowId, sourceID.uuidString) + + library.closeWindow(other.id) + let afterClose = server.controlTree(window: nil, allWindows: true) + XCTAssertEqual(afterClose.result?.trees?.map(\.windowId), [sourceID.uuidString]) + } + func testSelectingAnEmptyWorkspaceReportsAndTargetsIt() throws { let store = try XCTUnwrap(library.activeStore) let owner = try XCTUnwrap(store.currentWorkspaceID) diff --git a/agtermUITests/MultiWindowUITests.swift b/agtermUITests/MultiWindowUITests.swift index f38c29fa2..76ee03209 100644 --- a/agtermUITests/MultiWindowUITests.swift +++ b/agtermUITests/MultiWindowUITests.swift @@ -299,6 +299,63 @@ final class MultiWindowUITests: XCTestCase { return value.lowercased() } + /// The session ids in one window's `tree`, lowercased, in workspace/session order. + private func sessionIDs(inWindow id: UUID) throws -> [String] { + let response = try sendCommand(#"{"cmd":"tree","args":{"window":"\#(id.uuidString)"}}"#) + XCTAssertEqual(response["ok"] as? Bool, true, "tree for window \(id) should succeed: \(response)") + let tree = (response["result"] as? [String: Any])?["tree"] as? [String: Any] + let workspaces = (tree?["workspaces"] as? [[String: Any]]) ?? [] + return workspaces.flatMap { ($0["sessions"] as? [[String: Any]]) ?? [] } + .compactMap { ($0["id"] as? String)?.lowercased() } + } + + // DISCRIMINATING: the pre-move marker read back AFTER the move is what separates a real instance + // transfer from a recreated session — a fresh shell in window B would pass the tree assertions alone. + func testMovingSessionToAnotherWindowKeepsItsLiveShell() throws { + try seedTwoWindowsWithKnownSessions() + launch() + + XCTAssertTrue(app.staticTexts["alpha-ws"].waitForExistence(timeout: 30) + || app.staticTexts["beta-ws"].waitForExistence(timeout: 30), + "a seeded window's workspace should render") + XCTAssertTrue(pollWindowCount(atLeast: 2, timeout: 10), "two windows should open, got \(app.windows.count)") + XCTAssertTrue(pollIndexOpenState([windowAID: true, windowBID: true], timeout: 10), + "both seeded windows should be marked open in windows.json") + + let moving = try XCTUnwrap(sessionByWindow[windowAID], "window A's seeded session id") + let resident = try XCTUnwrap(sessionByWindow[windowBID], "window B's seeded session id") + + // realize the surface and leave a unique string in its scrollback while it still lives in window A. + let marker = "premove-\(UUID().uuidString.prefix(8))" + let beforeFile = markerDir.appendingPathComponent("premove") + XCTAssertEqual(try sendCommand(#"{"cmd":"window.select","target":"\#(windowAID.uuidString)"}"#)["ok"] as? Bool, + true, "selecting window A should succeed") + XCTAssertEqual(try typeUntilMarker("echo \(marker) > '\(beforeFile.path)'\n", target: moving.uuidString, + file: beforeFile, window: windowAID.uuidString), String(marker), + "the session's shell should be running before the move") + + let moved = try sendCommand( + #"{"cmd":"session.move","target":"\#(moving.uuidString)","args":{"toWindow":"\#(windowBID.uuidString)"}}"#) + XCTAssertEqual(moved["ok"] as? Bool, true, "moving the session to window B should succeed: \(moved)") + + XCTAssertEqual(try sessionIDs(inWindow: windowAID), [], + "window A's tree should no longer list the moved session") + XCTAssertEqual(try sessionIDs(inWindow: windowBID).sorted(), + [resident, moving].map { $0.uuidString.lowercased() }.sorted(), + "window B's tree should list its own session plus the moved one") + + let text = try sendCommand(#"{"cmd":"session.text","target":"\#(moving.uuidString)"}"#) + XCTAssertEqual(text["ok"] as? Bool, true, "session.text should still reach the moved session: \(text)") + let buffer = ((text["result"] as? [String: Any])?["text"] as? String) ?? "" + XCTAssertTrue(buffer.contains(marker), "the moved session should keep its pre-move scrollback: \(buffer)") + + let after = "postmove-\(UUID().uuidString.prefix(8))" + let afterFile = markerDir.appendingPathComponent("postmove") + XCTAssertEqual(try typeUntilMarker("echo \(after) > '\(afterFile.path)'\n", target: moving.uuidString, + file: afterFile, window: windowBID.uuidString), String(after), + "the moved session's shell should still take input from window B") + } + func testReopenAllAfterSimulatedQuitRestoresOpenSetAndSelection() throws { try seedTwoWindowsWithSelection() let expectedSelection = selectedByWindow diff --git a/docs/plans/completed/20260826-cross-window-session-move.md b/docs/plans/completed/20260826-cross-window-session-move.md new file mode 100644 index 000000000..46b6cef0a --- /dev/null +++ b/docs/plans/completed/20260826-cross-window-session-move.md @@ -0,0 +1,394 @@ +# Cross-window session move + +## Overview + +Let a session move from one open window to another, carrying its live shell, and expose which window and +workspace a session currently belongs to so a caller can find itself after the move. + +- **Problem it solves:** a session is pinned to the window it was created in. Splitting work across + windows means closing a session and starting over, losing the running process. There is no GitHub issue + or discussion for this — the only prior statement is `docs/plans/completed/20260619-multi-window.md`, + which deferred cross-window *drag* and *shared* state. A move keeps the strict 1:1 model intact + (one bundle, one window) and is not what that note excluded. +- **Second half — ownership read-back:** `AGTERM_WINDOW_ID` and `AGTERM_WORKSPACE_ID` are spawn-time + snapshots baked into the shell env (`SurfaceEnvironment.swift:24`). Nothing can rewrite a live + process's `environ`, so any move makes them stale, and today there is no command that answers "which + window/workspace owns this session". `AGTERM_WORKSPACE_ID` already goes stale on an ordinary + `session.move --workspace`, so this is a pre-existing hole this plan closes. + `control-api.md` also requires a state-setting command to publish its result on the session node, so + the move command owes this read-back regardless. +- **Integration:** the move is a transfer of one `Session` *instance* between two `AppStore`s. + `AppStore.moveSession` already keeps the instance alive within a store "so its attached surface and + live shell survive"; this generalizes that across stores. `WindowLibrary` owns the operation because it + spans two stores and is where cross-window lookup already lives. + +**Scope decisions (locked in planning):** +- Destination must be an **open** window; a closed one is refused with the existing wording + `window not open — window.select it first`. No auto-open: a closed window has no mounted deck, and + scene IDs are claimed from a FIFO queue, so the moved surface could land with no host. +- The move does **not** select or raise. A background move stays background, matching + `session.new --no-select`. `--select` opts into selecting it in the destination. +- GUI surface is the sidebar row menu + command palette. **No cross-window drag** — that stays deferred. + +## Context (from discovery) + +**Files/components involved:** +- `agtermCore/Sources/agtermCore/AppStore.swift:549` — `moveSession`, the same-instance in-store move. +- `agtermCore/Sources/agtermCore/WindowLibrary.swift:290` — `windowID(forSession:)`, `store(forSession:)`, + `store(for:)`, and `reopenRecentClosed(_:into:)`, which already inserts into an arbitrary target store. +- `agtermCore/Sources/agtermCore/AppStore+CloseReselection.swift` — reselection when the selected session + leaves the store. +- `agtermCore/Sources/agtermCore/ControlModes.swift:111` — `ControlSessionMove`. +- `agtermCore/Sources/agtermCore/ControlDispatcher.swift:502` — `dispatchSessionMove`. +- `agtermCore/Sources/agtermCore/ControlProtocol.swift:484,690,794` — `ControlSessionNode`, `ControlTree`, + `ControlWindowNode`, `ControlResult`. +- `agtermCore/Sources/agtermCore/AppStore.swift:257` — `controlTree`, the tree projection. +- `agtermCore/Sources/agtermctlKit/SessionCommands.swift:150-167` — `session move` CLI validation. +- `agterm/Control/ControlTargetResolver.swift:70` — session resolution scoping. +- `agterm/Control/ControlServer+SessionActions.swift:509` — app-side `moveSession`/`moveSessions`. +- `agterm/Control/ControlServer+AppCommands.swift:10` — `controlTree(window:)`. +- `agterm/Views/WorkspaceSidebar+ContextMenu.swift:102-116,212` — the "Move to" workspace submenu and + `menuMove`, the pattern the window submenu mirrors. +- `agterm/AppActions.swift:515` — `moveSession`, used by the palette's "Move Session to …" items. + +**Related patterns found:** +- `TerminalView.makeNSView` reuses `session.surface` if already set and `dismantleNSView` is a deliberate + no-op (`agterm/Views/TerminalView.swift:40,91`), so unmounting from one window's deck does not tear down + the shell. +- `GhosttySurfaceView.viewDidMoveToWindow` (`agterm/Ghostty/GhosttySurfaceView.swift:794`) re-pushes + `ghostty_surface_set_content_scale` and `ghostty_surface_set_size` for an existing surface; the + key-window observers are deliberately unfiltered so a re-host survives. Cross-display moves get the + right backing scale for free. **No new AppKit work is needed for the surface itself.** +- `WindowContentView+Detail.swift` mounts EVERY session of the store in one deck, so the moved session + unmounts from the source window's `ForEach` and mounts in the destination's. + +**Dependencies identified:** +- `TerminalZoomRegistry` (`TerminalZoom.swift:233`) and `DashboardControllerRegistry` + (`DashboardController.swift:175`) are keyed by `WindowInfo.ID` and can hold the moved session's surface; + `PickRegistry` is consulted the same way in `ControlServer+SurfaceIO.swift:374`. All three live in + `agtermCore`, so eviction stays host-free. +- Per-store session-keyed state that must be pruned or transferred: `selectedSessionID`, + `sidebarSelectionIDs`, `sessionRecency`, `pendingCloseSummary`. +- `--window` is already taken: on `session.move` it scopes where `--target` is *searched* + (`ControlTargetResolver.resolveSession`), not where the session goes. The destination flag must be + `--to-window`. + +## Development Approach + +- **Testing approach**: Regular (code first, then tests) +- Complete each task fully before moving to the next +- Make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task + - tests are not optional - they are a required part of the checklist + - write unit tests for new functions/methods + - write unit tests for modified functions/methods + - add new test cases for new code paths + - update existing test cases if behavior changes + - tests cover both success and error scenarios +- **CRITICAL: all tests must pass before starting next task** - no exceptions +- **CRITICAL: update this plan file when scope changes during implementation** +- Run tests after each change +- Maintain backward compatibility + +Project-specific rules that bind this plan: +- Model, persistence, parsing, validation, routing, response shaping stay in host-free `agtermCore`; the + app target is a side-effect adapter (the #78 hoist series). `agtermCore` imports no GhosttyKit or AppKit. +- New protocol fields are OPTIONAL so legacy decode survives without a `Snapshot.version` bump. +- No surface states a command total — adding a command must not require editing a count anywhere. +- Comments stay short and carry only non-obvious constraints. + +## Testing Strategy + +- **Unit tests**: required for every task. Host-free tests in `agtermCore/Tests/agtermCoreTests/` + (`WindowLibraryTests`, `AppStoreTests`, `ControlDispatcherTests` + `MockControlActions`, + `ControlProtocolTests`) and CLI validation tests in `agtermCore/Tests/agtermctlKitTests/CommandsTests`. +- **E2E tests**: `agtermUITests/MultiWindowUITests.swift` and `agtermUITests/ControlAPIUITests.swift` are + the XCUITest surface. Add the end-to-end move there in the task that completes the control path. +- **Gate discipline**: run `swift test` per task. Run `make test-app` and `make lint` ONCE at the end. + Scope XCUITest runs with `-only-testing://` — never re-run a whole suite + (`ControlAPIUITests` alone is ~82 methods / ~7.5 minutes). + +## Progress Tracking + +- Mark completed items with `[x]` immediately when done +- Add newly discovered tasks with ➕ prefix +- Document issues/blockers with ⚠️ prefix +- Update plan if implementation deviates from original scope +- Keep plan in sync with actual work done + +## What Goes Where + +- **Implementation Steps** (`[ ]` checkboxes): code, tests, documentation updates in this repo +- **Post-Completion** (no checkboxes): manual GUI verification and anything needing a running app + +## Implementation Steps + +### Task 1: Add detach/adopt seam to AppStore + +- [x] add `AppStore.detachSession(_ id: UUID) -> Session?` (landed in a new `AppStore+Transfer.swift`; + `AppStore.swift` is already at the 1000-line limit): removes the instance from + its workspace WITHOUT tearing down its surface, reselects through the existing close-reselection + path when it was `selectedSessionID`, prunes `sessionRecency` and `sidebarSelectionIDs`, calls + `scheduleTreeChanged()` and `save()`; returns nil for an unknown id +- [x] add `AppStore.adoptSession(_ session: Session, toWorkspace: UUID?, at index: Int?, select: Bool) -> Bool`: + inserts the instance (nil workspace → `currentWorkspaceID`, then `workspaces.last`), optionally + selects it, `scheduleTreeChanged()` + `save()`; false on an unknown workspace or a duplicate id +- [x] make `detachSession` tolerate emptying a workspace and emptying the whole store (a window with no + sessions renders "No session selected"; do not seed a replacement) +- [x] write tests for `detachSession` (selected vs unselected source, recency pruned, last-session-in-store, + unknown id) +- [x] write tests for `adoptSession` (default workspace, explicit workspace, `select:` both ways, unknown + workspace, duplicate id rejected) +- [x] run `cd agtermCore && swift test` - must pass before task 2 + +### Task 2: Add WindowLibrary.moveSession across windows + +- [x] add `WindowLibrary.moveSession(_ sessionID: UUID, toWindow: UUID, workspace: UUID?, select: Bool) -> Bool` + in `WindowLibrary.swift`: resolve the source store via `store(forSession:)`, the destination via + `store(for:)`; a nil destination store means not open and returns false +- [x] delegate a same-store call to the existing `AppStore.moveSession` so single-window behavior is + unchanged and the wire contract does not fork +- [x] before detaching, evict the session from the SOURCE window's `TerminalZoomRegistry` controller + (`clear()` when its target is this session) and `DashboardControllerRegistry` controller + (`close()` when the session is a member) — otherwise the source window keeps a zoom target or grid + cell pointing at an NSView now hosted by another window +- [x] refuse the move while the source window has a pending pick for this session (`PickRegistry`), + mirroring the `pick pending` guard in `ControlServer+SurfaceIO.swift:374` +- [x] compose detach + adopt, then `saveIndex()` is unnecessary but BOTH stores must persist (each + `save()`s its own `windows/.json`) +- [x] write tests for the happy path in `WindowLibraryTests`: the SAME `Session` instance (identity `===`) + and its `surface` slot survive; source no longer lists it; destination does +- [x] write tests for error/edge cases: closed destination, unknown session, unknown destination workspace, + same-window delegation, moving the source's selected session (source reselects), moving the last + session out of a window +- [x] write tests for zoom/dashboard eviction (source controller cleared) +- [x] run `cd agtermCore && swift test` - must pass before task 3 + +### Task 3: Add the wire contract for a destination window + +- [x] add a `.window(window: String, workspace: String?)` case to `ControlSessionMove` in `ControlModes.swift` +- [x] add `toWindow: String?` to `ControlArgs` in `ControlProtocol.swift` (optional, legacy decode intact) +- [x] route it in `ControlDispatcher.dispatchSessionCommand`/`dispatchSessionMove`: `--to-window` is a + placement intent, may carry an optional destination workspace, and REJECTS `--to` (reorder is + same-workspace) and `--after`/`--before` (anchors resolve within one store) + (the whole form parser moved to `ControlDispatcher+SessionMove.swift`: the fourth form pushed the + command switch past the cyclomatic-complexity limit and the file past 1000 lines) +- [x] extend `ControlActions.moveSession`/`moveSessions` signatures and `MockControlActions` for the new case + (`select: Bool`, which only the `.window` form reads — the destination's selection is another store's) +- [x] write tests in `ControlDispatcherTests` for routing the new case (single + batch, with and without a + destination workspace) — landed in `ControlDispatcherSessionMoveTests` (the 2000-line test-file limit) +- [x] write tests for the rejection paths (`--to-window` with `--to`, with `--after`, with `--before`) +- [x] write round-trip tests in `ControlProtocolTests` for `toWindow` encode/decode and legacy-payload decode +- [x] run `cd agtermCore && swift test` - must pass before task 4 + +➕ the app target must compile between tasks, so `ControlServer.moveSession`/`moveSessions` got their +`.window` arms here rather than in task 4 (single + batch, closed-window error text, destination-workspace +resolution against the destination store). Task 4 keeps the XCUITest and the resolver-path tests. + +### Task 4: Wire the app-side move + +- [x] handle `.window` in `ControlServer+SessionActions.moveSession` (`:509`): resolve the session with the + existing `resolver.resolveSession(target, window:)` (so `--window` keeps meaning "search scope"), + resolve the destination window id through `library.resolveWindow`, then call + `WindowLibrary.moveSession` (landed with task 3 so the app target compiled) +- [x] return the closed-window error verbatim as `window not open — window.select it first`, and resolve an + optional destination workspace against the DESTINATION store's workspaces (landed with task 3) +- [x] handle `.window` in `moveSessions` for batches, returning `affected` like the other batch paths + (landed with task 3) +- [x] add an XCUITest in `agtermUITests/MultiWindowUITests.swift`: two windows, move a session across, + assert both windows' `tree` and that the moved session still answers `session.text` +- [x] write tests for the resolver paths that stay host-free (destination resolution errors) — the + closed-destination resolve/refuse split and the destination-workspace scope in `WindowLibraryTests` + (where that seam lives), plus destination pass-through in `ControlDispatcherSessionMoveTests` +- [x] run `cd agtermCore && swift test`, then the single new UI test with + `-only-testing:agtermUITests/MultiWindowUITests/` - must pass before task 5 + +### Task 5: Add --to-window to agtermctl + +- [x] add `--to-window` to `session move` in `agtermCore/Sources/agtermctlKit/SessionCommands.swift`, help + text naming it a DESTINATION so it cannot be confused with the shared `--window` target scope +- [x] extend `validate()` (`:150-167`): `--to-window` satisfies the "provide a destination" requirement; + reject it with `--to`/`--after`/`--before`; allow it with a positional workspace (the destination + workspace INSIDE the target window) +- [x] populate `ControlArgs.toWindow` in `makeRequest()` +- [x] write validation tests in `agtermctlKitTests/CommandsTests` for each accepted and rejected combination, + pinning the exact error strings the way the existing move cases are pinned (`:269`, `:273`) +- [x] write a test that a bare `--window` still fails with the unchanged + `provide a destination workspace, --to, or --after/--before` message +- [x] run `cd agtermCore && swift test` - must pass before task 6 + +➕ `--select` landed here too: the dispatcher reads `args.select` only in the `.window` arm, so without the +flag the locked "`--select` opts into selecting it in the destination" decision had no CLI surface. + +### Task 6: Publish window/workspace ownership on the session node + +- [x] add optional `windowId` and `workspaceId` to `ControlSessionNode` in `ControlProtocol.swift` +- [x] add optional `windowId` to `ControlTree` — the tree is already a single-window projection and never + said which one +- [x] add a `windowID: String?` parameter to `AppStore.controlTree` (`AppStore.swift:257`) and stamp it on + every session node; `workspaceId` comes from the workspace already being iterated, so no new lookup + (the projection moved to `AppStore+ControlTree.swift`: `AppStore.swift` was already at the 1000-line + limit, and the node types moved to `ControlProtocol+Nodes.swift` for the same reason) +- [x] pass it from `ControlServer.buildTree(in:)` via `library.windowID(for: store)` +- [x] render both in `agtermctl tree`'s human output only where it earns the line (window id on the tree + header, not repeated per row) +- [x] write tests that `controlTree` stamps `windowId`/`workspaceId` on every node and that a + host-produced tree with no window id omits them +- [x] write decode tests proving a payload without the new fields still decodes (legacy server skew) +- [x] run `cd agtermCore && swift test` - must pass before task 7 + +➕ `workspaceId` is present exactly when `windowId` is — one all-or-nothing ownership stamp, so a +host-free projection never answers half of "who owns this session". + +⚠️ task 5 left `agtermctlKit/SessionCommands.swift` 7 lines over the 800-line `type_body_length` limit; +`session overlay` moved to `SessionCommands+Overlay.swift` here so `make lint` is clean again. + +### Task 7: Add tree --all-windows + +- [x] add `allWindows: Bool?` to `ControlArgs` and `trees: [ControlTree]?` to `ControlResult` +- [x] `ControlDispatcher` rejects `--all-windows` together with `--window` (one names a single window, the + other means every one) +- [x] implement it in `ControlServer.controlTree(window:)`: build one tree per OPEN window via + `library.openIDs()`, each tagged with its `windowId`; populate `trees`, leaving `tree` nil +- [x] add `--all-windows` to the `tree` CLI command with human rendering that prints each window's name and + id as a section header above its workspace tree +- [x] write tests for the mutual-exclusion error and for the multi-window projection shape +- [x] write tests that each returned tree carries the right `windowId` and that closed windows are absent +- [x] run `cd agtermCore && swift test` - must pass before task 8 + +➕ the fan-out itself landed host-free as `WindowLibrary.openTrees(_:)`, so the app arm is a projection map +and the closed-window/order behavior is covered in `WindowLibraryTests` rather than only in XCUITest. + +➕ `ControlTree.windowName` joined `windowId` (same all-or-nothing stamp): the section header names a window +a human picked, and the response is the CLI's only source for it. + +### Task 8: Add the GUI entry points + +- [x] add a "Move to Window" submenu in `agterm/Views/WorkspaceSidebar+ContextMenu.swift`, built like the + existing "Move to" workspace submenu (`:106-116`): list OTHER open windows by name, omit the submenu + entirely when there is no other open window +- [x] reuse `SessionBatchRequest` so multi-selection moves as one block, and route the action through + `AppActions` (which owns cross-window concerns) rather than the window-local `store` +- [x] add `AppActions.moveSession(_:toWindow:)` and a batch form beside the existing + `moveSession(_:toWorkspace:)` (`AppActions.swift:515`), delegating to `WindowLibrary.moveSession` +- [x] add "Move Session to Window …" palette entries beside the existing "Move Session to …" items, + gated on there being another open window +- [x] write host-free tests for the palette entry's visibility gate in `PaletteCatalogTests` +- [x] write tests for the `AppActions` delegation where it is host-free; note in the plan if the AppKit + submenu itself is manual-verification only +- [x] run `cd agtermCore && swift test` - must pass before task 9 + +➕ the "other open window" gate landed host-free as `WindowLibrary.moveDestinations(excluding:)`, shared by +the submenu and the palette, so its tests are in `WindowLibraryTests` rather than `PaletteCatalogTests` +(the move rows are per-window items, not `PaletteCommand` cases the catalog can gate). + +➕ `AppActions.moveSession(_:toWindow:)` and its batch form landed in `AppActions+Batch.swift`, beside the +other multi-session sidebar entry points; hosted coverage for both, and for the palette rows, is in +`agtermTests/AppActionsPaletteTests`. The NSMenu submenu itself stays manual-verification only. + +⚠️ task 7 left `agtermTests/ControlServerWorkspaceCommandsTests.swift` calling `controlTree(window:)` without +the new `allWindows` argument, so the app-hosted target did not compile; fixed here. + +### Task 9: Verify acceptance criteria + +- [x] verify all requirements from Overview are implemented: cross-window move preserves the live shell, + destination must be open, no raise/select without `--select`, ownership readable in one call +- [x] verify edge cases: last session out of a window, moving the selected session, zoomed session, + dashboard member, session with a shown split, session with an open overlay or scratch +- [x] verify `--window` semantics are UNCHANGED on every command that takes it +- [x] run full test suite (`cd agtermCore && swift test`) +- [x] run `make test-app` +- [x] run `make lint` - zero findings required +- [x] verify test coverage meets project standard + +➕ split/overlay/scratch survival had no direct assertion — that state rides the `Session` instance, but +nothing pinned it — so `moveSessionCarriesSplitOverlayAndScratchState` was added to `WindowLibraryTests`. + +Verification notes: a session inside a pending-close record is already out of `workspaces`, so `detachSession` +returns nil and the move is refused rather than resurrecting a closing shell. `ControlTargetResolver` is +untouched on this branch, which is what keeps `--window` a search scope everywhere. Line coverage on the +changed files: `AppStore+Transfer` 100%, `AppStore+ControlTree` 100%, `ControlDispatcher+SessionMove` 98.6%, +`SessionCommands` 98.2%, `WindowLibrary` 96.7%. + +### Task 10: [Final] Update documentation + +- [x] `.claude/rules/windows.md` — amend the "cross-window session drag is out of scope" line: a MOVE is + supported and keeps 1:1; DRAG remains out of scope +- [x] `.claude/rules/control-api.md` — `session.move` placement intents now include `--to-window`; + document `tree --all-windows` and the new node fields; note that `--window` stays a search scope +- [x] `.claude/rules/sidebar.md` — the row menu's new submenu +- [x] `plugins/agterm/skills/agterm/` (`SKILL.md`, `reference.md`, `examples.md`) — the sole source for + installed Claude/Codex copies: demote `AGTERM_WINDOW_ID`/`AGTERM_WORKSPACE_ID` to spawn-time hints + that go stale on any move, point at `tree --all-windows` as the source of truth, and note that + passing a stale `--window` turns a working command into `no such session` +- [x] `site/commands.html` — mirror the new argument, the new flag, and the new read-back fields +- [x] `site/docs.html` — the user-facing description of moving a session between windows +- [x] confirm NO surface states a command total (the rule that keeps a new command off every page) + +➕ `windows.md` gained a "Cross-window session move" section rather than one amended line: the registry +eviction, the pick guard, the open-destination refusal, and the no-AppKit-work fact each have a +consequence a reader would otherwise have to rediscover. `control-api.md` and `sidebar.md` cross-reference +it instead of restating it. + +➕ the tree's human output prints its `window []` header on EVERY response, not only +`--all-windows` — the app always stamps `windowId` — so the skill and site say that rather than tying the +header to the flag. `reference.md`'s "thirteen top-level fields" count became fifteen. + +⚠️ `site/index.html` needed no change: it lists major features and `softwareVersion`, and a session move is +a refinement of the existing multi-window story rather than a new headline. `site/llms.txt` states no +command detail, so it stays as-is. + +*Note: ralphex automatically moves completed plans to `docs/plans/completed/`* + +## Technical Details + +**Move semantics on the wire:** + +``` +session.move --target --to-window [] [--select] +``` + +- `--target` resolves as today: `active` against the frontmost store, an id/prefix across ALL open stores. +- `--window` (unchanged) narrows where `--target` is searched. +- `--to-window` names the destination; the optional positional workspace names the destination workspace + INSIDE that window, defaulting to its `currentWorkspaceID`. +- Rejected combinations: `--to-window` with `--to`, `--after`, or `--before`. + +**Ownership read-back:** + +``` +agtermctl tree --json --all-windows | jq -r --arg s "$AGTERM_SESSION_ID" ' + .result.trees[] | . as $t | .workspaces[] | . as $w | .sessions[] + | select(.id == $s) | {window: $t.windowId, workspace: $w.id}' +``` + +**Why `window.list` is not the place for this:** `control-api.md` states the window list is cached and +refreshed on specific events; a live session tree hung off it would go stale between commands. That is the +same reason `idleMs` is tree-only. + +**What is NOT needed:** any change to `GhosttySurfaceView` or `TerminalView`. The surface already survives +a re-host — `dismantleNSView` is a no-op, `makeNSView` reuses `session.surface`, and +`viewDidMoveToWindow` re-pushes scale and size. If a move produces a blank or mis-scaled pane, that is a +bug to investigate in the deck mount order, not a signal to add teardown. + +## Post-Completion + +*Items requiring manual intervention or external systems - no checkboxes, informational only* + +**Manual verification** (needs a Debug instance with an isolated `AGTERM_STATE_DIR` and short socket — +never the default socket, never the deployed app): +- move a session running an interactive TUI (`htop`, a Claude Code session) between windows on the SAME + display; confirm output continues and input still reaches it +- move between windows on displays with DIFFERENT backing scale factors; confirm the terminal re-rasterizes + at the destination scale rather than staying blurry or clipped +- move a session with a shown split and a set divider ratio; confirm both panes survive and the ratio holds +- move a session with an open overlay and one with an active scratch +- move the currently selected session out of a window and confirm the source reselects sensibly +- move the last session out of a window and confirm it renders "No session selected" rather than crashing +- confirm the sidebar submenu is absent with only one window open +- quit and relaunch; confirm the moved session restores in its NEW window + +**Known accepted limitation to communicate:** +- a moved session's already-running shell keeps its original `AGTERM_WINDOW_ID`/`AGTERM_WORKSPACE_ID`. + Nothing can rewrite a live process's environment. Newly spawned panes get correct values; existing + shells must query `tree --all-windows`. This is documented in the skill, not worked around. diff --git a/plugins/agterm/skills/agterm/SKILL.md b/plugins/agterm/skills/agterm/SKILL.md index 4f00a2ab2..e52bea514 100644 --- a/plugins/agterm/skills/agterm/SKILL.md +++ b/plugins/agterm/skills/agterm/SKILL.md @@ -9,7 +9,7 @@ description: > display the native fuzzy picker with caller-supplied choices and poll or cancel it; display an image inline via a bundled helper script; type into a session, copy its selection, or search its scrollback; post desktop notifications; manage windows (new, list, - select, close, resize, move); change font size; or reload and edit the keymap and the agterm-scoped + select, close, resize, move) and move a session with its live shell into another window; change font size; or reload and edit the keymap and the agterm-scoped ghostty config. Also covers the window/workspace/session addressing model and the AGTERM_* environment a spawned shell sees, plus subscribe to status, notification, session lifecycle, and tree-change events; diagnose problems @@ -21,6 +21,7 @@ when_to_use: > Trigger on: agterm, agtermctl, agterm control socket, session.new, session.close, session.type, session.split, session.split.close, session.scratch, session.focus, session.resize, surface.zoom, surface.cursor, cursor column, dashboard, pick, pick.open, pick.result, pick.cancel, native picker, session.go, session.copy, session.paste, session.selectall, session.text, session.search, session.status, session.flag, session.seen, session.reveal, session.duplicate, session.background, session.overlay, + session.move, move a session to another window, tree --all-windows, which window owns this session, session.hud, hud panel, show a message over a session, workspace.new, workspace.select, workspace.go, workspace.move, workspace.focus, workspace.filter, window.new, window.list, window.select, window.resize, window.move, window.zoom, window.fullscreen, window.minimize, quick terminal, sidebar, sidebar.mode, sidebar.expand, sidebar.collapse, flagged, notify, font.inc, keymap.reload, keymap.list, config.reload, theme.set, theme.list, events, events.read, event subscription, select theme, edit keymap, show an image, display an image inline, show-image, @@ -49,7 +50,13 @@ the control channel is available: - `AGTERM_ENABLED=1` — this shell runs inside agterm. - `AGTERM_SESSION_ID` — the current session's UUID (the session this shell belongs to). -- `AGTERM_WINDOW_ID` / `AGTERM_WORKSPACE_ID` — the owning window / workspace UUIDs. +- `AGTERM_WINDOW_ID` / `AGTERM_WORKSPACE_ID` — the window / workspace that owned the session WHEN THE + SHELL WAS SPAWNED. They are a snapshot, not a live link: `session move` (to another workspace or, with + `--to-window`, to another window) relocates the session while the running shell keeps the old values, + and nothing can rewrite a live process's environment. Treat them as hints; when it matters, read the + truth with `agtermctl tree --json --all-windows` and match `$AGTERM_SESSION_ID` against each session + node's `windowId` / `workspaceId`. Passing a stale `--window "$AGTERM_WINDOW_ID"` scopes the search to + the wrong window and turns a working command into `no such session`. - `AGTERM_SOCKET` — the absolute path to the control socket this app bound. - `AGTERM_PANE` / `AGTERM_PANE_ID` — the surface's pane role (`left`|`right`|`scratch`) and a stable per-surface token; the agent-status hook forwards them as `session status --pane` / `--pane-id`. The @@ -100,7 +107,10 @@ One slot, so a session shows either a HUD or a program overlay, never both. Sepa or whatever share Settings sets instead; not part of the tree and not owned by a window). Inspect the live tree any time with `agtermctl tree --json` (workspaces → sessions, each with -`id`, `name`, `cwd`, `title`, `active`, `split`, `overlay`, `hud`, `scratch`, `status`, `background`, `surfaces`). `title` is the raw OSC +`id`, `name`, `cwd`, `title`, `active`, `split`, `overlay`, `hud`, `scratch`, `status`, `background`, +`surfaces`, and the `windowId`/`workspaceId` that own it). `agtermctl tree --all-windows` returns one tree +per OPEN window instead of just the frontmost — the only call that locates a session in ANY window, and +the source of truth once a session has been moved. `title` is the raw OSC terminal title (e.g. a remote host over SSH), omitted when none was reported — read it when a session's local `cwd` is stale because it's connected to a remote. `surfaces[].id` is the control address for `surface zoom` and `surface cursor` (`left`, `right`, `scratch`, `overlay`, @@ -110,7 +120,8 @@ read-only top-level fields — `idleMs` (ms since the last user input in the win sidebar is currently shown — the read side of the write-only `sidebar` command), `sidebarMode` (`tree` or `flagged` — the read side of `sidebar mode`), `workspaceFilter`, `quickVisible` (whether the quick terminal is shown — the read side of the write-only `quick` command; app-level, so every window -reports the same value), `zoomedSurface`, the four `dashboard*` fields, `pickPending`, and `app` (the +reports the same value), `zoomedSurface`, the four `dashboard*` fields, `pickPending`, `windowId` and +`windowName` (which window this tree projects), and `app` (the serving app's `version`, plus `commit` when the build recorded one — the same value `agtermctl version` returns). reference.md lists every one with its exact shape. List windows with `agtermctl window list --json`; each window also reports `autoFollowMs`, `sidebarVisible`, `geometry` @@ -278,8 +289,10 @@ omitted when expanded). - `go --to next|prev|first|last|next-attention|prev-attention` — move the selection between sessions. - `move ` (relocate) or `move --to up|down|top|bottom` (reorder within the workspace) or `move --after SID | --before SID` (place after/before an anchor session; the anchor carries its own - workspace, so this relocates + positions in one shot, even cross-workspace). For workspace and - after/before placement, repeat `--target` to move several sessions as one ordered block. Do not repeat + workspace, so this relocates + positions in one shot, even cross-workspace) or + `move --to-window W [] [--select]` (move it to another OPEN window, carrying the live shell; + the optional workspace names one INSIDE that window). For workspace, after/before, and `--to-window` + placement, repeat `--target` to move several sessions as one ordered block. Do not repeat `--target` with `--to up|down|top|bottom`. - Shared pane selectors accept `primary`/`left`/`top` for the primary pane and `split`/`right`/`bottom` for the split pane. Commands supporting scratch also accept `scratch`. diff --git a/plugins/agterm/skills/agterm/examples.md b/plugins/agterm/skills/agterm/examples.md index 0ee3af36f..354c4be44 100644 --- a/plugins/agterm/skills/agterm/examples.md +++ b/plugins/agterm/skills/agterm/examples.md @@ -10,6 +10,7 @@ list, fetch and install one, or to read one as reference for a tricky case. ```bash agtermctl tree --json # workspaces -> sessions, active/split/overlay/scratch/flagged flags, surface ids +agtermctl tree --json --all-windows # the same, one tree per OPEN window, each tagged with its windowId agtermctl window list --json # windows, with open/active flags # what is each pane RUNNING right now (foreground argv; absent at the shell prompt or for a setuid program like top/sudo) @@ -189,6 +190,44 @@ agtermctl session close --target "$server" --target "$logs" workspace — the anchor already picks the workspace. Repeated `--target` is only for workspace and after/before placement, not `--to up|down|top|bottom`. +## Move a session to another window + +`--to-window` carries the session — live shell, running program and scrollback — into another OPEN +window. The destination is not raised and nobody's selection changes unless you ask with `--select`: + +```bash +win=$(agtermctl window list --json | jq -r '.result.windows[] | select(.name == "review") | .id') + +# move this session there, into that window's selected workspace, leaving it in the background +agtermctl session move --to-window "$win" --target "$AGTERM_SESSION_ID" + +# ...into a named workspace INSIDE that window, and switch the user to it +ws=$(agtermctl tree --json --window "$win" | jq -r '.result.tree.workspaces[] | select(.name == "logs") | .id') +agtermctl session move --to-window "$win" "$ws" --select --target "$AGTERM_SESSION_ID" + +# a whole sidebar block at once +agtermctl session move --to-window "$win" --target "$server" --target "$logs" +``` + +The destination must already be open (`agtermctl window select "$win"` opens a closed one first), +and `--to-window` cannot be combined with `--to` or `--after`/`--before`. Do not confuse it with +`--window`, which only says where `--target` is searched. + +## Find out which window and workspace own a session + +`$AGTERM_WINDOW_ID` / `$AGTERM_WORKSPACE_ID` are baked into the shell when it spawns, so any +`session move` leaves them stale — a running process's environment cannot be rewritten. Ask the app +instead: + +```bash +agtermctl tree --json --all-windows | jq -r --arg s "$AGTERM_SESSION_ID" ' + .result.trees[] | . as $t | .workspaces[] | . as $w | .sessions[] + | select(.id == $s) | "window=\($t.windowId) workspace=\($w.id)"' +``` + +`--all-windows` is the only projection that spans every open window; `--window` restricts the search to +one, so passing a stale `$AGTERM_WINDOW_ID` turns a working command into `no such session`. + ## Resize the split divider from a keybinding The divider is otherwise mouse-only — drag it, or double-click it for an even split. There is no built-in diff --git a/plugins/agterm/skills/agterm/reference.md b/plugins/agterm/skills/agterm/reference.md index 005a79107..6190b1d9f 100644 --- a/plugins/agterm/skills/agterm/reference.md +++ b/plugins/agterm/skills/agterm/reference.md @@ -95,13 +95,20 @@ SIGTERM use normal process behavior. (both full and floating); pass `--follow` to additionally SELECT the target, switching the user to it. - `--window ` (on session/workspace/tree/font/notify/pick commands) picks which window's 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. + an id/prefix session target is matched across all open windows. It is always a SEARCH SCOPE, never a + destination — `session move --to-window` is the one flag that names where a session goes. - `window.*` commands take the window selector as a positional argument, default `active` (frontmost). - A window need not be open to be a `window.*` target (e.g. `window select` opens a closed one). ## tree -`agtermctl tree [--json] [--window W]` — the workspace/session tree. Each session node: +`agtermctl tree [--json] [--window W] [--all-windows]` — the workspace/session tree. `--all-windows` +projects EVERY open window instead of one: the response carries `result.trees` (an array, each entry a +tree tagged with its own `windowId`/`windowName`) and leaves `result.tree` nil, and closed windows are +simply absent. Every tree — single or fanned out — prints a `window []` header above its +workspaces in human output, which is what separates the sections here. It is rejected together +with `--window`, which names a single window. This is the call that answers "which window owns this +session" for a session in any window — see the ownership recipe in examples.md. Each session node: `id`, `name`, `cwd`, `title` (the raw OSC terminal title — e.g. a remote host over SSH — omitted when none reported; distinct from `name`, the derived sidebar label), `active` (selected), `split` (split SHOWN side by side, the read side of `session split on|off`), @@ -180,7 +187,12 @@ default/left target (the main pane, or the promoted split survivor once the prim `font --pane left` writes); only the main pane's size survives a relaunch, so the split/scratch sizes and a promoted survivor are live-only — read them back here rather than from the snapshot), and `surfaces` (array of `{id, kind, active, visible}` where `kind` is -`left`|`right`|`scratch`|`overlay`|`overlay-left`|`overlay-right`). +`left`|`right`|`scratch`|`overlay`|`overlay-left`|`overlay-right`), plus +`windowId`/`workspaceId` (the window and workspace that own this +session RIGHT NOW — the read side of `session move`, and the only reliable answer once a session has been +moved, since a running shell's `AGTERM_WINDOW_ID`/`AGTERM_WORKSPACE_ID` still hold their spawn-time +values. Both come from the app together, so a projection carrying neither is a server that predates them, +never a half-answer). The surface `id` is the address for `surface zoom`; hidden-but-alive split/scratch surfaces are included so a script can zoom them without changing split/scratch visibility first. Caveat: `active`/`visible` derive from the session's own flags, not from zoom — and `visible` reads false for a pane behind a @@ -198,7 +210,7 @@ members), and `collapsed` (whether this workspace is COLLAPSED in the sidebar tr `workspace collapse`/`workspace expand` and `workspace new --collapsed`; `true` when collapsed, omitted when expanded, so an all-expanded tree carries no `collapsed` keys). -The tree object itself carries thirteen top-level read-only fields: `idleMs` (milliseconds since the last +The tree object itself carries fifteen top-level read-only fields: `idleMs` (milliseconds since the last user input in the window, omitted before any activity), `autoFollowMs` (the window's Auto-follow timeout in milliseconds, omitted when the setting is Disabled), `sidebarVisible` (whether the window's sidebar is currently shown — the read side of the write-only `sidebar` command, so a script @@ -222,7 +234,9 @@ as both), `dashboardHighlighted` (the highlighted cell's pane ref — the one En that exact pane), `dashboardFontSize` (the absolute font size in points applied to the cells, omitted when the mode is `untouched`), and `dashboardFontMode` (`auto` for `--auto-size`, `fixed` for `--font-size`, or `untouched`), plus `pickPending` (the id of the native picker currently awaiting an answer in this -window, omitted when none is pending), and `app` (which agterm is serving this socket: `version`, plus +window, omitted when none is pending), `windowId` and `windowName` (WHICH window this tree projects — +present on every tree the app serves, and what tells the sections of a `--all-windows` response apart), +and `app` (which agterm is serving this socket: `version`, plus `commit` when the build recorded one — the same value `agtermctl version` returns, so an agent already reading the tree gets its version floor without a second round-trip; it is not duplicated onto `window.list`, where a caller uses `version` instead). `idleMs` is live @@ -230,7 +244,8 @@ and grows while the window is idle, so it is on `tree` only, never `window.list` both; `sidebarMode`, `workspaceFilter`, `quickVisible`, `zoomedSurface`, the four `dashboard*` fields, and `pickPending` are `tree`-only (a GUI/keyboard change would leave a cached copy stale). All of those are read-only -projections of live GUI state. `app` is the one CONSTANT among them, and is absent from `window.list` +projections of live GUI state, except `windowId`/`windowName`, which identify the projection itself. +`app` is the one CONSTANT among them, and is absent from `window.list` for a different reason: it describes the serving app rather than a window, so repeating it on every row buys nothing. A caller with no tree uses `version`. @@ -386,13 +401,27 @@ buys nothing. A caller with no tree uses `version`. `session move --after SID | --before SID [--target]` — place the session directly after / before an anchor session (id / unique prefix / `active`). The anchor CARRIES ITS OWN WORKSPACE (resolved across all workspaces), so it relocates + positions in one shot, wherever the anchor lives — cross-workspace - placement falls out for free. Exactly one placement intent is required among {positional workspace, - `--to`, `--after`/`--before`}; `--after`/`--before` are mutually exclusive with each other, with `--to`, - and with a destination workspace (the anchor already names the workspace). - Repeat `--target` for a batch move with the workspace and after/before placement forms; the sessions + placement falls out for free. OR `session move --to-window W [] [--select] [--target]` — + move the session into ANOTHER WINDOW, carrying its live shell, scrollback and running program; the + optional positional workspace names a workspace INSIDE that window, defaulting to its selected one. + Exactly one placement intent is required among {positional workspace, + `--to`, `--after`/`--before`, `--to-window`}; `--after`/`--before` are mutually exclusive with each other, + with `--to`, and with a destination workspace (the anchor already names the workspace). + Repeat `--target` for a batch move with the workspace, after/before, and `--to-window` placement forms; + the sessions move as one ordered block after all sources are removed. Repeated `--target` is rejected with `--to up|down|top|bottom` because relative reorder is per-session. Batch moves return `result.affected`, counting only sessions whose position/workspace changed. +- `--to-window` is a DESTINATION and `--window` is a SEARCH SCOPE — the two are unrelated and combine + freely (`--window` still only says where `--target` is looked up). The destination window must be OPEN + (`window select` it first, else `window not open — window.select it first`); `--to-window` is rejected + with `--to` and with `--after`/`--before`. The move does NOT raise the destination or change either + window's selection: the session lands in the background, and `--select` opts into selecting it there. + `--select` is rejected without `--to-window` (`session.move --select requires --to-window`), and a + destination the app cannot move into — most reachably a picker pending in the SOURCE window — errors + with `cannot move session to that window`; answer or `pick cancel` it first. + The moved shell keeps its stale `AGTERM_WINDOW_ID`/`AGTERM_WORKSPACE_ID`; read `tree --all-windows` for + the session's real owner afterwards. Shared pane selectors accept `primary`/`left`/`top` for the primary pane and `split`/`right`/`bottom` for the split pane. Commands supporting scratch also accept `scratch`. diff --git a/site/commands.html b/site/commands.html index 3bf53211c..567645a45 100644 --- a/site/commands.html +++ b/site/commands.html @@ -421,7 +421,7 @@ word-break: break-word; " > - agtermctl tree [--json] [--window W] + agtermctl tree [--json] [--window W] [--all-windows]
tree @@ -430,6 +430,15 @@ Print the workspace/session tree. This is the read side of most of the API — nearly every state-mutating command has a matching field here, so a script can record a value, change it, and restore it.

+

+ --all-windows projects EVERY open window instead of one: the response + carries result.trees, an array of trees each tagged with its own + windowId / windowName, and leaves the singular + result.tree nil. Closed windows are absent, and it is rejected together + with --window, which names a single one. It is the only call that + locates a session in ANY window — the answer to "who owns this session" once one has been + moved with session move --to-window. +

Session node: id, @@ -482,8 +491,13 @@ restore override, omitted when none and an empty string when pinned to nothing), commandWait (a held --command session created with - session new --wait; omitted otherwise), and - unseen. + session new --wait; omitted otherwise), + unseen, and + windowId / workspaceId (the window and + workspace that own the session right now — the read side of + session move, and the reliable answer after a + cross-window move, since a running shell keeps its spawn-time + AGTERM_WINDOW_ID / AGTERM_WORKSPACE_ID).

Workspace node: @@ -518,13 +532,17 @@ pickPending (the pending native picker's id, omitted when none is open), zoomedSurface, the four - dashboard* fields, and + dashboard* fields, + windowId / windowName (which window this tree + projects — what tells the sections of an + --all-windows response apart), and app (which agterm is serving this socket — version, plus commit when the build recorded one; the same value agtermctl version returns). All are read-only, and all but - app are projections of live GUI state. + app and the two + window* fields are projections of live GUI state.

@@ -970,11 +988,11 @@
- agtermctl session move <workspace> | --to up|down|top|bottom | --after SID | --before SID [--target T ...] [--window W] + agtermctl session move <workspace> | --to up|down|top|bottom | --after SID | --before SID | --to-window W [<workspace>] [--select] [--target T ...] [--window W]
session.move

- Three mutually-exclusive placement intents, exactly one required. A positional + Four mutually-exclusive placement intents, exactly one required. A positional <workspace> relocates the session there (appending). --to reorders it within its own workspace. --after / @@ -986,6 +1004,24 @@ batch relative reorder via --to is not supported. Batch output reports the number of sessions actually moved.

+

+ --to-window is the fourth intent: it moves the session into another + window, carrying its live shell, running program and scrollback. The destination window must + already be OPEN (window not open — window.select it first otherwise); + an optional positional <workspace> names a workspace INSIDE that + window, defaulting to its selected one. The move neither raises the destination nor changes + either window's selection — the session lands in the background, and + --select opts into selecting it there, and is rejected + without it (session.move --select requires --to-window). + It cannot be combined with + --to, --after or + --before, and it is unrelated to + --window, which only scopes where + --target is searched. A moved shell keeps the + AGTERM_WINDOW_ID / + AGTERM_WORKSPACE_ID it was spawned with — read + tree --all-windows for the session's real owner afterwards. +

diff --git a/site/docs.html b/site/docs.html index 4656e6fb6..287fa3b93 100644 --- a/site/docs.html +++ b/site/docs.html @@ -886,7 +886,8 @@ select multiple sessions for batch flag, close, move, and drag. Right-clicking inside the selection keeps the whole batch, so Flag, Close, and Move to act on all of it; right-clicking a row outside narrows the menu to - that row. Dragged sessions keep running, shell and scrollback + that row. Move to Window sends the selection to another open window — + it appears only when a second window is open. Dragged and moved sessions keep running, shell and scrollback intact.
  • @@ -1321,6 +1322,19 @@ create, raise, move, resize, and minimize them, so a few lines of shell can give every window the same frame and park all but the one you are on, turning several windows into what feels like one that switches contents.

    +

    + A session is not stuck in the window it was created in. Move to Window in the + sidebar's right-click menu, the same entry in the action palette, or + agtermctl session move --to-window hands it to another OPEN window with its shell, running + program and scrollback intact — an htop or a long build keeps going as it changes windows. + The destination is not raised and nothing is re-selected unless you pass + --select, so a background job can be filed away without interrupting you. + One caveat: a shell that is already running keeps the + AGTERM_WINDOW_ID and AGTERM_WORKSPACE_ID it was started with, since + nothing can rewrite a live process's environment — ask + agtermctl tree --json --all-windows where a session lives now, and read the + windowId / workspaceId on its node. +

    @@ -2433,7 +2447,9 @@ AGTERM_PANE_ID (a stable per-surface token the agent-status hook forwards as session status --pane-id, so a promoted-then-re-split pane still tags correctly), so a - script can drive its own window without hard-coding ids. + script can drive its own window without hard-coding ids. The window and workspace ids are a snapshot taken when + the shell spawned: moving the session leaves them stale, so read the current owner from + agtermctl tree --json --all-windows when it matters.