Skip to content

feat(linux): size the quick terminal card as a percentage of the window - #22

Merged
melonamin merged 2 commits into
melonamin:linux-portfrom
n2tr2:linux-quick-terminal-percent-sizing
Aug 11, 2026
Merged

feat(linux): size the quick terminal card as a percentage of the window#22
melonamin merged 2 commits into
melonamin:linux-portfrom
n2tr2:linux-quick-terminal-percent-sizing

Conversation

@n2tr2

@n2tr2 n2tr2 commented Aug 9, 2026

Copy link
Copy Markdown

What

The Linux quick terminal is placed by fixed pixel margins — 56 px at the top and 44 px on the other three sides — so its size does not track the window. The bigger the window, the closer the card gets to filling it: on a 2560×1440 window it covers 96.5% of the width and reads as near-fullscreen, and the sidebar disappears underneath it.

large window, before and after

macOS sizes the same card proportionally: QuickTerminalPane is geo.size.width * 0.9 × geo.size.height * 0.9 (agterm/Views/WindowContentView.swift:781), inside a windowOverlayLayer that spans the full window width and is inset only by titlebarHeight (:126). So the macOS rule is "90% of the window, centered, below the titlebar" — and site/docs.html:681 already documents the quick terminal as "overlaid at 90% of the window". Linux was the outlier; the docs were already right.

Root cause

A fixed margin and a percentage disagree in opposite directions as the window changes size, so a single pair of constants cannot match the macOS rule at more than one window size. Measured on the two builds, same content, same conditions:

client area before after
1270 × 790 1182 px wide — 93.1% 1143 px wide — 90.0%
2550 × 1430 2462 px wide — 96.5% 2295 px wide — 90.0%

The "before" column is the tell: the card's proportion drifts with the window because the band is pinned at 44 px, while the "after" column is constant by construction.

small window, before and after

Fix

Two commits: the host-free math, then the GTK wiring.

LinuxQuickCardPolicy gains cardSizePercent (90 — the macOS constant) and cardAllocation(overlayWidth:overlayHeight:headerHeight:), returning the card rectangle in overlay coordinates: 90% of the area below the header, centered horizontally in the full overlay and vertically in that available area, so the header stays visible and clickable above the card.

The name is qualified deliberately: the unqualified sizePercent already means the session overlay's own percentage elsewhere in the port (AppControllerSurfaces.swift:147), and the two are unrelated numbers with different models — the overlay card has max(240, …)/max(160, …) pixel floors and is sized once at open time, not per layout pass.

The function is total over its Int32 inputs. The multiply widens to 64 bits and comes back through Int32(clamping:), the idiom GhosttySurfaceGeometry.initialBackingSize already uses — a naive width * cardSizePercent / 100 traps near Int32.max, while width / 100 * cardSizePercent would silently change the rounding. A headerHeight at or beyond overlayHeight (a window shorter than its own header) collapses the available height to 0 rather than going negative.

There are deliberately no pixel floors. macOS has none, and a floor here could not raise the window's own minimum size anyway — it could only clip the card.

The wiring is a GtkOverlay::get-child-position handler on the deck overlay, which hands quickFrame an explicit rectangle on every layout pass, plus the removal of the four margin calls in setQuick. Because the handler re-measures the overlay each pass, the card now tracks a live window resize and a hidden-toolbar toggle instead of holding a fixed inset.

Three details are load-bearing, and are pinned in the handler's doc comment:

  • connect() uses flags 0, not G_CONNECT_AFTER. The signal is declared when="last" with a boolean-handled accumulator and the class default returns TRUE for every child, so an AFTER connection would never run.
  • The frame keeps GTK_ALIGN_FILL and zero margins. gtk_widget_size_allocate re-applies align and margins inside the returned rectangle, so copying syncOverlay's GTK_ALIGN_CENTER would collapse the card back to its natural size and silently defeat the explicit allocation.
  • The header is measured only while it is shown. A hidden-but-previously-allocated GTK4 widget retains its last allocated height, which would otherwise inset the card by a strip that is not on screen in hidden-toolbar mode.

The handler answers only for quickFrame. Every other deckOverlay child returns 0 and keeps GTK's default, alignment-based placement: the per-session floating overlay frames, the zoom host, the dashboard host, the Ctrl-Tab switcher box and the GL-error label. The zoom case is load-bearing — zooming .quick hides quickFrame and adds a FILL/expand zoomHost, which must never be given the card rectangle.

