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
23
memory.md
23
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 <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 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()`.
|
||||
|
|
|
|||
122
src/App.svelte
122
src/App.svelte
|
|
@ -1,19 +1,30 @@
|
|||
<script lang="ts">
|
||||
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 Notifications, { type Toast } from "./components/Notifications.svelte";
|
||||
import Palette from "./components/Palette.svelte";
|
||||
import type { PaneOps } from "./lib/layout/ops";
|
||||
import {
|
||||
type TreeNode,
|
||||
type NodeId,
|
||||
type Orientation,
|
||||
type LeafNode,
|
||||
newLeaf,
|
||||
splitLeaf,
|
||||
closeLeaf,
|
||||
findLeaf,
|
||||
leafCount,
|
||||
walkLeaves,
|
||||
changeDistro,
|
||||
changeLabel,
|
||||
toggleBroadcast as toggleBroadcastInTree,
|
||||
serialize,
|
||||
deserialize,
|
||||
presetSingle,
|
||||
|
|
@ -30,12 +41,21 @@
|
|||
let ready = $state(false);
|
||||
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 {
|
||||
return !name.toLowerCase().startsWith("docker-desktop");
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// 1. Try the new APPDATA persistence.
|
||||
// 1. Try APPDATA persistence.
|
||||
let loaded: TreeNode | null = null;
|
||||
try {
|
||||
const json = await loadWorkspace();
|
||||
|
|
@ -44,17 +64,13 @@
|
|||
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) {
|
||||
try {
|
||||
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacy) {
|
||||
loaded = deserialize(legacy);
|
||||
if (loaded) {
|
||||
// Promote to APPDATA so it survives future loads even without
|
||||
// localStorage. Fire-and-forget; debounced save will catch it too.
|
||||
void saveWorkspace(legacy);
|
||||
}
|
||||
if (loaded) void saveWorkspace(legacy);
|
||||
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -72,10 +88,7 @@
|
|||
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);
|
||||
|
||||
ready = true;
|
||||
});
|
||||
|
||||
|
|
@ -104,10 +117,20 @@
|
|||
}, 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 ------------------------------------------------------------
|
||||
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 inherit = parent
|
||||
? { distro: parent.distro ?? defaultDistro, cwd: parent.cwd }
|
||||
|
|
@ -118,6 +141,7 @@
|
|||
function handleClose(leafId: NodeId) {
|
||||
const next = closeLeaf(tree, leafId);
|
||||
tree = next ?? newLeaf({ distro: defaultDistro });
|
||||
if (activeLeafId === leafId) activeLeafId = null;
|
||||
}
|
||||
|
||||
function handleSetDistro(leafId: NodeId, distro: string) {
|
||||
|
|
@ -128,12 +152,55 @@
|
|||
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({
|
||||
split: handleSplit,
|
||||
close: handleClose,
|
||||
setDistro: handleSetDistro,
|
||||
setLabel: handleSetLabel,
|
||||
toggleBroadcast: handleToggleBroadcast,
|
||||
broadcastFrom: handleBroadcastFrom,
|
||||
setActivePane: handleSetActivePane,
|
||||
registerPaneId: handleRegisterPaneId,
|
||||
notify: handleNotify,
|
||||
distros,
|
||||
activeLeafId,
|
||||
});
|
||||
|
||||
// ---- preset layouts ------------------------------------------------------
|
||||
|
|
@ -144,6 +211,17 @@
|
|||
}
|
||||
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>
|
||||
|
||||
<div class="app">
|
||||
|
|
@ -175,6 +253,10 @@
|
|||
<button class="preset-btn" title="2 × 2 grid" onclick={() => applyPreset(presetTwoByTwo)}>2×2</button>
|
||||
</span>
|
||||
|
||||
<button class="palette-btn" onclick={() => (paletteOpen = true)} title="Jump to pane (Ctrl+K)">
|
||||
⌘K
|
||||
</button>
|
||||
|
||||
<span class="layout-info">
|
||||
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
|
||||
</span>
|
||||
|
|
@ -185,6 +267,16 @@
|
|||
<Pane node={tree} {ops} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Notifications {notifications} onDismiss={dismissNotification} />
|
||||
|
||||
{#if paletteOpen}
|
||||
<Palette
|
||||
leaves={paletteLeaves}
|
||||
onPick={onPalettePick}
|
||||
onClose={() => (paletteOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -193,7 +285,7 @@
|
|||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.distro-btn, .preset-btn {
|
||||
.distro-btn, .preset-btn, .palette-btn {
|
||||
font: inherit;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
|
|
@ -204,7 +296,7 @@
|
|||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.distro-btn:hover, .preset-btn:hover {
|
||||
.distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
return JSON.stringify(root);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue