Add M4 orchestration: broadcast, idle notifications, palette

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) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 13:08:40 +01:00
parent 64b90ebddb
commit 3c2f6b8640
8 changed files with 578 additions and 28 deletions

View file

@ -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. - **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. - **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. - **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 ## 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] ~~**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] ~~**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] ~~**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 <distro> ps --ppid <shell_pid> -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 M0M5 critical path; either bolt on after M5 ship or fold into a "tabs" minor milestone. - [ ] **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 M0M5 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. - [ ] **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. - [ ] **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. - [ ] **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 ## 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 ### 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()`. - Backend: added `save_workspace(json)` and `load_workspace()` Tauri commands. Atomic write via tmp + rename. Path resolved from `app.path().app_config_dir()`.

View file

@ -1,19 +1,30 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { listDistros, saveWorkspace, loadWorkspace } from "./ipc"; import {
listDistros,
saveWorkspace,
loadWorkspace,
writeToPane,
type PaneId,
} from "./ipc";
import Pane from "./lib/layout/Pane.svelte"; import Pane from "./lib/layout/Pane.svelte";
import Notifications, { type Toast } from "./components/Notifications.svelte";
import Palette from "./components/Palette.svelte";
import type { PaneOps } from "./lib/layout/ops"; import type { PaneOps } from "./lib/layout/ops";
import { import {
type TreeNode, type TreeNode,
type NodeId, type NodeId,
type Orientation, type Orientation,
type LeafNode,
newLeaf, newLeaf,
splitLeaf, splitLeaf,
closeLeaf, closeLeaf,
findLeaf, findLeaf,
leafCount, leafCount,
walkLeaves,
changeDistro, changeDistro,
changeLabel, changeLabel,
toggleBroadcast as toggleBroadcastInTree,
serialize, serialize,
deserialize, deserialize,
presetSingle, presetSingle,
@ -30,12 +41,21 @@
let ready = $state(false); let ready = $state(false);
let tree = $state<TreeNode>(newLeaf()); let tree = $state<TreeNode>(newLeaf());
// ---- orchestration state (M4) -------------------------------------------
// leafId -> backend PaneId. Plain Map (no reactivity needed — only read
// from event handlers). Repopulated on every XtermPane mount.
const paneIdByLeaf = new Map<NodeId, PaneId>();
let notifications = $state<Toast[]>([]);
let nextNotifId = 1;
let activeLeafId = $state<NodeId | null>(null);
let paletteOpen = $state(false);
function isInteractiveDistro(name: string): boolean { function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop"); return !name.toLowerCase().startsWith("docker-desktop");
} }
onMount(async () => { onMount(async () => {
// 1. Try the new APPDATA persistence. // 1. Try APPDATA persistence.
let loaded: TreeNode | null = null; let loaded: TreeNode | null = null;
try { try {
const json = await loadWorkspace(); const json = await loadWorkspace();
@ -44,17 +64,13 @@
console.warn("loadWorkspace failed:", e); console.warn("loadWorkspace failed:", e);
} }
// 2. Fall back to the M2 localStorage layout (one-time migration). // 2. Migrate from M2 localStorage if APPDATA is empty.
if (!loaded) { if (!loaded) {
try { try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY); const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) { if (legacy) {
loaded = deserialize(legacy); loaded = deserialize(legacy);
if (loaded) { if (loaded) void saveWorkspace(legacy);
// Promote to APPDATA so it survives future loads even without
// localStorage. Fire-and-forget; debounced save will catch it too.
void saveWorkspace(legacy);
}
localStorage.removeItem(LEGACY_STORAGE_KEY); localStorage.removeItem(LEGACY_STORAGE_KEY);
} }
} catch (e) { } catch (e) {
@ -72,10 +88,7 @@
console.warn("list_distros failed:", e); console.warn("list_distros failed:", e);
} }
// 4. Backfill distro on any leaves that lack one (handles first launch
// and trees saved before defaults were resolved).
if (defaultDistro) backfillDistro(tree, defaultDistro); if (defaultDistro) backfillDistro(tree, defaultDistro);
ready = true; ready = true;
}); });
@ -104,10 +117,20 @@
}, SAVE_DEBOUNCE_MS); }, SAVE_DEBOUNCE_MS);
}); });
// ---- Ctrl+K palette toggle ----------------------------------------------
$effect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
paletteOpen = !paletteOpen;
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
// ---- pane ops ------------------------------------------------------------ // ---- pane ops ------------------------------------------------------------
function handleSplit(leafId: NodeId, orientation: Orientation) { function handleSplit(leafId: NodeId, orientation: Orientation) {
// Inherit distro + cwd from the parent leaf so split-from-project
// keeps both panes in the same context.
const parent = findLeaf(tree, leafId); const parent = findLeaf(tree, leafId);
const inherit = parent const inherit = parent
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd } ? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
@ -118,6 +141,7 @@
function handleClose(leafId: NodeId) { function handleClose(leafId: NodeId) {
const next = closeLeaf(tree, leafId); const next = closeLeaf(tree, leafId);
tree = next ?? newLeaf({ distro: defaultDistro }); tree = next ?? newLeaf({ distro: defaultDistro });
if (activeLeafId === leafId) activeLeafId = null;
} }
function handleSetDistro(leafId: NodeId, distro: string) { function handleSetDistro(leafId: NodeId, distro: string) {
@ -128,12 +152,55 @@
tree = changeLabel(tree, leafId, label); tree = changeLabel(tree, leafId, label);
} }
function handleToggleBroadcast(leafId: NodeId) {
tree = toggleBroadcastInTree(tree, leafId);
}
function handleBroadcastFrom(originLeafId: NodeId, dataB64: string) {
for (const leaf of walkLeaves(tree)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = paneIdByLeaf.get(leaf.id);
if (paneId == null) continue;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("broadcast write failed:", e),
);
}
}
function handleSetActivePane(leafId: NodeId) {
activeLeafId = leafId;
}
function handleRegisterPaneId(leafId: NodeId, paneId: PaneId | null) {
if (paneId == null) paneIdByLeaf.delete(leafId);
else paneIdByLeaf.set(leafId, paneId);
}
function handleNotify(message: string) {
const id = nextNotifId++;
notifications.push({ id, message });
setTimeout(() => {
notifications = notifications.filter((n) => n.id !== id);
}, 5000);
}
function dismissNotification(id: number) {
notifications = notifications.filter((n) => n.id !== id);
}
const ops: PaneOps = $derived({ const ops: PaneOps = $derived({
split: handleSplit, split: handleSplit,
close: handleClose, close: handleClose,
setDistro: handleSetDistro, setDistro: handleSetDistro,
setLabel: handleSetLabel, setLabel: handleSetLabel,
toggleBroadcast: handleToggleBroadcast,
broadcastFrom: handleBroadcastFrom,
setActivePane: handleSetActivePane,
registerPaneId: handleRegisterPaneId,
notify: handleNotify,
distros, distros,
activeLeafId,
}); });
// ---- preset layouts ------------------------------------------------------ // ---- preset layouts ------------------------------------------------------
@ -144,6 +211,17 @@
} }
tree = make({ distro: defaultDistro }); tree = make({ distro: defaultDistro });
} }
// ---- palette feed --------------------------------------------------------
const paletteLeaves = $derived.by<LeafNode[]>(() => {
if (!paletteOpen) return [];
return Array.from(walkLeaves(tree));
});
function onPalettePick(leafId: string) {
activeLeafId = leafId;
paletteOpen = false;
}
</script> </script>
<div class="app"> <div class="app">
@ -175,6 +253,10 @@
<button class="preset-btn" title="2 × 2 grid" onclick={() => applyPreset(presetTwoByTwo)}>2×2</button> <button class="preset-btn" title="2 × 2 grid" onclick={() => applyPreset(presetTwoByTwo)}>2×2</button>
</span> </span>
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
⌘K
</button>
<span class="layout-info"> <span class="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"} {leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
</span> </span>
@ -185,6 +267,16 @@
<Pane node={tree} {ops} /> <Pane node={tree} {ops} />
{/if} {/if}
</div> </div>
<Notifications {notifications} onDismiss={dismissNotification} />
{#if paletteOpen}
<Palette
leaves={paletteLeaves}
onPick={onPalettePick}
onClose={() => (paletteOpen = false)}
/>
{/if}
</div> </div>
<style> <style>
@ -193,7 +285,7 @@
gap: 4px; gap: 4px;
align-items: center; align-items: center;
} }
.distro-btn, .preset-btn { .distro-btn, .preset-btn, .palette-btn {
font: inherit; font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px; font-size: 11px;
@ -204,7 +296,7 @@
padding: 2px 8px; padding: 2px 8px;
cursor: pointer; cursor: pointer;
} }
.distro-btn:hover, .preset-btn:hover { .distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
background: #2a2a2a; background: #2a2a2a;
color: #ddd; color: #ddd;
} }