Teardown rides the usual registry recovery: controllerForWidget resolves through gWindows, which windowWillClose has already left, so a late emission on a closing window falls through to default placement rather than touching a freed controller.

One second-order behavioural change worth stating. Dropping the margins removes the quick frame's contribution to GtkOverlay::measure, so a window with a live quick terminal can now be dragged smaller than before — previously its own minimum plus 88/100 px of margin raised the window minimum. This is an improvement, but it is a real change.

Keep-in-sync

Nothing owed, recorded so it is not relitigated:

  • No control command. This is pure geometry with no new state — no AppActions action, no Command case, no agtermctl subcommand, so the write→read-back rule on tree is vacuous. A configurable size (quick.resize --percent, mirroring session.overlay.resize/overlaySizePercent) is the obvious shape if it is ever wanted; it is deferred, not exempt, and out of scope for a parity fix.
  • No Settings ▸ Interface toggle. The card's proportion is not a hideable affordance.
  • No README / site/ / agent-skill impact. No command, flag, keybinding, mode or model change — and site/docs.html:681 already says 90%, so this change makes the code match the docs rather than the other way round.

Change

feat(linux): add percent-based quick card allocation math

file +/−
agterm-linux/Tests/AgtermLinuxTests/LinuxQuickCardPolicyTests.swift +126 / −0
agterm-linux/Sources/AgtermLinux/LinuxQuickCardPolicy.swift +83 / −13

feat(linux): size the quick card via get-child-position

file +/−
agterm-linux/Sources/AgtermLinux/AppControllerCallbacks.swift +58 / −0
agterm-linux/Sources/AgtermLinux/AppController.swift +9 / −9

AppController.swift stays at 985 lines against the 1000-line file_length limit — a net 0, and no lint limit was touched. The signal connect is one line there calling an installQuickCardPlacement(on:) helper in the callbacks file, following the existing installEmptyWindowKeyController precedent: the inline unsafeBitCast for this 4-argument signature is ~226 columns, past the 200-column line_length limit.

The 13 removed lines in LinuxQuickCardPolicy.swift are the cardCSS shadow-budget comment, which bounded the shadow against the 44 px margin this change removes. Its replacement states a threshold instead of a constant — a 5% band clears the shadow's 8 px offset + 32 px blur only above ~800 px — and spells out the floating overlay card's different model so the two cards are not read as sharing math.

Testing

