Skip to content

feat: add per-terminal Ghostty theme switching - #40

Merged
srid merged 18 commits into
masterfrom
feat/theme-switching
Mar 22, 2026
Merged

feat: add per-terminal Ghostty theme switching#40
srid merged 18 commits into
masterfrom
feat/theme-switching

Conversation

@srid

@srid srid commented Mar 22, 2026

Copy link
Copy Markdown
Member

Summary

  • Fix broken text selection (selectionBackground/Foreground were both #ffffff)
  • Add Nix derivation (nix/ghostty-themes/) to parse 25 curated Ghostty themes from iTerm2-Color-Schemes repo
  • Extend oRPC contract with setTheme endpoint for server-side theme persistence per terminal
  • Each terminal maintains its own theme independently — allows light/dark terminal contrast
  • Theme switching via command palette (Cmd/Ctrl+K, type "Theme: ...")
  • Header displays active terminal's theme name
  • Themes survive page refresh (persisted in server registry)
  • Theme data delivered via KOLU_THEMES_JSON env var pointing to Nix store path

Test plan

  • nix build succeeds (theme derivation integrated into build)
  • just dev → create terminal → select text → selection colors are visible
  • Cmd/Ctrl+K → type "theme" → select "Dracula" → terminal re-renders with Dracula colors
  • Create 2nd terminal → starts with default theme (not Dracula)
  • Switch between terminals → each has its own theme, header updates accordingly
  • Refresh page → themes restored from server
  • just test-dev — e2e tests pass (including new theme switching scenarios)
  • just pc — pre-commit hooks pass

Closes #13

srid and others added 11 commits March 22, 2026 15:31
- 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.
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).
@srid srid mentioned this pull request Mar 22, 2026
5 tasks
srid added 3 commits March 22, 2026 16:23
- 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
Comment thread nix/ghostty-themes/default.nix Outdated
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"
];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not hardcode! You can look it up directly from the ghostty/ subdirectory?

Comment thread client/src/theme.ts Outdated
export const THEME = {
export const DEFAULT_THEME_NAME = "Tomorrow Night";

/** Hardcoded default theme (Tomorrow Night variant). */

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to hardcode?

Comment thread nix/modules/typescript.nix Outdated
let
nodejs = pkgs.nodejs;
pnpm = pkgs.pnpm;
ghosttyThemes = import ../ghostty-themes { inherit pkgs; };

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we callPackage ghosttyThemes and and wire it to self'.packages then we can reuse it in both the places.

srid added 3 commits March 22, 2026 16:43
- 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
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
srid merged commit 8ba76bb into master Mar 22, 2026
5 checks passed
@srid
srid deleted the feat/theme-switching branch March 22, 2026 21:27
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`)._
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.

Switch between Ghostty themes

1 participant