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 opens
agtermctl 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 status
agtermctl 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.