Run locally:

  • swift test --package-path agterm-linux — 210 tests / 30 suites. One failure: IntegrationServiceTests "Flatpak process environments do not offer a host launcher", which reproduces on an unmodified linux-port checkout on this box (a real agtermctl is installed at /opt/agterm-linux/bin, so packageCLI() resolves .installed before the flatpak guard is reached). The new Linux quick-terminal card allocation suite passes — 10 tests, including a 20-case @Test(arguments:) sweep.
  • swift test --package-path agtermCore — 2040 tests, 3 failures, all in CodexStatusHookTests and all pre-existing here (Manjaro ships a /usr/bin/plutil via libplist, so the hook takes the macOS branch). Confirmed with --filter CodexStatusHookTests; this change touches no file under agtermCore.
  • swift build for both AgtermLinux and agtermctl-linux — clean.
  • swiftlint lint --strict0 violations in 410 files.
  • scripts/check-linux-core-boundary.sh, scripts/check-linux-cli-drift.sh, git diff --check linux-port...HEAD — all clean.
  • scripts/test-linux-ui.sh — 13 of 14 scenarios pass, including hidden-toolbar, which is the scenario nearest this change (it toggles the very header the handler measures). The 14th, context-menu, fails identically on a pristine linux-port build — I built one and ran the same matrix against both to check rather than assume. Note this box also needs a private dbus-daemon + at-spi2-registryd for the suite to see the app at all; that is a host quirk, no repo file was changed for it.
  • The screenshots above were captured offscreen under Xvfb + llvmpipe software GL, from two binaries built from this branch and from unmodified linux-port at 86c29ff, under identical conditions. Every pixel value quoted is measured — the card rectangle is located by its alpha(#ffffff, 0.18) over #1e2228 border, which renders as a distinctive (71,74,79) — not eyeballed.

Not covered by any automated gate — stated plainly:

The card's on-screen geometry has no assertion behind it. LinuxQuickCardPolicyTests pins the math and the GTK handler is not unit-testable, but no AT-SPI scenario reads the card's extents, so deleting the installQuickCardPlacement line would silently revert this feature with every test still green. The hidden-toolbar centering and the live-resize tracking rest on manual verification on a real Wayland session, which is where the before/after above was confirmed by eye.

A scenario closing that gap is specified — drive agtermctl quick, wait on the quickVisible read-back, then compare the quick frame's Atspi WINDOW extents against the window's, the same shape as the existing verify_sidebar_row_height_follows_font_size — but it is not in this PR: it cannot be executed from the review worktree here, and shipping an unverified e2e scenario seemed worse than shipping none. Happy to add it in a follow-up if you would rather have it alongside.

n2tr2 and others added 2 commits August 9, 2026 02:58
The quick terminal card is placed by fixed pixel margins (56px top, 44px on
the other three sides), so its size does not track the window: on a large
window it stays a thin inset panel, and on a small one the margins eat most
of the content. macOS sizes the same card proportionally — WindowContentView
uses 0.9 x 0.9 of the window content below the titlebar, centered.

Add the host-free half of that parity. LinuxQuickCardPolicy gains
cardSizePercent (90, the macOS constant) and
cardAllocation(overlayWidth:overlayHeight:headerHeight:), returning the card
rectangle in overlay coordinates: cardSizePercent% of the area BELOW the
header, centered horizontally in the full overlay and vertically in that
available area, so the header stays visible and clickable above the card.
The name is qualified because the unqualified sizePercent already means the
session overlay's own percentage elsewhere in the port, and the two are
unrelated numbers.

The function is total over its Int32 inputs. The multiply widens to 64 bits
and comes back through Int32(clamping:), the idiom
GhosttySurfaceGeometry.initialBackingSize already uses — a naive
`width * cardSizePercent / 100` traps near Int32.max, while
`width / 100 * cardSizePercent` would silently change the rounding. A
headerHeight at or beyond overlayHeight (a window shorter than its own
header) collapses the available height to 0 instead of going negative.
There are deliberately no pixel floors: macOS has none, and a floor here
could not raise the window's own minimum size anyway — it could only clip
the card.

Also rewrite the cardCSS shadow-budget comment, which bounded the shadow
against the quick card's 44px margin that the next commit removes. The
replacement bound is a threshold rather than a constant — a 5% band clears
the shadow's 40px reach only above ~800px — and the floating overlay card's
different model (overlaySizePercent with a 240x160 floor, computed once at
open time) is spelled out so the two cards are not read as sharing math.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wire the allocation math into GTK. The deck overlay gains a
GtkOverlay::get-child-position handler that hands quickFrame an explicit
rectangle from LinuxQuickCardPolicy.cardAllocation on every layout pass, and
setQuick drops the 56px top / 44px side margins that used to place it.
Because the handler re-measures the overlay on each pass, the card now
tracks a live window resize and hidden-toolbar mode instead of holding a
fixed inset.

The handler answers ONLY for quickFrame. Every other deckOverlay child
returns 0 and keeps GTK's default, alignment-based placement: the
per-session floating overlay frames, the zoom host, the dashboard host, the
Ctrl-Tab switcher box and the GL-error label. The zoom case is load-bearing
— zooming .quick hides quickFrame and adds a FILL/expand zoomHost, which
must never be given the card rectangle.

Three details are load-bearing and documented at the handler:

- connect() uses flags 0, NOT G_CONNECT_AFTER. The signal is declared
  when="last" with a boolean-handled accumulator and the class default
  returns TRUE for every child, so an AFTER connection would never run.
- The frame keeps GTK_ALIGN_FILL and zero margins, because
  gtk_widget_size_allocate re-applies align and margins INSIDE the returned
  rectangle — copying syncOverlay's GTK_ALIGN_CENTER would collapse the card
  back to its natural size and silently defeat the explicit allocation.
- The header is measured only while it is shown. A hidden-but-previously-
  allocated GTK4 widget retains its last allocated height, which would
  otherwise inset the card by a strip that is not on screen in
  hidden-toolbar mode.

Teardown rides the usual registry recovery: controllerForWidget resolves
through gWindows, which windowWillClose has already left, so a late emission
on a closing window falls through to default placement rather than touching
a freed controller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@n2tr2
n2tr2 requested a review from melonamin as a code owner August 9, 2026 07:32
@melonamin

Copy link
Copy Markdown
Owner

Thank you — this is a focused GTK implementation, and the allocation behavior checks out against the current base. Local build, lint, Linux tests, core tests, and the exact-head CI suite all passed. Merging.

@melonamin
melonamin merged commit afc4c6a into melonamin:linux-port Aug 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants