Compare commits

...

35 commits
v0.4.0 ... main

Author SHA1 Message Date
738fa2e901 memory: log new claude-pane cursor-gap bug report + diagnosis plan 2026-06-11 22:50:04 +01:00
a72b2c3ff4 memory: note v0.4.1 release has wrong installer asset (0.4.0 .exe) + release.sh hardening TODO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:07:26 +01:00
8c6aded5d8 memory: customizable terminal colors session log (v0.4.1)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:53:44 +01:00
ca97fb3733 Bump version to 0.4.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:49:19 +01:00
7e624a3f96 Add customizable terminal colors (global theme + per-pane overrides)
Four editable colors (background/foreground/cursor/selection) via a new
ColorPanel modal with built-in presets and live preview. Global default
persists to localStorage and syncs across windows; per-pane overrides ride
on LeafNode.colorOverride in the workspace tree. Titlebar 🎨 button edits
the global theme; per-pane 🎨 chip overrides a single pane. Subsumes the
prior uncommitted softened-foreground tweak into lib/theme.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:41:19 +01:00
1febf2e096 memory: window-close crash fix VERIFIED on Windows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:24:30 +01:00
9144ba64b6 Fix: closing any window killed all (tokio::spawn panic on close path)
The synchronous on_window_event CloseRequested handler reached
WindowsState::schedule_save -> tokio::spawn, which panics ("no reactor
running") because that callback runs on the main thread with no ambient
Tokio runtime; the unhandled main-thread panic aborted the whole
process, taking every window + PTY down. (push_window_workspaces hit the
same line safely because it's an async tauri::command.)

- window_state.rs: tokio::spawn -> tauri::async_runtime::spawn (global
  runtime, works from any thread). Verified against tauri 2.11 source.
- lib.rs: defensive .build().run() guard — prevent_exit while any window
  remains so no path can orphan live PTYs; close logging warn!->debug!.

Source-verified; pending Windows runtime test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:09:46 +01:00
8b5f65a14a memory: session wrap-up log (cursor fix + 3 xterm features + toolbar; context bar shelved) 2026-05-29 21:03:31 +01:00
cd5500671a memory: context bar shelved (unsolvable pane↔session signal); toolbar fix kept 2026-05-28 23:48:00 +01:00
00a1e24ecf Shelve the per-pane context indicator (keep narrow-toolbar fix)
Reliable per-pane context tracking isn't achievable from transcripts: we
can't distinguish 'claude is live in this pane' from 'a shell sitting in
a directory that recently had a claude session' (claude renders inline,
not alt-screen; no WSL foreground-process access), and the 200k-vs-1M
window isn't recorded so % is unreliable. Removed the context indicator,
its OSC 7 cwd injection (pty.rs), the get_pane_context backend
(usage.rs), src/lib/usage.ts, the orchestration paneContext map, and the
App poll. The narrow-pane toolbar reflow (leaf--narrow/xnarrow tiers,
label shrink, close × pinned) is KEPT — it's verified and independent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:47:06 +01:00
15c2842ce1 Context bar: loosen recency gate to 3h (10min hid idle-but-live sessions)
The match works (cwd resolves, backend has the data), but a live claude
you're actively using can sit idle far longer than 10min, so the gate was
hiding it. Loosen to 3h — suppresses only genuinely dormant directories.
Can't distinguish 'claude live here' from 'shell in a recent claude dir'
without a WSL foreground-process probe (deferred).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:38:13 +01:00
a1d7919537 Context bar: defer OSC7 cwd update out of render phase (was dropped)
The OSC 7 handler runs synchronously inside term.write() as PTY data is
processed, which can coincide with React's render phase — calling the
parent setState there warned 'cannot update while rendering' and the
liveCwd update was dropped, so claude panes never registered their cwd
and the bar never showed (backend data + match were fine). Defer the
onCwd call via queueMicrotask. Plus a TEMP per-pane match-decision log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:19:25 +01:00
bbe827af22 TEMP: log context distros/sessions + OSC7-reported cwd for diagnosis 2026-05-28 23:14:03 +01:00
50766c3fdd memory: log OSC 7 confirmed + recency gate + absolute-token fix 2026-05-28 23:09:44 +01:00
c01a4decbf Context bar: show absolute tokens, assume 1M window (% wasn't reliable)
The transcript doesn't record the 200k-vs-1M window (model id is bare,
e.g. claude-opus-4-7; the [1m] in /context is display-only), so the
<200k→200k guess overstated the % for 1M users (a 42k session read 21%
instead of 4%). Fix: the indicator label now shows the absolute token
count (accurate regardless of window), and the fill bar assumes 1M (the
common case here; a 200k-only user would just see the bar read low while
the token number stays correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:09:15 +01:00
0358128b24 Context bar: only show for actively-written sessions (gate on recent mtime)
A bash pane sitting in a directory that once had a claude session was
lighting up with that session's stale context. Gate the indicator on the
matched session having been written within the last 10 min, so it tracks
a live claude rather than any dormant transcript in the same dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:06:30 +01:00
02d97d1520 memory: log OSC 7 live-cwd fix for the context bar (pending re-test) 2026-05-28 22:58:10 +01:00
d776f962da Context bar: match panes by live cwd via OSC 7 (was keyed on unset leaf.cwd)
The context indicator never showed because it matched on leaf.cwd, which
is almost always undefined (newLeaf sets none; the shell picker never
supplies one) — so the cwd<->transcript match never hit.

Fix: report each WSL pane's real working directory.
- pty.rs: inject PROMPT_COMMAND (forwarded via WSLENV) so the WSL shell
  emits OSC 7 (file://host/path) on every prompt. Default Ubuntu bash
  inherits an env-provided PROMPT_COMMAND; a shell that hard-assigns it,
  or a non-bash login shell, just won't report (indicator stays hidden,
  no breakage).
- XtermPane: register an OSC 7 handler, decode the path, emit onCwd.
- LeafPane: track liveCwd from onCwd and match the session on
  (liveCwd ?? leaf.cwd). OSC 7 fires at the bash prompt right before
  'claude' launches, so liveCwd is exactly claude's launch cwd; it also
  follows 'cd'.

tsc clean. Rust builds on the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:57:53 +01:00
24ab7f067f memory: narrow-toolbar verified; context bar blocked on leaf.cwd never being set 2026-05-28 22:53:45 +01:00
20b60661cb memory: backlog 'reattach window to existing window' idea
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:44:30 +01:00
5f8e9f92c5 memory: log pivot from usage panel to per-pane context-fill indicator
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:43:24 +01:00
d951c360ae Replace token-usage panel with per-pane context-fill indicator
For a subscription user, lifetime token totals + a $ estimate aren't
actionable; how full each session's context window is right now is. So:

- Removed the UsagePanel, the titlebar 💰 chip, and Ctrl+Shift+U.
- Repurposed the transcript reader (src-tauri/src/usage.rs): get_pane_context
  returns each recent session's CURRENT context occupancy = the last
  assistant turn's input + cache_read + cache_creation tokens (the prompt
  size), instead of lifetime sums. Same UNC/$HOME/cache/recency machinery.
- src/lib/usage.ts now holds context helpers (window inference 200k vs 1M by
  whether occupancy already exceeds 200k, % , green→amber→red ramp, label).
- App polls get_pane_context (15s, visibility-gated) into a cwd→context map
  exposed via orchestration; each LeafPane looks itself up by leaf.cwd and
  renders a slim fill bar + % in its header (hidden for non-claude/unmatched
  panes).

Also fixes the narrow-pane toolbar: a ResizeObserver sets leaf--narrow /
leaf--xnarrow width tiers; the label shrinks first, split buttons / status /
secondary chips drop out by tier, and the close × + context indicator stay
pinned right and visible down to the 180px min width.

tsc clean (apart from the not-yet-installed xterm addons). Rust builds on
the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:43:06 +01:00
b23f3d1ecb memory: log usage-panel scope/metric refinements + double-count investigation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:26:33 +01:00
ebbf8db407 Usage panel: scope to open panes, lead with tokens, label $ as API estimate
Addresses feedback on the usage panel:
- It was summing every recent session on the distro (all projects, mounted
  + home dirs), not the open panes' work — which read as inflated/double-
  counted. (Verified there's no literal double count: every transcript is
  read once and no two project dirs share a cwd, since claude resolves
  symlinks/mounts to the real path before mangling.) Now the panel + the
  titlebar chip default to sessions whose cwd matches an open pane, with an
  'open panes / all recent' toggle to see the full per-distro list.
- Token volume is now the headline figure; the API-cost estimate is shown
  as a clearly-labeled '~$' secondary, with a footer note that it's n/a on
  a Pro/Max subscription and can't reflect /usage quota. Kept visible (not
  hidden) so it can be validated against real API billing.

Frontend-only; backend still returns the full recent set for the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:26:15 +01:00
e3c3810ba0 memory: log per-session token tracking (done, pending Windows verify)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:16:20 +01:00
e30ac461af Commit pnpm-lock for the three new xterm addons
Pins @xterm/addon-canvas 0.7.0, addon-search 0.15.0, addon-unicode11
0.8.0 (installed on the Windows host) so the deps are reproducible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:16:03 +01:00
1df8c3181b Add per-session claude token/cost usage panel (WSL, v1)
Reads ~/.claude/projects/*.jsonl transcripts from the open WSL panes'
distros and shows per-session token counts + estimated USD cost, with a
running total in the titlebar.

Backend (src-tauri/src/usage.rs): new get_claude_usage command. For each
distro it probes $HOME once via wsl.exe, reaches the transcripts over the
\\wsl.localhost UNC share, and tallies message.usage per model per
session (summed by each line's model, since a session can switch models).
Results cached by (path,size,mtime) so polling only re-parses the file
that grew; recency-capped (30d / 50 sessions) to bound scan cost.
Windows-only; returns [] elsewhere. quiet_command made pub(crate).

Frontend: src/lib/usage.ts holds the pricing table (per-MTok rates,
matched by model-family substring) + cost/format helpers, so rates are
editable without recompiling Rust. UsagePanel.tsx mirrors the MCP panel
modal; rows whose transcript cwd matches an open pane are highlighted
with a [pane: label] tag. App polls every 20s (visible windows) for the
titlebar 💰 total and every 5s while the panel is open. Ctrl+Shift+U
opens it; added to shortcuts.ts + regenerated README.

tsc clean. Rust builds on the Windows host; needs runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:15:51 +01:00
a6d3f8a9f9 memory: mark cursor fix + 3 xterm features verified on Windows
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:57:32 +01:00
1bbc6a5783 memory: mark find-in-scrollback, unicode11, pane-nav implemented (pending Windows verify)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:52:07 +01:00
baa00dfc5c Add find-in-scrollback, unicode11, and keyboard pane navigation
Three xterm.js features, implemented together because they share the
XtermPane mount + the single attachCustomKeyEventHandler:

- Unicode 11: load @xterm/addon-unicode11, set activeVersion='11' after
  the canvas renderer so emoji/CJK/box-drawing widths stop drifting.
- Find in scrollback: @xterm/addon-search + a new per-pane SearchBar
  overlay (Ctrl+Shift+F to open, Enter/Shift+Enter next/prev, regex +
  case toggles, Esc to close & refocus). Overlay is an absolutely-
  positioned sibling in a position:relative wrapper so fit() is unaffected.
- Pane navigation: Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial neighbour via
  findNeighborInDirection) and Alt+1..9 (Nth leaf in walkLeaves order).
  XtermPane emits a NavigateIntent; App resolves the target leaf and sets
  it active, reusing the existing isActive->focusTrigger refocus chain.

All chords live in one attachCustomKeyEventHandler (xterm replaces the
handler on each call). Shortcuts added to shortcuts.ts (SoT for README +
Help), including the Alt+digit shell-conflict caveat. tsc clean apart
from the three not-yet-installed addon modules.

Needs pnpm install on the Windows host + runtime verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:51:29 +01:00
8bb080345e memory: log fan-out feature research backlog (5 prioritized + parked)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:39:23 +01:00
b5db68da8b memory: log in-app code-markup/editor-pane idea
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:32:05 +01:00
07bba99eb5 Use canvas renderer to fix stuck/ghost cursor in panes
The DOM renderer draws the cursor as a separate layered element; under
the Claude TUI's rapid cursor hide/show plus cursorBlink it leaves a
stale white block frozen where the cursor used to be. Load
@xterm/addon-canvas (composites the cursor into the text surface) with a
try/catch that falls back to the DOM renderer on init failure. Canvas
over WebGL because tiletopia runs many panes and WebView2 caps live
WebGL contexts (~16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 21:31:59 +01:00
df159056a1 memory: log v0.4.0 release wrap-up
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:44:58 +01:00
5ef35e3a74 README: add tabs + multi-window to feature highlights
The at-a-glance highlights list omitted the two headline 0.4.0 features
(tabs and multi-window pane transfer); body sections already covered them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:43:21 +01:00
23 changed files with 1750 additions and 46 deletions

View file

@ -3,6 +3,8 @@
A Windows desktop app for running and arranging many WSL terminals at once. Built primarily for managing multiple `claude` sessions across projects in parallel; works for any multi-shell workflow.
- Tiling layout — recursive splits, draggable dividers, drag-to-swap pane headers, preset layouts (single / 2-col / 3-col / 2-row / 2×2)
- Tabs — each tab is an independent tile layout (one per project); PTYs in inactive tabs keep running
- Multi-window — pop a pane into its own window (right-click its toolbar, or drag it past the window edge); the PTY survives the move and scrollback replays
- Three shell kinds per pane: WSL distros, PowerShell, saved SSH hosts (with optional Windows Credential Managerstored passwords for auto-typing at the prompt)
- Per-pane distro + cwd + label + font-size + broadcast state, persisted across restarts
- Broadcast input to a group of panes (per-pane 📡 chip, or global toggle in the titlebar)
@ -59,7 +61,10 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
| Key | Action |
|---|---|
| `Ctrl+K` | Open jump-to-pane palette |
| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction |
| `Ctrl+Shift+← / → / ↑ / ↓` | Focus neighbour pane in that direction (window-level — works even when no terminal is focused) |
| `Ctrl+Alt+← / → / ↑ / ↓` | Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it) |
| `Ctrl+Alt+H / J / K / L` | Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right) |
| `Alt+1 … Alt+9` | Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict. |
**Broadcast**
@ -80,6 +85,9 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
| Key | Action |
|---|---|
| `Ctrl+Shift+C / Ctrl+Shift+V` | Copy selection / paste in terminal |
| `Ctrl+Shift+F` | Open find-in-scrollback bar for the focused pane |
| `Enter / Shift+Enter` | Next / previous match (while search bar is focused) |
| `Escape` | Close find bar and return focus to terminal |
**Help**

120
memory.md
View file

@ -38,6 +38,7 @@ Durable memory for this project. Read at session start, update before session en
- [x] ~~**M5 — Ship infrastructure.**~~ Custom icon, version bumped to 0.1.0, `scripts/release.sh` for one-shot tag+upload, README install section. Done 2026-05-22. **Next step (user action):** run `pnpm tauri build` on Windows then `scripts/release.sh v0.1.0` from WSL to cut the actual release.
- [ ] **Native Windows shells (cmd / pwsh)?** `portable-pty` supports them for free; keep the option open. Decide whether to expose in UI at M3.
- [ ] **Persistent scrollback across app restarts.** Would need an out-of-process mux daemon. Big scope creep; explicitly deferred past v1.
- [ ] **Code markup / syntax highlighting in-app (VSCode-style).** User idea 2026-05-28 — "would be kind of neat." Two readings, different feasibility: (a) **highlight code in terminal output** — not really doable in xterm.js; it renders raw bytes/ANSI and has no concept of "this region is Python." Would need to detect code blocks and re-emit ANSI color, which is fragile and fights TUIs like claude that already color their own output. (b) **a dedicated editor/viewer pane type** alongside terminal panes — embed Monaco or CodeMirror as a new LeafNode kind, open a file from the pane's cwd, get real VSCode-grade highlighting + read/scroll (maybe edit). This is the tractable version: the layout tree already supports heterogeneous leaves, so it's "add a non-xterm pane kind" rather than reworking the renderer. Scope: pick editor lib (CodeMirror 6 is lighter than Monaco for an embed), file-open IPC over WSL paths, decide read-only vs editable. Defer — nice-to-have, not core to the multi-terminal purpose.
- [ ] **Keybinding philosophy.** Copy tmux, copy WezTerm, or invent? Decide at M3.
- [ ] **Help (?) overlay.** Small `?` icon in the titlebar, opens a modal listing all keyboard shortcuts (split / close / promote / broadcast / palette / font size / nav) and quick tips on shell-picker dropdown + SSH host manager + saved-password autotype. Same modal style as `Palette` / `HostManager`. Source of truth lives in one place — refactor the README shortcuts table to be generated from it (or vice versa) so they can't drift.
- [ ] **MCP server: Claude controls tiletopia.** Expose a Model Context Protocol server (stdio transport, runs inside the Tauri app or a sidecar) so a Claude session — running anywhere, including inside one of tiletopia's own panes — can drive the workspace. Capabilities to expose as MCP tools / resources:
@ -50,8 +51,126 @@ Durable memory for this project. Read at session start, update before session en
- Tauri integration: Rust-side MCP server using a published crate (or hand-rolled JSON-RPC); reuses the existing `PtyManager` + `hosts.json` + workspace state. Frontend gets read-only events when the MCP causes a layout change so the UI reflects it without races. Big — milestone-scale work; needs a design doc before code.
- **Status:** v1 (read-only, 2026-05-25) + v2 (write surface, 2026-05-26 across PRs 14) shipped. All 11 originally-planned write tools are live: set_label, close_pane, swap_panes, promote_pane, apply_preset, spawn_pane, connect_host, write_pane, add_host, delete_host. Open polish items live in the per-session-log "follow-ups" sections.
## Feature backlog — 2026-05-28 fan-out research
Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecosystem, codebase gap-analysis) into things to add. **Headline finding:** tiletopia already owns the hard primitives (tiling, multi-window, broadcast, MCP control surface); the real gap vs Conductor/Crystal/claude-squad/Vibe-Kanban is *git-worktree isolation + per-session status/cost/diff visibility*. Full agent deliverables are in this session's conversation; condensed here.
**→ Exploring first (user-selected 2026-05-28):**
- [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\<distro>` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`.
- **PIVOTED 2026-05-28 → per-pane context-fill indicator (replaces the panel).** User decided lifetime token totals + $ aren't worth it on a subscription; what's actionable is *current context-window occupancy* per pane (spot the one needing `/compact`). Removed `UsagePanel`, the 💰 titlebar chip, and `Ctrl+Shift+U`. Repurposed `usage.rs`: `get_pane_context` returns each recent session's **current** occupancy = the LAST assistant turn's `input + cache_read + cache_creation` tokens (verified ~274k on this 1M session). `src/lib/usage.ts` now does window inference (200k vs 1M by whether occupancy already exceeds 200k — model id doesn't encode the variant), %, color ramp. App polls 15s (visibility-gated) → `cwd→SessionContext` map via orchestration; `LeafPane` renders a slim fill bar + % in the header, matched by `leaf.cwd`. **Also fixed narrow-pane toolbar** (user report: close × clipped when slim): a `ResizeObserver` in LeafPane sets `leaf--narrow`/`leaf--xnarrow` tiers; label shrinks first, split/status/secondary chips drop by tier, close × + context indicator stay pinned-right + visible down to the 180px min. Plan: `~/.claude/plans/greedy-cooking-flask.md` (rewritten for the pivot). **Pending Windows runtime verify.** Window-size 200k/1M is inferred (approx near boundary); `~`-spawned / cd'd panes may not match their session.
- **Windows test 2026-05-28:** narrow-pane toolbar reflow (close × stays visible when shrunk, leaf--narrow/xnarrow tiers) **VERIFIED working.** BUT the context bar **does not show** — root-caused: it keys on `leaf.cwd`, which is ~always `undefined` (`newLeaf` sets no cwd; the shell picker never supplies one; only split-inheritance propagates it). So the cwd↔transcript match never hits for normal panes. Needs the pane's *live* cwd to work — leading options: capture via OSC 7 (default WSL bash under tiletopia doesn't emit it → would need injecting a PROMPT_COMMAND at spawn, shell-specific), or an "active pane shows its distro's currently-active session" heuristic gated on recent mtime. Decision pending with user.
- **Fix implemented 2026-05-28 (OSC 7 live cwd, user chose this) — PENDING re-test.** `pty.rs` Wsl arm now sets `PROMPT_COMMAND` (forwarded via `WSLENV=…:PROMPT_COMMAND/u`) to `printf '\033]7;file://%s%s\033\\' "$HOSTNAME" "$PWD"` so the shell emits OSC 7 each prompt; default Ubuntu bash inherits an env-provided PROMPT_COMMAND (a hard-assigning rc or non-bash shell won't report → bar hidden, no breakage). `XtermPane` registers `term.parser.registerOscHandler(7, …)`, decodes the path, fires new `onCwd` prop. `LeafPane` tracks `liveCwd` and matches on `(liveCwd ?? leaf.cwd)`. OSC 7 fires at the bash prompt right before `claude` launches → `liveCwd` = claude's launch cwd; also follows `cd`. **If still blank after re-test:** check the shell actually emits OSC 7 (it won't if the user's rc hard-sets PROMPT_COMMAND, or default shell isn't bash) and that backend `get_pane_context` returns sessions (UNC/$HOME probe).
- **SHELVED 2026-05-28 (user decision).** After getting OSC 7 + the queueMicrotask render-phase fix working (matching confirmed via console diagnostics), the remaining wall was unsolvable from transcripts: **can't distinguish "claude is live in this pane" from "a shell sitting in a directory that recently had a claude session."** No reliable signal — claude Code renders **inline (not the alternate-screen buffer)** so alt-screen detection fails; no WSL foreground-process access from the Windows host (wsl.exe PID ≠ linux shell PID); and any mtime recency gate can't separate an idle-but-live session (was 18min idle) from a stale neighbouring shell (52min). Also the 200k-vs-1M window isn't in the transcript (`model` is bare `claude-opus-4-7`; the `[1m]` in `/context` is display-only) so % is unreliable (showed absolute tokens instead). Removed the indicator, OSC 7 injection (pty.rs), `usage.rs`/`get_pane_context`, `src/lib/usage.ts`, orchestration `paneContext`, and the App poll. **KEPT: the narrow-pane toolbar reflow** (`leaf--narrow`/`leaf--xnarrow` width tiers via ResizeObserver, label shrinks first, close × stays pinned/visible to the 180px min) — verified working, independent of the context feature. **If ever revisited:** the only correct approach is a WSL foreground-process probe (the deferred "is claude foreground" idle-detection backlog item) to know which pane is actually running claude.
- **Windows test #2 2026-05-28 (OSC 7) + refinements:** OSC 7 injection **confirmed working** (`echo $PROMPT_COMMAND` shows our printf; a fresh pane lit up). Two issues found + fixed: (1) **bars appeared on plain bash panes** sitting in a dir that once had a claude session → added a **recency gate** (`CONTEXT_ACTIVE_MS = 10min`): only show when the matched session was written recently, so it tracks a live claude not a dormant transcript. (2) **The `[1m]` 1M-context marker is NOT in the transcript** — model id is bare (`claude-opus-4-7`), `[1m]` is display-only in `/context`. So the 200k-vs-1M window is unknowable from transcripts; the old `<200k→200k` guess overstated % for 1M users (42k read 21% vs claude's real 4%). Fix: indicator **label now shows absolute token count** (`formatTokens`, accurate regardless of window); the fill bar **assumes 1M**. A long-running claude pane spawned by the OLD binary won't have OSC 7 → no bar until respawned. **Still pending: confirm a freshly-spawned claude pane shows the right number.**
- **Superseded — original lifetime-token panel refinements (kept for history):** (1) **Scope** — panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** — every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia → /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** — user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing — user will check at work.
- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium.
- [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight.
- [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.)
- [x] ~~**Pane navigation key handler.**~~ Done + **verified on Windows 2026-05-28** — Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial via `findNeighborInDirection`) + Alt+1..9 (Nth `walkLeaves` leaf). New `NavigateIntent` union in orchestration.tsx; XtermPane emits intent via new `onNavigate` prop → LeafPane → App `navigateTo` sets active leaf (reuses isActive→focusTrigger refocus). All chords share the one `attachCustomKeyEventHandler`. **Caveats:** Alt+1..9 swallows bare Alt+digit (breaks readline digit-arg / vim buffer-jump); Ctrl+Alt+Arrow may collide with Windows virtual-desktop switching — both noted in shortcuts.ts, v2 mitigation = opt-out toggle or Ctrl+Alt+Shift+Arrow.
**Stuck/ghost cursor bug — FIXED + verified on Windows 2026-05-28.** The DOM renderer (xterm default) draws the cursor as a separate layered DOM element; under the Claude TUI's rapid cursor hide/show (`\x1b[?25l/h`) + `cursorBlink` it left a stale white block frozen at the old cursor position. Fix: load `@xterm/addon-canvas` in XtermPane after `term.open()` (composites the cursor into the text surface), wrapped in try/catch that falls back to the DOM renderer on init failure. Chose canvas over WebGL because tiletopia runs many panes and WebView2 caps live WebGL contexts at ~16. User confirmed the marker no longer sticks.
**Implementation note:** the three above were built in one fan-out workflow (parallel design on haiku/sonnet → single sonnet implementer applying to shared files), since all three touch `XtermPane`'s mount + its single `attachCustomKeyEventHandler` (xterm replaces the handler on each call, so they MUST coexist in one registration — don't add a second `attachCustomKeyEventHandler` anywhere).
**Parked — circle back (saved, not yet prioritized):**
*Tier 1 — core "many claudes" mission (highest leverage):*
- [ ] **Git worktree per session.** Spawn each claude pane into its own auto-created worktree+branch so parallel sessions on one repo can't clobber each other. The defining feature of every dedicated tool in the space (Crystal, Conductor, claude-squad, Vibe Kanban); Claude Code itself has `--worktree`. Unlocks best-of-N variants side-by-side. Fiddly part is worktree lifecycle/cleanup-on-close. Difficulty: medium.
- [ ] **Session status: working / waiting-for-input / done.** Existing idle detection conflates "blocked on a permission prompt" with "finished." Pattern-match claude's prompt strings (`Do you want to proceed?`, ``, y/n) to distinguish *needs-me* vs *done*. This is what lets one human supervise 8 agents; makes native notifications 10× more useful. Difficulty: medium.
- [ ] **Cross-session diff review.** Per-pane side tab rendering `git diff` in that session's worktree, with accept/reject. With worktrees, reviewing N branches is the bottleneck. Difficulty: medium.
- [ ] **Prompt queueing per pane.** Queue follow-up prompts that auto-send when claude returns to idle. Builds on existing idle detection + broadcast plumbing. Difficulty: easy.
- [ ] **Session templates / "spawn N".** Named launch presets (cwd, worktree scheme, initial prompt, env) + "spawn 3 copies, each a different approach." Difficulty: easy.
- [ ] **Auto-restart / resume on crash or context-limit.** Watch PTY exit codes, distinguish clean vs crash, re-spawn with `claude --resume`/`--continue` to keep long unattended runs alive. Difficulty: medium.
- [ ] **Per-session budget caps w/ auto-pause.** Token/$ ceiling per session/workspace; auto-pause or notify at ~85%, flag sessions stuck retrying. Layers on cost tracking. Difficulty: medium.
- [ ] **Kanban/task-board view over sessions.** Card = task = worktree = agent, moving queued → running → needs-review → merged (à la Vibe Kanban). MCP server makes Claude-driven task decomposition feasible. Substantial 2nd UI paradigm — defer until the Tier-1 cluster lands. Difficulty: hard.
*Tier 2 — terminal power-user:*
- [ ] **Layout restore across restarts (lighter version).** `@xterm/addon-serialize` snapshots screen+scrollback so reopening restores live-looking terminals. The 80% version of the already-deferred "persistent scrollback" (which needs an out-of-process mux daemon). Difficulty: medium.
- [ ] **Output triggers (regex → action).** iTerm2-style: watch each PTY stream for user regex, fire notify/highlight/auto-keystroke/mark. Reuses the idle-detection data tap; more precise than generic idle. Difficulty: medium.
- [ ] **Quick-select / hints mode.** Overlay short labels on URLs/paths/hashes in the visible buffer; type label to copy/open (WezTerm quick-select / Kitty hints). Difficulty: medium.
- [ ] **Activity markers / decorations.** `registerMarker()` + `registerDecoration()` to mark prompt boundaries / errors / command-finished in the gutter + jump between them. Difficulty: medium.
- [ ] **Stacked / floating panes.** Zellij-style: collapse 10+ panes into stacks (thin title bars, expand on focus), or float a scratch terminal over the grid. Scales past where pure tiling breaks (~8 panes). Difficulty: medium.
- [ ] **Capture / pipe pane output.** tmux capture-pane / pipe-pane: dump scrollback to file or tee live output to a log/command. Auto-logging each claude session → searchable transcripts. Difficulty: easy.
- [ ] **Pane fuzzy switcher.** Extend the Ctrl+K palette with a pane-target source: fuzzy-find any pane across tabs/windows by title/cwd/project/command. Difficulty: easy.
- [ ] **Saved command/prompt snippet library.** Reusable parameterized commands/prompts inserted into any pane (or broadcast) via the palette (Warp Workflows). Difficulty: easy.
- [ ] **System clipboard addon (OSC 52).** `@xterm/addon-clipboard` so a claude session inside WSL can set the host clipboard. Difficulty: easy.
- [ ] **Inline images (sixel / iTerm IIP).** `@xterm/addon-image` to render images CLIs emit (charts, previews, imgcat). Niche; needs memory tuning. Difficulty: medium.
- [ ] **Inline file/markdown/diff preview.** Click a path in output → side-panel preview (markdown render, image, diff) without leaving the app (Wave Terminal). Difficulty: hard.
*Tier 3 — platform & polish (some overlap existing backlog):*
- [ ] **System tray + minimize-to-tray.** `TrayIcon` (`@tauri-apps/api/tray`) — keep tiletopia resident, restore/jump-to-workspace from tray. Difficulty: easymedium.
- [ ] **Single-instance + window-state persistence.** `tauri-plugin-single-instance` + `tauri-plugin-window-state` — no duplicate launches, restore window geometry (the per-window-geometry gap noted elsewhere in this file). Difficulty: easy.
- [ ] **Global summon hotkey.** `tauri-plugin-global-shortcut` — system-wide hotkey to raise tiletopia from any app. Difficulty: easy.
- [ ] **Settings panel.** A home for the already-deferred configurable idle threshold + MCP port + theme toggle, all currently hardcoded. Difficulty: easymedium.
- [ ] **Small UX wins (codebase agent):** auto-save MCP policy rules (debounce like workspace save); `Ctrl+Shift+N` for new pane; 5s undo-toast on pane close (toast infra exists); narrow-window titlebar overflow menu; stronger broadcast-group visual tint; change-cwd-without-respawn (needs `/proc/<pid>/cwd` probe).
(Native OS notifications, configurable idle threshold, and persistent scrollback already appear in the top checklist — not duplicated here; the research reinforces their priority and the status-detection item above multiplies the notification payoff.)
## Session log
### 2026-06-11 — NEW user-reported cursor bug (diagnosis pending user A/B test)
**Symptom:** typing in a pane, the cursor "gets stuck" / shows a gap between typed text and the cursor block; after a few seconds of not typing the gap "vanishes" (display snaps correct). User Q&A: only noticed **inside claude** (not confirmed at plain bash); **a few seconds** to self-correct; unknown whether visual-only or a real eaten character. Distinct from the 2026-05-28 stuck/ghost cursor (that was the DOM renderer leaving a stale block; fixed via canvas addon).
**Leading hypothesis: Claude Code TUI input-render buffering, not tiletopia.** Claude's Ink TUI does render+stdin on one event loop; under load it buffers keystroke echo and flushes in a batch — cursor lags/gaps then catches up. Documented upstream: claude-code #58498 (input invisible/cursor frozen, dumps at once), #63504 (Windows host CPU pressure starves input loop), #29366, #2847. Running many parallel claudes (tiletopia's whole purpose) = exactly the CPU-contention trigger.
**Decisive test (user to run):** same distro, run `claude` in Windows Terminal, type fast mid-session — if it reproduces there, it's claude upstream, not tiletopia. Also check whether it correlates with number of busy panes.
**If tiletopia-implicated:** note `@xterm/addon-canvas` is now **deprecated upstream** (no fixes, removed in xterm v6; webgl is the recommended path — would need context-pool management given the ~16 WebGL context cap with many panes; xterm 5.5's DOM renderer is faster than when we abandoned it but would regress the 05-28 ghost-cursor fix). Renderer swap is the lever ONLY if the A/B test pins it on tiletopia.
### 2026-06-01 — Customizable terminal colors (global theme + per-pane override), v0.4.1
**Feature:** user-editable terminal colors. Scope = **global default + per-pane override** (both, per the user's choice). Editable colors = **background / foreground / cursor / selection** only (NOT the full 16-color ANSI ramp — explicitly out of scope). UI = **modal + presets**.
**New `src/lib/theme.ts`** is the model: `PaneColors` type (4 optional hex fields); `DEFAULT_PANE_COLORS` (the historical palette: bg `#0c0c0c`, fg `#c5c8c6`, cursor `#ffffff`, selection `#3a3a3a`); `COLOR_PRESETS` (Tiletopia Dark, Solarized Dark, Gruvbox Dark, Dracula, Nord, Light); `resolvePaneColors(global, override)` (override > global > default, field-by-field, always returns all 4); `toXtermTheme()` → xterm `ITheme` (maps `selection``selectionBackground` per xterm 5.5 rename, pins `cursorAccent`=background, and keeps the fixed softened `white #c5c8c6`/`brightWhite #e0e0e0` slice in `BASE_XTERM_THEME`); `loadGlobalColors`/`saveGlobalColors` (localStorage, hex-validated).
**Persistence split — NO Rust changes needed.** Global default → **localStorage** (`tiletopia.globalColors.v1`), shared per-origin across windows, live cross-window sync via the `storage` event. Per-pane → new optional **`LeafNode.colorOverride`** riding in the workspace tree; the Rust backend stores the tree as opaque `serde_json::Value` (`window_state.rs`), so any new optional leaf field round-trips for free — confirmed before coding (same reason `fontSizeOffset`/`broadcast`/`mcpAllow` persist). `colorOverride` preserved across `setLeafShell` + `reshapeToPreset`; new metadata-only `setLeafColors` mutator (clears override when passed undefined/all-undefined).
**Live apply:** `XtermPane` gained a `colors?: Required<PaneColors>` prop; mount theme = `toXtermTheme(initialColorsRef ?? DEFAULT_PANE_COLORS)`; a new effect (keyed on the 4 fields, not object identity) sets `term.options.theme` + `term.refresh()` on change — mirrors the existing fontSize effect. No fit/resize (color doesn't change cell geometry). **This subsumed a pre-existing uncommitted softened-foreground tweak** (the old literal `theme:{background,foreground}` block) into theme.ts.
**Wiring:** orchestration gained `globalColors`, `setLeafColors`, `openColorPanel(leafId?)`. New `ColorPanel.tsx`/`.css` modal (mirrors McpPanel style): **Global default / This pane** tab toggle, 4 color-picker+hex rows (per-row "↺ revert to global" in pane mode), live preview swatch, preset buttons, reset action. Titlebar **🎨** button → global mode; per-pane toolbar **🎨** chip (lights up when overridden) → that pane.
**Tests:** added `setLeafColors` describe + extended `setLeafShell` preservation test in `tree.test.ts`; new `theme.test.ts` (resolve precedence, toXtermTheme mapping, preset shape). `vitest` **cannot run in WSL**`node_modules` holds the Windows rollup native binary, not `@rollup/rollup-linux-x64-gnu`; do NOT install it from WSL (corrupts the Windows build tree). `tsc -b` passes (covers src + tests via tsconfig.app's `include:["src"]`). Run `pnpm test` on the Windows host.
**Commits:** `7e624a3` (feature), `ca97fb3` (bump 0.4.0→**0.4.1** in package.json + tauri.conf.json + Cargo.toml + Cargo.lock), `8c6aded` (this memory entry). Pushed to origin/main. Then released `v0.4.1` via `scripts/release.sh v0.4.1`.
**⚠️ UNRESOLVED — wrong installer attached to the v0.4.1 release.** The git tag `v0.4.1` and the Forgejo release entry (title v0.4.1) are correct, but the attached `.exe` is **`tiletopia_0.4.0_x64-setup.exe`**, not 0.4.1. Cause: `release.sh` picks the newest `*-setup.exe` by **mtime** (`ls -1t | head -n1`); a stale 0.4.0 build (23:44) was newest when release.sh ran (23:51); the correct 0.4.1 build landed at 23:56, after publish. `tiletopia.mcpb` asset is fine. **Fix (needs running — was auto-denied as an outward-facing release-asset edit; user to authorize/run):**
```
tea releases assets create --login rdx4 v0.4.1 src-tauri/target/release/bundle/nsis/tiletopia_0.4.1_x64-setup.exe
tea releases assets delete --login rdx4 --confirm v0.4.1 tiletopia_0.4.0_x64-setup.exe
```
**TODO — harden `scripts/release.sh`** so this can't recur: select `tiletopia_${pkg_version}_x64-setup.exe` explicitly (fail if missing) instead of newest-by-mtime; optionally bail if no installer is newer than the bump commit.
### 2026-05-30 — FIX: closing any window killed all windows (Tokio-runtime panic)
**Symptom:** after dragging a pane out (or spawning) a daughter window, closing *either* the main or a daughter window closed them all, dumping `exit code 101`.
**Root cause (confirmed via a 3-agent Workflow + reading the installed `tauri-runtime-wry-2.11.2` / `tauri-2.11.2` source):** NOT the exit logic and NOT WebView2. It was a **panic on the main thread**. The synchronous `on_window_event` `CloseRequested` handler in `lib.rs` calls `WindowsState::forget()``schedule_save()``tokio::spawn` (`window_state.rs:95`). That callback runs on the wry event-loop main thread with **no ambient Tokio runtime**, so `tokio::spawn` panics (`there is no reactor running…`); an unhandled main-thread panic aborts the whole process, taking every window + PTY down. `push_window_workspaces` hit the same `schedule_save` line but never crashed because it's an `async #[tauri::command]` that already runs inside Tauri's managed Tokio runtime — the bug only fired on the window-close path.
**Fix (`src-tauri/src/window_state.rs`):** swap `tokio::spawn`**`tauri::async_runtime::spawn`**, which schedules onto Tauri's global lazily-init'd Tokio runtime and works from *any* thread (incl. sync callbacks). Verified against `tauri-2.11.2/src/async_runtime.rs`: same `JoinHandle` shape, has `.abort()` (needed for the debounce cancel), and `tokio::time::sleep` still works inside the spawned future. Imports: `JoinHandle`+`spawn` now from `tauri::async_runtime`, `Duration` from `std::time`, `sleep` from `tokio::time`. **Rule learned: never call `tokio::spawn`/`tokio::*` runtime APIs from `on_window_event`, the `RunEvent` `.run()` closure, `Drop` impls, or any sync helper reachable from them — use `tauri::async_runtime::spawn`. Audit found this was the ONLY unsafe instance (`mcp.rs:800` and `mcp.rs:1502` are in async contexts → safe).**
**Also `src-tauri/src/lib.rs` (defensive, not the primary fix):** switched `.run(generate_context!())``.build(…).run(|app, event| …)` and on `RunEvent::ExitRequested` call `api.prevent_exit()` iff `code.is_none() && !webview_windows().is_empty()` — belt-and-suspenders so no future path can tear down the process (and orphan live PTYs) while any window remains; explicit `AppHandle::exit(Some)` is always honored. Verified-from-source semantics: wry emits `ExitRequested{code:None}` **only** when the last window is destroyed (window store empty), and `manager.on_window_close` removes the window from `webview_windows()` *before* `ExitRequested` fires, so the count is accurate and there's no zombie risk. Window close/destroy logging demoted `warn!``debug!` (run `RUST_LOG=tiletopia=debug` to trace).
**Status: VERIFIED on Windows 2026-05-30** (`pnpm tauri dev`) — closing a daughter (and the main) no longer kills the other windows; no exit-101. Fix committed in `9144ba6`. **Known minor follow-up:** a deliberately-closed window's *own* panes leak their PTYs (webview JS doesn't run XtermPane unmount cleanup on OS close), so those WSL shells linger orphaned — lower priority than persistence, not fixed.
### 2026-05-28/29 — bug fix + feature batch from the backlog (post-0.4.0)
Started from a user-reported **stuck/ghost cursor** in panes; fixed by switching xterm from the DOM renderer to `@xterm/addon-canvas` (DOM renderer leaves a stale cursor block under the Claude TUI's rapid hide/show + blink). User verified fixed on Windows.
Then a 4-agent fan-out research pass into features (logged in the "Feature backlog — 2026-05-28 fan-out research" section above), from which the user picked 5 to explore. Shipped + **verified on Windows**: **find-in-scrollback** (Ctrl+Shift+F + SearchBar overlay), **Unicode 11**, **keyboard pane navigation** (Ctrl+Alt+arrows/HJKL + Alt+1..9) — built via a Workflow (parallel design on haiku/sonnet → one sonnet implementer). Plus a **narrow-pane toolbar reflow** (close × stays visible to the 180px min via `leaf--narrow`/`xnarrow` width tiers) — verified.
**Per-session token tracking → context bar → SHELVED.** Built a WSL transcript reader + usage panel, then pivoted (user feedback) to a per-pane context-fill indicator, then **shelved it entirely** — couldn't reliably tell "claude is live in this pane" from "a shell in a dir that recently had a claude session" (no alt-screen, no WSL foreground-process access, no usable mtime cutoff; 200k/1M window not in transcripts). Full postmortem under the per-session-cost backlog item above. The narrow-toolbar fix was kept; everything else from that thread was reverted.
Backlog added: **"reattach window to existing window."** Misc cleanups: removed an accidental `dev` npm package + stale `inotify` lockfile/workspace cruft from the Windows side. **Still open in the "explore first" set: smart link providers.** All commits pushed to `main` (tip `cd55006`); deps (canvas/search/unicode11) installed + lockfile committed. No version bump / release cut this session.
### 2026-05-28 — **v0.4.0 shipped** (tabs + multi-window made actually working)
Resume session that took the 2026-05-28 tabs/multi-window feature from "authored, unverified, buggy" to a shipped release. User built the NSIS `.exe` on Windows and ran `scripts/release.sh v0.4.0` (which also attaches `tiletopia.mcpb` now — the script was updated since the earlier session log note claimed it didn't). Version bumped 0.3.0 → 0.4.0 across package.json + Cargo.toml + tauri.conf.json + Cargo.lock atomically (commit `2a1f1d4`). README highlights list got tabs + multi-window bullets (`5ef35e3`); body sections + shortcut tables were already current, hard-deny count already 14, `gen:readme --check` clean.
Commits this session: `bea6cf2` (capability + StrictMode adopt fix), `e6d0040` (accumulation + tab-close + scrollbars + drag ghost), `309b602` (XtermPane listener leak), `2a1f1d4` (version), `5ef35e3` (README). **Full technical detail for all fixes is in the "RESOLVED 2026-05-28 (resume session)" block under the original feature's session log below** — capability glob, destructive-read×StrictMode session loss, drag ghost (B1), drag-out registration wait, workspace-accumulation aggregator fix + corrupted-file reset, tab-close popover portal, global scrollbars, and the pre-release 3-agent audit (1 medium fixed, 1 high deferred).
**Known deferred follow-up (carried):** the HIGH-severity transfer-refcount/PTY leak if a detached window closes mid-adopt — low-probability, ship-now decision. Proper fix sketched in the audit notes below (label→paneId adopting registry + close-handler force-kill).
### 2026-05-28 — Tabs + multi-window pane transfer (3 phases, pushed)
Two big features the user asked for in one session. Three commits on `main`: `1a035ad` (Phase 1 tabs), `8ad5178` (Phase 2 transfer), `6faf7e5` (Phase 3 drag-out). **Rust side authored in WSL — cargo build still needs verification on Windows host before this is runnable.**
@ -149,6 +268,7 @@ Smoke test on Windows revealed bugs specific to detached (non-main) windows. Mai
- **Window position persistence across restart.** User chose "tabs persist, not windows" in the design Q&A so this is by design, but if a power user ever wants restored window geometry, the `WindowsState` map already has the structure to track it; just add inner_size/outer_position to the per-window entry.
- **Drag-out across monitors with mismatched DPI.** Tauri 2's `outerPosition()` is physical px while `clientX/Y` is CSS px. My implementation only uses clientX/Y (no async query at drag start), so multi-monitor drag works as long as the user releases far enough from the source window's edge. New window appears at the OS default position; user manually drags it to the target monitor. Acceptable v1.
- **Drag a pane INTO an existing other window.** Only NEW-window drag in v1. Adding "drag to existing window" needs cross-window pointer-event coordination (Tauri 2 doesn't expose this). Defer.
- **Reattach window to an existing window** (user request 2026-05-28). The inverse of drag-out: take a detached window's pane(s) and merge them back into another open window as new tab(s) or splits, then close the now-empty source window. Same hard problem as the pane-into-window item above — Tauri 2 doesn't expose cross-window pointer drag, so this likely needs a non-drag affordance instead: e.g. a "Send to window ▸ <pick target>" entry in the pane toolbar right-click menu (reuses the existing PTY-transfer path — `mark_pane_transferring` → target adopts via `existingPaneId`/`claim_pane` — just targeting an existing window's label instead of `create_pane_window`). Needs a live window/label registry the menu can list. Defer.
- **CLAUDE.md still says Svelte 5** (called out in 5+ session logs now). Bump it next time someone touches the file.
### 2026-05-26 — **v0.3.0 shipped to Forgejo releases**

View file

@ -1,7 +1,7 @@
{
"name": "tiletopia",
"private": true,
"version": "0.4.0",
"version": "0.4.1",
"type": "module",
"scripts": {
"dev": "vite",
@ -18,7 +18,10 @@
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-opener": "^2.0.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^5.5.0",
"react": "^18.3.0",

36
pnpm-lock.yaml generated
View file

@ -17,9 +17,18 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2.0.0
version: 2.5.4
'@xterm/addon-canvas':
specifier: ^0.7.0
version: 0.7.0(@xterm/xterm@5.5.0)
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
'@xterm/addon-search':
specifier: ^0.15.0
version: 0.15.0(@xterm/xterm@5.5.0)
'@xterm/addon-unicode11':
specifier: ^0.8.0
version: 0.8.0(@xterm/xterm@5.5.0)
'@xterm/addon-web-links':
specifier: ^0.12.0
version: 0.12.0
@ -584,11 +593,26 @@ packages:
'@vitest/utils@2.1.9':
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
'@xterm/addon-canvas@0.7.0':
resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-search@0.15.0':
resolution: {integrity: sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-unicode11@0.8.0':
resolution: {integrity: sha512-LxinXu8SC4OmVa6FhgwsVCBZbr8WoSGzBl2+vqe8WcQ6hb1r6Gj9P99qTNdPiFPh4Ceiu2pC8xukZ6+2nnh49Q==}
peerDependencies:
'@xterm/xterm': ^5.0.0
'@xterm/addon-web-links@0.12.0':
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
@ -1286,10 +1310,22 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 1.2.0
'@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-search@0.15.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-unicode11@0.8.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
'@xterm/addon-web-links@0.12.0': {}
'@xterm/xterm@5.5.0': {}

2
src-tauri/Cargo.lock generated
View file

@ -4221,7 +4221,7 @@ dependencies = [
[[package]]
name = "tiletopia"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"anyhow",
"axum",

View file

@ -1,6 +1,6 @@
[package]
name = "tiletopia"
version = "0.4.0"
version = "0.4.1"
description = "Tiling multi-terminal manager for WSL"
authors = ["megaproxy"]
edition = "2021"

View file

@ -67,12 +67,34 @@ pub fn run() {
.manage(windows_state)
.manage(pending_inits)
.on_window_event(move |window, event| {
let label = window.label().to_string();
// Window-lifecycle tracing for the multi-window close behavior.
// Silent at the default `info` level; run with
// `RUST_LOG=tiletopia=debug` to confirm the event sequence when a
// window closes (which windows the runtime still tracks, whether a
// close triggers an app-exit). Verified against tauri-runtime-wry
// 2.11: closing a non-last window emits NO ExitRequested, so other
// windows survive; only the last window's Destroyed triggers exit.
match event {
tauri::WindowEvent::CloseRequested { .. }
| tauri::WindowEvent::Destroyed => {
let open: Vec<String> = window
.app_handle()
.webview_windows()
.keys()
.cloned()
.collect();
tracing::debug!("window {event:?} label={label} open_windows={open:?}");
}
_ => {}
}
// When a non-main window closes, drop its workspaces from the
// aggregator AND any unconsumed pending-init payload so neither
// resurrect on next launch. Matches Chrome-style "closing a
// detached window discards its tabs" intent.
if let tauri::WindowEvent::CloseRequested { .. } = event {
let label = window.label().to_string();
if label != MAIN_WINDOW_LABEL {
pending_inits_for_event.by_label.lock().remove(&label);
windows_state_for_event
@ -109,6 +131,28 @@ pub fn run() {
commands::mcp_policy_save,
commands::mcp_hard_deny_labels,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
// Keep the process alive as long as ANY window is open. Every
// window (main + drag-out "daughter" windows) shares one process,
// and every PTY is owned by the single PtyManager in it. Tauri/wry
// emits `ExitRequested { code: None }` only when the LAST window is
// destroyed (tauri-runtime-wry 2.11 emits it solely when the window
// store goes empty); an explicit `AppHandle::exit(n)` carries
// `code: Some(n)`. By the time this fires, the closed window has
// already been removed from `webview_windows()`, so the check is
// accurate. We only ever reach the empty-set case here, but guard
// defensively: if any window somehow remains, refuse the exit so a
// stray close can't tear the process down and orphan live PTYs.
// An explicit exit (Some) is always honored.
if let tauri::RunEvent::ExitRequested { code, api, .. } = event {
let open: Vec<String> =
app_handle.webview_windows().keys().cloned().collect();
tracing::debug!("RunEvent::ExitRequested code={code:?} open_windows={open:?}");
if code.is_none() && !open.is_empty() {
api.prevent_exit();
}
}
});
}

View file

@ -21,13 +21,20 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use parking_lot::Mutex;
use serde_json::Value;
// `async_runtime::spawn` schedules onto Tauri's global Tokio runtime and works
// from ANY thread — including the synchronous `on_window_event` callback that
// reaches `schedule_save` via `forget()` on window close. Plain `tokio::spawn`
// panics there ("no reactor running") because that callback has no ambient
// runtime, and a main-thread panic aborts the whole process, taking every
// window + PTY with it. See the close-crash fix.
use tauri::async_runtime::{spawn, JoinHandle};
use tauri::{AppHandle, Manager};
use tokio::task::JoinHandle;
use tokio::time::{sleep, Duration};
use tokio::time::sleep;
const WORKSPACE_FILE: &str = "workspace.json";
const SAVE_DEBOUNCE: Duration = Duration::from_millis(500);
@ -92,7 +99,7 @@ impl WindowsState {
if let Some(prev) = slot.take() {
prev.abort();
}
let handle = tokio::spawn(async move {
let handle = spawn(async move {
sleep(SAVE_DEBOUNCE).await;
if let Err(e) = me.save_now(&app).await {
tracing::warn!("debounced workspace save failed: {e:#}");

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "tiletopia",
"version": "0.4.0",
"version": "0.4.1",
"identifier": "com.megaproxy.tiletopia",
"build": {
"beforeDevCommand": "pnpm dev",

View file

@ -77,6 +77,7 @@ import {
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
toggleMcpAllow as toggleMcpAllowInTree,
setLeafColors as setLeafColorsInTree,
setAllBroadcast,
adjustFontSize,
adjustAllFontSizes,
@ -98,7 +99,7 @@ import {
presetTwoRows,
presetTwoByTwo,
} from "./lib/layout/tree";
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
import { OrchestrationProvider, type Orchestration, type NavigateIntent } from "./lib/layout/orchestration";
import LeafPane from "./lib/layout/LeafPane";
import Gutter from "./lib/layout/Gutter";
import Notifications, { type Toast } from "./components/Notifications";
@ -106,6 +107,13 @@ import Palette from "./components/Palette";
import HostManager from "./components/HostManager";
import Help from "./components/Help";
import McpPanel from "./components/McpPanel";
import ColorPanel from "./components/ColorPanel";
import {
type PaneColors,
GLOBAL_COLORS_STORAGE_KEY,
loadGlobalColors,
saveGlobalColors,
} from "./lib/theme";
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
import TabStrip from "./components/TabStrip";
import "./App.css";
@ -239,6 +247,15 @@ export default function App() {
token: null,
});
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
// App-wide default terminal colours, loaded from localStorage. Per-pane
// overrides live on the LeafNode (colorOverride); this is the fallback.
const [globalColors, setGlobalColors] = useState<PaneColors>(() =>
loadGlobalColors(),
);
const [colorPanelOpen, setColorPanelOpen] = useState(false);
const [colorPanelMode, setColorPanelMode] = useState<"global" | "pane">(
"global",
);
const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
@ -647,6 +664,34 @@ export default function App() {
setTree((t) => toggleMcpAllowInTree(t, leafId));
}, []);
const setLeafColors = useCallback(
(leafId: NodeId, colors: PaneColors | undefined) => {
setTree((t) => setLeafColorsInTree(t, leafId, colors));
},
[],
);
const openColorPanel = useCallback((leafId?: NodeId) => {
if (leafId) setActiveLeafId(leafId);
setColorPanelMode(leafId ? "pane" : "global");
setColorPanelOpen(true);
}, [setActiveLeafId]);
// Persist the global theme on every change, and pick up edits made in
// OTHER windows. localStorage is shared per-origin: the `storage` event
// fires only in windows that did NOT make the write, so this can't loop.
useEffect(() => {
saveGlobalColors(globalColors);
}, [globalColors]);
useEffect(() => {
function onStorage(e: StorageEvent) {
if (e.key !== GLOBAL_COLORS_STORAGE_KEY) return;
setGlobalColors(loadGlobalColors());
}
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
// ---- MCP server lifecycle ------------------------------------------------
const refreshMcpStatus = useCallback(async () => {
try {
@ -717,6 +762,36 @@ export default function App() {
setActiveLeafId(leafId);
}, []);
// navigateTo — called from XtermPane's attachCustomKeyEventHandler via
// LeafPane's onNavigate prop. Resolves the target leaf from the current
// layout tree and sets it active; the LeafPane isActive→focusTrigger
// effect then refocuses the xterm textarea automatically.
const navigateTo = useCallback((intent: NavigateIntent) => {
const currentTree = treeRef.current;
const currentActiveId = activeLeafId;
if (intent.kind === "direction") {
const layout = flattenLayout(currentTree);
if (!currentActiveId) {
const first = layout.leaves[0]?.leaf.id;
if (first) setActiveLeafId(first);
return;
}
const nextId = findNeighborInDirection(
layout.leaves,
currentActiveId,
intent.dir,
);
if (nextId) setActiveLeafId(nextId);
} else {
// intent.kind === "index"
const leaves = Array.from(walkLeaves(currentTree));
// Clamp: Alt+9 with 3 panes picks the 3rd pane.
const target = leaves[Math.min(intent.n, leaves.length) - 1];
if (target) setActiveLeafId(target.id);
}
}, [activeLeafId]); // treeRef is a ref — stable, intentionally not listed
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
@ -822,6 +897,7 @@ export default function App() {
return;
}
// Ctrl+Shift+Alt+B — global broadcast all/none
if (ctrl && shift && alt && key === "b") {
e.preventDefault();
@ -1234,14 +1310,18 @@ export default function App() {
activeLeafId,
distros,
hosts,
globalColors,
split,
close,
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
setLeafColors,
openHostManager,
openColorPanel,
setActive,
navigateTo,
registerPaneId,
broadcastFrom,
notify,
@ -1258,14 +1338,18 @@ export default function App() {
activeLeafId,
distros,
hosts,
globalColors,
split,
close,
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
setLeafColors,
openHostManager,
openColorPanel,
setActive,
navigateTo,
registerPaneId,
broadcastFrom,
notify,
@ -2053,6 +2137,14 @@ export default function App() {
>
🤖
</button>
<button
className="palette-btn"
onClick={() => openColorPanel()}
title="Terminal colours (global theme + per-pane overrides)"
aria-label="Terminal colours"
>
🎨
</button>
<button
className="palette-btn"
onClick={() => setHelpOpen(true)}
@ -2173,6 +2265,24 @@ export default function App() {
/>
)}
{colorPanelOpen && (() => {
const activeLeaf = activeLeafId ? findLeaf(tree, activeLeafId) : null;
return (
<ColorPanel
globalColors={globalColors}
onChangeGlobal={setGlobalColors}
activeLeafId={activeLeaf ? activeLeafId : null}
activeLeafLabel={activeLeaf?.label}
activeOverride={activeLeaf?.colorOverride}
onChangeActive={(colors) => {
if (activeLeafId) setLeafColors(activeLeafId, colors);
}}
initialMode={colorPanelMode}
onClose={() => setColorPanelOpen(false)}
/>
);
})()}
{confirmQueue.length > 0 && (
<McpConfirm
spec={confirmQueue[0]}

View file

@ -0,0 +1,203 @@
.color-panel {
position: fixed;
top: 8vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 92vw);
max-height: 84vh;
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.color-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.color-title { font-weight: 600; font-size: 13px; }
.color-close {
background: transparent; border: none; color: #888;
font-size: 18px; line-height: 1; padding: 2px 8px;
cursor: pointer; border-radius: 3px;
}
.color-close:hover { background: #2a2a2a; color: #ddd; }
/* ---- Mode toggle -------------------------------------------------------- */
.color-modes {
display: flex;
gap: 0;
border-bottom: 1px solid #2a2a2a;
padding: 0 10px;
}
.color-mode {
position: relative;
font: inherit;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.04em;
background: transparent;
color: #777;
border: none;
border-bottom: 2px solid transparent;
padding: 7px 12px 5px;
cursor: pointer;
transition: color 0.1s, border-color 0.1s;
}
.color-mode:hover:not(:disabled) { color: #bbb; }
.color-mode:disabled { color: #555; cursor: default; }
.color-mode--active {
color: #cce6ff;
border-bottom-color: #4488cc;
}
/* ---- Body --------------------------------------------------------------- */
.color-body { padding: 14px 18px; overflow-y: auto; }
.color-blurb { margin: 0 0 14px; font-size: 11px; line-height: 1.5; color: #999; }
/* ---- Colour rows -------------------------------------------------------- */
.color-rows { display: flex; flex-direction: column; gap: 8px; }
.color-row {
display: flex;
align-items: center;
gap: 10px;
}
.color-row-label {
flex: 0 0 90px;
font-size: 12px;
color: #bbb;
}
.color-swatch {
flex: 0 0 auto;
width: 34px;
height: 26px;
padding: 0;
border: 1px solid #3a3a3a;
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.color-swatch::-webkit-color-swatch-wrapper { padding: 2px; }
.color-swatch::-webkit-color-swatch { border: none; border-radius: 2px; }
.color-hex {
flex: 0 0 96px;
font-family: inherit;
font-size: 12px;
background: #0e0e0e;
color: #ddd;
border: 1px solid #333;
border-radius: 4px;
padding: 5px 8px;
}
.color-hex:focus { outline: none; border-color: #4488cc; }
.color-inherit-tag {
font-size: 10px;
color: #666;
font-style: italic;
}
.color-clear-field {
background: transparent;
border: 1px solid #333;
color: #888;
border-radius: 4px;
width: 24px;
height: 24px;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.color-clear-field:hover { background: #2a2a2a; color: #ddd; }
/* ---- Live preview ------------------------------------------------------- */
.color-preview {
margin: 16px 0;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
line-height: 1.7;
overflow: hidden;
}
.color-preview-line { white-space: pre; }
.color-preview-prompt { font-weight: 600; opacity: 0.85; }
.color-preview-cursor {
display: inline-block;
width: 8px;
height: 14px;
margin-left: 2px;
vertical-align: text-bottom;
border-radius: 1px;
}
/* ---- Presets ------------------------------------------------------------ */
.color-presets { margin-top: 4px; }
.color-presets-label {
display: block;
font-size: 11px;
color: #888;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.color-presets-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.color-preset {
display: inline-flex;
align-items: center;
gap: 7px;
font: inherit;
font-size: 11px;
color: #bbb;
background: #1d1d1d;
border: 1px solid #333;
border-radius: 5px;
padding: 5px 9px 5px 5px;
cursor: pointer;
}
.color-preset:hover { border-color: #4488cc; color: #eee; }
.color-preset-swatch {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 20px;
border: 1px solid;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
/* ---- Actions ------------------------------------------------------------ */
.color-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
}
.color-reset {
font: inherit;
font-size: 11px;
color: #cbb;
background: transparent;
border: 1px solid #443;
border-radius: 5px;
padding: 6px 12px;
cursor: pointer;
}
.color-reset:hover { background: #2a2420; color: #eed; border-color: #665; }

View file

@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import type { NodeId } from "../lib/layout/tree";
import {
type PaneColors,
COLOR_PRESETS,
resolvePaneColors,
} from "../lib/theme";
import "./ColorPanel.css";
interface ColorPanelProps {
/** App-wide default theme. */
globalColors: PaneColors;
/** Persist a new global theme (pass {} to reset to built-in defaults). */
onChangeGlobal: (colors: PaneColors) => void;
/** Active pane being targeted in per-pane mode (null only global mode
* is available). */
activeLeafId: NodeId | null;
/** Human label for the active pane, shown in the mode toggle. */
activeLeafLabel?: string;
/** The active pane's current override (undefined → fully inherits global). */
activeOverride: PaneColors | undefined;
/** Persist the active pane's override (undefined → clear it). */
onChangeActive: (colors: PaneColors | undefined) => void;
/** Which target the panel opens on. */
initialMode: "global" | "pane";
onClose: () => void;
}
type Mode = "global" | "pane";
const FIELDS: { key: keyof PaneColors; label: string }[] = [
{ key: "background", label: "Background" },
{ key: "foreground", label: "Foreground" },
{ key: "cursor", label: "Cursor" },
{ key: "selection", label: "Selection" },
];
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
/** Expand #rgb #rrggbb so `<input type="color">` (which only accepts the
* 6-digit form) always gets a valid value. */
function expandHex(hex: string): string {
if (/^#[0-9a-fA-F]{3}$/.test(hex)) {
return "#" + hex.slice(1).split("").map((c) => c + c).join("");
}
return hex;
}
export default function ColorPanel({
globalColors,
onChangeGlobal,
activeLeafId,
activeLeafLabel,
activeOverride,
onChangeActive,
initialMode,
onClose,
}: ColorPanelProps) {
// Fall back to global mode if asked for per-pane with no active pane.
const [mode, setMode] = useState<Mode>(
initialMode === "pane" && activeLeafId ? "pane" : "global",
);
const paneMode = mode === "pane" && !!activeLeafId;
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
// The override layer we're editing: the leaf's override in pane mode, or
// the global theme itself in global mode. `resolved` fills every field so
// the swatches/preview always show a concrete colour.
const editLayer: PaneColors = paneMode ? (activeOverride ?? {}) : globalColors;
const resolved = paneMode
? resolvePaneColors(globalColors, activeOverride)
: resolvePaneColors(globalColors, undefined);
/** Whether a field is explicitly set on the layer we're editing (vs.
* inherited). Only meaningful in pane mode for the "inherited" hint. */
const isSet = (key: keyof PaneColors) => editLayer[key] !== undefined;
function setField(key: keyof PaneColors, value: string) {
const next: PaneColors = { ...editLayer, [key]: value };
if (paneMode) onChangeActive(next);
else onChangeGlobal(next);
}
/** Pane mode only: drop one field's override so it re-inherits the global. */
function clearField(key: keyof PaneColors) {
if (!paneMode) return;
const next: PaneColors = { ...editLayer };
delete next[key];
onChangeActive(next);
}
function applyPreset(colors: PaneColors) {
if (paneMode) onChangeActive({ ...colors });
else onChangeGlobal({ ...colors });
}
function resetAll() {
if (paneMode) onChangeActive(undefined);
else onChangeGlobal({});
}
return (
<>
<button className="backdrop" onClick={onClose} aria-label="Close" />
<div className="color-panel" role="dialog" aria-label="Terminal colours">
<header className="color-header">
<span className="color-title">Terminal colours</span>
<button className="color-close" onClick={onClose} aria-label="Close">
×
</button>
</header>
{/* Target toggle: edit the global default or just the active pane. */}
<div className="color-modes" role="tablist">
<button
className={`color-mode${mode === "global" ? " color-mode--active" : ""}`}
role="tab"
aria-selected={mode === "global"}
onClick={() => setMode("global")}
>
Global default
</button>
<button
className={`color-mode${paneMode ? " color-mode--active" : ""}`}
role="tab"
aria-selected={paneMode}
disabled={!activeLeafId}
onClick={() => setMode("pane")}
title={
activeLeafId
? "Override colours for the active pane only"
: "Select a pane first to override it"
}
>
{activeLeafId
? `This pane (${activeLeafLabel || "active"})`
: "This pane"}
</button>
</div>
<div className="color-body">
<p className="color-blurb">
{paneMode
? "These colours override the global theme for the active pane only. Unset rows inherit the global default."
: "These colours apply to every pane that doesn't have its own override. Saved across restarts and shared with new windows."}
</p>
{/* Editable colour rows */}
<div className="color-rows">
{FIELDS.map(({ key, label }) => {
const value = resolved[key]!;
const inherited = paneMode && !isSet(key);
return (
<div className="color-row" key={key}>
<span className="color-row-label">{label}</span>
<input
type="color"
className="color-swatch"
value={expandHex(value)}
onChange={(e) => setField(key, e.target.value)}
aria-label={label}
/>
<input
type="text"
className="color-hex"
value={value}
spellCheck={false}
onChange={(e) => {
const v = e.target.value.trim();
if (HEX_RE.test(v)) setField(key, v);
}}
/>
{paneMode &&
(inherited ? (
<span className="color-inherit-tag" title="Inheriting the global default">
inherited
</span>
) : (
<button
className="color-clear-field"
onClick={() => clearField(key)}
title="Revert this colour to the global default"
aria-label={`Revert ${label} to global`}
>
</button>
))}
</div>
);
})}
</div>
{/* Live preview */}
<div
className="color-preview"
style={{ background: resolved.background, color: resolved.foreground }}
aria-hidden="true"
>
<div className="color-preview-line">
<span className="color-preview-prompt">user@tiletopia</span>:~$ ls -la
</div>
<div className="color-preview-line">
<span style={{ background: resolved.selection }}>selected text</span>{" "}
normal output
<span
className="color-preview-cursor"
style={{ background: resolved.cursor }}
/>
</div>
</div>
{/* Presets */}
<div className="color-presets">
<span className="color-presets-label">Presets</span>
<div className="color-presets-row">
{COLOR_PRESETS.map((p) => (
<button
key={p.name}
className="color-preset"
onClick={() => applyPreset(p.colors)}
title={`Apply ${p.name}`}
>
<span
className="color-preset-swatch"
style={{
background: p.colors.background,
color: p.colors.foreground,
borderColor: p.colors.selection,
}}
>
Ab
</span>
{p.name}
</button>
))}
</div>
</div>
<div className="color-actions">
<button className="color-reset" onClick={resetAll}>
{paneMode ? "Reset pane to global" : "Reset to defaults"}
</button>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,105 @@
/* ---------------------------------------------------------------------------
SearchBar find-in-scrollback overlay.
Positioned absolutely inside XtermPane's container div (which must be
position: relative). Sits at the top-right of the pane, z-index 10 so it
floats above the xterm canvas but below any app-level modals (z-index 100).
Colour palette matches Palette.css / Help.css: #181818 surface, #2a2a2a
borders, #e6e6e6 text, #1a3a5c accent.
--------------------------------------------------------------------------- */
.search-bar {
position: absolute;
top: 4px;
right: 4px;
z-index: 10;
display: flex;
align-items: center;
gap: 3px;
background: #181818;
border: 1px solid #2a2a2a;
border-radius: 5px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55);
padding: 3px 4px;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
color: #e6e6e6;
}
.search-input {
font: inherit;
font-size: 12px;
color: #e6e6e6;
background: #1f1f1f;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 3px 7px;
outline: none;
width: 180px;
caret-color: #e6e6e6;
}
.search-input:focus {
border-color: #1a3a5c;
box-shadow: 0 0 0 1px #1a3a5c;
}
.search-input::placeholder {
color: #555;
}
/* Toggle buttons (Aa / .*) */
.search-toggle {
font: inherit;
font-size: 11px;
background: transparent;
border: 1px solid #2a2a2a;
border-radius: 3px;
color: #888;
padding: 2px 5px;
cursor: pointer;
line-height: 1;
transition: background 0.1s, color 0.1s;
}
.search-toggle:hover,
.search-toggle[aria-pressed="true"] {
background: #1a3a5c;
border-color: #1a5c8a;
color: #cce6ff;
}
/* Prev / Next navigation arrows */
.search-nav {
font: inherit;
font-size: 13px;
background: transparent;
border: 1px solid #2a2a2a;
border-radius: 3px;
color: #aaa;
padding: 1px 6px;
cursor: pointer;
line-height: 1;
}
.search-nav:hover {
background: #2a2a2a;
color: #e6e6e6;
}
/* Close button */
.search-close {
background: transparent;
border: none;
color: #666;
font-size: 16px;
line-height: 1;
padding: 1px 5px;
cursor: pointer;
border-radius: 3px;
}
.search-close:hover {
background: #2a2a2a;
color: #ddd;
}

View file

@ -0,0 +1,177 @@
import { useRef, useEffect, useState } from "react";
import type { SearchAddon } from "@xterm/addon-search";
import "./SearchBar.css";
// ---------------------------------------------------------------------------
// SearchBar — per-pane find-in-scrollback overlay.
//
// Rendered as an absolutely-positioned sibling of the xterm canvas inside
// XtermPane's container div (position: relative). The SearchAddon instance
// is owned by XtermPane and passed down as a prop; no IPC or Context needed.
//
// Toggle state (caseSensitive, regex) uses useState so aria-pressed reflects
// the live value on every render — refs alone don't trigger re-renders.
// ---------------------------------------------------------------------------
interface SearchBarProps {
searchAddon: SearchAddon;
onClose: () => void;
}
export default function SearchBar({ searchAddon, onClose }: SearchBarProps) {
const inputRef = useRef<HTMLInputElement>(null);
const queryRef = useRef("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [useRegex, setUseRegex] = useState(false);
// Keep stable refs to toggle values so findNext/findPrev closures always
// see the current value without needing to be recreated on each state change.
const caseSensitiveRef = useRef(caseSensitive);
const useRegexRef = useRef(useRegex);
useEffect(() => { caseSensitiveRef.current = caseSensitive; }, [caseSensitive]);
useEffect(() => { useRegexRef.current = useRegex; }, [useRegex]);
// Autofocus the input when the bar mounts.
useEffect(() => {
queueMicrotask(() => inputRef.current?.focus());
}, []);
function getOptions() {
return {
caseSensitive: caseSensitiveRef.current,
regex: useRegexRef.current,
// Highlight all matches and mark the active one distinctly.
decorations: {
matchBackground: "#3a3a00",
matchBorder: "#888800",
matchOverviewRuler: "#888800",
activeMatchBackground: "#b5890080",
activeMatchBorder: "#e6c000",
activeMatchColorOverviewRuler: "#e6c000",
},
};
}
function findNext() {
if (!queryRef.current) return;
searchAddon.findNext(queryRef.current, getOptions());
}
function findPrev() {
if (!queryRef.current) return;
searchAddon.findPrevious(queryRef.current, getOptions());
}
function handleInput(e: React.ChangeEvent<HTMLInputElement>) {
queryRef.current = e.target.value;
// Live-search: jump to next match as you type.
if (queryRef.current) {
searchAddon.findNext(queryRef.current, getOptions());
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
findPrev();
} else {
findNext();
}
}
}
function toggleCase() {
setCaseSensitive((v) => {
const next = !v;
caseSensitiveRef.current = next;
// Re-run with the new option so decorations update immediately.
if (queryRef.current) {
searchAddon.findNext(queryRef.current, {
...getOptions(),
caseSensitive: next,
});
}
return next;
});
}
function toggleRegex() {
setUseRegex((v) => {
const next = !v;
useRegexRef.current = next;
if (queryRef.current) {
searchAddon.findNext(queryRef.current, {
...getOptions(),
regex: next,
});
}
return next;
});
}
return (
<div className="search-bar" role="search" aria-label="Find in terminal">
<input
ref={inputRef}
type="text"
className="search-input"
placeholder="Find…"
onChange={handleInput}
onKeyDown={handleKeyDown}
aria-label="Search term"
spellCheck={false}
/>
<button
className="search-toggle"
title="Case-sensitive"
aria-label="Toggle case-sensitive"
aria-pressed={caseSensitive ? "true" : "false"}
onClick={toggleCase}
>
Aa
</button>
<button
className="search-toggle"
title="Regular expression"
aria-label="Toggle regular expression"
aria-pressed={useRegex ? "true" : "false"}
onClick={toggleRegex}
>
.*
</button>
<button
className="search-nav"
title="Previous match (Shift+Enter)"
aria-label="Previous match"
onClick={findPrev}
>
&#8593;
</button>
<button
className="search-nav"
title="Next match (Enter)"
aria-label="Next match"
onClick={findNext}
>
&#8595;
</button>
<button
className="search-close"
title="Close (Escape)"
aria-label="Close search"
onClick={onClose}
>
×
</button>
</div>
);
}

View file

@ -1,7 +1,11 @@
import { useRef, useEffect } from "react";
import { useRef, useEffect, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { CanvasAddon } from "@xterm/addon-canvas";
import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import SearchBar from "./SearchBar";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
readText as clipboardReadText,
@ -20,6 +24,12 @@ import {
type PaneId,
type SpawnSpec,
} from "../ipc";
import type { NavigateIntent } from "../lib/layout/orchestration";
import {
type PaneColors,
DEFAULT_PANE_COLORS,
toXtermTheme,
} from "../lib/theme";
// ---------------------------------------------------------------------------
// base64 helpers (private to this module)
@ -71,6 +81,15 @@ interface XtermPaneProps {
focusTrigger?: number;
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
fontSize?: number;
/** Fully-resolved terminal colours (global theme merged with any per-pane
* override). Changes are applied live to the running terminal. */
colors?: Required<PaneColors>;
/** Called when the user presses a tiling-WM navigation chord inside the
* terminal. XtermPane only emits the intent; the parent (LeafPane/App)
* resolves the target leaf from the current layout and sets it active.
* Defined as an optional callback so single-pane windows don't require
* wiring it up. */
onNavigate?: (intent: NavigateIntent) => void;
}
const DEFAULT_XTERM_FONT_SIZE = 13;
@ -89,15 +108,22 @@ export default function XtermPane({
onFocus,
focusTrigger = 0,
fontSize,
colors,
onNavigate,
}: XtermPaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const paneIdRef = useRef<PaneId | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
// Stash the most recent `fontSize` prop so the mount effect can pick
// up the initial value without re-running when it changes (the secondary
// effect below handles dynamic updates).
const initialFontSizeRef = useRef(fontSize);
// Same trick for the initial theme — the mount effect reads this once; the
// secondary effect below applies later changes live.
const initialColorsRef = useRef(colors);
// Stable refs for callbacks so the mount effect doesn't need to re-run when
// parents pass new inline functions, while still always calling the latest version.
@ -106,12 +132,18 @@ export default function XtermPane({
const onInputRef = useRef(onInput);
const onDataReceivedRef = useRef(onDataReceived);
const onFocusRef = useRef(onFocus);
const onNavigateRef = useRef(onNavigate);
// Stable ref for setSearchOpen so it can be called from inside the
// attachCustomKeyEventHandler closure without the closure going stale.
const setSearchOpenRef = useRef<(v: boolean) => void>(setSearchOpen);
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
useEffect(() => { onNavigateRef.current = onNavigate; }, [onNavigate]);
useEffect(() => { setSearchOpenRef.current = setSearchOpen; }, [setSearchOpen]);
// -------------------------------------------------------------------------
// Mount / unmount: create terminal, spawn PTY, wire listeners
@ -124,10 +156,12 @@ export default function XtermPane({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
cursorBlink: true,
theme: {
background: "#0c0c0c",
foreground: "#e6e6e6",
},
// Theme is resolved by the parent (global default merged with any
// per-pane override) and applied live by the effect below. The fixed
// slice — softened white/brightWhite that tame the Claude TUI's
// emphasis slots so nothing hits glaring pure white — lives in
// toXtermTheme / BASE_XTERM_THEME (see lib/theme.ts).
theme: toXtermTheme(initialColorsRef.current ?? DEFAULT_PANE_COLORS),
scrollback: 5000,
convertEol: false,
allowProposedApi: true,
@ -149,6 +183,41 @@ export default function XtermPane({
);
term.open(container);
// Use the canvas renderer instead of xterm's default DOM renderer.
// The DOM renderer draws the cursor as a separate layered element and,
// under the Claude TUI's rapid hide/show (\x1b[?25l/h) + cursorBlink,
// leaves a stale cursor block frozen where the cursor used to be (the
// "stuck white marker"). The canvas renderer composites the cursor into
// the same surface as the text, so hide/show transitions clear cleanly.
// Chosen over the WebGL addon because tiletopia runs many panes at once
// and Chromium/WebView2 caps live WebGL contexts (~16) — canvas has no
// such hard limit. Loaded after open() so the core renderer exists.
try {
term.loadAddon(new CanvasAddon());
} catch (e) {
// If canvas init fails for any reason, xterm falls back to the DOM
// renderer on its own — degrade gracefully rather than blank the pane.
console.warn("CanvasAddon load failed; using DOM renderer:", e);
}
// Load Unicode 11 addon for correct width handling of emoji, CJK, and
// box-drawing characters. This prevents cursor drift in TUIs that rely on
// Unicode 11 character widths. Loaded after CanvasAddon so the renderer
// surface is set before width calculations begin.
try {
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = "11";
} catch (e) {
console.warn("Unicode11Addon load failed:", e);
}
// Load the search addon so find-in-scrollback works. Must be loaded
// after open() so the terminal viewport exists for decoration rendering,
// and after CanvasAddon since it decorates the same canvas surface.
const search = new SearchAddon();
searchAddonRef.current = search;
term.loadAddon(search);
// Initial size — fit before asking the PTY for its dimensions.
fit.fit();
@ -261,18 +330,44 @@ export default function XtermPane({
onInputRef.current?.(b64);
});
// Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste from clipboard.
// Runs before xterm consumes the key, so the textarea never sees a raw
// Ctrl+V (which would otherwise inject ^V into the PTY). term.paste()
// routes through onData → writeToPane, so broadcasting and bracketed
// paste both keep working for free.
// Intercept tiling-WM chords before the PTY sees them. All families
// share ONE attachCustomKeyEventHandler call — xterm.js replaces the
// previous handler on every call, so a second call anywhere would
// silently discard all earlier interceptions.
//
// Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so
// WebView2 doesn't surface its native "Allow clipboard access?" prompt.
// Family 1: Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste.
// Uses tauri-plugin-clipboard-manager so WebView2 never shows its
// native "Allow clipboard access?" prompt. term.paste() routes
// through onData → writeToPane so broadcasting + bracketed paste
// keep working for free.
//
// Family 2: Ctrl+Shift+F — open/focus the find-in-scrollback bar.
// Swallowed before xterm or the PTY sees the raw keypress. Uses the
// stable setSearchOpenRef so the closure never goes stale.
//
// Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L — spatial pane focus.
// XtermPane emits onNavigate({ kind: "direction", dir }) and returns
// false so the chord is swallowed before it reaches the PTY. The
// parent (LeafPane → App) resolves the neighbour and bumps
// focusTrigger on the new active pane.
//
// Family 4: Alt+1..9 — index-based pane focus.
// Emits onNavigate({ kind: "index", n }) and swallows. Note: bare
// Alt+digit is used by some shells (readline digit-argument, vim/nvim)
// — this interception is an accepted v1 trade-off (see shortcuts.ts).
term?.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
if (!e.ctrlKey || !e.shiftKey || e.altKey) return true;
// --- Family 1 & 2: Ctrl+Shift+* (no Alt) ---------------------------
if (e.ctrlKey && e.shiftKey && !e.altKey) {
if (e.code === "KeyF") {
// Ctrl+Shift+F — open find-in-scrollback bar.
e.preventDefault();
setSearchOpenRef.current(true);
return false;
}
if (e.code === "KeyC") {
// Ctrl+Shift+C — copy selection to clipboard.
const sel = term?.getSelection();
if (sel) {
void clipboardWriteText(sel).catch((err) =>
@ -283,6 +378,8 @@ export default function XtermPane({
return false;
}
if (e.code === "KeyV") {
// Ctrl+Shift+V — paste from clipboard via term.paste() so
// broadcasting and bracketed paste work for free.
e.preventDefault();
clipboardReadText()
.then((text) => {
@ -291,6 +388,42 @@ export default function XtermPane({
.catch((err) => console.warn("clipboard read failed:", err));
return false;
}
}
// --- Family 3: Ctrl+Alt+Arrow / Ctrl+Alt+H/J/K/L (spatial nav) -----
if (e.ctrlKey && e.altKey && !e.shiftKey && onNavigateRef.current) {
// Arrow keys
const ARROW_DIR: Record<string, "left" | "right" | "up" | "down"> = {
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
};
// Vim-style HJKL
const VIM_DIR: Record<string, "left" | "right" | "up" | "down"> = {
KeyH: "left",
KeyJ: "down",
KeyK: "up",
KeyL: "right",
};
const dir = ARROW_DIR[e.code] ?? VIM_DIR[e.code];
if (dir) {
e.preventDefault();
onNavigateRef.current({ kind: "direction", dir });
return false;
}
}
// --- Family 4: Alt+1..9 (index-based pane focus) -------------------
if (e.altKey && !e.ctrlKey && !e.shiftKey && onNavigateRef.current) {
const digit = e.code.match(/^Digit([1-9])$/);
if (digit) {
e.preventDefault();
onNavigateRef.current({ kind: "index", n: parseInt(digit[1], 10) });
return false;
}
}
return true;
});
@ -371,6 +504,7 @@ export default function XtermPane({
term = null;
termRef.current = null;
fitRef.current = null;
searchAddonRef.current = null;
paneIdRef.current = null;
};
// spec is read once at mount; intentionally omitted from deps so we
@ -417,5 +551,51 @@ export default function XtermPane({
}
}, [fontSize]);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
// -------------------------------------------------------------------------
// Live colour-theme changes (global theme edit, per-pane override, preset).
//
// Setting term.options.theme re-tints the renderer immediately; a refresh
// forces the canvas surface to repaint already-drawn cells with the new
// palette (xterm only re-tints on the next write otherwise). Cell geometry
// is unaffected, so no fit()/resize is needed — unlike the font-size path.
// -------------------------------------------------------------------------
useEffect(() => {
const term = termRef.current;
if (!term || !colors) return;
try {
term.options.theme = toXtermTheme(colors);
term.refresh(0, term.rows - 1);
} catch (e) {
console.warn("theme apply failed", e);
}
// Depend on the individual fields rather than the object identity so a
// parent that rebuilds an equal colours object each render doesn't churn.
}, [colors?.background, colors?.foreground, colors?.cursor, colors?.selection]);
// Close the search bar and return focus to the xterm textarea so the user
// can resume typing immediately. Queries the well-known xterm helper
// textarea selector — the same pattern used in the focusTrigger effect.
function closeSearch() {
setSearchOpen(false);
const ta = containerRef.current?.querySelector<HTMLTextAreaElement>(
".xterm-helper-textarea",
);
ta?.focus();
}
// The outer wrapper is position:relative so the absolutely-positioned
// SearchBar anchors inside the pane without escaping to a positioned
// ancestor further up the tree. The FitAddon measures containerRef's div
// (the inner one), which still fills 100% of the wrapper — no sizing break.
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{searchOpen && searchAddonRef.current && (
<SearchBar
searchAddon={searchAddonRef.current}
onClose={closeSearch}
/>
)}
</div>
);
}

View file

@ -84,6 +84,10 @@
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
/* Give up width first when the pane is narrow, so the chips, context
indicator, and close button stay visible (overrides .pane-toolbar > *). */
flex-shrink: 1;
min-width: 0;
}
.pane-label:hover {
background: #222;
@ -242,6 +246,9 @@
.pane-status.idle { color: #d96060; }
.pane-actions {
/* Final fallback right-anchor (non-claude pane has no .pane-ctx, and at
narrow tiers .pane-status is hidden) so the close button stays pinned right. */
margin-left: auto;
display: flex;
gap: 2px;
}
@ -264,6 +271,20 @@
background: #5a1a1a;
color: #fcc;
}
/* ---- narrow-pane reflow -------------------------------------------------
The close button stays visible at every width; lower-priority toolbar items
drop out by tier so a 180px pane keeps its close ×. */
.leaf--narrow .pane-status,
.leaf--narrow .pane-actions .pane-btn:not(.close) {
display: none;
}
.leaf--xnarrow .pane-status,
.leaf--xnarrow .pane-actions .pane-btn:not(.close),
.leaf--xnarrow .distro-wrap,
.leaf--xnarrow .bcast-chip {
display: none;
}
.xterm-wrap {
flex: 1 1 auto;
min-height: 0;

View file

@ -9,6 +9,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
import { resolvePaneColors } from "../theme";
import { useOrchestration } from "./orchestration";
import XtermPane from "../../components/XtermPane";
import type { SpawnSpec } from "../../ipc";
@ -42,6 +43,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState("");
const labelInputRef = useRef<HTMLInputElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
const startEditLabel = useCallback(
(e: MouseEvent) => {
@ -156,6 +158,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
return () => orch.reportLeafIdle(leaf.id, false);
}, [leaf.id, orch.reportLeafIdle]);
// ---- width tier ---------------------------------------------------------
// Drives which toolbar items collapse on a narrow pane (CSS does the hiding).
// The close button + context indicator stay visible at every tier; min pane
// width is 180px (MIN_PANE_PX), so "xnarrow" must keep those reachable.
const [widthTier, setWidthTier] = useState<"" | "narrow" | "xnarrow">("");
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const w = el.clientWidth;
setWidthTier(w < 230 ? "xnarrow" : w < 320 ? "narrow" : "");
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// ---- broadcast ---------------------------------------------------------
const onTerminalInput = useCallback(
(b64: string) => {
@ -194,6 +212,14 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
[orch.setActive, leaf.id],
);
// Delegate keyboard navigation intents from XtermPane up to App via
// orch.navigateTo. XtermPane stays dumb (emits intent only); App resolves
// the target leaf from the current layout and bumps focusTrigger.
const onPaneNavigate = useCallback(
(intent: Parameters<typeof orch.navigateTo>[0]) => orch.navigateTo(intent),
[orch.navigateTo],
);
const onStatus = useCallback((msg: string, ok: boolean) => {
setStatus(msg);
setStatusOk(ok);
@ -380,7 +406,8 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
return (
<div
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
ref={rootRef}
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isIdle ? " idle" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}${widthTier ? ` leaf--${widthTier}` : ""}`}
role="group"
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
data-leaf-id={leaf.id}
@ -521,6 +548,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
🤖
</button>
<button
className={`bcast-chip color-chip${leaf.colorOverride ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.openColorPanel(leaf.id);
}}
title={
leaf.colorOverride
? "This pane has custom colours — click to edit"
: "Set custom colours for this pane"
}
aria-pressed={leaf.colorOverride ? "true" : "false"}
>
🎨
</button>
{isIdle && statusOk ? (
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
idle
@ -575,8 +618,10 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
onInput={onTerminalInput}
onDataReceived={onDataReceived}
onFocus={onXtermFocus}
onNavigate={onPaneNavigate}
focusTrigger={focusTrigger}
fontSize={resolveFontSize(leaf.fontSizeOffset)}
colors={resolvePaneColors(orch.globalColors, leaf.colorOverride)}
/>
) : (
<div className="leaf-missing-host">

View file

@ -1,6 +1,7 @@
import { createContext, useContext, type ReactNode } from "react";
import type { Orientation, NodeId, LeafShellSpec } from "./tree";
import type { Orientation, NodeId, LeafShellSpec, Direction } from "./tree";
import type { PaneId, SshHost } from "../../ipc";
import type { PaneColors } from "../theme";
/**
* Orchestration context every piece of shared state and every operation
@ -21,6 +22,10 @@ export interface Orchestration {
/** Saved SSH hosts loaded from `hosts.json`. Reactive changes when the
* user edits hosts via {@link openHostManager}. */
hosts: SshHost[];
/** App-wide default terminal colours. Reactive edited via the colour
* panel. Each leaf resolves its effective theme from this plus its own
* {@link LeafNode.colorOverride}. */
globalColors: PaneColors;
// Tree mutations
split: (leafId: NodeId, orientation: Orientation) => void;
@ -34,9 +39,15 @@ export interface Orchestration {
/** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane
* toolbar drives this. */
toggleMcpAllow: (leafId: NodeId) => void;
/** Set or clear a leaf's per-pane colour override (undefined fall back
* to the global theme). */
setLeafColors: (leafId: NodeId, colors: PaneColors | undefined) => void;
// SSH host management
openHostManager: () => void;
/** Open the colour panel. When `leafId` is given the panel starts in
* per-pane mode targeting that leaf; otherwise it edits the global theme. */
openColorPanel: (leafId?: NodeId) => void;
// Per-pane orchestration
setActive: (leafId: NodeId) => void;
@ -62,6 +73,16 @@ export interface Orchestration {
* The PTY stays alive across the move (the new window's XtermPane
* adopts the existing PaneId; scrollback ring is replayed). */
moveToNewWindow: (leafId: NodeId) => void;
/**
* Navigate focus from within a pane's key-handler. XtermPane emits the
* intent; LeafPane/App resolve the target leaf and set it active.
*
* `{ kind: "direction", dir }` move to the spatial neighbour in that
* direction using the same flattenLayout geometry as Ctrl+Shift+Arrow.
* `{ kind: "index", n }` focus the Nth leaf in DFS (walkLeaves) order,
* 1-indexed, clamped to the leaf count (so Alt+9 with 3 panes picks pane 3).
*/
navigateTo: (intent: NavigateIntent) => void;
/** Returns a PaneId only for leaves that just arrived via a window
* transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip
* the spawn). One-shot App clears the entry once the pane has
@ -69,6 +90,13 @@ export interface Orchestration {
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
}
/** Discriminated intent emitted by XtermPane's key handler. App resolves
* the actual target leaf from the current tree without XtermPane needing
* to know anything about layout geometry or leaf ordering. */
export type NavigateIntent =
| { kind: "direction"; dir: Direction }
| { kind: "index"; n: number };
const OrchestrationContext = createContext<Orchestration | null>(null);
export function OrchestrationProvider({

View file

@ -13,6 +13,7 @@ import {
changeLabel,
toggleBroadcast,
toggleMcpAllow,
setLeafColors,
adjustFontSize,
adjustAllFontSizes,
resolveFontSize,
@ -302,12 +303,13 @@ describe("setLeafShell", () => {
expect(next.id).not.toBe(leaf.id);
});
it("preserves label / broadcast / fontSizeOffset across the shell change", () => {
it("preserves label / broadcast / fontSizeOffset / colorOverride across the shell change", () => {
const leaf = newLeaf({
distro: "Ubuntu",
label: "my pane",
broadcast: true,
fontSizeOffset: 2,
colorOverride: { background: "#101010" },
});
const next = setLeafShell(leaf, leaf.id, {
shellKind: "powershell",
@ -315,6 +317,7 @@ describe("setLeafShell", () => {
expect(next.label).toBe("my pane");
expect(next.broadcast).toBe(true);
expect(next.fontSizeOffset).toBe(2);
expect(next.colorOverride).toEqual({ background: "#101010" });
});
});
@ -389,6 +392,58 @@ describe("toggleMcpAllow", () => {
});
});
describe("setLeafColors", () => {
it("sets an override on a leaf with none", () => {
const leaf = newLeaf();
expect(leaf.colorOverride).toBeUndefined();
const next = setLeafColors(leaf, leaf.id, {
background: "#001122",
foreground: "#ddeeff",
}) as LeafNode;
expect(next.colorOverride).toEqual({
background: "#001122",
foreground: "#ddeeff",
});
});
it("replaces an existing override wholesale", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, { cursor: "#ff0000" }) as LeafNode;
expect(next.colorOverride).toEqual({ cursor: "#ff0000" });
});
it("clears the override when passed undefined", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, undefined) as LeafNode;
expect(next.colorOverride).toBeUndefined();
expect("colorOverride" in next).toBe(false);
});
it("clears the override when passed an all-undefined object", () => {
const leaf = newLeaf({ colorOverride: { background: "#000000" } });
const next = setLeafColors(leaf, leaf.id, {
background: undefined,
foreground: undefined,
cursor: undefined,
selection: undefined,
}) as LeafNode;
expect(next.colorOverride).toBeUndefined();
expect("colorOverride" in next).toBe(false);
});
it("returns the same reference when clearing an already-unset override", () => {
const leaf = newLeaf();
const next = setLeafColors(leaf, leaf.id, undefined);
expect(next).toBe(leaf);
});
it("MUST NOT swap the leaf id (metadata-only, no PTY respawn)", () => {
const leaf = newLeaf();
const next = setLeafColors(leaf, leaf.id, { background: "#123456" }) as LeafNode;
expect(next.id).toBe(leaf.id);
});
});
describe("resolveFontSize", () => {
it("returns the default when offset is undefined or 0", () => {
expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE);

View file

@ -5,6 +5,8 @@
//! tmux / i3 / Zellij use — dragging a gutter mutates one parent ratio,
//! both sibling subtrees reflow automatically.
import type { PaneColors } from "../theme";
export type NodeId = string;
/** 'h' = side-by-side (a on left, b on right). 'v' = stacked (a on top, b below). */
@ -44,6 +46,13 @@ export interface LeafNode {
* later doesn't require migrating saved workspaces.
*/
fontSizeOffset?: number;
/**
* Per-pane colour override. Any field set here wins over the app-wide
* global theme (see {@link resolvePaneColors}); unset fields fall through.
* Undefined / empty means "use the global theme". Metadata-only changing
* it never respawns the PTY.
*/
colorOverride?: PaneColors;
/**
* If true, this pane is visible to the MCP server (Claude can list it,
* read its scrollback, etc.). Default-DENY: when undefined or false, the
@ -111,6 +120,7 @@ export function setLeafShell(
label: node.label,
broadcast: node.broadcast,
fontSizeOffset: node.fontSizeOffset,
colorOverride: node.colorOverride,
};
if (spec.shellKind === "wsl") {
if (spec.distro !== undefined) base.distro = spec.distro;
@ -294,6 +304,32 @@ export function toggleMcpAllow(root: TreeNode, leafId: NodeId): TreeNode {
});
}
/** Set (or clear) a leaf's per-pane colour override. Pass `undefined` or an
* empty object to drop the override so the pane falls back to the global
* theme. Metadata-only does NOT swap the id, so the PTY keeps running. */
export function setLeafColors(
root: TreeNode,
leafId: NodeId,
colors: PaneColors | undefined,
): TreeNode {
return replaceById(root, leafId, (node) => {
if (node.kind !== "leaf") return node;
const empty =
!colors ||
(colors.background === undefined &&
colors.foreground === undefined &&
colors.cursor === undefined &&
colors.selection === undefined);
if (empty) {
if (node.colorOverride === undefined) return node;
const next: LeafNode = { ...node };
delete next.colorOverride;
return next;
}
return { ...node, colorOverride: colors };
});
}
/** Compute the actual pixel font size from a leaf's offset, clamped to
* [MIN_FONT_SIZE, MAX_FONT_SIZE]. */
export function resolveFontSize(offset: number | undefined): number {
@ -383,6 +419,7 @@ export function reshapeToPreset(
if (src.label !== undefined) slot.label = src.label;
if (src.broadcast !== undefined) slot.broadcast = src.broadcast;
if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset;
if (src.colorOverride !== undefined) slot.colorOverride = src.colorOverride;
if (src.mcpAllow !== undefined) slot.mcpAllow = src.mcpAllow;
}

View file

@ -66,7 +66,23 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{ keys: "Ctrl+K", description: "Open jump-to-pane palette" },
{
keys: "Ctrl+Shift+← / → / ↑ / ↓",
description: "Focus neighbour pane in that direction",
description:
"Focus neighbour pane in that direction (window-level — works even when no terminal is focused)",
},
{
keys: "Ctrl+Alt+← / → / ↑ / ↓",
description:
"Focus neighbour pane in that direction (from inside the terminal — intercepted before the PTY sees it)",
},
{
keys: "Ctrl+Alt+H / J / K / L",
description:
"Same as Ctrl+Alt+Arrow but in Vim-style HJKL order (left / down / up / right)",
},
{
keys: "Alt+1 … Alt+9",
description:
"Focus the Nth pane in layout order (DFS: left-to-right, top-to-bottom); clamped to pane count. Note: swallows bare Alt+digit — shells using readline digit-argument or vim buffer-jump may conflict.",
},
],
},
@ -100,6 +116,18 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
keys: "Ctrl+Shift+C / Ctrl+Shift+V",
description: "Copy selection / paste in terminal",
},
{
keys: "Ctrl+Shift+F",
description: "Open find-in-scrollback bar for the focused pane",
},
{
keys: "Enter / Shift+Enter",
description: "Next / previous match (while search bar is focused)",
},
{
keys: "Escape",
description: "Close find bar and return focus to terminal",
},
],
},
{

79
src/lib/theme.test.ts Normal file
View file

@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import {
resolvePaneColors,
toXtermTheme,
DEFAULT_PANE_COLORS,
COLOR_PRESETS,
type PaneColors,
} from "./theme";
describe("resolvePaneColors", () => {
it("falls back to defaults when nothing is set", () => {
expect(resolvePaneColors(undefined, undefined)).toEqual(DEFAULT_PANE_COLORS);
});
it("uses global values over defaults", () => {
const global: PaneColors = { background: "#111111", cursor: "#abcdef" };
const r = resolvePaneColors(global, undefined);
expect(r.background).toBe("#111111");
expect(r.cursor).toBe("#abcdef");
// Unset fields still come from defaults.
expect(r.foreground).toBe(DEFAULT_PANE_COLORS.foreground);
expect(r.selection).toBe(DEFAULT_PANE_COLORS.selection);
});
it("per-pane override wins over global, field by field", () => {
const global: PaneColors = { background: "#111111", foreground: "#222222" };
const override: PaneColors = { background: "#999999" };
const r = resolvePaneColors(global, override);
expect(r.background).toBe("#999999"); // override wins
expect(r.foreground).toBe("#222222"); // inherits global
expect(r.cursor).toBe(DEFAULT_PANE_COLORS.cursor); // inherits default
});
it("always returns all four fields defined", () => {
const r = resolvePaneColors({}, {});
expect(Object.keys(r).sort()).toEqual([
"background",
"cursor",
"foreground",
"selection",
]);
});
});
describe("toXtermTheme", () => {
it("maps resolved colours onto the xterm ITheme shape", () => {
const theme = toXtermTheme({
background: "#0c0c0c",
foreground: "#c5c8c6",
cursor: "#ffffff",
selection: "#3a3a3a",
});
expect(theme.background).toBe("#0c0c0c");
expect(theme.foreground).toBe("#c5c8c6");
expect(theme.cursor).toBe("#ffffff");
// selection maps to xterm 5.x's renamed property.
expect(theme.selectionBackground).toBe("#3a3a3a");
// cursorAccent is pinned to the background for block-cursor legibility.
expect(theme.cursorAccent).toBe("#0c0c0c");
});
it("keeps the fixed softened white/brightWhite slice", () => {
const theme = toXtermTheme(DEFAULT_PANE_COLORS);
expect(theme.white).toBe("#c5c8c6");
expect(theme.brightWhite).toBe("#e0e0e0");
});
});
describe("COLOR_PRESETS", () => {
it("starts with the tiletopia default and every preset is fully specified", () => {
expect(COLOR_PRESETS[0].name).toBe("Tiletopia Dark");
expect(COLOR_PRESETS[0].colors).toEqual(DEFAULT_PANE_COLORS);
for (const p of COLOR_PRESETS) {
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
expect(p.colors[key]).toMatch(/^#[0-9a-fA-F]{6}$/);
}
}
});
});

160
src/lib/theme.ts Normal file
View file

@ -0,0 +1,160 @@
//! Terminal colour theming.
//!
//! tiletopia ships one hard-coded dark palette historically baked into
//! XtermPane. This module turns that into a customisable model:
//!
//! - a GLOBAL default theme (persisted to localStorage, app-wide), and
//! - optional PER-PANE overrides (stored on the LeafNode, persisted with the
//! workspace tree).
//!
//! Only four colours are user-editable — background, foreground, cursor, and
//! selection — the ones that actually move the needle on readability. The
//! rest of xterm's ITheme (the 16-colour ANSI palette, etc.) stays fixed in
//! {@link BASE_XTERM_THEME}: notably `white`/`brightWhite` keep the softened
//! values that tame the Claude TUI's emphasis slots (see XtermPane history).
import type { ITheme } from "@xterm/xterm";
/** The four user-editable colours. All optional: an undefined field on a
* per-pane override falls through to the global default; an undefined field
* on the global default falls through to {@link DEFAULT_PANE_COLORS}. */
export interface PaneColors {
/** Terminal background. */
background?: string;
/** Default text colour. */
foreground?: string;
/** Cursor block colour. */
cursor?: string;
/** Selection highlight background. */
selection?: string;
}
/** Fixed slice of the xterm theme that is NOT user-editable. The softened
* white/brightWhite values date back to the original hard-coded theme they
* keep the Claude TUI's emphasis text from hitting glaring pure white. */
const BASE_XTERM_THEME: ITheme = {
white: "#c5c8c6",
brightWhite: "#e0e0e0",
};
/** Ground-truth defaults the historical tiletopia palette. Every editable
* field resolves to one of these when nothing overrides it. Also exposed as
* the first preset ("Tiletopia Dark"). */
export const DEFAULT_PANE_COLORS: Required<PaneColors> = {
background: "#0c0c0c",
foreground: "#c5c8c6",
cursor: "#ffffff",
selection: "#3a3a3a",
};
/** A named, ready-to-apply colour set shown as a one-click starting point in
* the colour panel. */
export interface ColorPreset {
name: string;
colors: Required<PaneColors>;
}
/** Built-in presets. The first is the tiletopia default; the rest are
* well-known community palettes (background/foreground/cursor/selection
* only the ANSI ramp is left to {@link BASE_XTERM_THEME}). */
export const COLOR_PRESETS: ColorPreset[] = [
{ name: "Tiletopia Dark", colors: DEFAULT_PANE_COLORS },
{
name: "Solarized Dark",
colors: { background: "#002b36", foreground: "#839496", cursor: "#93a1a1", selection: "#073642" },
},
{
name: "Gruvbox Dark",
colors: { background: "#282828", foreground: "#ebdbb2", cursor: "#ebdbb2", selection: "#504945" },
},
{
name: "Dracula",
colors: { background: "#282a36", foreground: "#f8f8f2", cursor: "#f8f8f2", selection: "#44475a" },
},
{
name: "Nord",
colors: { background: "#2e3440", foreground: "#d8dee9", cursor: "#d8dee9", selection: "#434c5e" },
},
{
name: "Light",
colors: { background: "#fafafa", foreground: "#1c1c1c", cursor: "#1c1c1c", selection: "#cfe0ff" },
},
];
/** Merge a per-pane override on top of the global default, then fill any
* still-missing field from {@link DEFAULT_PANE_COLORS}. The result always
* has all four fields defined. */
export function resolvePaneColors(
global: PaneColors | undefined,
override: PaneColors | undefined,
): Required<PaneColors> {
return {
background:
override?.background ?? global?.background ?? DEFAULT_PANE_COLORS.background,
foreground:
override?.foreground ?? global?.foreground ?? DEFAULT_PANE_COLORS.foreground,
cursor: override?.cursor ?? global?.cursor ?? DEFAULT_PANE_COLORS.cursor,
selection:
override?.selection ?? global?.selection ?? DEFAULT_PANE_COLORS.selection,
};
}
/** Build a full xterm ITheme from resolved colours. cursorAccent is pinned to
* the background so a block cursor's glyph stays readable. */
export function toXtermTheme(colors: Required<PaneColors>): ITheme {
return {
...BASE_XTERM_THEME,
background: colors.background,
foreground: colors.foreground,
cursor: colors.cursor,
cursorAccent: colors.background,
selectionBackground: colors.selection,
};
}
// ---------------------------------------------------------------------------
// Global-default persistence (localStorage; frontend-only, no backend hop).
// localStorage is shared across all windows of the same origin, so a new
// window picks up the saved theme at startup, and the `storage` event lets
// open windows react live (see App's listener).
// ---------------------------------------------------------------------------
export const GLOBAL_COLORS_STORAGE_KEY = "tiletopia.globalColors.v1";
/** #rgb / #rrggbb hex validator what `<input type="color">` emits and what
* xterm accepts. We reject anything else so a corrupt localStorage value
* can't poison the theme. */
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function sanitizeColors(raw: unknown): PaneColors {
if (typeof raw !== "object" || raw === null) return {};
const o = raw as Record<string, unknown>;
const out: PaneColors = {};
for (const key of ["background", "foreground", "cursor", "selection"] as const) {
const v = o[key];
if (typeof v === "string" && HEX_RE.test(v)) out[key] = v;
}
return out;
}
/** Read the saved global theme. Returns {} ( all defaults) when absent or
* unparseable. */
export function loadGlobalColors(): PaneColors {
try {
const raw = localStorage.getItem(GLOBAL_COLORS_STORAGE_KEY);
if (!raw) return {};
return sanitizeColors(JSON.parse(raw));
} catch {
return {};
}
}
/** Persist the global theme. Empty object is stored as-is (means "all
* defaults"), keeping the round-trip lossless. */
export function saveGlobalColors(colors: PaneColors): void {
try {
localStorage.setItem(GLOBAL_COLORS_STORAGE_KEY, JSON.stringify(colors));
} catch (e) {
console.warn("saveGlobalColors failed:", e);
}
}