View file

@ -0,0 +1,81 @@
<script lang="ts">
export interface Toast {
id: number;
message: string;
}
let {
notifications,
onDismiss,
}: {
notifications: Toast[];
onDismiss: (id: number) => void;
} = $props();
</script>
<div class="toast-stack">
{#each notifications as t (t.id)}
<div class="toast">
<span class="toast-msg">{t.message}</span>
<button
class="toast-x"
onclick={() => onDismiss(t.id)}
aria-label="Dismiss notification"
>×</button>
</div>
{/each}
</div>
<style>
.toast-stack {
position: fixed;
top: 36px;
right: 12px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 100;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
min-width: 220px;
max-width: 320px;
padding: 8px 10px 8px 14px;
background: #1f1f1f;
color: #ddd;
border: 1px solid #3a5a8c;
border-left-width: 3px;
border-radius: 4px;
font-size: 12px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
animation: slide-in 180ms ease-out;
}
.toast-msg {
flex: 1 1 auto;
line-height: 1.3;
word-break: break-word;
}
.toast-x {
flex: 0 0 auto;
background: transparent;
border: none;
color: #777;
font-size: 16px;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
border-radius: 3px;
}
.toast-x:hover {
background: #2a2a2a;
color: #ddd;
}
@keyframes slide-in {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import type { LeafNode } from "../lib/layout/tree";
let {
leaves,
onPick,
onClose,
}: {
leaves: LeafNode[];
onPick: (leafId: string) => void;
onClose: () => void;
} = $props();
let query = $state("");
let inputEl: HTMLInputElement | null = $state(null);
let highlightIndex = $state(0);
$effect(() => {
queueMicrotask(() => inputEl?.focus());
});
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return leaves;
return leaves.filter((l) => {
const blob = `${l.label ?? ""} ${l.distro ?? ""} ${l.cwd ?? ""}`.toLowerCase();
return blob.includes(q);
});
});
// Clamp highlight whenever the filtered list changes.
$effect(() => {
if (highlightIndex >= filtered.length) highlightIndex = Math.max(0, filtered.length - 1);
});
function pick(idx: number) {
const l = filtered[idx];
if (l) onPick(l.id);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (filtered.length > 0) {
highlightIndex = (highlightIndex + 1) % filtered.length;
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (filtered.length > 0) {
highlightIndex = (highlightIndex - 1 + filtered.length) % filtered.length;
}
} else if (e.key === "Enter") {
e.preventDefault();
pick(highlightIndex);
}
}
</script>
<button
class="backdrop"
onclick={onClose}
aria-label="Close palette"
></button>
<div class="palette" role="dialog" aria-label="Jump to pane">
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKey}
placeholder="Jump to pane — type to filter, ↑/↓ + Enter"
class="palette-input"
/>
<ul class="palette-list">
{#if filtered.length === 0}
<li class="empty">No matching panes.</li>
{:else}
{#each filtered as leaf, i}
<li>
<button
class="palette-item"
class:highlight={i === highlightIndex}
onclick={() => pick(i)}
onmouseenter={() => (highlightIndex = i)}
>
<span class="name">{leaf.label ?? "(unnamed)"}</span>
<span class="meta">{leaf.distro ?? "default"}</span>
{#if leaf.cwd}<span class="meta cwd">{leaf.cwd}</span>{/if}
{#if leaf.broadcast}<span class="meta bcast">📡</span>{/if}
</button>
</li>
{/each}
{/if}
</ul>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
z-index: 99;
border: 0;
padding: 0;
cursor: default;
}
.palette {
position: fixed;
top: 12vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 90vw);
background: #181818;
border: 1px solid #2a2a2a;
border-radius: 6px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
}
.palette-input {
font: inherit;
font-size: 14px;
color: #fff;
background: #1f1f1f;
border: none;
border-bottom: 1px solid #2a2a2a;
padding: 10px 14px;
outline: none;
}
.palette-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 50vh;
overflow-y: auto;
}
.palette-item {
width: 100%;
text-align: left;
background: transparent;
border: none;
color: #ccc;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: baseline;
gap: 10px;
font: inherit;
font-size: 12px;
}
.palette-item.highlight {
background: #1a3a5c;
color: #cce6ff;
}
.name {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-weight: 600;
}
.meta {
color: #888;
font-size: 11px;
}
.palette-item.highlight .meta {
color: #9bd;
}
.meta.cwd {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta.bcast {
margin-left: auto;
}
.empty {
color: #777;
font-style: italic;
padding: 8px 10px;
list-style: none;
}
</style>

View file

@ -17,10 +17,22 @@
distro = undefined, distro = undefined,
cwd = undefined, cwd = undefined,
onStatus = (_s: string, _ok: boolean) => {}, onStatus = (_s: string, _ok: boolean) => {},
onSpawn = undefined,
onInput = undefined,
onDataReceived = undefined,
focusTrigger = 0,
}: { }: {
distro?: string; distro?: string;
cwd?: string; cwd?: string;
onStatus?: (msg: string, ok: boolean) => void; 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(); } = $props();
let containerEl: HTMLDivElement; let containerEl: HTMLDivElement;
@ -76,6 +88,7 @@
try { try {
paneId = await spawnPane({ distro, cwd, cols, rows }); paneId = await spawnPane({ distro, cwd, cols, rows });
onStatus(`pane ${paneId} alive`, true); onStatus(`pane ${paneId} alive`, true);
onSpawn?.(paneId);
} catch (e) { } catch (e) {
const msg = `spawn_pane failed: ${e}`; const msg = `spawn_pane failed: ${e}`;
term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`); term.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
@ -85,6 +98,7 @@
unlistenData = await onPaneData(paneId, (b64) => { unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64)); term?.write(b64ToBytes(b64));
onDataReceived?.();
}); });
unlistenExit = await onPaneExit(paneId, () => { unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n"); term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
@ -93,7 +107,9 @@
term.onData((data) => { term.onData((data) => {
if (paneId == null) return; 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. // Re-fit on container resize; forward new size to the PTY.
@ -122,6 +138,13 @@
} }
term?.dispose(); 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();
});
</script> </script>
<div class="xterm-host" bind:this={containerEl}></div> <div class="xterm-host" bind:this={containerEl}></div>

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte";
import type { LeafNode } from "./tree"; import type { LeafNode } from "./tree";
import type { PaneOps } from "./ops"; import type { PaneOps } from "./ops";
import XtermPane from "../../components/XtermPane.svelte"; import XtermPane from "../../components/XtermPane.svelte";
@ -11,6 +12,8 @@
ops: PaneOps; ops: PaneOps;
} = $props(); } = $props();
const active = $derived(ops.activeLeafId === leaf.id);
let status = $state("starting…"); let status = $state("starting…");
let statusOk = $state(true); let statusOk = $state(true);
@ -19,10 +22,10 @@
let labelDraft = $state(""); let labelDraft = $state("");
let labelInputEl: HTMLInputElement | null = $state(null); let labelInputEl: HTMLInputElement | null = $state(null);
function startEditLabel() { function startEditLabel(e: MouseEvent) {
e.stopPropagation();
labelDraft = leaf.label ?? ""; labelDraft = leaf.label ?? "";
editingLabel = true; editingLabel = true;
// Focus the input after Svelte renders it.
queueMicrotask(() => labelInputEl?.select()); queueMicrotask(() => labelInputEl?.select());
} }
@ -59,16 +62,71 @@
if (d !== leaf.distro) ops.setDistro(leaf.id, d); if (d !== leaf.distro) ops.setDistro(leaf.id, d);
} }
// Dismiss popover on outside click.
$effect(() => { $effect(() => {
if (!distroOpen) return; if (!distroOpen) return;
const onDocClick = () => (distroOpen = false); const onDocClick = () => (distroOpen = false);
window.addEventListener("click", onDocClick); window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick); return () => window.removeEventListener("click", onDocClick);
}); });
// ---- idle detection ------------------------------------------------------
const IDLE_THRESHOLD_MS = 5000;
let lastDataTime = Date.now();
let notifiedThisIdle = false;
let idleTimer: number | null = null;
function onDataReceived() {
lastDataTime = Date.now();
notifiedThisIdle = false;
}
function checkIdle() {
if (notifiedThisIdle) return;
if (Date.now() - lastDataTime >= IDLE_THRESHOLD_MS) {
notifiedThisIdle = true;
const name = leaf.label ?? leaf.distro ?? "pane";
ops.notify(`${name} is idle`);
}
}
idleTimer = window.setInterval(checkIdle, 1000);
onDestroy(() => {
if (idleTimer != null) clearInterval(idleTimer);
});
// ---- broadcast -----------------------------------------------------------
function onTerminalInput(b64: string) {
if (leaf.broadcast) ops.broadcastFrom(leaf.id, b64);
}
// ---- focus / active ------------------------------------------------------
let focusTrigger = $state(0);
$effect(() => {
if (active) focusTrigger += 1;
});
function onPaneClick() {
if (!active) ops.setActivePane(leaf.id);
}
// ---- pane id registration ------------------------------------------------
function onPaneSpawned(paneId: number) {
ops.registerPaneId(leaf.id, paneId);
}
onDestroy(() => {
ops.registerPaneId(leaf.id, null);
});
</script> </script>
<div class="leaf"> <div
class="leaf"
class:active
class:broadcasting={leaf.broadcast}
role="group"
aria-label={"Terminal pane: " + (leaf.label ?? leaf.distro ?? "unnamed")}
onpointerdown={onPaneClick}
>
<div class="pane-toolbar"> <div class="pane-toolbar">
{#if editingLabel} {#if editingLabel}
<input <input
@ -98,7 +156,13 @@
{leaf.distro ?? "(default)"} {leaf.distro ?? "(default)"}
</button> </button>
{#if distroOpen} {#if distroOpen}
<div class="distro-menu" onclick={(e) => e.stopPropagation()} role="menu" tabindex="-1" onkeydown={() => {}}> <div
class="distro-menu"
onclick={(e) => e.stopPropagation()}
role="menu"
tabindex="-1"
onkeydown={() => {}}
>
{#each ops.distros as d} {#each ops.distros as d}
<button <button
class="distro-menu-item" class="distro-menu-item"
@ -110,25 +174,33 @@
{/if} {/if}
</span> </span>
<button
class="bcast-chip"
class:on={leaf.broadcast}
onclick={(e) => { e.stopPropagation(); ops.toggleBroadcast(leaf.id); }}
title={leaf.broadcast ? "Broadcasting (click to leave group)" : "Click to broadcast input to other broadcast panes"}
aria-pressed={leaf.broadcast ? "true" : "false"}
>📡</button>
<span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span> <span class="pane-status {statusOk ? 'ok' : 'err'}">{status}</span>
<span class="pane-actions"> <span class="pane-actions">
<button <button
class="pane-btn" class="pane-btn"
title="Split right" title="Split right"
onclick={() => ops.split(leaf.id, "h")} onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "h"); }}
aria-label="Split right" aria-label="Split right"
>⇥</button> >⇥</button>
<button <button
class="pane-btn" class="pane-btn"
title="Split down" title="Split down"
onclick={() => ops.split(leaf.id, "v")} onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "v"); }}
aria-label="Split down" aria-label="Split down"
>⇣</button> >⇣</button>
<button <button
class="pane-btn close" class="pane-btn close"
title="Close pane" title="Close pane"
onclick={() => ops.close(leaf.id)} onclick={(e) => { e.stopPropagation(); ops.close(leaf.id); }}
aria-label="Close pane" aria-label="Close pane"
>×</button> >×</button>
</span> </span>
@ -141,6 +213,10 @@
status = msg; status = msg;
statusOk = ok; statusOk = ok;
}} }}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
{onDataReceived}
{focusTrigger}
/> />
</div> </div>
</div> </div>
@ -153,7 +229,19 @@
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 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 { .pane-toolbar {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; display: flex;
@ -202,7 +290,8 @@
.distro-wrap { .distro-wrap {
position: relative; position: relative;
} }
.distro-chip { .distro-chip,
.bcast-chip {
font: inherit; font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 10px; font-size: 10px;
@ -213,10 +302,23 @@
padding: 1px 6px; padding: 1px 6px;
cursor: pointer; cursor: pointer;
} }
.distro-chip:hover { .distro-chip:hover,
.bcast-chip:hover {
background: #2a2a3a; background: #2a2a3a;
color: #aac; 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 { .distro-menu {
position: absolute; position: absolute;
top: 100%; top: 100%;

View file

@ -1,4 +1,5 @@
import type { NodeId, Orientation } from "./tree"; import type { NodeId, Orientation } from "./tree";
import type { PaneId } from "../../ipc";
/** /**
* Bundle of operations + data that any pane in the tree may need. * Bundle of operations + data that any pane in the tree may need.
@ -6,10 +7,30 @@ import type { NodeId, Orientation } from "./tree";
* prop drilling. * prop drilling.
*/ */
export interface PaneOps { export interface PaneOps {
// ---- tree mutation
split: (leafId: NodeId, orientation: Orientation) => void; split: (leafId: NodeId, orientation: Orientation) => void;
close: (leafId: NodeId) => void; close: (leafId: NodeId) => void;
setDistro: (leafId: NodeId, distro: string) => void; setDistro: (leafId: NodeId, distro: string) => void;
setLabel: (leafId: NodeId, label: string | undefined) => 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. */ /** All distros known to the backend; populated once at app start. */
distros: string[]; distros: string[];
/** The currently-focused pane, if any. */
activeLeafId: NodeId | null;
} }

View file

@ -19,6 +19,12 @@ export interface LeafNode {
cwd?: string; cwd?: string;
/** Optional user label shown in the pane toolbar. */ /** Optional user label shown in the pane toolbar. */
label?: string; 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 { export interface SplitNode {
@ -174,6 +180,24 @@ export function leafCount(root: TreeNode): number {
return leafCount(root.a) + leafCount(root.b); return leafCount(root.a) + leafCount(root.b);
} }
/** Iterate all leaves in left-to-right order. */
export function* walkLeaves(root: TreeNode): Generator<LeafNode> {
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 { export function serialize(root: TreeNode): string {
return JSON.stringify(root); return JSON.stringify(root);
} }