feat: add runtime theme system - #86
Conversation
`theme check <file>` now builds its registry from the file's own directory,
as the V1 spec requires, so a portable package whose child extends a sibling
validates as the package it is. The neighbourhood is read with the loader's
own limits and lexicographic order, the explicit target can never collide
with a second read of itself, and only the target may stand in for a built-in
of the same id. A parent that actually came from a sibling is reported, since
installing the child alone would leave it unresolvable.
`{ auto = false }` is no longer accepted: `auto` is the whole-style reset and
`false` is a line the resolver ignores, so it is now a span-accurate error in
both validation modes.
`ResolvedTheme` carries its invariants in the type rather than by convention.
Its fields, those of `ResolvedSemantic` and `ResolvedGradient`, `GradientId`'s
constructor and `ResolvedComponents::new` are crate-private; reading accessors
cover everything a CLI export or a renderer needs. A paint role pointing past
the gradient table now trips a debug assertion instead of only discolouring a
frame, while release builds keep the defensive fallback.
Also stops `paint_color_at` from panicking on a rect whose right or bottom
edge saturates, which no layout produces but a public method must survive.
Petyok
left a comment
There was a problem hiding this comment.
Review: FIX FIRST — not merge-ready yet
Thanks @michabbb — this is a serious theme-system contribution (schema, inheritance, picker, CLI, PTY exclusion work, broad tests/docs).
CI: Lint ✅ · Ubuntu ✅ · macOS ❌ (tui::tests::default_dashboard_matches_frozen_legacy_buffer)
Blockers
- Golden dashboard hermeticity —
render_agent_panelcallsdetect_agent()(readsSSH_AUTH_SOCK/ssh-add) every frame; macOS runners have an agent socket, so the frozen fixture fails deterministically. Verified locally: unset sock → pass; set sock → fail. - Tab-slide PTY protection gap —
FrameComposition::captureonly protects when the active (incoming) sessionshows_remote_pty. During Ctrl+arrow onto a Connecting tab, outgoing Running PTY cells are blitted then theme-painted. Same class of bug already fixed for the exit slide.
Also please (lower priority)
- Bound theme file reads (
metadatathen unboundedread_to_stringTOCTOU / FIFO hang). theme checkshould reject non-regular files.theme showshould not dumptoml_sourcefor unparseable symlink targets outside the themes dir.
Parse/validate/resolve/manager look solid under review (cycles, depth, compatible discard, transactional reload, reserved IDs). Happy to re-review once the two blockers are green on macOS.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| @@ -401,12 +434,18 @@ pub(crate) fn content_fade(at: Option<std::time::Instant>, motion: bool) -> f32 | |||
| } | |||
|
|
|||
| pub(crate) fn render_agent_panel(buf: &mut Buffer, area: Rect, app: &App) { | |||
There was a problem hiding this comment.
blocker: this panel still calls detect_agent() (around L454) every frame — SSH_AUTH_SOCK + ssh-add -l. The new frozen golden expects socket not found, so macOS CI (and any contributor with an agent) fails deterministically.
keys.rs already reads app.agent_info. Paint from that cache here too, and set a fixed AgentInfo in the golden setup. Do not use process-global env::remove_var in the test (races the parallel harness).
Bonus: removes a subprocess from the 50 ms render loop.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| // but are SSHub's own chrome, and a theme is allowed to back them. | ||
| if shows_session_view(app) { | ||
| if let Some(session) = app.active_session() { | ||
| if crate::session::render::shows_remote_pty(session) { |
There was a problem hiding this comment.
blocker: protection is keyed only on the active session. During session_tab_switch, active is the incoming tab. If that tab is Connecting / disconnected Exited, protected stays empty while render_session_tab_slide still blits the outgoing Running PTY into remote_pty_rect, then apply_app_background paints theme colour into those cells.
Please protect remote_pty_rect whenever session_tab_switch is armed (over-protect like the exit slide), and add a regression test for Running → Connecting mid-slide.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| // fixed reference while its highlight slides between tabs (#35). | ||
| let area = crate::session::render::body_rect(frame.area()); | ||
| // fixed reference while its highlight slides between tabs (#35). The rect | ||
| // is the viewport's own, so the region cleared and blitted here is exactly |
There was a problem hiding this comment.
risk: render_session_tab_slide still takes its own Instant::now() for progress (just above this blit). FrameComposition already captured a frame clock — same skew the exit-slide comment warns about. Please drive tab-slide progress from the composition clock too.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| assert!(buffer_contains(&buffer, "web-prod")); | ||
| fn default_dashboard_matches_frozen_legacy_buffer() { | ||
| let buffer = render_default_theme_golden_surface(&test_app_with_hosts(), 132, 38); | ||
| assert_eq!( |
There was a problem hiding this comment.
nit: assert_eq! on ~5k-line signatures dumps an unusable diff in CI (macOS log stops at left == right failed). Prefer reporting the first differing cell (coord + expected/actual).
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| length, | ||
| }); | ||
| } | ||
| let text = fs::read_to_string(file).map_err(|source| ThemeRegistryError::Io { |
There was a problem hiding this comment.
risk: size check is metadata then unbounded read_to_string. Concurrent grow / FIFO after a zero-length metadata can bypass the 1 MiB bound or hang the CLI. Prefer open once + .take(MAX+1), and require a regular file (this path currently skips the is_file() guard used by the directory loader).
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| continue; | ||
| } | ||
|
|
||
| let text = match fs::read_to_string(&path) { |
There was a problem hiding this comment.
risk: same metadata-then-unbounded-read pattern on the directory loader. Open the file handle once and read through .take(MAX_THEME_FILE_BYTES + 1).
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| println!( | ||
| "# copied from theme '{id}'; change `name` before installing under a new filename" | ||
| ); | ||
| print!("{}", record.toml_source); |
There was a problem hiding this comment.
risk: theme show prints toml_source with no validity gate. A symlink planted in themes/ that points at a private file (e.g. ~/.ssh/id_ed25519) fails TOML parse, stays listed as invalid, and theme show <id> dumps the target contents under the user’s privileges (≤1 MiB).
Keep symlink follow for discovery if you want shared packs, but either reject canonicalize-outside-themes-dir, or suppress toml_source for records that failed to parse.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| } | ||
| } | ||
|
|
||
| if app.config.appearance.opaque_background { |
There was a problem hiding this comment.
nit / docs: intentional carve-out — opaque_background paints theme.semantic().canvas into PTY Reset cells. Fine as designed, but PR/docs “themes never recolour remote PTY” overstates this. Worth aligning the wording with what the tests already say.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| self.diagnostics.push(ThemeDiagnostic::error( | ||
| origin, | ||
| span, | ||
| format!("unknown parent theme `{parent}`"), |
There was a problem hiding this comment.
nit: when the parent file exists but is invalid, it is absent from definitions, so the child gets unknown parent theme. Prefer distinguishing “missing” vs “installed but failed to load — fix parent first”.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
| site.origin.clone(), | ||
| Some(site.span.clone()), | ||
| format!( | ||
| "`{}` is not a closed frame and cannot use the `perimeter` \ |
There was a problem hiding this comment.
nit: when the child redefines a gradient as perimeter while inheriting a non-closed paint site, the message can say inherited from {child} (last writer of the gradient). Detection is correct; naming both halves (gradient origin vs paint site) would avoid sending the author to the wrong file.
Written by Cursor Grok 4.5 (Cursor) on behalf of the maintainer.
FindingsHigh
Medium
Low
Written by openai/gpt-5.6-luna (OpenCode) on behalf of the maintainer. |
Tiny-terminal safety: fit_popup clamps the push-key pickers and the broadcast wizard popups, with explicit guards before every set_string, so 1x1 up to 40x2 frames render instead of panicking. CLI hygiene: plain theme output escapes control characters in theme names and diagnostics, gradient keys and string values are serialised through toml_edit, and the read-only theme commands no longer create the config directory. Legacy migration copies with symlink_metadata and rejects symlinked roots and entries. Theme chrome: six components.keygen.* roles enter the catalogue (234/233 roles), the keygen form drops its hardcoded ANSI styles, and paint_border gradients popup frames without repainting their titles. The session slide only draws on a dashboard snapshot matching the current area. Gradients: positions are f64 end to end, and GradientId carries the resolve generation so an id from another resolve run is rejected rather than silently indexing a foreign table.
Petyok
left a comment
There was a problem hiding this comment.
Re-review of the fix commits (b56fe1a8, 12860200, f852a636) against both earlier reviews. Every claim below was verified by reading the current code and running the suite — not from the commit messages.
Verification performed
- Checked out the PR head (
f852a63) in a worktree;just testgreen locally (all four targets), CI green including macOS. - All findings from the 2026-07-31 review and the 2026-08-01 findings comment traced to their current code, tests located and read; the one remaining crash was reproduced by execution, not just static analysis.
What's verifiably fixed — 13 of 14 items
From the 2026-07-31 review: golden-dashboard hermeticity (agent detection moved out of the renderer entirely — render_agent_panel reads app.agent_info, detection lives in the event loop behind a 30 s throttle with an injectable detector; cleaner than the original env-var plumbing), the tab-slide PTY gap (session_tab_slide_regions protects both sides of the slide, with Running→Connecting tests), and all three file-handling asks (bounded take() reads with O_NONBLOCK and a post-open regularity re-check, non-regular files rejected uniformly, no toml_source dump for invalid records).
From the 2026-08-01 findings: control-char sanitization funnels through one sanitize_plain/render() pair with unit + subprocess tests (no unescaped sibling path found); read-only theme CLI uses the side-effect-free config_dir_path() and migration rejects symlinked roots/entries via symlink_metadata; keygen is fully role-driven (six new components.keygen.* roles, zero Color:: literals left, marker + parity + legacy-inventory coverage); selection-bar holes closed in all three screens via a shared inherit_background, with buffer tests asserting fg and bg; dashboard_snapshot is genuinely cleared in invalidate_theme_visual_state (the commit message undersold this — both mutation seams route through it) plus an area guard on session entry; resolved-TOML export goes through toml_edit with a round-trip test on odd ."\ name; gradient stop positions are f64 end to end with the exact 0.5/0.50000001 pair tested; GradientId carries a per-resolve generation minted from a non-wrapping process-global counter, checked before every lookup, with the demanded cross-theme API test; role counts reconciled at 234/233 everywhere and pinned to ROLE_SPECS by test.
Gradient coverage in general is real ring/line painting now (paint_border + paint_popup_border paired at every popup call site I checked, identity cards included), not a sampled flat colour.
Why REQUEST_CHANGES: one blocker survives
The tiny-terminal fix covers the three wizard popups but not the docked live panel in the same file — docked_rect still clamp-then-grows past a zero-height body and the panel renderers guard their input rect, not the buffer. Reproduced by execution: running broadcast + small terminal = panic in ratatui's index_of. Full trace, failing test (built on this PR's own TINY matrix and fixtures) and fix shape in the file comment on src/tui/screens/broadcast.rs.
Small items
- CHANGELOG entries sit inside the already-released
[0.11.0]instead of[Unreleased], and the credit line is missing (inline comment). - The tunnel host picker's separator is the one
SeparatorSecondarysite that still flattens gradients (inline comment). - Noted, not asked:
copy_dir_recursiveis check-then-act (symlink_metadata→fs::copy), so a same-privilege racer can still swap in a symlink between check and copy. Much narrower than the original finding and unreachable via the theme CLI; fine to leave for a follow-up.
With the docked-panel panic fixed (keep the test) and the CHANGELOG moved, this is merge-ready from my side — the fix work across both review rounds is thorough and honestly tested.
Written by Fable 5 (Claude Code) on behalf of the maintainer.
There was a problem hiding this comment.
Blocking: the docked live panel still panics on a tiny terminal — same bug class as the wizard-popup finding, one renderer over. (File-level comment because docked_rect itself is pre-existing code outside this diff.)
The fit_popup fix covers the three wizard popups, but the docked panel path doesn't go through it: docked_rect (line ~420) still does clamp-then-grow (.min(...).max(20) / .max(6) — exactly the pattern fit_popup was introduced to kill), and render_broadcast_panel / render_broadcast_zoomed guard the rect they are handed, not the buffer. At a 20x6 frame dashboard_layout_zoomed(...).body is (0, 6, 20, 0), so docked_rect returns (0, 6, 20, 6) — entirely below a 6-row buffer — and src/tui/mod.rs:603-614 renders the panel with it whenever app.broadcast is Some. ratatui 0.30's set_string panics on an out-of-range row (index_of), so: running broadcast + terminal shrunk small = the TUI dies.
Reproduced, not just traced — this test (built on your own TINY matrix and fixtures) fails on every size from 1x1 to 20x6:
#[test]
fn the_docked_panel_survives_a_tiny_terminal() {
for zoomed in [false, true] {
let mut app = themed_app(resolved_default());
app.broadcast = Some(four_states());
for (w, h) in TINY {
let area = Rect::new(0, 0, *w, *h);
let body =
crate::tui::dashboard_layout::dashboard_layout_zoomed(area, 0).body;
let buf = frame_at(area, |f| {
if zoomed {
render_broadcast_zoomed(f, body, &app);
} else {
render_broadcast_panel(f, docked_rect(body), &app, false);
}
});
assert_eq!(buf.area, area, "docked at {w}x{h} zoomed={zoomed}");
}
}
}panicked at ratatui-core-0.1.2/src/buffer/buffer.rs:251:
index outside of buffer: the area is Rect { x: 0, y: 0, width: 1, height: 1 } but index is (0, 6)
Fix shape: route docked_rect through the same clamp discipline as fit_popup (never grow past the body, and treat a zero-height body as "don't render"), and keep the test.
| optional dependencies, so there is no build step and nothing is fetched from | ||
| outside the registry. Prebuilt for Linux x64, macOS arm64 and macOS x64; every | ||
| other platform keeps using `cargo install sshub`. | ||
| - **Runtime theme system** - SSHub's colours are TOML files now, not constants. |
There was a problem hiding this comment.
These entries are inside ## [0.11.0] - 2026-07-30 — a release that already shipped (tagged, on main and crates.io) — while ## [Unreleased] above sits empty. As written, the changelog claims the theme system was part of 0.11.0. Please move the four entries up under [Unreleased] (per CONTRIBUTING §5) and add the credit to the first one: - **Runtime theme system** (PR #86 by @michabbb) - … — the credit line is required by CONTRIBUTING § Credit (you did both correctly in #88).
| let sep: String = std::iter::repeat_n('\u{2500}', inner_w).collect(); | ||
| buf.set_string(row_x, popup.y + 2, &sep, theme::dim()); | ||
| buf.set_string( | ||
| row_x, | ||
| popup.y + 2, | ||
| &sep, | ||
| Style::default().fg(crate::tui::blit::line_color( | ||
| theme, | ||
| PaintRole::SeparatorSecondary, | ||
| Rect::new(row_x, popup.y + 2, inner_w as u16, 1), | ||
| )), |
There was a problem hiding this comment.
Minor, non-blocking: this is now the only SeparatorSecondary site that samples line_color without a follow-up paint_line pass, so a gradient assigned to that role renders flat here while every sibling separator sweeps. The other pickers (session_picker.rs, push_key_pickers.rs) deliberately use StyleRole::PopupHint with a comment saying why — this one uses the gradient-capable paint role but skips the pass. Either add the paint_line call or switch to the non-gradient role with the same comment as the siblings.
|
@Petyok give me some time, i currently tokenmaxxing and running out of resets 😂 |
The docked broadcast panel could panic on a tiny terminal: `docked_rect` clamped then grew back past the body (`.max(20)` / `.max(6)`), so a zero-height body produced a rect entirely below the buffer, and both panel renderers guarded the rect they were handed rather than the buffer. Route the size through `fit_popup` so it never outgrows the body, let a zero extent stay zero, and intersect the incoming rect with the frame in `render_broadcast_panel` and `render_broadcast_zoomed`. A zero-sized `docked_rect` is a new precondition for its other consumers, so `the_docked_panel_survives_a_tiny_terminal` covers `spawn_rect` and the toast stack as well — the latter in both anchor branches, since the panel-gone branch is the one that computes `dock.y + dock.height`. The tunnel host picker was the one `SeparatorSecondary` site that sampled `line_color` without the matching `paint_line` pass, so a gradient on that role rendered flat there while every sibling separator swept. Finally, the CHANGELOG entries claimed the theme system shipped in 0.11.0, a release that is already tagged and on crates.io; all six move up under [Unreleased], with the credit line CONTRIBUTING asks for.
`copy_dir_recursive` rejected symlinks, but check-then-act: it read `symlink_metadata` and then handed the path to `fs::copy`, which resolves symlinks. Anything that replaced a regular file with a link in that window had its target copied into the new config directory — `~/.ssh/id_rsa`, for instance. Reproduced by simulating the swap: the old branch returned `Ok(())` with the key material in the destination. The file branch now opens the source once with `O_NOFOLLOW`, confirms it is regular on that handle rather than on the path again, and copies out of the handle, so a swapped link fails with `ELOOP` instead of being followed. `io::copy` does not carry permissions the way `fs::copy` does, so the destination is created with the source's mode and set to it again after the content is written — a migrated `0600` secret must not land as `0644`. The guard lives in `copy_regular_file` so a test can reach it directly: every existing test stops at the pre-check and would stay green if this second line of defence were removed. Directories are still traversed by path, which leaves the same race for a directory swapped against a symlink. Closing that needs an fd-relative `openat` traversal; it is noted in a comment and left for a follow-up.
Four findings, two of them reproduced before being acted on. The symlink guard in `copy_regular_file` covered the source and left the destination open: `create(true).truncate(true)` follows a link at the destination path, so a link planted in the staging tree had its target truncated and overwritten — the source-side bug read foreign data, this one destroyed it, and the call still returned `Ok(())`. The staging tree is freshly created and discarded on every failed attempt, so the destination must never already exist: open it with `create_new` and `O_NOFOLLOW`. Clipping the docked panel against the frame stopped the panic but fed the clipped rect back in as the layout area, so a panel sliding out of view was re-laid out instead of cropped — narrower box, re-ellipsised title, right border pinned to the terminal edge. That is every exit animation, not just tiny terminals. Layout now always uses the original rect; only when it overhangs does the panel render into a scratch buffer and get blitted onto the visible part, so a fully visible panel allocates no scratch buffer (the renderer still allocates its title, badge and text fragments as before). Both halves of the tiny-terminal fix were only held by one test that needed both to be removed before it failed, so either half could regress unnoticed. `docked_rect`'s geometry and each renderer's clipping are now pinned separately. The bounded handle read for theme files was held by nothing: the oversize tests all trip the path-based `metadata.len()` pre-check first, so removing `take(MAX + 1)` left all 30 registry tests green — while that second read is exactly what defends against a file growing after the pre-check.
Both came out of resolving the upstream merge in `save_config_at`, and `.github/workflows/ci.yml` runs clippy with `-D warnings`, so they would have failed the pipeline rather than sat there as warnings.
|
Thanks — all three items from the 2026-08-01 review are addressed, and a couple of things turned up along the way that are worth reporting honestly. The three itemsThe docked panel panic. CHANGELOG. Moved — but there were six entries, not four: The tunnel separator.
|
Two defects from @Petyok's manual review of PR Petyok#86, and the switch rework that testing the first fix made unavoidable. The remote grid kept the emulator's foreground while taking our background, so `summer` drew near-white on cream. `fill_reset_background` wrote one channel; both are now written as a pair, per channel, which also keeps reverse video correct. Two semantic slots join the core (23 -> 25): `pty_background` and `pty_foreground`, declared in `default` as references to `background` and `text`, so a theme that paints its own ground paints it under the grid too without any theme file changing. `opaque_background` is gone. It asked to fill what a theme left empty, which is nothing at all under a theme that paints everything - measured as zero changed cells under four of the five built-ins. Asking to release is the question every theme can answer, so SSHub is opaque out of the box and two independent switches hand a surface back to the terminal: `transparent_sshub_background` and `transparent_session_background`. The retired key is dropped from config.toml on the next save. Releasing works by role, not by colour. `with_ground_released` resolves the ground slots and every role inheriting from them to the terminal's own background before anything is drawn, so panel bodies painted through `semantic.surface` come free while selection bars and status colours stay - a theme may legitimately give its selection and its surface the same value, and no colour comparison over the finished frame could tell them apart. Three defects in this fix work itself, all found by review while the suite was green: the enter slide claimed the resting viewport as ownership and painted grid ground over 220 cells of dashboard; releasing SSHub's surfaces also released the grid's fallback, coupling two switches meant to be independent, with a test that accepted it because it compared against the same broken value; and a theme setting `pty_foreground = "terminal"` re-opened the original bug, which `PtyGround::of` now answers with no ground at all rather than half of one. No alpha slider: ANSI has no per-cell alpha, and only the emulator can blend, behind cells left at the default background.
|
Both confirmed and fixed. The second one turned out not to be a 1.
|
|
lgtm! |
`[Unreleased] → Added` had grown to nine bullets, seven of them PR #86: the theme system, the picker, the built-ins, gradients, the PTY ground, the CLI and the docs each claimed a top-level entry, so the section read as nine features when it is two. Every other release in this file gives one bullet per feature — the known hosts manager covers its overlay, filter, delete and refresh in a single entry. Same content, restructured: the theme system is one entry with the picker, built-ins, gradients, PTY ground and CLI nested under it, and the doc links folded into the parts they document. The `opaque_background` replacement moves to `Changed`, where a removed config key belongs.




Summary
This PR adds a complete runtime theme system to SSHub. Colours and styles are
no longer fixed renderer constants: every SSHub surface reads from a validated
TOML theme that can be inherited, previewed live, checked headlessly and
changed without rebuilding the application.
The system covers the dashboard, shared chrome, popups, forms, session views,
tunnels, SFTP, broadcast, audit, identities, settings, help, keybindings and
the startup animation. The embedded remote PTY is deliberately excluded from
theme painting, so a theme can never recolour or overwrite remote terminal
content.
Built-in theme preview
The video above previews the themes shipped in the binary. SSHub includes five
built-ins which work without any external files:
defaultpreserves SSHub's original dark appearance and acts as theinheritance root.
summeris a bright, warm daylight theme and demonstrates lightsurfaces and subtle gradients.
aquauses deep-water blues, cyan accents and a perimeter-lit focusframe.
firecombines charcoal surfaces with red, orange and gold whilekeeping semantic status colours distinct.
high-contrastuses white on pure black, saturated statuses and nodecorative gradients.
All built-ins are embedded, pass through the same parser and validator as user
themes, and can be exported as editable TOML with
sshub theme show.What users can customise
Layered TOML themes
A theme can be as small as:
Everything omitted is inherited from
default. From there, authors can workat four increasingly precise layers:
[palette].accent,background,surface,text,selection_bg,success,warning,error,connectingandunknown.[gradients].[components]when a specific cell or surface needs to differ from itssemantic fallback.
This makes broad changes cheap: changing
semantic.accentrecolours everycomponent that inherits the accent, while a single component can still be
fine-tuned independently.
Colour expressions and terminal-aware values
Theme values support:
#RRGGBBliterals;{ rgb = [r, g, b] }values;palette.*andsemantic.*references;-1.0to1.0;terminalto preserve the terminal emulator's own default colour;autoto reset an inherited component override to its catalogue fallback;nativefor tint roles such as distro logos, preserving their originalasset colours.
Style roles independently control foreground, background and the modifiers
bold,dim,italic,underlined,reversedandcrossed_out.Inheritance and reset behaviour
User themes can extend any available theme by ID. Definitions are deep-merged
first and colour references are resolved afterwards, so a child can replace a
semantic colour and automatically update component styles inherited from its
parent. Cycles, missing parents and chains deeper than 16 are diagnosed.
autoprovides a deliberate escape hatch: it removes an inherited componentcustomisation and restores that role's built-in semantic recipe. Individual
style fields and modifier lists can also be reset without duplicating the rest
of the parent definition.
Static gradients
Named gradients support multiple ordered stops and five directions:
horizontalverticaldiagonal_downdiagonal_upperimeterGradients can paint backgrounds, separators and frames. Perimeter gradients
follow the ring of a closed rectangular frame; validation rejects them on
roles that do not own a closed perimeter. Rendering is static, allocation-free
per cell, clipped to the intended surface and never applied to the remote PTY.
Runtime theme picker
The Settings overlay now contains a dedicated theme picker:
focused/unfocused states;
Escrestores the theme that was active before opening the picker;rreloads the themes directory;Enterpersistsappearance.active_themetoconfig.toml;disappearing;
runtime mode and show warnings;
User themes live in
~/.config/sshub/themes/*.toml, or beneath$SSHUB_CONFIG_DIR/themes/when that environment variable is set. The filename is the technical theme ID; built-in IDs are reserved.
Headless CLI
The PR adds a database-free, TUI-free
sshub themecommand family:sshub theme list [--format plain|json]sshub theme show <id> [--resolved] [--format toml|json]sshub theme check <file> [--format plain|json]Diagnostics collect independent problems in one run, include file, line and
column where available, and suggest close matches for misspelled keys and
sentinels. The CLI has stable exit-code behaviour for success, invalid input
and command misuse, and shell completions plus the man page include the new
commands.
Validation, compatibility and safety
The implementation introduces a versioned V1 schema with:
theme checkand compatible mode for runtime loading;a productive renderer;
defaultreproduces the legacy appearance, with eachintentional exception documented and tested.
Implementation overview
The new
src/theme/domain is split into focused stages:catalog.rsdefines semantic slots, role types, fallback recipes and thepublic component contract;
parse.rsbuilds span-aware definitions from TOML;validate.rschecks schema rules and produces actionable diagnostics;resolve.rsmerges inheritance and resolves references, transforms,opacity, gradients and sentinels;
registry.rsdiscovers built-in and user themes without allowing user filesto replace reserved IDs;
manager.rsowns the active and preview themes;gradient.rssamples resolved static gradients;builtins.rsembeds and verifies the five reference themes.The TUI renderers now consume typed
ColorRole,StyleRole,PaintRoleandTintRolevalues. Shared panel bundles keep titles, badges, borders andbackgrounds from the same role family, while buffer post-processing provides
gradient fills and transitions without leaking into protected PTY regions.
Documentation
The feature includes a dedicated, user-facing manual:
metadata, palette and semantic layers, every colour expression, inheritance,
auto, gradients, the full 234-role catalogue, picker controls, CLI exitcodes, troubleshooting and copy-pasteable example themes.
The README, changelog, man page and shell completions also document the public
workflow and commands.
Validation performed
The final reviewed branch passed:
git diff --checkcargo fmt --checkcargo clippy --all-targets -- -D warningsjust testTest results:
The final changes also completed an independent adversarial review loop with
no remaining PR-blocking findings.