diff --git a/README.md b/README.md index c3038c5..23ec0cb 100644 --- a/README.md +++ b/README.md @@ -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 Manager–stored 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** diff --git a/memory.md b/memory.md index 97c730c..cb38d7a 100644 --- a/memory.md +++ b/memory.md @@ -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 1–4) 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\` 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: easy–medium. +- [ ] **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: easy–medium. +- [ ] **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//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` 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 ▸ " 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** diff --git a/package.json b/package.json index 26121e1..e77cff9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb8b88..21bfa82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4f0de08..d7a99cd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4221,7 +4221,7 @@ dependencies = [ [[package]] name = "tiletopia" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bf16fd0..f8c7e52 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a88bac..c8cf4f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 = 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 = + 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(); + } + } + }); } diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs index a6ba575..36f6014 100644 --- a/src-tauri/src/window_state.rs +++ b/src-tauri/src/window_state.rs @@ -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:#}"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a57e25f..cb37dc1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 07fefe2..766058d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => + loadGlobalColors(), + ); + const [colorPanelOpen, setColorPanelOpen] = useState(false); + const [colorPanelMode, setColorPanelMode] = useState<"global" | "pane">( + "global", + ); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); 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() { > 🤖 + + + + {/* Target toggle: edit the global default or just the active pane. */} +
+ + +
+ +
+

+ {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."} +

+ + {/* Editable colour rows */} +
+ {FIELDS.map(({ key, label }) => { + const value = resolved[key]!; + const inherited = paneMode && !isSet(key); + return ( +
+ {label} + setField(key, e.target.value)} + aria-label={label} + /> + { + const v = e.target.value.trim(); + if (HEX_RE.test(v)) setField(key, v); + }} + /> + {paneMode && + (inherited ? ( + + inherited + + ) : ( + + ))} +
+ ); + })} +
+ + {/* Live preview */} + + + {/* Presets */} +
+ Presets +
+ {COLOR_PRESETS.map((p) => ( + + ))} +
+
+ +
+ +
+
+ + + ); +} diff --git a/src/components/SearchBar.css b/src/components/SearchBar.css new file mode 100644 index 0000000..0f389bd --- /dev/null +++ b/src/components/SearchBar.css @@ -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; +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..1d41a99 --- /dev/null +++ b/src/components/SearchBar.tsx @@ -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(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) { + 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) { + 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 ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index de4309a..402b729 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -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; + /** 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(null); const termRef = useRef(null); const fitRef = useRef(null); const paneIdRef = useRef(null); + const searchAddonRef = useRef(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,36 +330,100 @@ 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; - if (e.code === "KeyC") { - const sel = term?.getSelection(); - if (sel) { - void clipboardWriteText(sel).catch((err) => - console.warn("clipboard write failed:", err), - ); + + // --- 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) => + console.warn("clipboard write failed:", err), + ); + } + e.preventDefault(); + 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) => { + if (text && term) term.paste(text); + }) + .catch((err) => console.warn("clipboard read failed:", err)); + return false; } - e.preventDefault(); - return false; } - if (e.code === "KeyV") { - e.preventDefault(); - clipboardReadText() - .then((text) => { - if (text && term) term.paste(text); - }) - .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 = { + ArrowLeft: "left", + ArrowRight: "right", + ArrowUp: "up", + ArrowDown: "down", + }; + // Vim-style HJKL + const VIM_DIR: Record = { + 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
; + // ------------------------------------------------------------------------- + // 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( + ".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 ( +
+
+ {searchOpen && searchAddonRef.current && ( + + )} +
+ ); } diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index 785e5f2..521c69d 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -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; diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 75ad84c..35b1c7e 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -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(null); + const rootRef = useRef(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[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 (
+ + {isIdle && statusOk ? ( 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)} /> ) : (
diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index cd381ff..10d90d5 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -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(null); export function OrchestrationProvider({ diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 41f9d08..2d14c60 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -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); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 352dbec..e02904b 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -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; } diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 5b1b789..a72cddb 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -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", + }, ], }, { diff --git a/src/lib/theme.test.ts b/src/lib/theme.test.ts new file mode 100644 index 0000000..e17c1d5 --- /dev/null +++ b/src/lib/theme.test.ts @@ -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}$/); + } + } + }); +}); diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..ca70c3d --- /dev/null +++ b/src/lib/theme.ts @@ -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 = { + 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; +} + +/** 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 { + 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): 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 `` 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; + 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); + } +}