feat(linux): give the quick terminal card visible chrome - #15
Merged
melonamin merged 3 commits intoAug 4, 2026
Merged
Conversation
The quick terminal (and the floating session overlay, which shares the agterm-quick class) rendered with no visible boundary against the session behind it. libadwaita's frame node already draws a 1px border and a 12px radius, but the border color is currentColor-derived — invisible dark on dark — and the square opaque GtkGLArea child painted over the corner arcs, erasing the rounded silhouette. Hoist the rule into a host-free LinuxQuickCardPolicy.cardCSS carrying the contract, and give it light-polarity chrome at the unchanged 1px width (border 1px solid alpha(#ffffff, 0.18), explicit border-radius 12px, and a strong offset box-shadow), then clip the GL child to the rounded card with GTK_OVERFLOW_HIDDEN at both frame-construction sites. Border, radius and clip match the macOS chrome; the shadow is deliberately offset and stronger, because a subtle centered one is exactly what proved invisible dark on dark. The opaque #1e2228 backing and the absence of padding are preserved, and the border width stays exactly 1px so the widget's measured chrome is unchanged.
GTK drops an unparseable CSS declaration silently — a Theme parser line on the app's stderr is the only signal anywhere — and the smoke sent that stderr to DEVNULL, so a typo in installAppCSS shipped as missing chrome with every unit test and AT-SPI assertion still green. Capture each launched instance's stderr into the artifact directory and fail the run on any Theme parser error or warning; GTK emits the warning variant from the same call site for a deprecated or unimplemented construct, which drops the declaration just as silently. Covers the whole app CSS, not just the quick/overlay card rule. The match is scoped to the `<data>` section name so it stays OUR CSS: GTK's default handler prints the same line for every provider with no connected handler, Adwaita and libadwaita included, and a parse message from a system stylesheet is one this repo cannot fix. All four of the app's providers load from a string, whose section has no GFile and prints `<data>`; a resource- or file-loaded stylesheet prints a display name instead. Verified against the real parser on GTK 4.22.4 and in the 4.14.0 sources the CI runner ships. The guard must not fail open, so the runner hands over the exact log path instead of both sides deriving the same filename, and the sink stamps a marker the runner requires afterwards — an empty log now fails loudly rather than reading like a clean one. Also run the surface-lifetimes overlay through BOTH shapes. A floating card (--size-percent 60 --follow) is the only place either GTK_OVERFLOW_HIDDEN call site runs under Xvfb on this branch, and --follow is load-bearing there: a floating frame on a non-selected session is hidden and skipped in layout, so the rounded clip would never reach GSK. The un-sized default overlay then runs the same assertions after the card tears down, keeping syncOverlay's stack branch covered — this scenario is the suite's only `session overlay open`, so converting it rather than pairing it would have left the more common shape with none. Both waits poll the tree's `overlay` flag, which also proves each teardown ran.
`@keyframes agterm-blink-pulse` used a comma-separated selector list (`0%, 100%`). GTK gained GTK_CSS_TOKEN_COMMA handling in _gtk_css_keyframes_parse after 4.14: 4.22 builds a selector array and loops on the comma, while 4.14 consumes a single progress value and then expects the block, so the comma is a parse error. GTK drops an unparseable @Keyframes silently, so the agent-status pulse has been dead on GTK 4.14 -- Ubuntu 24.04, which is the CI runner -- with nothing to report it. Splitting into one selector per keyframe parses on both; verified against the 4.14.0 and 4.22.4 sources and by loading the composed app CSS through a real GtkCssProvider locally. Found by the CSS parse guard added in the previous commit, on its first CI run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Owner
|
Thank you — this is a sound Linux-native implementation. The Quick Terminal and floating-overlay chrome is correctly scoped to GTK, the clipping behavior is well justified, and the added CSS validation is useful. Local tests, release build, and lint all pass. Great work. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The Linux quick terminal (Ctrl+
) and the sized floating session overlay read as a flat region of the window rather than a panel floating above the session behind them. There is no contour to separate card from content, nothing to lift the card off the background, and the squareGtkGLArea` paints into the corners the frame is supposed to round off.One part of the reported symptom was never broken, and it is worth saying so up front. libadwaita already draws a 1px border and a 12px radius on
frame— on this box (libadwaita 1:1.9.2-1,gtk4 1:4.22.4-1), from the stylesheet compiled into/usr/lib/libadwaita-1.so.0:So this is not "add a missing border". The border was there; it was derived from
currentColor, which on a dark card resolves darker than its own surround — measured at luma 27 against a 48 background. A dark line on a dark panel reads as an absence of chrome, not as a contour. What was genuinely missing is the shadow (nothing at all) and the clip.Root cause
Three separate things, only one of which is a true absence:
currentColor-derived, so it goes the wrong way on a dark card: darker than the background it is meant to separate from..cardcontributes only a faint box-shadow that is invisible at this scale, so nothing lifts the card off the session.GTK_OVERFLOW_VISIBLEis the widget default, so the square, opaqueGtkGLAreachild paints over the frame's rounded corner arcs, flattening the silhouette the radius describes.Fix
One host-free CSS constant plus one call at each of the two frame-construction sites.
LinuxQuickCardPolicy.cardCSSemits the single.agterm-quickrule thatinstallAppCSS()interpolates atGTK_STYLE_PROVIDER_PRIORITY_APPLICATION(600), which is what lets it win over the theme's 200 — the same mechanism the pre-existingbackground-color: #1e2228already relied on:contentSize(request:chrome:)-style subtraction valid. A negative pin in the unit test forbids aborder-widthdeclaration for exactly this reason.8 + 32 = 40 pxunderstates a Gaussian's reach, and the halo resolves only just inside the card's 44/56 px margins.#1e2228backing stays opaque — it is what stops the card going see-through when the surface below draws atbackground-opacity < 1. It paints under the border area, because plain.card(unlikebutton.card, stylesheet line 378) does not setbackground-clip: padding-box, so the 18% white composites over the backing.gtk_widget_set_overflow(W(frame), GTK_OVERFLOW_HIDDEN)at both sites clips the GL child to the padding-box rounded rect. GTK snapshots the CSS background, border, and shadow before pushing that clip, so the card's own chrome is unaffected — only the child stops overdrawing the arcs.The clip's effect is small, and the screenshot is zoomed 16x to make it visible at all. Tracing the first painted terminal column per row at the corner: before it is
52on every row; after it is54, 53, 52— a 2 px inset over the arc. Real, but a couple of pixels, not a reshaping. Consequences worth knowing: roughly 2–4 px of the corner cells are clipped (the same trade-off macOS'sclipShapemakes), andgtk_widget_pickstops hit-testing the corner arcs, so those few pixels become click-dead.Both cards share the
agterm-quickclass on the same GtkFrame shape, so the floating overlay inherits all of it by construction — no second class, no divergence..cardstays on both frames: it still suppliescolor: var(--card-fg-color)and the:focus-visibleoutline.A second commit adds a CI guard for this change's worst failure mode. GTK drops an unparseable declaration silently, so a typo would present as "chrome simply absent" — indistinguishable from the fix not working, with unit tests still green because they assert a Swift string, not GTK parseability.
atspi_smoke.pynow routes the app's stderr to an artifact file andtest-linux-ui.shfails the run onTheme parser (error|warning): <data>. The<data>scoping is deliberate and verified: a string-loaded provider's section prints the literal<data>(gtkcssprovider.cpassesfile = NULL;gtkcsssection.crenders<data>whensection->file == NULL), while resource-loaded system stylesheets print a display name — so the guard covers all four of the app's providers and cannot be reddened by a deprecation in GTK's or libadwaita's own CSS. Verified against both the local GTK 4.22.4 and the 4.14.0 source the CI runner uses; the format string is identical in both.The same commit also extends the AT-SPI overlay scenario. It previously had the suite's only
session overlay opencall, un-sized, sosyncOverlay's floating branch — where one of the two newset_overflowcalls lives — never ran in CI. It now exercises both shapes: a sized--size-percent 60 --followcard and the original full overlay, so neither teardown path loses coverage.--followis load-bearing: without it the frame is createdset_visible(0), GTK skips a hidden widget in layout, and the rounded clip never reaches GSK.Across themes
The three new declarations are hardcoded, not theme-derived — deliberately, but it raises the obvious question of what happens under a theme other than the default, so it is measured rather than argued. The border composites
alpha(#ffffff, 0.18)over the frame's own#1e2228backing, never over the theme, so it lands at luma 73.7 regardless of theme; only what sits behind it changes. Same three sample points as above, one instance per theme:Two things fall out of this, and both are load-bearing:
The darker the theme, the worse the original bug and the bigger the win. On the default theme the old border sat at luma 27 against 48 — a wrong-polarity line, but a visible one. On Kanagawa Dragon it sat at 27 against 22, a delta of +4.6: not merely wrong-polarity but genuinely invisible. That is the regime the bug was actually reported from, and it is where the fix helps most.
The border and the shadow cover for each other across the range. A black shadow has almost no headroom to darken an already-near-black session (11 luma steps on Kanagawa), so there the light border does the separating. On a light theme the arithmetic inverts: the shadow darkens by 127 and carries the card, while the border — still at 73.7 — reads as a dark contour against 239. Note that on a light theme the border was already clearly visible before this change (delta −220), so the fix slightly reduces its contrast there (−220 → −165); both are far above any visibility threshold, and the newly added shadow more than compensates. There is no theme in which the card ends up less separated than it started.
The guard found a real bug on its first run
Worth reporting, because it is the whole argument for the guard existing.
build-linuxwent red on the first CI run of this branch — every AT-SPI scenario passed, and then the new check failed the job on:Column 35 of line 2 is the comma in
@keyframes agterm-blink-pulse { 0%, 100% { … } … }— a line this PR does not touch (theApp.swifthunk here is+1/−1on the.agterm-quickline). GTK gainedGTK_CSS_TOKEN_COMMAhandling in_gtk_css_keyframes_parsesometime after 4.14: 4.22.4 builds a selector array and loops on the comma (gtkcsskeyframes.c:511-513), while 4.14.0 consumes a single progress value and goes straight to the block (gtkcsskeyframes.c:336-356), so the comma is a parse error there.Because GTK drops an unparseable
@keyframessilently, the agent-status blink pulse has simply been dead on GTK 4.14 — which is Ubuntu 24.04, and therefore the CI runner itself — with nothing anywhere to report it. It parses on the 4.22 most developers run, so it was invisible from a dev box.The third commit splits it into one selector per keyframe, which parses on both. Verified against the 4.14.0 and 4.22.4 sources, and by loading the fully composed
installAppCSSstring through a realGtkCssProviderlocally. The CSS block is now all-ASCII, deliberately: the comment explaining a 4.14 parser constraint should not itself introduce a non-ASCII character into CSS I cannot run 4.14 against.Keep-in-sync
Nothing owed, recorded so it is not relitigated:
AppActionsaction, noCommandcase, noagtermctlsubcommand. Both frames are already driven by the existingquickandsession.overlaycommands, and since nothing new is set, the write→read-back rule ontreeis vacuous.site// agent-skill impact — no command, flag, keybinding, mode, or model change. The README's quick-terminal screenshot is a macOS capture, so it is not stale.docs/plans/completed/carries the plan doc, matching the convention already in the branch; it is the bulk of the diff by line count.Change
feat(linux): give the quick terminal card visible chromeagterm-linux/Sources/AgtermLinux/LinuxQuickCardPolicy.swiftagterm-linux/Tests/AgtermLinuxTests/LinuxQuickCardPolicyTests.swiftagterm-linux/Sources/AgtermLinux/AppController.swiftagterm-linux/Sources/AgtermLinux/App.swiftagterm-linux/Sources/AgtermLinux/AppControllerSurfaces.swiftdocs/plans/completed/20260803-linux-quick-terminal-card-chrome.mdtest(linux): fail the UI smoke on a GTK CSS parse erroragterm-linux/tests/atspi_smoke.pyscripts/test-linux-ui.shfix(linux): make the blink keyframe parse on GTK 4.14agterm-linux/Sources/AgtermLinux/App.swiftAppController.swiftlands at 993 lines against the 1000-linefile_lengthlimit — a net +1, and no limit was touched.Testing
Run locally:
swift test --package-path agterm-linux— 187 tests / 28 suites. One failure:IntegrationServiceTests"Flatpak process environments do not offer a host launcher", which reproduces on an unmodifiedlinux-portcheckout on this box (a realagtermctlis installed, so the probe resolves.installedrather than.unavailable). The newLinux quick-terminal card chromesuite passes.swift test --package-path agtermCore— 2040 tests / 84 suites, 3 failures, all inCodexStatusHookTestsand all pre-existing here (Manjaro'sgnustep-baseships a/usr/bin/plutilthat rejects-extract … raw). Confirmed with--filter CodexStatusHookTests; this change touches no file underagtermCore.swift buildfor bothAgtermLinuxandagtermctl-linux— clean.swiftlint lint --strict— 0 violations, 0 serious in 408 files (0.65.0, via mise, withLINUX_SOURCEKIT_LIB_PATHpointed at the toolchain'susr/lib).git diff --check linux-port...HEADandscripts/check-linux-cli-drift.sh— clean.Theme parserlines on the dev instance's stderr, and zero in every capture run below. This is the check the new CI guard automates.linux-port, under identical conditions. All pixel values quoted are measured, not eyeballed.Not run locally — relying on branch CI:
scripts/test-linux-ui.sh, the full AT-SPI sweep, including this branch's own additions (the CSS-parse guard and the extended overlay scenario). The first CI run has since exercised it end to end: every scenario passed, including the extended overlay case that drives the new clip under llvmpipe, and the guard correctly failed the job on the pre-existing 4.14 keyframe error described above. It is not runnable end-to-end on this box: thecontext-menuscenario fails here for an unrelated, pre-existing reason (session rows do not receive pointer events at their reported position, reproducible at a base checkout), and the sweep aborts at the first failing scenario. The guard's shell logic was instead simulated against{empty, clean, app warning, app error, system-stylesheet warning, system-stylesheet error, missing log, missing marker}×{suite exit 0, exit 2}, andpython3 -m py_compile/bash -nboth pass.GskOutsetShadowNode. Under Xvfb, an explicitGSK_RENDERER=nglrun is pixel-identical to the sandbox default (andGSK_RENDERERis definitely being read — a bogus value warns), but that sandbox has no Vulkan, so it does not exercise the renderer a desktop session actually picks.