Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d1c6dae
docs: add overlay anchor + cols/rows sizing implementation plan
umputun Jul 23, 2026
8e434b6
feat: add host-free overlay layout model and resolver
umputun Jul 23, 2026
1b4be98
feat: migrate overlay to OverlaySize enum with anchor and options
umputun Jul 23, 2026
a1e0ca9
feat: add overlay cols/rows/anchor control args and tree read-back
umputun Jul 23, 2026
8e4773b
feat: validate and route overlay size/anchor in dispatcher
umputun Jul 23, 2026
92d13c9
feat: render overlay cols/rows sizing and 9-point anchor
umputun Jul 23, 2026
0663d25
feat: add agtermctl overlay cols/rows/anchor flags
umputun Jul 23, 2026
0d41d47
test: e2e for overlay cols/rows sizing and anchor
umputun Jul 23, 2026
fa788d1
test: e2e for overlay cols/rows sizing and anchor with 1-cell margin
umputun Jul 23, 2026
24a0d3b
fix: use line-height for overlay anchor margin on both axes
umputun Jul 23, 2026
b1f385c
docs: document overlay cols/rows sizing and anchor across surfaces
umputun Jul 23, 2026
d80577e
test: verify overlay anchor + cols/rows acceptance criteria
umputun Jul 23, 2026
2b12b62
docs: record overlay metrics-via-CELL_SIZE pattern and close out plan
umputun Jul 23, 2026
b323834
fix: address code review findings
umputun Jul 23, 2026
95edc1c
refactor: centralize overlay pairing validation and address smells
umputun Jul 23, 2026
8733135
feat: report terminal content area size in tree
umputun Jul 23, 2026
f5d1467
fix: apply overlay margin as a uniform safe-area inset on all sides
umputun Jul 23, 2026
b57c431
fix: address codex review findings (applied read-back re-report, spli…
umputun Jul 23, 2026
3a6b642
docs: correct canvasCols contract (safe-area clamp + pixel split meas…
umputun Jul 23, 2026
8729b28
docs: move completed plan 20260722-overlay-anchor-and-cell-sizing.md …
umputun Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 137 additions & 43 deletions .claude/rules/control-api.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions .claude/rules/libghostty.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 4 additions & 2 deletions agterm/AppActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
12 changes: 7 additions & 5 deletions agterm/Control/ControlServer+SessionActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
29 changes: 29 additions & 0 deletions agterm/Control/ControlServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions agterm/Ghostty/GhosttyCallbacks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading