This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/attach.ts
T
Miguel Palhas 88b6bba9f2
ci / web (push) Successful in 34s
ci / rust (push) Successful in 3m56s
ci / flake (push) Successful in 7m8s
feat(maestro): show only the program on session cards
The command fragment named the full command line, so a session spawned
with `claude --dangerously-skip-permissions` spent the whole line on a
flag. Cards now name the program only — its icon in the web client, its
bare name in the TUI — and keep the full command line in the tooltip.

Adds `maestro_protocol::display::command_program`, the Rust counterpart
of the web's `splitCommand`.
2026-09-01 11:11:39 +01:00

554 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
/**
* A single live attach: an xterm terminal bound to the daemon's attach WebSocket.
*
* The connection is **decoupled from the UI**: it owns a long-lived WS that
* keeps streaming PTY output into the terminal buffer whether or not the view
* is currently mounted. The shell moves `el` between the visible content pane
* and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never
* the connection. Only `dispose()` (eviction, an explicit disconnect, or the
* session ending) tears the socket down — so switching away and back is
* instant, with scrollback already in place.
*/
export interface AttachConnection {
readonly sessionId: Uuid;
/** The `.attach` element (header + terminal body) to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit to the pane, focus, sync size. */
onMount(): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
export interface AttachOptions {
/** Invoked when the user clicks the view's explicit "disconnect" control. */
onRequestClose?: (sessionId: Uuid) => void;
/** Invoked when the socket closes on its own — the session ended or the link
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
* shell uses this to auto-close the pane rather than leave a dead one. */
onClosed?: (sessionId: Uuid) => void;
/** Asked on every close: should the socket be retried? The shell answers
* from the catalog — see `shouldRetryClose`. */
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
/** What the header shows about the session (name / command). Optional — falls
* back to a short session id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
}
/** Header echoes the sidebar: the program becomes its tool icon when we have a
* mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
info?: AttachInfo,
): AttachConnection {
const el = document.createElement("div");
el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
<div class="attach-body"></div>
`;
const body = el.querySelector(".attach-body") as HTMLDivElement;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
const keybar = buildKeyBar((data) => sendInput(data));
el.appendChild(keybar.el);
const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => {
stateEl.textContent = s;
stateEl.dataset.state = kind;
};
// Mobile-only (CSS hides it on desktop, where the sidebar is always in view):
// the session list is an off-canvas drawer and this is what opens it.
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
requestToggleDrawer(),
);
disconnectBtn.addEventListener("click", () => {
opts.onRequestClose?.(sessionId);
});
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
// The terminal stays dark in both app themes (convention); colors are
// tuned to the maestro palette — signal-green cursor, slate background.
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning.
{
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => {
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
})._core;
return core?._renderService?.dimensions?.css?.cell?.height || 16;
};
if (vp) {
let lastY = 0;
let lastX = 0;
let remainder = 0;
vp.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
lastX = e.touches[0].clientX;
remainder = 0;
},
{ passive: true },
);
vp.addEventListener(
"touchmove",
(e) => {
if (e.touches.length !== 1) return;
const ch = cellH();
const y = e.touches[0].clientY;
const px = lastY - y + remainder; // finger up → scroll toward newer
lastY = y;
const lines = Math.trunc(px / ch);
remainder = px - lines * ch;
if (lines === 0) return;
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
// reporting on — that's the signal to forward instead of scrollback.
const mouseTracked = term.modes.mouseTrackingMode !== "none";
if (mouseTracked && screen) {
// One wheel event per crossed cell, dispatched at the touch point so
// xterm computes valid cell coords for the mouse report.
const dir = lines > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(lines); i++) {
screen.dispatchEvent(
new WheelEvent("wheel", {
deltaY: dir * ch,
deltaMode: 0,
clientX: lastX,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
}
e.preventDefault();
} else if (term.buffer.active.type !== "alternate") {
term.scrollLines(lines);
e.preventDefault();
}
},
{ passive: false },
);
}
}
// Mobile: the special-key bar is only useful while the soft keyboard is up,
// so reveal it only when the terminal's hidden textarea is focused (CSS still
// gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown
// preventDefault, so the bar stays put during use. Re-fit on the toggle since
// showing/hiding the bar changes the terminal's available height.
const ta = term.textarea;
if (ta) {
ta.addEventListener("focus", () => {
keybar.el.classList.add("keybar-active");
requestAnimationFrame(tryFit);
});
ta.addEventListener("blur", () => {
keybar.el.classList.remove("keybar-active");
requestAnimationFrame(tryFit);
});
}
let mounted = false;
// We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts
// a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`,
// so with the scrollbar hidden it falls back to a phantom 15px and the grid
// stops ~1–2 columns short of the edge. Same math, minus that subtraction.
const tryFit = () => {
if (!mounted) return; // parked views have no meaningful size to fit to
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
})._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit(); // fall back if internals aren't ready yet
return;
}
// Measure the viewport's real inner width (clientWidth excludes any
// scrollbar), not the container's — so columns always fit inside the
// viewport whether or not xterm reserves a scrollbar. Falls back to the
// padded container box before the viewport exists.
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW =
parseInt(ps.width) -
(parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH =
parseInt(ps.height) -
(parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
/** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the
* mobile key bar so both flow through the same path. */
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
function connect(): void {
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
setState("connected", "open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data));
} else if (ev.data instanceof Blob) {
// Some browsers may default to Blob despite binaryType="arraybuffer".
ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b)));
}
};
ws.onerror = () => {
setState("error", "error");
};
ws.onclose = (ev) => {
// The web proxy relays a daemon rejection (unknown session) as an
// application close code (4000+) carrying a human reason. Show it in the
// pane — both the header and the terminal body — and KEEP the pane up so
// the user can read why, instead of letting it flash and vanish (#39).
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
setState(reason, "error");
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
// An intentional dispose has already set `disposed`; eviction and user
// close handle their own pane teardown.
if (disposed) return;
if (opts.shouldReconnect?.(sessionId, ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setState("reconnecting…", "connecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setState(`closed (${ev.code})`, "closed");
opts.onClosed?.(sessionId);
};
}
connect();
// PTY input: every keystroke goes out as a binary frame. A pending key-bar
// Ctrl modifier (mobile) folds into the next typed character here.
const dataDisp = term.onData((data) => {
sendInput(keybar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
// FitAddon only recomputes on the events we call it from — a window resize
// misses container-driven changes (sidebar drag, mobile layout settling,
// orientation, late font load). Observe the body directly so columns always
// match the real rendered width and the terminal fills edge to edge.
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
// Re-fit once the monospace font is actually loaded — cell width changes when
// the fallback is swapped out, which otherwise leaves stale columns.
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
let disposed = false;
return {
sessionId,
el,
onMount() {
mounted = true;
// Defer a tick so the element has its content-pane dimensions before fit.
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (ws.readyState === WebSocket.OPEN) term.focus();
});
},
onUnmount() {
mounted = false;
},
focusTerminal() {
term.focus();
},
dispose() {
if (disposed) return;
disposed = true;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
el.remove();
},
};
}
/**
* One key in the mobile key bar.
* - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt).
* - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a
* pending modifier can promote it to the `\x1b[1;<n><final>` form.
* - `seq` is a plain key (Esc, Tab) sent verbatim.
*/
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; seq: string };
type Modifier = "ctrl" | "alt";
/** CSI final bytes: arrows A–D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
/**
* The keys the mobile soft keyboard hides, ordered to flow row-major into a
* 5-column / 2-row grid (NAP-342 review):
*
* Esc Tab Home ↑ End
* Ctrl Alt ← ↓ →
*
* — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, and the cursor keys as
* a real-keyboard inverted-T on the right, with Home/End filling its top corners.
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
];
/**
* Fold a single character into its control code: letters and `@[\]^_` map to
* 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged.
*/
interface KeyBar {
readonly el: HTMLElement;
/** Fold pending modifiers into typed input (soft keyboard), consuming them. */
consume(data: string): string;
}
/**
* Build the Termux-style special-key bar. Hidden by default; CSS reveals it on
* coarse-pointer (touch) devices. `send` delivers bytes to the PTY.
*
* Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key —
* tapped here or typed on the soft keyboard — carries the modifier, then they
* disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and
* the keyboard stays open.
*/
function buildKeyBar(send: (data: string) => void): KeyBar {
const el = document.createElement("div");
el.className = "attach-keybar";
el.setAttribute("role", "toolbar");
el.setAttribute("aria-label", "Special keys");
const armed: Record<Modifier, boolean> = { ctrl: false, alt: false };
const modBtns: Partial<Record<Modifier, HTMLButtonElement>> = {};
const setMod = (mod: Modifier, on: boolean) => {
armed[mod] = on;
modBtns[mod]?.setAttribute("aria-pressed", String(on));
};
const clearMods = () => {
setMod("ctrl", false);
setMod("alt", false);
};
for (const def of KEY_DEFS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "keycap";
btn.textContent = def.label;
if ("mod" in def) {
btn.classList.add("keycap-mod");
btn.setAttribute("aria-pressed", "false");
btn.setAttribute("aria-label", `${def.label} modifier`);
modBtns[def.mod] = btn;
btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod]));
} else if ("cursor" in def) {
btn.addEventListener("click", () => {
send(cursorSeq(def.cursor, armed.ctrl, armed.alt));
clearMods();
});
} else {
btn.addEventListener("click", () => {
send(def.seq);
clearMods();
});
}
// Keep the terminal focused (soft keyboard up) — don't let the tap steal it.
// preventDefault suppresses the native `:active` style, so flash an explicit
// press animation here for tactile feedback.
btn.addEventListener("pointerdown", (e) => {
e.preventDefault();
btn.classList.remove("is-pressed");
void btn.offsetWidth; // restart the animation if re-tapped mid-flash
btn.classList.add("is-pressed");
});
btn.addEventListener("animationend", () => btn.classList.remove("is-pressed"));
el.appendChild(btn);
}
return {
el,
consume(data: string): string {
if ((armed.ctrl || armed.alt) && data.length === 1) {
const out = applyModsToChar(data, armed.ctrl, armed.alt);
clearMods();
return out;
}
return data;
},
};
}