feat: add per-terminal Ghostty theme switching - #40
Merged
Conversation
- Fix broken text selection (selectionBackground/Foreground were both #ffffff) - Add Nix derivation to parse 25 curated Ghostty themes from iTerm2-Color-Schemes - Extend oRPC contract with setTheme endpoint for server-side theme persistence - Each terminal maintains its own theme independently (light/dark contrast) - Theme switching via command palette (Cmd/Ctrl+K → "Theme: ...") - Header displays active terminal's theme name - Themes survive page refresh (persisted in server registry) - Add e2e tests for theme switching scenarios Closes #13
Header, sidebar, command palette, and app background now derive their colors from the active terminal's theme. Light themes (e.g. Gruvbox Light) no longer show dark chrome UI around a light terminal. Adds ChromeColors helper that detects luminance and generates matching surface/border/text colors. E2e test verifies light theme adaptation.
The "select terminal 1" step uses createdTerminalIds which doesn't include the background terminal. Use sidebar position instead. Also fix command palette selection test to use inline styles.
# Conflicts: # client/src/App.tsx
ghostty-web's terminal.options.theme setter logs a warning and does nothing after open(). Bypass it by calling renderer.setTheme() and renderer.render() directly to apply theme changes at runtime.
Chrome (header, sidebar, command palette) stays dark regardless of terminal theme. Only the terminal content area changes colors. This avoids visual noise when switching between terminals with different themes.
ghostty-web's WASM terminal stores colors internally and renderer-only setTheme doesn't affect existing content. Use terminal.reset() which rebuilds the WASM terminal from options, then re-stream screen state from the server to restore content. Add e2e test that verifies the actual terminal container background color changes after theme switch (not just the header text).
5 tasks
- Remove redundant activeTheme memo; derive from activeThemeName - Add version guard to async theme effect to prevent race conditions on rapid theme switches - Use terminals.set() in setTerminalTheme for consistency with other registry mutators
- Extract terminalBaseFields shape for Zod discriminated union (DRY) - Build themesByName Map for O(1) lookup instead of linear .find() - Use Object.fromEntries for theme restoration from server - Extract pollUntil helper + hexToRgb utility in test steps
srid
commented
Mar 22, 2026
Comment on lines
+13
to
+39
| themes = [ | ||
| "Atom One Dark" | ||
| "Ayu" | ||
| "Ayu Light" | ||
| "Catppuccin Latte" | ||
| "Catppuccin Mocha" | ||
| "Dracula" | ||
| "Everforest Dark Hard" | ||
| "Flexoki Dark" | ||
| "Flexoki Light" | ||
| "GitHub Dark" | ||
| "GitHub Light Default" | ||
| "Gruvbox Dark" | ||
| "Gruvbox Light" | ||
| "Kanagawa Wave" | ||
| "Material Ocean" | ||
| "Monokai Pro" | ||
| "Nord" | ||
| "Rose Pine" | ||
| "Rose Pine Moon" | ||
| "Snazzy" | ||
| "Solarized Dark Patched" | ||
| "TokyoNight Night" | ||
| "TokyoNight Storm" | ||
| "Tomorrow Night" | ||
| "Ubuntu" | ||
| ]; |
Member
Author
There was a problem hiding this comment.
Do not hardcode! You can look it up directly from the ghostty/ subdirectory?
srid
commented
Mar 22, 2026
| export const THEME = { | ||
| export const DEFAULT_THEME_NAME = "Tomorrow Night"; | ||
|
|
||
| /** Hardcoded default theme (Tomorrow Night variant). */ |
srid
commented
Mar 22, 2026
| let | ||
| nodejs = pkgs.nodejs; | ||
| pnpm = pkgs.pnpm; | ||
| ghosttyThemes = import ../ghostty-themes { inherit pkgs; }; |
Member
Author
There was a problem hiding this comment.
If we callPackage ghosttyThemes and and wire it to self'.packages then we can reuse it in both the places.
- Replace createSignal<Record> with createStore for per-key reactivity (changing one terminal's theme no longer causes others to re-evaluate) - Replace manual localStorage get/set with @solid-primitives/storage makePersisted for font size persistence - Update pnpm deps hash for new @solid-primitives/storage dependency
- Parse ALL themes from ghostty/ dir instead of hardcoded list - Remove hardcoded DEFAULT_THEME; use "Tomorrow Night" from JSON - Use callPackage + self'.packages for ghosttyThemes (shared across devshell.nix and typescript.nix)
srid
marked this pull request as ready for review
March 22, 2026 21:02
…alette - Extract terminal session state (list, activeId, themes, commands) from App.tsx into useTerminals.ts (singleton hook pattern) - Move Cmd/Ctrl+K shortcut + open/close into CommandPalette.tsx (always mounted, owns its behavior) - Move wsStatus signal into rpc.ts (owns the WebSocket connection) - Rename registry.ts → terminals.ts (clearer name) - Add SolidJS patterns section to CLAUDE.md - Open #46 for future refactoring opportunities App.tsx is now a thin layout shell (~80 lines, down from ~190).
srid
added a commit
that referenced
this pull request
Jul 8, 2026
…1714) `@kolu/surface-map` is a **keyed map of remote surfaces** — one entry surface, typed once, served N times, keyed at runtime — and this PR is **two honest halves at once**: it builds that framework *and* ships the product surface it unlocks. A browser tab now **live-switches which machine's terminals it views** — no reload — over a warm pool of per-host padi bindings, reaching a host's cells through `padiMap.entry("zest").cells.daemonStatus.use(…)`. Supersedes the closed #1708 (clean-room redesign; the ratified Atlas note `remote-surfaces.mdx` is the sole spec). First of three serial PRs — this one, then a drishti adoption (#90), then a mechanical odu rename (#40); both companions' pins are already bumped to this HEAD and green. ## The shape `KOLU_PADI_HOST` stops being an all-or-nothing "the whole canvas becomes that box" knob and becomes a **comma-seed list** for a warm pool. `local` is always the implicit, **unremovable** default, so **env-unset is a 1-member map — pixel-identical to today's single-host canvas** (the multi-host UI is gated off and renders *nothing*). No dual code path: the map is *always* the wire shape; N=1 is the degenerate case. ## Half 1 — the framework (`@kolu/surface-map` + `@kolu/surface{,-remote}`) - `defineSurfaceMap(keySchema, entry)` + the uniform fold envelope `{ mapKey, input }` on every proc; `entries` is the one membership authority (a `Collection<Key, EntryStatus>`), status a **projection**, never a second writer. A void-input member folds to **no input key** (schema declares none), so the fold is zod-version-independent — closing the drishti-fleet incident where zod ≥4.3.7's stricter missing-key check silently broke every void fold. - Client `connectSurfaceMap`: `entry(key)` (pure lens, total, cached) + `useEntry(acc)` (Solid, owns swap disposal, re-keys on switch); existence is always a **value** (`state()`), never a nullable `entry()`. `Entry.rpc` is the per-key procedure client (folds `{mapKey}` into every call; absent-key → typed `MAP_KEY_UNKNOWN`). - `@kolu/surface`: ref-counted base subscription **dedup** (N views of one `(proc, input)` → one upstream sub). - `@kolu/surface-remote` (**renamed** from `surface-nix-host`): `serveHostMap` bridges the warm ssh pool → the map, fusing pool membership with each session's `onState`, and folds a **clock offset** (RTT-halved at admit) into `connected` so two hosts render on two clocks without comparing them. ### Typed status vocabulary (D1/D2) - **`EntryStatus<Cause>`** — the `failed` arm carries a *typed* domain-cause discriminant, generic so `@kolu/surface-map` stays volatility-neutral (`= string` default keeps every consumer compiling); padi instantiates `EntryStatus<PadiEntryFailedCause>`. - **Typed contract-skew pair (D2)** — a `contract-skew-refused` cause carries a real `{ running, expected }` version pair on the wire, never re-scanned from a human string. - The **projection contract**: `warming` = IN MOTION (coming up, or coming back on its own); `failed` = NOT PROCEEDING WITHOUT INTERVENTION (a standing refuse *or* a terminal give-up). A retriable reconnect-backoff reads `warming`; a standing refuse reads `failed` + its cause. ## Half 2 — the gated product surface (kolu app) The **always-map flip**: the server serves + the client dials the map in one atomic change (the 4 import-time module-const subs die; host-scoped readouts ride `useEntry(activeHost)` under one app-scope owner; ~30 procedure sites go `padiRpcOf(activeHost())`). Gated entirely on `hostMapGate` (from `isMultiHost()`) — the client never reads env. - **Host selector strip** — one chip per pool host: a fact-only-green connection dot, a live awaiting-you badge read off that machine, active ring, guest remove-✕ (visible-dimmed at rest), a `+ add` input, and a real DOM-measured overflow row (`⋯ +N` menu) + a `sm` dropdown switcher. `hosts.add`/`hosts.remove` root RPCs; removing the unremovable default fails **loud**. - **Per-host canvas (shape B)** — each host keeps its own `HostView` (active tile, MRU, per-tile attention, restore latches) keyed by the canonical host string; a switch **swaps** the record, so host A's tiles never bleed into B, and a returning host re-hydrates its splits + focus. A pure switch fires **no wrong-host RPC**. A tile's split survives an A→B→A round trip (a transient Corvu collapse is no longer persisted). - **Stationary daemon slot** (header) — the active host's padi + kaval identity live in ONE fixed segment right after the Kolu chip; content re-keys on `activeHost`, position/size never change, so a host-switch never reflows the strip. - **Host-down cards (Skew-UX)** — when a host's binding is down, a cause-typed card (each `EntryFailedCause` first-class, incl. **`cross-supervisor` → "Another kolu owns this host"**) with a single **`[Switch to local]`** action and **no Retry**; a `contract-skew-refused` host shows a running→expected skew badge in the stationary slot. `CanvasFacts` is a discriminated union so kaval-derived facts are reachable ONLY on the `connected` arm (`.test-d.ts`-pinned). ## Ops — never two supervisors on one padi (P0 ownership-gated drain) Only **one** kolu-server may supervise a given padi state root — two would drain-and-respawn the same daemon in a livelock. - **Local pid-gate** — a sibling `supervisor.pid` (reusing the daemon pid-gate's atomic `link(2)` claim); a second kolu on the same root **fails fast at boot** with the `KOLU_STATE_DIR`/`KOLU_PADI_STATE_DIR` remedy. A same-lineage restart (dead predecessor) reaps the stale gate and still drains. - **Remote (D3)** — the existing anti-livelock fight-detection surfaces the typed **`cross-supervisor`** cause and fail-honest-refuses on both axes (never rides a contested build). **`KOLU_REMOTE_PADI_STATE_DIR`** forwards `--state-root` to the remote padi as the primary isolation lever (two kolus reach distinct remote padis). - dev / e2e paths isolate their state so the gate never false-positives beside production. ## Review + validation trail - **Serial gauntlet** (lens-debate → codex-debate → simplify → code-police) on top of a perfection-review + six adversarial re-sweeps. Defense-in-depth earned its keep — a `process.exit(1)`-defeating unguarded guest `session.destroy()` throw was caught by code-police alone (guarded at the source: a throwing teardown leaves the server alive + other entries streaming). - **Architecture review** (23 agents / 6 lenses / 16 refuted) → one confirmed framework defect + 2 minors, all fixed + pinned: the **P4** `projectStatus` collapse of retriable `disconnected` onto terminal `failed` (now → `warming`); a bare-field-presence `EntrySession|EntryFault` (now `kind`-tagged); an adopt path that could silently no-op `markConnected` (now enters `connected` directly + loud on a connector-contract breach). - **Live user-flow retest** (isolated `nix run` kolu ⇄ a real remote box via chrome-devtools) caught **two card-masking bugs no unit test could see**: `projectStatus` masking a standing refuse as `warming` (the chip), and `resolveCanvasMode`'s loading gate stranding a `failed` host so the host-down card never rendered (the canvas). Both fixed + pinned; each reproduced against a live host first. - **D3 validated for real** — driving a genuinely contested `sincereintent` (another kolu supervising its padi on a different build), the cross-supervisor path fired, the card rendered verbatim, `[Switch to local]` worked, and setting `KOLU_REMOTE_PADI_STATE_DIR` brought the remote up `connected` with no fight. - **Known tail**: an intermittent stdio-teardown `AbortError` flake (an orphaned orpc pull rejection on transport close) is root-caused with a deterministic mechanism repro + fix hypotheses in #1719 — deferred (a real fix would be an unvalidated shared-teardown change) rather than guessed; the wrong-premise in-PR comment is corrected so no false-premise code remains. ## Green Two-platform CI green (x86_64-linux + aarch64-darwin). Whole-workspace tsc; biome `--error-on-warnings`; nix build (client + server) + the full production `kolu.drv`. ## Try it locally ```sh KOLU_PADI_HOST="<ssh-host>" nix run github:juspay/kolu/surface-map ``` _Generated by [`/be`](https://github.com/srid/agency) on Claude Code (model `claude-opus-4-8`)._
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.
Summary
#ffffff)nix/ghostty-themes/) to parse 25 curated Ghostty themes from iTerm2-Color-Schemes reposetThemeendpoint for server-side theme persistence per terminalKOLU_THEMES_JSONenv var pointing to Nix store pathTest plan
nix buildsucceeds (theme derivation integrated into build)just dev→ create terminal → select text → selection colors are visiblejust test-dev— e2e tests pass (including new theme switching scenarios)just pc— pre-commit hooks passCloses #13