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

@ -1,4 +1,5 @@
<script lang="ts">
import { onDestroy } from "svelte";
import type { LeafNode } from "./tree";
import type { PaneOps } from "./ops";
import XtermPane from "../../components/XtermPane.svelte";
@ -11,6 +12,8 @@
ops: PaneOps;
} = $props();
const active = $derived(ops.activeLeafId === leaf.id);
let status = $state("starting…");
let statusOk = $state(true);
@ -19,10 +22,10 @@
let labelDraft = $state("");
let labelInputEl: HTMLInputElement | null = $state(null);
function startEditLabel() {
function startEditLabel(e: MouseEvent) {
e.stopPropagation();
labelDraft = leaf.label ?? "";
editingLabel = true;
// Focus the input after Svelte renders it.
queueMicrotask(() => labelInputEl?.select());
}
@ -59,16 +62,71 @@
if (d !== leaf.distro) ops.setDistro(leaf.id, d);
}
// Dismiss popover on outside click.
$effect(() => {
if (!distroOpen) return;
const onDocClick = () => (distroOpen = false);
window.addEventListener("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>
<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">
{#if editingLabel}
<input
@ -98,7 +156,13 @@
{leaf.distro ?? "(default)"}
</button>
{#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}
<button
class="distro-menu-item"
@ -110,25 +174,33 @@
{/if}
</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-actions">
<button
class="pane-btn"
title="Split right"
onclick={() => ops.split(leaf.id, "h")}
onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "h"); }}
aria-label="Split right"
>⇥</button>
<button
class="pane-btn"
title="Split down"
onclick={() => ops.split(leaf.id, "v")}
onclick={(e) => { e.stopPropagation(); ops.split(leaf.id, "v"); }}
aria-label="Split down"
>⇣</button>
<button
class="pane-btn close"
title="Close pane"
onclick={() => ops.close(leaf.id)}
onclick={(e) => { e.stopPropagation(); ops.close(leaf.id); }}
aria-label="Close pane"
>×</button>
</span>
@ -141,6 +213,10 @@
status = msg;
statusOk = ok;
}}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
{onDataReceived}
{focusTrigger}
/>
</div>
</div>
@ -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%;