diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md
index 7c671287..4dfce260 100644
--- a/.claude/rules/control-api.md
+++ b/.claude/rules/control-api.md
@@ -69,7 +69,8 @@ paths:
`session.status`/`status`+`statusPane` (+`statusBlink`/`statusColor` for `--blink`/`--color`),
`session.flag`/`flagged`, `session.focus`/`splitFocused`, `session.resize`/`splitRatio`,
`session.restore`/`restoreCommand`+`splitRestoreCommand`,
- `session.overlay.resize`/`overlaySizePercent`, `sidebar`/`sidebarVisible` (top-level),
+ `session.overlay.open`+`session.overlay.resize`/`overlaySizePercent`+`overlayCols`/`overlayRows` (requested)
+ +`overlayColsApplied`/`overlayRowsApplied` (realized)+`overlayAnchor`, `sidebar`/`sidebarVisible` (top-level),
`sidebar.mode`/`sidebarMode`, `workspace.focus`/`focused` (workspace node),
`workspace.collapse`+`workspace.expand`/`collapsed` (workspace node), `quick`/`quickVisible` (top-level),
`font.*`/`fontSize`+`splitFontSize`+`scratchFontSize` (the per-pane LIVE font size — the split/scratch
@@ -674,8 +675,29 @@ paths:
in `ControlAPIUITests`.
`session.overlay.open`/`session.overlay.close` run an ephemeral terminal on top of a session executing
one program (`args.command`, e.g. a TUI); by default it is full single-pane size,
- hiding the single/split underneath, but `args.sizePercent` (1–100, clamped in `openOverlay`) makes
- it a *floating* opaque framed panel at that percent of the pane with the session still visible.
+ hiding the single/split underneath, but a *floating* opaque framed panel with the session still visible is
+ requested one of two ways.
+ `args.sizePercent` (1–100) sizes it to that percent of the pane in both dimensions;
+ `args.cols`+`args.rows` (both required, each >= 1) size it to an EXACT terminal grid via the live cell
+ metrics.
+ Both are ADAPTIVE: a percent or cols/rows request larger than the pane is clamped to the whole cells that
+ fit (the host-free `OverlayLayout.panelSize` in `agtermCore`), and the realized grid rides back on `tree`
+ as `overlayColsApplied`/`overlayRowsApplied` (below), so a script sees any clamp.
+ Open no longer silently clamps an out-of-range `sizePercent` — Decision 3 tightened it to a HARD error in
+ the dispatcher + CLI `validate()`, matching resize; the store keeps its defensive clamp for internal
+ callers only.
+ `args.anchor` places a floating panel at one of nine `OverlayAnchor` positions
+ (`top-left`/`top`/`top-right`/`left`/`center`/`right`/`bottom-left`/`bottom`/`bottom-right`, default
+ `center` = today's centered placement); it requires a floating size, so an anchor on a full-pane open is a
+ dispatcher error.
+ A floating panel is NEVER flush with the pane border: it sits inside a uniform SAFE AREA — the pane inset by
+ a base-level margin of one line-height (`cell.cellHeight`) on ALL FOUR sides, symmetric and INDEPENDENT of
+ the anchor (a full-width band is inset left/right exactly like the top; `center` keeps it centered within the
+ safe area).
+ Host-free in `OverlayLayout.panelRect`: the requested size is clamped to the safe area per axis
+ (`panelW = min(requested, paneW - 2m)`) and placed at the anchor's unit position within it
+ (`originX = m + anchor.unitX * (usableW - panelW)`); nil/unusable cell metrics → `m = 0` (no margin).
+ A `.full` overlay is unchanged (fills the whole pane, no margin).
`args.color` (`#rrggbb`, REUSING the `session.background` field — no new arg — validated by the shared
`WatermarkConfig.isValidColorHex` at BOTH the CLI `validate()` and the server arm) gives the overlay
pane its OWN solid background color, independent of the session's `session.background color`;
@@ -684,7 +706,7 @@ paths:
honoring window translucency), built in `GhosttySurfaceView.applyOverlayBackgroundColor` from the
view's `overlayBackgroundColorHex` in `createSurface` — works identically for the full + floating variants.
`AppStore.openOverlay`/`closeOverlay` set non-persisted `Session.overlay*` state (incl.
- `overlaySizePercent`, nil = full / non-nil = floating; and `overlayBackgroundColor`,
+ `overlaySize`: `.full` = full / `.percent`|`.cells` = floating; `overlayAnchor`; and `overlayBackgroundColor`,
set at open / cleared at close), and the surface runs `config.command` with
`onExit → closeOverlay`.
Both variants render IN the per-session eager deck, so the overlay program runs regardless of which
@@ -699,17 +721,22 @@ paths:
keeps the AppKit `NSSplitView` from re-hosting and overrunning UP into the transparent titlebar.
(The panel used to mount OUTSIDE `sessionDetail` as a `detailPane` `.overlay` for exactly this reason;
the always-present constant-shape sibling holds the same invariant IN-deck.)
- FULL (`overlaySizePercent` nil): fraction 1.0, drawn translucent + blurred with NO opaque backing and NO
+ FULL (`overlaySize == .full`): the whole pane, drawn translucent + blurred with NO opaque backing and NO
chrome (`Color.clear` backing, 0 corner radius, 0 shadow), and the pane(s) behind hidden at `.opacity(0)`
+ `.allowsHitTesting(false)` via `hideForOverlay` (= `fullOverlay || scratchActive`; kept MOUNTED, shells
alive like the deck's inactive sessions), so its transparency reveals the window backing (desktop, tint +
blur), not the session.
- FLOATING (`overlaySizePercent` set): fraction = `percent/100`, drawn as an opaque `terminalColor`-backed,
- hairline-framed, shadowed panel centered in the detail area with the pane(s) VISIBLE around it.
- The modifier CHAIN is IDENTICAL across both variants — only the parameter VALUES flip (backing color,
- corner radius, shadow radius, frame fraction) — so `session.overlay.resize` switching full<->% is a
- value-update, never a child add/remove or a re-parent of the overlay surface NSView (a re-parent would
- blank its Metal drawable).
+ FLOATING (`overlaySize != .full`, i.e. `.percent` or `.cells`): sized AND placed by a single
+ `OverlayLayout.panelRect` (percent fraction or whole-cell-clamped grid, clamped to the safe area, positioned
+ at the anchor's unit point within it), drawn as an opaque `terminalColor`-backed, hairline-framed, shadowed
+ panel with the pane(s) VISIBLE around it (`center` reproduces the original dead-center placement, now within
+ the safe area).
+ Placement is a CONSTANT `ZStack(alignment: .topLeading)` plus a leading/top `padding` by the computed
+ `rect.origin` — no anchor-specific `swiftUIAlignment` branch, so the anchor axis mapping cannot desync.
+ The modifier CHAIN is IDENTICAL across both variants and every anchor — only the parameter VALUES flip
+ (backing color, corner radius, shadow radius, frame size, padding origin) — so
+ `session.overlay.resize` switching full<->floating or re-anchoring is a value-update, never a child
+ add/remove or a re-parent of the overlay surface NSView (a re-parent would blank its Metal drawable).
Hit-testing on the PANES stays gated on `.allowsHitTesting(!hideForOverlay)` and must NOT flip when a
FLOATING overlay opens: changing the panes' OWN `allowsHitTesting` on overlay-open (e.g. to
`!session.overlayActive`) ALSO triggers the NSSplitView titlebar-overrun — the SAME class of perturbation
@@ -727,8 +754,8 @@ paths:
transparent titlebar, painting the split over the header (Codex-confirmed;
the quick terminal renders at this level for the same reason and never hit it).
`overlayPanel`'s `GeometryReader` reports the detail area EXACTLY — no manual sidebar/titlebar insets
- (computing those at the window level mis-centered the panel one line low) — so it sizes the floating panel
- to `sizePercent`% and centers it in the detail area, the pane(s) visible around it.
+ (computing those at the window level mis-placed the panel one line off) — so it sizes the floating panel via
+ `OverlayLayout.panelSize` and places it by its anchor in the detail area, the pane(s) visible around it.
`isActive` gates the overlay surface's focus, so a background floating overlay RUNS but does not steal
focus (mirrors the full overlay).
Because both kinds mount in the eager deck, `ControlServer` does NOT select on open by default; it SELECTS
@@ -775,31 +802,48 @@ paths:
at parse via `validate()`); the program's OUTPUT is its own concern — a TUI like revdiff renders in
the overlay and writes results to its own `--output` file, which the caller reads (the control channel
does NOT capture stdout).
- `session.overlay.resize` (target = session) resizes an ALREADY-OPEN overlay IN PLACE between full and
- floating — the way to change size without closing and re-running the program.
- Exactly one of `sizePercent` (1...100 → floating) or `full: true` (→ the full-pane overlay) must be set;
- both, neither, or a percent outside 1...100 is a dispatcher error (mirrored by the CLI `validate()`), and
- `no overlay` when none is open.
+ `session.overlay.resize` (target = session) resizes AND/OR re-anchors an ALREADY-OPEN overlay IN PLACE —
+ the way to change size or position without closing and re-running the program.
+ It takes at most one size mode — `sizePercent` (1...100 → floating), `cols`+`rows` (→ an exact grid, clamped
+ to fit), or `full: true` (→ the full-pane overlay) — and/or an `anchor`: a nil size keeps the current size
+ and a nil anchor keeps the current anchor, so `--anchor` alone re-anchors in place while a size mode alone
+ keeps the anchor.
+ At least one of {a size mode, `anchor`} must be set (a bare resize is a dispatcher error); more than one
+ size mode, a percent outside 1...100, or `cols` without `rows` (or vice versa) is a dispatcher error
+ (mirrored by the CLI `validate()`); `full` ⊥ `anchor` (a full overlay is not anchored); and `no overlay`
+ when none is open.
It is a NEW `Command` case (unlike the `--follow` arg, which rode the existing `overlay.open`) because it
- needs its own arg validation, and `full` is a NEW `ControlArgs` field added to distinguish "switch to
- full" (nil `overlaySizePercent`) from "unset" on the wire.
- The arm mutates the non-persisted `Session.overlaySizePercent` via `AppStore.resizeOverlay` (clamping
- 1...100, guarding `overlayActive`), and the detail pane re-flows the SAME surface host: the unified
- `WindowContentView.overlayPanel` now renders BOTH variants (full = translucent, no chrome, panes hidden by
- `hideForOverlay`; floating = opaque framed panel over visible panes), so a full<->% switch never re-parents
- the overlay NSView (which would blank its Metal drawable) nor changes the ZStack shape — the old
- `if fullOverlay` z2 sibling is gone, and the always-present `overlayPanel` at z3 is the single host.
- Four-point keep-in-sync audit for `session.overlay.resize`: (1) `case sessionOverlayResize = "session.overlay.resize"`
- + `ControlArgs.full` in `ControlProtocol.swift`, (2) the `.sessionOverlayResize` dispatcher arm (exactly-one
- + range validation) → the app-side `resizeSessionOverlay` (→ `AppStore.resizeOverlay`) behind `ControlActions`,
- (3) the `session overlay resize --size-percent|--full` subcommand (`Resize`, `validate()`-guarded) in
- `agtermctlKit`, (4) round-trip in `ControlProtocolTests` + dispatcher routing/validation in `ControlDispatcherTests`
- + `AppStorePaneTests` (resize clamp/switch/no-overlay) + CLI mapping in `CommandsTests` + the e2e
- `testOverlayResizeSwitchesFloatingAndFull` in `ControlOverlaySplitUITests`.
- The READ side is `ControlSessionNode.overlaySizePercent` on each `tree` node (see the `tree` read-side
- fields below) — populated in `AppStore.controlTree`, round-tripped by `treeSessionNodeRoundTripsWithOverlaySizePercent`/`…OmitsOverlaySizePercentWhenNil`
- and `AppStorePaneTests.controlTreeReportsOverlaySizePercent`, and mirrored in the agent-skill `reference.md`
- tree schema — so a script can record an overlay's size before zooming to `--full` and restore it exactly.
+ needs its own arg validation, and `full` is a distinct `ControlArgs` field so the wire can tell "switch to
+ full" apart from "keep the current size".
+ The arm calls `AppStore.resizeOverlay(_:size:anchor:)` (mutating the non-persisted `Session.overlaySize`/
+ `overlayAnchor`, retaining a defensive percent clamp, guarding `overlayActive`), and the detail pane
+ re-flows the SAME surface host: the unified `WindowContentView.overlayPanel` renders BOTH variants and every
+ anchor (full = translucent, no chrome, panes hidden by `hideForOverlay`; floating = opaque framed panel over
+ visible panes, placed by its anchor), so a full<->floating switch or a re-anchor never re-parents the overlay
+ NSView (which would blank its Metal drawable) nor changes the ZStack shape — the old `if fullOverlay` z2
+ sibling is gone, and the always-present `overlayPanel` at z3 is the single host.
+ A `--full` round-trip PRESERVES the anchor (Decision 2 — only `closeOverlay` resets it to `.center`), so a
+ later re-float returns to where it was.
+ Keep-in-sync for `session.overlay.resize`: (1) `case sessionOverlayResize = "session.overlay.resize"` +
+ `ControlArgs.full`/`cols`/`rows`/`anchor` in `ControlProtocol.swift`, (2) the `.sessionOverlayResize`
+ dispatcher arm (one-of + range + cols·rows pairing + `full`⊥`anchor` + at-least-one validation) → the
+ app-side `resizeSessionOverlay(…size:anchor:)` (→ `AppStore.resizeOverlay`) behind `ControlActions`, (3) the
+ `session overlay resize --size-percent|--cols/--rows|--full|--anchor` subcommand (`Resize`,
+ `validate()`-guarded) in `agtermctlKit`, (4) round-trip in `ControlProtocolTests` + dispatcher
+ routing/validation in `ControlDispatcherTests` + `AppStorePaneTests` (resize
+ clamp/switch/re-anchor/no-overlay) + CLI mapping in `CommandsTests` + the e2e
+ (`testOverlayResizeSwitchesFloatingAndFull` plus the cols/rows + anchor cases) in `ControlOverlaySplitUITests`.
+ The READ side is on each `tree` node (see the `tree` read-side fields below), populated in
+ `AppStore.controlTree`: `overlaySizePercent` (a `.percent` overlay's percent),
+ `overlayCols`/`overlayRows` (a `.cells` overlay's REQUESTED grid),
+ `overlayColsApplied`/`overlayRowsApplied` (the REALIZED grid any floating overlay rendered after clamping —
+ exposes a clamp/drift), and `overlayAnchor` (any open overlay's anchor, incl. full).
+ Round-tripped by `treeSessionNodeRoundTripsWithOverlaySizePercent`/`…OmitsOverlaySizePercentWhenNil`,
+ `treeSessionNodeRoundTripsWithOverlayCellsAppliedAndAnchor`/`…WithOverlayPercentAppliedAndAnchor`,
+ `treeSessionNodeKeepsAnchorButOmitsGridForFullOverlay`/`…OmitsOverlayGridAndAnchorWhenNoOverlay`, and the
+ `AppStorePaneTests` populate tests (`controlTreeReportsOverlaySizePercent`/`…OverlayColsRowsAndAnchor`);
+ mirrored in the agent-skill `reference.md` tree schema — so a script can record an overlay's exact size AND
+ position before zooming to `--full` and restore both.
`surface.zoom` (mode `show`|`hide`|`toggle`) fills the target window with ONE terminal surface,
hiding the sidebar and collapsing the title bar to a slim strip (traffic lights + an exit button;
the zoomed terminal is inset below `titlebarHeight`, NOT borderless) — the control half of
@@ -1266,12 +1310,31 @@ paths:
is each pane running".
It ALSO surfaces `background` on each node — the `BackgroundWatermark` spec set via `session.background`
(omitted when none), the read side of set/clear so a script can query the current watermark.
- It ALSO surfaces `overlaySizePercent` on each node — an OPEN overlay's size (`session.overlayActive ? session.overlaySizePercent : nil`
- in the tree builder): nil/omitted = the full-pane overlay OR no overlay (so gate on `overlay` first),
- else the floating panel's percent (1...100).
- It is the READ side of `session.overlay.resize` (which had only the write side), so a tmux-style zoom
- script can record the current size before switching to `--full` and restore the EXACT original on un-zoom
- (not a guessed default).
+ It ALSO surfaces the OPEN overlay's size + position on each node, all gated on `overlay` first — the READ
+ side of `session.overlay.open`/`.resize`, so a tmux-style zoom script can record the exact geometry before
+ switching to `--full` and restore the ORIGINAL on un-zoom (not a guessed default):
+ `overlaySizePercent` — a `.percent` overlay's floating-panel percent (1...100); omitted for cells, full, or
+ no overlay.
+ `overlayCols`/`overlayRows` — a `.cells` overlay's REQUESTED grid (the restore key); omitted otherwise.
+ `overlayColsApplied`/`overlayRowsApplied` — the REALIZED grid any floating overlay actually rendered after
+ the whole-cell clamp (`session.floatingOverlayActive ? session.overlayAppliedCols/Rows : nil`, the app
+ writing the realized grid into observed `Session` state on surface realization / `CELL_SIZE` / backing-scale
+ change); omitted for a full overlay or none.
+ Distinct from the requested `overlayCols`/`overlayRows`, so an oversized request comes back smaller here and
+ a script can detect the clamp.
+ The applied grid is EVENTUALLY CONSISTENT: the app refreshes it ASYNC off the realized surface after an
+ open/resize, so a read right after a `session.overlay.resize` can briefly report the PRIOR size's grid
+ until the settle-resize re-reports.
+ `resizeOverlay` deliberately KEEPS the old applied value across a resize (it does NOT nil it): when the new
+ size actually changes the physical grid the app re-reports it, and when it does not (e.g. two oversized
+ cell requests both clamping to the same grid) the old value is already the correct realized grid — niling
+ it would strand the read-back at nil forever, because the app's last-value dedup suppresses re-reporting an
+ unchanged grid.
+ So a script must POLL `tree` for the applied fields after an open/resize (mirroring the e2e's
+ `pollOverlayApplied`), not read once, before comparing against the request.
+ `overlayAnchor` — the anchor rawValue of ANY open overlay including a full one
+ (`session.overlayActive ? session.overlayAnchor.rawValue : nil`), since the anchor is preserved across a
+ `--full` round-trip (Decision 2); omitted when no overlay is open.
It ALSO surfaces `splitRatio` on each node — the left-pane divider fraction of a session that HAS a split
(`session.hasSplit ? session.splitRatio : nil` in the tree builder, so shown OR hidden splits report it),
nil/omitted when there is no split or the ratio was never explicitly set (divider then at the default 0.5).
@@ -1284,6 +1347,37 @@ paths:
It is the READ side of `session.focus` (write-only), so a script can record which pane was focused and
restore it via `session.focus --pane left|right` (a `false` is emitted, distinct from the nil no-split
case — the left pane being focused is real state).
+ It ALSO surfaces `canvasCols`/`canvasRows` on each node — the session's terminal CONTENT AREA (the overlay
+ "canvas") in whole cells at the BASE font, the exact coordinate system `session.overlay.open --cols/--rows`
+ land in, so a script can size a floating overlay as a fraction of the canvas
+ (`--rows round(0.30*canvasRows)` is a 30%-tall strip).
+ It has NO write command — it is a pure DERIVED read (the max whole-cell grid that fills the detail area),
+ so it is NOT a state-mutating read-back pair; it is supplied APP-SIDE like `fontSize`
+ (the host-free tree can't read a surface), via a `canvasGrid: (Session) -> (cols: Int, rows: Int)?` closure
+ on `AppStore.controlTree` that `ControlServer.buildTree` fills from `GhosttySurfaceView.liveGrid()`
+ (`ghostty_surface_size().columns/.rows` — UNITLESS cell counts, so unlike the overlay cell metrics there is
+ NO px→point Retina conversion).
+ It is SPLIT-AGNOSTIC — the whole detail region an overlay fills, taken as ONE area:
+ `canvasRows` is the full height (`ControlServer.sessionCanvasGrid` reads the main pane's rows, since a
+ left/right split keeps full height), and `canvasCols` is the full width — the primary pane's columns when
+ NOT split (exact), and for a side-by-side split (`session.isSplit`) the WHOLE detail width measured from
+ BOTH panes' backing-pixel widths at ONE cell size (the primary pane's), floored ONCE
+ (`OverlayLayout.splitCanvasCols`) — NOT the sum of each pane's already-floored (and possibly different-font)
+ column count, which double-floors and mixes cell sizes.
+ A hidden split maximized on the split pane reports that pane's own full-width grid (`hasSplit && splitFocused`).
+ A side-by-side split `canvasCols` slightly UNDERESTIMATES a single full-width surface only by the thin
+ divider between the panes (a fraction of a cell; the per-pane inner padding IS now counted, riding in each
+ pane's pixel width), exact for the unsplit case — the "as-close-as-possible for split, exact unsplit" trade
+ documented in the field's godoc.
+ Because the main surface's live font tracks `session.fontSize` (cmd-+/-) and a fresh overlay is created
+ with the SAME `session.fontSize`, the grid measured at the main pane's live font equals the base overlay
+ font by construction.
+ A floating overlay does NOT fill the canvas edge-to-edge, though: every floating overlay is inset by a
+ uniform one-line-height SAFE-AREA margin on all four sides, so `overlay open --cols canvasCols` CLAMPS to
+ the usable area (canvas minus the margins) rather than filling it.
+ A script wanting an exact fit reads the realized grid back on `overlayColsApplied`/`overlayRowsApplied`,
+ or requests within (canvas − margin).
+ Omitted when no surface is realized (the overlay-metrics nil convention).
`tree` ALSO carries, at the TOP level (alongside `idleMs`/`autoFollowMs`), `sidebarVisible` — the read
side of the write-only `sidebar` command (per-window sidebar visibility), populated LIVE from the
projected window's store in `AppStore.controlTree`.
diff --git a/.claude/rules/libghostty.md b/.claude/rules/libghostty.md
index 5c1fc1a2..b34a2fd3 100644
--- a/.claude/rules/libghostty.md
+++ b/.claude/rules/libghostty.md
@@ -299,6 +299,19 @@ paths:
but is harder: it is NOT fixed by `refresh` or a forced `set_size` jitter,
and a font change doesn't resize the view so this `updateMetalLayerSize` path never fires for it —
still parked.
+- **A live libghostty surface metric drives SwiftUI reactively by MIRRORING it into OBSERVED `Session`
+ state, never by reading the imperative surface from the view body.**
+ `Session.overlaySurface` (and `GhosttySurfaceView` generally) is `@ObservationIgnored` and assigned in
+ `TerminalView.makeNSView`, so a view that read `ghostty_surface_size()` off it would not re-lay-out when
+ the metric changes.
+ Instead the app refreshes OBSERVED `Session.overlayCellMetrics` (points, px÷`backingScaleFactor`) and
+ `overlayAppliedCols/Rows` (the realized grid) from `GHOSTTY_ACTION_CELL_SIZE` (`GhosttyCallbacks.swift`),
+ surface realization, and `updateMetalLayerSize` (backing-scale change), and `overlayPanel` consumes those
+ observed values via `OverlayLayout.panelSize` — so a font/DPI change re-sizes the floating panel
+ reactively with no view-body poll of the imperative NSView.
+ Generalize this for any "get a live libghostty metric into SwiftUI": convert at the app boundary
+ (pixels→points), store on observed `Session` state, refresh from the relevant `GHOSTTY_ACTION_*`
+ callback, and read the observed value from the body.
- **Dashboard grid = the N-PANE generalization of terminal-zoom's reparent, focus inverted.**
Where `surface.zoom` reparents ONE session surface into the window and focuses it, the dashboard
(`DashboardView` + `WindowContentView+Dashboard.swift`, driven by the host-free `DashboardController`)
diff --git a/CLAUDE.md b/CLAUDE.md
index f650a452..f118cb39 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -356,7 +356,8 @@ always in context:
`session.background`/`background`, `notify`+`session.seen`/`unseen`,
`session.status`/`status`+`statusPane` (+`statusBlink`/`statusColor` for `--blink`/`--color`),
`session.flag`/`flagged`, `session.focus`/`splitFocused`, `session.resize`/`splitRatio`,
- `session.overlay.resize`/`overlaySizePercent`, `sidebar`/`sidebarVisible`, `sidebar.mode`/`sidebarMode`,
+ `session.overlay.open`+`session.overlay.resize`/`overlaySizePercent`+`overlayCols`/`overlayRows` (requested)+`overlayColsApplied`/`overlayRowsApplied` (realized)+`overlayAnchor`,
+ `sidebar`/`sidebarVisible`, `sidebar.mode`/`sidebarMode`,
`workspace.focus`/`focused`, `quick`/`quickVisible`,
`window.move`+`window.resize`/`geometry`, `window.fullscreen`+`window.zoom`/`fullscreen`+`zoomed`.
When adding a state-mutating command, ask "how does a script read back what I just set?" and add that
diff --git a/README.md b/README.md
index c69b3f67..afa651f6 100644
--- a/README.md
+++ b/README.md
@@ -288,20 +288,22 @@ These are also the Edit menu's Copy/Paste/Select All (⌘C/⌘V/⌘A), which act
agtermctl session overlay open "revdiff HEAD~3" --target 9f3c # review the last 3 commits over session 9f3c
agtermctl session overlay open "htop" # on the active session
agtermctl session overlay open "htop" --size-percent 70 # a floating, framed panel at 70% of the pane
+agtermctl session overlay open "htop" --cols 80 --rows 24 --anchor bottom-right # an exact 80x24 grid, parked bottom-right
agtermctl session overlay open "revdiff HEAD~3" --size-percent 80 --background-color "#2a1a3a" # tint the overlay pane
agtermctl session overlay open "revdiff HEAD~3" --target 9f3c --follow # switch the user to session 9f3c as the overlay opens
agtermctl session overlay open "make test" --wait # keep the overlay open after exit (press a key to close)
agtermctl session overlay open "make test" --block # block until it exits; exit with its status
agtermctl session overlay resize --size-percent 60 --target 9f3c # resize an open overlay to a floating 60% panel
+agtermctl session overlay resize --anchor top-left --target 9f3c # re-anchor the floating panel in place (same size)
agtermctl session overlay resize --full --target 9f3c # switch it back to the full-pane overlay
agtermctl session overlay close --target 9f3c # close it from a script
```
-By default an overlay opens on its `--target` without switching the active session — full and floating both run their program in the background and appear when the user visits that session; pass `--follow` to select the target as the overlay opens (a no-op if it is already active). `session overlay resize` changes an already-open overlay in place — `--size-percent N` (1–100) makes it a floating panel, `--full` switches it back to full size — and the program keeps running across the change. By default it closes the instant the program exits; `--wait` keeps it on a "press any key to close" prompt so you can read the program's final output. A `*` `(overlay)` tag in `agtermctl tree` marks a session whose overlay is open.
+By default an overlay opens on its `--target` without switching the active session — full and floating both run their program in the background and appear when the user visits that session; pass `--follow` to select the target as the overlay opens (a no-op if it is already active). `session overlay resize` changes an already-open overlay in place — a size mode (`--size-percent N`, `--cols N --rows M`, or `--full`) and/or `--anchor` — and the program keeps running across the change. By default it closes the instant the program exits; `--wait` keeps it on a "press any key to close" prompt so you can read the program's final output. A `*` `(overlay)` tag in `agtermctl tree` marks a session whose overlay is open.
`--block` runs the program in the overlay (rendering normally) and blocks until it exits, then exits with the program's status — useful in a script that needs the outcome of an interactive run. The program's output stays its own concern: a TUI writes its result to its own file (for example `revdiff --output=…`) which the script reads, while `--block` reports only the exit status (the overlay never captures stdout). `--block` can't be combined with `--wait`; `session overlay result` reports the last overlay's exit status on demand for a manual open → poll flow.
-By default the overlay fills the pane, drawn translucent, hiding the session beneath it. Pass `--size-percent N` (1–100) for a *floating* variant instead: an opaque, framed panel sized to N% of the pane in both dimensions and centered in it, with the session still visible around it. Useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens.
+By default the overlay fills the pane, drawn translucent, hiding the session beneath it. For a *floating* variant instead — an opaque, framed panel with the session still visible around it — size it one of two ways: `--size-percent N` (1–100) sizes it to N% of the pane in both dimensions, or `--cols N --rows M` sizes it to an exact terminal grid. Both are adaptive — a `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a `--size-percent` panel is a fraction of the pane and so is never larger than it — and the grid that actually rendered is read back on `agtermctl tree` (as `overlayColsApplied`/`overlayRowsApplied`). To size relative to the pane, `agtermctl tree` also reports `canvasCols`/`canvasRows` — the terminal content area (the overlay "canvas") in whole cells at the base font, the exact coordinate system `--cols/--rows` land in — so a script can compute a concrete grid as a fraction of the canvas (for example a 30%-tall top strip: `--cols $canvasCols --rows $((canvasRows*30/100)) --anchor top`). `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`, `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inside a uniform safe-area margin of one line-height on all four sides, so it is never flush with any border. The margin is symmetric and independent of the anchor: a full-width band (`--cols $canvasCols`) is inset off the left and right exactly like the top, and `center` keeps the panel dead-centered within that safe area. A floating overlay is useful for a small auxiliary program (a picker, a monitor) that you want floating over — not replacing — the terminal you're working in. It composes with `--block` (a blocking floating overlay). Like a full overlay it opens in the background and runs even when the target is not active; pass `--follow` to switch the user to the target as it opens. `session overlay resize` re-anchors or re-sizes it in place: `--anchor` alone moves the panel keeping its size, `--full` reverts to full-pane (which preserves the anchor for a later re-float, and cannot be combined with `--anchor`), and an out-of-range `--size-percent` is a hard error on both open and resize.
A session's terminal surface is created lazily — it does not exist until the session has been shown at least once. Injecting text into a never-shown session therefore fails with `session not realized` unless you pass `--select`, which selects the session (realizing its surface) before injecting:
diff --git a/agterm/AppActions.swift b/agterm/AppActions.swift
index 15680a1d..5de1e6cd 100644
--- a/agterm/AppActions.swift
+++ b/agterm/AppActions.swift
@@ -354,7 +354,8 @@ final class AppActions {
func editKeymap() {
guard uiActionsEnabled else { return }
guard let store, let id = store.selectedSessionID, let path = settingsModel?.keymapPath else { return }
- if store.openOverlay(id, command: ConfigPaths.editorCommand(forPath: path), sizePercent: 95) {
+ if store.openOverlay(id, options: .init(command: ConfigPaths.editorCommand(forPath: path),
+ size: .percent(95), anchor: .center)) {
keymapEditOverlaySession = id
}
}
@@ -387,7 +388,8 @@ final class AppActions {
func editGhosttyConfig() {
guard uiActionsEnabled else { return }
guard let store, let id = store.selectedSessionID, let path = settingsModel?.ghosttyConfigPath else { return }
- if store.openOverlay(id, command: ConfigPaths.editorCommand(forPath: path), sizePercent: 95) {
+ if store.openOverlay(id, options: .init(command: ConfigPaths.editorCommand(forPath: path),
+ size: .percent(95), anchor: .center)) {
ghosttyEditOverlaySession = id
ghosttyEditOverlaySnapshot = try? String(contentsOfFile: path, encoding: .utf8)
}
diff --git a/agterm/Control/ControlServer+SessionActions.swift b/agterm/Control/ControlServer+SessionActions.swift
index 982906fb..e1a65108 100644
--- a/agterm/Control/ControlServer+SessionActions.swift
+++ b/agterm/Control/ControlServer+SessionActions.swift
@@ -27,9 +27,10 @@ extension ControlServer: ControlActions {
func openSessionOverlay(_ target: String?, window: String?,
options: ControlSessionOverlayOpenOptions) -> ControlResponse {
resolver.resolveSession(target, window: window) { store, id in
- guard store.openOverlay(id, command: options.command, cwd: options.cwd,
- wait: options.wait, sizePercent: options.sizePercent,
- backgroundColor: options.backgroundColor) else {
+ guard store.openOverlay(id, options: OverlayOpenOptions(
+ command: options.command, cwd: options.cwd, wait: options.wait,
+ size: options.size, anchor: options.anchor,
+ backgroundColor: options.backgroundColor)) else {
return ControlResponse(ok: false, error: "overlay already open")
}
// Both overlay kinds mount and run in the per-session eager deck regardless of which session
@@ -52,9 +53,10 @@ extension ControlServer: ControlActions {
}
}
- func resizeSessionOverlay(_ target: String?, window: String?, sizePercent: Int?) -> ControlResponse {
+ func resizeSessionOverlay(_ target: String?, window: String?,
+ size: OverlaySize?, anchor: OverlayAnchor?) -> ControlResponse {
resolver.resolveSession(target, window: window) { store, id in
- guard store.resizeOverlay(id, sizePercent: sizePercent) else {
+ guard store.resizeOverlay(id, size: size, anchor: anchor) else {
return ControlResponse(ok: false, error: "no overlay")
}
return ControlResponse(ok: true, result: ControlResult(id: id.uuidString))
diff --git a/agterm/Control/ControlServer.swift b/agterm/Control/ControlServer.swift
index 2edcf731..1ca9abc8 100644
--- a/agterm/Control/ControlServer.swift
+++ b/agterm/Control/ControlServer.swift
@@ -533,6 +533,7 @@ final class ControlServer {
fontSize: { ($0.addressableSurface as? GhosttySurfaceView)?.currentFontSize() },
splitFontSize: { ($0.splitSurface as? GhosttySurfaceView)?.currentFontSize() },
scratchFontSize: { ($0.scratchSurface as? GhosttySurfaceView)?.currentFontSize() },
+ canvasGrid: { self.sessionCanvasGrid($0) },
quickVisible: { windowID.flatMap { QuickTerminalRegistry.shared.controller(for: $0)?.isVisible } ?? false },
zoomedSurface: { windowID.flatMap { TerminalZoomRegistry.shared.controller(for: $0)?.target?.controlID } },
dashboardMembers: {
@@ -558,6 +559,34 @@ final class ControlServer {
)
}
+ /// The session's terminal content-area grid (columns × rows) for the `tree` `canvasCols`/`canvasRows`
+ /// read-back — the whole detail region an overlay is placed within, measured at the session's base font
+ /// from the live `ghostty_surface_size()` grid (unitless cell counts, no Retina conversion). The primary
+ /// pane's grid when NOT split (exact). When the split is shown side-by-side, `canvasCols` is the WHOLE
+ /// detail width measured from BOTH panes' backing-pixel widths at ONE cell size
+ /// (`OverlayLayout.splitCanvasCols`) — floored once, not the sum of each pane's already-floored
+ /// (possibly different-font) column count — so the per-pane inner padding IS counted and only the thin
+ /// divider (a fraction of a cell) is uncounted. `canvasRows` is the main pane's rows (a left/right split
+ /// keeps full height). A hidden split maximized on the split pane reports that pane's own full-width grid.
+ /// A floating overlay is inset by a uniform one-line-height safe-area margin, so a `--cols canvasCols`
+ /// request clamps to the usable area (read `overlayColsApplied`/`overlayRowsApplied` for the realized
+ /// grid). nil when no surface is realized (the overlay-metrics nil convention).
+ private func sessionCanvasGrid(_ session: Session) -> (cols: Int, rows: Int)? {
+ let mainView = session.surface as? GhosttySurfaceView
+ let splitView = session.splitSurface as? GhosttySurfaceView
+ if session.isSplit, let mainView, let splitView, let main = mainView.liveGrid(),
+ let mainPx = mainView.liveWidthPx(), let splitPx = splitView.liveWidthPx(),
+ let cols = OverlayLayout.splitCanvasCols(primaryWidthPx: mainPx.widthPx, splitWidthPx: splitPx.widthPx,
+ primaryCellWidthPx: mainPx.cellWidthPx) {
+ return (cols: cols, rows: main.rows)
+ }
+ // a hidden (maximized-one-pane) split focused on the split pane: that pane fills the detail width.
+ if session.hasSplit, session.splitFocused, let split = splitView?.liveGrid() {
+ return split
+ }
+ return mainView?.liveGrid()
+ }
+
/// Creates a session in `workspaceID` of `store` with the `session.new` args (cwd default $HOME,
/// optional command/name), focuses it when it lands in the frontmost window (so a keymap `session new`
/// opens focused like the GUI New Session; a background `--window` target keeps focus), and returns the
diff --git a/agterm/Ghostty/GhosttyCallbacks.swift b/agterm/Ghostty/GhosttyCallbacks.swift
index 4d6ad6cb..0ca8d95e 100644
--- a/agterm/Ghostty/GhosttyCallbacks.swift
+++ b/agterm/Ghostty/GhosttyCallbacks.swift
@@ -50,9 +50,13 @@ final class GhosttyCallbacks: @unchecked Sendable {
case GHOSTTY_ACTION_CELL_SIZE:
// fires when the cell pixel size changes (font-size change via cmd +/-, or DPI
// change). used only as a trigger: the view reads the live font size and the app
- // persists it.
+ // persists it, and (for an overlay surface) refreshes the overlay cell metrics so a
+ // `.cells` panel re-snaps to whole cells. each call self-guards on its own wiring.
guard let view = surfaceView(from: target) else { return true }
- DispatchQueue.main.async { view.reportFontSize() }
+ DispatchQueue.main.async {
+ view.reportFontSize()
+ view.refreshOverlayMetrics()
+ }
return true
case GHOSTTY_ACTION_RENDER:
// libghostty signals this surface has a frame ready to paint. agterm is demand-driven (no poll
diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift
index 32b93721..6bd23bba 100644
--- a/agterm/Ghostty/GhosttySurfaceView.swift
+++ b/agterm/Ghostty/GhosttySurfaceView.swift
@@ -136,6 +136,20 @@ final class GhosttySurfaceView: NSView, TerminalSurface {
/// off the CELL_SIZE action and reads the size via `ghostty_surface_inherited_config`.
var onFontSizeChange: ((Double) -> Void)?
+ /// For an OVERLAY surface: called on the main actor with the surface's live cell metrics (POINTS) and
+ /// its realized grid (columns, rows) whenever they change — surface realization, `GHOSTTY_ACTION_CELL_SIZE`,
+ /// and any backing-size/scale change. The overlay carries no `view.session`, so the factory wires this to
+ /// write `session.overlayCellMetrics`/`overlayAppliedCols`/`overlayAppliedRows`, which drive the `.cells`
+ /// panel layout and the `tree` read-back. Nil for main/split/scratch surfaces. Nilled in `destroySurface`.
+ var onOverlayMetrics: ((OverlayCellMetrics, Int, Int) -> Void)?
+
+ /// Last overlay metrics reported through `onOverlayMetrics`, so `refreshOverlayMetrics` skips a redundant
+ /// write — the observed session state would otherwise re-invalidate the panel every layout pass, and the
+ /// `.cells` panel (which converges in one resize step) would keep re-writing at the fixed point.
+ private var lastOverlayCell: OverlayCellMetrics?
+ private var lastOverlayCols: Int?
+ private var lastOverlayRows: Int?
+
/// Called on the main actor when libghostty enters search mode (START_SEARCH), carrying the current
/// needle (nil when none). The factory wires this to toggle the session's search bar — if the bar is
/// already visible it sends `end_search` (the ⌘F-again close), else it opens the bar and seeds the
@@ -498,6 +512,79 @@ final class GhosttySurfaceView: NSView, TerminalSurface {
onFontSizeChange?(size) // the store no-ops a same-value write.
}
+ /// The overlay surface's live cell + padding + grid metrics from `ghostty_surface_size`, all in BACKING
+ /// PIXELS (cell width/height and the derived non-cell padding remainder, plus the realized columns/rows).
+ struct OverlayPixelMetrics {
+ let cellW: Double, cellH: Double, padW: Double, padH: Double
+ let cols: Int, rows: Int
+ }
+
+ /// The surface's live terminal grid (columns × rows) from `ghostty_surface_size`, or nil when the surface
+ /// isn't realized or has no size yet. Unlike `overlayPixelMetrics`, this returns the raw cell COUNTS — a
+ /// grid count is unitless, so there is no px→point (Retina) conversion. Feeds the `tree`
+ /// `canvasCols`/`canvasRows` content-area read-back; measured at the surface's current font, which tracks
+ /// the session's base overlay font (both follow `session.fontSize`).
+ func liveGrid() -> (cols: Int, rows: Int)? {
+ guard let surface else { return nil }
+ let size = ghostty_surface_size(surface)
+ let cols = Int(size.columns)
+ let rows = Int(size.rows)
+ guard cols > 0, rows > 0 else { return nil }
+ return (cols: cols, rows: rows)
+ }
+
+ /// The surface's live content WIDTH and cell WIDTH in backing pixels from `ghostty_surface_size`, or nil
+ /// when the surface isn't realized or has no cell size yet. Feeds `sessionCanvasGrid`'s split measurement,
+ /// which spans both panes' pixel widths at ONE cell size — a px/px ratio, so no Retina conversion.
+ func liveWidthPx() -> (widthPx: Double, cellWidthPx: Double)? {
+ guard let surface else { return nil }
+ let size = ghostty_surface_size(surface)
+ let cellW = Double(size.cell_width_px)
+ let widthPx = Double(size.width_px)
+ guard cellW > 0, widthPx > 0 else { return nil }
+ return (widthPx: widthPx, cellWidthPx: cellW)
+ }
+
+ /// Reads the overlay surface's live pixel metrics from `ghostty_surface_size`. nil when the surface isn't
+ /// realized or has no cell size yet. The caller converts px→points via `backingScaleFactor` before handing
+ /// them to the host-free layout resolver (which works in the point space `GeometryReader`/`WindowGeometry.Size`
+ /// use). Padding is the total non-cell remainder — an estimate, per the padding-drift note in the overlay plan.
+ func overlayPixelMetrics() -> OverlayPixelMetrics? {
+ guard let surface else { return nil }
+ let size = ghostty_surface_size(surface)
+ let cellW = Double(size.cell_width_px)
+ let cellH = Double(size.cell_height_px)
+ guard cellW > 0, cellH > 0 else { return nil }
+ let cols = Int(size.columns)
+ let rows = Int(size.rows)
+ let padW = Double(size.width_px) - Double(cols) * cellW
+ let padH = Double(size.height_px) - Double(rows) * cellH
+ return OverlayPixelMetrics(cellW: cellW, cellH: cellH, padW: padW, padH: padH, cols: cols, rows: rows)
+ }
+
+ /// Refreshes the OVERLAY surface's cell metrics + realized grid onto the owning session (via the factory
+ /// closure — the overlay has no `view.session`). No-op unless this is an overlay surface (`onOverlayMetrics`
+ /// wired) with a realized surface. Reads BACKING PIXELS from `ghostty_surface_size` and converts to POINTS
+ /// via the window's `backingScaleFactor` — the Retina conversion is load-bearing, since the resolver and
+ /// the SwiftUI pane size are in points. Skips a redundant write so it can be called freely on realization,
+ /// `CELL_SIZE`, and every backing-size change without re-invalidating the panel at the `.cells` fixed point.
+ func refreshOverlayMetrics() {
+ guard let report = onOverlayMetrics, let px = overlayPixelMetrics() else { return }
+ // require the surface's OWN window for a real backing scale. Without one (not yet in a window) skip
+ // the refresh and leave the metrics unchanged rather than converting px->points with a guessed scale;
+ // a later realization/CELL_SIZE refresh reports the right value once the window is available.
+ guard let window else { return }
+ let scale = Double(window.backingScaleFactor)
+ guard scale > 0 else { return }
+ let cell = OverlayCellMetrics(cellWidth: px.cellW / scale, cellHeight: px.cellH / scale,
+ padWidth: px.padW / scale, padHeight: px.padH / scale)
+ guard cell != lastOverlayCell || px.cols != lastOverlayCols || px.rows != lastOverlayRows else { return }
+ lastOverlayCell = cell
+ lastOverlayCols = px.cols
+ lastOverlayRows = px.rows
+ report(cell, px.cols, px.rows)
+ }
+
/// Draws the surface now, servicing libghostty's `GHOSTTY_ACTION_RENDER` demand. Main-actor.
func renderNow() {
guard let surface else { return }
@@ -619,6 +706,12 @@ final class GhosttySurfaceView: NSView, TerminalSurface {
// the overlay grabs first responder itself (TerminalView's once-on-attach grab misses the
// deferred overlay surface); a bounded run-loop retry beats the SwiftUI/AppKit responder race.
requestAutoFocus(in: window)
+
+ // capture the overlay surface's initial cell metrics + grid so a `.cells` panel can snap to whole
+ // cells (no-op for non-overlay surfaces). The first realization is at the full-pane fallback size
+ // (metrics are nil until now); the follow-up resize to the `.cells` size refreshes the applied grid
+ // via updateMetalLayerSize.
+ refreshOverlayMetrics()
}
/// Marks the surface focused in libghostty after a retried `makeFirstResponder` (the overlay/reparent
@@ -721,6 +814,7 @@ final class GhosttySurfaceView: NSView, TerminalSurface {
onUserInputClearsStatus = nil
onUserInput = nil
onFontSizeChange = nil
+ onOverlayMetrics = nil
onSearchStart = nil
onSearchEnd = nil
onSearchTotal = nil
@@ -798,6 +892,9 @@ final class GhosttySurfaceView: NSView, TerminalSurface {
// an unchanged grid is a no-op and the 120Hz `ghostty_app_tick` only draws surfaces flagged dirty,
// so without this the re-hosted pane keeps a blank drawable even though its terminal buffer is intact.
ghostty_surface_refresh(surface)
+ // a resize (incl. the post-realization settle to the `.cells` size) or a backing-scale change moves
+ // the overlay surface's grid/metrics, so refresh the read-back off the new size (no-op otherwise).
+ refreshOverlayMetrics()
}
// MARK: - First responder
diff --git a/agterm/Resources/agent-skill/SKILL.md b/agterm/Resources/agent-skill/SKILL.md
index d884b22d..da663196 100644
--- a/agterm/Resources/agent-skill/SKILL.md
+++ b/agterm/Resources/agent-skill/SKILL.md
@@ -160,14 +160,24 @@ spec — image/text watermark or solid color — set via `session background`, o
`unseen` (the unseen-notification badge count — raised by `notify`/OSC 9/777, cleared by `session
seen` — omitted when zero), `commandWait` (whether a `--command` session was created with `--wait` to
hold open after the command exits — the read side of `session new --wait`, omitted for a plain or
-non-holding session), `overlaySizePercent` (an open overlay's floating-panel percent 1–100,
-omitted for a full-pane overlay or no overlay so gate on `overlay` first; the read side of `overlay
-resize` for a record-then-restore zoom), `splitRatio` (the left-pane divider fraction 0.05–0.95 of a
+non-holding session), `overlaySizePercent` (a `--size-percent` overlay's floating-panel percent 1–100,
+omitted for a cells-mode, full-pane, or absent overlay so gate on `overlay` first; the read side of
+`overlay resize` for a record-then-restore zoom), `overlayCols`/`overlayRows` (a cells-mode overlay's
+REQUESTED grid), `overlayColsApplied`/`overlayRowsApplied` (the REALIZED grid any floating overlay rendered
+after clamping — compare to the requested grid to detect a clamp), `overlayAnchor` (which of the nine
+positions any open overlay anchors to, reported even for a full overlay — the anchor survives `--full`),
+`splitRatio` (the left-pane divider fraction 0.05–0.95 of a
session that has a split — shown or hidden; omitted when there's no split or the ratio was never set (at
the default 0.5) —
the read side of `session resize`, record it to restore the exact divider), `splitFocused`
(which pane holds focus in a session that has a split — `true` = split/right, `false` = main/left; omitted
-when there's no split; the read side of `session focus`, record it to restore focus), and `surfaces`
+when there's no split; the read side of `session focus`, record it to restore focus),
+`canvasCols`/`canvasRows` (the session's terminal CONTENT AREA — the "overlay canvas" — in whole cells at
+the base font, so a script sizes a floating overlay as a fraction of the canvas: `overlay open --cols
+$canvasCols --rows $((canvasRows*30/100)) --anchor top` is a ~30%-tall top strip; split-agnostic — full
+height, full width across the whole detail area. A floating overlay is inset by a uniform one-line-height
+safe-area margin, so `--cols $canvasCols` CLAMPS to the usable width (read the realized grid back on
+`overlayColsApplied`/`overlayRowsApplied`); omitted when no surface is realized), and `surfaces`
(`id`, `kind`, `active`, `visible`) for `surface zoom`. The tree top level carries `zoomedSurface`
(the control id of the currently zoomed surface, omitted when nothing is zoomed — the read side of
`surface zoom`, so a script can check the zoom state and record-then-restore). It also carries the read
@@ -275,12 +285,20 @@ omitted when expanded).
terminal background color. Per session; survives restart. `--opacity` 0.0–1.0. (An image/text watermark
renders the pane opaque, overriding window translucency, so it shows; a `color` takes no opacity and
honors the Settings window translucency instead.)
-- `overlay open [--cwd DIR] [--wait] [--block] [--size-percent N] [--background-color #rrggbb] [--follow]` ·
- `overlay resize (--size-percent N | --full)` ·
+- `overlay open [--cwd DIR] [--wait] [--block] [--size-percent N | --cols N --rows M] [--anchor POS] [--background-color #rrggbb] [--follow]` ·
+ `overlay resize [--size-percent N | --cols N --rows M | --full] [--anchor POS]` ·
`overlay close` ·
`overlay result` — run a program on top of a session; `--block` waits and exits with its status.
- `overlay resize` changes an ALREADY-OPEN overlay: `--size-percent N` (1-100) makes it a floating panel,
- `--full` switches it back to the full-pane overlay; the program keeps running (no re-spawn).
+ Full by default; make it a floating panel with `--size-percent N` (1–100, hard error out of range) or
+ `--cols N --rows M` (exact grid). `--cols/--rows` clamps to the whole cells that fit; `--size-percent` is a
+ fraction of the pane (never larger). Either way the realized grid reads back as
+ `overlayColsApplied`/`overlayRowsApplied`. `--anchor POS` parks a floating panel at one of nine positions
+ (`top-left` … `center` (default) … `bottom-right`) inside a uniform safe-area margin of one line-height on
+ all four sides (never flush with a border; symmetric and anchor-independent, so a full-width band is inset
+ left/right like the top); it needs a floating size. `overlay resize` changes an ALREADY-OPEN overlay in place (the program keeps running, no
+ re-spawn): pass a size mode (`--size-percent`/`--cols`+`--rows`/`--full`) and/or `--anchor` (at least one);
+ `--anchor` alone re-anchors keeping the size, `--full` reverts to full-pane but preserves the anchor and
+ cannot be combined with `--anchor`.
Target with `--target "$AGTERM_SESSION_ID"` for YOUR session (default `active` is the user's selection).
**By default `overlay open` does NOT switch the user** — full and floating (`--size-percent`) both open
on `--target` and run their program in the background; the panel appears when the user visits that
diff --git a/agterm/Resources/agent-skill/examples.md b/agterm/Resources/agent-skill/examples.md
index 28dc8c99..96023100 100644
--- a/agterm/Resources/agent-skill/examples.md
+++ b/agterm/Resources/agent-skill/examples.md
@@ -226,6 +226,39 @@ agtermctl session overlay resize --full --target "$AGTERM_SESSION_ID"
agtermctl session overlay close
```
+Exact terminal-grid sizing + a corner anchor (instead of a percent): size the panel to a known TUI layout
+and park it out of the way. A cols/rows request larger than the pane clamps to the whole cells that fit —
+read the realized grid back off `tree`:
+
+```bash
+# an 80x24 panel parked in the bottom-right corner (inset one line-height off every edge — a uniform safe-area margin)
+agtermctl session overlay open "zsh -lc 'htop'" --target "$AGTERM_SESSION_ID" --cols 80 --rows 24 --anchor bottom-right
+# what actually rendered (requested vs clamped): overlayCols/Rows = requested, overlayColsApplied/RowsApplied = realized
+agtermctl tree --json | jq '.. | objects | select(.overlay == true) | {overlayCols, overlayRows, overlayColsApplied, overlayRowsApplied, overlayAnchor}'
+```
+
+Size an overlay as a FRACTION of the canvas: read `canvasCols`/`canvasRows` off `tree` (the terminal
+content area in cells at the base font — the coordinate system `--cols/--rows` land in), then compute a
+concrete grid. A ~30%-tall top strip spanning the full usable width (a floating overlay is inset by a
+uniform one-line-height safe-area margin, so a `--cols $canvasCols` request clamps to the usable width —
+read `overlayColsApplied`/`overlayRowsApplied` back for the exact rendered grid):
+
+```bash
+read -r cols rows < <(agtermctl tree --json \
+ | jq -r --arg id "$AGTERM_SESSION_ID" '.result.tree.workspaces[].sessions[] | select(.id|ascii_downcase == ($id|ascii_downcase)) | "\(.canvasCols) \(.canvasRows)"')
+# a top strip ~30% of the height, full usable width (clamped inside the safe-area margin), parked at the top
+agtermctl session overlay open "zsh -lc 'tail -f /var/log/system.log'" --target "$AGTERM_SESSION_ID" \
+ --cols "$cols" --rows "$(( rows * 30 / 100 ))" --anchor top
+```
+
+Re-anchor an open floating panel in place — no size change, program keeps running (`--anchor` alone keeps
+the current size; a size mode alone keeps the current anchor):
+
+```bash
+agtermctl session overlay resize --anchor top-left --target "$AGTERM_SESSION_ID" # move it to the top-left, same size
+agtermctl session overlay resize --anchor center --target "$AGTERM_SESSION_ID" # back to centered
+```
+
Manual open + poll for status instead of `--block`:
```bash
diff --git a/agterm/Resources/agent-skill/reference.md b/agterm/Resources/agent-skill/reference.md
index bede900f..1f5c0406 100644
--- a/agterm/Resources/agent-skill/reference.md
+++ b/agterm/Resources/agent-skill/reference.md
@@ -100,10 +100,20 @@ to restore focus via `session focus --pane left|right`),
`commandWait` (whether a `--command` session was created with `--wait` to hold open after the command
exits — the read side of `session new --wait`; omitted for a plain or non-holding session),
`overlay` (overlay shown),
-`overlaySizePercent` (an open overlay's size — the
-floating panel's percent of the pane, 1–100; omitted = a full-pane overlay or no overlay, so gate on
-`overlay` first; the read side of `session overlay resize`, e.g. record it before switching to `--full`
-to restore the exact size), `scratch` (scratch shown), `flagged` (in the
+`overlaySizePercent` (an open PERCENT-mode overlay's size — the
+floating panel's percent of the pane, 1–100; omitted for a cells-mode overlay, a full-pane overlay, or no
+overlay, so gate on `overlay` first; the read side of a `--size-percent` overlay, e.g. record it before
+switching to `--full` to restore the exact size),
+`overlayCols`/`overlayRows` (the REQUESTED grid of a cells-mode overlay opened/resized with
+`--cols N --rows M`; omitted for a percent, full, or absent overlay — the restore key for a cells overlay),
+`overlayColsApplied`/`overlayRowsApplied` (the REALIZED grid an open floating overlay actually rendered
+after whole-cell clamping — set for both percent and cells floating overlays, omitted for a full overlay or
+none; an oversized `--cols/--rows` request comes back smaller here, so compare against the requested grid to
+detect a clamp),
+`overlayAnchor` (which of the nine positions an open overlay anchors to — `top-left` … `center` …
+`bottom-right`; reported for ANY open overlay including a full one (the anchor is preserved across `--full`),
+omitted when no overlay is open — the read side of the overlay `--anchor`),
+`scratch` (scratch shown), `flagged` (in the
flagged working-set), `status` (the agent-status — `active`|`completed`|`blocked` — omitted when
idle), `statusPane` (which pane set that status — `left` (main) | `right` (split) | `scratch` — the
`--pane` value from `session status`, omitted when unset or idle; gated on the same non-idle condition
@@ -124,7 +134,20 @@ when zero), `fontSize`/`splitFontSize`/`scratchFontSize` (the LIVE font size in
the read side of `font --pane`; each omitted when that pane isn't realized. `fontSize` tracks the
default/left target (the main pane, or the promoted split survivor once the primary exits — the same pane
`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
+promoted survivor are live-only — read them back here rather than from the snapshot),
+`canvasCols`/`canvasRows` (the session's terminal CONTENT AREA — the "overlay canvas" — in whole cells at
+the session's BASE font, the coordinate system `overlay open --cols/--rows` land in; so a script sizes a
+floating overlay as a fraction of the canvas: `--rows $((canvasRows*30/100))` is 30% of the height. It is
+the WHOLE detail region an overlay is placed within (everything except the sidebar and title bar) taken as
+ONE area, split-AGNOSTIC: `canvasRows` is the full height (a left/right split keeps full height) and
+`canvasCols` the full width — the primary pane's columns unsplit (exact), and when split side-by-side the
+whole detail width measured from both panes' pixel widths at ONE cell size (floored once, so the per-pane
+padding IS counted, not a sum of per-pane floored columns; a split `canvasCols` underestimates a single
+full-width surface only by the thin divider, a fraction of a cell). A floating overlay does NOT fill the
+canvas edge-to-edge: it is inset by a uniform one-line-height safe-area margin on all four sides, so a
+`--cols $canvasCols` request CLAMPS to the usable area — read the realized grid back on
+`overlayColsApplied`/`overlayRowsApplied`, or request within (canvas − margin) for an exact fit. Omitted
+when no surface is realized), and `surfaces` (array
of `{id, kind, active, visible}` where `kind` is `left`|`right`|`scratch`|`overlay`).
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`
@@ -428,14 +451,26 @@ All ten are read-only projections of GUI state.
background-opacity); a `color` instead honors the Settings window translucency. Read the current
background back from a session's `background` field in `tree --json` (a `{kind, colorHex, …}` object,
omitted when none).
-- `session overlay open [--cwd DIR] [--wait] [--block] [--size-percent N] [--background-color #rrggbb] [--follow] [--target] [--window W]`
+- `session overlay open [--cwd DIR] [--wait] [--block] [--size-percent N | --cols N --rows M] [--anchor POS] [--background-color #rrggbb] [--follow] [--target] [--window W]`
— run `command` in an ephemeral terminal on top of the session; it closes when the command exits.
`command` runs through `sh -c` (so shell operators DO work here) but with the app's GUI `PATH` (no
`/opt/homebrew/bin`), so a bare Homebrew or other non-default binary fails with exit 127 — the overlay
flashes open then vanishes and `overlay result` reports 127; give an absolute path or wrap in
`"zsh -lc '…'"`.
- Full-size by default (hides the session); `--size-percent N` (1–100) makes it a floating framed panel
- with the session visible behind. **By default the overlay does NOT switch the active session** — full
+ Full-size by default (hides the session). Give it a *floating* framed panel — the session still visible
+ behind it — one of two ways: `--size-percent N` sizes it to N% of the pane in both dimensions, or
+ `--cols N --rows M` sizes it to an exact terminal grid (both required, each ≥ 1). Both are ADAPTIVE — a
+ `--cols/--rows` request larger than the pane is clamped down to the whole cells that fit, while a
+ `--size-percent` panel is a fraction of the pane and never exceeds it — and the grid that actually
+ rendered is read back on `tree` as `overlayColsApplied`/`overlayRowsApplied`. `--size-percent` must be
+ 1–100 — an out-of-range value is a hard error (it is no longer silently clamped on open).
+ `--anchor POS` places a floating panel at one of nine positions — `top-left`, `top`, `top-right`, `left`,
+ `center` (default), `right`, `bottom-left`, `bottom`, `bottom-right` — inside a uniform safe-area margin of
+ one line-height on ALL FOUR sides, so the panel is never flush with any border. The margin is symmetric and
+ independent of the anchor: a full-width band (`--cols $canvasCols`) is inset off the left and right exactly
+ like the top, and `center` keeps the panel dead-centered within that safe area.
+ `--anchor` requires a floating size (`--size-percent` or `--cols/--rows`) — on a full-pane overlay it is an
+ error. **By default the overlay does NOT switch the active session** — full
and floating both open on `--target` and run their program in the background, appearing when the user
visits that session. **Pass `--follow` to select the target after opening** (a no-op if it is already
active); use it when you want the user pulled to the overlay, omit it to open quietly. `--background-color #rrggbb` gives the overlay pane its own solid
@@ -446,12 +481,17 @@ All ten are read-only projections of GUI state.
own output file, not the control channel. Returns the overlay's session id. `--target` defaults to
`active`, so an automated caller should pass `--target "$AGTERM_SESSION_ID"` — otherwise a (usually
blocking, full-pane) overlay lands on whatever session is currently active, not the calling one.
-- `session overlay resize (--size-percent N | --full) [--target] [--window W]` — resize an ALREADY-OPEN
- overlay in place. Exactly one of `--size-percent N` (1–100, makes it a floating framed panel) or
- `--full` (switches it back to the full-pane overlay that hides the session) is required; passing both
- or neither, or a percent outside 1–100, is an error. The overlay program keeps running across the
- resize — it is a layout re-flow, never a re-spawn. Errors `no overlay` when none is open. Returns the
- session id.
+- `session overlay resize [--size-percent N | --cols N --rows M | --full] [--anchor POS] [--target] [--window W]`
+ — resize and/or re-anchor an ALREADY-OPEN overlay in place. Give at most one size mode —
+ `--size-percent N` (1–100) or `--cols N --rows M` (exact grid, clamped to fit) for a floating panel, or
+ `--full` to switch back to the full-pane overlay that hides the session — and/or `--anchor POS` to move a
+ floating panel to one of the nine positions. At least one of a size mode or `--anchor` is required (a bare
+ resize with neither is an error); `--anchor` alone re-anchors in place keeping the current size, and a size
+ mode alone keeps the current anchor. `--full` cannot be combined with `--anchor` (a full overlay is not
+ anchored), but a `--full` round-trip PRESERVES the anchor, so a later re-float returns to where it was.
+ Passing more than one size mode, a percent outside 1–100, or `--cols` without `--rows` (or vice versa), is
+ an error. The overlay program keeps running across the resize — it is a layout re-flow, never a re-spawn.
+ Errors `no overlay` when none is open. Returns the session id.
- `session overlay close [--target] [--window W]` — close (destroy) the overlay.
- `session overlay result [--target] [--window W]` — returns `result.exitCode` once the overlay has
closed. Errors `still running` while up, `no result` if none ran.
diff --git a/agterm/Views/WindowContentView.swift b/agterm/Views/WindowContentView.swift
index 5173de71..ca5d61a4 100644
--- a/agterm/Views/WindowContentView.swift
+++ b/agterm/Views/WindowContentView.swift
@@ -365,8 +365,8 @@ struct WindowContentView: View {
/// INSIDE them), so the NSSplitView never re-layouts on a zoom toggle and the divider stays put;
/// `SplitRatioAccessor` rides the primary wrapper as one persistent instance, suspended while zoomed.
@ViewBuilder private func sessionDetail(_ session: Session, isActive: Bool) -> some View {
- // a FULL overlay (no size) hides the session beneath it (opacity 0) and draws translucent; a
- // FLOATING overlay (overlaySizePercent set) leaves the session VISIBLE and draws a smaller
+ // a FULL overlay (`.full` size) hides the session beneath it (opacity 0) and draws translucent; a
+ // FLOATING overlay (a non-`.full` `overlaySize`) leaves the session VISIBLE and draws a smaller
// opaque framed panel on top. Either way the pane(s) stay non-interactive while an overlay is up.
let fullOverlay = session.fullOverlayActive
// While zoomed OR while the dashboard is open, the normal deck stays mounted only to realize
@@ -491,7 +491,7 @@ struct WindowContentView: View {
// closing, OR resizing an overlay never re-hosts the NSSplitView (the titlebar-overrun trigger)
// and never re-parents the surface (which would blank its Metal drawable). Full fills the area
// translucent with the pane(s) hidden by `hideForOverlay`; floating draws an opaque framed panel
- // over the still-visible pane(s). Switching full<->% (session.overlay.resize) only re-flows the frame.
+ // over the still-visible pane(s). Switching full<->floating (session.overlay.resize) only re-flows the frame.
overlayPanel(session: session, isActive: deckInteractive && isActive)
.zIndex(3)
}
@@ -523,29 +523,39 @@ struct WindowContentView: View {
/// ALWAYS-PRESENT sibling. The content is gated INSIDE the GeometryReader, so the ZStack's child count
/// never changes when an overlay opens/closes (constant shape = no NSSplitView re-host = no titlebar
/// overrun), and BOTH variants share this single surface host, so `session.overlay.resize` switching
- /// full<->% only re-flows the frame — it never re-parents the NSView (which would blank its Metal drawable).
- /// A nil `overlaySizePercent` fills the detail area translucent (no opaque backing/frame) with the pane(s)
- /// hidden by `hideForOverlay`; a percent draws an opaque, framed panel at that size, centered, with the
- /// pane(s) visible around it. Per-session in the eager deck, so the surface mounts + program runs even when
- /// the session isn't active.
+ /// full<->floating only re-flows the frame — it never re-parents the NSView (which would blank its Metal drawable).
+ /// A `.full` `overlaySize` fills the detail area translucent (no opaque backing/frame) with the pane(s)
+ /// hidden by `hideForOverlay`; a floating size (`.percent`/`.cells`) draws an opaque, framed panel sized and
+ /// placed by `OverlayLayout.panelRect` inside a uniform SAFE AREA — the pane inset by one line-height on all
+ /// four sides — anchored by `overlayAnchor` (nine positions, default center), with the pane(s) visible
+ /// around it. Only the frame size and the top-leading padding origin change across full/floating/anchor —
+ /// the ZStack alignment is a constant `.topLeading` and the child count is constant (no anchor-specific view
+ /// branches), so the NSSplitView is never re-hosted. Per-session in the eager deck, so the surface mounts +
+ /// program runs even when the session isn't active.
@ViewBuilder private func overlayPanel(session: Session, isActive: Bool) -> some View {
GeometryReader { geo in
- ZStack {
+ let floating = session.floatingOverlayActive
+ let paneSize = WindowGeometry.Size(width: Double(geo.size.width), height: Double(geo.size.height))
+ // ONE unified safe-area size+placement: a floating panel is sized then CLAMPED to the pane inset by
+ // a uniform one-line-height margin on ALL FOUR sides, and placed at its anchor WITHIN that safe area
+ // (so a full-width band is inset left/right exactly like the top, independent of the anchor); a full
+ // overlay fills the whole pane at origin (0,0) with no margin.
+ let rect = OverlayLayout.panelRect(session.overlaySize, pane: paneSize,
+ cell: session.overlayCellMetrics, anchor: session.overlayAnchor)
+ ZStack(alignment: .topLeading) {
if session.overlayActive, deckHostsSurface(session: session, surface: .overlay) {
- let floating = session.overlaySizePercent != nil
- let fraction = session.overlaySizePercent.map { CGFloat($0) / 100 } ?? 1
// transparent click-catcher over the whole detail area: absorbs clicks AROUND a floating
// panel so they can't reach the still-hit-testable panes and steal the overlay's first
// responder (the full variant hides the panes, so it's covered either way).
Color.clear.contentShape(Rectangle())
TerminalView(session: session, surfaceKeyPath: \.overlaySurface,
makeSurface: makeOverlaySurface, isActive: isActive, deckVisible: isActive && !quickTerminal.isVisible)
- .frame(width: geo.size.width * fraction, height: geo.size.height * fraction)
+ .frame(width: CGFloat(rect.size.width), height: CGFloat(rect.size.height))
// floating = opaque backing + hairline frame + shadow so it reads as a distinct window
// over the still-visible session; full = translucent, no chrome (libghostty draws only
// the terminal, so the window backing shows through). The modifier CHAIN stays constant
- // across both variants — only the parameters go inert for full — so a full<->% resize
- // keeps the same view tree and never re-hosts the surface NSView.
+ // across both variants — only the parameters go inert for full — so a full<->floating
+ // resize keeps the same view tree and never re-hosts the surface NSView.
.background(floating ? terminalColor : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: floating ? 12 : 0))
.overlay(
@@ -553,6 +563,26 @@ struct WindowContentView: View {
.strokeBorder(floating ? Color.white.opacity(0.18) : Color.clear, lineWidth: 1)
)
.shadow(radius: floating ? 24 : 0)
+ // the Metal-backed surface is not in the a11y tree, so expose the panel's frame via a
+ // zero-content overlay marker (the dashboard-cell pattern) that the e2e reads. As an
+ // .overlay it adds NO ZStack child, so the constant-child-count NSSplitView-overrun
+ // invariant holds; allowsHitTesting(false) keeps it off the overlay's own input. The
+ // floating panel carries `overlay-floating-panel`, the full one `overlay-full-panel`
+ // (its frame is the detail-area reference the e2e measures floating margins against) —
+ // the id string flips as a VALUE (no view branch), so the chain stays constant.
+ .overlay(
+ Color.clear
+ .allowsHitTesting(false)
+ .accessibilityElement()
+ .accessibilityIdentifier(floating ? "overlay-floating-panel" : "overlay-full-panel")
+ )
+ // place the panel at its safe-area origin: a constant top-leading ZStack + leading/top
+ // padding by the computed origin pushes the panel (AND its a11y marker, so the marker
+ // still reports the panel's own frame) in from the top-left. A values-only modifier (no
+ // anchor-specific view branch), so the ZStack child count never changes and the
+ // NSSplitView is never re-hosted (NSSplitView-overrun invariant).
+ .padding(EdgeInsets(top: CGFloat(rect.origin.y), leading: CGFloat(rect.origin.x),
+ bottom: 0, trailing: 0))
.id("\(session.id.uuidString)-overlay")
}
}
diff --git a/agterm/agtermApp.swift b/agterm/agtermApp.swift
index 840b9ba4..df594843 100644
--- a/agterm/agtermApp.swift
+++ b/agterm/agtermApp.swift
@@ -444,6 +444,16 @@ struct agtermApp: App {
// removes the session first, so this no-ops there — but the result is unqueryable after that anyway.
view.onExitCodeCaptured = { store.recordOverlayExit(sessionID, code: $0) }
view.onExit = { store.closeOverlay(sessionID) }
+ // the overlay is sessionless (no view.session), so route its live cell metrics + realized grid back
+ // onto the owning session here (in POINTS — the view already divided the pixel metrics by the
+ // backing scale). These drive the `.cells` panel layout and the `tree` applied-grid read-back.
+ // destroySurface nils this, breaking the store -> surface -> closure retain cycle.
+ view.onOverlayMetrics = { metrics, cols, rows in
+ guard let session = store.session(withID: sessionID) else { return }
+ session.overlayCellMetrics = metrics
+ session.overlayAppliedCols = cols
+ session.overlayAppliedRows = rows
+ }
// typing in the cover counts as user activity: reset the window's auto-follow idle timer so an
// idle fire can't change the underlying selection (vanishing the overlay) while you type in it.
// destroySurface nils this, breaking the store -> surface -> closure retain cycle.
diff --git a/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift
new file mode 100644
index 00000000..de4fb0ca
--- /dev/null
+++ b/agtermCore/Sources/agtermCore/AppStore+ControlTree.swift
@@ -0,0 +1,98 @@
+import Foundation
+
+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.
+ 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 },
+ canvasGrid: (Session) -> (cols: Int, rows: Int)? = { _ in nil },
+ quickVisible: () -> Bool? = { nil },
+ zoomedSurface: () -> String? = { nil },
+ dashboardMembers: () -> [String]? = { nil },
+ dashboardHighlighted: () -> String? = { nil },
+ dashboardFontSize: () -> Double? = { nil },
+ dashboardFontMode: () -> String? = { nil }) -> ControlTree {
+ let activeID = selectedSessionID
+ let activeWorkspaceID = activeID.flatMap { workspace(forSession: $0)?.id }
+ 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
+ // `overlaySizePercent` (back-compat wire field) derives from `overlaySize`: the percent for
+ // a `.percent` floating overlay, omitted for full or cells (and when no overlay is open).
+ let overlayPercent: Int? = if session.overlayActive,
+ case .percent(let percent) = session.overlaySize { percent } else { nil }
+ // the REQUESTED grid rides the node only for a cells-mode overlay (nil for percent/full/none).
+ let overlayCols: Int?
+ let overlayRows: Int?
+ if session.overlayActive, case .cells(let cols, let rows) = session.overlaySize {
+ overlayCols = cols
+ overlayRows = rows
+ } else {
+ overlayCols = nil
+ overlayRows = nil
+ }
+ // the REALIZED grid (any floating overlay) exposes a clamp/drift; the anchor rides ANY open
+ // overlay incl. full (Decision 2 — anchor is always reported while an overlay is up).
+ let overlayColsApplied = session.floatingOverlayActive ? session.overlayAppliedCols : nil
+ let overlayRowsApplied = session.floatingOverlayActive ? session.overlayAppliedRows : nil
+ let overlayAnchor = session.overlayActive ? session.overlayAnchor.rawValue : nil
+ // the terminal content area (overlay canvas) in cells at the base font — supplied app-side
+ // (the host-free tree can't read a surface), omitted when no surface is realized.
+ let canvas = canvasGrid(session)
+ 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,
+ splitRatio: session.hasSplit ? session.splitRatio : nil,
+ splitFocused: session.hasSplit ? session.splitFocused : nil,
+ overlay: session.overlayActive,
+ overlaySizePercent: overlayPercent,
+ overlayCols: overlayCols, overlayRows: overlayRows,
+ overlayColsApplied: overlayColsApplied,
+ overlayRowsApplied: overlayRowsApplied, overlayAnchor: overlayAnchor,
+ scratch: session.scratchActive, flagged: session.flagged,
+ commandWait: (session.initialCommand != nil && session.commandWait) ? true : nil,
+ foreground: foreground(session),
+ splitForeground: splitForeground(session),
+ // the PERSISTED overrides, never 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,
+ background: session.backgroundWatermark,
+ unseen: session.unseenCount > 0 ? session.unseenCount : nil,
+ fontSize: fontSize(session),
+ splitFontSize: splitFontSize(session),
+ scratchFontSize: scratchFontSize(session),
+ surfaces: surfaces,
+ canvasCols: canvas?.cols, canvasRows: canvas?.rows)
+ }
+ return ControlWorkspaceNode(id: workspace.id.uuidString, name: workspace.name,
+ active: workspace.id == activeWorkspaceID,
+ focused: workspace.id == focusedWorkspaceID ? true : nil,
+ collapsed: workspace.isExpanded ? nil : true,
+ sessions: sessions)
+ }
+ return ControlTree(workspaces: nodes, idleMs: idleMs(), autoFollowMs: autoFollowMs,
+ sidebarVisible: sidebarVisible, sidebarMode: sidebarMode.rawValue,
+ quickVisible: quickVisible(), zoomedSurface: zoomedSurface(),
+ dashboardMembers: dashboardMembers(),
+ dashboardHighlighted: dashboardHighlighted(),
+ dashboardFontSize: dashboardFontSize(),
+ dashboardFontMode: dashboardFontMode())
+ }
+}
diff --git a/agtermCore/Sources/agtermCore/AppStore+Panes.swift b/agtermCore/Sources/agtermCore/AppStore+Panes.swift
index 28dcbc82..77bb028a 100644
--- a/agtermCore/Sources/agtermCore/AppStore+Panes.swift
+++ b/agtermCore/Sources/agtermCore/AppStore+Panes.swift
@@ -1,5 +1,26 @@
import Foundation
+/// Options for opening a session overlay, grouped into a struct per the 4-plus-parameter rule. Host-free;
+/// the app-target arms and the internal editor-overlay callers build one and hand it to `openOverlay`.
+public struct OverlayOpenOptions: Sendable {
+ public let command: String
+ public let cwd: String?
+ public let wait: Bool
+ public let size: OverlaySize
+ public let anchor: OverlayAnchor
+ public let backgroundColor: String?
+
+ public init(command: String, cwd: String? = nil, wait: Bool = false, size: OverlaySize = .full,
+ anchor: OverlayAnchor = .center, backgroundColor: String? = nil) {
+ self.command = command
+ self.cwd = cwd
+ self.wait = wait
+ self.size = size
+ self.anchor = anchor
+ self.backgroundColor = backgroundColor
+ }
+}
+
// MARK: - Split, overlay, and scratch panes
extension AppStore {
@@ -167,42 +188,58 @@ extension AppStore {
closeSplit(sessionID)
}
- /// Opens an ephemeral overlay terminal on a session running `command` (e.g. a TUI). The overlay
- /// surface is created lazily by the detail pane and runs the command as its process; when the
- /// program exits, `closeOverlay` tears it down. No-op (returns false) when the session is unknown
- /// or already has an overlay open. NOT persisted — the overlay never survives a relaunch.
+ /// Opens an ephemeral overlay terminal on a session running `options.command` (e.g. a TUI). The overlay
+ /// surface is created lazily by the detail pane and runs the command as its process; when the program
+ /// exits, `closeOverlay` tears it down. No-op (returns false) when the session is unknown or already
+ /// has an overlay open. NOT persisted — the overlay never survives a relaunch.
///
- /// `sizePercent` (clamped to 1...100) requests a *floating* overlay: an opaque, framed panel sized
- /// to that percent of the pane, with the session still visible behind it. nil gives the default
- /// full-pane overlay that hides the session.
+ /// `options.size` chooses the layout: `.full` gives the default full-pane overlay that hides the
+ /// session; `.percent`/`.cells` request a *floating* overlay, an opaque framed panel with the session
+ /// still visible behind it, placed by `options.anchor`. A `.percent` is defensively clamped to 1...100
+ /// for internal callers; the dispatcher/CLI hard-error out-of-range values before reaching here.
///
- /// `backgroundColor` (`#rrggbb`) gives the overlay pane its own solid background, independent of the
- /// session's; nil leaves the default theme background. Read by the overlay surface factory at creation.
- @discardableResult public func openOverlay(_ sessionID: UUID, command: String, cwd: String? = nil,
- wait: Bool = false, sizePercent: Int? = nil,
- backgroundColor: String? = nil) -> Bool {
+ /// `options.backgroundColor` (`#rrggbb`) gives the overlay pane its own solid background, independent
+ /// of the session's; nil leaves the default theme background. Read by the surface factory at creation.
+ @discardableResult public func openOverlay(_ sessionID: UUID, options: OverlayOpenOptions) -> Bool {
guard let session = session(withID: sessionID), !session.overlayActive else { return false }
- session.overlayCommand = command
- session.overlayCwd = cwd
- session.overlayWait = wait
+ session.overlayCommand = options.command
+ session.overlayCwd = options.cwd
+ session.overlayWait = options.wait
session.overlayExitCode = nil
- session.overlaySizePercent = sizePercent.map { min(100, max(1, $0)) }
- session.overlayBackgroundColor = backgroundColor
+ session.overlaySize = AppStore.clampedOverlaySize(options.size)
+ session.overlayAnchor = options.anchor
+ session.overlayBackgroundColor = options.backgroundColor
session.overlayActive = true
return true
}
- /// Resizes an already-open overlay in place. `sizePercent` (clamped to 1...100) switches it to a
- /// *floating* opaque framed panel at that percent of the pane with the session visible behind it;
- /// nil switches it to the full-pane overlay that hides the session and draws translucent. The overlay
- /// surface stays mounted (the detail pane hosts both variants in one place), so this only re-flows the
- /// layout — the program keeps running, never re-spawns. No-op (returns false) with no overlay open.
- @discardableResult public func resizeOverlay(_ sessionID: UUID, sizePercent: Int?) -> Bool {
+ /// Resizes and/or re-anchors an already-open overlay in place. `size` switches the layout — `.full`
+ /// to the full-pane overlay that hides the session, `.percent`/`.cells` to a floating panel over the
+ /// still-visible session; `anchor` moves the floating panel. Either may be nil to KEEP the current
+ /// value, so a resize, an in-place re-anchor, or both are one call; a `.full` size keeps the anchor
+ /// (only `closeOverlay` resets it). The overlay surface stays mounted, so this only re-flows the layout
+ /// — the program keeps running, never re-spawns. No-op (returns false) with no overlay open.
+ @discardableResult public func resizeOverlay(_ sessionID: UUID, size: OverlaySize? = nil,
+ anchor: OverlayAnchor? = nil) -> Bool {
guard let session = session(withID: sessionID), session.overlayActive else { return false }
- session.overlaySizePercent = sizePercent.map { min(100, max(1, $0)) }
+ // keep the realized grid (`overlayAppliedCols/Rows`) as-is across a resize. When the new size DOES
+ // change the physical grid, the app re-reports it on the surface's settle-resize (eventually
+ // consistent). When it does NOT (e.g. two oversized cell requests clamping to the same grid), the
+ // old value is still the correct realized grid — and the app's last-value dedup would suppress any
+ // re-report, so niling it here would strand the read-back at nil forever.
+ if let size { session.overlaySize = AppStore.clampedOverlaySize(size) }
+ if let anchor { session.overlayAnchor = anchor }
return true
}
+ /// Clamps a `.percent` overlay size into 1...100 — the defensive internal clamp retained from the old
+ /// `overlaySizePercent` setter, so a raw internal caller can't set an out-of-range percent. `.full` and
+ /// `.cells` pass through unchanged (their bounds are enforced at the dispatcher/CLI boundary).
+ static func clampedOverlaySize(_ size: OverlaySize) -> OverlaySize {
+ guard case .percent(let percent) = size else { return size }
+ return .percent(min(100, max(1, percent)))
+ }
+
/// Records the overlay program's exit status (parsed app-side from the wrapper's temp file on the
/// surface's teardown) so `session.overlay.result` can report it after the overlay closes. No-op
/// for an unknown session.
@@ -221,7 +258,11 @@ extension AppStore {
session.overlayCommand = nil
session.overlayCwd = nil
session.overlayWait = false
- session.overlaySizePercent = nil
+ session.overlaySize = .full
+ session.overlayAnchor = .center
+ session.overlayCellMetrics = nil
+ session.overlayAppliedCols = nil
+ session.overlayAppliedRows = nil
session.overlayBackgroundColor = nil
return true
}
diff --git a/agtermCore/Sources/agtermCore/AppStore.swift b/agtermCore/Sources/agtermCore/AppStore.swift
index b718bc03..6d8a1046 100644
--- a/agtermCore/Sources/agtermCore/AppStore.swift
+++ b/agtermCore/Sources/agtermCore/AppStore.swift
@@ -178,74 +178,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 },
- dashboardMembers: () -> [String]? = { nil },
- dashboardHighlighted: () -> String? = { nil },
- dashboardFontSize: () -> Double? = { nil },
- dashboardFontMode: () -> String? = { nil }) -> ControlTree {
- let activeID = selectedSessionID
- let activeWorkspaceID = activeID.flatMap { workspace(forSession: $0)?.id }
- 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,
- splitRatio: session.hasSplit ? session.splitRatio : nil,
- splitFocused: session.hasSplit ? session.splitFocused : nil,
- overlay: session.overlayActive,
- overlaySizePercent: session.overlayActive ? session.overlaySizePercent : nil,
- scratch: session.scratchActive, flagged: session.flagged,
- commandWait: (session.initialCommand != nil && session.commandWait) ? true : nil,
- foreground: foreground(session),
- splitForeground: splitForeground(session),
- // the PERSISTED overrides, never 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,
- background: session.backgroundWatermark,
- unseen: session.unseenCount > 0 ? session.unseenCount : nil,
- fontSize: fontSize(session),
- splitFontSize: splitFontSize(session),
- scratchFontSize: scratchFontSize(session),
- surfaces: surfaces)
- }
- return ControlWorkspaceNode(id: workspace.id.uuidString, name: workspace.name,
- active: workspace.id == activeWorkspaceID,
- focused: workspace.id == focusedWorkspaceID ? true : nil,
- collapsed: workspace.isExpanded ? nil : true,
- sessions: sessions)
- }
- return ControlTree(workspaces: nodes, idleMs: idleMs(), autoFollowMs: autoFollowMs,
- sidebarVisible: sidebarVisible, sidebarMode: sidebarMode.rawValue,
- quickVisible: quickVisible(), zoomedSurface: zoomedSurface(),
- dashboardMembers: dashboardMembers(),
- dashboardHighlighted: dashboardHighlighted(),
- dashboardFontSize: dashboardFontSize(),
- dashboardFontMode: dashboardFontMode())
- }
-
/// Creates a workspace and appends it. When `clearFocus` (the default) it clears any active focus so
/// the new (empty) workspace is immediately visible — else `visibleWorkspaces` returns only the
/// focused one and the new workspace is silently hidden (the auto-reveal contract, like `addSession`).
diff --git a/agtermCore/Sources/agtermCore/ControlDispatcher.swift b/agtermCore/Sources/agtermCore/ControlDispatcher.swift
index 154ba1ec..6eaec317 100644
--- a/agtermCore/Sources/agtermCore/ControlDispatcher.swift
+++ b/agtermCore/Sources/agtermCore/ControlDispatcher.swift
@@ -62,7 +62,10 @@ public protocol ControlActions {
func openSessionOverlay(_ target: String?, window: String?,
options: ControlSessionOverlayOpenOptions) -> ControlResponse
func closeSessionOverlay(_ target: String?, window: String?) -> ControlResponse
- func resizeSessionOverlay(_ target: String?, window: String?, sizePercent: Int?) -> ControlResponse
+ /// Resize/re-anchor an open overlay. `size` nil keeps the current size, `anchor` nil keeps the current
+ /// anchor; the dispatcher guarantees at least one is non-nil.
+ func resizeSessionOverlay(_ target: String?, window: String?,
+ size: OverlaySize?, anchor: OverlayAnchor?) -> ControlResponse
func sessionOverlayResult(_ target: String?, window: String?) -> ControlResponse
func setSessionBackground(_ target: String?, window: String?,
options: ControlSessionBackgroundOptions) -> ControlResponse
@@ -96,16 +99,18 @@ public struct ControlSessionOverlayOpenOptions: Equatable, Sendable {
public let command: String
public let cwd: String?
public let wait: Bool
- public let sizePercent: Int?
+ public let size: OverlaySize
+ public let anchor: OverlayAnchor
public let backgroundColor: String?
public let follow: Bool
- public init(command: String, cwd: String?, wait: Bool, sizePercent: Int?, backgroundColor: String?,
- follow: Bool = false) {
+ public init(command: String, cwd: String?, wait: Bool, size: OverlaySize = .full,
+ anchor: OverlayAnchor = .center, backgroundColor: String?, follow: Bool = false) {
self.command = command
self.cwd = cwd
self.wait = wait
- self.sizePercent = sizePercent
+ self.size = size
+ self.anchor = anchor
self.backgroundColor = backgroundColor
self.follow = follow
}
@@ -487,37 +492,11 @@ public struct ControlDispatcher {
return await actions.searchSession(request.target, window: request.args?.window,
text: request.args?.text, to: request.args?.to)
case .sessionOverlayOpen:
- guard let command = request.args?.command, !command.isEmpty else {
- return ControlResponse(ok: false, error: "session.overlay.open requires a command")
- }
- if let color = request.args?.color, !WatermarkConfig.isValidColorHex(color) {
- return ControlResponse(ok: false, error: "invalid color: \(color) (#rrggbb)")
- }
- return actions.openSessionOverlay(request.target, window: request.args?.window,
- options: ControlSessionOverlayOpenOptions(
- command: command,
- cwd: request.args?.cwd,
- wait: request.args?.wait ?? false,
- sizePercent: request.args?.sizePercent,
- backgroundColor: request.args?.color,
- follow: request.args?.follow ?? false
- ))
+ return dispatchSessionOverlayOpen(request)
case .sessionOverlayClose:
return actions.closeSessionOverlay(request.target, window: request.args?.window)
case .sessionOverlayResize:
- let wantsFull = request.args?.full == true
- let percent = request.args?.sizePercent
- if wantsFull, percent != nil {
- return ControlResponse(ok: false, error: "session.overlay.resize: --full is mutually exclusive with --size-percent")
- }
- if !wantsFull, percent == nil {
- return ControlResponse(ok: false, error: "session.overlay.resize requires --size-percent or --full")
- }
- if let percent, !(1...100).contains(percent) {
- return ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100")
- }
- return actions.resizeSessionOverlay(request.target, window: request.args?.window,
- sizePercent: wantsFull ? nil : percent)
+ return dispatchSessionOverlayResize(request)
case .sessionOverlayResult:
return actions.sessionOverlayResult(request.target, window: request.args?.window)
case .sessionBackground:
@@ -529,6 +508,53 @@ public struct ControlDispatcher {
}
}
+ /// `session.overlay.open`: build the overlay open options. Absence of any size arg means a full-pane
+ /// overlay (open has no `--full`); an `--anchor` is only meaningful for a floating overlay.
+ private func dispatchSessionOverlayOpen(_ request: ControlRequest) -> ControlResponse {
+ let args = request.args
+ guard let command = args?.command, !command.isEmpty else {
+ return ControlResponse(ok: false, error: "session.overlay.open requires a command")
+ }
+ if let color = args?.color, !WatermarkConfig.isValidColorHex(color) {
+ return ControlResponse(ok: false, error: "invalid color: \(color) (#rrggbb)")
+ }
+ let size: OverlaySize
+ let anchor: OverlayAnchor
+ switch OverlayArgs.resolveOpen(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows,
+ anchor: args?.anchor) {
+ case .invalid(let message): return ControlResponse(ok: false, error: message)
+ case .resolved(let parsedSize, let parsedAnchor):
+ size = parsedSize
+ anchor = parsedAnchor
+ }
+ return actions.openSessionOverlay(request.target, window: args?.window,
+ options: ControlSessionOverlayOpenOptions(
+ command: command,
+ cwd: args?.cwd,
+ wait: args?.wait ?? false,
+ size: size,
+ anchor: anchor,
+ backgroundColor: args?.color,
+ follow: args?.follow ?? false
+ ))
+ }
+
+ /// `session.overlay.resize`: a nil `size`/`anchor` keeps the current one, so at least one must be set;
+ /// `--full` reverts to the full-pane overlay and cannot carry an anchor.
+ private func dispatchSessionOverlayResize(_ request: ControlRequest) -> ControlResponse {
+ let args = request.args
+ let size: OverlaySize?
+ let anchor: OverlayAnchor?
+ switch OverlayArgs.resolveResize(sizePercent: args?.sizePercent, cols: args?.cols, rows: args?.rows,
+ full: args?.full ?? false, anchor: args?.anchor) {
+ case .invalid(let message): return ControlResponse(ok: false, error: message)
+ case .resolved(let parsedSize, let parsedAnchor):
+ size = parsedSize
+ anchor = parsedAnchor
+ }
+ return actions.resizeSessionOverlay(request.target, window: args?.window, size: size, anchor: anchor)
+ }
+
private func dispatchAppCommand(_ request: ControlRequest) -> ControlResponse {
switch request.cmd {
case .fontInc:
diff --git a/agtermCore/Sources/agtermCore/ControlProtocol.swift b/agtermCore/Sources/agtermCore/ControlProtocol.swift
index 68deb7ad..567c40cd 100644
--- a/agtermCore/Sources/agtermCore/ControlProtocol.swift
+++ b/agtermCore/Sources/agtermCore/ControlProtocol.swift
@@ -194,11 +194,21 @@ public struct ControlArgs: Codable, Sendable, Equatable {
public var wait: Bool?
/// For `session.overlay.open`, the percent of the pane (1...100) a *floating* overlay panel
/// occupies in both dimensions; omitted gives the default full-pane overlay. Also carries the new
- /// size for `session.overlay.resize` (mutually exclusive with `full`).
+ /// size for `session.overlay.resize`. Mutually exclusive with `cols`/`rows` and (on resize) `full`.
public var sizePercent: Int?
/// For `session.overlay.resize`, requests the full-pane (translucent, session-hidden) overlay —
- /// the way to switch a floating overlay back to full. Mutually exclusive with `sizePercent`.
+ /// the way to switch a floating overlay back to full. Mutually exclusive with `sizePercent`/`cols`/`rows`,
+ /// and cannot be combined with `anchor` (a full overlay is not anchored).
public var full: Bool?
+ /// For `session.overlay.open`/`.resize`, the exact terminal grid a *floating* overlay panel sizes to
+ /// via the surface's live cell metrics: `cols` columns × `rows` rows, both or neither, mutually
+ /// exclusive with `sizePercent`/`full`. The panel is clamped to whole cells that fit the pane.
+ public var cols: Int?
+ public var rows: Int?
+ /// For `session.overlay.open`/`.resize`, which of nine positions a *floating* overlay panel anchors to
+ /// within the pane (`OverlayAnchor` raw value, e.g. `top-left`/`center`/`bottom-right`); omitted =
+ /// `center` (today's behavior).
+ public var anchor: String?
/// For `session.overlay.open`, whether to select/switch to the target after opening; omitted/false
/// opens in the background without changing the active session (the default for both full and
/// floating overlays).
@@ -252,6 +262,7 @@ public struct ControlArgs: Codable, Sendable, Equatable {
createWorkspace: Bool? = nil, collapsed: Bool? = nil, noSelect: Bool? = nil,
text: String? = nil, select: Bool? = nil, mode: String? = nil,
command: String? = nil, wait: Bool? = nil, sizePercent: Int? = nil, full: Bool? = nil,
+ cols: Int? = nil, rows: Int? = nil, anchor: String? = nil,
follow: Bool? = nil, window: String? = nil,
pane: String? = nil, paneID: String? = nil, to: String? = nil,
after: String? = nil, before: String? = nil, run: String? = nil,
@@ -279,6 +290,9 @@ public struct ControlArgs: Codable, Sendable, Equatable {
self.wait = wait
self.sizePercent = sizePercent
self.full = full
+ self.cols = cols
+ self.rows = rows
+ self.anchor = anchor
self.follow = follow
self.window = window
self.pane = pane
@@ -381,6 +395,21 @@ public struct ControlSessionNode: Codable, Sendable, Equatable {
/// floating panel's percent of the pane (1...100). Absent when no overlay is open. The read side of
/// `session.overlay.resize` — record the current size before resizing so a script can restore it exactly.
public let overlaySizePercent: Int?
+ /// For a floating overlay opened in CELLS mode, the REQUESTED grid (`session.overlay.open`/`.resize
+ /// --cols N --rows M`); nil/omitted for a percent overlay, a full overlay, or no overlay. The restore
+ /// key of a cells-mode overlay — record it before resizing, restore it exactly afterwards.
+ public let overlayCols: Int?
+ public let overlayRows: Int?
+ /// The REALIZED grid an OPEN floating overlay actually rendered after whole-cell clamping — set for any
+ /// floating overlay (percent or cells), nil/omitted for a full overlay, no overlay, or before the
+ /// surface reports. Distinct from the requested `overlayCols`/`overlayRows`, so a script can detect a
+ /// clamp (an oversized cols/rows request comes back smaller here).
+ public let overlayColsApplied: Int?
+ public let overlayRowsApplied: Int?
+ /// For any OPEN overlay (floating OR full), which of nine positions the panel anchors to
+ /// (`OverlayAnchor` raw value); nil/omitted when no overlay is open. The read side of the overlay
+ /// `--anchor` — preserved across a `--full` round-trip, so it is reported even for a full overlay.
+ public let overlayAnchor: String?
public let scratch: Bool
public let flagged: Bool
/// For a `--command` session, whether it was created to HOLD its surface after the command exits
@@ -439,17 +468,39 @@ public struct ControlSessionNode: Codable, Sendable, Equatable {
/// addition uses, keeping Codable synthesized). Hidden-but-alive surfaces are included so control
/// clients can zoom them without mutating split/scratch visibility first.
public let surfaces: [ControlSurfaceNode]?
+ /// The session's terminal CONTENT AREA (the overlay "canvas") in whole cells at the session's BASE font
+ /// — the font a fresh `session.overlay.open` uses — so a script can size a floating overlay as a
+ /// fraction of the canvas: `--rows round(0.30 * canvasRows)` is a 30%-tall strip. It reports the
+ /// coordinate system `session.overlay.open --cols/--rows` land in, taken as ONE area and SPLIT-AGNOSTIC:
+ /// `canvasRows` is the full height in cells (a left/right split keeps full height) and `canvasCols` is the
+ /// full width across the whole detail area — the primary pane's columns when NOT split (exact), and for a
+ /// side-by-side split the whole detail width measured from BOTH panes' BACKING-PIXEL widths at ONE cell
+ /// size (the primary pane's), floored ONCE — so the per-pane inner padding IS counted and only the thin
+ /// divider (a fraction of a cell) is uncounted, slightly UNDERESTIMATING a single full-width surface
+ /// (best-effort — it never over-reports). A floating overlay does NOT fill the canvas edge-to-edge: every
+ /// floating overlay is inset by a uniform one-line-height SAFE-AREA margin on all four sides, so a request
+ /// as large as the canvas (`--cols canvasCols`) CLAMPS to the usable area (canvas minus the margins). Read
+ /// the realized grid back on `overlayColsApplied`/`overlayRowsApplied` for the exact result, or request
+ /// within (canvas − margin) for an exact fit. nil/omitted when no surface is realized / the grid is
+ /// unknown. Measured app-side from the live `ghostty_surface_size()` grid (unitless cell counts — no
+ /// Retina conversion), which tracks the main pane's cmd-+/- zoom exactly as the base overlay font does.
+ public let canvasCols: Int?
+ public let canvasRows: Int?
public init(id: String, name: String, cwd: String, title: String? = nil, active: Bool, split: Bool,
splitRatio: Double? = nil, splitFocused: Bool? = nil,
- overlay: Bool = false, overlaySizePercent: Int? = nil, scratch: Bool = false, flagged: Bool = false,
+ overlay: Bool = false, overlaySizePercent: Int? = nil,
+ overlayCols: Int? = nil, overlayRows: Int? = nil,
+ overlayColsApplied: Int? = nil, overlayRowsApplied: Int? = nil, overlayAnchor: String? = 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,
background: BackgroundWatermark? = nil, unseen: Int? = nil,
fontSize: Double? = nil, splitFontSize: Double? = nil, scratchFontSize: Double? = nil,
- surfaces: [ControlSurfaceNode]? = nil) {
+ surfaces: [ControlSurfaceNode]? = nil,
+ canvasCols: Int? = nil, canvasRows: Int? = nil) {
self.id = id
self.name = name
self.cwd = cwd
@@ -460,6 +511,11 @@ public struct ControlSessionNode: Codable, Sendable, Equatable {
self.splitFocused = splitFocused
self.overlay = overlay
self.overlaySizePercent = overlaySizePercent
+ self.overlayCols = overlayCols
+ self.overlayRows = overlayRows
+ self.overlayColsApplied = overlayColsApplied
+ self.overlayRowsApplied = overlayRowsApplied
+ self.overlayAnchor = overlayAnchor
self.scratch = scratch
self.flagged = flagged
self.commandWait = commandWait
@@ -477,6 +533,8 @@ public struct ControlSessionNode: Codable, Sendable, Equatable {
self.splitFontSize = splitFontSize
self.scratchFontSize = scratchFontSize
self.surfaces = surfaces
+ self.canvasCols = canvasCols
+ self.canvasRows = canvasRows
}
}
diff --git a/agtermCore/Sources/agtermCore/OverlayArgs.swift b/agtermCore/Sources/agtermCore/OverlayArgs.swift
new file mode 100644
index 00000000..a44da0f1
--- /dev/null
+++ b/agtermCore/Sources/agtermCore/OverlayArgs.swift
@@ -0,0 +1,139 @@
+import Foundation
+
+/// The outcome of validating the overlay SIZE args (`--size-percent` / `--cols` / `--rows`, and `--full`
+/// on resize): a resolved size mode, no size arg present (open → full, resize → keep current), or a
+/// validation failure carrying the canonical error string.
+public enum OverlaySizeParse: Equatable, Sendable {
+ case size(OverlaySize)
+ case unspecified
+ case invalid(String)
+}
+
+/// The outcome of parsing the `--anchor` selector: the parsed anchor, no `--anchor` present, or a
+/// validation failure carrying the canonical error string.
+public enum OverlayAnchorParse: Equatable, Sendable {
+ case anchor(OverlayAnchor)
+ case absent
+ case invalid(String)
+}
+
+/// The outcome of resolving the combined size + anchor for `session.overlay.open`: a concrete size (no
+/// size arg → `.full`) and anchor (no `--anchor` → `.center`), or a validation failure carrying the
+/// canonical error string.
+public enum OverlayOpenResolution: Equatable, Sendable {
+ case resolved(size: OverlaySize, anchor: OverlayAnchor)
+ case invalid(String)
+}
+
+/// The outcome of resolving the combined size + anchor for `session.overlay.resize`: an optional size (nil
+/// = keep the current size) and optional anchor (nil = keep the current anchor), or a validation failure
+/// carrying the canonical error string.
+public enum OverlayResizeResolution: Equatable, Sendable {
+ case resolved(size: OverlaySize?, anchor: OverlayAnchor?)
+ case invalid(String)
+}
+
+/// Host-free validation of the `session.overlay.open`/`.resize` size + anchor args, the SINGLE source of
+/// the one-of / range / pairing rules and their error strings. Both the control dispatcher and the
+/// `agtermctl` CLI resolve the args through `resolveOpen`/`resolveResize` and map an `.invalid` result to
+/// their own error type (`ControlResponse` vs `ValidationError`), so the two surfaces cannot drift.
+public enum OverlayArgs {
+ /// Which overlay verb is validating: carries the error-message prefix and whether `--full` is a valid
+ /// size mode (resize only).
+ public enum Command: Sendable {
+ case open, resize
+ var name: String { self == .open ? "session.overlay.open" : "session.overlay.resize" }
+ var allowsFull: Bool { self == .resize }
+ }
+
+ /// Resolves the `session.overlay.open` size + anchor: absence of any size arg means a full-pane overlay
+ /// (open has no `--full`) and absence of `--anchor` means `.center`. An `--anchor` is only meaningful
+ /// for a floating overlay, so an anchor over a full-pane open is rejected.
+ public static func resolveOpen(sizePercent: Int?, cols: Int?, rows: Int?,
+ anchor rawAnchor: String?) -> OverlayOpenResolution {
+ let size: OverlaySize
+ switch parseSize(sizePercent: sizePercent, cols: cols, rows: rows, full: false, command: .open) {
+ case .invalid(let message): return .invalid(message)
+ case .unspecified: size = .full
+ case .size(let parsed): size = parsed
+ }
+ switch parseAnchor(rawAnchor) {
+ case .invalid(let message): return .invalid(message)
+ case .absent: return .resolved(size: size, anchor: .center)
+ case .anchor(let parsed):
+ guard size != .full else {
+ return .invalid("--anchor requires a floating overlay: use --size-percent or --cols/--rows")
+ }
+ return .resolved(size: size, anchor: parsed)
+ }
+ }
+
+ /// Resolves the `session.overlay.resize` size + anchor: a nil size keeps the current size and a nil
+ /// anchor keeps the current anchor, so at least one of {a size mode, `--anchor`} must be set. `--full`
+ /// reverts to the full-pane overlay and cannot carry an anchor.
+ public static func resolveResize(sizePercent: Int?, cols: Int?, rows: Int?, full: Bool,
+ anchor rawAnchor: String?) -> OverlayResizeResolution {
+ let size: OverlaySize?
+ switch parseSize(sizePercent: sizePercent, cols: cols, rows: rows, full: full, command: .resize) {
+ case .invalid(let message): return .invalid(message)
+ case .unspecified: size = nil
+ case .size(let parsed): size = parsed
+ }
+ let anchor: OverlayAnchor?
+ switch parseAnchor(rawAnchor) {
+ case .invalid(let message): return .invalid(message)
+ case .absent: anchor = nil
+ case .anchor(let parsed): anchor = parsed
+ }
+ if case .some(.full) = size, anchor != nil {
+ return .invalid("--full cannot be combined with --anchor")
+ }
+ if size == nil, anchor == nil {
+ return .invalid("session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor")
+ }
+ return .resolved(size: size, anchor: anchor)
+ }
+
+ /// Validates the size args and resolves the `OverlaySize`. At most one size mode may be set; a percent
+ /// is a hard `1...100` error (Decision 3); `cols`/`rows` are both-or-neither and each `>= 1`. `command`
+ /// prefixes the per-command errors and gates the resize-only `--full` mode (open has no `--full`, so a
+ /// stray `full` is ignored there).
+ public static func parseSize(sizePercent: Int?, cols: Int?, rows: Int?, full: Bool,
+ command: Command) -> OverlaySizeParse {
+ let fullMode = command.allowsFull && full
+ let hasCells = cols != nil || rows != nil
+ let modeCount = (fullMode ? 1 : 0) + (sizePercent != nil ? 1 : 0) + (hasCells ? 1 : 0)
+ if modeCount > 1 {
+ let modes = command.allowsFull ? "--full, --size-percent, or --cols/--rows" : "--size-percent or --cols/--rows"
+ return .invalid("\(command.name): use only one of \(modes)")
+ }
+ if fullMode { return .size(.full) }
+ if let sizePercent {
+ guard (1...100).contains(sizePercent) else {
+ return .invalid("\(command.name): --size-percent must be 1...100")
+ }
+ return .size(.percent(sizePercent))
+ }
+ if hasCells {
+ guard let cols, let rows else {
+ return .invalid("\(command.name): provide both --cols and --rows")
+ }
+ guard cols >= 1, rows >= 1 else {
+ return .invalid("\(command.name): --cols and --rows must be >= 1")
+ }
+ return .size(.cells(cols: cols, rows: rows))
+ }
+ return .unspecified
+ }
+
+ /// Parses `--anchor` to an `OverlayAnchor`: `.absent` when omitted, `.anchor` when valid, or `.invalid`
+ /// with the `unknown anchor` message listing the nine positions.
+ public static func parseAnchor(_ raw: String?) -> OverlayAnchorParse {
+ guard let raw else { return .absent }
+ guard let parsed = OverlayAnchor(rawValue: raw) else {
+ let valid = OverlayAnchor.allCases.map(\.rawValue).joined(separator: "|")
+ return .invalid("unknown anchor: \(raw) (\(valid))")
+ }
+ return .anchor(parsed)
+ }
+}
diff --git a/agtermCore/Sources/agtermCore/OverlayLayout.swift b/agtermCore/Sources/agtermCore/OverlayLayout.swift
new file mode 100644
index 00000000..0ddabea6
--- /dev/null
+++ b/agtermCore/Sources/agtermCore/OverlayLayout.swift
@@ -0,0 +1,151 @@
+import Foundation
+
+/// The requested size mode for a session overlay panel. Host-free; the app resolves it to a concrete
+/// panel size via `OverlayLayout.panelSize`. Replaces the stored `Session.overlaySizePercent: Int?`.
+public enum OverlaySize: Equatable, Sendable {
+ /// Fill the whole pane.
+ case full
+ /// A uniform fraction of the pane in both dimensions; the caller pre-validates `1...100`.
+ case percent(Int)
+ /// An exact terminal grid; the caller pre-validates `cols >= 1, rows >= 1`.
+ case cells(cols: Int, rows: Int)
+}
+
+/// One of nine positions a floating overlay panel anchors to within its pane. `unitX`/`unitY` express the
+/// anchor as unit coordinates — 0 (leading/top), 0.5 (center), 1 (trailing/bottom) — which the app maps
+/// to a SwiftUI `Alignment`. The default (`.center`) reproduces today's centered placement.
+public enum OverlayAnchor: String, CaseIterable, Sendable {
+ case topLeft = "top-left"
+ case top
+ case topRight = "top-right"
+ case left
+ case center
+ case right
+ case bottomLeft = "bottom-left"
+ case bottom
+ case bottomRight = "bottom-right"
+
+ /// Horizontal unit position: 0 (leading), 0.5 (center), or 1 (trailing).
+ public var unitX: Double {
+ switch self {
+ case .topLeft, .left, .bottomLeft: return 0
+ case .top, .center, .bottom: return 0.5
+ case .topRight, .right, .bottomRight: return 1
+ }
+ }
+
+ /// Vertical unit position: 0 (top), 0.5 (center), or 1 (bottom).
+ public var unitY: Double {
+ switch self {
+ case .topLeft, .top, .topRight: return 0
+ case .left, .center, .right: return 0.5
+ case .bottomLeft, .bottom, .bottomRight: return 1
+ }
+ }
+}
+
+/// Live cell + padding metrics for an overlay surface, in POINTS (not backing pixels). The app reads the
+/// pixel metrics from `ghostty_surface_size()` and divides by the window's backing scale before
+/// constructing this, so the host-free resolver works in the same point space as the SwiftUI pane size.
+/// `padWidth`/`padHeight` are the total non-cell remainder (an estimate, per the padding-drift note).
+public struct OverlayCellMetrics: Equatable, Sendable {
+ public let cellWidth: Double
+ public let cellHeight: Double
+ public let padWidth: Double
+ public let padHeight: Double
+
+ public init(cellWidth: Double, cellHeight: Double, padWidth: Double, padHeight: Double) {
+ self.cellWidth = cellWidth
+ self.cellHeight = cellHeight
+ self.padWidth = padWidth
+ self.padHeight = padHeight
+ }
+
+ /// Whether the metrics can drive a cells-mode layout — both cell dimensions must be positive.
+ public var isUsable: Bool { cellWidth > 0 && cellHeight > 0 }
+}
+
+/// Pure resolver turning an `OverlaySize` request into a concrete panel rect within a pane. Host-free and
+/// unit-tested; the app applies the result as a SwiftUI frame + placement. All sizes are in points.
+public enum OverlayLayout {
+ /// Resolves `size` against `pane` and (for cells mode) the live `cell` metrics.
+ ///
+ /// - `.full` returns the full pane.
+ /// - `.percent(p)` returns `pane * p/100` (always <= pane for a valid `1...100` percent).
+ /// - `.cells(cols, rows)` snaps each dimension to whole cells that fit the pane: at least one cell, at
+ /// most the whole cells the pane holds after padding, and never larger than the pane. With nil or
+ /// unusable `cell` metrics it falls back to the full pane.
+ public static func panelSize(_ size: OverlaySize, pane: WindowGeometry.Size,
+ cell: OverlayCellMetrics?) -> WindowGeometry.Size {
+ switch size {
+ case .full:
+ return pane
+ case .percent(let percent):
+ let fraction = Double(percent) / 100
+ return WindowGeometry.Size(width: pane.width * fraction, height: pane.height * fraction)
+ case .cells(let cols, let rows):
+ guard let cell, cell.isUsable else { return pane }
+ let width = cellExtent(count: cols, cellSize: cell.cellWidth, pad: cell.padWidth, available: pane.width)
+ let height = cellExtent(count: rows, cellSize: cell.cellHeight, pad: cell.padHeight, available: pane.height)
+ return WindowGeometry.Size(width: width, height: height)
+ }
+ }
+
+ /// Snaps a requested cell count to a whole-cell extent that fits `available`: floors at one cell, caps
+ /// at the whole cells that fit after `pad`, and never exceeds `available` (so a pane smaller than one
+ /// cell + padding still yields a panel no larger than the pane).
+ private static func cellExtent(count: Int, cellSize: Double, pad: Double, available: Double) -> Double {
+ let fitCount = Int(((available - pad) / cellSize).rounded(.down))
+ let usedCount = Swift.max(1, Swift.min(count, fitCount))
+ return Swift.min(Double(usedCount) * cellSize + pad, available)
+ }
+
+ /// The concrete panel RECT (origin + size, in points) for `size` within `pane`, anchored by `anchor`
+ /// inside a uniform SAFE AREA — the pane inset by a base-level margin `m` on ALL FOUR sides. `m` is one
+ /// line-height (`cell.cellHeight`) when the cell metrics are usable, else 0. The margin is a DEFAULT on
+ /// every side, symmetric and independent of the anchor: a full-usable-size band carries an equal margin
+ /// on every edge (a full-width top band is inset left/right exactly like the top).
+ ///
+ /// - `.full` fills the whole pane at origin `(0, 0)` with NO margin (the translucent session-hiding cover).
+ /// - a FLOATING size (`.percent`/`.cells`) is sized by `panelSize`, then CLAMPED to the safe area per axis
+ /// (`panelW = min(requestedW, paneW - 2m)`, same for height), and PLACED at the anchor's unit position
+ /// within the safe area (`originX = m + anchor.unitX * (usableW - panelW)`, same for y) — so the panel is
+ /// never closer than `m` to any edge, a full-usable-size panel fills the safe area with an `m` margin all
+ /// around, and the anchor positions a smaller panel within it.
+ public static func panelRect(_ size: OverlaySize, pane: WindowGeometry.Size,
+ cell: OverlayCellMetrics?, anchor: OverlayAnchor) -> WindowGeometry.Rect {
+ if case .full = size {
+ return WindowGeometry.Rect(origin: WindowGeometry.Point(x: 0, y: 0), size: pane)
+ }
+ let margin: Double
+ if let cell, cell.isUsable {
+ margin = cell.cellHeight
+ } else {
+ margin = 0
+ }
+ let usableWidth = Swift.max(0, pane.width - 2 * margin)
+ let usableHeight = Swift.max(0, pane.height - 2 * margin)
+ let requested = panelSize(size, pane: pane, cell: cell)
+ let width = Swift.min(requested.width, usableWidth)
+ let height = Swift.min(requested.height, usableHeight)
+ let originX = margin + anchor.unitX * (usableWidth - width)
+ let originY = margin + anchor.unitY * (usableHeight - height)
+ return WindowGeometry.Rect(origin: WindowGeometry.Point(x: originX, y: originY),
+ size: WindowGeometry.Size(width: width, height: height))
+ }
+
+ /// Whole-cell column count spanning a side-by-side split's full detail WIDTH, measured from the two
+ /// panes' backing-pixel widths at ONE cell size (the primary pane's) and floored ONCE. This is the
+ /// true single full-detail grid a floating overlay's `--cols` fills — it avoids the double-floor and the
+ /// mixed-font error of summing each pane's already-floored column count (the panes can carry different
+ /// live font sizes). The thin divider between the panes is uncounted, so it underestimates the whole
+ /// detail width by a fraction of a cell. Unitless (px / px), so no Retina conversion. nil when the
+ /// primary cell width or the total width is non-positive.
+ public static func splitCanvasCols(primaryWidthPx: Double, splitWidthPx: Double,
+ primaryCellWidthPx: Double) -> Int? {
+ guard primaryCellWidthPx > 0 else { return nil }
+ let totalWidthPx = primaryWidthPx + splitWidthPx
+ guard totalWidthPx > 0 else { return nil }
+ return Int((totalWidthPx / primaryCellWidthPx).rounded(.down))
+ }
+}
diff --git a/agtermCore/Sources/agtermCore/Session.swift b/agtermCore/Sources/agtermCore/Session.swift
index dc000d06..dc147733 100644
--- a/agtermCore/Sources/agtermCore/Session.swift
+++ b/agtermCore/Sources/agtermCore/Session.swift
@@ -206,25 +206,43 @@ public final class Session: Identifiable {
/// In-memory only (absent from `snapshot()`), so it never persists.
@ObservationIgnored public var overlayExitCode: Int?
- /// For a *floating* overlay, the percent of the pane (both width and height) the panel occupies,
- /// 1...100; nil for the default full-pane overlay. A floating overlay renders as an opaque, framed
- /// panel centered in the pane with the session still VISIBLE behind it (the full overlay instead
- /// hides the session and draws translucent). Observed, so the detail pane picks the right layout.
- /// Set at open, cleared on close; never persisted.
- public var overlaySizePercent: Int?
-
- /// Whether a FULL-coverage overlay is up: `overlayActive` with no size percent. A full overlay hides
- /// the session content beneath it — the pane(s) AND a shown scratch — so its translucent background
- /// reveals the window backing, never a covered surface (under window translucency every surface
- /// renders a fully transparent background, so anything left visible below would bleed through).
+ /// The requested size mode for the overlay panel: `.full` (the default full-pane overlay that hides
+ /// the session and draws translucent), `.percent(1...100)`, or `.cells(cols, rows)` — the latter two
+ /// floating, an opaque framed panel over the still-visible session placed by `overlayAnchor`. Host-free;
+ /// the app resolves it to a concrete frame via `OverlayLayout.panelSize`. Observed, so the detail pane
+ /// re-flows when it changes. Set at open/resize, reset to `.full` on close; never persisted.
+ public var overlaySize: OverlaySize = .full
+
+ /// Where a *floating* overlay panel anchors within the pane — one of nine positions, default `.center`
+ /// (today's placement). Observed, so the detail pane re-positions when it changes. Preserved across a
+ /// `.full` round-trip (only `closeOverlay` resets it to `.center`); never persisted.
+ public var overlayAnchor: OverlayAnchor = .center
+
+ /// The overlay surface's live cell + padding metrics in POINTS, app-maintained (written on surface
+ /// realization, `GHOSTTY_ACTION_CELL_SIZE`, and backing-scale change) so a `.cells` layout can snap to
+ /// whole cells. Observed, so the panel re-lays-out when the metrics change — the imperative
+ /// `overlaySurface` can't drive the view. nil before the surface reports; reset on close.
+ public var overlayCellMetrics: OverlayCellMetrics?
+
+ /// The overlay surface's REALIZED grid (columns/rows actually rendered after clamping), app-maintained
+ /// from the live surface for the `tree` read-back, so a script can detect a clamp or drift vs the
+ /// request. Observed; nil before the surface reports or when no overlay is open; reset on close.
+ public var overlayAppliedCols: Int?
+ /// The overlay surface's realized row count — the row analogue of `overlayAppliedCols`.
+ public var overlayAppliedRows: Int?
+
+ /// Whether a FULL-coverage overlay is up: `overlayActive` with `overlaySize == .full`. A full overlay
+ /// hides the session content beneath it — the pane(s) AND a shown scratch — so its translucent
+ /// background reveals the window backing, never a covered surface (under window translucency every
+ /// surface renders a fully transparent background, so anything left visible below would bleed through).
/// A floating (sized) overlay is not a cover: it draws an opaque panel over still-visible content.
- public var fullOverlayActive: Bool { overlayActive && overlaySizePercent == nil }
+ public var fullOverlayActive: Bool { overlayActive && overlaySize == .full }
- /// Whether a FLOATING overlay is up: `overlayActive` WITH a size percent — the complement of
- /// `fullOverlayActive`. A floating overlay draws an opaque, framed panel sized to `overlaySizePercent`%
- /// over the still-visible session rather than covering it, so it is not a cover. Read by the detail
- /// pane to gate the floating panel.
- public var floatingOverlayActive: Bool { overlayActive && overlaySizePercent != nil }
+ /// Whether a FLOATING overlay is up: `overlayActive` with a non-`.full` `overlaySize` — the complement
+ /// of `fullOverlayActive`. A floating overlay draws an opaque, framed panel sized to `overlaySize` over
+ /// the still-visible session rather than covering it, so it is not a cover. Read by the detail pane to
+ /// gate the floating panel.
+ public var floatingOverlayActive: Bool { overlayActive && overlaySize != .full }
/// Whether the scratch terminal is shown on top of this session (full single-pane size, hiding
/// the single/split content underneath, like a full overlay). The scratch is a third per-session
diff --git a/agtermCore/Sources/agtermctlKit/SessionCommands.swift b/agtermCore/Sources/agtermctlKit/SessionCommands.swift
index 1faf9208..4f0a8eca 100644
--- a/agtermCore/Sources/agtermctlKit/SessionCommands.swift
+++ b/agtermCore/Sources/agtermctlKit/SessionCommands.swift
@@ -605,23 +605,33 @@ struct Session: ParsableCommand {
@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: "Render a floating panel exactly COLS columns wide (needs --rows); sized to the grid, clamped to fit the pane.") var cols: Int?
+ @Option(name: .long, help: "Render a floating panel exactly ROWS rows tall (needs --cols); sized to the grid, clamped to fit the pane.") var rows: Int?
+ @Option(name: .long, help: "Anchor a floating panel: top-left, top, top-right, left, center (default), right, bottom-left, bottom, bottom-right.") var anchor: String?
@Option(name: .long, help: "Solid background color (#rrggbb) for the overlay pane, independent of the session's own.") var backgroundColor: String?
@OptionGroup var target: TargetOptions
@OptionGroup var options: ClientOptions
- // reject the mutually-exclusive combo + a malformed color at parse time (before any connection),
- // so it's a clean usage error and is unit-testable without a socket.
+ // reject the mutually-exclusive combos, a bad size/anchor, and a malformed color at parse time
+ // (before any connection), so it's a clean usage error and is unit-testable without a socket.
+ // the host-free OverlayArgs.resolveOpen owns the size + anchor rules (incl. anchor-requires-a-
+ // floating-size), shared verbatim with the dispatcher.
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")
}
+ if case .invalid(let message) = OverlayArgs.resolveOpen(sizePercent: sizePercent, cols: cols,
+ rows: rows, anchor: anchor) {
+ throw ValidationError(message)
+ }
}
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,
+ sizePercent: sizePercent, cols: cols, rows: rows,
+ anchor: anchor, follow: follow ? true : nil,
color: backgroundColor)))
}
@@ -630,7 +640,8 @@ struct Session: ParsableCommand {
let client = SocketClient(path: options.socketPath())
// open via the same `makeRequest()` the non-block path uses (DRY): in block mode `validate()`
// guarantees `!wait`, so its `wait` is nil — identical to opening non-wait, and the floating
- // `--size-percent` is carried through the single source instead of a duplicated ControlArgs.
+ // size (`--size-percent`/`--cols`/`--rows`) + `--anchor` are carried through the 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)
@@ -671,25 +682,31 @@ struct Session: ParsableCommand {
}
struct Resize: RequestCommand {
- static let configuration = CommandConfiguration(abstract: "Resize an open overlay: floating at a percent, or back to full-pane.")
+ static let configuration = CommandConfiguration(
+ abstract: "Resize or re-anchor an open overlay: a floating percent or exact cols/rows, full-pane, or a 9-point anchor.")
@Option(name: .long, help: "Resize to a floating, framed panel at PERCENT (1-100) of the pane.") var sizePercent: Int?
+ @Option(name: .long, help: "Resize to a floating panel exactly COLS columns wide (needs --rows); clamped to fit the pane.") var cols: Int?
+ @Option(name: .long, help: "Resize to a floating panel exactly ROWS rows tall (needs --cols); clamped to fit the pane.") var rows: Int?
+ @Option(name: .long, help: "Re-anchor the floating panel: top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right.") var anchor: String?
@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.
+ // validate at parse time (before any connection) so it's a clean usage error, unit-testable
+ // without a socket; the dispatcher re-checks the same rules. The host-free OverlayArgs.resolveResize
+ // owns them all: one-of {--full, --size-percent, --cols/--rows} OR none, at least one of {a size
+ // mode, --anchor}, and --full ⊥ --anchor.
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")
+ if case .invalid(let message) = OverlayArgs.resolveResize(sizePercent: sizePercent, cols: cols,
+ rows: rows, full: full, anchor: anchor) {
+ throw ValidationError(message)
}
}
func makeRequest() throws -> ControlRequest {
ControlRequest(cmd: .sessionOverlayResize, target: target.target,
- args: options.withWindow(ControlArgs(sizePercent: sizePercent, full: full ? true : nil)))
+ args: options.withWindow(ControlArgs(sizePercent: sizePercent, full: full ? true : nil,
+ cols: cols, rows: rows, anchor: anchor)))
}
}
diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift
index 26bf4252..9c113ecb 100644
--- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift
@@ -425,28 +425,42 @@ struct AppStorePaneTests {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
- #expect(store.openOverlay(session.id, command: "revdiff", cwd: "/b") == true)
+ #expect(store.openOverlay(session.id, options: .init(command: "revdiff", cwd: "/b")) == true)
#expect(session.overlayActive == true)
#expect(session.overlayCommand == "revdiff")
#expect(session.overlayCwd == "/b")
- // no size given → the default full-pane overlay, not a floating one.
- #expect(session.overlaySizePercent == nil)
+ // no size given → the default full-pane overlay, not a floating one, centered.
+ #expect(session.overlaySize == .full)
+ #expect(session.overlayAnchor == .center)
+ #expect(session.fullOverlayActive)
// a second open while one is active is a no-op.
- #expect(store.openOverlay(session.id, command: "other") == false)
+ #expect(store.openOverlay(session.id, options: .init(command: "other")) == false)
#expect(session.overlayCommand == "revdiff")
}
+ @Test func openOverlaySetsSizeAndAnchor() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ // a floating cells-mode overlay parked in a corner: size and anchor both land on the session.
+ #expect(store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24),
+ anchor: .bottomRight)) == true)
+ #expect(session.overlaySize == .cells(cols: 80, rows: 24))
+ #expect(session.overlayAnchor == .bottomRight)
+ #expect(session.floatingOverlayActive)
+ }
+
@Test func openOverlayCarriesBackgroundColorAndCloseClears() {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
- #expect(store.openOverlay(session.id, command: "revdiff", backgroundColor: "#2a1a3a") == true)
+ #expect(store.openOverlay(session.id, options: .init(command: "revdiff", backgroundColor: "#2a1a3a")) == true)
#expect(session.overlayBackgroundColor == "#2a1a3a")
// close clears the overlay's color back to nil, like the other ephemeral overlay fields.
store.closeOverlay(session.id)
#expect(session.overlayBackgroundColor == nil)
// omitting the color leaves it nil (default theme background, unchanged behavior).
- #expect(store.openOverlay(session.id, command: "revdiff") == true)
+ #expect(store.openOverlay(session.id, options: .init(command: "revdiff")) == true)
#expect(session.overlayBackgroundColor == nil)
}
@@ -454,14 +468,14 @@ struct AppStorePaneTests {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
- store.openOverlay(session.id, command: "revdiff")
+ store.openOverlay(session.id, options: .init(command: "revdiff"))
#expect(session.overlayExitCode == nil)
store.recordOverlayExit(session.id, code: 10)
#expect(store.closeOverlay(session.id) == true)
// the exit code survives close (read by session.overlay.result after the overlay vanishes)...
#expect(session.overlayExitCode == 10)
// ...and is reset when a new overlay opens.
- #expect(store.openOverlay(session.id, command: "revdiff") == true)
+ #expect(store.openOverlay(session.id, options: .init(command: "revdiff")) == true)
#expect(session.overlayExitCode == nil)
}
@@ -474,58 +488,124 @@ struct AppStorePaneTests {
#expect(session.overlayExitCode == nil)
}
- @Test func openOverlayFloatingClampsSizePercent() {
+ @Test func openOverlayFloatingClampsPercent() {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
- #expect(store.openOverlay(session.id, command: "htop", sizePercent: 70) == true)
- #expect(session.overlaySizePercent == 70)
- // close clears the floating size back to nil.
+ #expect(store.openOverlay(session.id, options: .init(command: "htop", size: .percent(70))) == true)
+ #expect(session.overlaySize == .percent(70))
+ // close resets the size back to .full (and the anchor to center).
store.closeOverlay(session.id)
- #expect(session.overlaySizePercent == nil)
- // out-of-range values clamp to 1...100, including negatives; the exact bounds pass through.
- store.openOverlay(session.id, command: "htop", sizePercent: 250)
- #expect(session.overlaySizePercent == 100)
+ #expect(session.overlaySize == .full)
+ #expect(session.overlayAnchor == .center)
+ // the defensive internal clamp: out-of-range percents clamp to 1...100, including negatives; the
+ // exact bounds pass through. (the dispatcher/CLI hard-error these — this guards the store's clamp.)
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(250)))
+ #expect(session.overlaySize == .percent(100))
store.closeOverlay(session.id)
- store.openOverlay(session.id, command: "htop", sizePercent: 0)
- #expect(session.overlaySizePercent == 1)
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(0)))
+ #expect(session.overlaySize == .percent(1))
store.closeOverlay(session.id)
- store.openOverlay(session.id, command: "htop", sizePercent: -5)
- #expect(session.overlaySizePercent == 1)
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(-5)))
+ #expect(session.overlaySize == .percent(1))
store.closeOverlay(session.id)
- store.openOverlay(session.id, command: "htop", sizePercent: 100)
- #expect(session.overlaySizePercent == 100)
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(100)))
+ #expect(session.overlaySize == .percent(100))
store.closeOverlay(session.id)
- store.openOverlay(session.id, command: "htop", sizePercent: 1)
- #expect(session.overlaySizePercent == 1)
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(1)))
+ #expect(session.overlaySize == .percent(1))
}
- @Test func resizeOverlaySwitchesFullAndFloatingAndClamps() {
+ @Test func resizeOverlayNoOpWithoutOverlay() {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
- // no overlay open → no-op, leaves size untouched.
- #expect(store.resizeOverlay(session.id, sizePercent: 50) == false)
- #expect(session.overlaySizePercent == nil)
- // open full, then resize it to a floating percent (nil → 60).
- store.openOverlay(session.id, command: "htop")
- #expect(session.overlaySizePercent == nil)
- #expect(store.resizeOverlay(session.id, sizePercent: 60) == true)
- #expect(session.overlaySizePercent == 60)
+ // no overlay open → no-op, leaves the size at its .full default.
+ #expect(store.resizeOverlay(session.id, size: .percent(50)) == false)
+ #expect(session.overlaySize == .full)
+ }
+
+ @Test func resizeOverlayPercentThenCellsThenFullAndClamps() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ store.openOverlay(session.id, options: .init(command: "htop"))
+ #expect(session.overlaySize == .full)
+ // full → floating percent.
+ #expect(store.resizeOverlay(session.id, size: .percent(60)) == true)
+ #expect(session.overlaySize == .percent(60))
#expect(session.floatingOverlayActive)
- // resize back to full (nil).
- #expect(store.resizeOverlay(session.id, sizePercent: nil) == true)
- #expect(session.overlaySizePercent == nil)
+ // percent → exact cells.
+ #expect(store.resizeOverlay(session.id, size: .cells(cols: 100, rows: 30)) == true)
+ #expect(session.overlaySize == .cells(cols: 100, rows: 30))
+ #expect(session.floatingOverlayActive)
+ // cells → back to full.
+ #expect(store.resizeOverlay(session.id, size: .full) == true)
+ #expect(session.overlaySize == .full)
#expect(session.fullOverlayActive)
- // out-of-range percents clamp to 1...100.
- store.resizeOverlay(session.id, sizePercent: 250)
- #expect(session.overlaySizePercent == 100)
- store.resizeOverlay(session.id, sizePercent: 0)
- #expect(session.overlaySizePercent == 1)
+ // out-of-range percents clamp to 1...100 on resize too.
+ store.resizeOverlay(session.id, size: .percent(250))
+ #expect(session.overlaySize == .percent(100))
+ store.resizeOverlay(session.id, size: .percent(0))
+ #expect(session.overlaySize == .percent(1))
// the overlay program keeps running across every resize (no re-spawn).
#expect(session.overlayActive)
}
+ @Test func resizeOverlayAnchorOnlyKeepsSize() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(80), anchor: .center))
+ // a nil size = keep the current size; only the anchor moves.
+ #expect(store.resizeOverlay(session.id, anchor: .topRight) == true)
+ #expect(session.overlaySize == .percent(80))
+ #expect(session.overlayAnchor == .topRight)
+ }
+
+ @Test func resizeOverlaySizeOnlyKeepsAnchor() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(80), anchor: .bottomLeft))
+ // a nil anchor = keep the current anchor; only the size changes.
+ #expect(store.resizeOverlay(session.id, size: .cells(cols: 40, rows: 12)) == true)
+ #expect(session.overlaySize == .cells(cols: 40, rows: 12))
+ #expect(session.overlayAnchor == .bottomLeft)
+ }
+
+ @Test func resizeOverlayKeepsAppliedGridForAppToReReport() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24), anchor: .center))
+ // simulate the app-maintained realized grid from the first size.
+ session.overlayAppliedCols = 72
+ session.overlayAppliedRows = 20
+ // a SIZE-changing resize does NOT nil the realized grid: the app re-reports it on the settle-resize
+ // when the grid actually changes, and if the grid stays identical (e.g. two oversized requests both
+ // clamping to the same grid) the old value is still correct — niling it would strand the read-back at
+ // nil forever because the app's last-value dedup suppresses re-reporting an unchanged grid.
+ #expect(store.resizeOverlay(session.id, size: .cells(cols: 30, rows: 10)) == true)
+ #expect(session.overlayAppliedCols == 72)
+ #expect(session.overlayAppliedRows == 20)
+ // a re-anchor-only resize (nil size) also leaves the realized grid untouched.
+ #expect(store.resizeOverlay(session.id, anchor: .topLeft) == true)
+ #expect(session.overlayAppliedCols == 72)
+ #expect(session.overlayAppliedRows == 20)
+ }
+
+ @Test func resizeOverlayFullPreservesAnchor() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(80), anchor: .top))
+ // resizing to .full keeps the anchor — only closeOverlay resets it to center (Decision 2).
+ #expect(store.resizeOverlay(session.id, size: .full) == true)
+ #expect(session.overlaySize == .full)
+ #expect(session.overlayAnchor == .top)
+ }
+
@Test func controlTreeReportsCommandWait() throws {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
@@ -553,18 +633,56 @@ struct AppStorePaneTests {
var node = try #require(store.controlTree().workspaces[0].sessions.first)
#expect(node.overlay == false)
#expect(node.overlaySizePercent == nil)
- // floating overlay: the percent rides the node so a script can record it before zooming.
- store.openOverlay(session.id, command: "htop", sizePercent: 95)
+ // floating percent overlay: the percent rides the node so a script can record it before zooming.
+ store.openOverlay(session.id, options: .init(command: "htop", size: .percent(95)))
node = try #require(store.controlTree().workspaces[0].sessions.first)
#expect(node.overlay == true)
#expect(node.overlaySizePercent == 95)
- // full-pane overlay: open but no size (nil = full).
- store.resizeOverlay(session.id, sizePercent: nil)
+ // a cells-mode overlay is floating but NOT percent, so the percent wire field is omitted.
+ store.resizeOverlay(session.id, size: .cells(cols: 80, rows: 24))
+ node = try #require(store.controlTree().workspaces[0].sessions.first)
+ #expect(node.overlay == true)
+ #expect(node.overlaySizePercent == nil)
+ // full-pane overlay: open but no percent (full).
+ store.resizeOverlay(session.id, size: .full)
node = try #require(store.controlTree().workspaces[0].sessions.first)
#expect(node.overlay == true)
#expect(node.overlaySizePercent == nil)
}
+ @Test func controlTreeReportsOverlayColsRowsAndAnchor() throws {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ // no overlay: every overlay grid/anchor field is omitted.
+ var node = try #require(store.controlTree().workspaces[0].sessions.first)
+ #expect(node.overlayCols == nil)
+ #expect(node.overlayColsApplied == nil)
+ #expect(node.overlayAnchor == nil)
+ // a cells-mode floating overlay parked in a corner: the REQUESTED grid + anchor ride the node, and
+ // the app-maintained REALIZED grid (overlayApplied*) exposes any clamp (here clamped smaller).
+ store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24),
+ anchor: .bottomRight))
+ session.overlayAppliedCols = 72
+ session.overlayAppliedRows = 20
+ node = try #require(store.controlTree().workspaces[0].sessions.first)
+ #expect(node.overlayCols == 80)
+ #expect(node.overlayRows == 24)
+ #expect(node.overlayColsApplied == 72)
+ #expect(node.overlayRowsApplied == 20)
+ #expect(node.overlayAnchor == "bottom-right")
+ // resize to full keeps + reports the anchor (Decision 2) but drops the requested + realized grid
+ // (cols/rows/applied are floating-only).
+ store.resizeOverlay(session.id, size: .full)
+ node = try #require(store.controlTree().workspaces[0].sessions.first)
+ #expect(node.overlay == true)
+ #expect(node.overlayCols == nil)
+ #expect(node.overlayRows == nil)
+ #expect(node.overlayColsApplied == nil)
+ #expect(node.overlayRowsApplied == nil)
+ #expect(node.overlayAnchor == "bottom-right")
+ }
+
@Test func controlTreeReportsSplitRatio() throws {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
@@ -638,6 +756,21 @@ struct AppStorePaneTests {
#expect(node.scratchFontSize == 11)
}
+ @Test func controlTreeThreadsCanvasGridFromClosure() throws {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ _ = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ // default closure (host-free / no realized surface): both canvas fields omitted (nil).
+ var node = try #require(store.controlTree().workspaces[0].sessions.first)
+ #expect(node.canvasCols == nil)
+ #expect(node.canvasRows == nil)
+ // the host supplies the content-area grid via the closure (app reads it off the live surface).
+ node = try #require(store.controlTree(canvasGrid: { _ in (cols: 132, rows: 43) })
+ .workspaces[0].sessions.first)
+ #expect(node.canvasCols == 132)
+ #expect(node.canvasRows == 43)
+ }
+
@Test func controlTreeFontSizeReadsPromotedSurvivorViaAddressableSurface() throws {
// regression: after the primary pane exits, the fontSize read-back must resolve through
// addressableSurface — the same surface the font default/left WRITE path targets. With true
@@ -716,7 +849,7 @@ struct AppStorePaneTests {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
- store.openOverlay(session.id, command: "revdiff")
+ store.openOverlay(session.id, options: .init(command: "revdiff"))
let overlay = SpySurface()
session.overlaySurface = overlay
#expect(store.closeOverlay(session.id) == true)
@@ -728,11 +861,30 @@ struct AppStorePaneTests {
#expect(store.closeOverlay(session.id) == false)
}
+ @Test func closeOverlayResetsSizeAnchorAndMetrics() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ store.openOverlay(session.id, options: .init(command: "htop", size: .cells(cols: 80, rows: 24),
+ anchor: .bottomRight))
+ // simulate the app-maintained live metrics + realized grid that the surface would populate.
+ session.overlayCellMetrics = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 4)
+ session.overlayAppliedCols = 80
+ session.overlayAppliedRows = 24
+ #expect(store.closeOverlay(session.id) == true)
+ // close resets the whole floating-overlay state to full/center/nil.
+ #expect(session.overlaySize == .full)
+ #expect(session.overlayAnchor == .center)
+ #expect(session.overlayCellMetrics == nil)
+ #expect(session.overlayAppliedCols == nil)
+ #expect(session.overlayAppliedRows == nil)
+ }
+
@Test func closeSessionTearsDownOverlaySurface() {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
- store.openOverlay(session.id, command: "revdiff")
+ store.openOverlay(session.id, options: .init(command: "revdiff"))
let overlay = SpySurface()
session.overlaySurface = overlay
store.closeSession(session.id)
diff --git a/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift b/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift
index f671da34..8607bafb 100644
--- a/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/AppStoreTests.swift
@@ -1407,7 +1407,7 @@ struct AppStoreTests {
ControlSessionNode(id: b.id.uuidString, name: "remote:~/b", cwd: "/live/b",
title: "remote:~/b", active: true, split: true,
splitFocused: false,
- overlay: true, scratch: true, flagged: true,
+ overlay: true, overlayAnchor: "center", scratch: true, flagged: true,
status: "blocked", statusPane: "right",
background: BackgroundWatermark(kind: .text, text: "PROD"),
surfaces: [
diff --git a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift
index 5ec78d70..d926ea0f 100644
--- a/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift
@@ -1178,7 +1178,7 @@ struct ControlDispatcherTests {
#expect(actions.calls.isEmpty)
}
- @Test func sessionOverlayOpenRoutesOptionsAndEchoesActionResponse() async {
+ @Test func sessionOverlayOpenRoutesPercentOptionsAndEchoesActionResponse() async {
let actions = MockControlActions()
let dispatcher = ControlDispatcher(actions: actions)
actions.nextOverlayOpenResponse = ControlResponse(ok: false, error: "overlay already open")
@@ -1187,19 +1187,39 @@ struct ControlDispatcherTests {
cmd: .sessionOverlayOpen,
target: "session",
args: ControlArgs(cwd: "/tmp", command: "cat", wait: true,
- sizePercent: 70, follow: true, window: "win", color: "#2a1a3a")
+ sizePercent: 70, anchor: "top-right", follow: true, window: "win", color: "#2a1a3a")
))
#expect(response == ControlResponse(ok: false, error: "overlay already open"))
#expect(actions.calls == [
.overlayOpen(target: "session", window: "win",
ControlSessionOverlayOpenOptions(command: "cat", cwd: "/tmp", wait: true,
- sizePercent: 70, backgroundColor: "#2a1a3a",
- follow: true))
+ size: .percent(70), anchor: .topRight,
+ backgroundColor: "#2a1a3a", follow: true))
])
}
- @Test func sessionOverlayOpenDefaultsFollowToFalseWhenOmitted() async {
+ @Test func sessionOverlayOpenRoutesCellsAndAnchor() async {
+ let actions = MockControlActions()
+ let dispatcher = ControlDispatcher(actions: actions)
+ actions.nextOverlayOpenResponse = ControlResponse(ok: true, result: ControlResult(id: "session"))
+
+ let response = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen,
+ target: "session",
+ args: ControlArgs(command: "htop", cols: 80, rows: 24, anchor: "bottom-left")
+ ))
+
+ #expect(response == ControlResponse(ok: true, result: ControlResult(id: "session")))
+ #expect(actions.calls == [
+ .overlayOpen(target: "session", window: nil,
+ ControlSessionOverlayOpenOptions(command: "htop", cwd: nil, wait: false,
+ size: .cells(cols: 80, rows: 24), anchor: .bottomLeft,
+ backgroundColor: nil, follow: false))
+ ])
+ }
+
+ @Test func sessionOverlayOpenDefaultsToFullCenterWhenSizeOmitted() async {
let actions = MockControlActions()
let dispatcher = ControlDispatcher(actions: actions)
actions.nextOverlayOpenResponse = ControlResponse(ok: true, result: ControlResult(id: "session"))
@@ -1214,11 +1234,46 @@ struct ControlDispatcherTests {
#expect(actions.calls == [
.overlayOpen(target: "session", window: nil,
ControlSessionOverlayOpenOptions(command: "cat", cwd: nil, wait: false,
- sizePercent: nil, backgroundColor: nil,
- follow: false))
+ size: .full, anchor: .center,
+ backgroundColor: nil, follow: false))
])
}
+ @Test func sessionOverlayOpenRejectsSizeAndAnchorErrors() async {
+ let actions = MockControlActions()
+ let dispatcher = ControlDispatcher(actions: actions)
+
+ let colsWithoutRows = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", cols: 80)))
+ let percentTooBig = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", sizePercent: 150)))
+ let percentTooSmall = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", sizePercent: 0)))
+ let percentAndCells = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen, target: "session",
+ args: ControlArgs(command: "cat", sizePercent: 50, cols: 80, rows: 24)))
+ let badCells = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", cols: 0, rows: 24)))
+ let unknownAnchor = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen, target: "session",
+ args: ControlArgs(command: "cat", sizePercent: 50, anchor: "sideways")))
+ let anchorWithoutFloating = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayOpen, target: "session", args: ControlArgs(command: "cat", anchor: "top-left")))
+
+ #expect(colsWithoutRows == ControlResponse(ok: false, error: "session.overlay.open: provide both --cols and --rows"))
+ #expect(percentTooBig == ControlResponse(ok: false, error: "session.overlay.open: --size-percent must be 1...100"))
+ #expect(percentTooSmall == ControlResponse(ok: false, error: "session.overlay.open: --size-percent must be 1...100"))
+ #expect(percentAndCells == ControlResponse(
+ ok: false, error: "session.overlay.open: use only one of --size-percent or --cols/--rows"))
+ #expect(badCells == ControlResponse(ok: false, error: "session.overlay.open: --cols and --rows must be >= 1"))
+ #expect(unknownAnchor == ControlResponse(
+ ok: false,
+ error: "unknown anchor: sideways (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)"))
+ #expect(anchorWithoutFloating == ControlResponse(
+ ok: false, error: "--anchor requires a floating overlay: use --size-percent or --cols/--rows"))
+ #expect(actions.calls.isEmpty)
+ }
+
@Test func sessionOverlayCloseAndResultRouteTargetAndWindow() async {
let actions = MockControlActions()
let dispatcher = ControlDispatcher(actions: actions)
@@ -1266,10 +1321,10 @@ struct ControlDispatcherTests {
))
#expect(response == ControlResponse(ok: true, result: ControlResult(id: "session")))
- #expect(actions.calls == [.overlayResize(target: "session", window: "win", sizePercent: 60)])
+ #expect(actions.calls == [.overlayResize(target: "session", window: "win", size: .percent(60), anchor: nil)])
}
- @Test func sessionOverlayResizeFullRoutesNilSizePercent() async {
+ @Test func sessionOverlayResizeFullRoutesFullSize() async {
let actions = MockControlActions()
let dispatcher = ControlDispatcher(actions: actions)
@@ -1278,7 +1333,34 @@ struct ControlDispatcherTests {
))
#expect(response?.ok == true)
- #expect(actions.calls == [.overlayResize(target: "session", window: nil, sizePercent: nil)])
+ #expect(actions.calls == [.overlayResize(target: "session", window: nil, size: .full, anchor: nil)])
+ }
+
+ @Test func sessionOverlayResizeRoutesCellsWithAnchor() async {
+ let actions = MockControlActions()
+ let dispatcher = ControlDispatcher(actions: actions)
+
+ let response = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayResize, target: "session",
+ args: ControlArgs(cols: 100, rows: 30, anchor: "right")
+ ))
+
+ #expect(response?.ok == true)
+ #expect(actions.calls == [
+ .overlayResize(target: "session", window: nil, size: .cells(cols: 100, rows: 30), anchor: .right)
+ ])
+ }
+
+ @Test func sessionOverlayResizeAnchorOnlyKeepsSize() async {
+ let actions = MockControlActions()
+ let dispatcher = ControlDispatcher(actions: actions)
+
+ let response = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayResize, target: "session", args: ControlArgs(anchor: "center")
+ ))
+
+ #expect(response?.ok == true)
+ #expect(actions.calls == [.overlayResize(target: "session", window: nil, size: nil, anchor: .center)])
}
@Test func sessionOverlayResizeRejectsMissingConflictingAndOutOfRange() async {
@@ -1292,11 +1374,20 @@ struct ControlDispatcherTests {
cmd: .sessionOverlayResize, target: "session", args: ControlArgs(sizePercent: 101)))
let tooSmall = await dispatcher.dispatch(ControlRequest(
cmd: .sessionOverlayResize, target: "session", args: ControlArgs(sizePercent: 0)))
+ let fullAndAnchor = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayResize, target: "session", args: ControlArgs(full: true, anchor: "top-left")))
+ let colsWithoutRows = await dispatcher.dispatch(ControlRequest(
+ cmd: .sessionOverlayResize, target: "session", args: ControlArgs(cols: 80)))
- #expect(missing == ControlResponse(ok: false, error: "session.overlay.resize requires --size-percent or --full"))
- #expect(both == ControlResponse(ok: false, error: "session.overlay.resize: --full is mutually exclusive with --size-percent"))
+ #expect(missing == ControlResponse(
+ ok: false,
+ error: "session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor"))
+ #expect(both == ControlResponse(
+ ok: false, error: "session.overlay.resize: use only one of --full, --size-percent, or --cols/--rows"))
#expect(tooBig == ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100"))
#expect(tooSmall == ControlResponse(ok: false, error: "session.overlay.resize: --size-percent must be 1...100"))
+ #expect(fullAndAnchor == ControlResponse(ok: false, error: "--full cannot be combined with --anchor"))
+ #expect(colsWithoutRows == ControlResponse(ok: false, error: "session.overlay.resize: provide both --cols and --rows"))
#expect(actions.calls.isEmpty)
}
diff --git a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift
index c7370fcd..92d40737 100644
--- a/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift
@@ -112,6 +112,37 @@ struct ControlProtocolTests {
#expect(!json.contains("follow"), "a nil follow must be omitted from the JSON; got \(json)")
}
+ @Test func sessionOverlayRoundTripsWithColsRowsAnchor() throws {
+ // the new overlay sizing args: an exact cols×rows grid and a 9-point anchor ride the request, so a
+ // cells-mode floating overlay parked in a corner round-trips (open), as does a re-anchor-only resize.
+ let open = ControlRequest(cmd: .sessionOverlayOpen, target: "9f3c",
+ args: ControlArgs(command: "htop", cols: 80, rows: 24, anchor: "bottom-right"))
+ let decodedOpen = try roundTrip(open)
+ #expect(decodedOpen == open)
+ #expect(decodedOpen.args?.cols == 80)
+ #expect(decodedOpen.args?.rows == 24)
+ #expect(decodedOpen.args?.anchor == "bottom-right")
+
+ let resize = ControlRequest(cmd: .sessionOverlayResize, target: "9f3c", args: ControlArgs(anchor: "top-left"))
+ let decodedResize = try roundTrip(resize)
+ #expect(decodedResize == resize)
+ #expect(decodedResize.args?.anchor == "top-left")
+ }
+
+ @Test func sessionOverlayOmitsColsRowsAnchorWhenNil() throws {
+ let request = ControlRequest(cmd: .sessionOverlayOpen, target: "9f3c", args: ControlArgs(command: "revdiff"))
+ let decoded = try roundTrip(request)
+ #expect(decoded == request)
+ #expect(decoded.args?.cols == nil)
+ #expect(decoded.args?.rows == nil)
+ #expect(decoded.args?.anchor == nil)
+ // omit-when-nil WIRE contract: nil cols/rows/anchor must not encode at all, not emit as null.
+ let json = String(data: try JSONEncoder().encode(request), encoding: .utf8) ?? ""
+ #expect(!json.contains("cols"), "a nil cols must be omitted from the JSON; got \(json)")
+ #expect(!json.contains("rows"), "a nil rows must be omitted from the JSON; got \(json)")
+ #expect(!json.contains("anchor"), "a nil anchor must be omitted from the JSON; got \(json)")
+ }
+
@Test func sessionTextRoundTripsWithAllLinesAndPane() throws {
let request = ControlRequest(cmd: .sessionText, target: "9f3c",
args: ControlArgs(pane: "left", all: true, lines: 50))
@@ -616,6 +647,98 @@ struct ControlProtocolTests {
#expect(decoded.overlaySizePercent == nil)
}
+ @Test func treeSessionNodeRoundTripsWithOverlayCellsAppliedAndAnchor() throws {
+ // a cells-mode floating overlay: the REQUESTED grid, the REALIZED (clamped) grid, and the anchor all
+ // ride the node so a script can record the request, detect a clamp, and restore the anchor.
+ let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false,
+ overlay: true, overlayCols: 80, overlayRows: 24,
+ overlayColsApplied: 72, overlayRowsApplied: 20, overlayAnchor: "bottom-right")
+ let response = ControlResponse(ok: true, result: ControlResult(tree: ControlTree(
+ workspaces: [ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session])])))
+ let decoded = try roundTrip(response)
+ #expect(decoded == response)
+ let node = decoded.result?.tree?.workspaces.first?.sessions.first
+ #expect(node?.overlayCols == 80)
+ #expect(node?.overlayRows == 24)
+ #expect(node?.overlayColsApplied == 72)
+ #expect(node?.overlayRowsApplied == 20)
+ #expect(node?.overlayAnchor == "bottom-right")
+ }
+
+ @Test func treeSessionNodeRoundTripsWithOverlayPercentAppliedAndAnchor() throws {
+ // a percent floating overlay: NO requested cols/rows, but the realized grid + anchor still ride the
+ // node (applied is set for ANY floating overlay, percent or cells).
+ let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false,
+ overlay: true, overlaySizePercent: 70,
+ overlayColsApplied: 96, overlayRowsApplied: 28, overlayAnchor: "center")
+ let response = ControlResponse(ok: true, result: ControlResult(tree: ControlTree(
+ workspaces: [ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session])])))
+ let decoded = try roundTrip(response)
+ #expect(decoded == response)
+ let node = decoded.result?.tree?.workspaces.first?.sessions.first
+ #expect(node?.overlaySizePercent == 70)
+ #expect(node?.overlayCols == nil)
+ #expect(node?.overlayRows == nil)
+ #expect(node?.overlayColsApplied == 96)
+ #expect(node?.overlayRowsApplied == 28)
+ #expect(node?.overlayAnchor == "center")
+ }
+
+ @Test func treeSessionNodeKeepsAnchorButOmitsGridForFullOverlay() throws {
+ // a FULL overlay: no cols/rows/applied (those are floating-only), but the anchor IS emitted
+ // (Decision 2 — the anchor is preserved and reported even while full).
+ let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false,
+ overlay: true, overlayAnchor: "top")
+ let json = String(data: try JSONEncoder().encode(session), encoding: .utf8) ?? ""
+ #expect(!json.contains("overlayCols"), "a full overlay must omit overlayCols/overlayColsApplied; got \(json)")
+ #expect(!json.contains("overlayRows"), "a full overlay must omit overlayRows/overlayRowsApplied; got \(json)")
+ #expect(json.contains("\"overlayAnchor\":\"top\""), "a full overlay must emit its anchor; got \(json)")
+ let decoded = try JSONDecoder().decode(ControlSessionNode.self, from: Data(json.utf8))
+ #expect(decoded.overlayCols == nil)
+ #expect(decoded.overlayColsApplied == nil)
+ #expect(decoded.overlayAnchor == "top")
+ }
+
+ @Test func treeSessionNodeOmitsOverlayGridAndAnchorWhenNoOverlay() throws {
+ // no overlay open — every overlay grid/anchor key must be omitted, not emitted as null.
+ let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false)
+ let json = String(data: try JSONEncoder().encode(session), encoding: .utf8) ?? ""
+ #expect(!json.contains("overlayCols"), "no overlay must omit overlayCols/overlayColsApplied; got \(json)")
+ #expect(!json.contains("overlayRows"), "no overlay must omit overlayRows/overlayRowsApplied; got \(json)")
+ #expect(!json.contains("overlayAnchor"), "no overlay must omit overlayAnchor; got \(json)")
+ let decoded = try JSONDecoder().decode(ControlSessionNode.self, from: Data(json.utf8))
+ #expect(decoded.overlayCols == nil)
+ #expect(decoded.overlayRows == nil)
+ #expect(decoded.overlayColsApplied == nil)
+ #expect(decoded.overlayRowsApplied == nil)
+ #expect(decoded.overlayAnchor == nil)
+ }
+
+ @Test func treeSessionNodeRoundTripsWithCanvasGrid() throws {
+ // the terminal content area (overlay canvas) in cells at the base font, so a script can size a
+ // floating overlay as a fraction of the canvas (`--cols canvasCols --rows canvasRows` fills it).
+ let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false,
+ canvasCols: 132, canvasRows: 43)
+ let response = ControlResponse(ok: true, result: ControlResult(tree: ControlTree(
+ workspaces: [ControlWorkspaceNode(id: "w1", name: "work", active: true, sessions: [session])])))
+ let decoded = try roundTrip(response)
+ #expect(decoded == response)
+ let node = decoded.result?.tree?.workspaces.first?.sessions.first
+ #expect(node?.canvasCols == 132)
+ #expect(node?.canvasRows == 43)
+ }
+
+ @Test func treeSessionNodeOmitsCanvasGridWhenNil() throws {
+ // no realized surface (grid unknown) — both keys must be omitted, not emitted as null.
+ let session = ControlSessionNode(id: "s1", name: "shell", cwd: "/tmp", active: true, split: false)
+ let json = String(data: try JSONEncoder().encode(session), encoding: .utf8) ?? ""
+ #expect(!json.contains("canvasCols"), "a nil canvas grid must omit canvasCols; got \(json)")
+ #expect(!json.contains("canvasRows"), "a nil canvas grid must omit canvasRows; got \(json)")
+ let decoded = try JSONDecoder().decode(ControlSessionNode.self, from: Data(json.utf8))
+ #expect(decoded.canvasCols == nil)
+ #expect(decoded.canvasRows == nil)
+ }
+
@Test func treeSessionNodeRoundTripsWithRestoreCommand() throws {
// the read side of session.restore: the pinned shell line rides the tree node per pane so a script
// can record what is pinned, change it, and restore the original.
diff --git a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift
index 57f978c3..a3bd9271 100644
--- a/agtermCore/Tests/agtermCoreTests/MockControlActions.swift
+++ b/agtermCore/Tests/agtermCoreTests/MockControlActions.swift
@@ -58,7 +58,7 @@ final class MockControlActions: ControlActions {
case sessionSearch(target: String?, window: String?, text: String?, to: String?)
case overlayOpen(target: String?, window: String?, ControlSessionOverlayOpenOptions)
case overlayClose(target: String?, window: String?)
- case overlayResize(target: String?, window: String?, sizePercent: Int?)
+ case overlayResize(target: String?, window: String?, size: OverlaySize?, anchor: OverlayAnchor?)
case overlayResult(target: String?, window: String?)
case sessionBackground(target: String?, window: String?, ControlSessionBackgroundOptions)
case sessionText(target: String?, window: String?, ControlSessionTextOptions)
@@ -372,8 +372,9 @@ final class MockControlActions: ControlActions {
return nextOverlayCloseResponse
}
- func resizeSessionOverlay(_ target: String?, window: String?, sizePercent: Int?) -> ControlResponse {
- calls.append(.overlayResize(target: target, window: window, sizePercent: sizePercent))
+ func resizeSessionOverlay(_ target: String?, window: String?,
+ size: OverlaySize?, anchor: OverlayAnchor?) -> ControlResponse {
+ calls.append(.overlayResize(target: target, window: window, size: size, anchor: anchor))
return nextOverlayResizeResponse
}
diff --git a/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift
new file mode 100644
index 00000000..610f6155
--- /dev/null
+++ b/agtermCore/Tests/agtermCoreTests/OverlayArgsTests.swift
@@ -0,0 +1,143 @@
+import Foundation
+import Testing
+@testable import agtermCore
+
+struct OverlayArgsTests {
+ // MARK: - parseSize
+
+ @Test func sizeUnspecifiedWhenNothingSet() {
+ let result = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: nil, full: false, command: .open)
+ #expect(result == .unspecified)
+ }
+
+ @Test func sizeResolvesPercent() {
+ let result = OverlayArgs.parseSize(sizePercent: 50, cols: nil, rows: nil, full: false, command: .open)
+ #expect(result == .size(.percent(50)))
+ }
+
+ @Test func sizeResolvesCells() {
+ let result = OverlayArgs.parseSize(sizePercent: nil, cols: 40, rows: 12, full: false, command: .open)
+ #expect(result == .size(.cells(cols: 40, rows: 12)))
+ }
+
+ @Test func sizeResolvesFullWhenAllowed() {
+ let result = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: nil, full: true, command: .resize)
+ #expect(result == .size(.full))
+ }
+
+ @Test func sizeIgnoresFullWhenNotAllowed() {
+ // open has no --full, so a stray full flag is treated as no size = unspecified (open → full-pane).
+ let result = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: nil, full: true, command: .open)
+ #expect(result == .unspecified)
+ }
+
+ @Test func sizeRejectsMultipleModesOpen() {
+ let result = OverlayArgs.parseSize(sizePercent: 50, cols: 40, rows: 12, full: false, command: .open)
+ #expect(result == .invalid("session.overlay.open: use only one of --size-percent or --cols/--rows"))
+ }
+
+ @Test func sizeRejectsMultipleModesResize() {
+ let result = OverlayArgs.parseSize(sizePercent: 50, cols: nil, rows: nil, full: true, command: .resize)
+ #expect(result == .invalid("session.overlay.resize: use only one of --full, --size-percent, or --cols/--rows"))
+ }
+
+ @Test func sizeRejectsPercentOutOfRange() {
+ let high = OverlayArgs.parseSize(sizePercent: 150, cols: nil, rows: nil, full: false, command: .open)
+ #expect(high == .invalid("session.overlay.open: --size-percent must be 1...100"))
+ let low = OverlayArgs.parseSize(sizePercent: 0, cols: nil, rows: nil, full: false, command: .resize)
+ #expect(low == .invalid("session.overlay.resize: --size-percent must be 1...100"))
+ }
+
+ @Test func sizeRejectsColsWithoutRows() {
+ let colsOnly = OverlayArgs.parseSize(sizePercent: nil, cols: 40, rows: nil, full: false, command: .open)
+ #expect(colsOnly == .invalid("session.overlay.open: provide both --cols and --rows"))
+ let rowsOnly = OverlayArgs.parseSize(sizePercent: nil, cols: nil, rows: 12, full: false, command: .resize)
+ #expect(rowsOnly == .invalid("session.overlay.resize: provide both --cols and --rows"))
+ }
+
+ @Test func sizeRejectsNonPositiveCells() {
+ let result = OverlayArgs.parseSize(sizePercent: nil, cols: 0, rows: 12, full: false, command: .open)
+ #expect(result == .invalid("session.overlay.open: --cols and --rows must be >= 1"))
+ }
+
+ // MARK: - parseAnchor
+
+ @Test func anchorAbsentWhenNil() {
+ #expect(OverlayArgs.parseAnchor(nil) == .absent)
+ }
+
+ @Test func anchorResolvesEachValidValue() {
+ for anchor in OverlayAnchor.allCases {
+ #expect(OverlayArgs.parseAnchor(anchor.rawValue) == .anchor(anchor))
+ }
+ }
+
+ @Test func anchorRejectsUnknownWithNinePositions() {
+ let result = OverlayArgs.parseAnchor("middle")
+ #expect(result == .invalid("unknown anchor: middle (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)"))
+ }
+
+ // MARK: - resolveOpen
+
+ @Test func openResolvesFullCenterWhenNothingSet() {
+ #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: nil, rows: nil, anchor: nil)
+ == .resolved(size: .full, anchor: .center))
+ }
+
+ @Test func openResolvesPercentWithAnchor() {
+ #expect(OverlayArgs.resolveOpen(sizePercent: 50, cols: nil, rows: nil, anchor: "bottom-right")
+ == .resolved(size: .percent(50), anchor: .bottomRight))
+ }
+
+ @Test func openResolvesCellsWithAnchor() {
+ #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: 40, rows: 12, anchor: "top-left")
+ == .resolved(size: .cells(cols: 40, rows: 12), anchor: .topLeft))
+ }
+
+ @Test func openRejectsAnchorWithoutFloating() {
+ #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: nil, rows: nil, anchor: "top-left")
+ == .invalid("--anchor requires a floating overlay: use --size-percent or --cols/--rows"))
+ }
+
+ @Test func openPropagatesSizeAndAnchorRejections() {
+ #expect(OverlayArgs.resolveOpen(sizePercent: 0, cols: nil, rows: nil, anchor: nil)
+ == .invalid("session.overlay.open: --size-percent must be 1...100"))
+ #expect(OverlayArgs.resolveOpen(sizePercent: nil, cols: 40, rows: nil, anchor: nil)
+ == .invalid("session.overlay.open: provide both --cols and --rows"))
+ #expect(OverlayArgs.resolveOpen(sizePercent: 50, cols: nil, rows: nil, anchor: "middle")
+ == .invalid("unknown anchor: middle (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)"))
+ }
+
+ // MARK: - resolveResize
+
+ @Test func resizeResolvesAnchorOnlyKeepingSize() {
+ #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: false, anchor: "top")
+ == .resolved(size: nil, anchor: .top))
+ }
+
+ @Test func resizeResolvesPercentFullAndCells() {
+ #expect(OverlayArgs.resolveResize(sizePercent: 60, cols: nil, rows: nil, full: false, anchor: nil)
+ == .resolved(size: .percent(60), anchor: nil))
+ #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: true, anchor: nil)
+ == .resolved(size: .full, anchor: nil))
+ #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: 40, rows: 12, full: false, anchor: "left")
+ == .resolved(size: .cells(cols: 40, rows: 12), anchor: .left))
+ }
+
+ @Test func resizeRejectsFullWithAnchor() {
+ #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: true, anchor: "top-left")
+ == .invalid("--full cannot be combined with --anchor"))
+ }
+
+ @Test func resizeRejectsNothingSet() {
+ #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: false, anchor: nil)
+ == .invalid("session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor"))
+ }
+
+ @Test func resizePropagatesSizeAndAnchorRejections() {
+ #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: 5, rows: nil, full: false, anchor: nil)
+ == .invalid("session.overlay.resize: provide both --cols and --rows"))
+ #expect(OverlayArgs.resolveResize(sizePercent: nil, cols: nil, rows: nil, full: false, anchor: "bogus")
+ == .invalid("unknown anchor: bogus (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)"))
+ }
+}
diff --git a/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift
new file mode 100644
index 00000000..37738669
--- /dev/null
+++ b/agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift
@@ -0,0 +1,277 @@
+import Foundation
+import Testing
+@testable import agtermCore
+
+struct OverlayLayoutTests {
+ private func pane(_ width: Double, _ height: Double) -> WindowGeometry.Size {
+ WindowGeometry.Size(width: width, height: height)
+ }
+
+ private func expectSize(_ size: WindowGeometry.Size, _ width: Double, _ height: Double) {
+ #expect(size.width == width)
+ #expect(size.height == height)
+ }
+
+ private func expectRect(_ rect: WindowGeometry.Rect, x: Double, y: Double, width: Double, height: Double) {
+ #expect(rect.origin.x == x)
+ #expect(rect.origin.y == y)
+ #expect(rect.size.width == width)
+ #expect(rect.size.height == height)
+ }
+
+ // mirrors the app-side px->points conversion: every pixel metric divided by the backing scale.
+ private func metrics(cellWpx: Double, cellHpx: Double, padWpx: Double, padHpx: Double,
+ scale: Double) -> OverlayCellMetrics {
+ OverlayCellMetrics(cellWidth: cellWpx / scale,
+ cellHeight: cellHpx / scale,
+ padWidth: padWpx / scale,
+ padHeight: padHpx / scale)
+ }
+
+ @Test func fullReturnsWholePane() {
+ expectSize(OverlayLayout.panelSize(.full, pane: pane(800, 600), cell: nil), 800, 600)
+ }
+
+ @Test func percentScalesBothDimensions() {
+ expectSize(OverlayLayout.panelSize(.percent(50), pane: pane(800, 600), cell: nil), 400, 300)
+ }
+
+ @Test func percentHundredEqualsPane() {
+ expectSize(OverlayLayout.panelSize(.percent(100), pane: pane(800, 600), cell: nil), 800, 600)
+ }
+
+ @Test func percentOneIsTiny() {
+ expectSize(OverlayLayout.panelSize(.percent(1), pane: pane(800, 600), cell: nil), 8, 6)
+ }
+
+ @Test func cellsWithinPaneSnapToRequestedGrid() {
+ let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6)
+ let result = OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: cell)
+ // 10*8+4 = 84 wide, 5*16+6 = 86 tall.
+ expectSize(result, 84, 86)
+ }
+
+ @Test func cellsWiderThanPaneClampToWholeCells() {
+ let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6)
+ // pane holds floor((200-4)/8) = 24 whole columns; a 100-col request clamps to 24 -> 24*8+4 = 196.
+ let result = OverlayLayout.panelSize(.cells(cols: 100, rows: 5), pane: pane(200, 600), cell: cell)
+ expectSize(result, 196, 86)
+ }
+
+ @Test func cellsTallerThanPaneClampToWholeCells() {
+ let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6)
+ // pane holds floor((200-6)/16) = 12 whole rows; a 100-row request clamps to 12 -> 12*16+6 = 198.
+ let result = OverlayLayout.panelSize(.cells(cols: 10, rows: 100), pane: pane(800, 200), cell: cell)
+ expectSize(result, 84, 198)
+ }
+
+ @Test func cellsClampBothDimensionsIndependently() {
+ let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6)
+ let result = OverlayLayout.panelSize(.cells(cols: 100, rows: 100), pane: pane(200, 200), cell: cell)
+ // width floor((200-4)/8) = 24 -> 196; height floor((200-6)/16) = 12 -> 198.
+ expectSize(result, 196, 198)
+ }
+
+ // guards ONLY that the resolver is scale-agnostic — it works in POINT space, so a 1x surface and the
+ // same physical surface at 2x, once BOTH are converted to points (the app divides pixels by the backing
+ // scale before constructing the metrics, mirrored by `metrics(scale:)`), produce identical point
+ // metrics and thus an identical panel. The resolver itself takes no scale, so this cannot catch a bug in
+ // the actual px÷backingScaleFactor conversion — that is app-side and is guarded end to end by the e2e
+ // `stty size` check (`testOverlayColsRowsRealizedGridMatchesRequest`).
+ @Test func equalPointMetricsFromDifferentPixelScalesResolveIdentically() {
+ // a surface at 1x: 8px/16px cells, 4px/2px total padding.
+ let oneX = metrics(cellWpx: 8, cellHpx: 16, padWpx: 4, padHpx: 2, scale: 1)
+ // the same physical surface at 2x: pixel metrics double, backing scale is 2.
+ let twoX = metrics(cellWpx: 16, cellHpx: 32, padWpx: 8, padHpx: 4, scale: 2)
+ #expect(oneX == twoX)
+
+ let request = OverlaySize.cells(cols: 10, rows: 5)
+ let paneSize = pane(800, 600)
+ let sizeOneX = OverlayLayout.panelSize(request, pane: paneSize, cell: oneX)
+ let sizeTwoX = OverlayLayout.panelSize(request, pane: paneSize, cell: twoX)
+ #expect(sizeOneX == sizeTwoX)
+ // in points both resolve to 10*8+4 = 84 wide, 5*16+2 = 82 tall.
+ expectSize(sizeOneX, 84, 82)
+ }
+
+ @Test func subPixelCellMetricsProduceExactPointSize() {
+ // 15px cells on a 2x display convert to 7.5pt; padding 3px -> 1.5pt.
+ let cell = OverlayCellMetrics(cellWidth: 7.5, cellHeight: 7.5, padWidth: 1.5, padHeight: 1.5)
+ let result = OverlayLayout.panelSize(.cells(cols: 4, rows: 4), pane: pane(800, 600), cell: cell)
+ // 4*7.5+1.5 = 31.5 in each dimension.
+ expectSize(result, 31.5, 31.5)
+ }
+
+ @Test func subPixelAvailableFloorsFitCount() {
+ // available 100, pad 1.5, cell 7.5 -> floor((100-1.5)/7.5) = floor(13.133) = 13 columns.
+ let cell = OverlayCellMetrics(cellWidth: 7.5, cellHeight: 7.5, padWidth: 1.5, padHeight: 1.5)
+ let result = OverlayLayout.panelSize(.cells(cols: 50, rows: 50), pane: pane(100, 100), cell: cell)
+ // 13*7.5+1.5 = 99, both dimensions.
+ expectSize(result, 99, 99)
+ }
+
+ @Test func nilCellMetricsFallBackToPane() {
+ expectSize(OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: nil), 800, 600)
+ }
+
+ @Test func unusableCellMetricsFallBackToPane() {
+ let zeroWidth = OverlayCellMetrics(cellWidth: 0, cellHeight: 16, padWidth: 4, padHeight: 6)
+ expectSize(OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: zeroWidth), 800, 600)
+ let negative = OverlayCellMetrics(cellWidth: -8, cellHeight: -16, padWidth: 0, padHeight: 0)
+ expectSize(OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(800, 600), cell: negative), 800, 600)
+ }
+
+ @Test func paneSmallerThanOneCellFloorsToOneCellButNeverExceedsPane() {
+ let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6)
+ // pane too small to hold even one cell + padding: floor at one cell yet capped at the pane size.
+ let result = OverlayLayout.panelSize(.cells(cols: 10, rows: 5), pane: pane(5, 10), cell: cell)
+ expectSize(result, 5, 10)
+ }
+
+ @Test func minOneCellFloorWhenRequestIsZeroFit() {
+ let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 4, padHeight: 6)
+ // a pane that fits exactly one cell + padding still yields one whole cell.
+ let result = OverlayLayout.panelSize(.cells(cols: 100, rows: 100), pane: pane(12, 22), cell: cell)
+ // width 1*8+4 = 12, height 1*16+6 = 22.
+ expectSize(result, 12, 22)
+ }
+
+ @Test func isUsableReflectsPositiveCellDimensions() {
+ #expect(OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 0, padHeight: 0).isUsable)
+ #expect(!OverlayCellMetrics(cellWidth: 0, cellHeight: 16, padWidth: 0, padHeight: 0).isUsable)
+ #expect(!OverlayCellMetrics(cellWidth: 8, cellHeight: 0, padWidth: 0, padHeight: 0).isUsable)
+ #expect(!OverlayCellMetrics(cellWidth: -1, cellHeight: -1, padWidth: 0, padHeight: 0).isUsable)
+ }
+
+ @Test func anchorHasNineCasesWithDefaultCenter() {
+ #expect(OverlayAnchor.allCases.count == 9)
+ #expect(OverlayAnchor(rawValue: "center") == .center)
+ }
+
+ @Test(arguments: [
+ (OverlayAnchor.topLeft, 0.0, 0.0), (.top, 0.5, 0.0), (.topRight, 1.0, 0.0),
+ (.left, 0.0, 0.5), (.center, 0.5, 0.5), (.right, 1.0, 0.5),
+ (.bottomLeft, 0.0, 1.0), (.bottom, 0.5, 1.0), (.bottomRight, 1.0, 1.0)
+ ])
+ func anchorUnitPoints(anchor: OverlayAnchor, unitX: Double, unitY: Double) {
+ #expect(anchor.unitX == unitX)
+ #expect(anchor.unitY == unitY)
+ }
+
+ @Test func anchorRawValuesUseHyphenatedCornerNames() {
+ #expect(OverlayAnchor.topLeft.rawValue == "top-left")
+ #expect(OverlayAnchor.topRight.rawValue == "top-right")
+ #expect(OverlayAnchor.bottomLeft.rawValue == "bottom-left")
+ #expect(OverlayAnchor.bottomRight.rawValue == "bottom-right")
+ #expect(OverlayAnchor.top.rawValue == "top")
+ #expect(OverlayAnchor(rawValue: "diagonal") == nil)
+ }
+
+ // MARK: - panelRect (uniform base-level safe-area size + placement)
+
+ // margin = cellHeight = 16, so a 800x600 pane has a usable safe area of 768x568 (16pt inset per side).
+ private func marginCell() -> OverlayCellMetrics {
+ OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 0, padHeight: 0)
+ }
+
+ @Test func fullOverlayFillsWholePaneNoMarginOnEveryAnchor() {
+ // a full overlay ignores the safe-area margin on every anchor: origin (0,0), size == pane.
+ for anchor in OverlayAnchor.allCases {
+ let rect = OverlayLayout.panelRect(.full, pane: pane(800, 600), cell: marginCell(), anchor: anchor)
+ expectRect(rect, x: 0, y: 0, width: 800, height: 600)
+ }
+ }
+
+ @Test func floatingFullSizeRequestFillsSafeAreaOnEveryAnchor() {
+ // a >= usable request (100%) clamps to the safe area (768x568) and, with no slack left, lands at
+ // origin (m, m) = (16, 16) regardless of the anchor — an equal margin on all four sides.
+ for anchor in OverlayAnchor.allCases {
+ let rect = OverlayLayout.panelRect(.percent(100), pane: pane(800, 600), cell: marginCell(), anchor: anchor)
+ expectRect(rect, x: 16, y: 16, width: 768, height: 568)
+ }
+ }
+
+ @Test func cornerAnchorPlacesSmallPanelAtMargin() {
+ // top-left: a small floating panel sits exactly one line-height off the leading + top edges.
+ let tl = OverlayLayout.panelRect(.percent(10), pane: pane(800, 600), cell: marginCell(), anchor: .topLeft)
+ expectRect(tl, x: 16, y: 16, width: 80, height: 60)
+
+ // bottom-right: one line-height off the trailing + bottom edges (mirrored via the anchor unit point).
+ // originX = 16 + (768-80) = 704, originY = 16 + (568-60) = 524; the right + bottom margins are both 16.
+ let br = OverlayLayout.panelRect(.percent(10), pane: pane(800, 600), cell: marginCell(), anchor: .bottomRight)
+ expectRect(br, x: 704, y: 524, width: 80, height: 60)
+ #expect(800 - (br.origin.x + br.size.width) == 16)
+ #expect(600 - (br.origin.y + br.size.height) == 16)
+ }
+
+ @Test func centerAnchorCentersWithinSafeArea() {
+ // center: a small panel is centered, so the left/right and top/bottom margins are symmetric.
+ let rect = OverlayLayout.panelRect(.percent(10), pane: pane(800, 600), cell: marginCell(), anchor: .center)
+ expectRect(rect, x: 360, y: 270, width: 80, height: 60)
+ #expect(rect.origin.x == 800 - (rect.origin.x + rect.size.width)) // left margin == right margin
+ #expect(rect.origin.y == 600 - (rect.origin.y + rect.size.height)) // top margin == bottom margin
+ }
+
+ @Test func fullWidthBandIsInsetOnAllFourSides() {
+ // a full-usable-width top band (cols far exceeding the pane, few rows) is clamped to the safe-area
+ // WIDTH and, anchored top, carries an EQUAL one-line-height margin on the left, right, AND top (the
+ // maintainer fix: a full-width band is inset left/right exactly like the top, independent of anchor),
+ // with the bottom free.
+ let rect = OverlayLayout.panelRect(.cells(cols: 200, rows: 5), pane: pane(800, 600), cell: marginCell(), anchor: .top)
+ let leftMargin = rect.origin.x
+ let rightMargin = 800 - (rect.origin.x + rect.size.width)
+ let topMargin = rect.origin.y
+ #expect(leftMargin == 16)
+ #expect(rightMargin == 16)
+ #expect(topMargin == 16)
+ #expect(leftMargin == rightMargin) // symmetric horizontal margins for a full-width band
+ #expect(leftMargin == topMargin) // horizontal margin equals the vertical margin (uniform)
+ #expect(rect.size.width == 768) // the band fills the whole safe-area width
+ }
+
+ @Test func nilCellMetricsYieldNoMarginPanelMayFillPane() {
+ // nil cell metrics -> m = 0: a 100% floating panel fills the pane (no safe-area inset).
+ let rect = OverlayLayout.panelRect(.percent(100), pane: pane(800, 600), cell: nil, anchor: .center)
+ expectRect(rect, x: 0, y: 0, width: 800, height: 600)
+ }
+
+ @Test func unusableCellMetricsYieldNoMargin() {
+ // a zero-width cell is unusable -> m = 0, no inset, panel may fill the pane.
+ let zeroWidth = OverlayCellMetrics(cellWidth: 0, cellHeight: 16, padWidth: 0, padHeight: 0)
+ let rect = OverlayLayout.panelRect(.percent(100), pane: pane(800, 600), cell: zeroWidth, anchor: .topLeft)
+ expectRect(rect, x: 0, y: 0, width: 800, height: 600)
+ }
+
+ @Test func cellsBandInsetFromEverySideEvenAnchoredTopLeft() {
+ // a cells band that exactly fills the safe area sits at (m, m) with an m margin on all sides, even at
+ // top-left: originX = m + 0*(usableW - panelW) = m, and panelW == usableW leaves an m gap on the right.
+ let cell = OverlayCellMetrics(cellWidth: 8, cellHeight: 16, padWidth: 0, padHeight: 0)
+ let rect = OverlayLayout.panelRect(.cells(cols: 200, rows: 200), pane: pane(800, 600), cell: cell, anchor: .topLeft)
+ // usableW = 768 (96 whole cells of 8pt), usableH = 568.
+ expectRect(rect, x: 16, y: 16, width: 768, height: 568)
+ #expect(800 - (rect.origin.x + rect.size.width) == 16) // right margin present despite top-left anchor
+ #expect(600 - (rect.origin.y + rect.size.height) == 16) // bottom margin present too
+ }
+
+ @Test func splitCanvasColsFloorsCombinedWidthOnce() {
+ // two 800px panes at an 8px cell: 1600/8 = 200 columns spanning the whole detail width.
+ #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 800, splitWidthPx: 800, primaryCellWidthPx: 8) == 200)
+ }
+
+ @Test func splitCanvasColsFloorsOnceNotPerPane() {
+ // each pane holds 12.5 cells (100/8); summing per-pane floored counts gives 12+12 = 24, but the whole
+ // detail width is 200/8 = 25 — flooring ONCE recovers the cell straddling the (uncounted) divider.
+ #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 100, splitWidthPx: 100, primaryCellWidthPx: 8) == 25)
+ }
+
+ @Test func splitCanvasColsUsesPrimaryCellSizeIgnoringSplitFont() {
+ // the split pane's own (larger) font is irrelevant — the combined width is measured at the PRIMARY
+ // cell size: (400 + 320) / 8 = 90, not a mix of 8px and 16px cells.
+ #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 400, splitWidthPx: 320, primaryCellWidthPx: 8) == 90)
+ }
+
+ @Test func splitCanvasColsNilForNonPositiveInputs() {
+ #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 800, splitWidthPx: 800, primaryCellWidthPx: 0) == nil)
+ #expect(OverlayLayout.splitCanvasCols(primaryWidthPx: 0, splitWidthPx: 0, primaryCellWidthPx: 8) == nil)
+ }
+}
diff --git a/agtermCore/Tests/agtermCoreTests/SessionTests.swift b/agtermCore/Tests/agtermCoreTests/SessionTests.swift
index 71ed8736..417a7533 100644
--- a/agtermCore/Tests/agtermCoreTests/SessionTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/SessionTests.swift
@@ -402,20 +402,38 @@ struct SessionTests {
}
@Test func fullOverlayActiveOnlyForFullCoverageOverlay() {
- // the full-coverage overlay (no size) hides the session content beneath it — panes AND scratch —
+ // the full-coverage overlay (.full) hides the session content beneath it — panes AND scratch —
// so its translucent background reveals the window backing, never the covered surfaces.
let session = Session(initialCwd: "/repo")
// no overlay: nothing to hide behind.
#expect(session.fullOverlayActive == false)
- // full-coverage overlay (no size percent): active.
+ #expect(session.floatingOverlayActive == false)
+ // full-coverage overlay (.full size): active and not floating.
session.overlayActive = true
#expect(session.fullOverlayActive == true)
- // floating (sized) overlay: draws an opaque panel over visible content, not a full cover.
- session.overlaySizePercent = 80
+ #expect(session.floatingOverlayActive == false)
+ // floating (percent) overlay: draws an opaque panel over visible content, not a full cover.
+ session.overlaySize = .percent(80)
#expect(session.fullOverlayActive == false)
- // overlay closed with a stale size percent lingering: still not a cover.
+ #expect(session.floatingOverlayActive == true)
+ // a cells-mode floating overlay is likewise not a full cover.
+ session.overlaySize = .cells(cols: 80, rows: 24)
+ #expect(session.fullOverlayActive == false)
+ #expect(session.floatingOverlayActive == true)
+ // overlay closed with a stale non-full size lingering: neither predicate fires.
session.overlayActive = false
#expect(session.fullOverlayActive == false)
+ #expect(session.floatingOverlayActive == false)
+ }
+
+ @Test func overlayStateDefaultsToFullCenterNilMetrics() {
+ // a fresh session carries the full/center/nil overlay defaults until an open sets them.
+ let session = Session(initialCwd: "/repo")
+ #expect(session.overlaySize == .full)
+ #expect(session.overlayAnchor == .center)
+ #expect(session.overlayCellMetrics == nil)
+ #expect(session.overlayAppliedCols == nil)
+ #expect(session.overlayAppliedRows == nil)
}
@Test func paneRoleResolvesTokenToItsCurrentSlot() {
diff --git a/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift b/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift
index 9429340c..529e8d93 100644
--- a/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift
@@ -101,7 +101,7 @@ struct TerminalZoomTests {
store.closeScratch(session.id)
#expect(!TerminalZoomController.isTargetValid(.session(session.id, .scratch), in: store, quickTerminalVisible: false))
- #expect(store.openOverlay(session.id, command: "top"))
+ #expect(store.openOverlay(session.id, options: .init(command: "top")))
#expect(TerminalZoomController.isTargetValid(.session(session.id, .overlay), in: store, quickTerminalVisible: false))
#expect(store.closeOverlay(session.id))
#expect(!TerminalZoomController.isTargetValid(.session(session.id, .overlay), in: store, quickTerminalVisible: false))
diff --git a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift
index d0b02644..61ae1e73 100644
--- a/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift
+++ b/agtermCore/Tests/agtermctlKitTests/CommandsTests.swift
@@ -773,6 +773,74 @@ struct CommandsTests {
#expect(throws: (any Error).self) { try Agtermctl.parseAsRoot(["session", "overlay", "resize", "--size-percent", "0"]) }
}
+ @Test func sessionOverlayOpenWithCellsAndAnchor() throws {
+ let expected = ControlRequest(cmd: .sessionOverlayOpen, target: "active",
+ args: ControlArgs(command: "htop", cols: 80, rows: 24, anchor: "top-left"))
+ #expect(try request(["session", "overlay", "open", "htop", "--cols", "80", "--rows", "24",
+ "--anchor", "top-left"]) == expected)
+ }
+
+ @Test func sessionOverlayOpenPercentWithAnchor() throws {
+ let expected = ControlRequest(cmd: .sessionOverlayOpen, target: "active",
+ args: ControlArgs(command: "htop", sizePercent: 50, anchor: "bottom-right"))
+ #expect(try request(["session", "overlay", "open", "htop", "--size-percent", "50",
+ "--anchor", "bottom-right"]) == expected)
+ }
+
+ @Test func sessionOverlayOpenValidatesSizeAndAnchor() {
+ // Decision 3: open now hard-errors on an out-of-range percent (previously silently clamped), and
+ // mirrors the dispatcher's one-of / pairing / anchor rules.
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "0"])
+ == "session.overlay.open: --size-percent must be 1...100")
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "101"])
+ == "session.overlay.open: --size-percent must be 1...100")
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--cols", "80"])
+ == "session.overlay.open: provide both --cols and --rows")
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--rows", "24"])
+ == "session.overlay.open: provide both --cols and --rows")
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--cols", "0", "--rows", "24"])
+ == "session.overlay.open: --cols and --rows must be >= 1")
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "50", "--cols", "5", "--rows", "5"])
+ == "session.overlay.open: use only one of --size-percent or --cols/--rows")
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--size-percent", "50", "--anchor", "bogus"])
+ == "unknown anchor: bogus (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")
+ #expect(validationMessage(["session", "overlay", "open", "htop", "--anchor", "top-left"])
+ == "--anchor requires a floating overlay: use --size-percent or --cols/--rows")
+ }
+
+ @Test func sessionOverlayResizeWithCells() throws {
+ let expected = ControlRequest(cmd: .sessionOverlayResize, target: "active", args: ControlArgs(cols: 100, rows: 30))
+ #expect(try request(["session", "overlay", "resize", "--cols", "100", "--rows", "30"]) == expected)
+ }
+
+ @Test func sessionOverlayResizeAnchorOnly() throws {
+ // an --anchor with no size keeps the current size and just re-anchors (nil size on the wire).
+ let expected = ControlRequest(cmd: .sessionOverlayResize, target: "active", args: ControlArgs(anchor: "top"))
+ #expect(try request(["session", "overlay", "resize", "--anchor", "top"]) == expected)
+ }
+
+ @Test func sessionOverlayResizeCellsWithAnchor() throws {
+ let expected = ControlRequest(cmd: .sessionOverlayResize, target: "9f3c",
+ args: ControlArgs(cols: 40, rows: 12, anchor: "left"))
+ #expect(try request(["session", "overlay", "resize", "--cols", "40", "--rows", "12",
+ "--anchor", "left", "--target", "9f3c"]) == expected)
+ }
+
+ @Test func sessionOverlayResizeValidatesSizeAndAnchor() {
+ #expect(validationMessage(["session", "overlay", "resize"])
+ == "session.overlay.resize requires a size (--full, --size-percent, --cols/--rows) or --anchor")
+ #expect(validationMessage(["session", "overlay", "resize", "--full", "--anchor", "top-left"])
+ == "--full cannot be combined with --anchor")
+ #expect(validationMessage(["session", "overlay", "resize", "--full", "--cols", "5", "--rows", "5"])
+ == "session.overlay.resize: use only one of --full, --size-percent, or --cols/--rows")
+ #expect(validationMessage(["session", "overlay", "resize", "--cols", "5"])
+ == "session.overlay.resize: provide both --cols and --rows")
+ #expect(validationMessage(["session", "overlay", "resize", "--size-percent", "150"])
+ == "session.overlay.resize: --size-percent must be 1...100")
+ #expect(validationMessage(["session", "overlay", "resize", "--anchor", "bogus"])
+ == "unknown anchor: bogus (top-left|top|top-right|left|center|right|bottom-left|bottom|bottom-right)")
+ }
+
@Test func sessionOverlayBlockRejectsWait() {
// validate() enforces the mutually-exclusive flags at parse time (before any connection).
#expect(throws: (any Error).self) {
diff --git a/agtermUITests/ControlOverlaySplitUITests.swift b/agtermUITests/ControlOverlaySplitUITests.swift
index 0722fe3a..05028dec 100644
--- a/agtermUITests/ControlOverlaySplitUITests.swift
+++ b/agtermUITests/ControlOverlaySplitUITests.swift
@@ -677,6 +677,329 @@ final class ControlOverlaySplitUITests: ControlAPITestCase {
XCTAssertEqual(empty["ok"] as? Bool, false, "resize with no fraction should fail: \(empty)")
}
+ // session.overlay.open --cols/--rows/--anchor: the tree read-back reports the REQUESTED grid
+ // (overlayCols/Rows), the realized grid after clamping (overlayColsApplied/RowsApplied), and the anchor.
+ // The window is given headroom first so a 40x12 grid fits without clamping (applied == requested).
+ func testOverlayOpenColsRowsAnchorReadBack() throws {
+ let id = try activeSessionID()
+ try resizeWindow(width: 1100, height: 750)
+
+ let open = try sendOverlayOpen(target: id, command: "cat", args: ["cols": 40, "rows": 12, "anchor": "top-right"])
+ XCTAssertEqual(open["ok"] as? Bool, true, "overlay open with cols/rows/anchor should succeed: \(open)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up")
+
+ let node = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12),
+ "the tree should report the realized overlay grid (overlayColsApplied/RowsApplied)")
+ // the requested grid is echoed verbatim.
+ XCTAssertEqual(node["overlayCols"] as? Int, 40, "tree should echo the requested cols: \(node)")
+ XCTAssertEqual(node["overlayRows"] as? Int, 12, "tree should echo the requested rows: \(node)")
+ // the applied grid reflects the realized surface; with headroom it matches the request within padding
+ // drift (a 2x Retina px->point miss would size the grid ~2x off).
+ let appliedCols = try XCTUnwrap(node["overlayColsApplied"] as? Int, "applied cols should be present: \(node)")
+ let appliedRows = try XCTUnwrap(node["overlayRowsApplied"] as? Int, "applied rows should be present: \(node)")
+ XCTAssertTrue(abs(appliedCols - 40) <= 2, "applied cols \(appliedCols) should match the requested 40 (2x would be ~80): \(node)")
+ XCTAssertTrue(abs(appliedRows - 12) <= 2, "applied rows \(appliedRows) should match the requested 12 (2x would be ~24): \(node)")
+ XCTAssertEqual(node["overlayAnchor"] as? String, "top-right", "tree should report the anchor: \(node)")
+
+ let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#)
+ XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)")
+ }
+
+ // The ACTUAL grid check that a wire/echo round-trip alone can't make: run `stty size` inside the overlay
+ // and assert the pty's realized rows/cols match the requested cols/rows. libghostty sizes the pty from the
+ // panel's POINT dimensions, so a Retina px->point miss would size the grid ~2x off and this marker catches
+ // it. `cat` holds the overlay open so the tree read-back can be compared against stty's report.
+ func testOverlayColsRowsRealizedGridMatchesRequest() throws {
+ let id = try activeSessionID()
+ try resizeWindow(width: 1100, height: 750)
+
+ let marker = markerDir.appendingPathComponent("overlay-grid")
+ // the overlay wraps its command in `sh -c`, so `stty size` (prints "rows cols") then `cat` work directly.
+ let cmd = "stty size > \(marker.path); cat"
+ let open = try sendOverlayOpen(target: id, command: cmd, args: ["cols": 40, "rows": 12])
+ XCTAssertEqual(open["ok"] as? Bool, true, "overlay open should succeed: \(open)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up")
+
+ let sizeText = try XCTUnwrap(pollMarker(marker, timeout: 15), "the overlay command should write its grid size")
+ let numbers = sizeText.split(separator: " ").compactMap { Int($0) }
+ XCTAssertEqual(numbers.count, 2, "stty size should report two integers (rows cols): \(sizeText)")
+ let rows = numbers[0], cols = numbers[1]
+ // the realized grid must match the request within padding drift; a 2x Retina error would be ~80x24.
+ XCTAssertTrue(abs(cols - 40) <= 2, "the overlay's actual columns \(cols) should match the requested 40 (2x would be ~80)")
+ XCTAssertTrue(abs(rows - 12) <= 2, "the overlay's actual rows \(rows) should match the requested 12 (2x would be ~24)")
+
+ // the tree read-back's APPLIED grid reflects the same realized surface stty reads.
+ let node = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12), "the tree should report the realized grid")
+ let appliedCols = try XCTUnwrap(node["overlayColsApplied"] as? Int, "applied cols should be present: \(node)")
+ let appliedRows = try XCTUnwrap(node["overlayRowsApplied"] as? Int, "applied rows should be present: \(node)")
+ XCTAssertTrue(abs(appliedCols - cols) <= 1, "applied cols \(appliedCols) should match stty's \(cols): \(node)")
+ XCTAssertTrue(abs(appliedRows - rows) <= 1, "applied rows \(appliedRows) should match stty's \(rows): \(node)")
+
+ let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#)
+ XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)")
+ }
+
+ // The requested-vs-applied CLAMP path end to end: a deliberately OVERSIZED grid (500x200) cannot fit any
+ // real pane, so the whole-cell clamp must produce a realized grid SMALLER than the request. The tree echoes
+ // the requested grid verbatim (overlayCols/Rows == 500/200) while the APPLIED grid
+ // (overlayColsApplied/RowsApplied) — read from the REALIZED surface, not the request — must be strictly
+ // less on BOTH axes. This is the case the fitting tests can't reach (they give headroom so applied ==
+ // requested): a bug populating applied from the request instead of the realized surface would pass those
+ // but FAIL here.
+ func testOverlayColsRowsClampReportsAppliedBelowRequested() throws {
+ let id = try activeSessionID()
+ // a modest window so 500x200 vastly exceeds the pane and the clamp definitely binds on both axes.
+ try resizeWindow(width: 900, height: 650)
+
+ let open = try sendOverlayOpen(target: id, command: "cat", args: ["cols": 500, "rows": 200])
+ XCTAssertEqual(open["ok"] as? Bool, true, "oversized overlay open should succeed: \(open)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up")
+
+ let node = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12),
+ "the tree should report the realized overlay grid after clamping")
+ // the requested grid is echoed verbatim (the restore key), unclamped.
+ XCTAssertEqual(node["overlayCols"] as? Int, 500, "tree should echo the requested cols verbatim: \(node)")
+ XCTAssertEqual(node["overlayRows"] as? Int, 200, "tree should echo the requested rows verbatim: \(node)")
+ // the applied grid reflects the realized surface, clamped strictly below the impossible request.
+ let appliedCols = try XCTUnwrap(node["overlayColsApplied"] as? Int, "applied cols should be present: \(node)")
+ let appliedRows = try XCTUnwrap(node["overlayRowsApplied"] as? Int, "applied rows should be present: \(node)")
+ XCTAssertLessThan(appliedCols, 500, "applied cols \(appliedCols) must be clamped below the requested 500: \(node)")
+ XCTAssertLessThan(appliedRows, 200, "applied rows \(appliedRows) must be clamped below the requested 200: \(node)")
+ // sanity: the realized grid is still a real, positive grid (not zero/degenerate).
+ XCTAssertGreaterThan(appliedCols, 0, "applied cols should be a real grid: \(node)")
+ XCTAssertGreaterThan(appliedRows, 0, "applied rows should be a real grid: \(node)")
+
+ let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#)
+ XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)")
+ }
+
+ // tree canvasCols/canvasRows report the session's terminal CONTENT AREA in cells at the base font (the RAW
+ // grid — the read-back semantics are UNCHANGED by the safe-area margin), so a script can size a floating
+ // overlay as a fraction of the canvas. Sizing an overlay to the WHOLE canvas (`--cols canvasCols --rows
+ // canvasRows`) now CLAMPS to the safe area (the canvas inset by one line-height on all sides), so the
+ // realized (applied) grid is a FEW cells smaller than the canvas — but still CLOSE to it. This stays
+ // discriminating against a Retina ~2x cell-count miss: a canvas reported ~2x off would size the overlay at
+ // ~half or ~double the canvas, both far outside the narrow "canvas minus the margin" window asserted here.
+ func testTreeReportsCanvasGridAndOverlayFillsIt() throws {
+ let id = try activeSessionID()
+ try resizeWindow(width: 1100, height: 750)
+
+ let node = try XCTUnwrap(pollCanvasGrid(id: id, timeout: 12),
+ "the tree should report the terminal content area (canvasCols/canvasRows)")
+ let canvasCols = try XCTUnwrap(node["canvasCols"] as? Int, "canvasCols should be present: \(node)")
+ let canvasRows = try XCTUnwrap(node["canvasRows"] as? Int, "canvasRows should be present: \(node)")
+ XCTAssertGreaterThan(canvasCols, 0, "canvasCols should be a real grid: \(node)")
+ XCTAssertGreaterThan(canvasRows, 0, "canvasRows should be a real grid: \(node)")
+
+ // open an overlay sized to the whole canvas; the realized grid fills the SAFE AREA (canvas minus the
+ // one-line-height margin on each side), so it lands just below the canvas grid, not at it.
+ let open = try sendOverlayOpen(target: id, command: "cat", args: ["cols": canvasCols, "rows": canvasRows])
+ XCTAssertEqual(open["ok"] as? Bool, true, "overlay open filling the canvas should succeed: \(open)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up")
+
+ let applied = try XCTUnwrap(pollOverlayApplied(id: id, timeout: 12), "the tree should report the realized grid")
+ let appliedCols = try XCTUnwrap(applied["overlayColsApplied"] as? Int, "applied cols should be present: \(applied)")
+ let appliedRows = try XCTUnwrap(applied["overlayRowsApplied"] as? Int, "applied rows should be present: \(applied)")
+ // clamped by the safe-area margin: a few cells below the canvas, never above it, and nowhere near a
+ // ~2x miss (~half or ~double). the horizontal margin drops ~4 cols (2 line-heights / cell width), the
+ // vertical ~2 rows (2 line-heights / cell height).
+ XCTAssertLessThanOrEqual(appliedCols, canvasCols,
+ "the safe-area-clamped overlay never exceeds the canvas: applied \(appliedCols), canvas \(canvasCols)")
+ XCTAssertGreaterThan(appliedCols, canvasCols - 12,
+ "the overlay nearly fills the canvas width minus the margin (a ~2x miss would be far off): applied \(appliedCols), canvas \(canvasCols)")
+ XCTAssertLessThanOrEqual(appliedRows, canvasRows,
+ "the safe-area-clamped overlay never exceeds the canvas: applied \(appliedRows), canvas \(canvasRows)")
+ XCTAssertGreaterThan(appliedRows, canvasRows - 10,
+ "the overlay nearly fills the canvas height minus the margin (a ~2x miss would be far off): applied \(appliedRows), canvas \(canvasRows)")
+
+ let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#)
+ XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)")
+ }
+
+ // A floating panel sits inside a uniform SAFE AREA — the pane inset by one line-height on ALL FOUR sides,
+ // independent of the anchor. This test proves three things against a FULL-overlay detail-area reference:
+ // (1) a corner-anchored panel insets off BOTH anchored edges by a small, ~equal, positive margin (NOT
+ // flush, NOT a centered half-slack offset); (2) re-anchoring to the opposite corner mirrors the margin to
+ // the other two edges; (3) a FULL-WIDTH band (cols == canvasCols) is inset off the LEFT and RIGHT too — the
+ // maintainer fix (the margin is a base-level default on every side, so a full-width band is inset left/right
+ // exactly like the top, never flush against the side borders). The full overlay carries the
+ // `overlay-full-panel` marker (its frame is the whole detail area, the reference), the floating panel
+ // `overlay-floating-panel`; the Metal surface itself is not in the a11y tree.
+ func testFloatingOverlayPanelFrameFollowsCornerAnchor() throws {
+ let id = try activeSessionID()
+ try resizeWindow(width: 1100, height: 750)
+
+ // a FULL overlay fills the detail area exactly (origin 0,0, size = the pane) with NO margin, so its
+ // `overlay-full-panel` marker frame is the reference the floating safe-area margins are measured against.
+ let reference = try sendOverlayOpen(target: id, command: "cat", args: [:])
+ XCTAssertEqual(reference["ok"] as? Bool, true, "reference full overlay open should succeed: \(reference)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the reference overlay should be up")
+
+ let full = app.descendants(matching: .any).matching(identifier: "overlay-full-panel").firstMatch
+ XCTAssertTrue(full.waitForExistence(timeout: 10), "the full overlay panel should be in the a11y tree")
+ let detail = full.frame
+
+ // resize to a 40% floating panel anchored top-left: it shrinks well below the pane and insets one
+ // line-height off BOTH the top and the left inside the safe area.
+ let toFloating = try sendOverlayResize(target: id, args: ["sizePercent": 40, "anchor": "top-left"])
+ XCTAssertEqual(toFloating["ok"] as? Bool, true, "resize to floating top-left should succeed: \(toFloating)")
+ let panel = app.descendants(matching: .any).matching(identifier: "overlay-floating-panel").firstMatch
+ let tl = try XCTUnwrap(pollPanelWidth(panel: panel, below: detail.width * 0.6, timeout: 8),
+ "the panel should shrink to the floating size")
+ XCTAssertLessThan(tl.height, detail.height * 0.6, "a 40% floating panel should be much shorter than the pane")
+
+ // top-left => a small POSITIVE margin off the pane's left and top (NOT flush at 0, NOT the large
+ // half-slack offset a centered panel would show). the margin is one line-height (~17pt at the default
+ // font) on BOTH the left and the top, so the two are approximately equal.
+ let marginX = tl.minX - detail.minX
+ let marginY = tl.minY - detail.minY
+ XCTAssertGreaterThan(marginX, 2, "top-left panel should inset a line-height off the left, not sit flush (marginX=\(marginX))")
+ XCTAssertGreaterThan(marginY, 4, "top-left panel should inset a line-height off the top, not sit flush (marginY=\(marginY))")
+ XCTAssertLessThan(marginX, 30, "the left margin should be ~1 line-height, not a centered half-slack offset (marginX=\(marginX))")
+ XCTAssertLessThan(marginY, 45, "the top margin should be ~1 line-height, not a centered half-slack offset (marginY=\(marginY))")
+ XCTAssertEqual(marginX, marginY, accuracy: 3,
+ "the horizontal and vertical margins should be ~equal (both one line-height): marginX=\(marginX) marginY=\(marginY)")
+
+ // re-anchor to the opposite corner: the panel must move right AND down, and now insets a cell off the
+ // pane's RIGHT and BOTTOM edges by the same ~1-cell margin (mirrored to the anchored side).
+ let reanchor = try sendOverlayResize(target: id, args: ["anchor": "bottom-right"])
+ XCTAssertEqual(reanchor["ok"] as? Bool, true, "re-anchor should succeed: \(reanchor)")
+ let br = try XCTUnwrap(pollPanelMoved(panel: panel, from: tl, timeout: 8),
+ "the panel should move after re-anchoring to bottom-right")
+ XCTAssertGreaterThan(br.minX, tl.minX + 20, "bottom-right anchor should shift the panel right")
+ XCTAssertGreaterThan(br.minY, tl.minY + 20, "bottom-right anchor should shift the panel down")
+ let marginRight = detail.maxX - br.maxX
+ let marginBottom = detail.maxY - br.maxY
+ XCTAssertGreaterThan(marginRight, 2, "bottom-right panel should inset a cell off the right (marginRight=\(marginRight))")
+ XCTAssertGreaterThan(marginBottom, 4, "bottom-right panel should inset a cell off the bottom (marginBottom=\(marginBottom))")
+ XCTAssertLessThan(marginRight, 30, "the right margin should be ~1 cell (marginRight=\(marginRight))")
+ XCTAssertLessThan(marginBottom, 45, "the bottom margin should be ~1 cell (marginBottom=\(marginBottom))")
+
+ // a FULL-WIDTH band (cols == canvasCols) anchored top must now be inset from the LEFT and the RIGHT too
+ // — the maintainer fix: the safe-area margin is a base-level default on all sides, so a full-width band
+ // is inset left/right exactly like the top, never flush against the side borders (the OLD anchor-edge
+ // model left a full-width band flush left/right, which these > assertions now catch).
+ let canvasNode = try XCTUnwrap(pollCanvasGrid(id: id, timeout: 12), "the tree should report the canvas grid")
+ let canvasCols = try XCTUnwrap(canvasNode["canvasCols"] as? Int, "canvasCols should be present: \(canvasNode)")
+ let band = try sendOverlayResize(target: id, args: ["cols": canvasCols, "rows": 6, "anchor": "top"])
+ XCTAssertEqual(band["ok"] as? Bool, true, "resize to a full-width band should succeed: \(band)")
+ let bandFrame = try XCTUnwrap(pollPanelWidth(panel: panel, above: detail.width * 0.8, timeout: 8),
+ "the band should widen to nearly the full detail width")
+ let bandLeft = bandFrame.minX - detail.minX
+ let bandRight = detail.maxX - bandFrame.maxX
+ let bandTop = bandFrame.minY - detail.minY
+ XCTAssertGreaterThan(bandLeft, 2, "a full-width band must have a LEFT margin, not sit flush (bandLeft=\(bandLeft))")
+ XCTAssertGreaterThan(bandRight, 2, "a full-width band must have a RIGHT margin, not sit flush (bandRight=\(bandRight))")
+ XCTAssertGreaterThan(bandTop, 2, "a top-anchored band still has a top margin (bandTop=\(bandTop))")
+ XCTAssertLessThan(bandLeft, 30, "the band's left margin should be ~1 line-height (bandLeft=\(bandLeft))")
+ XCTAssertLessThan(bandRight, 30, "the band's right margin should be ~1 line-height (bandRight=\(bandRight))")
+ XCTAssertEqual(bandLeft, bandRight, accuracy: 3,
+ "the band's left and right margins should be ~equal (symmetric): left=\(bandLeft) right=\(bandRight)")
+ XCTAssertLessThan(bandFrame.width, detail.width,
+ "the band must be narrower than the full detail area (inset on both sides): band=\(bandFrame.width) detail=\(detail.width)")
+
+ let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#)
+ XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)")
+ }
+
+ // The full transition cycle over the socket, driven while a SPLIT is visible to guard the NSSplitView-overrun
+ // invariant (the overlay panel must keep a constant ZStack shape across every resize/re-anchor). Open
+ // --size-percent, resize to --cols/--rows, re-anchor with --anchor only (size retained), resize to --full
+ // (cols/rows cleared, anchor RETAINED per Decision 2), then back to a floating percent. The read-back is
+ // asserted after each step, and the split surviving the cycle is the no-crash/no-overrun oracle.
+ func testOverlayResizeTransitionsCycleWhileSplit() throws {
+ let id = try activeSessionID()
+ XCTAssertEqual(try sendCommand(#"{"cmd":"session.split","target":"\#(id)","args":{"mode":"on"}}"#)["ok"] as? Bool,
+ true, "opening the split should succeed")
+ XCTAssertTrue(pollActiveSessionSplit(true, timeout: 10), "the split should be up")
+
+ // open a floating percent overlay: default (center) anchor, no cols/rows.
+ let open = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 50])
+ XCTAssertEqual(open["ok"] as? Bool, true, "floating overlay open should succeed: \(open)")
+ let atPercent = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlaySizePercent"] as? Int == 50 },
+ "tree should report the 50% floating overlay")
+ XCTAssertEqual(atPercent["overlayAnchor"] as? String, "center", "the default anchor is center: \(atPercent)")
+ XCTAssertNil(atPercent["overlayCols"] as? Int, "a percent overlay reports no requested cols: \(atPercent)")
+
+ // resize to an exact grid with a corner anchor: percent clears, cols/rows + anchor set.
+ let toCells = try sendOverlayResize(target: id, args: ["cols": 30, "rows": 10, "anchor": "bottom-left"])
+ XCTAssertEqual(toCells["ok"] as? Bool, true, "resize to cols/rows should succeed: \(toCells)")
+ let atCells = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlayCols"] as? Int == 30 },
+ "tree should report the requested cols after resize")
+ XCTAssertEqual(atCells["overlayRows"] as? Int, 10, "tree should report the requested rows: \(atCells)")
+ XCTAssertEqual(atCells["overlayAnchor"] as? String, "bottom-left", "tree should report the new anchor: \(atCells)")
+ XCTAssertNil(atCells["overlaySizePercent"] as? Int, "a cells overlay reports no percent: \(atCells)")
+
+ // re-anchor ONLY: the size (cols/rows) is retained, just the anchor moves.
+ let reanchor = try sendOverlayResize(target: id, args: ["anchor": "top"])
+ XCTAssertEqual(reanchor["ok"] as? Bool, true, "re-anchor only should succeed: \(reanchor)")
+ let atTop = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlayAnchor"] as? String == "top" },
+ "tree should report the re-anchored overlay")
+ XCTAssertEqual(atTop["overlayCols"] as? Int, 30, "re-anchor keeps the requested cols: \(atTop)")
+ XCTAssertEqual(atTop["overlayRows"] as? Int, 10, "re-anchor keeps the requested rows: \(atTop)")
+
+ // resize to full: cols/rows + applied clear, but the anchor is PRESERVED across --full (Decision 2).
+ let toFull = try sendOverlayResize(target: id, args: ["full": true])
+ XCTAssertEqual(toFull["ok"] as? Bool, true, "resize to full should succeed: \(toFull)")
+ let atFull = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) {
+ ($0["overlay"] as? Bool == true) && ($0["overlayCols"] as? Int == nil) && ($0["overlaySizePercent"] as? Int == nil)
+ }, "tree should report a full overlay with cleared cols/rows")
+ XCTAssertNil(atFull["overlayColsApplied"] as? Int, "a full overlay reports no applied cols: \(atFull)")
+ XCTAssertEqual(atFull["overlayAnchor"] as? String, "top", "--full must PRESERVE the anchor (Decision 2): \(atFull)")
+
+ // back to a floating percent with a new anchor: the overlay never re-spawned and the split is intact.
+ let backToPercent = try sendOverlayResize(target: id, args: ["sizePercent": 60, "anchor": "bottom-right"])
+ XCTAssertEqual(backToPercent["ok"] as? Bool, true, "resize back to percent should succeed: \(backToPercent)")
+ let atPercent2 = try XCTUnwrap(pollOverlayNode(id: id, timeout: 10) { $0["overlaySizePercent"] as? Int == 60 },
+ "tree should report the 60% floating overlay")
+ XCTAssertEqual(atPercent2["overlayAnchor"] as? String, "bottom-right", "tree should report the new anchor: \(atPercent2)")
+
+ // the split survived every transition (the NSSplitView-overrun guard: no crash, no re-host).
+ XCTAssertTrue(pollActiveSessionSplit(true, timeout: 5), "the split must survive the resize/re-anchor cycle")
+ XCTAssertTrue(pollSessionRowCount(1, timeout: 5), "the session must survive the cycle")
+
+ let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#)
+ XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: false, timeout: 10), "the overlay should be gone")
+ XCTAssertTrue(pollActiveSessionSplit(true, timeout: 5), "the split remains after the overlay closes")
+ }
+
+ // The dispatcher rejects the invalid size/anchor combinations server-side (a raw client bypasses the CLI
+ // validate()): --anchor with no floating size, only one of --cols/--rows, an out-of-range OPEN percent
+ // (now a hard error, Decision 3), and --full combined with --anchor on resize.
+ func testOverlaySizingAnchorErrorsOverSocket() throws {
+ let id = try activeSessionID()
+
+ // --anchor without a floating size on open is rejected, and opens nothing.
+ let anchorNoSize = try sendOverlayOpen(target: id, command: "cat", args: ["anchor": "top-left"])
+ XCTAssertEqual(anchorNoSize["ok"] as? Bool, false, "anchor without a floating size should fail: \(anchorNoSize)")
+ XCTAssertEqual(anchorNoSize["error"] as? String,
+ "--anchor requires a floating overlay: use --size-percent or --cols/--rows", "\(anchorNoSize)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: false, timeout: 5), "the rejected open must not open an overlay")
+
+ // only one of --cols/--rows is a usage error.
+ let colsOnly = try sendOverlayOpen(target: id, command: "cat", args: ["cols": 40])
+ XCTAssertEqual(colsOnly["ok"] as? Bool, false, "cols without rows should fail: \(colsOnly)")
+ XCTAssertEqual(colsOnly["error"] as? String, "session.overlay.open: provide both --cols and --rows", "\(colsOnly)")
+
+ // an out-of-range percent now hard-errors on OPEN too (Decision 3 — open validation matches resize).
+ let badPercent = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 150])
+ XCTAssertEqual(badPercent["ok"] as? Bool, false, "an out-of-range open percent should fail: \(badPercent)")
+ XCTAssertEqual(badPercent["error"] as? String, "session.overlay.open: --size-percent must be 1...100", "\(badPercent)")
+
+ // open a real floating overlay, then --full + --anchor on resize is rejected.
+ let open = try sendOverlayOpen(target: id, command: "cat", args: ["sizePercent": 50])
+ XCTAssertEqual(open["ok"] as? Bool, true, "floating overlay open should succeed: \(open)")
+ XCTAssertTrue(pollSessionOverlay(id: id, expected: true, timeout: 10), "the overlay should be up")
+
+ let fullAndAnchor = try sendOverlayResize(target: id, args: ["full": true, "anchor": "bottom-right"])
+ XCTAssertEqual(fullAndAnchor["ok"] as? Bool, false, "--full with --anchor should fail: \(fullAndAnchor)")
+ XCTAssertEqual(fullAndAnchor["error"] as? String, "--full cannot be combined with --anchor", "\(fullAndAnchor)")
+
+ let close = try sendCommand(#"{"cmd":"session.overlay.close","target":"\#(id)"}"#)
+ XCTAssertEqual(close["ok"] as? Bool, true, "overlay close should succeed: \(close)")
+ }
+
/// Creates a session via `session.new` and returns its id as a `UUID`. `session.new` focuses the new
/// session, so the returned session becomes the active one.
private func newSession() throws -> UUID {
@@ -779,4 +1102,115 @@ final class ControlOverlaySplitUITests: ControlAPITestCase {
}
return false
}
+
+ // MARK: - Overlay sizing/anchor helpers
+
+ /// Sends a `session.overlay.open` for `target` running `command`, merging `extra` (cols/rows/anchor/
+ /// sizePercent/…) into the args. Built via JSONSerialization so numeric args stay numbers on the wire.
+ private func sendOverlayOpen(target: String, command: String, args extra: [String: Any]) throws -> [String: Any] {
+ var args: [String: Any] = ["command": command]
+ for (key, value) in extra { args[key] = value }
+ let obj: [String: Any] = ["cmd": "session.overlay.open", "target": target, "args": args]
+ return try sendCommand(String(data: JSONSerialization.data(withJSONObject: obj), encoding: .utf8)!)
+ }
+
+ /// Sends a `session.overlay.resize` for `target` with `extra` as the args (size and/or anchor).
+ private func sendOverlayResize(target: String, args extra: [String: Any]) throws -> [String: Any] {
+ let obj: [String: Any] = ["cmd": "session.overlay.resize", "target": target, "args": extra]
+ return try sendCommand(String(data: JSONSerialization.data(withJSONObject: obj), encoding: .utf8)!)
+ }
+
+ /// The `tree` session node for `id` (case-insensitive), scanning all workspaces, or nil if not found.
+ private func sessionNode(id: String) throws -> [String: Any]? {
+ let tree = try sendCommand(#"{"cmd":"tree"}"#)
+ guard let result = tree["result"] as? [String: Any],
+ let t = result["tree"] as? [String: Any],
+ let workspaces = t["workspaces"] as? [[String: Any]] else { return nil }
+ for ws in workspaces {
+ for s in (ws["sessions"] as? [[String: Any]] ?? [])
+ where (s["id"] as? String)?.lowercased() == id.lowercased() { return s }
+ }
+ return nil
+ }
+
+ /// Polls the `tree` node for `id` until `predicate` holds, returning the matching node, or nil on timeout.
+ private func pollOverlayNode(id: String, timeout: TimeInterval,
+ until predicate: ([String: Any]) -> Bool) -> [String: Any]? {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if let node = try? sessionNode(id: id), predicate(node) { return node }
+ usleep(200_000)
+ }
+ return nil
+ }
+
+ /// Polls the `tree` node for `id` until BOTH `overlayColsApplied` and `overlayRowsApplied` are present
+ /// (the realized grid is reported once the overlay surface has come up), returning the node, or nil.
+ private func pollOverlayApplied(id: String, timeout: TimeInterval) -> [String: Any]? {
+ pollOverlayNode(id: id, timeout: timeout) {
+ ($0["overlayColsApplied"] as? Int) != nil && ($0["overlayRowsApplied"] as? Int) != nil
+ }
+ }
+
+ /// Polls the `tree` node for `id` until BOTH `canvasCols` and `canvasRows` are present (the content-area
+ /// grid is reported once the session surface is realized), returning the node, or nil on timeout.
+ private func pollCanvasGrid(id: String, timeout: TimeInterval) -> [String: Any]? {
+ pollOverlayNode(id: id, timeout: timeout) {
+ ($0["canvasCols"] as? Int) != nil && ($0["canvasRows"] as? Int) != nil
+ }
+ }
+
+ /// Resizes the frontmost window to `width`x`height` and waits until the on-screen frame reflects it, so a
+ /// subsequently opened overlay sizes against a known pane.
+ private func resizeWindow(width: Int, height: Int) throws {
+ let resized = try sendCommand(#"{"cmd":"window.resize","args":{"width":\#(width),"height":\#(height)}}"#)
+ XCTAssertEqual(resized["ok"] as? Bool, true, "window.resize should succeed: \(resized)")
+ let window = app.windows.firstMatch
+ XCTAssertTrue(window.waitForExistence(timeout: 5), "window should exist")
+ let deadline = Date().addingTimeInterval(5)
+ while Date() < deadline {
+ let size = window.frame.size
+ if abs(size.width - CGFloat(width)) < 12, abs(size.height - CGFloat(height)) < 12 { return }
+ usleep(150_000)
+ }
+ }
+
+ /// Polls `panel`'s frame until its width drops below `threshold` (a full->floating resize shrinks it),
+ /// returning the new frame, or nil if it never shrank within `timeout`.
+ private func pollPanelWidth(panel: XCUIElement, below threshold: CGFloat, timeout: TimeInterval) -> CGRect? {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ let frame = panel.frame
+ if frame.width < threshold { return frame }
+ usleep(200_000)
+ }
+ let frame = panel.frame
+ return frame.width < threshold ? frame : nil
+ }
+
+ /// Polls `panel`'s frame until its width RISES above `threshold` (a resize to a wide band widens it),
+ /// returning the new frame, or nil if it never widened within `timeout`.
+ private func pollPanelWidth(panel: XCUIElement, above threshold: CGFloat, timeout: TimeInterval) -> CGRect? {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ let frame = panel.frame
+ if frame.width > threshold { return frame }
+ usleep(200_000)
+ }
+ let frame = panel.frame
+ return frame.width > threshold ? frame : nil
+ }
+
+ /// Polls `panel`'s frame until it has moved meaningfully from `original` (a re-anchor is async), returning
+ /// the new frame, or nil if it never moved within `timeout`.
+ private func pollPanelMoved(panel: XCUIElement, from original: CGRect, timeout: TimeInterval) -> CGRect? {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ let frame = panel.frame
+ if abs(frame.minX - original.minX) > 20 || abs(frame.minY - original.minY) > 20 { return frame }
+ usleep(200_000)
+ }
+ let frame = panel.frame
+ return (abs(frame.minX - original.minX) > 20 || abs(frame.minY - original.minY) > 20) ? frame : nil
+ }
}
diff --git a/docs/plans/completed/20260722-overlay-anchor-and-cell-sizing.md b/docs/plans/completed/20260722-overlay-anchor-and-cell-sizing.md
new file mode 100644
index 00000000..0642fe3c
--- /dev/null
+++ b/docs/plans/completed/20260722-overlay-anchor-and-cell-sizing.md
@@ -0,0 +1,572 @@
+# Overlay 9-Point Anchor Positioning + Exact Cols/Rows Sizing
+
+## Overview
+
+Floating (non-full) session overlays today support only a single `--size-percent N` (1–100), applied as
+one uniform fraction to BOTH width and height, and are always centered in the pane. This adds two
+capabilities to floating overlays, driven entirely over the control channel:
+
+1. **Exact cols×rows sizing** — `session.overlay.open`/`.resize` gain `--cols N --rows M`, sizing the
+ floating panel to an exact terminal grid via the surface's live cell metrics
+ (`ghostty_surface_size()`), as an alternative to `--size-percent`.
+2. **9-point anchor positioning** — `--anchor ` places the floating panel at one of nine positions
+ (`top-left · top · top-right · left · center · right · bottom-left · bottom · bottom-right`), default
+ `center` (today's behavior).
+
+Both are **adaptive/clamped**: a cols×rows (or percent) request larger than the pane is clamped to fit
+whole cells, and a 9-point anchor is always fully on-screen by construction (the panel is ≤ pane after
+clamp, so SwiftUI `Alignment` places it against an edge or centered without any manual position math).
+
+Benefits: scripts can size an overlay to fit a known TUI layout or an inline image at an exact cell size,
+and can park it out of the way (corner) instead of dead-center over the session.
+
+## Review Revisions (incorporated before implementation)
+
+This plan was reviewed by the `plan-review` agent and by Codex (GPT-5.6). Both findings and three
+maintainer decisions are folded in below:
+
+- **Retina pixel↔point conversion (Codex, blocking).** `ghostty_surface_size()` returns **backing
+ pixels** (`GhosttySurfaceView.updateMetalLayerSize` pushes `convertToBacking(bounds)` ×
+ `backingScaleFactor` to libghostty, `GhosttySurfaceView.swift:783`), while `GeometryReader` and
+ `WindowGeometry.Size` are **points**. Cell metrics MUST be converted px→points at the app boundary
+ before entering the host-free resolver, or cols×rows panels come out ~2× on Retina.
+- **Reactive metrics lifecycle (Codex, blocking).** `Session.overlaySurface` is `@ObservationIgnored`
+ and assigned imperatively in `TerminalView.makeNSView`, so reading metrics from the SwiftUI view body
+ does not invalidate the panel. Metrics flow through **observed** live-only `Session` state, refreshed
+ app-side on `GHOSTTY_ACTION_CELL_SIZE` (`GhosttyCallbacks.swift:50`), surface realization, and
+ backing-scale changes; the view consumes the observed value.
+- **Task 2 compile-break + missed callers (both reviews, blocking).** Removing
+ `Session.overlaySizePercent` orphans readers the original Task 2 file list missed:
+ `AppStore.controlTree` (`AppStore.swift:215`), the two editor-overlay callers
+ `AppActions.swift:357`/`:390` (`sizePercent: 95`), `TerminalZoomTests.swift:104`, and the
+ sizePercent-heavy `AppStorePaneTests.swift`. All are migrated inside Task 2.
+- **Decision 1 — read-back reports REQUESTED + APPLIED.** `tree` carries the requested cols/rows (the
+ record-then-restore key) AND the actual applied grid after clamping (so a script can detect it was
+ clamped), the applied grid supplied app-side from the realized overlay surface.
+- **Decision 2 — anchor is PRESERVED and ALWAYS REPORTED.** A `--full` round-trip keeps the anchor (only
+ `closeOverlay` resets it to center), and `overlayAnchor` is emitted on `tree` whenever an overlay is
+ open (including full), so there is no hidden anchor state.
+- **Decision 3 — open validation is TIGHTENED to match resize (intentional behavior change).** Today
+ `session overlay open` does not validate `--size-percent` (the store silently clamps 250→100, 0→1).
+ Open now hard-errors on an out-of-range percent / bad cols·rows / bad anchor in the dispatcher + CLI
+ `validate()`, exactly like resize. The store keeps its defensive clamp for internal callers; the
+ wire/CLI change is documented, not silent.
+- **Stronger e2e (Codex).** Wire/echo round-trips alone would pass even with the Retina bug — the e2e
+ additionally runs `stty size` inside the overlay to assert the actual grid, and asserts the floating
+ panel's frame vs the pane via an accessibility id.
+- **Exactness scope.** Sizing is best-effort-exact from correctly-scaled metrics; the derived padding
+ (`width_px − columns·cell_width_px`) is treated as an estimate, and the **applied** read-back exposes
+ any residual drift or clamp rather than hiding it. A bounded post-realization correction loop is
+ explicitly OUT of scope for v1 (revisit only if manual verification shows real drift).
+
+## Context (from discovery)
+
+- **Files/components involved:**
+ - Host-free model + protocol: `agtermCore/Sources/agtermCore/` — `Session.swift`, `AppStore.swift`
+ (`controlTree`), `AppStore+Panes.swift` (`openOverlay`/`resizeOverlay`), `ControlProtocol.swift`
+ (`ControlArgs`, `ControlSessionNode`), `ControlDispatcher.swift`
+ (`ControlActions`, `ControlSessionOverlayOpenOptions`, validation/routing). New file:
+ `OverlayLayout.swift` (the pure size/anchor model + resolver).
+ - App target: `agterm/AppActions.swift` (editor-overlay callers), `agterm/Control/ControlServer+SessionActions.swift`
+ (overlay arms), `agterm/Ghostty/GhosttySurfaceView.swift` + `agterm/Ghostty/GhosttyCallbacks.swift`
+ (cell-metrics readout + `CELL_SIZE` signal), `agterm/Views/WindowContentView.swift` (`overlayPanel`
+ rendering).
+ - CLI: `agtermCore/Sources/agtermctlKit/SessionCommands.swift` (`Overlay` subcommand tree).
+ - Docs/keep-in-sync: `agterm/Resources/agent-skill/{reference.md,examples.md,SKILL.md}`,
+ `site/commands.html`, `site/docs.html`, `README.md`, `.claude/rules/control-api.md`.
+ - Tests: `agtermCore/Tests/agtermCoreTests/` (`OverlayLayoutTests.swift` new, `SessionTests.swift`,
+ `AppStorePaneTests.swift`, `TerminalZoomTests.swift`, `ControlProtocolTests.swift`,
+ `ControlDispatcherTests.swift`, `MockControlActions.swift`),
+ `agtermCore/Tests/agtermctlKitTests/CommandsTests.swift`,
+ `agtermUITests/ControlOverlaySplitUITests.swift`.
+
+- **Related patterns found:**
+ - **Dispatcher-first control commands** — host-free arg validation, error strings, and response shape
+ live in `ControlDispatcher.dispatch(_:)` (`ControlDispatcher.swift:489-522` already owns overlay
+ open/resize); `ControlServer` supplies only side effects via `ControlActions`.
+ - **State-mutating command owes a read-back field** on `ControlSessionNode` — existing pair is
+ `session.overlay.resize` ↔ `overlaySizePercent`; extended here with requested + applied cols/rows and
+ the anchor.
+ - **App-supplied live values in `controlTree`** — the `fontSize`/`splitFontSize`/`scratchFontSize`
+ precedent (app-side closures read a live surface value the host-free tree can't). The applied overlay
+ grid follows the same idea, but simpler: the app writes the realized grid into observed `Session`
+ state, which `controlTree` reads directly.
+ - **`ghostty_surface_size()`** (`GhosttyKit.xcframework/.../ghostty.h:482`) returns
+ `columns`/`rows`/`width_px`/`height_px`/`cell_width_px`/`cell_height_px` in **backing pixels** — the
+ cell + padding + realized-grid source, live and font-accurate.
+ - **`GHOSTTY_ACTION_CELL_SIZE`** (`GhosttyCallbacks.swift:50`) — fires on font/DPI change; the trigger
+ to refresh the observed metrics.
+ - **NSSplitView-overrun invariant** (`.claude/rules/libghostty.md`) — `overlayPanel` is an
+ always-present, constant-shape `sessionDetail` ZStack sibling at `.zIndex(3)`; its inner content is
+ gated inside `if session.overlayActive` (`WindowContentView.swift:531-564`). Changing frame size and
+ ZStack `alignment` does NOT change child count and must not introduce anchor-specific `if`/`switch`
+ view branches, so the split is never re-hosted.
+ - **CoreGraphics-free `agtermCore`** — the resolver uses the Double-backed `WindowGeometry.Size`, never
+ `CGSize`; the app converts to/from `CGFloat` at the call site.
+
+- **Dependencies identified:** overlay state is **live-only** (absent from `SessionSnapshot`), so there is
+ NO persistence/restore migration. No new `Command` case (both capabilities are new args on the existing
+ `session.overlay.open`/`.resize`), so the public command count stays **64**.
+
+## Development Approach
+
+- **Testing approach**: Regular (code first, then tests) — per user selection. Tests are still a required
+ deliverable of every task and must pass before the next task starts.
+- complete each task fully before moving to the next; make small, focused changes.
+- **CRITICAL: every code-change task MUST include new/updated tests** (unit tests for new/modified
+ functions, new cases for new code paths, success + error scenarios).
+- **CRITICAL: all tests must pass before starting the next task.**
+- **Intermediate build state (expected):** the **app target** intentionally does not compile from the
+ middle of Task 2 through Task 5 (removed/changed overlay symbols in `WindowContentView`/`ControlServer`).
+ Tasks 2–4 gate on `cd agtermCore && swift test` (the host-free module, which DOES compile after each
+ task); the app build is first re-verified in Task 5. This is by design, not a regression.
+- **CRITICAL: update this plan file when scope changes during implementation.**
+- run `swift test`, the app build, and `make lint` as applicable; keep backward compatibility EXCEPT the
+ documented Decision-3 open-validation tightening (existing `--size-percent`/`--full` behavior and the
+ default-center anchor are otherwise unchanged).
+
+## Project-Rule Gates (agterm — verify against every task before marking complete)
+
+- **Module boundary**: `agtermCore` stays host-free — no GhosttyKit/AppKit/Metal AND no CoreGraphics.
+ The layout model, resolver, and `OverlayCellMetrics` use `WindowGeometry.Size` (Double, in **points**).
+ Reading pixel metrics, converting px→points, applying the SwiftUI frame/alignment, and populating the
+ applied grid are app-target only.
+- **Dispatcher-first**: all overlay arg validation, error strings, and one-of rules live in
+ `ControlDispatcher`; `ControlServer` arms only resolve the target and call the store.
+- **Read-back obligation (Decision 1)**: the write path owes matching read-back on `ControlSessionNode`,
+ populated in `AppStore.controlTree`, omitted from JSON when nil, covered by round-trip
+ `…RoundTrips`/`…OmitsWhenNil` tests + a `controlTree` populate test. Requested cols/rows/anchor are the
+ restore key; applied cols/rows expose clamp/drift.
+- **Anchor state (Decision 2)**: anchor preserved across `--full`; reset only on `closeOverlay`; emitted
+ on `tree` whenever an overlay is open.
+- **Open validation (Decision 3)**: dispatcher + CLI hard-error on out-of-range percent / bad cols·rows /
+ bad anchor for BOTH open and resize (documented tightening); the store keeps a defensive clamp.
+- **Options struct for 4+ params** (CLAUDE.md): overlay-open params go through an `OverlayOpenOptions`
+ struct, not a 7-positional-arg signature; both internal callers (`AppActions.swift:357/390`) migrate.
+- **NSSplitView-overrun invariant**: keep `overlayPanel`'s ZStack child count constant; only frame
+ parameters and ZStack `alignment` may change; no anchor-specific view branches.
+- **One test file per source file**; **keep-in-sync (HARD)**: agent-skill (incl. `SKILL.md`) +
+ `site/commands.html` + README + `site/docs.html` + `.claude/rules/control-api.md` in lockstep (Task 8);
+ `CHANGELOG.md` is release-only — do NOT touch it here.
+
+## Testing Strategy
+
+- **unit (agtermCore, `swift test`)**: resolver clamp/anchor + 1× / 2× (Retina) scaling + rounding +
+ degenerate guards (`OverlayLayoutTests`); Session predicates + anchor-preserve-on-full + close-reset
+ (`SessionTests`); store open/resize incl. migrated sizePercent/clamp tests (`AppStorePaneTests`,
+ `TerminalZoomTests`); wire round-trips incl. applied + anchor (`ControlProtocolTests`); dispatcher
+ validation incl. tightened open (`ControlDispatcherTests`); CLI mapping/validate (`CommandsTests`).
+- **e2e (XCUITest, `ControlOverlaySplitUITests`)**: open with cols/rows/anchor, resize, re-anchor,
+ read-back off `tree` (requested + applied + anchor); **actual grid via `stty size` marker**; **floating
+ panel frame vs pane via accessibility id**; error cases; repeated re-anchor/full/cell transitions while
+ a split is visible (NSSplitView guard). Must pass before the next task.
+
+## Progress Tracking
+
+- mark completed items with `[x]` immediately; add ➕ for new tasks, ⚠️ for blockers; keep the plan in
+ sync with actual work.
+
+## Solution Overview
+
+- **Host-free model** (`agtermCore/OverlayLayout.swift`):
+ - `OverlaySize` enum: `.full` | `.percent(Int)` | `.cells(cols: Int, rows: Int)` — replaces stored
+ `Session.overlaySizePercent: Int?`.
+ - `OverlayAnchor` string enum (9 cases, `CaseIterable`, default `.center`) with host-free
+ `unitX`/`unitY` ∈ {0, 0.5, 1}.
+ - `OverlayCellMetrics` — Double-backed, **in points** (`cellWidth`, `cellHeight`, `padWidth`,
+ `padHeight`); the app converts pixel metrics ÷ backing scale before constructing it.
+ - `OverlayLayout.panelSize(_:pane:cell:) -> WindowGeometry.Size` — pure resolver: `.full` → pane;
+ `.percent(p)` → `pane * p/100`; `.cells(c,r)` → whole-cell clamp against the pane (`usedCols =
+ max(1, min(c, floor((pane.w − padW)/cellW)))`, width `= usedCols·cellW + padW`; same for rows),
+ with explicit guards for nil metrics (fallback to pane), zero/invalid cell size, and pane smaller
+ than one cell + padding.
+- **Session state** (all observed / host-free): `overlaySize: OverlaySize = .full`, `overlayAnchor:
+ OverlayAnchor = .center`, `overlayCellMetrics: OverlayCellMetrics?` (points; app-maintained, drives
+ the view), `overlayAppliedCols: Int?`/`overlayAppliedRows: Int?` (app-maintained realized grid, for
+ read-back). `fullOverlayActive` = `overlayActive && overlaySize == .full`; `floatingOverlayActive` =
+ `overlayActive && overlaySize != .full`.
+- **Store API**: `openOverlay(_:options:)` takes `OverlayOpenOptions` (command/cwd/wait/size/anchor/
+ backgroundColor); `resizeOverlay(_:size:anchor:)` with optional size/anchor (nil = keep current) so a
+ resize, a re-anchor-in-place, or both are one call. `--full` keeps the anchor; `closeOverlay` resets
+ size→`.full`, anchor→`.center`, metrics/applied→nil.
+- **Wire**: `ControlArgs` gains `cols`/`rows`/`anchor`; `ControlSessionNode` gains
+ `overlayCols`/`overlayRows` (requested, cells mode only), `overlayColsApplied`/`overlayRowsApplied`
+ (realized grid, any floating overlay), `overlayAnchor` (any open overlay); `overlaySizePercent` kept.
+- **Dispatcher**: validates one-of {`--size-percent`, `--cols`+`--rows`} for open (no `--full` on open —
+ absence of a size = full) and one-of {`--full`, `--size-percent`, `--cols`+`--rows`} or none for
+ resize; cols·rows paired; percent 1…100 hard-error; anchor one of nine; anchor requires a floating
+ size on open; resize needs at least one of {size, anchor}; `--full` ⊥ `--anchor`.
+- **App rendering**: `GhosttySurfaceView.overlayPixelMetrics()` reads `ghostty_surface_size()`; the app
+ converts px→points via `backingScaleFactor` and writes `session.overlayCellMetrics` (on realization,
+ `CELL_SIZE`, backing-scale change) and `session.overlayAppliedCols/Rows` (from the realized grid).
+ `overlayPanel` sizes AND positions via a single `OverlayLayout.panelRect(session.overlaySize, pane:
+ geo.size, cell: session.overlayCellMetrics, anchor: session.overlayAnchor)` inside a constant
+ `ZStack(alignment: .topLeading)` + leading/top `padding` by `rect.origin`; a stable accessibility id on the
+ floating panel enables the e2e frame assertion.
+- **Overlay margin (maintainer feedback — REWORKED to a base-level safe-area inset)**: a floating overlay is
+ NEVER flush with the pane border. It sits inside a uniform SAFE AREA — the pane inset by a base-level margin
+ of one line-height (`cell.cellHeight`) on ALL FOUR sides, SYMMETRIC and INDEPENDENT of the anchor (a
+ full-width band is inset left/right exactly like the top; not only the anchored edges). The original model
+ (`OverlayLayout.anchorInsets` insetting only the anchored edge(s)) was WRONG — a full-width top band got a
+ top margin but sat flush left/right. Now host-free in `OverlayLayout.panelRect(_:pane:cell:anchor:) ->
+ WindowGeometry.Rect`: the requested size (percent → `pane*p/100`; cells → whole-cell extent) is clamped to
+ the safe area per axis (`panelW = min(requested, paneW - 2m)`) and placed at the anchor's unit position
+ within it (`originX = m + anchor.unitX * (usableW - panelW)`, same for y); nil/unusable cell metrics → `m =
+ 0` (no margin, panel may fill the pane); a `.full` overlay is UNCHANGED (fills the whole pane at origin
+ (0,0), no margin). `overlayPanel` applies the rect as `.frame(rect.size)` + a leading/top `padding` by
+ `rect.origin` (a values-only modifier — no anchor-specific view branch, no `swiftUIAlignment` — so the ZStack
+ child count stays constant per the NSSplitView-overrun invariant), applied AFTER the a11y marker so the
+ marker still reports the panel's own frame. Applies to ANY floating overlay (percent or cells). A full-usable
+ request clamps to the safe area (`canvasCols`/`canvasRows` read-back semantics are UNCHANGED — the raw grid;
+ the applied read-back shows the clamped size).
+
+➕ **Follow-on addition (post-completion scope): canvas grid read-back on `tree`.**
+A separate, maintainer-confirmed addition beyond the original ten tasks: report the terminal CONTENT AREA
+size in cells on each `tree` session node so a script can compute concrete `overlay open --cols/--rows` as a
+fraction of the canvas (`--rows round(0.30*canvasRows)` is a ~30%-tall strip; note a floating overlay is
+inset by a one-line-height safe-area margin, so a full-canvas request clamps to the usable area).
+- Two new `ControlSessionNode` fields `canvasCols`/`canvasRows` (`ControlProtocol.swift`), optional +
+ omit-when-nil, measured at the session's BASE font (the font a fresh overlay uses) so the reported grid is
+ exactly the coordinate system `--cols/--rows` land in.
+- Populated app-side (the host-free tree can't read a surface), the `fontSize`-closure pattern: a
+ `canvasGrid: (Session) -> (cols: Int, rows: Int)?` closure on `AppStore.controlTree`, filled in
+ `ControlServer.buildTree` from `ControlServer.sessionCanvasGrid` reading the new
+ `GhosttySurfaceView.liveGrid()` (`ghostty_surface_size().columns/.rows` — UNITLESS cell counts, so NO
+ px→point Retina conversion, unlike the overlay cell metrics).
+- **Split handling (measurement decision):** split-agnostic — the whole detail region taken as ONE area.
+ `canvasRows` = the main pane's rows (full height; a left/right split keeps full height).
+ `canvasCols` = the primary pane's columns when NOT split (exact), the whole detail width measured from BOTH
+ panes' BACKING-PIXEL widths at ONE cell size (the primary pane's) and floored ONCE
+ (`OverlayLayout.splitCanvasCols`) when the split is shown side-by-side (`isSplit`), or the split pane's own
+ full-width grid for a hidden split maximized on it (`hasSplit && splitFocused`). A side-by-side split
+ `canvasCols` slightly underestimates a single full-width surface by ONLY the thin divider (a fraction of a
+ cell; the per-pane inner padding IS counted, riding in each pane's pixel width) — the confirmed
+ "exact-unsplit, as-close-as-possible-for-split" trade, documented in the field godoc; never the half-width
+ main pane alone.
+- Tests: round-trip + omit-when-nil in `ControlProtocolTests`; a `controlTree` populate test
+ (`controlTreeThreadsCanvasGridFromClosure`) in `AppStorePaneTests` (closure-based, like the fontSize
+ populate test); and the e2e `testTreeReportsCanvasGridAndOverlayFillsIt` in `ControlOverlaySplitUITests`
+ (reads `canvasCols`/`canvasRows` off `tree`, asserts > 0, then `overlay open --cols canvasCols --rows
+ canvasRows` yields `overlayColsApplied`/`overlayRowsApplied` ≈ the canvas — catching a ~2x cell-count miss).
+- Keep-in-sync surfaces updated: agent-skill `reference.md`/`SKILL.md`/`examples.md`, `site/commands.html`,
+ `site/docs.html`, `README.md`, and `.claude/rules/control-api.md`. No new `Command` case (a pure derived
+ read), so the command count stays 64.
+
+## Technical Details
+
+**Host-free types (`agtermCore/Sources/agtermCore/OverlayLayout.swift`):**
+
+```swift
+public enum OverlaySize: Equatable, Sendable {
+ case full
+ case percent(Int) // caller pre-validates 1...100
+ case cells(cols: Int, rows: Int) // caller pre-validates cols>=1, rows>=1
+}
+
+public enum OverlayAnchor: String, CaseIterable, Sendable {
+ case topLeft = "top-left", top, topRight = "top-right"
+ case left, center, right
+ case bottomLeft = "bottom-left", bottom, bottomRight = "bottom-right"
+ public var unitX: Double { /* 0 | 0.5 | 1 */ }
+ public var unitY: Double { /* 0 | 0.5 | 1 */ }
+}
+
+public struct OverlayCellMetrics: Equatable, Sendable { // POINTS, not pixels
+ public let cellWidth: Double, cellHeight: Double, padWidth: Double, padHeight: Double
+ public init(cellWidth: Double, cellHeight: Double, padWidth: Double, padHeight: Double)
+ public var isUsable: Bool { cellWidth > 0 && cellHeight > 0 }
+}
+
+public enum OverlayLayout {
+ public static func panelSize(_ size: OverlaySize, pane: WindowGeometry.Size,
+ cell: OverlayCellMetrics?) -> WindowGeometry.Size
+ // .full -> pane; .percent -> pane*p/100 (<=pane); .cells -> whole-cell clamp; nil/unusable cell -> pane
+}
+```
+
+**Pixel→point conversion (app-side, `GhosttySurfaceView`):**
+```swift
+func overlayPixelMetrics() -> (cellW: Double, cellH: Double, padW: Double, padH: Double,
+ cols: Int, rows: Int)? // from ghostty_surface_size(surface), all *_px
+// app converts to points: OverlayCellMetrics(cellWidth: cellW/scale, cellHeight: cellH/scale,
+// padWidth: (width_px - cols*cellW)/scale, padHeight: (height_px - rows*cellH)/scale)
+// with scale = window.backingScaleFactor. padding is an ESTIMATE (total non-cell remainder).
+```
+
+**`Session`:** remove `overlaySizePercent`; add `overlaySize`/`overlayAnchor`/`overlayCellMetrics`/
+`overlayAppliedCols`/`overlayAppliedRows`; re-express `fullOverlayActive`/`floatingOverlayActive` against
+`overlaySize` (the `topmostSurface` accessor reads `overlayActive`, NOT the percent — leave it alone).
+
+**Wire read-back (`ControlSessionNode`, populated in `AppStore.controlTree`, omitted when nil):**
+- `overlaySizePercent: Int?` — set only when `overlaySize == .percent` (unchanged; back-compat).
+- `overlayCols`/`overlayRows: Int?` — REQUESTED grid; set only when `overlaySize == .cells`.
+- `overlayColsApplied`/`overlayRowsApplied: Int?` — realized grid from `overlayAppliedCols/Rows`; set for
+ ANY open floating overlay (percent or cells), so a script sees what actually rendered / any clamp.
+- `overlayAnchor: String?` — anchor `rawValue`, set whenever an overlay is open (incl. full).
+
+**Dispatcher validation.** Open: at most one of {`sizePercent`, (`cols`&`rows`)}; `cols` and `rows` both
+or neither ("provide both --cols and --rows"); `sizePercent` 1…100 (hard error — Decision 3); `cols`/
+`rows` >= 1; `anchor` parses to `OverlayAnchor`; `anchor` without a floating size errors. No size = full
+overlay (open has no `--full`). Resize: one-of {`full`, `sizePercent`, `cols`+`rows`} OR none; at least
+one of {a size mode, `anchor`}; `full` ⊥ `anchor`; same range/pairing/anchor rules.
+
+**Processing flow:** CLI parse+validate → `ControlRequest` args {sizePercent|cols,rows, anchor / full} →
+`ControlDispatcher.dispatch` validates + builds `OverlayOpenOptions` / resize call → `ControlActions` arm
+→ `AppStore.openOverlay/resizeOverlay` sets `overlaySize`/`overlayAnchor` → app keeps
+`overlayCellMetrics`/`overlayAppliedCols/Rows` fresh via `CELL_SIZE`/realization → `overlayPanel` renders
+via `OverlayLayout.panelSize` + anchor alignment. `tree` reports requested + applied + anchor.
+
+## What Goes Where
+
+- **Implementation Steps** (`[ ]`): all code, tests, and in-repo docs (agent-skill, site, README, rules).
+- **Post-Completion** (no checkboxes): manual dev-instance verification of rendered placement and
+ cell-exact fit (not accessibility-observable beyond the e2e frame check), and the exactness/drift call.
+
+## Implementation Steps
+
+### Task 1: Host-free overlay layout model + resolver
+
+**Files:**
+- Create: `agtermCore/Sources/agtermCore/OverlayLayout.swift`
+- Create: `agtermCore/Tests/agtermCoreTests/OverlayLayoutTests.swift`
+
+- [x] add `OverlaySize`, `OverlayAnchor` (with `unitX`/`unitY`), `OverlayCellMetrics` (points, `isUsable`)
+- [x] add `OverlayLayout.panelSize(_:pane:cell:)` — full/percent/whole-cell-clamp with guards: nil or
+ unusable cell → pane; pane smaller than one cell + padding → clamp to >=1 cell but never exceed pane
+- [x] write tests: percent (incl. 100), cells within pane, cells wider/taller than pane (whole-cell
+ clamp), **1× and 2× (Retina) metrics producing the correct point size**, sub-pixel rounding cases,
+ nil/unusable metrics fallback, min-1-cell floor, all-9 anchor unit points
+- [x] run `cd agtermCore && swift test` — must pass before next task
+
+### Task 2: Session state + store API + controlTree percent migration (single compiling unit)
+
+**Files:**
+- Modify: `agtermCore/Sources/agtermCore/Session.swift`
+- Modify: `agtermCore/Sources/agtermCore/AppStore+Panes.swift`
+- Modify: `agtermCore/Sources/agtermCore/AppStore.swift`
+- Modify: `agterm/AppActions.swift`
+- Modify: `agtermCore/Tests/agtermCoreTests/SessionTests.swift`
+- Modify: `agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift`
+- Modify: `agtermCore/Tests/agtermCoreTests/TerminalZoomTests.swift`
+
+- [x] replace `Session.overlaySizePercent` with `overlaySize: OverlaySize = .full`; add `overlayAnchor:
+ OverlayAnchor = .center`, observed `overlayCellMetrics: OverlayCellMetrics?`, `overlayAppliedCols:
+ Int?`, `overlayAppliedRows: Int?`; re-express `fullOverlayActive`/`floatingOverlayActive`
+- [x] add `OverlayOpenOptions`; `AppStore.openOverlay(_:options:)` (defensive percent clamp retained);
+ `resizeOverlay(_:size:anchor:)` (nil = keep; `.full` keeps anchor); `closeOverlay` resets
+ size/anchor/metrics/applied
+- [x] migrate `AppStore.controlTree` percent read to derive from `overlaySize` (keep `overlaySizePercent`
+ wire field populated); migrate `AppActions.swift:357/390` to `openOverlay(options:
+ .init(... size: .percent(95) ...))`
+- [x] update `AppStorePaneTests` (open/resize via new API; keep the 250→100 / 0→1 defensive-clamp cases)
+ and `TerminalZoomTests:104`
+- [x] write tests: open sets size/anchor; resize percent→cells→full; resize anchor-only keeps size;
+ size-only keeps anchor; **`--full` preserves the anchor**; predicates per mode; close resets to
+ full/center/nil
+- [x] run `cd agtermCore && swift test` — must pass before next task (app target not yet built)
+
+### Task 3: Wire protocol — args + read-back node (requested + applied + anchor)
+
+**Files:**
+- Modify: `agtermCore/Sources/agtermCore/ControlProtocol.swift`
+- Modify: `agtermCore/Sources/agtermCore/AppStore.swift`
+- Modify: `agtermCore/Tests/agtermCoreTests/ControlProtocolTests.swift`
+- Modify: `agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift`
+
+- [x] add `ControlArgs.cols`/`rows`/`anchor`; thread through its init
+- [x] add `ControlSessionNode.overlayCols`/`overlayRows`/`overlayColsApplied`/`overlayRowsApplied`/
+ `overlayAnchor`; thread through its init (keep `overlaySizePercent`)
+- [x] populate the new node fields in `AppStore.controlTree`: requested cols/rows when `.cells`; applied
+ cols/rows from `overlayAppliedCols/Rows` when floating; `overlayAnchor` rawValue when
+ `overlayActive`; all omitted when nil
+- [x] write tests: `ControlArgs` round-trip with cols/rows/anchor; `ControlSessionNode` round-trip for
+ cells+applied+anchor and for percent+applied+anchor; `…OmitsWhenNil` for full overlay and no
+ overlay; a `controlTree` populate test in `AppStorePaneTests` (requested vs applied, anchor emitted
+ while full)
+- [x] run `swift test` — must pass before next task
+
+### Task 4: Dispatcher validation + routing (tightened open)
+
+**Files:**
+- Modify: `agtermCore/Sources/agtermCore/ControlDispatcher.swift`
+- Modify: `agtermCore/Tests/agtermCoreTests/ControlDispatcherTests.swift`
+- Modify: `agtermCore/Tests/agtermCoreTests/MockControlActions.swift`
+
+- [x] extend `ControlActions.openSessionOverlay`/`resizeSessionOverlay` + `ControlSessionOverlayOpenOptions`
+ to carry `OverlaySize`/`OverlayAnchor`; update `MockControlActions`
+- [x] `.sessionOverlayOpen`: parse size (percent/cells, no `--full`), validate one-of + cols·rows pairing
+ + **percent 1…100 hard-error (Decision 3)** + anchor parse + anchor-requires-floating; build options
+- [x] `.sessionOverlayResize`: validate one-of {full/percent/cells} or none, at-least-one-of {size,
+ anchor}, `full` ⊥ `anchor`; pass `size: OverlaySize?`/`anchor: OverlayAnchor?`
+- [x] factor size/anchor parsing into shared pure helpers used by both arms
+- [x] write tests: each valid open/resize mode routes correctly (assert mock call args); each error
+ (both cols/rows required, **percent out-of-range now errors on open**, unknown anchor,
+ anchor-without-floating, full+anchor, resize-with-nothing)
+- [x] run `swift test` — must pass before next task
+
+### Task 5: App-side arms + observable metrics + overlayPanel rendering
+
+**Files:**
+- Modify: `agterm/Control/ControlServer+SessionActions.swift`
+- Modify: `agterm/Ghostty/GhosttySurfaceView.swift`
+- Modify: `agterm/Ghostty/GhosttyCallbacks.swift`
+- Modify: `agterm/Views/WindowContentView.swift`
+
+- [x] update the overlay arms to pass `size`/`anchor` into `openOverlay(options:)`/`resizeOverlay(_:size:anchor:)`
+- [x] add `GhosttySurfaceView.overlayPixelMetrics()` from `ghostty_surface_size()`; app converts px→points
+ via `backingScaleFactor` and writes `session.overlayCellMetrics` + `session.overlayAppliedCols/Rows`
+ on surface realization, `GHOSTTY_ACTION_CELL_SIZE` (`GhosttyCallbacks.swift:50`), and backing-scale
+ change (view consumes the observed metrics — no view-body polling of the imperative NSView)
+- [x] `overlayPanel`: size via `OverlayLayout.panelSize(session.overlaySize, pane: geo.size, cell:
+ session.overlayCellMetrics)`; position via `ZStack(alignment: floating ?
+ session.overlayAnchor.swiftUIAlignment : .center)`; add the app-side `OverlayAnchor.swiftUIAlignment`
+ mapping; add a stable accessibility id to the floating panel
+- [x] confirm ZStack child count unchanged and no anchor-specific view branches (NSSplitView-overrun
+ rule); full path + `hideForOverlay` untouched
+- [x] app builds (`make build`); `make lint` passes
+- [x] visual verification (deferred to Post-Completion — not automatable in this run): in an isolated dev
+ instance (short `AGTERM_STATE_DIR` + its own socket) that cols/rows fit, each anchor places correctly,
+ and Retina scaling is right — record in Post-Completion
+- [x] run `swift test` (host-free unchanged) — must pass before next task
+
+### Task 6: agtermctl CLI — open/resize flags
+
+**Files:**
+- Modify: `agtermCore/Sources/agtermctlKit/SessionCommands.swift`
+- Modify: `agtermCore/Tests/agtermctlKitTests/CommandsTests.swift`
+
+- [x] `session overlay open`: add `--cols`/`--rows`/`--anchor`; `validate()` mirrors dispatcher (one-of
+ size, cols&rows together, **percent 1…100 now enforced on open**, anchor value, anchor-requires-
+ floating); map into `ControlArgs`
+- [x] `session overlay resize`: add `--cols`/`--rows`/`--anchor`; extend `validate()` to one-of size or
+ none, at-least-one of {size, anchor}, `full` ⊥ `anchor`
+- [x] update help text
+- [x] write tests: request mapping for each open/resize mode; `validate()` accepts valid combos and
+ rejects each invalid one (incl. the new open percent-range error) with the expected message
+- [x] run `swift test` — must pass before next task
+
+### Task 7: End-to-end XCUITests (wire + actual grid + frame)
+
+**Files:**
+- Modify: `agtermUITests/ControlOverlaySplitUITests.swift`
+- ➕ Modify: `agterm/Views/WindowContentView.swift` (Task-5 follow-up surfaced by the frame e2e — see note)
+
+- [x] e2e: open a floating overlay with `--cols/--rows` + `--anchor`; assert `tree` read-back (requested
+ `overlayCols/Rows`, applied `overlayColsApplied/RowsApplied`, `overlayAnchor`)
+- [x] e2e: assert the **actual grid** — run `stty size > marker; cat` in the overlay and assert the
+ marker reports the requested (or clamped) rows/cols (would catch the Retina bug)
+- [x] e2e: assert the floating panel's **frame vs the pane** via its accessibility id for a corner anchor
+- [x] e2e: open `--size-percent`, resize to `--cols/--rows`, re-anchor with `--anchor` only, resize back
+ to `--full` (read-back: cols/rows cleared, anchor retained); cycle these while a split is visible
+- [x] e2e: error cases over the socket (anchor without floating, both cols/rows required, full+anchor,
+ **open --size-percent out of range now errors**)
+- [x] run the overlay e2e target — must pass before next task
+
+➕ **Deviation (Task-5 follow-up):** the `overlay-floating-panel` accessibility id added in Task 5 was on
+the Metal-backed `TerminalView`, which the codebase precedent (`dashboard-cell`, `quick-terminal`) confirms
+is NOT exposed in the a11y tree — the frame e2e failed with "panel should be in the a11y tree". Fixed by
+moving the id onto a zero-content `.overlay(Color.clear …)` marker sized to the panel (the dashboard-cell
+pattern). As an `.overlay` modifier it adds NO ZStack child, so the constant-child-count NSSplitView-overrun
+invariant is preserved. All 5 e2e methods pass after the fix.
+
+➕ **Deviation (maintainer feedback — overlay margin, REWORKED to a base-level safe-area inset):** a floating
+overlay used to sit FLUSH against the pane border, which looked bad. The FIRST fix insetted only the ANCHORED
+edge(s) (`OverlayLayout.anchorInsets`), but that was WRONG — a full-width top band got a top margin yet stayed
+flush left/right (no horizontal slack). The maintainer directed a rework to a BASE-LEVEL uniform margin: a
+floating overlay now sits inside a uniform SAFE AREA — the pane inset by one line-height (`cell.cellHeight`)
+on ALL FOUR sides, symmetric and INDEPENDENT of the anchor (a full-width band is inset left/right exactly like
+the top). `anchorInsets` + `OverlayInsets` + the `swiftUIAlignment` mapping were REMOVED; the host-free
+`OverlayLayout.panelRect(_:pane:cell:anchor:) -> WindowGeometry.Rect` now returns size AND origin in ONE call
+(requested size clamped to the safe area per axis, placed at the anchor's unit position within it; nil/unusable
+metrics → `m = 0`; `.full` fills the pane at origin (0,0) with no margin). `overlayPanel` applies it as a
+`.frame(rect.size)` + a constant `ZStack(alignment: .topLeading)` and a leading/top `padding(rect.origin)` — a
+values-only modifier (no anchor branch), applied AFTER the a11y marker so the marker keeps reporting the
+panel's own frame, preserving the NSSplitView-overrun invariant. `OverlayLayoutTests` replaced the
+`anchorInsets` cases with `panelRect` safe-area cases (full-size-fills-safe-area / corner-at-margin / center /
+full-width-band-inset-all-sides / nil-metrics-no-margin). The frame e2e now takes the detail-area reference
+from a FULL overlay (`overlay-full-panel` marker) and asserts a corner panel's ~1-line-height margins AND that
+a full-width band (`--cols canvasCols`) is inset off the LEFT and RIGHT (> 0), discriminating against the old
+flush-left/right behavior. Applies to ANY floating overlay (percent or cells). All 5 e2e methods still pass.
+
+### Task 8: Keep-in-sync documentation surfaces
+
+**Files:**
+- Modify: `agterm/Resources/agent-skill/reference.md`
+- Modify: `agterm/Resources/agent-skill/examples.md`
+- Modify: `agterm/Resources/agent-skill/SKILL.md`
+- Modify: `site/commands.html`
+- Modify: `site/docs.html`
+- Modify: `README.md`
+- Modify: `.claude/rules/control-api.md`
+
+- [x] agent-skill `reference.md`: update the `session overlay open`/`resize` entries with
+ `--cols/--rows/--anchor`, the tightened open validation, the clamp/adaptive note, and the read-back
+ fields (requested + applied + anchor); command count stays 64
+- [x] agent-skill `SKILL.md`: update the overlay invocation line + the `overlaySizePercent` schema note
+ (NOT optional — it carries the overlay schema)
+- [x] agent-skill `examples.md`: add a cols/rows + anchor recipe and a re-anchor-only example
+- [x] `site/commands.html`: update the overlay entries (invocation, args, `tree` read-back fields)
+- [x] `README.md` + `site/docs.html`: update the overlay section (mirror each other)
+- [x] `.claude/rules/control-api.md`: update the overlay command **args** doc (not only the read-back
+ note) — new flags, tightened open validation, requested+applied read-back, anchor-preserved-on-full
+- [x] grep the skill + `control-api.md` for stale percent-only / "centered" / old-state-shape wording and
+ fix; note in the plan that `site/index.html` is intentionally untouched (non-major, non-release)
+- [x] re-run `make lint` to confirm nothing regressed
+
+> **Note:** `site/index.html` is intentionally left untouched. This change is a per-command / per-arg
+> addition (overlay `--cols/--rows/--anchor` + read-back fields), not a major feature or a release;
+> `site/index.html` tracks major features and the current release version (`softwareVersion` JSON-LD), so it
+> is out of scope for this doc pass.
+
+### Task 9: Verify acceptance criteria
+- [x] verify Overview: cols/rows sizing, 9 anchors, size clamp, percent/full unchanged, default center,
+ requested+applied+anchor read-back, Decision-3 open tightening — all confirmed in code:
+ `OverlaySize.cells` + `OverlayLayout.panelSize`/`cellExtent` (clamp); `OverlayAnchor` 9 cases,
+ `Session.overlayAnchor = .center` default, preserved on `--full` (`resizeOverlay`), reset only in
+ `closeOverlay`; anchor emitted on tree whenever `overlayActive` (`AppStore+ControlTree`);
+ Decision-3 percent `1...100` hard-error for open in `parseOverlaySize`/CLI `validateSize`
+- [x] verify edge cases: cols/rows > pane (clamped, applied reflects it), 1×1, Retina 2× correct,
+ anchor with no floating size (error), resize anchor-only, `--full` preserves anchor, no new
+ `Command` case (count 64), no persistence changes — all confirmed: `cellExtent` floors at 1 cell /
+ caps at fit / never exceeds pane; Retina px→point via `refreshOverlayMetrics` ÷ `backingScaleFactor`;
+ `git diff d597d05..HEAD` adds ZERO `Command` cases; `Snapshot.swift` has no overlay fields and was
+ untouched by the branch
+- [x] run full host-free suite: `cd agtermCore && swift test` — 1780 tests in 74 suites passed
+- [x] run e2e: the `ControlOverlaySplitUITests` overlay cases (incl. `stty size` + frame assertions) —
+ 5/5 passed (`testOverlayOpenColsRowsAnchorReadBack`, `testOverlayColsRowsRealizedGridMatchesRequest`,
+ `testFloatingOverlayPanelFrameFollowsCornerAnchor`, `testOverlayResizeTransitionsCycleWhileSplit`,
+ `testOverlaySizingAnchorErrorsOverSocket`)
+- [x] `make lint` clean; app builds (`make build`) — lint exit 0 with 0 findings; Debug BUILD SUCCEEDED
+
+### Task 10: Final documentation + close-out
+- [x] update `CLAUDE.md`/`.claude/rules` only if a genuinely new pattern emerged (e.g. the observed-
+ metrics-from-`CELL_SIZE` bridge, if worth recording) — added the "live libghostty surface metric
+ drives SwiftUI reactively by mirroring into observed `Session` state" note to
+ `.claude/rules/libghostty.md` (near the `updateMetalLayerSize` / rendering-plumbing notes)
+- [x] move this plan to `docs/plans/completed/` (performed by exec finalize — kept in place through review phases)
+
+## Post-Completion
+*Items requiring manual intervention or external systems — no checkboxes, informational only*
+
+**Manual verification:**
+- Placement and cell-exact fit: verify by eye in an isolated dev instance (`open -n --env
+ AGTERM_STATE_DIR=/tmp/agt-overlay --env AGTERM_CONTROL_SOCKET=/tmp/agt-overlay.sock /agterm.app`,
+ drive via `agtermctl … --socket /tmp/agt-overlay.sock`): each of the nine anchors places against the
+ correct edge/corner; `--cols/--rows` renders the exact grid on BOTH a 1× and a Retina display; an
+ oversized request clamps to whole cells and `overlayColsApplied/RowsApplied` report the clamp. Never
+ touch the deployed `~/Applications/agterm.app`.
+- **Exactness/drift call:** if manual verification shows the realized grid is consistently off by a cell
+ from the request (padding-estimate drift), open a follow-up to add a bounded post-realization frame
+ correction — deliberately OUT of v1 scope, with the applied read-back exposing the drift meanwhile.
+- Cell metrics when the overlay font differs from the main pane's: the overlay is created with
+ `session.fontSize` (`agtermApp.swift:435`), so the pre-realization main-surface fallback matches in the
+ normal case; the differing-font case to sanity-check is an overlay zoomed AFTER creation, or a main
+ surface under a transient dashboard font override — not a merely-zoomed main pane.
+
+**External system updates:** none — no consuming projects, no deployment/config changes.
+
+---
+Smells pre-check: skipped — non-Go project (no `go.mod`; the Go-specific planning rules — go-architect,
+code-quality block, Design Contract, smells pre-check — do not apply). agterm project-rule gates are in
+the "Project-Rule Gates" section above. Reviewed by plan-review + Codex; findings and three maintainer
+decisions folded into "Review Revisions".
diff --git a/site/commands.html b/site/commands.html
index 0d7a9f84..e3584d37 100644
--- a/site/commands.html
+++ b/site/commands.html
@@ -423,7 +423,12 @@
splitRatio,
splitFocused,
overlay,
- overlaySizePercent,
+ overlaySizePercent (a percent-mode overlay's floating-panel percent),
+ overlayCols /
+ overlayRows (a cells-mode overlay's requested grid),
+ overlayColsApplied /
+ overlayRowsApplied (the realized grid any floating overlay rendered after clamping),
+ overlayAnchor (which of nine positions any open overlay anchors to, reported even for a full overlay),
scratch,
flagged,
status,
@@ -439,8 +444,17 @@
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
+ canvasCols /
+ canvasRows (the terminal content area — the overlay
+ "canvas" — in whole cells at the base font, the coordinate system
+ overlay open --cols/--rows land in,
+ so a script can size a floating overlay as a fraction of the canvas; split-agnostic full height and full width. A floating
+ overlay is inset by a uniform one-line-height safe-area margin, so a
+ --cols canvasCols request clamps to the
+ usable area — read overlayColsApplied/overlayRowsApplied
+ back for the realized grid. Omitted when no surface is realized).
Workspace node:
@@ -1162,19 +1176,33 @@
- agtermctl session overlay open <command> [--cwd DIR] [--wait] [--block] [--size-percent N]
- [--background-color #rrggbb] [--follow] [--target T] [--window W]
+ agtermctl session overlay open <command> [--cwd DIR] [--wait] [--block] [--size-percent N | --cols N --rows M]
+ [--anchor POS] [--background-color #rrggbb] [--follow] [--target T] [--window W]
session.overlay.open
- Full-size by default, hiding the session;
- --size-percent N (1–100) makes it a floating
- framed panel with the session visible behind.
+ Full-size by default, hiding the session. Make it a floating framed panel — the session visible behind — with
+ --size-percent N (1–100 of the pane) or
+ --cols N --rows M (an exact terminal grid, both required).
+ Both are adaptive: a --cols/--rows request larger than the pane clamps to the whole cells that fit (a --size-percent panel is a fraction of the pane, never larger), and the realized grid reads back on
+ tree as
+ overlayColsApplied/overlayRowsApplied.
+ An out-of-range --size-percent is a hard error (no longer silently clamped on open).
Returns the overlay's
result.id.
--background-color gives the overlay pane its
own solid color, independent of the session's.
+
+ --anchor POS parks a floating panel at one of nine positions —
+ top-left, top,
+ top-right, left,
+ center (default), right,
+ bottom-left, bottom,
+ bottom-right — inside a uniform safe-area margin of one line-height on all four sides
+ (never flush with any border; symmetric and anchor-independent, so a full-width band is inset left/right exactly like the top).
+ It requires a floating size; on a full-pane overlay it errors.
+
It does not switch the active session by default — both variants open on
--target and run in the background. Pass
@@ -1202,19 +1230,27 @@
- agtermctl session overlay resize (--size-percent N | --full) [--target T] [--window W]
+ agtermctl session overlay resize [--size-percent N | --cols N --rows M | --full] [--anchor POS] [--target T] [--window W]
session.overlay.resize
- Resize an already-open overlay in place. Exactly one of
- --size-percent N (1–100, floating) or
- --full is required; both, neither, or an
- out-of-range percent errors. The program keeps running across the resize — it is a layout re-flow, never a re-spawn.
+ Resize and/or re-anchor an already-open overlay in place. Give at most one size mode —
+ --size-percent N (1–100) or
+ --cols N --rows M (floating), or
+ --full — and/or
+ --anchor POS; at least one is required.
+ --anchor alone re-anchors keeping the current size; a size mode alone keeps the anchor.
+ The program keeps running across the resize — it is a layout re-flow, never a re-spawn.
Errors no overlay when none is open.
- Record the node's overlaySizePercent first to
- restore the exact size after a zoom to --full.
+ --full cannot be combined with
+ --anchor, but a
+ --full round-trip preserves the anchor. Record the node's
+ overlaySizePercent (or
+ overlayCols/overlayRows) and
+ overlayAnchor first to restore the exact size and position after a zoom to
+ --full.
diff --git a/site/docs.html b/site/docs.html
index 7891b309..b8d64470 100644
--- a/site/docs.html
+++ b/site/docs.html
@@ -1306,14 +1306,18 @@
>
agtermctl session overlay open
"htop" --size-percent 70
agtermctl
session overlay open
"revdiff HEAD~3" --size-percent 80 --background-color
"#2a1a3a"# an exact 80x24 grid, parked in the bottom-right corner (clamped to fit)agtermctl session overlay open
"htop" --cols 80 --rows 24 --anchor
+ bottom-right
# switch the user to the target as the overlay opensagtermctl session overlay open
"revdiff HEAD~3" --size-percent 80
--target 9f3c --follow
# keep it open after exit, or block until it exits and inherit its statusagtermctl session overlay open
"make test" --wait
agtermctl session
overlay open
"make test" --block
# resize an open overlay in place — floating percent, or back to full (the program keeps running)agtermctl session overlay resize --size-percent 60 --target 9f3c
agtermctl session overlay resize --full
+ ># resize/re-anchor an open overlay in place — a size mode and/or --anchor (the program keeps running)
agtermctl session overlay resize --size-percent 60 --target 9f3c
agtermctl session overlay resize --anchor
+ top-left --target 9f3c
agtermctl session overlay resize --full
--target 9f3c
agtermctl session overlay close --target
9f3c
agtermctl session overlay result
# last overlay's exit status
@@ -1323,17 +1327,32 @@
to close" prompt so you can read the final output;
--block reports only the exit
status (the overlay never captures stdout — a TUI writes its own result file). By default an overlay opens on its
- target without switching the active session (full and
--size-percent
- floating alike);
+ target without switching the active session (full and floating alike);
--follow switches the user to
the target.
+
+
+ Size a floating panel with
+ --size-percent N (1–100 of the pane) or
+ --cols N --rows M (an exact terminal grid);
+ the cols/rows grid clamps to the whole cells that fit and a percent panel simply never exceeds the pane; the realized grid reads back on
+ agtermctl tree as
+ overlayColsApplied/overlayRowsApplied.
+ To size relative to the pane,
+ agtermctl tree also reports
+ canvasCols/canvasRows
+ — the terminal content area in whole cells at the base font, the coordinate system
+ --cols/--rows land in — so a script can compute a concrete grid as a fraction of the canvas.
+ --anchor parks it at one of nine positions
+ (center is the default) inside a uniform safe-area margin of one
+ line-height on all four sides, so it is never flush with a border — the margin is symmetric and anchor-independent, so a full-width band is inset left/right exactly like the top.
session overlay resize changes an
- open overlay in place —
- --size-percent makes it floating,
- --full switches it back — without
- restarting the program. A
+ open overlay in place — a size mode
+ (--size-percent,
+ --cols/--rows, or
+ --full) and/or
+ --anchor — without restarting the program;
+ --full preserves the anchor for a later re-float. A
(overlay) tag in
agtermctl tree marks a session
whose overlay is open.