From 3c2f6b86404ff5b577206681c4629ab3c3f3011b Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 13:08:40 +0100 Subject: [PATCH] Add M4 orchestration: broadcast, idle notifications, palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree.ts - LeafNode gains broadcast?: boolean - walkLeaves(root) generator; toggleBroadcast helper ops.ts (PaneOps) - toggleBroadcast, broadcastFrom, setActivePane, registerPaneId, notify; activeLeafId data field. XtermPane.svelte - onSpawn(paneId), onInput(b64), onDataReceived(), and focusTrigger prop. All optional; backward-compatible. LeafPane.svelte - πŸ“‘ broadcast toggle; 5s idle detection -> ops.notify (once per idle cycle); active + broadcasting border colors; click-to-focus via setActivePane + focusTrigger bump. New Notifications.svelte - Top-right toast stack, slide-in, 5s auto-dismiss + click Γ—. New Palette.svelte - Modal overlay, backdrop, filtered leaf list with ↑/↓ + Enter, Escape to close. App.svelte - paneIdByLeaf Map for routing; notifications array + auto-dismiss; activeLeafId; Ctrl+K global listener; broadcastFrom routes via walkLeaves + writeToPane to all other broadcast leaves; ⌘K button in titlebar. Co-Authored-By: Claude Opus 4.7 (1M context) --- memory.md | 23 +++- src/App.svelte | 122 +++++++++++++++--- src/components/Notifications.svelte | 81 ++++++++++++ src/components/Palette.svelte | 188 ++++++++++++++++++++++++++++ src/components/XtermPane.svelte | 25 +++- src/lib/layout/LeafPane.svelte | 122 ++++++++++++++++-- src/lib/layout/ops.ts | 21 ++++ src/lib/layout/tree.ts | 24 ++++ 8 files changed, 578 insertions(+), 28 deletions(-) create mode 100644 src/components/Notifications.svelte create mode 100644 src/components/Palette.svelte diff --git a/memory.md b/memory.md index 57a66b0..832fa3c 100644 --- a/memory.md +++ b/memory.md @@ -17,15 +17,23 @@ Durable memory for this project. Read at session start, update before session en - **Pane operations bundled into a `PaneOps` interface** in `lib/layout/ops.ts`. Pane and SplitNode just pass `ops` through; LeafPane consumes it. Replaces M2's per-callback prop drilling (would have been split + close + setDistro + setLabel + distros = 5 separate props). Easier to grow as M4 adds broadcast / palette ops. - **Per-pane distro change forces a remount via id swap.** `changeDistro` in `tree.ts` assigns a new id to the leaf; `Pane.svelte`'s `{#key leaf.id}` unmounts XtermPane (which kills the old PTY) and mounts a fresh one with the new distro. Same mechanism we already use for split/close. - **Split inherits parent's distro AND cwd** (not label β€” label is a per-pane name, not a hierarchy thing). So "split right" while in a project keeps both panes in that project. +- **Broadcast input is frontend-routed, not a backend command.** Each LeafPane reports its backend `PaneId` to App via `ops.registerPaneId`. When a broadcasting pane's `XtermPane.onInput` fires, App's `broadcastFrom` walks all other leaves with `broadcast === true` and calls `writeToPane(theirPaneId, b64)`. No Rust changes needed; the existing per-pane write path does the work N times. Origin pane writes to its own PTY normally β€” broadcast is purely about mirroring to others. +- **Idle detection lives in LeafPane.** Each pane tracks `lastDataTime` (reset on every `XtermPane.onDataReceived`) and a `setInterval` that fires `ops.notify` after `IDLE_THRESHOLD_MS` (5000ms) of silence, once per idle cycle. No backend involvement β€” purely observes the existing PTY data stream. The "is foreground process claude" filter is **deferred** (would need a Rust-side foreground-process probe); for now every pane notifies after 5s of quiet. +- **In-app toasts (top-right stack), 5s auto-dismiss.** Lives in `Notifications.svelte`; App owns the array + auto-dismiss timer. Not native OS notifications β€” defer `tauri-plugin-notification` if/when we want desktop alerts that work when the app is backgrounded. +- **Ctrl+K palette: modal overlay with text filter on `label | distro | cwd`**, arrow-key nav, Enter to focus. Activating a pane sets `activeLeafId`; `LeafPane` has a `$derived` `active = ops.activeLeafId === leaf.id` and a `$effect` that bumps a `focusTrigger` counter when active flips true; `XtermPane` watches `focusTrigger` and calls `term.focus()`. Active pane gets a blue 1px border; broadcasting pane gets orange. ## Open questions / TODOs - [x] ~~**M2 β€” splits-tree layout component.** Two panes side by side, draggable divider, both panes alive. Save/restore layout as JSON.~~ Done 2026-05-22. - [x] ~~**M3 β€” workspace persistence + preset layouts + per-pane distro + pane labels.**~~ Done 2026-05-22. +- [x] ~~**M4 β€” orchestration.** Broadcast input, idle notifications, Ctrl+K palette.~~ Done 2026-05-22. - [x] ~~**Auto-save debouncing.**~~ 500ms timer in `App.svelte` `$effect`. -- [x] ~~**HMR distro picker reset.**~~ No longer an issue β€” per-pane distro selection via in-toolbar popover; titlebar `default:` only seeds new splits. +- [x] ~~**HMR distro picker reset.**~~ No longer an issue β€” per-pane distro selection. +- [ ] **Idle detection: filter by "claude is foreground."** Currently every pane notifies after 5s silence, which fires too eagerly when the user is reading a `claude` response. Want to detect that `claude` (or any user-specified process) is actually running in the pane's shell before notifying. Needs a Rust-side probe over WSL: `wsl.exe -d ps --ppid -o comm=`. Defer to a future polish pass. +- [ ] **Native OS notifications.** Right now toasts only show while the app is focused. `tauri-plugin-notification` would push to Windows Action Center; useful for "claude finished" when the app is minimized. Worth adding if/when the user actually backgrounds the app while waiting for sessions. +- [ ] **Configurable idle threshold.** Hardcoded 5000ms in `LeafPane.svelte`. Should move into a settings panel; M5 territory. - [ ] **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. -- [ ] **M4 β€” orchestration.** Broadcast input groups (write same bytes to N PTYs flagged into a group), idle/finish notifications (detect when a `claude` pane stops emitting output for >Ns β†’ toast), Ctrl+K fuzzy palette over `label / distro / cwd`. Will extend `PaneOps` with a couple more methods + add a "broadcast group" concept to the leaf type. +- [ ] **M5 β€” Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts. - [ ] **M5 β€” Ship.** Replace placeholder icons, NSIS installer, Forgejo release. Copy `claude-usage-widget`'s release scripts. - [ ] **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. @@ -33,6 +41,17 @@ Durable memory for this project. Read at session start, update before session en ## Session log +### 2026-05-22 β€” M4 orchestration (broadcast + notifications + palette) + +- `tree.ts`: added `broadcast?: boolean` to LeafNode; `walkLeaves` generator; `toggleBroadcast` helper (metadata-only, no id swap). +- `ops.ts`: extended `PaneOps` with `toggleBroadcast`, `broadcastFrom`, `setActivePane`, `registerPaneId`, `notify`, plus `activeLeafId` data field. +- `XtermPane.svelte`: added optional callbacks `onSpawn`, `onInput` (called after each writeToPane on user keypress), `onDataReceived` (called per PTY output chunk), and a `focusTrigger` prop (counter; bumping it refocuses the terminal). All optional; pre-M4 callers untouched. +- `LeafPane.svelte`: πŸ“‘ broadcast toggle in toolbar; idle detection (5s threshold, 1s polling, fires once per idle cycle); active/broadcasting border colors; click anywhere on the leaf sets it active; on active=true bumps focusTrigger so XtermPane refocuses. +- New `Notifications.svelte`: top-right toast stack, slide-in animation, 5s auto-dismiss + manual Γ—. +- New `Palette.svelte`: modal overlay with backdrop, autofocused text input, filtered list (label/distro/cwd substring), ↑/↓ navigation, Enter/click to pick, Escape to close. +- `App.svelte`: paneIdByLeaf Map (non-reactive lookup); notifications $state with auto-dismiss; activeLeafId; paletteOpen with global Ctrl+K listener; broadcastFrom routes via walkLeaves + writeToPane; ⌘K button in titlebar. +- `pnpm check` clean (111 files). + ### 2026-05-22 β€” M3 persistence + presets + per-pane distro/label - Backend: added `save_workspace(json)` and `load_workspace()` Tauri commands. Atomic write via tmp + rename. Path resolved from `app.path().app_config_dir()`. diff --git a/src/App.svelte b/src/App.svelte index f3e64c0..3464f08 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,19 +1,30 @@
@@ -175,6 +253,10 @@ + + {leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"} @@ -185,6 +267,16 @@ {/if}
+ + + + {#if paletteOpen} + (paletteOpen = false)} + /> + {/if} diff --git a/src/components/Palette.svelte b/src/components/Palette.svelte new file mode 100644 index 0000000..9f420c1 --- /dev/null +++ b/src/components/Palette.svelte @@ -0,0 +1,188 @@ + + + + + + + diff --git a/src/components/XtermPane.svelte b/src/components/XtermPane.svelte index e9fe79e..ccccd09 100644 --- a/src/components/XtermPane.svelte +++ b/src/components/XtermPane.svelte @@ -17,10 +17,22 @@ distro = undefined, cwd = undefined, onStatus = (_s: string, _ok: boolean) => {}, + onSpawn = undefined, + onInput = undefined, + onDataReceived = undefined, + focusTrigger = 0, }: { distro?: string; cwd?: string; onStatus?: (msg: string, ok: boolean) => void; + /** Fired once when the backend PTY is alive and we have its PaneId. */ + onSpawn?: (paneId: PaneId) => void; + /** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */ + onInput?: (dataB64: string) => void; + /** Fired whenever output arrives from the PTY. Used for idle detection. */ + onDataReceived?: () => void; + /** Increment to refocus the terminal programmatically (palette etc.). */ + focusTrigger?: number; } = $props(); let containerEl: HTMLDivElement; @@ -76,6 +88,7 @@ try { paneId = await spawnPane({ distro, cwd, cols, rows }); onStatus(`pane ${paneId} alive`, true); + onSpawn?.(paneId); } catch (e) { const msg = `spawn_pane failed: ${e}`; term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`); @@ -85,6 +98,7 @@ unlistenData = await onPaneData(paneId, (b64) => { term?.write(b64ToBytes(b64)); + onDataReceived?.(); }); unlistenExit = await onPaneExit(paneId, () => { term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); @@ -93,7 +107,9 @@ term.onData((data) => { if (paneId == null) return; - void writeToPane(paneId, stringToB64(data)); + const b64 = stringToB64(data); + void writeToPane(paneId, b64); + onInput?.(b64); }); // Re-fit on container resize; forward new size to the PTY. @@ -122,6 +138,13 @@ } term?.dispose(); }); + + // Refocus the terminal whenever the parent bumps focusTrigger. + $effect(() => { + // Reactive read on focusTrigger so this effect re-runs when it changes. + focusTrigger; + if (term && focusTrigger > 0) term.focus(); + });
diff --git a/src/lib/layout/LeafPane.svelte b/src/lib/layout/LeafPane.svelte index 51342d1..2defc64 100644 --- a/src/lib/layout/LeafPane.svelte +++ b/src/lib/layout/LeafPane.svelte @@ -1,4 +1,5 @@ -
+
{#if editingLabel} {#if distroOpen} -
e.stopPropagation()} role="menu" tabindex="-1" onkeydown={() => {}}> +
e.stopPropagation()} + role="menu" + tabindex="-1" + onkeydown={() => {}} + > {#each ops.distros as d} + {status} @@ -141,6 +213,10 @@ status = msg; statusOk = ok; }} + onSpawn={onPaneSpawned} + onInput={onTerminalInput} + {onDataReceived} + {focusTrigger} />
@@ -153,7 +229,19 @@ height: 100%; min-width: 0; min-height: 0; + border: 1px solid transparent; + box-sizing: border-box; } + .leaf.active { + border-color: #3a5a8c; + } + .leaf.broadcasting { + border-color: #c98a1f; + } + .leaf.active.broadcasting { + border-color: #e0a432; + } + .pane-toolbar { flex: 0 0 auto; display: flex; @@ -202,7 +290,8 @@ .distro-wrap { position: relative; } - .distro-chip { + .distro-chip, + .bcast-chip { font: inherit; font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; font-size: 10px; @@ -213,10 +302,23 @@ padding: 1px 6px; cursor: pointer; } - .distro-chip:hover { + .distro-chip:hover, + .bcast-chip:hover { background: #2a2a3a; color: #aac; } + .bcast-chip { + color: #777; + background: #1c1c1c; + border-color: #2a2a2a; + padding: 1px 5px; + } + .bcast-chip.on { + background: #4a3010; + color: #f0c060; + border-color: #c98a1f; + } + .distro-menu { position: absolute; top: 100%; diff --git a/src/lib/layout/ops.ts b/src/lib/layout/ops.ts index ba3d704..52e12b7 100644 --- a/src/lib/layout/ops.ts +++ b/src/lib/layout/ops.ts @@ -1,4 +1,5 @@ import type { NodeId, Orientation } from "./tree"; +import type { PaneId } from "../../ipc"; /** * Bundle of operations + data that any pane in the tree may need. @@ -6,10 +7,30 @@ import type { NodeId, Orientation } from "./tree"; * prop drilling. */ export interface PaneOps { + // ---- tree mutation split: (leafId: NodeId, orientation: Orientation) => void; close: (leafId: NodeId) => void; setDistro: (leafId: NodeId, distro: string) => void; setLabel: (leafId: NodeId, label: string | undefined) => void; + toggleBroadcast: (leafId: NodeId) => void; + + // ---- orchestration (M4) + /** + * Called from a broadcasting pane when its user types. App looks up + * every other broadcast-enabled leaf and writes the same bytes to it. + * Origin pane's own PTY is written by XtermPane directly. + */ + broadcastFrom: (originLeafId: NodeId, dataB64: string) => void; + /** Mark a leaf as the active (focused) pane. */ + setActivePane: (leafId: NodeId) => void; + /** LeafPane reports its backend PaneId once spawned, or null on destroy. */ + registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void; + /** Append a transient toast to the notification stack. */ + notify: (message: string) => void; + + // ---- data /** All distros known to the backend; populated once at app start. */ distros: string[]; + /** The currently-focused pane, if any. */ + activeLeafId: NodeId | null; } diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index d9f6b49..2770312 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -19,6 +19,12 @@ export interface LeafNode { cwd?: string; /** Optional user label shown in the pane toolbar. */ label?: string; + /** + * If true, keystrokes typed in this pane are mirrored to every other + * leaf with `broadcast === true`. Toggle via the πŸ“‘ button in the + * pane toolbar. + */ + broadcast?: boolean; } export interface SplitNode { @@ -174,6 +180,24 @@ export function leafCount(root: TreeNode): number { return leafCount(root.a) + leafCount(root.b); } +/** Iterate all leaves in left-to-right order. */ +export function* walkLeaves(root: TreeNode): Generator { + if (root.kind === "leaf") { + yield root; + } else { + yield* walkLeaves(root.a); + yield* walkLeaves(root.b); + } +} + +/** Toggle a leaf's broadcast flag. Metadata-only β€” does NOT swap the id, so the pane is not respawned. */ +export function toggleBroadcast(root: TreeNode, leafId: NodeId): TreeNode { + return replaceById(root, leafId, (node) => { + if (node.kind !== "leaf") return node; + return { ...node, broadcast: !node.broadcast }; + }); +} + export function serialize(root: TreeNode): string { return JSON.stringify(root); }