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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .claude/rules/control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions .claude/rules/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 `"<sessionID>:<paneRole>"`, coalescing repeats and carrying the
click target without `userInfo`. `didReceive` activates the app and calls `AppActions.reveal`: select
- `TerminalNotification.identity` encodes `"<windowID>:<sessionID>:<paneRole>"`, 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.
Expand Down
4 changes: 4 additions & 0 deletions .claude/rules/sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion .claude/rules/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions agterm/AppActions+Batch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions agterm/AppActions+Palette.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ extension AppActions {
self?.moveSession(sessionID, toWorkspace: target)
})
}
// one "Move Session to Window: <name>" 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
Expand Down
10 changes: 8 additions & 2 deletions agterm/Control/ControlServer+AppCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}
Expand Down
Loading
Loading