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:
parent
64b90ebddb
commit
3c2f6b8640
8 changed files with 578 additions and 28 deletions
81
src/components/Notifications.svelte
Normal file
81
src/components/Notifications.svelte
Normal 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>
|
||||
188
src/components/Palette.svelte
Normal file
188
src/components/Palette.svelte
Normal 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>
|
||||
|
|
@ -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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="xterm-host" bind:this={containerEl}></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue