From c92847413bc8d61311208e49f0c87341c557d8f7 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 26 May 2026 19:32:22 +0100 Subject: [PATCH 01/45] Session log: v0.3.0 shipped + release-time gotchas for next time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the session that took MCP from read-only v1 → full write surface in v0.3.0. Notes the four release-time hiccups (tsc -b narrowing miss, rm -rf src-tauri/target wiping the installer, pnpm install hang from WSL, separate Cargo.lock commit) with fixes shipped and a clean recipe for the next release. Co-Authored-By: Claude Opus 4.7 (1M context) --- memory.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/memory.md b/memory.md index 006e88f..f22704f 100644 --- a/memory.md +++ b/memory.md @@ -52,6 +52,25 @@ Durable memory for this project. Read at session start, update before session en ## Session log +### 2026-05-26 — **v0.3.0 shipped to Forgejo releases** + +Cut after a marathon session that took MCP from read-only v1 → full write surface + policy engine + audit + safeguards + .mcpb bundle. Tag `v0.3.0`, both `tiletopia_0.3.0_x64-setup.exe` and `tiletopia.mcpb` attached. + +**Release-time hiccups** (all fixed in subsequent commits — read these before the next release): + +- `pnpm tauri build` failed type-check on a `a.spec!.hostId` non-null assertion that drops the `kind === "ssh"` narrowing inside a `hosts.find` closure. `pnpm check` ran `tsc --noEmit` which had been silently missing the bug; `tsc -b` (what `pnpm build` uses) caught it. Fixed the line + switched the check script to `tsc -b` (both project-reference tsconfigs already have `noEmit: true`, so no emission). Commits `e1ceaab`, `7e285b2`. +- After the Windows build I ran `rm -rf src-tauri/target` from WSL to clear tsc cache — wiped the cargo target dir *including the freshly-built installer*. /mnt/d/ is the real Windows filesystem. Lesson: `src-tauri/target/` is cargo's output dir, NOT just tsc cache; do not touch without rebuild plan. The user rebuilt; cost a single `pnpm tauri build` cycle. +- `pnpm run build:mcpb` from `release.sh` hung indefinitely when run from WSL — pnpm auto-runs `pnpm install` first, which walks `node_modules` across the /mnt/d/ filesystem boundary and stalls for minutes. The bundle script is pure Node + fs, no deps to install. Switched release.sh to call `node scripts/build-mcpb.mjs` directly. Commit `1db8b26`. +- `Cargo.lock` needed committing separately after the version bump (cargo updated it during `pnpm tauri build`). Worth doing the version bump + `cargo check` together next time so the lock-file change is atomic with the version commit. + +**For the next release:** +1. Bump version in `package.json` + `src-tauri/Cargo.toml` + `src-tauri/tauri.conf.json` +2. Run `cargo check` (or any cargo command) to update `Cargo.lock` +3. Commit all four files + push +4. `pnpm tauri build` on Windows +5. `./scripts/release.sh vX.Y.Z` from WSL +6. Edit the auto-generated release note on Forgejo with a proper changelog + ### 2026-05-26 — Clear cargo warnings: drop v2.1 classifier scaffold, annotate rmcp tool_router Four pre-existing dead-code warnings out of every cargo build. Three were the v2.1 classifier scaffold sitting unused in `mcp_policy.rs` (`ClassifierHint` enum, `PolicyClassifier` trait, `NoopClassifier` struct + impl). Deleted — the scaffold being unused for weeks was a stronger "no plan" signal than its presence was a "TODO" signal. If we actually want classifier upgrade-on-Ask later (v0.4.0 candidate), trivial to re-add; the design questions (Anthropic vs Ollama, API key UX, monthly cost cap, privacy disclosure) need a focused session. From 1a035ad0a60d4cacf6cba4b081a60162861a368c Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 18:43:32 +0100 Subject: [PATCH 02/45] Phase 1: tabbed workspaces Each tab is an independent tile tree; PTYs in non-active tabs keep running (render-all-panes with visibility:hidden on inactive layers so xterm.js's fit() still sees valid dimensions and the existing per-pane resize dedupe absorbs no-op SIGWINCHes). workspace.json shape goes from a bare TreeNode to `{ version: 2, workspaces: [{ id, name, tree }] }` with a legacy v1 auto-wrap migration (the old single tree becomes one tab named "Default"). App.tsx wraps the old single-tree state in workspace-aware state but keeps `tree` / `setTree` / `activeLeafId` / `setActiveLeafId` as identity-stable derived wrappers (reading currentWorkspaceId from a ref), so the bulk of App.tsx stays unchanged. XtermPane's initial term.focus() now checks `visibility !== "hidden"` on the container so a pane mounting inside a hidden tab on app boot doesn't yank focus away from the active tab. The focus poller is scoped to the active workspace layer for the same reason. Shortcuts: Ctrl+T new tab, Ctrl+Shift+T close current (window.confirm when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9 switch to tab N. README + help overlay auto-generated from shortcuts.ts. 79/79 vitest pass (7 new envelope-migration cases). tsc -b clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 + src/App.tsx | 383 ++++++++++++++++++++++++++++++----- src/components/TabStrip.css | 178 ++++++++++++++++ src/components/TabStrip.tsx | 212 +++++++++++++++++++ src/components/XtermPane.tsx | 14 +- src/lib/layout/tree.test.ts | 83 ++++++++ src/lib/layout/tree.ts | 86 ++++++++ src/lib/shortcuts.ts | 19 ++ 8 files changed, 933 insertions(+), 52 deletions(-) create mode 100644 src/components/TabStrip.css create mode 100644 src/components/TabStrip.tsx diff --git a/README.md b/README.md index 15ac12d..6f06417 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,15 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Ctrl+Shift+W` | Close active pane | | `Ctrl+Shift+P` | Promote active pane out one level (turns a nested pane into a full row/column; self-inverse) | +**Tabs** + +| Key | Action | +|---|---| +| `Ctrl+T` | New tab (blank workspace, one pane) | +| `Ctrl+Shift+T` | Close current tab (confirms when the tab has live panes) | +| `Ctrl+PageDown / Ctrl+PageUp` | Switch to next / previous tab | +| `Ctrl+1 … Ctrl+9` | Switch to tab 1 … 9 | + **Navigation** | Key | Action | @@ -79,6 +88,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil - **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click. - **Drag pane headers to swap** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard. - **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch. +- **Tabs (workspaces)** — Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json. - **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. diff --git a/src/App.tsx b/src/App.tsx index 276bd4b..f552e49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import { type Orientation, type LeafNode, type LeafShellSpec, + type Workspace, newLeaf, newId, splitLeaf, @@ -59,7 +60,9 @@ import { MIN_PANE_PX, type Direction, serialize, - deserialize, + serializeWorkspaces, + deserializeWorkspaces, + singletonEnvelope, presetSingle, presetTwoColumns, presetThreeColumns, @@ -75,6 +78,7 @@ import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; +import TabStrip from "./components/TabStrip"; import "./App.css"; import "./lib/layout/Gutter.css"; @@ -114,8 +118,86 @@ function describeSpec(spec: SpawnSpec): string { export default function App() { // ---- top-level state ----------------------------------------------------- - const [tree, setTree] = useState(() => newLeaf()); - const [activeLeafId, setActiveLeafId] = useState(null); + // Workspaces (tabs). Each is one independent tile tree. The mount effect + // seeds an initial singleton workspace if the persisted envelope is empty. + const [workspaces, setWorkspaces] = useState(() => { + const t = newLeaf(); + return [{ id: newId(), name: "Default", tree: t }]; + }); + const [currentWorkspaceId, setCurrentWorkspaceId] = useState( + () => "", // filled on mount alongside workspaces + ); + /** Per-workspace remembered active leaf — each tab keeps its own focus + * cursor so switching back doesn't dump the user into a random pane. */ + const [activeLeafByWorkspace, setActiveLeafByWorkspace] = useState< + Map + >(() => new Map()); + + // ---- workspace-aware tree wrappers -------------------------------------- + // These mirror the v0.3.0 single-tree API (`tree`, `setTree(updater)`, + // `activeLeafId`, `setActiveLeafId`) but operate on whichever workspace is + // current. Keeps the bulk of App.tsx unchanged across the tabs refactor. + // + // Refs first so the setter useCallbacks below capture stable references + // and don't need currentWorkspaceId in their dep arrays. + const currentWorkspaceIdRef = useRef(currentWorkspaceId); + const workspacesRef = useRef(workspaces); + + const currentWorkspace = useMemo( + () => workspaces.find((w) => w.id === currentWorkspaceId) ?? null, + [workspaces, currentWorkspaceId], + ); + const tree: TreeNode = currentWorkspace?.tree ?? workspaces[0]?.tree ?? newLeaf(); + + // Identity-stable across renders — both read the live currentWorkspaceId + // from a ref so every useCallback that captures these doesn't need to + // list them in its deps (mirroring the v0.3.0 stable-setter contract). + // The ref is updated in the workspacesRef / currentWorkspaceIdRef effects + // below; it's seeded synchronously by useState in the same render so + // first-render calls see the initial id. + const setTree = useCallback>>( + (updater) => { + setWorkspaces((ws) => { + if (ws.length === 0) return ws; + const targetId = currentWorkspaceIdRef.current || ws[0].id; + return ws.map((w) => { + if (w.id !== targetId) return w; + const next = + typeof updater === "function" + ? (updater as (prev: TreeNode) => TreeNode)(w.tree) + : updater; + if (next === w.tree) return w; + return { ...w, tree: next }; + }); + }); + }, + [], + ); + + const activeLeafId: NodeId | null = + (currentWorkspaceId && activeLeafByWorkspace.get(currentWorkspaceId)) ?? null; + + const setActiveLeafId = useCallback< + React.Dispatch> + >( + (updater) => { + setActiveLeafByWorkspace((prev) => { + const wsId = currentWorkspaceIdRef.current; + if (!wsId) return prev; + const cur = prev.get(wsId) ?? null; + const next = + typeof updater === "function" + ? (updater as (prev: NodeId | null) => NodeId | null)(cur) + : updater; + if (next === cur) return prev; + const m = new Map(prev); + m.set(wsId, next); + return m; + }); + }, + [], + ); + const [distros, setDistros] = useState([]); const [defaultShell, setDefaultShell] = useState({ shellKind: "wsl", @@ -142,24 +224,34 @@ export default function App() { useEffect(() => { treeRef.current = tree; }, [tree]); + // workspacesRef + currentWorkspaceIdRef are declared up by the tree + // wrappers so setTree / setActiveLeafId can capture them; sync here. + useEffect(() => { + workspacesRef.current = workspaces; + }, [workspaces]); + useEffect(() => { + currentWorkspaceIdRef.current = currentWorkspaceId; + }, [currentWorkspaceId]); // ---- mount: load workspace + distros + hosts ---------------------------- useEffect(() => { let cancelled = false; (async () => { - let loaded: TreeNode | null = null; + let loadedEnvelope: ReturnType = null; try { const json = await loadWorkspace(); - if (json) loaded = deserialize(json); + if (json) loadedEnvelope = deserializeWorkspaces(json); } catch (e) { console.warn("loadWorkspace failed:", e); } - if (!loaded) { + if (!loadedEnvelope) { try { const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); if (legacy) { - loaded = deserialize(legacy); - if (loaded) void saveWorkspace(legacy); + loadedEnvelope = deserializeWorkspaces(legacy); + if (loadedEnvelope) { + void saveWorkspace(serializeWorkspaces(loadedEnvelope)); + } localStorage.removeItem(LEGACY_STORAGE_KEY); } } catch (e) { @@ -190,14 +282,20 @@ export default function App() { })(); if (cancelled) return; - if (loaded) { - if (initialDefault.shellKind === "wsl" && initialDefault.distro) { - backfillWslDistro(loaded, initialDefault.distro); - } - setTree(loaded); - } else { - setTree(newLeaf(defaultShellAsLeafProps(initialDefault))); + + let envelope = loadedEnvelope; + if (!envelope) { + envelope = singletonEnvelope( + newLeaf(defaultShellAsLeafProps(initialDefault)), + ); } + if (initialDefault.shellKind === "wsl" && initialDefault.distro) { + for (const w of envelope.workspaces) { + backfillWslDistro(w.tree, initialDefault.distro); + } + } + setWorkspaces(envelope.workspaces); + setCurrentWorkspaceId(envelope.workspaces[0].id); setDistros(resolvedDistros); setHosts(resolvedHosts); setDefaultShell(initialDefault); @@ -212,27 +310,34 @@ export default function App() { useEffect(() => { if (!ready) return; const id = window.setTimeout(() => { - saveWorkspace(serialize(tree)).catch((e) => - console.warn("saveWorkspace failed:", e), - ); + saveWorkspace( + serializeWorkspaces({ version: 2, workspaces }), + ).catch((e) => console.warn("saveWorkspace failed:", e)); }, SAVE_DEBOUNCE_MS); return () => clearTimeout(id); - }, [tree, ready]); + }, [workspaces, ready]); // ---- focus polling → setActive (xterm.js eats pointerdown) -------------- + // Scoped to the current workspace layer so a hidden tab's lingering + // textarea focus (visibility:hidden doesn't auto-blur on tab switch) + // doesn't yank activeLeafId into a tab the user can't see. useEffect(() => { let lastLeafId: string | null = null; const interval = window.setInterval(() => { const el = document.activeElement; const leafEl = el?.closest("[data-leaf-id]"); - const id = leafEl?.getAttribute("data-leaf-id") ?? null; + if (!leafEl) return; + const wsEl = leafEl.closest("[data-workspace-id]"); + const wsId = wsEl?.getAttribute("data-workspace-id") ?? null; + if (wsId !== currentWorkspaceIdRef.current) return; + const id = leafEl.getAttribute("data-leaf-id"); if (id && id !== lastLeafId) { lastLeafId = id; setActiveLeafId(id); } }, 250); return () => clearInterval(interval); - }, []); + }, [setActiveLeafId]); // notify is defined up here (and not next to dismissNotification) because // the split callback below uses it to warn when a pane is too small to @@ -364,6 +469,95 @@ export default function App() { setTree((t) => setLeafShell(t, leafId, spec)); }, []); + // ---- tab (workspace) operations ---------------------------------------- + /** Append a fresh blank workspace using the current default shell, then + * switch to it. */ + const createTab = useCallback(() => { + const idx = workspacesRef.current.length + 1; + const w: Workspace = { + id: newId(), + name: `Tab ${idx}`, + tree: newLeaf(defaultShellAsLeafProps(defaultShell)), + }; + setWorkspaces((ws) => [...ws, w]); + setCurrentWorkspaceId(w.id); + }, [defaultShell]); + + /** Switch the visible workspace. No-op if the id isn't in the list. */ + const switchTab = useCallback((id: NodeId) => { + if (!workspacesRef.current.some((w) => w.id === id)) return; + setCurrentWorkspaceId(id); + }, []); + + const renameTab = useCallback((id: NodeId, name: string) => { + setWorkspaces((ws) => ws.map((w) => (w.id === id ? { ...w, name } : w))); + }, []); + + /** Close a tab. Kills every PTY in its tree first (so the call site + * doesn't need to walk leaves itself). If the closing tab was current, + * switch to the adjacent one. If it was the only tab, replace it with a + * fresh default — tiletopia must always have at least one workspace. */ + const closeTab = useCallback( + (id: NodeId) => { + const target = workspacesRef.current.find((w) => w.id === id); + if (!target) return; + for (const leaf of walkLeaves(target.tree)) { + const paneId = paneIdByLeafRef.current.get(leaf.id); + if (paneId != null) { + void killPane(paneId).catch((e) => + console.warn("killPane failed:", e), + ); + paneIdByLeafRef.current.delete(leaf.id); + } + } + + const idx = workspacesRef.current.findIndex((w) => w.id === id); + const wasCurrent = currentWorkspaceIdRef.current === id; + + setWorkspaces((prev) => { + const next = prev.filter((w) => w.id !== id); + if (next.length === 0) { + return [ + { + id: newId(), + name: "Default", + tree: newLeaf(defaultShellAsLeafProps(defaultShell)), + }, + ]; + } + return next; + }); + + setActiveLeafByWorkspace((prev) => { + if (!prev.has(id)) return prev; + const m = new Map(prev); + m.delete(id); + return m; + }); + + if (wasCurrent) { + // Use the snapshot of workspaces BEFORE removal to find a neighbor. + const remaining = workspacesRef.current.filter((w) => w.id !== id); + if (remaining.length > 0) { + const nextIdx = Math.min(idx, remaining.length - 1); + setCurrentWorkspaceId(remaining[nextIdx].id); + } + // else: setWorkspaces above will populate a fresh default; the + // workspaces effect runs and updates currentWorkspaceIdRef below. + } + }, + [defaultShell], + ); + + // When the workspaces list changes (load, close last tab, etc.), make sure + // currentWorkspaceId points at something real. + useEffect(() => { + if (workspaces.length === 0) return; + if (!workspaces.some((w) => w.id === currentWorkspaceId)) { + setCurrentWorkspaceId(workspaces[0].id); + } + }, [workspaces, currentWorkspaceId]); + const setLabel = useCallback((leafId: NodeId, label: string | undefined) => { setTree((t) => changeLabel(t, leafId, label)); }, []); @@ -583,6 +777,65 @@ export default function App() { return; } + // Ctrl+T — new tab. + if (ctrl && !shift && !alt && key === "t") { + e.preventDefault(); + e.stopPropagation(); + createTab(); + return; + } + + // Ctrl+Shift+T — close current tab. Confirms via window.confirm when + // the tab has live panes (matches the X-button popover's intent + // without re-using its anchored UI from the keyboard path). + if (ctrl && shift && !alt && key === "t") { + e.preventDefault(); + e.stopPropagation(); + const wsId = currentWorkspaceIdRef.current; + const ws = workspacesRef.current.find((w) => w.id === wsId); + if (!ws) return; + const paneCount = leafCount(ws.tree); + if (paneCount > 0) { + const labels = Array.from(walkLeaves(ws.tree)) + .map((l) => l.label ?? l.distro ?? `(${l.shellKind})`) + .join(", "); + const ok = window.confirm( + `Close tab "${ws.name}"? This will kill ${paneCount} pane${paneCount === 1 ? "" : "s"}: ${labels}`, + ); + if (!ok) return; + } + closeTab(wsId); + return; + } + + // Ctrl+PageDown / Ctrl+PageUp — switch to next / previous tab. + if (ctrl && !shift && !alt && (key === "pagedown" || key === "pageup")) { + e.preventDefault(); + e.stopPropagation(); + const ws = workspacesRef.current; + if (ws.length < 2) return; + const idx = ws.findIndex((w) => w.id === currentWorkspaceIdRef.current); + if (idx < 0) return; + const delta = key === "pagedown" ? 1 : -1; + const nextIdx = (idx + delta + ws.length) % ws.length; + switchTab(ws[nextIdx].id); + return; + } + + // Ctrl+1..9 — switch to tab N (1-indexed, capped at 9 even if more). + if (ctrl && !shift && !alt && e.code.startsWith("Digit")) { + const n = parseInt(e.code.slice(5), 10); + if (n >= 1 && n <= 9) { + const ws = workspacesRef.current; + if (ws[n - 1]) { + e.preventDefault(); + e.stopPropagation(); + switchTab(ws[n - 1].id); + return; + } + } + } + // All remaining shortcuts require Ctrl+Shift with no Alt. if (!ctrl || !shift || alt) return; @@ -629,7 +882,7 @@ export default function App() { window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [split, close, toggleBroadcast, promoteActive]); + }, [split, close, toggleBroadcast, promoteActive, createTab, closeTab, switchTab]); // Waiters keyed by leaf id — used by the MCP spawn_pane / connect_host // handlers, which must reply with the new paneId but can only get one @@ -1408,11 +1661,11 @@ export default function App() { [paletteOpen, tree], ); - // ---- flat layout — leaves as siblings keyed by id; gutters separate ----- - // This lets React preserve LeafPane (and its PTY) across any tree reshape - // — split, close, preset application, etc. The tree changes, the boxes - // change, the leaves re-position but DON'T unmount. - const layout = useMemo(() => flattenLayout(tree), [tree]); + // ---- flat layout per workspace — leaves as siblings keyed by id --------- + // Each workspace gets its own .workspace-layer (rendered below). Layouts + // are recomputed inline per tab; the per-workspace render preserves + // LeafPane (and its PTY) across any tree reshape and across tab switches + // (since non-active layers are visibility:hidden rather than unmounted). const paneWrapRef = useRef(null); const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => { setTree((t) => updateSplitRatio(t, splitId, ratio)); @@ -1651,32 +1904,62 @@ export default function App() { + +
{ready && ( - {layout.leaves.map(({ leaf, box }) => ( -
- -
- ))} - {layout.gutters.map((g) => ( - - ))} + {workspaces.map((ws) => { + const wsLayout = flattenLayout(ws.tree); + const isCurrent = ws.id === currentWorkspaceId; + return ( +
+ {wsLayout.leaves.map(({ leaf, box }) => ( +
+ +
+ ))} + {isCurrent && + wsLayout.gutters.map((g) => ( + + ))} +
+ ); + })}
)}
diff --git a/src/components/TabStrip.css b/src/components/TabStrip.css new file mode 100644 index 0000000..4a3c17b --- /dev/null +++ b/src/components/TabStrip.css @@ -0,0 +1,178 @@ +.tab-strip { + flex: 0 0 auto; + display: flex; + align-items: stretch; + gap: 2px; + padding: 4px 8px 0 8px; + background: #161616; + border-bottom: 1px solid #2a2a2a; + font-size: 12px; + color: #aaa; + user-select: none; + overflow-x: auto; + overflow-y: visible; + min-height: 28px; + box-sizing: border-box; + white-space: nowrap; + /* Allow the inline confirm popover to spill over the bottom edge instead + of being clipped by overflow:hidden. overflow-y:visible alongside + overflow-x:auto only works because confirm popovers position themselves + absolutely. */ +} + +.tab-strip-item { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 4px 4px 10px; + border: 1px solid #2a2a2a; + border-bottom: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + background: #1a1a1a; + color: #999; + cursor: pointer; + max-width: 200px; + min-width: 80px; + flex-shrink: 0; +} +.tab-strip-item:hover { + background: #232323; + color: #ccc; +} +.tab-strip-item.active { + background: #0c0c0c; + color: #e6e6e6; + border-color: #2a5a8c; + /* Pull the active tab visually onto the pane area below it. */ + margin-bottom: -1px; +} + +.tab-strip-name { + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + min-width: 0; +} + +.tab-strip-rename { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + background: #0c0c0c; + color: #e6e6e6; + border: 1px solid #2a5a8c; + border-radius: 2px; + padding: 1px 4px; + width: 100%; + flex: 1 1 auto; + min-width: 0; + outline: none; +} + +.tab-strip-close { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 13px; + line-height: 1; + background: transparent; + color: #777; + border: none; + border-radius: 2px; + padding: 0 4px; + cursor: pointer; + flex: 0 0 auto; +} +.tab-strip-close:hover { + background: #c94040; + color: #fff; +} + +.tab-strip-add { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 14px; + line-height: 1; + background: #1a1a1a; + color: #aaa; + border: 1px solid #2a2a2a; + border-radius: 4px; + padding: 2px 10px; + cursor: pointer; + align-self: center; + margin-left: 4px; + flex: 0 0 auto; +} +.tab-strip-add:hover { + background: #1a3a5c; + color: #fff; + border-color: #2a5a8c; +} + +/* Inline confirm popover anchored to the close button — spills below the + strip. Plain matte panel; reuses the existing app palette. */ +.tab-strip-confirm { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 50; + min-width: 260px; + max-width: 360px; + background: #1a1a1a; + color: #e6e6e6; + border: 1px solid #c98a1f; + border-radius: 4px; + padding: 10px 12px; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + white-space: normal; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6); + cursor: default; +} +.tab-strip-confirm-title { + font-weight: 600; + color: #f0c060; + margin-bottom: 6px; +} +.tab-strip-confirm-body { + color: #ccc; + margin-bottom: 10px; +} +.tab-strip-confirm-labels { + color: #e6e6e6; + font-size: 11px; + margin-top: 4px; + word-break: break-word; +} +.tab-strip-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} +.tab-strip-confirm-btn { + font: inherit; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 11px; + background: #2a2a2a; + color: #ddd; + border: 1px solid #333; + border-radius: 3px; + padding: 4px 10px; + cursor: pointer; +} +.tab-strip-confirm-btn.cancel:hover { + background: #333; +} +.tab-strip-confirm-btn.destructive { + background: #4a1010; + color: #f8c0c0; + border-color: #c94040; +} +.tab-strip-confirm-btn.destructive:hover { + background: #6a1818; + color: #fff; +} diff --git a/src/components/TabStrip.tsx b/src/components/TabStrip.tsx new file mode 100644 index 0000000..cb06e0c --- /dev/null +++ b/src/components/TabStrip.tsx @@ -0,0 +1,212 @@ +import { + useState, + useRef, + useEffect, + useCallback, + useMemo, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree"; +import "./TabStrip.css"; + +interface TabStripProps { + workspaces: Workspace[]; + currentWorkspaceId: NodeId | null; + onSwitch: (id: NodeId) => void; + onCreate: () => void; + /** Caller MUST handle PTY teardown for the tab's leaves before removing it + * from the workspaces list. TabStrip just gates the action on user + * confirm. */ + onClose: (id: NodeId) => void; + onRename: (id: NodeId, name: string) => void; +} + +/** Tab strip displayed above the pane area. One pill per workspace; click to + * switch, double-click name to rename, × to close (with inline confirm if + * the tab has live panes), + at the end to spawn a new blank workspace. */ +export default function TabStrip({ + workspaces, + currentWorkspaceId, + onSwitch, + onCreate, + onClose, + onRename, +}: TabStripProps) { + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(""); + const editInputRef = useRef(null); + const [confirmingId, setConfirmingId] = useState(null); + + const startEdit = useCallback( + (id: NodeId, current: string, e: ReactMouseEvent) => { + e.stopPropagation(); + setEditingId(id); + setDraft(current); + queueMicrotask(() => editInputRef.current?.select()); + }, + [], + ); + const commitEdit = useCallback(() => { + if (editingId == null) return; + const trimmed = draft.trim(); + if (trimmed) onRename(editingId, trimmed); + setEditingId(null); + }, [editingId, draft, onRename]); + const cancelEdit = useCallback(() => setEditingId(null), []); + const onEditKey = useCallback( + (e: ReactKeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + commitEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelEdit(); + } + }, + [commitEdit, cancelEdit], + ); + + // Outside-click dismissal for the inline confirm popover. + useEffect(() => { + if (confirmingId == null) return; + const onDocClick = () => setConfirmingId(null); + // Run on next tick so the click that opened the confirm doesn't immediately close it. + const id = window.setTimeout( + () => window.addEventListener("click", onDocClick), + 0, + ); + return () => { + clearTimeout(id); + window.removeEventListener("click", onDocClick); + }; + }, [confirmingId]); + + const confirmingWorkspace = useMemo( + () => workspaces.find((w) => w.id === confirmingId) ?? null, + [workspaces, confirmingId], + ); + + const confirmingPaneLabels = useMemo(() => { + if (!confirmingWorkspace) return [] as string[]; + return Array.from(walkLeaves(confirmingWorkspace.tree)).map( + (l) => l.label ?? l.distro ?? `(${l.shellKind})`, + ); + }, [confirmingWorkspace]); + + const requestClose = useCallback( + (id: NodeId, e: ReactMouseEvent) => { + e.stopPropagation(); + const w = workspaces.find((ws) => ws.id === id); + if (!w) return; + // Silent close when the tab has no live panes (e.g. empty default leaf + // with no PTY yet — but every leaf has one, so effectively never zero). + // The leafCount check leaves room for a future "empty tab" state. + if (leafCount(w.tree) === 0) { + onClose(id); + return; + } + setConfirmingId(id); + }, + [workspaces, onClose], + ); + + const confirmClose = useCallback( + (e: ReactMouseEvent) => { + e.stopPropagation(); + if (confirmingId == null) return; + const id = confirmingId; + setConfirmingId(null); + onClose(id); + }, + [confirmingId, onClose], + ); + + return ( +
+ {workspaces.map((w) => { + const isActive = w.id === currentWorkspaceId; + const isEditing = editingId === w.id; + const isConfirming = confirmingId === w.id; + return ( +
onSwitch(w.id)} + onDoubleClick={(e) => startEdit(w.id, w.name, e)} + title={`Switch to ${w.name}`} + > + {isEditing ? ( + setDraft(e.target.value)} + onKeyDown={onEditKey} + onBlur={commitEdit} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {w.name} + )} + + {isConfirming && ( +
e.stopPropagation()} + > +
+ Close "{confirmingWorkspace?.name}"? +
+
+ This will kill {confirmingPaneLabels.length} pane + {confirmingPaneLabels.length === 1 ? "" : "s"}: +
+ {confirmingPaneLabels.join(", ")} +
+
+
+ + +
+
+ )} +
+ ); + })} + +
+ ); +} diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 36f5839..3eee830 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -273,8 +273,18 @@ export default function XtermPane({ }); ro.observe(container); - // Focus so typing immediately lands in the terminal. - term?.focus(); + // Focus so typing immediately lands in the terminal — but ONLY if the + // host container is actually visible. With multiple tabs (workspaces), + // a pane in a hidden tab still mounts and spawns; we must not yank + // focus into a tab the user can't see. CSS `visibility: hidden` is + // inherited, so the computed style on the container reflects whether + // any ancestor (workspace-layer) is hiding us. + if ( + container.isConnected && + getComputedStyle(container).visibility !== "hidden" + ) { + term?.focus(); + } })(); return () => { diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 58a755a..41f9d08 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -21,6 +21,10 @@ import { MAX_FONT_SIZE, serialize, deserialize, + serializeWorkspaces, + deserializeWorkspaces, + singletonEnvelope, + WORKSPACES_VERSION, presetSingle, presetTwoColumns, presetThreeColumns, @@ -665,3 +669,82 @@ describe("serialize / deserialize", () => { expect(back.sshHostId).toBe("h-1"); }); }); + +describe("workspaces envelope", () => { + it("roundtrips a multi-workspace envelope", () => { + const env = { + version: WORKSPACES_VERSION, + workspaces: [ + { id: "w1", name: "alpha", tree: newLeaf({ distro: "Ubuntu" }) }, + { + id: "w2", + name: "beta", + tree: newSplit("h", newLeaf({ label: "left" }), newLeaf()), + }, + ], + }; + const back = deserializeWorkspaces(serializeWorkspaces(env)); + expect(back).toEqual(env); + }); + + it("returns null on invalid JSON", () => { + expect(deserializeWorkspaces("not json")).toBeNull(); + }); + + it("returns null when version is wrong or workspaces is missing", () => { + expect(deserializeWorkspaces('{"version": 99, "workspaces": []}')).toBeNull(); + expect(deserializeWorkspaces('{"version": 2}')).toBeNull(); + }); + + it("returns null when an envelope has zero valid workspaces", () => { + expect( + deserializeWorkspaces('{"version": 2, "workspaces": [{"id": 1}]}'), + ).toBeNull(); + }); + + it("migrates a legacy v1 bare-tree JSON into a single 'Default' workspace", () => { + const legacy = JSON.stringify({ + kind: "split", + id: "s1", + orientation: "h", + ratio: 0.5, + a: { kind: "leaf", id: "a", distro: "Ubuntu" }, + b: { kind: "leaf", id: "b", distro: "PowerShell" }, + }); + const env = deserializeWorkspaces(legacy); + expect(env).not.toBeNull(); + expect(env!.version).toBe(WORKSPACES_VERSION); + expect(env!.workspaces.length).toBe(1); + expect(env!.workspaces[0].name).toBe("Default"); + // Per-leaf legacy migration also applied — PowerShell sentinel mapped. + const tree = env!.workspaces[0].tree as SplitNode; + expect((tree.a as LeafNode).shellKind).toBe("wsl"); + expect((tree.b as LeafNode).shellKind).toBe("powershell"); + expect((tree.b as LeafNode).distro).toBeUndefined(); + }); + + it("singletonEnvelope wraps a tree with a fresh workspace id", () => { + const t = newLeaf({ label: "only" }); + const env = singletonEnvelope(t, "Main"); + expect(env.workspaces.length).toBe(1); + expect(env.workspaces[0].name).toBe("Main"); + expect(env.workspaces[0].tree).toBe(t); + expect(typeof env.workspaces[0].id).toBe("string"); + expect(env.workspaces[0].id).not.toBe(t.id); + }); + + it("skips malformed workspaces but keeps the valid ones", () => { + const env = { + version: WORKSPACES_VERSION, + workspaces: [ + { id: "ok", name: "alpha", tree: { kind: "leaf", id: "L" } }, + { id: 42, name: "bad-id", tree: { kind: "leaf", id: "L2" } }, + { id: "no-tree", name: "still-bad" }, + ], + }; + const back = deserializeWorkspaces(JSON.stringify(env)); + expect(back).not.toBeNull(); + expect(back!.workspaces.length).toBe(1); + expect(back!.workspaces[0].id).toBe("ok"); + }); +}); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index ce383cd..352dbec 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -703,6 +703,92 @@ export function deserialize(json: string): TreeNode | null { } } +// ---- workspaces envelope --------------------------------------------------- + +/** One named tab in the tab strip. Each workspace owns its own tile tree; + * leaf NodeIds remain globally unique across workspaces so the app-level + * paneIdByLeaf map continues to work without partitioning. */ +export interface Workspace { + id: NodeId; + name: string; + tree: TreeNode; +} + +/** Top-level persistence shape. `version` bumps when the envelope schema + * changes; the v1 shape was a bare TreeNode at the JSON root, migrated + * automatically by {@link deserializeWorkspaces}. */ +export interface WorkspacesEnvelope { + version: 2; + workspaces: Workspace[]; +} + +export const WORKSPACES_VERSION = 2 as const; + +/** Construct an envelope wrapping a single workspace with the given tree. + * Used for first-launch and as the destination of the v1→v2 migration. */ +export function singletonEnvelope(tree: TreeNode, name = "Default"): WorkspacesEnvelope { + return { + version: WORKSPACES_VERSION, + workspaces: [{ id: newId(), name, tree }], + }; +} + +export function serializeWorkspaces(env: WorkspacesEnvelope): string { + return JSON.stringify(env); +} + +/** Parse a persisted workspaces envelope. Accepts: + * - Current shape: `{ version: 2, workspaces: [{ id, name, tree }] }` + * - Legacy v1 shape: a bare {@link TreeNode} — wrapped as one workspace + * named "Default" with a fresh id. + * Per-leaf legacy migrations ({@link migrateLegacyLeaves}) still apply to + * each workspace's tree. Returns null when the JSON is unrecognisable. */ +export function deserializeWorkspaces(json: string): WorkspacesEnvelope | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + + // v1: bare TreeNode at the root + if (isTreeNode(parsed)) { + return singletonEnvelope(migrateLegacyLeaves(parsed)); + } + + // v2: envelope + if ( + typeof parsed === "object" && + parsed !== null && + (parsed as { version?: unknown }).version === WORKSPACES_VERSION && + Array.isArray((parsed as { workspaces?: unknown }).workspaces) + ) { + const raw = (parsed as { workspaces: unknown[] }).workspaces; + const workspaces: Workspace[] = []; + for (const w of raw) { + if ( + typeof w !== "object" || + w === null || + typeof (w as { id?: unknown }).id !== "string" || + typeof (w as { name?: unknown }).name !== "string" || + !isTreeNode((w as { tree?: unknown }).tree) + ) { + continue; + } + const tw = w as { id: string; name: string; tree: TreeNode }; + workspaces.push({ + id: tw.id, + name: tw.name, + tree: migrateLegacyLeaves(tw.tree), + }); + } + if (workspaces.length === 0) return null; + return { version: WORKSPACES_VERSION, workspaces }; + } + + return null; +} + /** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */ const LEGACY_POWERSHELL_DISTRO = "PowerShell"; diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index b3692d1..3e0941b 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -30,6 +30,21 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ }, ], }, + { + title: "Tabs", + items: [ + { keys: "Ctrl+T", description: "New tab (blank workspace, one pane)" }, + { + keys: "Ctrl+Shift+T", + description: "Close current tab (confirms when the tab has live panes)", + }, + { + keys: "Ctrl+PageDown / Ctrl+PageUp", + description: "Switch to next / previous tab", + }, + { keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" }, + ], + }, { title: "Navigation", items: [ @@ -108,6 +123,10 @@ export const TIPS: TipSpec[] = [ title: "Workspace persistence", body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.", }, + { + title: "Tabs (workspaces)", + body: "Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.", + }, { title: "MCP server (let Claude drive the workspace)", body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.", From 8ad51787fcffa48eca2d536cfd012fadf77c0081 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 18:57:31 +0100 Subject: [PATCH 03/45] Phase 2: drag-/right-click-a-pane-to-new-window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click any pane's title bar → "Move to new window" pops it into a fresh tiletopia window with its PTY intact. Same Tauri process; the PtyManager is shared, so the existing PaneId stays valid and Tauri 2's process-wide event routing keeps pane://{id}/data flowing into the new window's XtermPane. Mechanism (Rust-side, plan-agent's main correction over my draft): - pty.rs: PtyManager.transferring is a per-pane refcount; kill_pane becomes a no-op while it's >0. Source window's React unmount calls kill_pane → silently dropped while in flight; target window's claim_pane decrements after it has subscribed. - window_state.rs: per-window workspaces snapshot map + debounced-by-tokio aggregate save. Each window pushes its tabs via push_window_workspaces; backend writes the merged { version: 2, workspaces: [...] } envelope. Non-main windows have their entries dropped on CloseRequested so closing a detached window discards its tabs (Chrome-style). - commands: mark_pane_transferring, claim_pane, get_pane_ring (base64 scrollback ring snapshot), create_pane_window, take_pending_window_init, push_window_workspaces. Frontend: - XtermPane gets `existingPaneId?: PaneId`: skip spawn, replay ring snapshot via term.write before attaching the live data listener, resize PTY to this window's grid, claim_pane. Scrollback replay was the plan agent's other ship-in-v1 call — without it a transferred Claude session looks blank until next prompt repaint. - LeafPane: onContextMenu opens a fixed-positioned "Move to new window" popover. Esc / outside-click dismiss. - orchestration adds moveToNewWindow + getInitialPaneIdFor; App owns a one-shot transferredPaneIdsRef cleared in registerPaneId. - App mount branches on getCurrentWebviewWindow().label: main loads workspace.json as before; non-main calls take_pending_window_init and builds a singleton workspace around the adopted leaf. - MCP mirror + onMcpRequest only run in main (paneIdByLeafRef is per- window; Claude sees the main window's current tab as the single workspace surface). pnpm check (tsc -b) clean. 79/79 vitest pass. Rust side authored in WSL; cargo build needs verification on Windows host before this is runnable. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 + src-tauri/src/commands.rs | 162 +++++++++++++++++++++++++- src-tauri/src/lib.rs | 33 ++++++ src-tauri/src/pty.rs | 39 +++++++ src-tauri/src/window_state.rs | 153 ++++++++++++++++++++++++ src/App.tsx | 192 +++++++++++++++++++++++++++---- src/components/XtermPane.tsx | 100 ++++++++++++---- src/ipc.ts | 47 ++++++++ src/lib/layout/LeafPane.css | 33 ++++++ src/lib/layout/LeafPane.tsx | 59 ++++++++++ src/lib/layout/orchestration.tsx | 11 ++ src/lib/shortcuts.ts | 10 ++ 12 files changed, 797 insertions(+), 48 deletions(-) create mode 100644 src-tauri/src/window_state.rs diff --git a/README.md b/README.md index 6f06417..d6e09c9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Ctrl+PageDown / Ctrl+PageUp` | Switch to next / previous tab | | `Ctrl+1 … Ctrl+9` | Switch to tab 1 … 9 | +**Multi-window** + +| Key | Action | +|---|---| +| `Right-click pane toolbar → Move to new window` | Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays) | + **Navigation** | Key | Action | diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dd21f6a..732ad35 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; use tokio::sync::RwLock; use crate::creds; @@ -11,6 +11,7 @@ use crate::hosts::{self, SshHost, SshHostView}; use crate::mcp::{self, McpMirror, McpServerHandle, McpState, PendingActions, RunningServer}; use crate::mcp_policy::McpPolicy; use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec}; +use crate::window_state::{PendingInit, PendingInits, WindowsState}; const WORKSPACE_FILE: &str = "workspace.json"; @@ -62,6 +63,165 @@ pub async fn kill_pane( manager.kill(id).map_err(|e| e.to_string()) } +/// Bump the per-pane "do not kill during transfer" refcount. Called by the +/// source window just before removing the leaf from its tree (which triggers +/// React to unmount XtermPane, which calls `kill_pane`). The kill is then a +/// no-op until {@link claim_pane} drops the refcount. +#[tauri::command] +pub async fn mark_pane_transferring( + manager: tauri::State<'_, Arc>, + id: PaneId, +) -> Result<(), String> { + manager.mark_transferring(id); + Ok(()) +} + +/// Drop the transfer refcount one. Called by the target window's XtermPane +/// mount once it has subscribed to the pane's events and replayed the +/// scrollback ring — at which point the PTY is safely "owned" by the +/// target. +#[tauri::command] +pub async fn claim_pane( + manager: tauri::State<'_, Arc>, + id: PaneId, +) -> Result<(), String> { + manager.claim(id); + Ok(()) +} + +/// Return the per-pane scrollback ring snapshot as base64. The target +/// window's XtermPane writes it into xterm.js BEFORE attaching the live +/// pane://{id}/data listener, so the user sees recent output (covers +/// "Claude is in the middle of a thought" — a transferred pane that's +/// idle shouldn't look blank). Bounded by PANE_RING_CAPACITY (~256 KiB). +#[tauri::command] +pub async fn get_pane_ring( + manager: tauri::State<'_, Arc>, + id: PaneId, +) -> Result { + let ring = manager + .ring(id) + .ok_or_else(|| format!("no pane with id {id}"))?; + let (bytes, _seq) = ring.lock().snapshot(); + Ok(B64.encode(&bytes)) +} + +/// Spawn a new app window and stash the pending-init payload keyed by the +/// new window's label. The target window pulls it via +/// {@link take_pending_window_init} during App mount. +/// +/// Returns the new window's label so the caller can correlate. +#[tauri::command] +pub async fn create_pane_window( + app: AppHandle, + pendings: tauri::State<'_, Arc>, + payload: PendingInit, +) -> Result { + // Generate a label that's deterministic-but-unique. Tauri requires + // labels to be ASCII-alphanumeric + dashes/underscores. + let label = format!( + "pane-window-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0) + ); + + // Stash BEFORE building the window — the target may finish bootstrapping + // and call take_pending_window_init before we return from build(). + pendings.by_label.lock().insert(label.clone(), payload); + + // Position the new window offset from the source's outer rect so it + // doesn't land exactly on top. If we can't query the source, fall back + // to the OS-default (center). + let (px, py, w, h) = source_window_geometry(&app); + + let mut builder = WebviewWindowBuilder::new( + &app, + label.clone(), + WebviewUrl::App("index.html".into()), + ) + .title("tiletopia") + .inner_size(w, h) + .min_inner_size(480.0, 320.0) + .resizable(true) + .decorations(true) + .visible(true); + if let (Some(x), Some(y)) = (px, py) { + builder = builder.position(x + 60.0, y + 60.0); + } else { + builder = builder.center(); + } + if let Err(e) = builder.build() { + // Clean up our pending entry so we don't leak it. + pendings.by_label.lock().remove(&label); + return Err(format!("create webview window: {e}")); + } + + Ok(label) +} + +/// Read and remove the pending-init for the current window. Returns None +/// when there is no pending payload (main window startup; window opened +/// without a transfer; second call after the first consumed it). +#[tauri::command] +pub async fn take_pending_window_init( + pendings: tauri::State<'_, Arc>, + label: String, +) -> Result, String> { + Ok(pendings.by_label.lock().remove(&label)) +} + +/// Push this window's workspaces snapshot to the backend aggregator. Called +/// every time the React state changes (debounced inside Rust); the next +/// debounce tick writes the aggregated envelope to disk. +/// +/// `workspaces_json` is the per-window list as JSON (an array of +/// `{ id, name, tree }` objects — matches the frontend's envelope.workspaces +/// shape). Stored as serde Values so this module doesn't need to know +/// anything about the tree shape. +#[tauri::command] +pub async fn push_window_workspaces( + app: AppHandle, + state: tauri::State<'_, Arc>, + label: String, + workspaces_json: String, +) -> Result<(), String> { + let parsed: serde_json::Value = serde_json::from_str(&workspaces_json) + .map_err(|e| format!("invalid workspaces JSON: {e}"))?; + let arr = parsed + .as_array() + .ok_or_else(|| "workspaces JSON must be an array".to_string())?; + let owned = arr.to_vec(); + let state_arc: Arc = (*state).clone(); + state_arc.push(app, label, owned); + Ok(()) +} + +/// Best-effort: read outer position + inner size of the main window so the +/// new window opens nearby instead of slamming the OS default. Returns +/// (Some(x), Some(y), w, h) when available; falls back to a reasonable +/// default size when the main window query fails. +fn source_window_geometry(app: &AppHandle) -> (Option, Option, f64, f64) { + // Try the focused window first, then fall back to the main one. + let win = app + .webview_windows() + .into_iter() + .find_map(|(_, w)| if w.is_focused().unwrap_or(false) { Some(w) } else { None }) + .or_else(|| app.get_webview_window("main")); + let Some(win) = win else { + return (None, None, 1100.0, 700.0); + }; + let pos = win.outer_position().ok(); + let size = win.inner_size().ok(); + let scale = win.scale_factor().unwrap_or(1.0); + let w = size.as_ref().map(|s| s.width as f64 / scale).unwrap_or(1100.0); + let h = size.as_ref().map(|s| s.height as f64 / scale).unwrap_or(700.0); + let px = pos.as_ref().map(|p| p.x as f64 / scale); + let py = pos.as_ref().map(|p| p.y as f64 / scale); + (px, py, w, h) +} + /// Write the workspace JSON to `%APPDATA%\com.megaproxy.tiletopia\workspace.json`. /// Writes to a `.tmp` and renames over the real file so a crash mid-write /// can't leave a partial file readable. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 40ec343..51e3053 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,11 +6,13 @@ mod hosts; mod mcp; mod mcp_policy; mod pty; +mod window_state; use std::sync::Arc; use crate::mcp::{McpServerHandle, McpState, PendingActions}; use crate::pty::PtyManager; +use crate::window_state::{PendingInits, WindowsState, MAIN_WINDOW_LABEL}; pub fn run() { let _ = tracing_subscriber::fmt() @@ -40,6 +42,15 @@ pub fn run() { // Pending action registry — separate managed state so mcp_action_reply can // grab it without needing to lock McpState or reach into TileService. let pending_actions: Arc = Arc::new(PendingActions::default()); + // Cross-window workspace aggregator: every window pushes its tab list + // here; backend debounces + writes the merged envelope to workspace.json. + let windows_state: Arc = Arc::new(WindowsState::default()); + // Pane-transfer pending-init registry: source window stashes a payload + // keyed by the new window's label; target window pulls it during mount. + let pending_inits: Arc = Arc::new(PendingInits::default()); + + let windows_state_for_event = Arc::clone(&windows_state); + let pending_inits_for_event = Arc::clone(&pending_inits); tauri::Builder::default() .plugin(tauri_plugin_clipboard_manager::init()) @@ -48,12 +59,34 @@ pub fn run() { .manage(mcp_state) .manage(McpServerHandle::default()) .manage(pending_actions) + .manage(windows_state) + .manage(pending_inits) + .on_window_event(move |window, event| { + // 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 + .forget(window.app_handle().clone(), &label); + } + } + }) .invoke_handler(tauri::generate_handler![ commands::list_distros, commands::spawn_pane, commands::write_to_pane, commands::resize_pane, commands::kill_pane, + commands::mark_pane_transferring, + commands::claim_pane, + commands::get_pane_ring, + commands::create_pane_window, + commands::take_pending_window_init, + commands::push_window_workspaces, commands::save_workspace, commands::load_workspace, commands::list_ssh_hosts, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 2f90930..c404fdf 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -109,6 +109,16 @@ struct PaneHandle { pub struct PtyManager { panes: Mutex>, next_id: AtomicU64, + /// Per-pane "this PTY is mid-transfer between windows; do not kill it + /// even if some window's XtermPane unmounts" refcount. Incremented by + /// {@link mark_transferring} when a transfer begins; decremented by + /// {@link claim} when the target window finishes mounting. While >0, + /// {@link kill} is a no-op for that id. + /// + /// Refcount (vs. plain flag) so concurrent transfers — or the rare + /// case where a transfer is retried before the previous one fully + /// releases — don't drop the suppression early. + transferring: Mutex>, } impl PtyManager { @@ -116,6 +126,27 @@ impl PtyManager { Self { panes: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + transferring: Mutex::new(HashMap::new()), + } + } + + /// Bump the transferring refcount for a pane. While >0, {@link kill} is + /// a no-op so the source window's React unmount-cleanup can't tear + /// down the PTY mid-transfer. + pub fn mark_transferring(&self, id: PaneId) { + *self.transferring.lock().entry(id).or_insert(0) += 1; + } + + /// Decrement the transferring refcount. When it reaches zero the entry + /// is removed and {@link kill} can act on this pane again. + pub fn claim(&self, id: PaneId) { + let mut map = self.transferring.lock(); + if let Some(rc) = map.get_mut(&id) { + if *rc > 1 { + *rc -= 1; + } else { + map.remove(&id); + } } } @@ -258,6 +289,14 @@ impl PtyManager { } pub fn kill(&self, id: PaneId) -> Result<()> { + // If a transfer is in flight for this pane, suppress the kill so + // the source window's unmount-cleanup can't race the target + // window's mount-claim. The target's claim() will decrement the + // refcount; the next caller of kill() (if any) will actually kill. + if self.transferring.lock().contains_key(&id) { + tracing::debug!("pty kill suppressed during transfer for pane {id}"); + return Ok(()); + } let mut panes = self.panes.lock(); if let Some(mut pane) = panes.remove(&id) { // Best-effort: ask the child to die. Dropping `master` after this diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs new file mode 100644 index 0000000..0529464 --- /dev/null +++ b/src-tauri/src/window_state.rs @@ -0,0 +1,153 @@ +//! Cross-window workspace state aggregator. +//! +//! Each window owns its own list of workspaces (tabs) in its React state. +//! When that list changes, the window calls `push_window_workspaces` to +//! ship a snapshot down here. This module merges every window's snapshot +//! into one envelope and persists it to `workspace.json` on a debounced +//! timer — same `{ version: 2, workspaces: [...] }` shape the frontend +//! reads at startup. +//! +//! The Rust side stays agnostic of the per-tree shape: workspaces are +//! stored as `serde_json::Value` so this module never needs to be updated +//! when LeafNode / SplitNode fields change. +//! +//! Lifetime of per-window entries: +//! - Created/updated on every `push_window_workspaces` call. +//! - The main window pushes initially after loading from disk; detached +//! windows push after takeing their pending-init payload. +//! - On detached-window close (handled in lib.rs), the entry is removed +//! so the next save doesn't resurrect tabs the user explicitly closed. +//! The main window's entry persists across the app lifetime. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use parking_lot::Mutex; +use serde_json::Value; +use tauri::{AppHandle, Manager}; +use tokio::task::JoinHandle; +use tokio::time::{sleep, Duration}; + +const WORKSPACE_FILE: &str = "workspace.json"; +const SAVE_DEBOUNCE: Duration = Duration::from_millis(500); + +/// The label of the main (boot) window. Matches `tauri.conf.json`'s +/// `windows[0].label`. Used to decide whether a window-close should +/// retain or discard that window's tabs. +pub const MAIN_WINDOW_LABEL: &str = "main"; + +#[derive(Default)] +pub struct WindowsState { + per_window: Mutex>>, + save_task: Mutex>>, +} + +impl WindowsState { + /// Replace this window's workspaces snapshot and schedule a debounced + /// save. Subsequent calls within the debounce window cancel the + /// previous save task — so a flurry of UI mutations only writes once. + pub fn push( + self: &Arc, + app: AppHandle, + label: String, + workspaces: Vec, + ) { + self.per_window.lock().insert(label, workspaces); + self.schedule_save(app); + } + + /// Drop a window's snapshot from the aggregate. Called on close of a + /// non-main window so its tabs don't reappear on next launch. + pub fn forget(self: &Arc, app: AppHandle, label: &str) { + let removed = self.per_window.lock().remove(label).is_some(); + if removed { + self.schedule_save(app); + } + } + + /// Build the on-disk envelope by concatenating every window's + /// workspaces in stable label order (main first when present, then + /// the rest sorted alphabetically by label — deterministic so the + /// file diff stays stable across no-op saves). + fn build_envelope(&self) -> Value { + let map = self.per_window.lock(); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort_by(|a, b| { + // main first, then alpha + match (a.as_str(), b.as_str()) { + (MAIN_WINDOW_LABEL, _) => std::cmp::Ordering::Less, + (_, MAIN_WINDOW_LABEL) => std::cmp::Ordering::Greater, + (x, y) => x.cmp(y), + } + }); + let mut workspaces: Vec = Vec::new(); + for k in keys { + if let Some(list) = map.get(k) { + for w in list { + workspaces.push(w.clone()); + } + } + } + serde_json::json!({ + "version": 2, + "workspaces": workspaces, + }) + } + + fn schedule_save(self: &Arc, app: AppHandle) { + let me = Arc::clone(self); + let mut slot = self.save_task.lock(); + if let Some(prev) = slot.take() { + prev.abort(); + } + let handle = tokio::spawn(async move { + sleep(SAVE_DEBOUNCE).await; + if let Err(e) = me.save_now(&app).await { + tracing::warn!("debounced workspace save failed: {e:#}"); + } + }); + *slot = Some(handle); + } + + async fn save_now(&self, app: &AppHandle) -> Result<()> { + let envelope = self.build_envelope(); + let json = serde_json::to_string(&envelope).context("serialize envelope")?; + let dir = app + .path() + .app_config_dir() + .map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?; + std::fs::create_dir_all(&dir).context("create_dir_all")?; + let path = dir.join(WORKSPACE_FILE); + let tmp = dir.join(format!("{WORKSPACE_FILE}.tmp")); + std::fs::write(&tmp, json.as_bytes()).context("write tmp")?; + std::fs::rename(&tmp, &path).context("rename tmp -> final")?; + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Pane-transfer pending-init registry +// --------------------------------------------------------------------------- + +/// Payload the source window stashes in the backend before opening a new +/// window; the target window pulls it during App mount via +/// `take_pending_window_init`. +/// +/// `leaf_json` and `workspace_name` are owned by the source — the backend +/// doesn't parse the leaf shape. `pane_id` is the existing PTY id the +/// target window's XtermPane should attach to (instead of spawning). +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct PendingInit { + #[serde(rename = "leafJson")] + pub leaf_json: String, + #[serde(rename = "paneId")] + pub pane_id: crate::pty::PaneId, + #[serde(rename = "workspaceName")] + pub workspace_name: String, +} + +#[derive(Default)] +pub struct PendingInits { + pub by_label: Mutex>, +} diff --git a/src/App.tsx b/src/App.tsx index f552e49..69a3113 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,11 @@ import { mcpPolicySave, writeToPane, killPane, + markPaneTransferring, + claimPane, + createPaneWindow, + takePendingWindowInit, + pushWindowWorkspaces, type PaneId, type SpawnSpec, type SshHost, @@ -29,6 +34,14 @@ import { type McpAuditEntry, } from "./ipc"; import { listen } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; + +const MAIN_WINDOW_LABEL = "main"; +/** Current window label, captured once at module load — used to decide + * load path (load_workspace vs take_pending_window_init) and to push + * this window's state to the cross-window aggregator. */ +const CURRENT_WINDOW_LABEL = getCurrentWebviewWindow().label; +const IS_MAIN_WINDOW = CURRENT_WINDOW_LABEL === MAIN_WINDOW_LABEL; import { type TreeNode, type NodeId, @@ -83,7 +96,6 @@ import "./App.css"; import "./lib/layout/Gutter.css"; const LEGACY_STORAGE_KEY = "tiletopia.tree.v1"; -const SAVE_DEBOUNCE_MS = 500; /** Picker default for *new* panes. SSH never lives here — SSH connections * are always explicit, never a default. */ @@ -220,6 +232,10 @@ export default function App() { // ---- non-reactive lookups ----------------------------------------------- const paneIdByLeafRef = useRef>(new Map()); const nextNotifIdRef = useRef(1); + /** Leaves that just arrived via a window transfer, mapped to the + * existing PaneId their XtermPane should adopt. One-shot: cleared in + * registerPaneId once the pane registers. */ + const transferredPaneIdsRef = useRef>(new Map()); const treeRef = useRef(tree); useEffect(() => { treeRef.current = tree; @@ -237,25 +253,62 @@ export default function App() { useEffect(() => { let cancelled = false; (async () => { - let loadedEnvelope: ReturnType = null; - try { - const json = await loadWorkspace(); - if (json) loadedEnvelope = deserializeWorkspaces(json); - } catch (e) { - console.warn("loadWorkspace failed:", e); - } - if (!loadedEnvelope) { + // First: is this a detached window with a pending transfer payload? + // Non-main windows ALWAYS go through this path (they never read + // workspace.json — only main owns it). A detached window with no + // pending init is the dev-reload / edge case; we boot with a blank + // default workspace. + let initialEnvelope: ReturnType = null; + let adoptedLeafId: NodeId | null = null; + + if (!IS_MAIN_WINDOW) { try { - const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); - if (legacy) { - loadedEnvelope = deserializeWorkspaces(legacy); - if (loadedEnvelope) { - void saveWorkspace(serializeWorkspaces(loadedEnvelope)); + const pending = await takePendingWindowInit(CURRENT_WINDOW_LABEL); + if (pending) { + try { + const adoptedLeaf = JSON.parse(pending.leafJson) as LeafNode; + if (adoptedLeaf && adoptedLeaf.kind === "leaf") { + transferredPaneIdsRef.current.set(adoptedLeaf.id, pending.paneId); + adoptedLeafId = adoptedLeaf.id; + initialEnvelope = { + version: 2, + workspaces: [ + { + id: newId(), + name: pending.workspaceName || "Detached", + tree: adoptedLeaf, + }, + ], + }; + } + } catch (e) { + console.warn("invalid pending leafJson:", e); } - localStorage.removeItem(LEGACY_STORAGE_KEY); } } catch (e) { - console.warn("legacy localStorage migration failed:", e); + console.warn("takePendingWindowInit failed:", e); + } + } else { + // Main window: load workspace.json (and legacy fallback). + try { + const json = await loadWorkspace(); + if (json) initialEnvelope = deserializeWorkspaces(json); + } catch (e) { + console.warn("loadWorkspace failed:", e); + } + if (!initialEnvelope) { + try { + const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); + if (legacy) { + initialEnvelope = deserializeWorkspaces(legacy); + if (initialEnvelope) { + void saveWorkspace(serializeWorkspaces(initialEnvelope)); + } + localStorage.removeItem(LEGACY_STORAGE_KEY); + } + } catch (e) { + console.warn("legacy localStorage migration failed:", e); + } } } @@ -283,7 +336,7 @@ export default function App() { if (cancelled) return; - let envelope = loadedEnvelope; + let envelope = initialEnvelope; if (!envelope) { envelope = singletonEnvelope( newLeaf(defaultShellAsLeafProps(initialDefault)), @@ -296,6 +349,13 @@ export default function App() { } setWorkspaces(envelope.workspaces); setCurrentWorkspaceId(envelope.workspaces[0].id); + if (adoptedLeafId) { + setActiveLeafByWorkspace((prev) => { + const m = new Map(prev); + m.set(envelope!.workspaces[0].id, adoptedLeafId); + return m; + }); + } setDistros(resolvedDistros); setHosts(resolvedHosts); setDefaultShell(initialDefault); @@ -306,15 +366,16 @@ export default function App() { }; }, []); - // ---- debounced save ------------------------------------------------------ + // ---- workspace sync to backend aggregator ------------------------------- + // Every window pushes its own workspaces snapshot; the backend merges + // across windows and debounces the actual workspace.json write (500ms + // tokio sleep inside Rust). This replaces the v0.3.0 per-window + // saveWorkspace path which would race when two windows wrote at once. useEffect(() => { if (!ready) return; - const id = window.setTimeout(() => { - saveWorkspace( - serializeWorkspaces({ version: 2, workspaces }), - ).catch((e) => console.warn("saveWorkspace failed:", e)); - }, SAVE_DEBOUNCE_MS); - return () => clearTimeout(id); + pushWindowWorkspaces(CURRENT_WINDOW_LABEL, JSON.stringify(workspaces)).catch( + (e) => console.warn("pushWindowWorkspaces failed:", e), + ); }, [workspaces, ready]); // ---- focus polling → setActive (xterm.js eats pointerdown) -------------- @@ -899,6 +960,9 @@ export default function App() { return; } paneIdByLeafRef.current.set(leafId, paneId); + // One-shot: now that the pane has registered, the transferred-id + // hint is consumed. + transferredPaneIdsRef.current.delete(leafId); const waiter = pendingPaneRegistrations.current.get(leafId); if (waiter) { pendingPaneRegistrations.current.delete(leafId); @@ -908,6 +972,71 @@ export default function App() { [], ); + const getInitialPaneIdFor = useCallback( + (leafId: NodeId): PaneId | undefined => + transferredPaneIdsRef.current.get(leafId), + [], + ); + + /** Pop the given leaf into a fresh top-level window. The source's + * XtermPane will unmount as the leaf leaves this window's tree; + * markPaneTransferring keeps the underlying PTY alive until the new + * window's XtermPane adopts it via existingPaneId. */ + const moveToNewWindow = useCallback( + async (leafId: NodeId) => { + const leaf = findLeaf(treeRef.current, leafId); + if (!leaf || leaf.kind !== "leaf") { + notify("Cannot move — pane not found"); + return; + } + const paneId = paneIdByLeafRef.current.get(leafId); + if (paneId == null) { + notify("Cannot move — PTY not ready yet"); + return; + } + + try { + await markPaneTransferring(paneId); + } catch (e) { + notify(`mark_pane_transferring failed: ${e}`); + return; + } + + // Snapshot the leaf BEFORE removing — closeLeaf may produce a tree + // where this leaf is no longer present, breaking findLeaf later. + const leafJson = JSON.stringify(leaf); + const workspaceName = leaf.label ?? `Pane ${paneId}`; + + // Remove from current tree (sibling promotes naturally via closeLeaf). + // If this leaf was the entire tree, fall back to a fresh default so + // the source workspace never becomes empty (matches close behavior). + setTree( + (t) => + closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)), + ); + paneIdByLeafRef.current.delete(leafId); + setActiveLeafByWorkspace((prev) => { + const wsId = currentWorkspaceIdRef.current; + if (!wsId) return prev; + if (prev.get(wsId) !== leafId) return prev; + const m = new Map(prev); + m.set(wsId, null); + return m; + }); + + try { + await createPaneWindow({ leafJson, paneId, workspaceName }); + } catch (e) { + notify(`Failed to open new window: ${e}`); + // The leaf is already gone from our tree and the PTY is orphaned + // in transferring state. Drop the refcount so a manual kill could + // eventually succeed; but the leaf no longer exists in any tree. + void claimPane(paneId).catch(() => {}); + } + }, + [defaultShell, notify], + ); + /** Insert a new leaf into the tree from a SpawnSpec — used by the MCP * spawn_pane and connect_host handlers. Returns the new leaf's id * (caller awaits waitForPaneRegistration on it for the paneId). @@ -1097,6 +1226,8 @@ export default function App() { setHeaderDragOver, endHeaderDrag, reportLeafIdle, + moveToNewWindow, + getInitialPaneIdFor, }), [ activeLeafId, @@ -1119,6 +1250,8 @@ export default function App() { setHeaderDragOver, endHeaderDrag, reportLeafIdle, + moveToNewWindow, + getInitialPaneIdFor, ], ); @@ -1126,11 +1259,18 @@ export default function App() { // Whenever the tree, hosts, or active selection change AND the MCP server // is running, push a fresh mirror down to the backend. Per-leaf mcpAllow // gates whether each leaf appears in the mirror (default-deny). + // + // Multi-window scoping: only the MAIN window pushes the mirror. Detached + // windows have their own current-workspace tree but Claude sees ONE + // workspace surface — main's current tab. Otherwise two windows would + // overwrite each other's mirrors on every keystroke and Claude's view + // would flap unpredictably. const allowedPaneCount = useMemo( () => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length, [tree], ); useEffect(() => { + if (!IS_MAIN_WINDOW) return; if (!mcpStatus.running) return; const leaves: Record = {}; for (const leaf of walkLeaves(tree)) { @@ -1590,6 +1730,10 @@ export default function App() { ); useEffect(() => { + // Only the main window handles MCP requests — paneIdByLeafRef is + // per-window so a request targeting a leaf in another window would + // fail anyway. Keeps responsibility clean: MCP sees main, period. + if (!IS_MAIN_WINDOW) return; let cancelled = false; let unlisten: (() => void) | undefined; void onMcpRequest(async (req: McpActionRequest) => { diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 3eee830..3b90015 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -15,6 +15,8 @@ import { killPane, onPaneData, onPaneExit, + getPaneRing, + claimPane, type PaneId, type SpawnSpec, } from "../ipc"; @@ -50,6 +52,12 @@ interface XtermPaneProps { * changing it later does NOT respawn — callers force a respawn by * changing the React `key` (see Pane.svelte / LeafPane). */ spec: SpawnSpec; + /** Attach to an existing PTY (transferred from another window) instead of + * spawning a new one. When set: spec is ignored at the spawn step, the + * scrollback ring is replayed into xterm.js, the live data listener is + * attached, and the transfer refcount is claimed (decremented) so the + * source window's killPane is no longer suppressed. */ + existingPaneId?: PaneId; onStatus?: (msg: string, ok: boolean) => void; /** Fired once when the backend PTY is alive and we have its PaneId. */ onSpawn?: (paneId: PaneId) => void; @@ -73,6 +81,7 @@ const DEFAULT_XTERM_FONT_SIZE = 13; export default function XtermPane({ spec, + existingPaneId, onStatus, onSpawn, onInput, @@ -153,33 +162,78 @@ export default function XtermPane({ const cols = term!.cols; const rows = term!.rows; - try { - paneId = await spawnPane({ spec, cols, rows }); - if (destroyed) { - void killPane(paneId); + if (existingPaneId != null) { + // Adoption path: a window-transfer landed us here with an existing + // PTY id. Don't spawn — replay the scrollback ring first (so the + // user sees recent output like a thinking Claude session), then + // attach the live listener, resize the PTY to this window's grid, + // and release the transfer-refcount. + paneId = existingPaneId; + paneIdRef.current = paneId; + onStatusRef.current?.(`pane ${paneId} adopted`, true); + onSpawnRef.current?.(paneId); + try { + const ringB64 = await getPaneRing(paneId); + if (destroyed) return; + if (ringB64) { + term?.write(b64ToBytes(ringB64)); + } + } catch (e) { + console.warn("getPaneRing failed:", e); + } + if (destroyed) return; + unlistenData = await onPaneData(paneId, (b64) => { + term?.write(b64ToBytes(b64)); + onDataReceivedRef.current?.(); + }); + if (destroyed) return; + unlistenExit = await onPaneExit(paneId, () => { + term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); + onStatusRef.current?.(`pane ${paneId} exited`, false); + }); + // Match the PTY to our cell grid (the source window may have had + // different dimensions). + try { + await resizePane(paneId, cols, rows); + } catch (e) { + console.warn("resizePane on adopt failed:", e); + } + // Release the transfer refcount so future killPane calls on this + // id are no longer suppressed. + try { + await claimPane(paneId); + } catch (e) { + console.warn("claimPane failed:", e); + } + } else { + try { + paneId = await spawnPane({ spec, cols, rows }); + if (destroyed) { + void killPane(paneId); + return; + } + paneIdRef.current = paneId; + onStatusRef.current?.(`pane ${paneId} alive`, true); + onSpawnRef.current?.(paneId); + } catch (e) { + if (destroyed) return; + const msg = `spawn_pane failed: ${e}`; + term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`); + onStatusRef.current?.(msg, false); return; } - paneIdRef.current = paneId; - onStatusRef.current?.(`pane ${paneId} alive`, true); - onSpawnRef.current?.(paneId); - } catch (e) { - if (destroyed) return; - const msg = `spawn_pane failed: ${e}`; - term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`); - onStatusRef.current?.(msg, false); - return; + + unlistenData = await onPaneData(paneId, (b64) => { + term?.write(b64ToBytes(b64)); + onDataReceivedRef.current?.(); + }); + + unlistenExit = await onPaneExit(paneId, () => { + term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); + onStatusRef.current?.(`pane ${paneId} exited`, false); + }); } - unlistenData = await onPaneData(paneId, (b64) => { - term?.write(b64ToBytes(b64)); - onDataReceivedRef.current?.(); - }); - - unlistenExit = await onPaneExit(paneId, () => { - term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); - onStatusRef.current?.(`pane ${paneId} exited`, false); - }); - term?.onData((data) => { if (paneId == null) return; const b64 = stringToB64(data); diff --git a/src/ipc.ts b/src/ipc.ts index e1d48c8..6660ed8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -53,6 +53,53 @@ export const resizePane = (id: PaneId, cols: number, rows: number): Promise => invoke("kill_pane", { id }); +/** Increment the "do not kill" transfer refcount for a pane. Source window + * calls this BEFORE removing the leaf from its tree so the unmount-driven + * kill_pane on the source becomes a no-op until the target window's + * XtermPane has claimed it. */ +export const markPaneTransferring = (id: PaneId): Promise => + invoke("mark_pane_transferring", { id }); + +/** Decrement the transfer refcount. Target window's XtermPane calls this + * after subscribing to pane://{id}/data and replaying the ring snapshot. */ +export const claimPane = (id: PaneId): Promise => + invoke("claim_pane", { id }); + +/** Snapshot of the per-pane scrollback ring as base64. Target window's + * XtermPane writes it into xterm.js before attaching the live data + * listener so a transferred pane doesn't open blank. */ +export const getPaneRing = (id: PaneId): Promise => + invoke("get_pane_ring", { id }); + +// ---- multi-window pane transfer ------------------------------------------- + +export interface PendingInit { + leafJson: string; + paneId: PaneId; + workspaceName: string; +} + +/** Open a new window and stash the pending-init payload keyed by the new + * window's label. Returns the new label. */ +export const createPaneWindow = (payload: PendingInit): Promise => + invoke("create_pane_window", { payload }); + +/** Read and remove the pending-init for the current window. Null when there + * is no pending payload (main window startup, or this call already + * consumed it). */ +export const takePendingWindowInit = ( + label: string, +): Promise => + invoke("take_pending_window_init", { label }); + +/** Push this window's workspaces snapshot to the backend aggregator. The + * backend debounces and writes the merged envelope to workspace.json. */ +export const pushWindowWorkspaces = ( + label: string, + workspacesJson: string, +): Promise => + invoke("push_window_workspaces", { label, workspacesJson }); + export const onPaneData = ( id: PaneId, cb: (b64: string) => void, diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index 97e6074..f5ff85f 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -269,3 +269,36 @@ min-height: 0; position: relative; } + +/* Right-click context menu on the pane toolbar. Fixed-positioned popover + floating in the viewport; the LeafPane parent renders it inside its + own DOM tree so clicks within the menu still get the + stop-propagation chain. */ +.pane-context-menu { + z-index: 200; + min-width: 180px; + background: #1a1a1a; + color: #e6e6e6; + border: 1px solid #2a5a8c; + border-radius: 4px; + padding: 4px; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; + font-size: 12px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6); +} +.pane-context-menu-item { + display: block; + width: 100%; + text-align: left; + background: transparent; + color: #e6e6e6; + border: none; + border-radius: 2px; + padding: 6px 10px; + font: inherit; + cursor: pointer; +} +.pane-context-menu-item:hover { + background: #2a5a8c; + color: #fff; +} diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index d02f13f..ef1f8c2 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -185,6 +185,38 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { setStatusOk(ok); }, []); + // ---- right-click context menu ------------------------------------------ + // Single entry in v1: "Move to new window" (pops the pane out into a + // fresh top-level tiletopia window without losing the PTY). + const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); + const openContextMenu = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setMenuPos({ x: e.clientX, y: e.clientY }); + }, + [], + ); + const closeContextMenu = useCallback(() => setMenuPos(null), []); + useEffect(() => { + if (!menuPos) return; + const onDocClick = () => setMenuPos(null); + const onEsc = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape") setMenuPos(null); + }; + // Defer attaching the click listener so the click that opened the menu + // doesn't immediately close it. + const t = window.setTimeout(() => { + window.addEventListener("click", onDocClick); + window.addEventListener("keydown", onEsc, true); + }, 0); + return () => { + clearTimeout(t); + window.removeEventListener("click", onDocClick); + window.removeEventListener("keydown", onEsc, true); + }; + }, [menuPos]); + // ---- header-drag swap --------------------------------------------------- // Drag the toolbar onto another pane's toolbar/body to swap their tree // positions. Uses a movement threshold so accidental tiny moves while @@ -306,6 +338,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { onPointerMove={onToolbarPointerMove} onPointerUp={onToolbarPointerUp} onPointerCancel={onToolbarPointerCancel} + onContextMenu={openContextMenu} > {editingLabel ? ( )} + {menuPos && ( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + +
+ )} ); } diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index 754c9b7..cd381ff 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -56,6 +56,17 @@ export interface Orchestration { // own quiet-state crosses the threshold; App aggregates so the titlebar // can show an "N idle" count without spamming toast notifications. reportLeafIdle: (leafId: NodeId, idle: boolean) => void; + + // Multi-window pane transfer --------------------------------------------- + /** Pop a pane out of the current workspace into a fresh top-level window. + * The PTY stays alive across the move (the new window's XtermPane + * adopts the existing PaneId; scrollback ring is replayed). */ + moveToNewWindow: (leafId: NodeId) => 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 + * registered. */ + getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined; } const OrchestrationContext = createContext(null); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 3e0941b..0222f14 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -45,6 +45,16 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ { keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" }, ], }, + { + title: "Multi-window", + items: [ + { + keys: "Right-click pane toolbar → Move to new window", + description: + "Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays)", + }, + ], + }, { title: "Navigation", items: [ From 6faf7e5e19eb879a35744ac979371eec3cdee5b1 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 18:59:48 +0100 Subject: [PATCH 04/45] Phase 3: drag pane past window edge to detach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing header-drag gesture (which swaps panes inside the window) with an "outside the window" case: release the drag more than 60px past any viewport edge and the pane detaches into a new window via the same moveToNewWindow path the right-click menu uses. The 60px slop avoids triggering on accidental release over the OS titlebar / window chrome — without it any drag that ended above clientY=0 would fire as a detach, which is wrong because that area is still inside the user's window. No backend changes — Phase 2's transfer mechanism already handles everything; this just wires a second entry point. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 ++- src/lib/layout/LeafPane.tsx | 26 +++++++++++++++++++++++--- src/lib/shortcuts.ts | 9 +++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d6e09c9..c3038c5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | Key | Action | |---|---| | `Right-click pane toolbar → Move to new window` | Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays) | +| `Drag pane toolbar past the window edge` | Same as the right-click action — release the drag well outside the window to detach into a new window | **Navigation** @@ -92,7 +93,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil - **SSH host manager** — Titlebar 🔑 SSH hosts opens the manager. Add hostname / user / port / identity file / jump host / extra ssh args. Saved hosts appear in every pane's dropdown. - **Saved passwords** — Optionally save a host's password — stored in Windows Credential Manager (DPAPI-encrypted), never written to hosts.json. When ssh prompts on connect it's typed automatically. Hosts with a saved password show 🔒 in the list. - **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click. -- **Drag pane headers to swap** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard. +- **Drag pane headers to swap or detach** — Grab a pane's title bar and drag onto another pane to swap their tree positions. Drag well outside the window edge (more than ~60px past) and release to detach the pane into a new window — same mechanism as the right-click 'Move to new window' action, PTY stays alive. - **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch. - **Tabs (workspaces)** — Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json. - **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index ef1f8c2..7fd9ee7 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -268,6 +268,12 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { [orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id], ); + /** How far past a viewport edge the cursor must travel before a release + * is treated as "drag pane out of window" instead of "drop on empty + * space inside this window". Picked so an accidental release on the OS + * titlebar (~30px tall) stays inside the threshold. */ + const PANE_DRAG_OUT_MARGIN = 60; + const onToolbarPointerUp = useCallback( (e: ReactPointerEvent) => { const st = dragStartRef.current; @@ -275,12 +281,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); const wasDragging = st.dragging; dragStartRef.current = null; - if (wasDragging) { - document.body.style.cursor = ""; + if (!wasDragging) return; + document.body.style.cursor = ""; + + const releasedFarOutside = + e.clientX < -PANE_DRAG_OUT_MARGIN || + e.clientX > window.innerWidth + PANE_DRAG_OUT_MARGIN || + e.clientY < -PANE_DRAG_OUT_MARGIN || + e.clientY > window.innerHeight + PANE_DRAG_OUT_MARGIN; + + if (releasedFarOutside) { + // Cancel any in-flight swap state without committing, then pop + // this pane into a fresh window. moveToNewWindow handles the + // PTY-handoff + closeLeaf in the source. + orch.endHeaderDrag(false); + orch.moveToNewWindow(leaf.id); + } else { orch.endHeaderDrag(true); } }, - [orch.endHeaderDrag], + [orch.endHeaderDrag, orch.moveToNewWindow, leaf.id], ); const onToolbarPointerCancel = useCallback( diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 0222f14..5b1b789 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -53,6 +53,11 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ description: "Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays)", }, + { + keys: "Drag pane toolbar past the window edge", + description: + "Same as the right-click action — release the drag well outside the window to detach into a new window", + }, ], }, { @@ -126,8 +131,8 @@ export const TIPS: TipSpec[] = [ body: "http and https URLs in terminal output get underlined and open in your default browser on click.", }, { - title: "Drag pane headers to swap", - body: "Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard.", + title: "Drag pane headers to swap or detach", + body: "Grab a pane's title bar and drag onto another pane to swap their tree positions. Drag well outside the window edge (more than ~60px past) and release to detach the pane into a new window — same mechanism as the right-click 'Move to new window' action, PTY stays alive.", }, { title: "Workspace persistence", From 597f9ac9b702cf3f5d3a74ea80b32fd1f57050ed Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 19:01:26 +0100 Subject: [PATCH 05/45] Session log: tabs + multi-window pane transfer (3 phases) Documents architecture (Rust-side transferring refcount; backend-aggregated save; scrollback ring replay), the load-bearing Tauri facts (process-wide event routing, shared PtyManager), and the verification steps still needed on the Windows host. Co-Authored-By: Claude Opus 4.7 (1M context) --- memory.md | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/memory.md b/memory.md index f22704f..892abc2 100644 --- a/memory.md +++ b/memory.md @@ -34,7 +34,7 @@ Durable memory for this project. Read at session start, update before session en - [ ] **Configurable idle threshold.** Hardcoded 5000ms in `LeafPane.svelte`. Should move into a settings panel; M5 territory. - [x] ~~**Logic tests for `tree.ts`.**~~ Vitest, 43 cases, runs via `pnpm test`. Done 2026-05-22. - [ ] **Component-level tests** (vitest + jsdom + @testing-library/svelte) — would have caught the M4 active-border reactivity bug. Useful when the Svelte component surface stops being trivial; defer until/unless something else goes sideways. -- [ ] **Multi-workspace tabs.** Several independent layouts the user can switch between. Saved as `workspaces.json` with `{ current: id, list: [{ id, name, tree }] }`. Not on the M0–M5 critical path; either bolt on after M5 ship or fold into a "tabs" minor milestone. +- [x] ~~**Multi-workspace tabs.**~~ Done 2026-05-28. Implementation lives under "Tabs + multi-window pane transfer" session log. Envelope shape ended up as `{ version: 2, workspaces: [{ id, name, tree }] }` (no separate `current` field — per-window in React state only). - [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. @@ -52,6 +52,58 @@ Durable memory for this project. Read at session start, update before session en ## Session log +### 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.** + +**Phase 1 — tabbed workspaces.** Tab strip above the existing pane area; each tab owns an independent tile tree. + +- **Persistence shape:** workspace.json migrated from bare `TreeNode` to `{ version: 2, workspaces: [{ id, name, tree }] }`. Legacy v1 is auto-detected in `deserializeWorkspaces` and wrapped as `[{ name: "Default", tree: }]`. Per-leaf `migrateLegacyLeaves` (PowerShell sentinel etc.) still applies per-tree. +- **PTYs survive tab switches via render-all-panes.** Every workspace's panes mount at once; inactive workspace layers use `visibility: hidden; pointer-events: none; z-index: 0` while keeping `position: absolute; inset: 0`. `visibility: hidden` (vs `display: none`) preserves the container's bounding rect so xterm.js's fit() reads valid dims; the existing per-pane resize dedupe in XtermPane (`lastSentCols/Rows` check) absorbs no-op SIGWINCHes. +- **`tree` / `setTree` kept as identity-stable derived wrappers** that read `currentWorkspaceIdRef.current`. Means the bulk of App.tsx didn't change despite the state model shift. Same trick for `activeLeafId` / `setActiveLeafId` — backed by `activeLeafByWorkspace: Map` so each tab remembers its own focus. +- **Hidden-tab focus guard (plan-agent catch).** XtermPane's mount-time `term.focus()` would yank focus into hidden tabs on app boot. Guarded with `getComputedStyle(container).visibility !== "hidden"`. CSS visibility is inherited, so the computed value on the container reflects the workspace-layer's setting. Focus poller in App.tsx:223 also scoped to the active workspace layer via `data-workspace-id` ancestor check. +- **Shortcuts:** Ctrl+T new tab, Ctrl+Shift+T close (window.confirm when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9 switch. shortcuts.ts is SoT; README + Help auto-regenerate via `pnpm gen:readme`. +- **Tab close confirm is inline popover** anchored to the X button (per plan-agent: not modal-queue style — close is user-initiated, not a stream of unsolicited prompts like MCP). + +**Phase 2 — multi-window pane transfer.** Right-click pane toolbar → "Move to new window" pops the pane into a fresh tiletopia window with its PTY intact. New window is a full peer with its own tab strip. + +- **The load-bearing facts** (verified by reading pty.rs / lib.rs / ipc.ts): + 1. `PaneId = u64`, never reused, sequence-assigned. Stable across windows. + 2. `pane://{id}/data` events go through `AppHandle::emit` — Tauri 2 event system is **process-wide**, so any window that `listen()`s on the same id gets the same stream. + 3. `PtyManager` lives in `Arc<>` managed state; one process, one manager, every window shares it. +- **Transfer-suppression: Rust-side refcount, NOT a JS module Set.** `PtyManager.transferring: Mutex>`. `kill_pane` becomes a no-op while refcount > 0. Source window's unmount calls `kill_pane` → silently dropped; target window's `claim_pane` decrements after subscribing. The JS-side "in-flight set" the plan-agent vetoed would have raced cross-window React event loops. +- **Scrollback replay shipped in v1** (plan-agent's other ship-in-v1 call). `get_pane_ring(id) -> base64` returns the existing PaneRing snapshot (256 KiB ≈ 3000 lines @ 80 cols). New window's XtermPane writes the ring to xterm.js BEFORE attaching the live `onPaneData` listener. Without this, a transferred Claude session looks blank until the next prompt repaint. +- **Cross-window save coordination via backend aggregator** (plan-agent's third correction). Each window debouncing its own write to workspace.json would race. New `window_state.rs`: `WindowsState { per_window: Mutex>>, save_task: Mutex> }`. Frontends call `push_window_workspaces(label, json)`; backend stores per-window, debounces save with a 500ms tokio sleep, atomic-writes the merged `{ version: 2, workspaces: [] }`. **Workspaces stored as `serde_json::Value`** — backend stays agnostic of tree shape across future LeafNode changes. +- **Non-main window close drops its entry** via `Tauri::WindowEvent::CloseRequested` in lib.rs `on_window_event`. Matches Chrome-style "closing a detached window discards its tabs". Main window's entry persists across the app lifetime so on next launch all of main's tabs reopen. +- **MCP scoped to main window only.** Both the mirror push and `onMcpRequest` subscription gated on `IS_MAIN_WINDOW = getCurrentWebviewWindow().label === "main"`. `paneIdByLeafRef` is per-window, so a request targeting a leaf in another window would fail to resolve anyway. Documented as "MCP sees main's current tab" — future extension could expose `list_windows()` / `switch_window()` MCP tools. + +**Phase 3 — drag-out gesture.** Extended the existing pointer-drag for header swap: release more than 60px past any viewport edge → drag-out via the same `moveToNewWindow` path. The 60px margin avoids triggering on accidental release over the OS titlebar (~30px). No backend changes — just a second entry point into Phase 2's mechanism. + +**Architecture artefacts worth remembering:** + +- **`getCurrentWebviewWindow().label`** is sync-available at module-load time (not async!) — captured into module-level `CURRENT_WINDOW_LABEL` and `IS_MAIN_WINDOW` constants. Cleaner than `useEffect`-awaiting it. +- **`transferredPaneIdsRef: Map`** is a one-shot side channel populated BEFORE `setWorkspaces` during mount, consumed in `registerPaneId`. LeafPane reads it via `orch.getInitialPaneIdFor(leaf.id)` and passes `existingPaneId` to XtermPane to skip spawn. Cleaner than threading the id through LeafNode (which is persisted state). +- **`WindowEvent::CloseRequested` closure captures `Arc` and `Arc` by move.** `windows_state_for_event.forget(label)` is the cleanup path; `pending_inits_for_event.by_label.lock().remove(&label)` removes any unconsumed init payload (the consumed-then-window-died case). + +**Phase 2 verification needed** (user, on Windows host): +1. `cd D:\dev\tiletopia && cargo check` — the Rust changes have to compile. Watch for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc` method receiver style on WindowsState. +2. `pnpm tauri dev` — smoke test: + - Existing workspace loads as one tab named "Default" ✓ migrate + - Ctrl+T spawns new tab with default-shell pane + - Switch tabs while a `sleep 60` is running in another tab — countdown continues + - Right-click any pane → "Move to new window" → new window appears with the pane, PTY content visible (ring replay) + - Resize new window → `tput cols` in the moved pane shows new dims + - Close new window → reopen the app → those tabs should NOT come back (the close-discards-tabs Chrome behavior) + - With MCP running, `list_panes` from Claude only sees main's current tab + +**Known follow-ups specific to this session** (none ship-blocking; all v0.4.0+ territory): + +- **Per-tab MCP visibility.** Today Claude only sees main's current tab; switching tabs in main changes Claude's view mid-conversation. Could expose `list_workspaces()` + `switch_workspace(id)` MCP tools. Defer until requested. +- **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. +- **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** Cut after a marathon session that took MCP from read-only v1 → full write surface + policy engine + audit + safeguards + .mcpb bundle. Tag `v0.3.0`, both `tiletopia_0.3.0_x64-setup.exe` and `tiletopia.mcpb` attached. From 681d15fdc33c74aac08b2aee1b5966265ef24fce Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 19:05:29 +0100 Subject: [PATCH 06/45] memory.md: fix Phase 2 verify command (cargo from src-tauri/, not root) Tauri keeps the crate in src-tauri/; cargo check from the project root fails with "could not find Cargo.toml". Caught by the user after I suggested the wrong cd. Added a preflight-checks rule to global ~/claude/CLAUDE.md so this generalises. Co-Authored-By: Claude Opus 4.7 (1M context) --- memory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory.md b/memory.md index 892abc2..6b6561e 100644 --- a/memory.md +++ b/memory.md @@ -86,7 +86,7 @@ Two big features the user asked for in one session. Three commits on `main`: `1a - **`WindowEvent::CloseRequested` closure captures `Arc` and `Arc` by move.** `windows_state_for_event.forget(label)` is the cleanup path; `pending_inits_for_event.by_label.lock().remove(&label)` removes any unconsumed init payload (the consumed-then-window-died case). **Phase 2 verification needed** (user, on Windows host): -1. `cd D:\dev\tiletopia && cargo check` — the Rust changes have to compile. Watch for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc` method receiver style on WindowsState. +1. `cd D:\dev\tiletopia\src-tauri && cargo check` — the Rust changes have to compile. **Note: `Cargo.toml` lives in `src-tauri/`, NOT the project root** (Tauri layout). I got this wrong in the original verification steps; user had to point it out. Added a preflight-checks rule to global `~/claude/CLAUDE.md`. Watch in the check output for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc` method receiver style on WindowsState. 2. `pnpm tauri dev` — smoke test: - Existing workspace loads as one tab named "Default" ✓ migrate - Ctrl+T spawns new tab with default-shell pane From bea6cf2977654bb2e68c1e2438ad3bbc19c9a1f0 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 19:46:30 +0100 Subject: [PATCH 07/45] Fix detached-window IPC scoping and pane-transfer session loss - capabilities/default.json: extend window scope to "pane-window-*" so detached windows can invoke/listen (fixes blank panes B2-B5). - App.tsx: memoize the destructive take_pending_window_init read at module scope so React StrictMode's double mount-effect doesn't consume the transfer payload twice and lose the adopted PTY session. - lib.rs: add `use tauri::Manager;` for Window::app_handle() in on_window_event. Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 31 +++++++++++++++++++++++++++++ src-tauri/capabilities/default.json | 2 +- src-tauri/src/lib.rs | 5 +++++ src/App.tsx | 18 ++++++++++++++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/memory.md b/memory.md index 6b6561e..5761c25 100644 --- a/memory.md +++ b/memory.md @@ -87,6 +87,37 @@ Two big features the user asked for in one session. Three commits on `main`: `1a **Phase 2 verification needed** (user, on Windows host): 1. `cd D:\dev\tiletopia\src-tauri && cargo check` — the Rust changes have to compile. **Note: `Cargo.toml` lives in `src-tauri/`, NOT the project root** (Tauri layout). I got this wrong in the original verification steps; user had to point it out. Added a preflight-checks rule to global `~/claude/CLAUDE.md`. Watch in the check output for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc` method receiver style on WindowsState. + +**Uncommitted local fix (as of 2026-05-28 wrap-up):** + +`src-tauri/src/lib.rs` has an added `use tauri::Manager;` import — needed because `Window::app_handle()` is a trait method (Manager trait) used in the new `on_window_event` handler. Same pattern as the `Emitter` trait stumble in v0.3.0. Cargo check went clean after this. **Not committed yet** — user wanted to smoke-test the feature first, then found the bug list below. Commit this fix at the same time as the bug-fix commit. + +**Detached-window bug list (deferred — user will resume):** + +Smoke test on Windows revealed bugs specific to detached (non-main) windows. Main window is unaffected. + +- **B1** — Drag-out has no ghost image during drag (cosmetic, user OK with deferring). +- **B2** — Detached window: transferred pane is blank, "idle" within 5s. No input, no output. +- **B3** — Detached window: shell-picker swap (Ubuntu → PowerShell → Ubuntu) doesn't spawn a working terminal. Fresh `spawn_pane` call from the detached window — toolbar updates but no PTY output. +- **B4** — Detached window: new tab (Ctrl+T or + button) creates the tab but no terminal. Same blank/idle symptom. +- **B5** — Right-click "Move to new window" produces the same broken detached window as drag-out. Confirms the bug is detached-window-scoped, not gesture-scoped. +- **B6** (control) — Main window: new tab, new pane, normal ops all work. + +**Strongest single hypothesis** for B2–B5: **Tauri 2's capability system gates `invoke` and `listen` per window-label.** Default capability config in `src-tauri/capabilities/default.json` (or similar) usually scopes to `"windows": ["main"]`. Newly-built `pane-window-*` labels match nothing → all IPC and events silently fail. One config fix (add wildcard window pattern, or programmatically attach a capability to each new window before `.build()`) would explain ALL of B2-B5 in one go. + +**Where to look first when resuming:** +1. `src-tauri/capabilities/*.json` — read the existing capability config to confirm scoping. +2. Try `"windows": ["main", "pane-window-*"]` (Tauri 2 supports glob patterns in capability window targets). +3. If that doesn't work: `AppHandle::add_capability(...)` on the new window before `.build()` in `commands.rs::create_pane_window`. +4. Verify by re-testing B4 first (simplest: fresh new tab in a detached window — needs only `invoke("spawn_pane")` and `listen("pane://...")` to work). + +**RESOLVED 2026-05-28 (resume session) — two root causes, both fixed:** + +- **B2–B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-` (commands.rs:122) → matched nothing → every `invoke`/`listen` silently denied. Fix: `"windows": ["main", "pane-window-*"]`. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is *applied* to, i.e. listed in `windows`.) +- **Session-loss-on-adopt (surfaced after B2–B5 cleared) = destructive read × StrictMode.** Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status `alive` not `adopted`) instead of adopting. Cause: `take_pending_window_init` is a **destructive** backend read (`by_label.remove`); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on the `cancelled` flag, pass 2 got `null` → fell back to `singletonEnvelope` (fresh "Default" + fresh spawn). The `cancelled`-flag pattern guards against *using* stale async results but cannot un-consume a destructive backend call. Fix: module-level memoized `consumePendingWindowInit()` in App.tsx so the take fires **exactly once per window** and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. **Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded by `cancelled`.** +- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean. B1 (drag ghost image) still deferred — cosmetic. +- Committed together with the carried-over `use tauri::Manager;` lib.rs import. + 2. `pnpm tauri dev` — smoke test: - Existing workspace loads as one tab named "Default" ✓ migrate - Ctrl+T spawns new tab with default-shell pane diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0b5585b..144512e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capability set for wsl-mux spike", - "windows": ["main"], + "windows": ["main", "pane-window-*"], "permissions": [ "core:default", "core:event:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 51e3053..3a88bac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,11 @@ mod window_state; use std::sync::Arc; +// `Manager` trait must be in scope to call `.app_handle()` on the `&Window` +// passed to the `on_window_event` closure below. Same pattern as the +// `Emitter` trait needed for `.emit()` (see 2026-05-26 PR-1 session log). +use tauri::Manager; + use crate::mcp::{McpServerHandle, McpState, PendingActions}; use crate::pty::PtyManager; use crate::window_state::{PendingInits, WindowsState, MAIN_WINDOW_LABEL}; diff --git a/src/App.tsx b/src/App.tsx index 69a3113..484c02a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,22 @@ const MAIN_WINDOW_LABEL = "main"; * this window's state to the cross-window aggregator. */ const CURRENT_WINDOW_LABEL = getCurrentWebviewWindow().label; const IS_MAIN_WINDOW = CURRENT_WINDOW_LABEL === MAIN_WINDOW_LABEL; + +/** `take_pending_window_init` is a DESTRUCTIVE backend read (it removes the + * entry). React StrictMode runs the mount effect twice in dev, so a plain + * call would consume the payload on the first (cancelled) pass and hand the + * second pass `null` — booting a fresh "Default" workspace and spawning a new + * PTY instead of adopting the transferred one (session lost). Memoize the + * promise at module scope so the backend take happens exactly once per window + * and every effect pass awaits the same result. */ +let pendingInitOnce: Promise>> | null = + null; +const consumePendingWindowInit = () => { + if (!pendingInitOnce) { + pendingInitOnce = takePendingWindowInit(CURRENT_WINDOW_LABEL); + } + return pendingInitOnce; +}; import { type TreeNode, type NodeId, @@ -263,7 +279,7 @@ export default function App() { if (!IS_MAIN_WINDOW) { try { - const pending = await takePendingWindowInit(CURRENT_WINDOW_LABEL); + const pending = await consumePendingWindowInit(); if (pending) { try { const adoptedLeaf = JSON.parse(pending.leafJson) as LeafNode; From e6d004002106c2014ff34c72b21c4181d5a246ee Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 20:24:09 +0100 Subject: [PATCH 08/45] Fix workspace accumulation, tab-close popover, scrollbars, drag ghost - window_state.rs: persist only the main window's workspaces. The aggregator flattened every window's tabs into the saved file; main then adopted the whole blob on launch, so detached windows' ephemeral tabs (and Pane N drag-out artifacts) accumulated without bound. - TabStrip: portal the close-confirm popover to with fixed, viewport-clamped positioning so the horizontally-scrolling strip can't clip it and it never runs off a window edge. - styles.css: make themed ::-webkit-scrollbar global, not just xterm viewport. - LeafPane: B1 drag-out ghost chip (portal, edge-pinned, orange detach state). - App.tsx: moveToNewWindow waits briefly for pane registration instead of failing instantly on an in-flight spawn/adopt. - gitignore cargo-test.lo*. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 +- memory.md | 13 ++++- src-tauri/src/window_state.rs | 33 ++++------- src/App.tsx | 15 ++++- src/components/TabStrip.css | 23 ++++---- src/components/TabStrip.tsx | 103 ++++++++++++++++++++++------------ src/lib/layout/LeafPane.css | 37 ++++++++++++ src/lib/layout/LeafPane.tsx | 76 +++++++++++++++++++++---- src/styles.css | 17 +++--- 9 files changed, 224 insertions(+), 95 deletions(-) diff --git a/.gitignore b/.gitignore index 81ba218..fa15af7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ src-tauri/gen/ /shot*.png /tiletopia-window.png /tilescript.ps1 -/cargo-test.log +/cargo-test.lo* diff --git a/memory.md b/memory.md index 5761c25..94ec740 100644 --- a/memory.md +++ b/memory.md @@ -115,8 +115,17 @@ Smoke test on Windows revealed bugs specific to detached (non-main) windows. Mai - **B2–B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-` (commands.rs:122) → matched nothing → every `invoke`/`listen` silently denied. Fix: `"windows": ["main", "pane-window-*"]`. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is *applied* to, i.e. listed in `windows`.) - **Session-loss-on-adopt (surfaced after B2–B5 cleared) = destructive read × StrictMode.** Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status `alive` not `adopted`) instead of adopting. Cause: `take_pending_window_init` is a **destructive** backend read (`by_label.remove`); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on the `cancelled` flag, pass 2 got `null` → fell back to `singletonEnvelope` (fresh "Default" + fresh spawn). The `cancelled`-flag pattern guards against *using* stale async results but cannot un-consume a destructive backend call. Fix: module-level memoized `consumePendingWindowInit()` in App.tsx so the take fires **exactly once per window** and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. **Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded by `cancelled`.** -- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean. B1 (drag ghost image) still deferred — cosmetic. -- Committed together with the carried-over `use tauri::Manager;` lib.rs import. +- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean. +- Committed (`bea6cf2`) together with the carried-over `use tauri::Manager;` lib.rs import. + +**Follow-on fixes same session (commit after `bea6cf2`):** + +- **B1 drag ghost (done).** Cursor-following chip via `createPortal` in LeafPane, `pointer-events:none` so it doesn't disturb the `elementFromPoint` drop-target hit-test. Turns orange "↗ New window" past the 60px edge margin. A webview **can't paint outside its own OS window**, so the chip is clamped to the viewport edge and flips to the cursor's inner side near right/bottom rather than vanishing — that's the best achievable; a ghost floating over the desktop is impossible. Hoisted `PANE_DRAG_OUT_MARGIN` + `isFarOutsideViewport()` to module scope so move-handler (preview) and up-handler (release) can't drift. +- **Drag-out "PTY not ready" (mitigated).** `moveToNewWindow` now `await waitForPaneRegistration(leafId, 5000)` instead of failing instantly when the id isn't registered yet — covers the race where a just-spawned/just-adopted pane is dragged before its async spawn round-trip registers. Resolves instantly if already registered. +- **Tab accumulation (root-caused + fixed).** The cross-window save aggregator (`window_state.rs::build_envelope`) concatenated EVERY window's workspaces into the saved file; on launch main loaded the whole blob and adopted it as its own tabs, then re-saved under "main" → unbounded growth (hit 14 tabs incl. `Pane 28`/`Pane 38` drag-out artifacts + piles of `Default` from pre-fix detached boots). Fix: `build_envelope` persists **only `MAIN_WINDOW_LABEL`'s** workspaces — detached windows are ephemeral by design (discarded on close), so they're now structurally unable to pollute the file. **Reset the corrupted `workspace.json`** (backed up to `workspace.json.corrupt-backup` in app config dir, then deleted; main reboots a clean single Default). Detached windows still `push_window_workspaces` (harmless; backend just ignores non-main for persistence). +- **Can't close tabs (fixed).** Tab strip is `overflow-x:auto`, which per spec coerces `overflow-y` to auto too → the in-strip absolutely-positioned close-confirm popover got clipped once enough tabs forced horizontal scroll. Fix: `createPortal` the confirm to ``, `position:fixed`, fixed `width:300px` (matches `CONFIRM_POPOVER_WIDTH` const in TabStrip.tsx), right-aligned to the × button then **clamped into the viewport** so a left-side tab doesn't run off the left edge. +- **Native scrollbars (fixed).** `::-webkit-scrollbar` theming was scoped to `.xterm-viewport` only; made it global (`*::-webkit-scrollbar` + `* { scrollbar-width/color }`) so the tab strip / panels / menus match the dark theme. +- **Capability fix recap:** `default.json` `"windows": ["main", "pane-window-*"]` — the load-bearing fix for the whole detached-window feature (B2–B5). Confirmed: app-defined Tauri commands aren't individually permission-gated; they're available to any window the capability is *applied* to (listed in `windows`). 2. `pnpm tauri dev` — smoke test: - Existing workspace loads as one tab named "Default" ✓ migrate diff --git a/src-tauri/src/window_state.rs b/src-tauri/src/window_state.rs index 0529464..a6ba575 100644 --- a/src-tauri/src/window_state.rs +++ b/src-tauri/src/window_state.rs @@ -66,29 +66,20 @@ impl WindowsState { } } - /// Build the on-disk envelope by concatenating every window's - /// workspaces in stable label order (main first when present, then - /// the rest sorted alphabetically by label — deterministic so the - /// file diff stays stable across no-op saves). + /// Build the on-disk envelope from ONLY the main window's workspaces. + /// + /// Detached windows are ephemeral — their tabs are discarded on close + /// (Chrome-style), and only the main window's tabs are meant to survive + /// a restart. Persisting every window's workspaces (the original design) + /// let detached windows' tabs — and the `Pane N` adopt-targets from + /// drag-out — leak into the saved file; on the next launch the main + /// window loaded the whole blob and adopted them all, so they + /// accumulated without bound. Keying the persisted set to the main label + /// makes detached state structurally unable to pollute it. fn build_envelope(&self) -> Value { let map = self.per_window.lock(); - let mut keys: Vec<&String> = map.keys().collect(); - keys.sort_by(|a, b| { - // main first, then alpha - match (a.as_str(), b.as_str()) { - (MAIN_WINDOW_LABEL, _) => std::cmp::Ordering::Less, - (_, MAIN_WINDOW_LABEL) => std::cmp::Ordering::Greater, - (x, y) => x.cmp(y), - } - }); - let mut workspaces: Vec = Vec::new(); - for k in keys { - if let Some(list) = map.get(k) { - for w in list { - workspaces.push(w.clone()); - } - } - } + let workspaces: Vec = + map.get(MAIN_WINDOW_LABEL).cloned().unwrap_or_default(); serde_json::json!({ "version": 2, "workspaces": workspaces, diff --git a/src/App.tsx b/src/App.tsx index 484c02a..07fefe2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1005,10 +1005,19 @@ export default function App() { notify("Cannot move — pane not found"); return; } - const paneId = paneIdByLeafRef.current.get(leafId); + // The pane's id is registered only after its XtermPane finishes the + // async spawn/adopt round-trip. If the user drags out a pane that's + // still completing that (e.g. just after a shell-swap, or a pane in a + // freshly-detached window), wait for registration instead of failing + // outright. Resolves immediately if already registered. + let paneId = paneIdByLeafRef.current.get(leafId); if (paneId == null) { - notify("Cannot move — PTY not ready yet"); - return; + try { + paneId = await waitForPaneRegistration(leafId, 5000); + } catch { + notify("Cannot move — PTY not ready yet"); + return; + } } try { diff --git a/src/components/TabStrip.css b/src/components/TabStrip.css index 4a3c17b..212d024 100644 --- a/src/components/TabStrip.css +++ b/src/components/TabStrip.css @@ -10,14 +10,11 @@ color: #aaa; user-select: none; overflow-x: auto; - overflow-y: visible; min-height: 28px; box-sizing: border-box; white-space: nowrap; - /* Allow the inline confirm popover to spill over the bottom edge instead - of being clipped by overflow:hidden. overflow-y:visible alongside - overflow-x:auto only works because confirm popovers position themselves - absolutely. */ + /* The confirm popover is portalled to (see TabStrip.tsx), so it is + not clipped by this strip's overflow. */ } .tab-strip-item { @@ -113,15 +110,15 @@ border-color: #2a5a8c; } -/* Inline confirm popover anchored to the close button — spills below the - strip. Plain matte panel; reuses the existing app palette. */ +/* Confirm popover anchored to the close button. Portalled to and + positioned `fixed` (top/right set inline) so the horizontally-scrolling + tab strip — overflow-x:auto forces overflow-y:auto, which would clip an + in-strip popover — can't hide it. Plain matte panel; app palette. */ .tab-strip-confirm { - position: absolute; - top: calc(100% + 4px); - right: 0; - z-index: 50; - min-width: 260px; - max-width: 360px; + position: fixed; + z-index: 1000; + /* width must match CONFIRM_POPOVER_WIDTH in TabStrip.tsx (clamp math). */ + width: 300px; background: #1a1a1a; color: #e6e6e6; border: 1px solid #c98a1f; diff --git a/src/components/TabStrip.tsx b/src/components/TabStrip.tsx index cb06e0c..8a64071 100644 --- a/src/components/TabStrip.tsx +++ b/src/components/TabStrip.tsx @@ -7,9 +7,14 @@ import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, } from "react"; +import { createPortal } from "react-dom"; import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree"; import "./TabStrip.css"; +/** Fixed width of the close-confirm popover — must match the `width` in + * TabStrip.css so the viewport-clamp math positions it accurately. */ +const CONFIRM_POPOVER_WIDTH = 300; + interface TabStripProps { workspaces: Workspace[]; currentWorkspaceId: NodeId | null; @@ -37,6 +42,14 @@ export default function TabStrip({ const [draft, setDraft] = useState(""); const editInputRef = useRef(null); const [confirmingId, setConfirmingId] = useState(null); + // Anchor rect (the close button's) for the confirm popover. The popover is + // portalled to with position:fixed because the tab strip scrolls + // horizontally (overflow-x:auto, which forces overflow-y to auto too), + // so an in-strip absolutely-positioned popover would be clipped. + const [confirmAnchor, setConfirmAnchor] = useState<{ + top: number; + left: number; + } | null>(null); const startEdit = useCallback( (id: NodeId, current: string, e: ReactMouseEvent) => { @@ -106,6 +119,19 @@ export default function TabStrip({ onClose(id); return; } + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + // Right-align the popover to the close button by default, then clamp + // both edges into the viewport so a left-side tab doesn't push it off + // the left edge (or a right-side tab off the right). + const pad = 8; + const left = Math.max( + pad, + Math.min( + rect.right - CONFIRM_POPOVER_WIDTH, + window.innerWidth - CONFIRM_POPOVER_WIDTH - pad, + ), + ); + setConfirmAnchor({ top: rect.bottom + 4, left }); setConfirmingId(id); }, [workspaces, onClose], @@ -127,7 +153,6 @@ export default function TabStrip({ {workspaces.map((w) => { const isActive = w.id === currentWorkspaceId; const isEditing = editingId === w.id; - const isConfirming = confirmingId === w.id; return (
× - {isConfirming && ( -
e.stopPropagation()} - > -
- Close "{confirmingWorkspace?.name}"? -
-
- This will kill {confirmingPaneLabels.length} pane - {confirmingPaneLabels.length === 1 ? "" : "s"}: -
- {confirmingPaneLabels.join(", ")} -
-
-
- - -
-
- )}
); })} @@ -207,6 +196,46 @@ export default function TabStrip({ > + + {confirmingId != null && + confirmAnchor && + createPortal( +
e.stopPropagation()} + > +
+ Close "{confirmingWorkspace?.name}"? +
+
+ This will kill {confirmingPaneLabels.length} pane + {confirmingPaneLabels.length === 1 ? "" : "s"}: +
+ {confirmingPaneLabels.join(", ")} +
+
+
+ + +
+
, + document.body, + )} ); } diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index f5ff85f..785e5f2 100644 --- a/src/lib/layout/LeafPane.css +++ b/src/lib/layout/LeafPane.css @@ -302,3 +302,40 @@ background: #2a5a8c; color: #fff; } + +/* Cursor-following ghost shown while dragging a pane toolbar (B1). Rendered + into document.body via a portal, offset from the cursor, and pointer-events + none so it never disturbs the elementFromPoint hit-test that drives the + drop-target highlight. */ +.pane-drag-ghost { + position: fixed; + z-index: 1000; + /* transform set inline so the chip can flip to the cursor's inner side + near the right/bottom edges (keeps it visible while pinned to the edge). */ + pointer-events: none; + display: flex; + align-items: center; + gap: 8px; + max-width: 320px; + padding: 4px 10px; + border: 1px solid #5a8cd8; + border-radius: 4px; + background: rgba(20, 28, 40, 0.95); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5); + font: inherit; + font-size: 12px; + color: #cfe0f5; + white-space: nowrap; +} +.pane-drag-ghost-label { + overflow: hidden; + text-overflow: ellipsis; +} +.pane-drag-ghost.detach { + border-color: #e09838; + color: #ffd9a0; +} +.pane-drag-ghost-hint { + font-weight: 600; + color: #ffb840; +} diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 7fd9ee7..75ad84c 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -7,6 +7,7 @@ import { type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; +import { createPortal } from "react-dom"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; @@ -15,6 +16,19 @@ import "./LeafPane.css"; const IDLE_THRESHOLD_MS = 5000; +/** How far past a viewport edge the cursor must travel before a release is + * treated as "drag pane out of window" instead of "drop on empty space + * inside this window". Picked so an accidental release on the OS titlebar + * (~30px tall) stays inside the threshold. */ +const PANE_DRAG_OUT_MARGIN = 60; + +/** True when a point is past any viewport edge by PANE_DRAG_OUT_MARGIN. */ +const isFarOutsideViewport = (x: number, y: number) => + x < -PANE_DRAG_OUT_MARGIN || + x > window.innerWidth + PANE_DRAG_OUT_MARGIN || + y < -PANE_DRAG_OUT_MARGIN || + y > window.innerHeight + PANE_DRAG_OUT_MARGIN; + export default function LeafPane({ leaf }: { leaf: LeafNode }) { const orch = useOrchestration(); const isActive = orch.activeLeafId === leaf.id; @@ -225,6 +239,17 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>( null, ); + // Cursor-following ghost shown while dragging the toolbar. `detach` flips + // true once the cursor is past the viewport edge by PANE_DRAG_OUT_MARGIN, + // mirroring the release condition in onToolbarPointerUp so the ghost + // previews what a release right now would do. + const [dragGhost, setDragGhost] = useState<{ + x: number; + y: number; + detach: boolean; + flipX: boolean; + flipY: boolean; + } | null>(null); const isDragSource = orch.dragSourceId === leaf.id; const isDragTarget = orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id; @@ -264,16 +289,27 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { const tEl = el?.closest("[data-leaf-id]"); const targetId = tEl?.getAttribute("data-leaf-id") ?? null; orch.setHeaderDragOver(targetId); + // Move the cursor-following ghost (B1). It has pointer-events:none so + // it doesn't interfere with the elementFromPoint hit-test above. + // A webview can't paint outside its own OS window, so once the cursor + // crosses the edge we clamp the chip to the viewport (and flip it to + // the cursor's inner side near right/bottom) so it stays visible and + // its `detach` styling is what previews the release. `detach` itself + // is computed from the RAW cursor position so the preview is accurate. + const GHOST_PAD = 4; + const FLIP_X_ZONE = 180; // ~max chip width + const FLIP_Y_ZONE = 48; + setDragGhost({ + x: Math.max(GHOST_PAD, Math.min(e.clientX, window.innerWidth - GHOST_PAD)), + y: Math.max(GHOST_PAD, Math.min(e.clientY, window.innerHeight - GHOST_PAD)), + detach: isFarOutsideViewport(e.clientX, e.clientY), + flipX: e.clientX > window.innerWidth - FLIP_X_ZONE, + flipY: e.clientY > window.innerHeight - FLIP_Y_ZONE, + }); }, [orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id], ); - /** How far past a viewport edge the cursor must travel before a release - * is treated as "drag pane out of window" instead of "drop on empty - * space inside this window". Picked so an accidental release on the OS - * titlebar (~30px tall) stays inside the threshold. */ - const PANE_DRAG_OUT_MARGIN = 60; - const onToolbarPointerUp = useCallback( (e: ReactPointerEvent) => { const st = dragStartRef.current; @@ -281,14 +317,11 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); const wasDragging = st.dragging; dragStartRef.current = null; + setDragGhost(null); if (!wasDragging) return; document.body.style.cursor = ""; - const releasedFarOutside = - e.clientX < -PANE_DRAG_OUT_MARGIN || - e.clientX > window.innerWidth + PANE_DRAG_OUT_MARGIN || - e.clientY < -PANE_DRAG_OUT_MARGIN || - e.clientY > window.innerHeight + PANE_DRAG_OUT_MARGIN; + const releasedFarOutside = isFarOutsideViewport(e.clientX, e.clientY); if (releasedFarOutside) { // Cancel any in-flight swap state without committing, then pop @@ -310,6 +343,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); const wasDragging = st.dragging; dragStartRef.current = null; + setDragGhost(null); if (wasDragging) { document.body.style.cursor = ""; orch.endHeaderDrag(false); @@ -579,6 +613,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { )} + {dragGhost && + createPortal( + , + document.body, + )} ); } diff --git a/src/styles.css b/src/styles.css index 51390ff..78989e6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -38,28 +38,31 @@ body { .xterm { height: 100%; } .xterm-viewport { background: #0c0c0c !important; } -/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these). */ -.xterm-viewport::-webkit-scrollbar { +/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these). + Applied globally so every scroll container (tab strip, panels, menus, + xterm viewport) matches the dark theme instead of falling back to the + native WebView2 scrollbar. */ +*::-webkit-scrollbar { width: 8px; height: 8px; } -.xterm-viewport::-webkit-scrollbar-track { +*::-webkit-scrollbar-track { background: transparent; } -.xterm-viewport::-webkit-scrollbar-thumb { +*::-webkit-scrollbar-thumb { background: #2a2a2a; border-radius: 4px; border: 1px solid #1a1a1a; } -.xterm-viewport::-webkit-scrollbar-thumb:hover { +*::-webkit-scrollbar-thumb:hover { background: #3a3a3a; } -.xterm-viewport::-webkit-scrollbar-corner { +*::-webkit-scrollbar-corner { background: transparent; } /* Firefox fallback (and the new spec) — not strictly needed in WebView2 but free-and-correct. */ -.xterm-viewport { +* { scrollbar-width: thin; scrollbar-color: #2a2a2a transparent; } From 309b6024d47b16157b9afcbe42e140716a9e250e Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 20:34:36 +0100 Subject: [PATCH 09/45] Fix XtermPane IPC listener leak on unmount-during-spawn/adopt Pre-release audit finding: after `unlistenData = await onPaneData(...)` (and the exit listener) there was no destroyed re-check, so if the pane unmounted during the await the sync cleanup captured a null unlisten and the pane://{id}/data subscription leaked. Unlisten before returning in both the adopt and spawn paths. Also logs the deferred (low-risk) transfer-refcount leak as a known follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 7 +++++++ src/components/XtermPane.tsx | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/memory.md b/memory.md index 94ec740..97c730c 100644 --- a/memory.md +++ b/memory.md @@ -127,6 +127,13 @@ Smoke test on Windows revealed bugs specific to detached (non-main) windows. Mai - **Native scrollbars (fixed).** `::-webkit-scrollbar` theming was scoped to `.xterm-viewport` only; made it global (`*::-webkit-scrollbar` + `* { scrollbar-width/color }`) so the tab strip / panels / menus match the dark theme. - **Capability fix recap:** `default.json` `"windows": ["main", "pane-window-*"]` — the load-bearing fix for the whole detached-window feature (B2–B5). Confirmed: app-defined Tauri commands aren't individually permission-gated; they're available to any window the capability is *applied* to (listed in `windows`). +**Pre-release audit (2026-05-28, 3-agent fan-out) — findings + dispositions:** + +- **(FIXED, medium) XtermPane IPC listener leak on unmount-during-await.** After `unlistenData = await onPaneData(...)` / `unlistenExit = await onPaneExit(...)` there was no `destroyed` re-check, so if the pane unmounted during the await (StrictMode, fast moveToNewWindow/closeTab) the sync cleanup had already captured a null unlisten and the `pane://{id}/data`/exit subscription leaked. Added `if (destroyed) { unlistenData?.(); unlistenExit?.(); return; }` after each assignment in both adopt and spawn paths. +- **(DEFERRED, high — known low-risk) Transferred-PTY/refcount leak if a detached window closes mid-adopt.** `mark_pane_transferring` bumps a refcount that suppresses `kill`; only `claim_pane` (from the target XtermPane mount) drops it. The `CloseRequested` handler (lib.rs:74) forgets workspaces + pending-init but never releases the refcount or kills the pane → if the window closes before adopt's `claim_pane`, that PTY + reader thread leak for the app lifetime. **In practice very low-probability**: adopt of a transferred pane is near-instant (paneId known synchronously, no spawn wait), so `claim` runs within ms of mount — by the time a user sees and closes the window, it's already claimed. User chose ship-now. **Proper fix when revisited:** keep a `label→paneId` "adopting" registry (set when `take_pending_window_init` consumes the payload, cleared by `claim_pane`), and have the close handler force-kill (drop refcount + kill) any still-unclaimed paneId for the closing label. The unconsumed-pending-init subset can be handled more cheaply (close handler already has the PendingInit.pane_id when the entry is still present). +- **(NOT FIXING, low) waitForPaneRegistration doesn't settle on early unmount** — `registerPaneId(leafId, null)` doesn't reject a pending waiter, so moveToNewWindow/MCP-spawn stalls until the timeout instead of failing fast. Functionally safe (timeout fires). +- tabs/LeafPane/TabStrip reviewer: no findings. + 2. `pnpm tauri dev` — smoke test: - Existing workspace loads as one tab named "Default" ✓ migrate - Ctrl+T spawns new tab with default-shell pane diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 3b90015..de4309a 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -186,11 +186,22 @@ export default function XtermPane({ term?.write(b64ToBytes(b64)); onDataReceivedRef.current?.(); }); - if (destroyed) return; + // `destroyed` may have flipped during the await — the sync cleanup + // already ran and captured a null unlisten, so unlisten here or the + // subscription leaks. + if (destroyed) { + unlistenData?.(); + return; + } unlistenExit = await onPaneExit(paneId, () => { term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); onStatusRef.current?.(`pane ${paneId} exited`, false); }); + if (destroyed) { + unlistenData?.(); + unlistenExit?.(); + return; + } // Match the PTY to our cell grid (the source window may have had // different dimensions). try { @@ -227,11 +238,20 @@ export default function XtermPane({ term?.write(b64ToBytes(b64)); onDataReceivedRef.current?.(); }); + if (destroyed) { + unlistenData?.(); + return; + } unlistenExit = await onPaneExit(paneId, () => { term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); onStatusRef.current?.(`pane ${paneId} exited`, false); }); + if (destroyed) { + unlistenData?.(); + unlistenExit?.(); + return; + } } term?.onData((data) => { From 2a1f1d41add95fac0b98f6c8467f29f0d30ee49b Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 20:36:22 +0100 Subject: [PATCH 10/45] Bump version to 0.4.0 Tabs + multi-window pane transfer feature release. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 8ce45a3..26121e1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tiletopia", "private": true, - "version": "0.3.0", + "version": "0.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c9b4ec0..4f0de08 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4221,7 +4221,7 @@ dependencies = [ [[package]] name = "tiletopia" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9f36451..bf16fd0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tiletopia" -version = "0.3.0" +version = "0.4.0" description = "Tiling multi-terminal manager for WSL" authors = ["megaproxy"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 056056e..a57e25f 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.3.0", + "version": "0.4.0", "identifier": "com.megaproxy.tiletopia", "build": { "beforeDevCommand": "pnpm dev", From 5ef35e3a7423e5c98afe605e7522fb68f080844e Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 20:43:21 +0100 Subject: [PATCH 11/45] README: add tabs + multi-window to feature highlights The at-a-glance highlights list omitted the two headline 0.4.0 features (tabs and multi-window pane transfer); body sections already covered them. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c3038c5..58eaa5a 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) From df159056a1d41d4f0713b879a6cb3fd7077c268a Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 20:44:58 +0100 Subject: [PATCH 12/45] memory: log v0.4.0 release wrap-up Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/memory.md b/memory.md index 97c730c..f25f6f5 100644 --- a/memory.md +++ b/memory.md @@ -52,6 +52,14 @@ Durable memory for this project. Read at session start, update before session en ## Session log +### 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.** From 07bba99eb5e3e8a71db8e8d723bf2aea1089bd4e Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 21:31:59 +0100 Subject: [PATCH 13/45] Use canvas renderer to fix stuck/ghost cursor in panes The DOM renderer draws the cursor as a separate layered element; under the Claude TUI's rapid cursor hide/show plus cursorBlink it leaves a stale white block frozen where the cursor used to be. Load @xterm/addon-canvas (composites the cursor into the text surface) with a try/catch that falls back to the DOM renderer on init failure. Canvas over WebGL because tiletopia runs many panes and WebView2 caps live WebGL contexts (~16). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 1 + src/components/XtermPane.tsx | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/package.json b/package.json index 26121e1..393ccb7 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@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-web-links": "^0.12.0", "@xterm/xterm": "^5.5.0", diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index de4309a..1e76493 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -2,6 +2,7 @@ import { useRef, useEffect } 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 type { UnlistenFn } from "@tauri-apps/api/event"; import { readText as clipboardReadText, @@ -149,6 +150,23 @@ 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); + } + // Initial size — fit before asking the PTY for its dimensions. fit.fit(); From b5db68da8b8f97c463922be02e2358028038bb72 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 21:32:05 +0100 Subject: [PATCH 14/45] memory: log in-app code-markup/editor-pane idea Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 1 + 1 file changed, 1 insertion(+) diff --git a/memory.md b/memory.md index f25f6f5..c1af1ed 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: From 8bb080345e1e27e721af33237bbc3656ec917511 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 21:39:23 +0100 Subject: [PATCH 15/45] memory: log fan-out feature research backlog (5 prioritized + parked) Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/memory.md b/memory.md index c1af1ed..57b28b6 100644 --- a/memory.md +++ b/memory.md @@ -51,6 +51,51 @@ 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):** +- [ ] **Per-session cost / token tracking.** Parse `~/.claude/projects//.jsonl` (`message.usage`: input/output/cache_read/cache_write + model per assistant line) → tokens + estimated $ per pane and per workspace. Easy parsing; the fiddly bit is mapping a tiletopia pane → its session file (capture session id / cwd at spawn). Difficulty: easy–medium. +- [ ] **Find in scrollback.** `@xterm/addon-search` — per-pane search box, `findNext`/`findPrevious`, regex + case opts, `searchOptions.decorations` for match highlight. Difficulty: easy. +- [ ] **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. +- [ ] **Unicode 11 + grapheme width.** `@xterm/addon-unicode11` (+ `@xterm/addon-unicode-graphemes`), set `terminal.unicode.activeVersion = '11'`. Fixes emoji/CJK/box-drawing width misalignment + cursor drift in TUIs. Difficulty: easy. +- [ ] **Pane navigation key handler.** `attachCustomKeyEventHandler()` to intercept tiling-WM chords (Ctrl+hjkl move focus, Alt+number select pane) before the PTY sees them, so shortcuts don't leak into the shell. Difficulty: easy. + +**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-05-28 — **v0.4.0 shipped** (tabs + multi-window made actually working) From baa00dfc5cfa49101e4439bc72691ad044f9e218 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 21:51:29 +0100 Subject: [PATCH 16/45] Add find-in-scrollback, unicode11, and keyboard pane navigation Three xterm.js features, implemented together because they share the XtermPane mount + the single attachCustomKeyEventHandler: - Unicode 11: load @xterm/addon-unicode11, set activeVersion='11' after the canvas renderer so emoji/CJK/box-drawing widths stop drifting. - Find in scrollback: @xterm/addon-search + a new per-pane SearchBar overlay (Ctrl+Shift+F to open, Enter/Shift+Enter next/prev, regex + case toggles, Esc to close & refocus). Overlay is an absolutely- positioned sibling in a position:relative wrapper so fit() is unaffected. - Pane navigation: Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial neighbour via findNeighborInDirection) and Alt+1..9 (Nth leaf in walkLeaves order). XtermPane emits a NavigateIntent; App resolves the target leaf and sets it active, reusing the existing isActive->focusTrigger refocus chain. All chords live in one attachCustomKeyEventHandler (xterm replaces the handler on each call). Shortcuts added to shortcuts.ts (SoT for README + Help), including the Alt+digit shell-conflict caveat. tsc clean apart from the three not-yet-installed addon modules. Needs pnpm install on the Windows host + runtime verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 + src/App.tsx | 34 +++++- src/components/SearchBar.css | 105 ++++++++++++++++++ src/components/SearchBar.tsx | 177 ++++++++++++++++++++++++++++++ src/components/XtermPane.tsx | 179 ++++++++++++++++++++++++++----- src/lib/layout/LeafPane.tsx | 9 ++ src/lib/layout/orchestration.tsx | 19 +++- src/lib/shortcuts.ts | 30 +++++- 8 files changed, 526 insertions(+), 29 deletions(-) create mode 100644 src/components/SearchBar.css create mode 100644 src/components/SearchBar.tsx diff --git a/package.json b/package.json index 393ccb7..102c58e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@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/src/App.tsx b/src/App.tsx index 07fefe2..ffceee2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -98,7 +98,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"; @@ -717,6 +717,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), []); @@ -1242,6 +1272,7 @@ export default function App() { toggleMcpAllow, openHostManager, setActive, + navigateTo, registerPaneId, broadcastFrom, notify, @@ -1266,6 +1297,7 @@ export default function App() { toggleMcpAllow, openHostManager, setActive, + navigateTo, registerPaneId, broadcastFrom, notify, 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 1e76493..a993c11 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -1,8 +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, @@ -21,6 +24,7 @@ import { type PaneId, type SpawnSpec, } from "../ipc"; +import type { NavigateIntent } from "../lib/layout/orchestration"; // --------------------------------------------------------------------------- // base64 helpers (private to this module) @@ -72,6 +76,12 @@ interface XtermPaneProps { focusTrigger?: number; /** Absolute font size in px. Changes are applied live (fit + PTY resize). */ fontSize?: number; + /** 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; @@ -90,11 +100,14 @@ export default function XtermPane({ onFocus, focusTrigger = 0, fontSize, + 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). @@ -107,12 +120,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 @@ -167,6 +186,24 @@ export default function XtermPane({ 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(); @@ -279,36 +316,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; }); @@ -389,6 +490,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 @@ -435,5 +537,30 @@ export default function XtermPane({ } }, [fontSize]); - return
; + // 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.tsx b/src/lib/layout/LeafPane.tsx index 75ad84c..cbd53af 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -194,6 +194,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); @@ -575,6 +583,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { onInput={onTerminalInput} onDataReceived={onDataReceived} onFocus={onXtermFocus} + onNavigate={onPaneNavigate} focusTrigger={focusTrigger} fontSize={resolveFontSize(leaf.fontSizeOffset)} /> diff --git a/src/lib/layout/orchestration.tsx b/src/lib/layout/orchestration.tsx index cd381ff..46effad 100644 --- a/src/lib/layout/orchestration.tsx +++ b/src/lib/layout/orchestration.tsx @@ -1,5 +1,5 @@ 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"; /** @@ -62,6 +62,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 +79,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/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", + }, ], }, { From 1bbc6a578339b9212f2af76dfb7eae31d6538cdc Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 21:52:07 +0100 Subject: [PATCH 17/45] memory: mark find-in-scrollback, unicode11, pane-nav implemented (pending Windows verify) Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/memory.md b/memory.md index 57b28b6..523448e 100644 --- a/memory.md +++ b/memory.md @@ -57,10 +57,12 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos **→ Exploring first (user-selected 2026-05-28):** - [ ] **Per-session cost / token tracking.** Parse `~/.claude/projects//.jsonl` (`message.usage`: input/output/cache_read/cache_write + model per assistant line) → tokens + estimated $ per pane and per workspace. Easy parsing; the fiddly bit is mapping a tiletopia pane → its session file (capture session id / cwd at spawn). Difficulty: easy–medium. -- [ ] **Find in scrollback.** `@xterm/addon-search` — per-pane search box, `findNext`/`findPrevious`, regex + case opts, `searchOptions.decorations` for match highlight. Difficulty: easy. - [ ] **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. -- [ ] **Unicode 11 + grapheme width.** `@xterm/addon-unicode11` (+ `@xterm/addon-unicode-graphemes`), set `terminal.unicode.activeVersion = '11'`. Fixes emoji/CJK/box-drawing width misalignment + cursor drift in TUIs. Difficulty: easy. -- [ ] **Pane navigation key handler.** `attachCustomKeyEventHandler()` to intercept tiling-WM chords (Ctrl+hjkl move focus, Alt+number select pane) before the PTY sees them, so shortcuts don't leak into the shell. Difficulty: easy. +- [x] ~~**Find in scrollback.**~~ Done (code) 2026-05-28, commit on `main` — `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight. **Pending: `pnpm install` on Windows host + runtime verify** (addon not in WSL node_modules; tsc clean otherwise). +- [x] ~~**Unicode 11 + grapheme width.**~~ Done (code) 2026-05-28 — `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. Same pending-install caveat. (Skipped the separate `addon-unicode-graphemes` for now.) +- [x] ~~**Pane navigation key handler.**~~ Done (code) 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. + +**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):** From a6d3f8a9f9d3fb0e586246c3c3dc33c442dd3c78 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 21:57:32 +0100 Subject: [PATCH 18/45] memory: mark cursor fix + 3 xterm features verified on Windows Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/memory.md b/memory.md index 523448e..e3dde1f 100644 --- a/memory.md +++ b/memory.md @@ -58,9 +58,11 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos **→ Exploring first (user-selected 2026-05-28):** - [ ] **Per-session cost / token tracking.** Parse `~/.claude/projects//.jsonl` (`message.usage`: input/output/cache_read/cache_write + model per assistant line) → tokens + estimated $ per pane and per workspace. Easy parsing; the fiddly bit is mapping a tiletopia pane → its session file (capture session id / cwd at spawn). Difficulty: easy–medium. - [ ] **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 (code) 2026-05-28, commit on `main` — `@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight. **Pending: `pnpm install` on Windows host + runtime verify** (addon not in WSL node_modules; tsc clean otherwise). -- [x] ~~**Unicode 11 + grapheme width.**~~ Done (code) 2026-05-28 — `@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. Same pending-install caveat. (Skipped the separate `addon-unicode-graphemes` for now.) -- [x] ~~**Pane navigation key handler.**~~ Done (code) 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. +- [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). From 1df8c3181bffb09d7da66841d78feaacdf653967 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 22:15:51 +0100 Subject: [PATCH 19/45] Add per-session claude token/cost usage panel (WSL, v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads ~/.claude/projects/*.jsonl transcripts from the open WSL panes' distros and shows per-session token counts + estimated USD cost, with a running total in the titlebar. Backend (src-tauri/src/usage.rs): new get_claude_usage command. For each distro it probes $HOME once via wsl.exe, reaches the transcripts over the \\wsl.localhost UNC share, and tallies message.usage per model per session (summed by each line's model, since a session can switch models). Results cached by (path,size,mtime) so polling only re-parses the file that grew; recency-capped (30d / 50 sessions) to bound scan cost. Windows-only; returns [] elsewhere. quiet_command made pub(crate). Frontend: src/lib/usage.ts holds the pricing table (per-MTok rates, matched by model-family substring) + cost/format helpers, so rates are editable without recompiling Rust. UsagePanel.tsx mirrors the MCP panel modal; rows whose transcript cwd matches an open pane are highlighted with a [pane: label] tag. App polls every 20s (visible windows) for the titlebar 💰 total and every 5s while the panel is open. Ctrl+Shift+U opens it; added to shortcuts.ts + regenerated README. tsc clean. Rust builds on the Windows host; needs runtime verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 +- src-tauri/src/lib.rs | 3 + src-tauri/src/pty.rs | 2 +- src-tauri/src/usage.rs | 278 ++++++++++++++++++++++++++++++++++ src/App.tsx | 80 ++++++++++ src/components/UsagePanel.css | 167 ++++++++++++++++++++ src/components/UsagePanel.tsx | 136 +++++++++++++++++ src/ipc.ts | 28 ++++ src/lib/shortcuts.ts | 10 ++ src/lib/usage.ts | 97 ++++++++++++ 10 files changed, 813 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/usage.rs create mode 100644 src/components/UsagePanel.css create mode 100644 src/components/UsagePanel.tsx create mode 100644 src/lib/usage.ts diff --git a/README.md b/README.md index 58eaa5a..aecdca1 100644 --- a/README.md +++ b/README.md @@ -61,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** @@ -82,6 +85,15 @@ 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 | + +**Panels** + +| Key | Action | +|---|---| +| `Ctrl+Shift+U` | Open the usage panel — per-session claude token counts + estimated cost for the open WSL panes | **Help** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3a88bac..1e0aac9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod hosts; mod mcp; mod mcp_policy; mod pty; +mod usage; mod window_state; use std::sync::Arc; @@ -66,6 +67,7 @@ pub fn run() { .manage(pending_actions) .manage(windows_state) .manage(pending_inits) + .manage(usage::UsageCache::default()) .on_window_event(move |window, event| { // When a non-main window closes, drop its workspaces from the // aggregator AND any unconsumed pending-init payload so neither @@ -108,6 +110,7 @@ pub fn run() { commands::mcp_policy_load, commands::mcp_policy_save, commands::mcp_hard_deny_labels, + usage::get_claude_usage, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index c404fdf..d4d78e5 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -495,7 +495,7 @@ fn looks_like_password_prompt(buf: &[u8]) -> bool { // ---- distro enumeration ----------------------------------------------------- /// Run a process without flashing a console window on Windows. -fn quiet_command(program: &str) -> std::process::Command { +pub(crate) fn quiet_command(program: &str) -> std::process::Command { let mut c = std::process::Command::new(program); #[cfg(windows)] { diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs new file mode 100644 index 0000000..e79ab02 --- /dev/null +++ b/src-tauri/src/usage.rs @@ -0,0 +1,278 @@ +//! Reads claude-code session transcripts and tallies token usage per session +//! for the usage panel. +//! +//! claude writes one JSONL transcript per session at +//! `~/.claude/projects//.jsonl`. Every assistant line +//! carries `cwd`, `sessionId`, `message.model`, and `message.usage` +//! (input/output/cache tokens). We read those straight out of the file, so the +//! reported cwd/model are accurate regardless of where the pane was spawned. +//! +//! Windows-only: the transcripts live inside each WSL distro, reached via the +//! `\\wsl.localhost\\…` 9p share. Returns empty on non-Windows. +//! +//! Cost is computed on the frontend (see src/lib/usage.ts) so the rate table is +//! easy to tweak; this module only returns raw per-model token tallies. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; +use serde::Serialize; + +use crate::pty::quiet_command; + +/// Ignore sessions older than this, and cap the number returned — bounds the +/// scan cost on machines with a large transcript history. +const MAX_AGE_MS: i64 = 30 * 24 * 60 * 60 * 1000; +const MAX_SESSIONS: usize = 50; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelUsage { + pub model: String, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionUsage { + pub session_id: String, + pub cwd: String, + pub project_dir: String, + pub distro: String, + pub last_active_ms: i64, + pub models: Vec, +} + +/// Parsed-file cache entry, validated by (size, mtime) so we only re-parse the +/// one transcript that actually grew between polls. +struct CachedFile { + size: u64, + mtime_ms: i64, + cwd: String, + models: Vec, +} + +#[derive(Default)] +pub struct UsageCache { + files: Mutex>, + /// distro -> resolved `$HOME` (one wsl.exe probe per distro per process). + homes: Mutex>, +} + +/// Read + tally claude usage across the given WSL distros (the distinct distros +/// of currently-open WSL panes). Newest sessions first, capped to MAX_SESSIONS. +#[tauri::command] +pub async fn get_claude_usage( + distros: Vec, + cache: tauri::State<'_, UsageCache>, +) -> Result, String> { + if !cfg!(windows) { + return Ok(Vec::new()); + } + let cache = cache.inner(); + let mut out: Vec = Vec::new(); + let mut seen = HashSet::new(); + for distro in distros.into_iter().filter(|d| !d.is_empty() && seen.insert(d.clone())) { + match collect_distro(&distro, cache) { + Ok(mut v) => out.append(&mut v), + Err(e) => tracing::warn!("usage scan for distro {distro} failed: {e}"), + } + } + out.sort_by(|a, b| b.last_active_ms.cmp(&a.last_active_ms)); + out.truncate(MAX_SESSIONS); + Ok(out) +} + +fn collect_distro(distro: &str, cache: &UsageCache) -> Result, String> { + let home = resolve_home(distro, cache)?; + let projects = projects_dir(distro, &home) + .ok_or_else(|| format!("no ~/.claude/projects reachable for {distro}"))?; + + // Gather candidate transcripts (path, project-dir name, mtime), newest first. + let now = now_ms(); + let mut candidates: Vec<(PathBuf, String, i64)> = Vec::new(); + for proj in std::fs::read_dir(&projects).map_err(|e| e.to_string())?.flatten() { + let proj_path = proj.path(); + if !proj_path.is_dir() { + continue; + } + let proj_name = proj.file_name().to_string_lossy().into_owned(); + let inner = match std::fs::read_dir(&proj_path) { + Ok(it) => it, + Err(_) => continue, + }; + for f in inner.flatten() { + let p = f.path(); + if p.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let mtime = std::fs::metadata(&p) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(sys_to_ms) + .unwrap_or(0); + if now - mtime > MAX_AGE_MS { + continue; + } + candidates.push((p, proj_name.clone(), mtime)); + } + } + candidates.sort_by(|a, b| b.2.cmp(&a.2)); + candidates.truncate(MAX_SESSIONS); + + let mut out = Vec::new(); + for (path, proj_name, mtime) in candidates { + let (cwd, models) = match parse_or_cache(&path, cache) { + Ok(v) => v, + Err(e) => { + tracing::debug!("skip transcript {}: {e}", path.display()); + continue; + } + }; + let session_id = path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + out.push(SessionUsage { + session_id, + cwd, + project_dir: proj_name, + distro: distro.to_string(), + last_active_ms: mtime, + models, + }); + } + Ok(out) +} + +/// Probe `$HOME` inside the distro once and cache it. `sh -c` (not a login +/// shell) so rc-file output can't contaminate stdout. +fn resolve_home(distro: &str, cache: &UsageCache) -> Result { + if let Some(h) = cache.homes.lock().get(distro) { + return Ok(h.clone()); + } + let out = quiet_command("wsl.exe") + .args(["-d", distro, "--", "sh", "-c", "printf %s \"$HOME\""]) + .output() + .map_err(|e| format!("wsl.exe $HOME probe: {e}"))?; + if !out.status.success() { + return Err(format!("wsl.exe $HOME probe exited {:?}", out.status.code())); + } + let home = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if home.is_empty() { + return Err("empty $HOME".into()); + } + cache.homes.lock().insert(distro.to_string(), home.clone()); + Ok(home) +} + +/// `~/.claude/projects` as a Windows UNC path into the distro. Tries the newer +/// `\\wsl.localhost\` share first, then the legacy `\\wsl$\` alias. +fn projects_dir(distro: &str, home: &str) -> Option { + let home_rel = home.trim_start_matches('/'); + for prefix in [ + format!(r"\\wsl.localhost\{distro}"), + format!(r"\\wsl$\{distro}"), + ] { + let p = PathBuf::from(prefix) + .join(home_rel) + .join(".claude") + .join("projects"); + if p.is_dir() { + return Some(p); + } + } + None +} + +fn parse_or_cache(path: &Path, cache: &UsageCache) -> Result<(String, Vec), String> { + let meta = std::fs::metadata(path).map_err(|e| e.to_string())?; + let size = meta.len(); + let mtime = meta.modified().ok().and_then(sys_to_ms).unwrap_or(0); + if let Some(c) = cache.files.lock().get(path) { + if c.size == size && c.mtime_ms == mtime { + return Ok((c.cwd.clone(), c.models.clone())); + } + } + let (cwd, models) = parse_file(path)?; + cache.files.lock().insert( + path.to_path_buf(), + CachedFile { + size, + mtime_ms: mtime, + cwd: cwd.clone(), + models: models.clone(), + }, + ); + Ok((cwd, models)) +} + +fn parse_file(path: &Path) -> Result<(String, Vec), String> { + let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let mut cwd = String::new(); + let mut by_model: HashMap = HashMap::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let v: serde_json::Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(_) => continue, // tolerate a truncated final line / stray text + }; + if cwd.is_empty() { + if let Some(c) = v.get("cwd").and_then(|x| x.as_str()) { + cwd = c.to_string(); + } + } + if v.get("type").and_then(|x| x.as_str()) != Some("assistant") { + continue; + } + let msg = match v.get("message") { + Some(m) => m, + None => continue, + }; + let usage = match msg.get("usage") { + Some(u) => u, + None => continue, + }; + let model = msg + .get("model") + .and_then(|x| x.as_str()) + .unwrap_or("unknown") + .to_string(); + let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0); + let entry = by_model.entry(model.clone()).or_insert_with(|| ModelUsage { + model, + input_tokens: 0, + output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + }); + entry.input_tokens += tok("input_tokens"); + entry.output_tokens += tok("output_tokens"); + entry.cache_creation_tokens += tok("cache_creation_input_tokens"); + entry.cache_read_tokens += tok("cache_read_input_tokens"); + } + + let mut models: Vec = by_model.into_values().collect(); + models.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens)); + Ok((cwd, models)) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn sys_to_ms(t: SystemTime) -> Option { + t.duration_since(UNIX_EPOCH).ok().map(|d| d.as_millis() as i64) +} diff --git a/src/App.tsx b/src/App.tsx index ffceee2..c0a9858 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,8 +23,10 @@ import { createPaneWindow, takePendingWindowInit, pushWindowWorkspaces, + getClaudeUsage, type PaneId, type SpawnSpec, + type SessionUsage, type SshHost, type McpStatus, type McpMirror, @@ -106,6 +108,8 @@ import Palette from "./components/Palette"; import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; +import UsagePanel from "./components/UsagePanel"; +import { totalCost, formatUsd } from "./lib/usage"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; import TabStrip from "./components/TabStrip"; import "./App.css"; @@ -239,6 +243,9 @@ export default function App() { token: null, }); const [mcpPanelOpen, setMcpPanelOpen] = useState(false); + const [usagePanelOpen, setUsagePanelOpen] = useState(false); + const [usageSessions, setUsageSessions] = useState([]); + const [usageLoading, setUsageLoading] = useState(false); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -750,6 +757,53 @@ export default function App() { const openHostManager = useCallback(() => setHostManagerOpen(true), []); const closeHostManager = useCallback(() => setHostManagerOpen(false), []); + // ---- claude usage tracking ---------------------------------------------- + // Reads ~/.claude transcripts in the open WSL panes' distros (backend). The + // fetch guard collapses overlapping calls (the open panel polls every 5s and + // the background heartbeat every 20s both call this). + const usageFetchingRef = useRef(false); + const refreshUsage = useCallback(async () => { + if (usageFetchingRef.current) return; + const distros = new Set(); + for (const leaf of walkLeaves(treeRef.current)) { + if (leaf.shellKind === "wsl" && leaf.distro) distros.add(leaf.distro); + } + if (distros.size === 0) { + setUsageSessions([]); + return; + } + usageFetchingRef.current = true; + setUsageLoading(true); + try { + setUsageSessions(await getClaudeUsage(Array.from(distros))); + } catch (e) { + console.warn("getClaudeUsage failed:", e); + } finally { + usageFetchingRef.current = false; + setUsageLoading(false); + } + }, []); + + // Background heartbeat so the titlebar total stays roughly current without + // the panel open. Gated on visibility so a hidden/minimized window stays quiet. + useEffect(() => { + const tick = () => { + if (document.visibilityState === "visible") void refreshUsage(); + }; + tick(); + const id = window.setInterval(tick, 20000); + return () => clearInterval(id); + }, [refreshUsage]); + + // cwd + label of open WSL panes, for highlighting matching sessions. + const openPanes = useMemo( + () => + Array.from(walkLeaves(tree)) + .filter((l) => l.shellKind === "wsl") + .map((l) => ({ cwd: l.cwd ?? "", label: l.label ?? l.distro ?? "pane" })), + [tree], + ); + // Outside-click dismissal for the titlebar dropdowns. Mirrors the // per-pane shell-picker pattern in LeafPane.tsx. useEffect(() => { @@ -852,6 +906,14 @@ export default function App() { return; } + // Ctrl+Shift+U — usage panel + if (ctrl && shift && !alt && key === "u") { + e.preventDefault(); + e.stopPropagation(); + setUsagePanelOpen((v) => !v); + return; + } + // Ctrl+Shift+Alt+B — global broadcast all/none if (ctrl && shift && alt && key === "b") { e.preventDefault(); @@ -2085,6 +2147,14 @@ export default function App() { > 🤖 + + + + +
+ {sessions.length === 0 ? ( +

+ {loading + ? "Reading transcripts…" + : "No recent claude sessions found in the open panes' WSL distros."} +

+ ) : ( +
    + {sessions.map((s) => { + const paneLabel = paneByCwd.get(s.cwd); + const open = paneLabel !== undefined; + return ( +
  • + + {open ? "●" : "○"} + +
    +
    + + {projectName(s.cwd) || s.projectDir} + + {dominantModel(s)} + + {formatTokens(sessionTokens(s))} tok + + {formatUsd(sessionCost(s))} +
    +
    + + {s.cwd} + + {open && ( + [pane: {paneLabel}] + )} + {relativeTime(s.lastActiveMs, nowMs)} +
    +
    +
  • + ); + })} +
+ )} +
+ +
+ ● = open pane  ·  estimate (rates may drift)  ·  recent + sessions only +
+
+ + ); +} + +/** Last path segment of a cwd for a compact project label. */ +function projectName(cwd: string): string { + const parts = cwd.split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : cwd; +} diff --git a/src/ipc.ts b/src/ipc.ts index 6660ed8..4600a5e 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,6 +39,34 @@ export interface SshHost { export const listDistros = (): Promise => invoke("list_distros"); +// ---- claude usage tracking ------------------------------------------------ + +/** Per-model token tally within one claude session. Mirrors Rust ModelUsage. */ +export interface ModelUsage { + model: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +/** One claude session's usage, read from its transcript. Mirrors Rust + * SessionUsage. Cost is computed frontend-side (see src/lib/usage.ts). */ +export interface SessionUsage { + sessionId: string; + cwd: string; + projectDir: string; + distro: string; + lastActiveMs: number; + models: ModelUsage[]; +} + +/** Scan ~/.claude/projects in the given WSL distros (distinct distros of + * open WSL panes) and return recent sessions' token tallies. WSL/Windows + * only — returns [] otherwise. */ +export const getClaudeUsage = (distros: string[]): Promise => + invoke("get_claude_usage", { distros }); + export const spawnPane = (args: { spec: SpawnSpec; cols: number; diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index a72cddb..8e71108 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -130,6 +130,16 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [ }, ], }, + { + title: "Panels", + items: [ + { + keys: "Ctrl+Shift+U", + description: + "Open the usage panel — per-session claude token counts + estimated cost for the open WSL panes", + }, + ], + }, { title: "Help", items: [{ keys: "F1", description: "Show this help overlay" }], diff --git a/src/lib/usage.ts b/src/lib/usage.ts new file mode 100644 index 0000000..a42ea27 --- /dev/null +++ b/src/lib/usage.ts @@ -0,0 +1,97 @@ +// Pricing + formatting helpers for the claude usage panel. Token tallies come +// from the backend (src-tauri/src/usage.rs); cost is applied here so the rate +// table is easy to edit without recompiling Rust. + +import type { SessionUsage } from "../ipc"; + +interface Rate { + /** USD per million tokens. */ + input: number; + output: number; + cacheWrite: number; + cacheRead: number; +} + +// Published Anthropic API rates, USD per million tokens, as of 2026-05. +// UPDATE if pricing changes. Matched against the model id by substring. +// cacheWrite uses the 5-minute-TTL rate (1.25× input); 1-hour cache writes +// (2× input) are billed slightly higher than this estimate shows. +const RATES: { match: string; rate: Rate }[] = [ + { match: "opus", rate: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } }, + { match: "sonnet", rate: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } }, + { match: "haiku", rate: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } }, +]; +// Unknown model → assume sonnet-tier rates (a middle-ground estimate). +const FALLBACK_RATE = RATES[1].rate; + +function rateFor(model: string): Rate { + const m = model.toLowerCase(); + return RATES.find((r) => m.includes(r.match))?.rate ?? FALLBACK_RATE; +} + +/** Estimated USD cost for one session, summed per-model. */ +export function sessionCost(s: SessionUsage): number { + let usd = 0; + for (const mu of s.models) { + const r = rateFor(mu.model); + usd += + (mu.inputTokens * r.input + + mu.outputTokens * r.output + + mu.cacheCreationTokens * r.cacheWrite + + mu.cacheReadTokens * r.cacheRead) / + 1_000_000; + } + return usd; +} + +/** Total tokens (all kinds) for one session. */ +export function sessionTokens(s: SessionUsage): number { + let t = 0; + for (const mu of s.models) { + t += mu.inputTokens + mu.outputTokens + mu.cacheCreationTokens + mu.cacheReadTokens; + } + return t; +} + +/** Short family name of the model that produced the most output in a session. */ +export function dominantModel(s: SessionUsage): string { + let best: SessionUsage["models"][number] | undefined; + for (const mu of s.models) { + if (!best || mu.outputTokens > best.outputTokens) best = mu; + } + return best ? shortModel(best.model) : "—"; +} + +export function shortModel(model: string): string { + const m = model.toLowerCase(); + if (m.includes("opus")) return "opus"; + if (m.includes("sonnet")) return "sonnet"; + if (m.includes("haiku")) return "haiku"; + return model; +} + +export function totalCost(sessions: SessionUsage[]): number { + return sessions.reduce((acc, s) => acc + sessionCost(s), 0); +} + +export function formatUsd(n: number): string { + return "$" + n.toFixed(2); +} + +export function formatTokens(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; + if (n >= 1_000) return Math.round(n / 1_000) + "k"; + return String(n); +} + +/** `nowMs` is passed in so callers can avoid Date.now() churn in render. */ +export function relativeTime(ms: number, nowMs: number): string { + const dt = Math.max(0, nowMs - ms); + const s = Math.floor(dt / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} From e30ac461afa4b4eb773195c1b72595741e868311 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 22:16:03 +0100 Subject: [PATCH 20/45] Commit pnpm-lock for the three new xterm addons Pins @xterm/addon-canvas 0.7.0, addon-search 0.15.0, addon-unicode11 0.8.0 (installed on the Windows host) so the deps are reproducible. Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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': {} From e3c3810ba065a3cb950807799328aae5c7af4506 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 22:16:20 +0100 Subject: [PATCH 21/45] memory: log per-session token tracking (done, pending Windows verify) Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/memory.md b/memory.md index e3dde1f..f5efa26 100644 --- a/memory.md +++ b/memory.md @@ -56,7 +56,7 @@ Durable memory for this project. Read at session start, update before session en 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):** -- [ ] **Per-session cost / token tracking.** Parse `~/.claude/projects//.jsonl` (`message.usage`: input/output/cache_read/cache_write + model per assistant line) → tokens + estimated $ per pane and per workspace. Easy parsing; the fiddly bit is mapping a tiletopia pane → its session file (capture session id / cwd at spawn). Difficulty: easy–medium. +- [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`. - [ ] **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.) From ebbf8db407171e29a37027c71273b8cc250defe1 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 22:26:15 +0100 Subject: [PATCH 22/45] Usage panel: scope to open panes, lead with tokens, label $ as API estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses feedback on the usage panel: - It was summing every recent session on the distro (all projects, mounted + home dirs), not the open panes' work — which read as inflated/double- counted. (Verified there's no literal double count: every transcript is read once and no two project dirs share a cwd, since claude resolves symlinks/mounts to the real path before mangling.) Now the panel + the titlebar chip default to sessions whose cwd matches an open pane, with an 'open panes / all recent' toggle to see the full per-distro list. - Token volume is now the headline figure; the API-cost estimate is shown as a clearly-labeled '~$' secondary, with a footer note that it's n/a on a Pro/Max subscription and can't reflect /usage quota. Kept visible (not hidden) so it can be validated against real API billing. Frontend-only; backend still returns the full recent set for the toggle. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/App.tsx | 9 +++++- src/components/UsagePanel.css | 29 ++++++++++++++++++- src/components/UsagePanel.tsx | 53 +++++++++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c0a9858..2299eea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -804,6 +804,13 @@ export default function App() { [tree], ); + // Titlebar chip total — scoped to the open panes ("this workspace"), matching + // the usage panel's default view, so it isn't inflated by unrelated projects. + const workspaceUsageTotal = useMemo(() => { + const cwds = new Set(openPanes.map((p) => p.cwd).filter(Boolean)); + return totalCost(usageSessions.filter((s) => cwds.has(s.cwd))); + }, [openPanes, usageSessions]); + // Outside-click dismissal for the titlebar dropdowns. Mirrors the // per-pane shell-picker pattern in LeafPane.tsx. useEffect(() => { @@ -2153,7 +2160,7 @@ export default function App() { title="claude token usage & estimated cost (Ctrl+Shift+U)" aria-label="Usage" > - 💰{usageSessions.length > 0 ? ` ${formatUsd(totalCost(usageSessions))}` : ""} + 💰{workspaceUsageTotal > 0 ? ` ~${formatUsd(workspaceUsageTotal)}` : ""} + + {formatTokens(shown.reduce((a, s) => a + sessionTokens(s), 0))} tok + + {" · ~"} + {formatUsd(total)} + + + )}

) : (
    - {sessions.map((s) => { + {shown.map((s) => { const paneLabel = paneByCwd.get(s.cwd); const open = paneLabel !== undefined; return ( @@ -121,8 +156,8 @@ export default function UsagePanel({
- ● = open pane  ·  estimate (rates may drift)  ·  recent - sessions only + ● = open pane  ·  ~$ is an API-pricing estimate (n/a on Pro/Max; + can't reflect /usage quota)  ·  recent sessions only
From b23f3d1ecbd305873c4ebee6a54fdfa9f5afaf04 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 22:26:33 +0100 Subject: [PATCH 23/45] memory: log usage-panel scope/metric refinements + double-count investigation Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 1 + 1 file changed, 1 insertion(+) diff --git a/memory.md b/memory.md index f5efa26..92d7b3e 100644 --- a/memory.md +++ b/memory.md @@ -57,6 +57,7 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos **→ 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`. + - **Refined same day after user feedback:** (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.) From d951c360ae8eaeaf796b03144f1dfdff75ea8a31 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 22:43:06 +0100 Subject: [PATCH 24/45] Replace token-usage panel with per-pane context-fill indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a subscription user, lifetime token totals + a $ estimate aren't actionable; how full each session's context window is right now is. So: - Removed the UsagePanel, the titlebar 💰 chip, and Ctrl+Shift+U. - Repurposed the transcript reader (src-tauri/src/usage.rs): get_pane_context returns each recent session's CURRENT context occupancy = the last assistant turn's input + cache_read + cache_creation tokens (the prompt size), instead of lifetime sums. Same UNC/$HOME/cache/recency machinery. - src/lib/usage.ts now holds context helpers (window inference 200k vs 1M by whether occupancy already exceeds 200k, % , green→amber→red ramp, label). - App polls get_pane_context (15s, visibility-gated) into a cwd→context map exposed via orchestration; each LeafPane looks itself up by leaf.cwd and renders a slim fill bar + % in its header (hidden for non-claude/unmatched panes). Also fixes the narrow-pane toolbar: a ResizeObserver sets leaf--narrow / leaf--xnarrow width tiers; the label shrinks first, split buttons / status / secondary chips drop out by tier, and the close × + context indicator stay pinned right and visible down to the 180px min width. tsc clean (apart from the not-yet-installed xterm addons). Rust builds on the Windows host; needs runtime verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 - src-tauri/src/lib.rs | 2 +- src-tauri/src/usage.rs | 113 ++++++++---------- src/App.tsx | 98 +++++----------- src/components/UsagePanel.css | 194 ------------------------------- src/components/UsagePanel.tsx | 171 --------------------------- src/ipc.ts | 32 ++--- src/lib/layout/LeafPane.css | 56 +++++++++ src/lib/layout/LeafPane.tsx | 47 +++++++- src/lib/layout/orchestration.tsx | 6 +- src/lib/shortcuts.ts | 10 -- src/lib/usage.ts | 112 +++++------------- 12 files changed, 235 insertions(+), 612 deletions(-) delete mode 100644 src/components/UsagePanel.css delete mode 100644 src/components/UsagePanel.tsx diff --git a/README.md b/README.md index aecdca1..23ec0cb 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,6 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Enter / Shift+Enter` | Next / previous match (while search bar is focused) | | `Escape` | Close find bar and return focus to terminal | -**Panels** - -| Key | Action | -|---|---| -| `Ctrl+Shift+U` | Open the usage panel — per-session claude token counts + estimated cost for the open WSL panes | - **Help** | Key | Action | diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e0aac9..5279e1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -110,7 +110,7 @@ pub fn run() { commands::mcp_policy_load, commands::mcp_policy_save, commands::mcp_hard_deny_labels, - usage::get_claude_usage, + usage::get_pane_context, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index e79ab02..e718dc8 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1,17 +1,15 @@ -//! Reads claude-code session transcripts and tallies token usage per session -//! for the usage panel. +//! Reads claude-code session transcripts to report each session's **current +//! context-window occupancy** for the per-pane context indicator. //! //! claude writes one JSONL transcript per session at //! `~/.claude/projects//.jsonl`. Every assistant line -//! carries `cwd`, `sessionId`, `message.model`, and `message.usage` -//! (input/output/cache tokens). We read those straight out of the file, so the -//! reported cwd/model are accurate regardless of where the pane was spawned. +//! carries `cwd`, `message.model`, and `message.usage`. The size of the prompt +//! sent on the most recent turn — `input_tokens + cache_read_input_tokens + +//! cache_creation_input_tokens` of the LAST assistant line — is a good proxy +//! for "how full is this session's context window right now". //! //! Windows-only: the transcripts live inside each WSL distro, reached via the //! `\\wsl.localhost\\…` 9p share. Returns empty on non-Windows. -//! -//! Cost is computed on the frontend (see src/lib/usage.ts) so the rate table is -//! easy to tweak; this module only returns raw per-model token tallies. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -29,23 +27,15 @@ const MAX_SESSIONS: usize = 50; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -pub struct ModelUsage { - pub model: String, - pub input_tokens: u64, - pub output_tokens: u64, - pub cache_creation_tokens: u64, - pub cache_read_tokens: u64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionUsage { +pub struct SessionContext { pub session_id: String, pub cwd: String, - pub project_dir: String, pub distro: String, pub last_active_ms: i64, - pub models: Vec, + /// Prompt size of the last assistant turn (input + both cache buckets) — + /// the current context-window occupancy. + pub context_tokens: u64, + pub model: String, } /// Parsed-file cache entry, validated by (size, mtime) so we only re-parse the @@ -54,7 +44,8 @@ struct CachedFile { size: u64, mtime_ms: i64, cwd: String, - models: Vec, + context_tokens: u64, + model: String, } #[derive(Default)] @@ -64,23 +55,24 @@ pub struct UsageCache { homes: Mutex>, } -/// Read + tally claude usage across the given WSL distros (the distinct distros -/// of currently-open WSL panes). Newest sessions first, capped to MAX_SESSIONS. +/// Read each recent session's current context occupancy across the given WSL +/// distros (the distinct distros of currently-open WSL panes). Newest first, +/// capped to MAX_SESSIONS. #[tauri::command] -pub async fn get_claude_usage( +pub async fn get_pane_context( distros: Vec, cache: tauri::State<'_, UsageCache>, -) -> Result, String> { +) -> Result, String> { if !cfg!(windows) { return Ok(Vec::new()); } let cache = cache.inner(); - let mut out: Vec = Vec::new(); + let mut out: Vec = Vec::new(); let mut seen = HashSet::new(); for distro in distros.into_iter().filter(|d| !d.is_empty() && seen.insert(d.clone())) { match collect_distro(&distro, cache) { Ok(mut v) => out.append(&mut v), - Err(e) => tracing::warn!("usage scan for distro {distro} failed: {e}"), + Err(e) => tracing::warn!("context scan for distro {distro} failed: {e}"), } } out.sort_by(|a, b| b.last_active_ms.cmp(&a.last_active_ms)); @@ -88,20 +80,19 @@ pub async fn get_claude_usage( Ok(out) } -fn collect_distro(distro: &str, cache: &UsageCache) -> Result, String> { +fn collect_distro(distro: &str, cache: &UsageCache) -> Result, String> { let home = resolve_home(distro, cache)?; let projects = projects_dir(distro, &home) .ok_or_else(|| format!("no ~/.claude/projects reachable for {distro}"))?; - // Gather candidate transcripts (path, project-dir name, mtime), newest first. + // Gather candidate transcripts (path, mtime), newest first. let now = now_ms(); - let mut candidates: Vec<(PathBuf, String, i64)> = Vec::new(); + let mut candidates: Vec<(PathBuf, i64)> = Vec::new(); for proj in std::fs::read_dir(&projects).map_err(|e| e.to_string())?.flatten() { let proj_path = proj.path(); if !proj_path.is_dir() { continue; } - let proj_name = proj.file_name().to_string_lossy().into_owned(); let inner = match std::fs::read_dir(&proj_path) { Ok(it) => it, Err(_) => continue, @@ -119,15 +110,15 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result, if now - mtime > MAX_AGE_MS { continue; } - candidates.push((p, proj_name.clone(), mtime)); + candidates.push((p, mtime)); } } - candidates.sort_by(|a, b| b.2.cmp(&a.2)); + candidates.sort_by(|a, b| b.1.cmp(&a.1)); candidates.truncate(MAX_SESSIONS); let mut out = Vec::new(); - for (path, proj_name, mtime) in candidates { - let (cwd, models) = match parse_or_cache(&path, cache) { + for (path, mtime) in candidates { + let (cwd, context_tokens, model) = match parse_or_cache(&path, cache) { Ok(v) => v, Err(e) => { tracing::debug!("skip transcript {}: {e}", path.display()); @@ -138,13 +129,13 @@ fn collect_distro(distro: &str, cache: &UsageCache) -> Result, .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); - out.push(SessionUsage { + out.push(SessionContext { session_id, cwd, - project_dir: proj_name, distro: distro.to_string(), last_active_ms: mtime, - models, + context_tokens, + model, }); } Ok(out) @@ -190,32 +181,39 @@ fn projects_dir(distro: &str, home: &str) -> Option { None } -fn parse_or_cache(path: &Path, cache: &UsageCache) -> Result<(String, Vec), String> { +fn parse_or_cache( + path: &Path, + cache: &UsageCache, +) -> Result<(String, u64, String), String> { let meta = std::fs::metadata(path).map_err(|e| e.to_string())?; let size = meta.len(); let mtime = meta.modified().ok().and_then(sys_to_ms).unwrap_or(0); if let Some(c) = cache.files.lock().get(path) { if c.size == size && c.mtime_ms == mtime { - return Ok((c.cwd.clone(), c.models.clone())); + return Ok((c.cwd.clone(), c.context_tokens, c.model.clone())); } } - let (cwd, models) = parse_file(path)?; + let (cwd, context_tokens, model) = parse_file(path)?; cache.files.lock().insert( path.to_path_buf(), CachedFile { size, mtime_ms: mtime, cwd: cwd.clone(), - models: models.clone(), + context_tokens, + model: model.clone(), }, ); - Ok((cwd, models)) + Ok((cwd, context_tokens, model)) } -fn parse_file(path: &Path) -> Result<(String, Vec), String> { +/// Returns (cwd, context_tokens, model) where context_tokens is the prompt size +/// of the LAST assistant turn — the current context-window occupancy. +fn parse_file(path: &Path) -> Result<(String, u64, String), String> { let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; let mut cwd = String::new(); - let mut by_model: HashMap = HashMap::new(); + let mut context_tokens = 0u64; + let mut model = String::new(); for line in content.lines() { let line = line.trim(); @@ -242,28 +240,19 @@ fn parse_file(path: &Path) -> Result<(String, Vec), String> { Some(u) => u, None => continue, }; - let model = msg + let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0); + // Overwrite each turn so we end up with the LAST assistant line's values. + context_tokens = tok("input_tokens") + + tok("cache_read_input_tokens") + + tok("cache_creation_input_tokens"); + model = msg .get("model") .and_then(|x| x.as_str()) .unwrap_or("unknown") .to_string(); - let tok = |k: &str| usage.get(k).and_then(|x| x.as_u64()).unwrap_or(0); - let entry = by_model.entry(model.clone()).or_insert_with(|| ModelUsage { - model, - input_tokens: 0, - output_tokens: 0, - cache_creation_tokens: 0, - cache_read_tokens: 0, - }); - entry.input_tokens += tok("input_tokens"); - entry.output_tokens += tok("output_tokens"); - entry.cache_creation_tokens += tok("cache_creation_input_tokens"); - entry.cache_read_tokens += tok("cache_read_input_tokens"); } - let mut models: Vec = by_model.into_values().collect(); - models.sort_by(|a, b| b.output_tokens.cmp(&a.output_tokens)); - Ok((cwd, models)) + Ok((cwd, context_tokens, model)) } fn now_ms() -> i64 { diff --git a/src/App.tsx b/src/App.tsx index 2299eea..8f214a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,10 +23,10 @@ import { createPaneWindow, takePendingWindowInit, pushWindowWorkspaces, - getClaudeUsage, + getPaneContext, type PaneId, type SpawnSpec, - type SessionUsage, + type SessionContext, type SshHost, type McpStatus, type McpMirror, @@ -108,8 +108,6 @@ import Palette from "./components/Palette"; import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; -import UsagePanel from "./components/UsagePanel"; -import { totalCost, formatUsd } from "./lib/usage"; import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; import TabStrip from "./components/TabStrip"; import "./App.css"; @@ -243,9 +241,7 @@ export default function App() { token: null, }); const [mcpPanelOpen, setMcpPanelOpen] = useState(false); - const [usagePanelOpen, setUsagePanelOpen] = useState(false); - const [usageSessions, setUsageSessions] = useState([]); - const [usageLoading, setUsageLoading] = useState(false); + const [contextSessions, setContextSessions] = useState([]); const [ready, setReady] = useState(false); const [notifications, setNotifications] = useState([]); const [paletteOpen, setPaletteOpen] = useState(false); @@ -757,59 +753,52 @@ export default function App() { const openHostManager = useCallback(() => setHostManagerOpen(true), []); const closeHostManager = useCallback(() => setHostManagerOpen(false), []); - // ---- claude usage tracking ---------------------------------------------- - // Reads ~/.claude transcripts in the open WSL panes' distros (backend). The - // fetch guard collapses overlapping calls (the open panel polls every 5s and - // the background heartbeat every 20s both call this). - const usageFetchingRef = useRef(false); - const refreshUsage = useCallback(async () => { - if (usageFetchingRef.current) return; + // ---- claude context tracking -------------------------------------------- + // Reads each recent session's current context occupancy from ~/.claude + // transcripts (backend), for the per-pane context-fill indicator. The fetch + // guard collapses overlapping ticks. + const contextFetchingRef = useRef(false); + const refreshContext = useCallback(async () => { + if (contextFetchingRef.current) return; const distros = new Set(); for (const leaf of walkLeaves(treeRef.current)) { if (leaf.shellKind === "wsl" && leaf.distro) distros.add(leaf.distro); } if (distros.size === 0) { - setUsageSessions([]); + setContextSessions([]); return; } - usageFetchingRef.current = true; - setUsageLoading(true); + contextFetchingRef.current = true; try { - setUsageSessions(await getClaudeUsage(Array.from(distros))); + setContextSessions(await getPaneContext(Array.from(distros))); } catch (e) { - console.warn("getClaudeUsage failed:", e); + console.warn("getPaneContext failed:", e); } finally { - usageFetchingRef.current = false; - setUsageLoading(false); + contextFetchingRef.current = false; } }, []); - // Background heartbeat so the titlebar total stays roughly current without - // the panel open. Gated on visibility so a hidden/minimized window stays quiet. + // Poll on a light interval, gated on visibility so a hidden/minimized window + // stays quiet. useEffect(() => { const tick = () => { - if (document.visibilityState === "visible") void refreshUsage(); + if (document.visibilityState === "visible") void refreshContext(); }; tick(); - const id = window.setInterval(tick, 20000); + const id = window.setInterval(tick, 15000); return () => clearInterval(id); - }, [refreshUsage]); + }, [refreshContext]); - // cwd + label of open WSL panes, for highlighting matching sessions. - const openPanes = useMemo( - () => - Array.from(walkLeaves(tree)) - .filter((l) => l.shellKind === "wsl") - .map((l) => ({ cwd: l.cwd ?? "", label: l.label ?? l.distro ?? "pane" })), - [tree], - ); - - // Titlebar chip total — scoped to the open panes ("this workspace"), matching - // the usage panel's default view, so it isn't inflated by unrelated projects. - const workspaceUsageTotal = useMemo(() => { - const cwds = new Set(openPanes.map((p) => p.cwd).filter(Boolean)); - return totalCost(usageSessions.filter((s) => cwds.has(s.cwd))); - }, [openPanes, usageSessions]); + // cwd -> newest session's context, consumed by each LeafPane via orchestration. + const paneContext = useMemo(() => { + const m = new Map(); + for (const s of contextSessions) { + if (!s.cwd) continue; + const prev = m.get(s.cwd); + if (!prev || s.lastActiveMs > prev.lastActiveMs) m.set(s.cwd, s); + } + return m; + }, [contextSessions]); // Outside-click dismissal for the titlebar dropdowns. Mirrors the // per-pane shell-picker pattern in LeafPane.tsx. @@ -913,13 +902,6 @@ export default function App() { return; } - // Ctrl+Shift+U — usage panel - if (ctrl && shift && !alt && key === "u") { - e.preventDefault(); - e.stopPropagation(); - setUsagePanelOpen((v) => !v); - return; - } // Ctrl+Shift+Alt+B — global broadcast all/none if (ctrl && shift && alt && key === "b") { @@ -1353,6 +1335,7 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, + paneContext, }), [ activeLeafId, @@ -1378,6 +1361,7 @@ export default function App() { reportLeafIdle, moveToNewWindow, getInitialPaneIdFor, + paneContext, ], ); @@ -2154,14 +2138,6 @@ export default function App() { > 🤖 - - - {formatTokens(shown.reduce((a, s) => a + sessionTokens(s), 0))} tok - - {" · ~"} - {formatUsd(total)} - - - - - - -
- {shown.length === 0 ? ( -

- {loading - ? "Reading transcripts…" - : sessions.length > 0 && !showAll - ? "No open pane has a matching claude session yet." - : "No recent claude sessions found in the open panes' WSL distros."} - {sessions.length > 0 && !showAll && ( - <> - {" "} - - - )} -

- ) : ( -
    - {shown.map((s) => { - const paneLabel = paneByCwd.get(s.cwd); - const open = paneLabel !== undefined; - return ( -
  • - - {open ? "●" : "○"} - -
    -
    - - {projectName(s.cwd) || s.projectDir} - - {dominantModel(s)} - - {formatTokens(sessionTokens(s))} tok - - {formatUsd(sessionCost(s))} -
    -
    - - {s.cwd} - - {open && ( - [pane: {paneLabel}] - )} - {relativeTime(s.lastActiveMs, nowMs)} -
    -
    -
  • - ); - })} -
- )} -
- -
- ● = open pane  ·  ~$ is an API-pricing estimate (n/a on Pro/Max; - can't reflect /usage quota)  ·  recent sessions only -
- - - ); -} - -/** Last path segment of a cwd for a compact project label. */ -function projectName(cwd: string): string { - const parts = cwd.split("/").filter(Boolean); - return parts.length ? parts[parts.length - 1] : cwd; -} diff --git a/src/ipc.ts b/src/ipc.ts index 4600a5e..5eb0829 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -39,33 +39,25 @@ export interface SshHost { export const listDistros = (): Promise => invoke("list_distros"); -// ---- claude usage tracking ------------------------------------------------ +// ---- claude context tracking ---------------------------------------------- -/** Per-model token tally within one claude session. Mirrors Rust ModelUsage. */ -export interface ModelUsage { - model: string; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; -} - -/** One claude session's usage, read from its transcript. Mirrors Rust - * SessionUsage. Cost is computed frontend-side (see src/lib/usage.ts). */ -export interface SessionUsage { +/** One claude session's current context-window occupancy, read from its + * transcript. Mirrors Rust SessionContext. `contextTokens` is the prompt + * size of the last assistant turn (input + both cache buckets). */ +export interface SessionContext { sessionId: string; cwd: string; - projectDir: string; distro: string; lastActiveMs: number; - models: ModelUsage[]; + contextTokens: number; + model: string; } -/** Scan ~/.claude/projects in the given WSL distros (distinct distros of - * open WSL panes) and return recent sessions' token tallies. WSL/Windows - * only — returns [] otherwise. */ -export const getClaudeUsage = (distros: string[]): Promise => - invoke("get_claude_usage", { distros }); +/** Scan ~/.claude/projects in the given WSL distros (distinct distros of open + * WSL panes) and return each recent session's current context occupancy. + * WSL/Windows only — returns [] otherwise. */ +export const getPaneContext = (distros: string[]): Promise => + invoke("get_pane_context", { distros }); export const spawnPane = (args: { spec: SpawnSpec; diff --git a/src/lib/layout/LeafPane.css b/src/lib/layout/LeafPane.css index 785e5f2..bccac05 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,55 @@ background: #5a1a1a; color: #fcc; } + +/* ---- per-pane context-fill indicator ----------------------------------- */ +.pane-ctx { + /* Fallback right-anchor: when .pane-status is hidden (narrow tiers) its + margin-left:auto is gone, so carry it here too. First auto in DOM order + (status → ctx → actions) consumes the free space; the rest no-op. */ + margin-left: auto; + display: flex; + align-items: center; + gap: 5px; + font-size: 10px; + color: #9aa0a6; +} +.pane-ctx-bar { + width: 42px; + height: 6px; + background: #2a2a2a; + border-radius: 3px; + overflow: hidden; +} +.pane-ctx-fill { + display: block; + height: 100%; + border-radius: 3px; + transition: width 0.3s, background 0.3s; +} +.pane-ctx-pct { + font-variant-numeric: tabular-nums; + min-width: 26px; + text-align: right; +} + +/* ---- narrow-pane reflow ------------------------------------------------- + The close button + context indicator stay 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; +} +/* Keep just the % (drop the bar) at the tightest width. */ +.leaf--xnarrow .pane-ctx-bar { + 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 cbd53af..a158396 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -10,6 +10,12 @@ import { import { createPortal } from "react-dom"; import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree"; import { useOrchestration } from "./orchestration"; +import { + contextLabel, + contextPercent, + contextColor, + contextFraction, +} from "../../lib/usage"; import XtermPane from "../../components/XtermPane"; import type { SpawnSpec } from "../../ipc"; import "./LeafPane.css"; @@ -42,6 +48,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 +163,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) => { @@ -386,9 +409,13 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { }; })(); + const ctx = + leaf.shellKind === "wsl" && leaf.cwd ? orch.paneContext.get(leaf.cwd) : undefined; + return (
{status} )} + {ctx && ( + + + + + {contextPercent(ctx)}% + + )} + + + + + {/* 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/XtermPane.tsx b/src/components/XtermPane.tsx index a993c11..402b729 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -25,6 +25,11 @@ import { 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) @@ -76,6 +81,9 @@ 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. @@ -100,6 +108,7 @@ export default function XtermPane({ onFocus, focusTrigger = 0, fontSize, + colors, onNavigate, }: XtermPaneProps) { const containerRef = useRef(null); @@ -112,6 +121,9 @@ export default function XtermPane({ // 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. @@ -144,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, @@ -537,6 +551,27 @@ export default function XtermPane({ } }, [fontSize]); + // ------------------------------------------------------------------------- + // 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. diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index e087694..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"; @@ -547,6 +548,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { 🤖 + + {isIdle && statusOk ? ( idle @@ -604,6 +621,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { 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 46effad..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, 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; 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/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); + } +} From ca97fb373312812a4d26b066661836babc6a8040 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Mon, 1 Jun 2026 23:49:19 +0100 Subject: [PATCH 42/45] Bump version to 0.4.1 Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 102c58e..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", 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/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", From 8c6aded5d85d4d953cbd565b58a359fe68e7762b Mon Sep 17 00:00:00 2001 From: megaproxy Date: Mon, 1 Jun 2026 23:53:44 +0100 Subject: [PATCH 43/45] memory: customizable terminal colors session log (v0.4.1) Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/memory.md b/memory.md index 3c0a70f..5a6f7a7 100644 --- a/memory.md +++ b/memory.md @@ -108,6 +108,22 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos ## Session log +### 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). Pushed to origin/main. **Release not yet built** — next step is `pnpm tauri build` on Windows, then `scripts/release.sh v0.4.1` from WSL (script validates tag==package.json version, tags, builds .mcpb, uploads installer+.mcpb via `tea --login rdx4`). + ### 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`. From a72b2c3ff4d7591af108233a50c350bf697e0aca Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 2 Jun 2026 00:07:26 +0100 Subject: [PATCH 44/45] memory: note v0.4.1 release has wrong installer asset (0.4.0 .exe) + release.sh hardening TODO Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/memory.md b/memory.md index 5a6f7a7..dedafa1 100644 --- a/memory.md +++ b/memory.md @@ -122,7 +122,14 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos **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). Pushed to origin/main. **Release not yet built** — next step is `pnpm tauri build` on Windows, then `scripts/release.sh v0.4.1` from WSL (script validates tag==package.json version, tags, builds .mcpb, uploads installer+.mcpb via `tea --login rdx4`). +**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) From 738fa2e901df0728df2bdccf886d5e8177e34d15 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 11 Jun 2026 22:50:04 +0100 Subject: [PATCH 45/45] memory: log new claude-pane cursor-gap bug report + diagnosis plan --- memory.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/memory.md b/memory.md index dedafa1..cb38d7a 100644 --- a/memory.md +++ b/memory.md @@ -108,6 +108,16 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos ## 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**.