For a subscription user, lifetime token totals + a $ estimate aren't
actionable; how full each session's context window is right now is. So:
- Removed the UsagePanel, the titlebar 💰 chip, and Ctrl+Shift+U.
- Repurposed the transcript reader (src-tauri/src/usage.rs): get_pane_context
returns each recent session's CURRENT context occupancy = the last
assistant turn's input + cache_read + cache_creation tokens (the prompt
size), instead of lifetime sums. Same UNC/$HOME/cache/recency machinery.
- src/lib/usage.ts now holds context helpers (window inference 200k vs 1M by
whether occupancy already exceeds 200k, % , green→amber→red ramp, label).
- App polls get_pane_context (15s, visibility-gated) into a cwd→context map
exposed via orchestration; each LeafPane looks itself up by leaf.cwd and
renders a slim fill bar + % in its header (hidden for non-claude/unmatched
panes).
Also fixes the narrow-pane toolbar: a ResizeObserver sets leaf--narrow /
leaf--xnarrow width tiers; the label shrinks first, split buttons / status /
secondary chips drop out by tier, and the close × + context indicator stay
pinned right and visible down to the 180px min width.
tsc clean (apart from the not-yet-installed xterm addons). Rust builds on
the Windows host; needs runtime verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three xterm.js features, implemented together because they share the
XtermPane mount + the single attachCustomKeyEventHandler:
- Unicode 11: load @xterm/addon-unicode11, set activeVersion='11' after
the canvas renderer so emoji/CJK/box-drawing widths stop drifting.
- Find in scrollback: @xterm/addon-search + a new per-pane SearchBar
overlay (Ctrl+Shift+F to open, Enter/Shift+Enter next/prev, regex +
case toggles, Esc to close & refocus). Overlay is an absolutely-
positioned sibling in a position:relative wrapper so fit() is unaffected.
- Pane navigation: Ctrl+Alt+Arrow / Ctrl+Alt+HJKL (spatial neighbour via
findNeighborInDirection) and Alt+1..9 (Nth leaf in walkLeaves order).
XtermPane emits a NavigateIntent; App resolves the target leaf and sets
it active, reusing the existing isActive->focusTrigger refocus chain.
All chords live in one attachCustomKeyEventHandler (xterm replaces the
handler on each call). Shortcuts added to shortcuts.ts (SoT for README +
Help), including the Alt+digit shell-conflict caveat. tsc clean apart
from the three not-yet-installed addon modules.
Needs pnpm install on the Windows host + runtime verification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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 <body> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Reverts in one combined commit:
- 9931a92 (inline pane_id + watch list into bash script)
- 6772b8d (pivot per-distro → per-pane via TILETOPIA_PANE_ID env)
- f51033a (original per-distro idle filter)
End-to-end probe never worked correctly against the real running app
even after fixing the wsl.exe-drops-positional-args bug. Probe script
ran fine in isolation but kept returning false-negative when called
through tiletopia's wsl.exe spawn. Rather than keep iterating, back
out cleanly — pane behaviour is now the original "go idle after 5s of
silence regardless of what's running."
memory.md session log notes the lessons for a future retry: don't ship
per-distro again (CLAUDE.md explicitly says multi-claude-per-distro is
the primary use case); prove the probe end-to-end before wiring into
the idle effect (a "Test probe" button in MCP panel would have caught
this in minutes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-distro suppression (shipped earlier today) broke tiletopia's primary
use case — multiple claude panes per distro means as soon as one runs
claude, ALL Ubuntu panes go silent. Tested live: user couldn't reproduce
idle on any pane because PID 46848 (their main session) tripped the gate.
New mechanism, per-pane via env-var marker:
1. pty.rs tags every WSL spawn with TILETOPIA_PANE_ID=<id> as a Windows
env var, plus WSLENV=...TILETOPIA_PANE_ID/u (appended to any pre-
existing WSLENV) so the var forwards into the distro. Pane id is now
reserved BEFORE build_command so the tag is available at spawn time.
2. probe.rs rewritten — is_watch_process_running(distro, pane_id) runs
a bash one-liner that pgreps for each watched name, then for each PID
checks /proc/<pid>/environ for the matching TILETOPIA_PANE_ID line.
Env inheritance does the work: shell inherits from wsl.exe, claude
inherits from shell. Cache keyed by (distro, pane_id).
3. Fail-safe INVERTED: probe failure now returns false (don't suppress)
instead of true (suppress). A transient error should never silence
the idle indicator permanently. Frontend catch updated to match.
4. LeafPane tracks PaneId in paneIdRef set by onPaneSpawned; idle ticks
before spawn-completion pass 0, which won't match any real marker so
the pane idles normally.
Existing panes won't have the marker until respawned — they'll always
show idle (since probe never matches). User opens fresh panes once after
deploying this. Documented in memory.md follow-ups.
pnpm check clean. Rust validation: cargo test --lib on Windows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Probes wsl.exe -d <distro> -- pgrep -x claude before flagging a WSL pane
idle, with a 3s per-distro cache on the Rust side. If claude is running
anywhere in the distro, all panes in that distro stay out of the idle set
(per-pane granularity is out of scope — PIDs aren't observable from
Windows). PowerShell + SSH panes skip the probe and keep the legacy
always-notify behaviour.
Each leaf now carries an optional fontSizeOffset, persisted in
workspace.json alongside everything else. Ctrl+= / Ctrl+- / Ctrl+0
adjust the active pane; adding Shift escalates to every pane (the
mirror of the broadcast Shift+Alt convention, with shift alone since
the keys are otherwise unused). Bindings match on e.code so layouts
that don't have "=" / "-" / "0" in the same spot still work.
XtermPane gained a fontSize prop. A secondary effect reacts to changes:
set term.options.fontSize, fit() to recompute cols/rows for the new
cell size, refresh(), then resizePane so bash redraws the prompt at
the right width. No remount, so PTY + scrollback survive zoom changes.
The new tree helpers (resolveFontSize / adjustFontSize /
adjustAllFontSizes) are metadata-only — they don't swap leaf ids, so
nothing respawns. reshapeToPreset also carries the offset across when
splicing existing leaves into a new layout. 12 new vitest cases pin
those invariants plus the clamp and reset-to-default behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| Ctrl+K | palette |
| Ctrl+Shift+E | split active pane right |
| Ctrl+Shift+O | split active pane down |
| Ctrl+Shift+W | close active pane |
| Ctrl+Shift+B | toggle broadcast on active |
| Ctrl+Shift+Alt+B | toggle broadcast on ALL panes |
| Ctrl+Shift+Arrow | focus neighbour pane in that direction |
The handler attaches at capture phase on window so it wins against
xterm.js. It bails when a non-terminal <input>/<textarea> is focused
so label edits and the palette input keep working normally.
Spatial neighbour-finding lives in tree.ts as findNeighborInDirection
— picks the leaf whose centre is most aligned in the perpendicular
axis, breaking ties by primary-axis distance.
Tooltips on toolbar/titlebar buttons now mention their shortcuts;
README has a key-binding table.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old behaviour: every pane fired orch.notify("X is idle") after 5s of
silence, stacking up to N toasts that took ages to dismiss.
New behaviour:
- LeafPane tracks its own isIdle state locally and reports up via
orch.reportLeafIdle(leafId, idle).
- App aggregates into a Set<NodeId> and renders "N idle" in red after
the "N panes" count in the titlebar (hidden when zero).
- The pane itself gets a red border (.leaf.idle) — but active and
broadcasting borders still take precedence, so the focus indicator
isn't masked by idle status.
- The pane's "alive" status text in the toolbar swaps to red "idle"
while it's quiet (reverts to "alive" the moment output arrives).
- Idle clears immediately on the next byte of output (no 1-second lag)
AND when the pane unmounts (cleanup effect).
No more flood of toasts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New interaction: click-and-drag any pane's toolbar onto another pane
to swap their positions in the tree. The shells / scrollback stay
intact (each leaf keeps its data; only the tree slot it occupies
changes).
Implementation:
- tree.ts: `swapLeaves(root, idA, idB)` walks the tree once,
substituting one leaf for the other at each occurrence. The leaf
objects themselves carry their id/distro/cwd/label/broadcast across,
so React preserves the LeafPane instances via the flat-list keying.
- orchestration.tsx: add drag lifecycle to the context —
dragSourceId / dragOverId (reactive) plus beginHeaderDrag,
setHeaderDragOver, endHeaderDrag (stable methods).
- App.tsx: implement those methods. endHeaderDrag(true) swaps if
source and over are different leaves.
- LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target
is a button/input). 5px movement threshold before drag commits to
prevent accidental swaps when clicking a chip etc. Pointer-capture
the toolbar so we keep getting move events even outside it. Use
document.elementFromPoint to find the leaf under the cursor.
- CSS: source pane fades to 40% opacity during drag; target pane
shows a 3px dashed blue outline; toolbar shows grab/grabbing
cursors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bug: clicking 📡 made the visual update (orange border) but typing
in a broadcasting pane only wrote to that pane — peers never received
the keystrokes.
Root cause: the orch context value (useMemo'd over activeLeafId,
distros, and the operation callbacks) is recreated every time
activeLeafId changes (i.e. every click). useEffect cleanups in
LeafPane that had `orch` in their deps fired their cleanup-then-setup
cycle on every click. The unmount-cleanup for paneId registration
ran `orch.registerPaneId(leaf.id, null)`, silently deleting paneIds
from App's paneIdByLeafRef map — so when broadcastFrom later walked
the tree looking up peers, the map returned undefined for every leaf
and the actual writeToPane calls never happened.
Fix: depend on the specific stable method references
(`orch.registerPaneId`, `orch.notify`, etc.) instead of the orch
object itself. The methods are all useCallback'd with stable deps
in App.tsx, so their references don't change across orch object
recreations — effect deps stay stable, no spurious cleanup.
Applied the same fix to all orch-using effects/callbacks in
LeafPane (commitLabel, pickDistro, onPaneClick, onPaneSpawned,
onXtermFocus, onTerminalInput, idle interval, paneId cleanup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After hours of fighting Svelte 5's prop-reactivity through the
recursive Pane → SplitNode → LeafPane chain (props captured at
mount, never updated; context+getter pattern crashed; DOM-direct
workarounds created zombie-split click-intercept bugs), we
checkpointed the Svelte version (branch svelte-archive at e9015b2,
tarball at D:\archives\tiletopia-svelte-2026-05-22.tar.gz) and
rewrote the frontend in React.
Kept verbatim:
- All of src-tauri/ (Rust backend, Tauri config, icons)
- scripts/ (make-icon.py, release.sh)
- README.md, CLAUDE.md, memory.md
- src/lib/layout/tree.ts (pure TS — 43 tests still pass)
- src/ipc.ts (Tauri command wrappers)
Rewrote in React:
- src/App.tsx (top-level state via useState, OrchestrationProvider
for descendants via React.Context)
- src/lib/layout/orchestration.tsx (React Context API for shared
state — known-reliable reactivity, no Svelte 5 wall)
- src/lib/layout/Pane.tsx (recursive dispatcher)
- src/lib/layout/SplitNode.tsx (draggable gutter, local ratio state)
- src/lib/layout/LeafPane.tsx (toolbar + XtermPane)
- src/components/XtermPane.tsx (xterm.js wrapper, refs for callbacks)
- src/components/Notifications.tsx, Palette.tsx
Build: Vite + @vitejs/plugin-react. TypeScript strict. Same Tauri 2
config. Verified: pnpm check (clean), pnpm test (43/43 pass).
Not yet verified: pnpm tauri dev — that requires the Windows host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>