agent-manager-template / web /src /components /TerminalPane.tsx
lvwerra's picture
lvwerra HF Staff
Pre-release hardening
ece21ed
Raw
History Blame Contribute Delete
23.5 kB
import { useEffect, useRef, useState } from 'react';
import { Terminal, type ITheme } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { ClipboardAddon, Base64 } from '@xterm/addon-clipboard';
import '@xterm/xterm/css/xterm.css';
import type { Cli, Session } from '../types';
import { STATE_LABEL } from '../types';
import Logo from './Logo';
import { CloseGlyph, RefreshGlyph } from './icons';
const THEMES: Record<'light' | 'dark', ITheme> = {
dark: {
background: '#0e1217', foreground: '#c6d0d8',
cursor: '#43c98a', cursorAccent: '#0e1217',
selectionBackground: '#2bb3bd44',
// ANSI tuned bright enough for the dark background (used by the prompt).
green: '#43c98a', brightGreen: '#5fe0a0',
cyan: '#5fd0d8', brightCyan: '#7fe3ea',
blue: '#6ea8fe', brightBlue: '#8cbcff',
red: '#e0726a', yellow: '#e0a948',
},
light: {
background: '#ffffff', foreground: '#1b2329',
cursor: '#0e7c86', cursorAccent: '#ffffff',
selectionBackground: '#0e7c8633',
// ANSI darkened for contrast on white (fixes the unreadable prompt in light mode).
green: '#1c8c57', brightGreen: '#1c8c57',
cyan: '#0e7c86', brightCyan: '#0e7c86',
blue: '#2257c4', brightBlue: '#2257c4',
red: '#c2433a', yellow: '#9a6a00',
},
};
type ConnState = 'connecting' | 'connected' | 'closed' | 'exited';
// Close code the server uses when the session's process exited for real (vs a
// transient drop). The client must NOT auto-reconnect on this, or it would
// respawn the agent in a loop and trample an in-progress login flow.
const EXIT_CODE = 4000;
function workspaceLabel(p: string | null) {
const rel = (p || '').replace(/^\.\/?/, '').replace(/^\/+|\/+$/g, '');
return rel ? `workspace/${rel}/` : 'workspace/';
}
// Serialize the current selection, joining soft-wrapped rows instead of
// emitting a newline per visual row. tmux repaints with explicit cursor moves,
// so a wrapped logical line arrives as separate full-width rows that xterm's
// default getSelection() would break with '\n'. We rejoin a row to the next
// when xterm flags it wrapped OR it's filled to the last column (the tmux case).
function selectionText(term: Terminal): string {
try {
const pos = term.getSelectionPosition?.();
if (!pos) return term.getSelection();
const buf = term.buffer.active;
const cols = term.cols;
const out: string[] = [];
let cur = '';
for (let y = pos.start.y; y <= pos.end.y; y++) {
const line = buf.getLine(y);
if (!line) continue;
const startX = y === pos.start.y ? pos.start.x : 0;
const endX = y === pos.end.y ? pos.end.x : cols;
cur += line.translateToString(false, startX, endX);
const lastChar = line.getCell(cols - 1)?.getChars() ?? '';
const full = endX >= cols && lastChar !== '' && lastChar !== ' ';
const wrapped = y < pos.end.y && ((buf.getLine(y + 1)?.isWrapped ?? false) || full);
if (!wrapped) { out.push(cur.replace(/[ \t]+$/, '')); cur = ''; }
}
if (cur) out.push(cur.replace(/[ \t]+$/, ''));
const joined = out.join('\n');
return joined || term.getSelection();
} catch {
return term.getSelection();
}
}
// Copying to the system clipboard has to survive two hostile conditions on the
// Space: the app is usually embedded in Hugging Face's cross-origin iframe
// (which blocks the async Clipboard API entirely), and the tab is often
// unfocused. So explicit user copies layer:
// 1. execCommand('copy') via a hidden textarea — the ONLY path that works
// inside the iframe, and it works during any user gesture.
// 2. navigator.clipboard.writeText — the modern path (focused, top-level).
// 3. if both fail (a requested copy couldn't write), stash briefly
// and flush on the user's next click, so the copy lands when they return.
// It must run synchronously inside the triggering event — deferring to a
// setTimeout loses the activation that execCommand needs.
const OSC52_COPY_WINDOW_MS = 1500;
let osc52CopyAllowedUntil = 0;
function allowNextOsc52Copy() {
osc52CopyAllowedUntil = Date.now() + OSC52_COPY_WINDOW_MS;
}
function consumeOsc52CopyPermission(): boolean {
if (Date.now() > osc52CopyAllowedUntil) return false;
osc52CopyAllowedUntil = 0;
return true;
}
function legacyCopy(text: string): boolean {
const prev = document.activeElement as HTMLElement | null;
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-9999px';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, text.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
prev?.focus?.(); // don't steal focus from the terminal
return ok;
} catch { return false; }
}
let clipStash: { text: string; at: number } | null = null;
function copyText(text: string) {
if (!text) return;
if (legacyCopy(text)) { clipStash = null; return; }
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text)
.then(() => { clipStash = null; })
.catch(() => { clipStash = { text, at: Date.now() }; });
} else {
clipStash = { text, at: Date.now() };
}
}
// A user-requested copy we couldn't complete lands on the next real interaction
// — bounded so a stale copy can't clobber the clipboard much later.
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', () => {
if (clipStash && Date.now() - clipStash.at < 8000) legacyCopy(clipStash.text);
clipStash = null;
}, true);
}
export default function TerminalPane({
session, cli, theme, focused, active, zoom = 100, dragId, onDragActive, onFocus, onRename, onClose,
}: {
session: Session;
cli?: Cli;
theme: 'light' | 'dark';
focused?: boolean;
active?: boolean;
zoom?: number;
dragId?: string; // set when the pane can be rearranged (group view)
onDragActive?: (dragging: boolean) => void;
onFocus?: () => void;
onRename?: (name: string) => void;
onClose: () => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const resyncRef = useRef<() => void>(() => {});
const reconnectRef = useRef<() => void>(() => {});
const [conn, setConn] = useState<ConnState>('connecting');
// "starting…" cover while the CLI boots into an empty pane (attach on the
// Space can take seconds). Hidden only when the screen actually SHOWS
// something — byte counts lie, because tmux's attach repaint of a blank
// 200×50 screen is already kilobytes of escapes.
const [booting, setBooting] = useState(true);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(session.name);
const commitName = () => {
const v = draft.trim();
if (v && v !== session.name) onRename?.(v);
setEditing(false);
};
useEffect(() => {
const term = new Terminal({
fontFamily: "'Geist Mono', ui-monospace, 'SF Mono', Menlo, 'Cascadia Code', monospace",
fontSize: 13,
cursorBlink: true,
scrollback: 20000,
theme: THEMES[theme],
// Let users make a *local* selection even when the app (tmux / an agent
// TUI) has grabbed the mouse: ⌥-drag on macOS, Shift-drag elsewhere.
macOptionClickForcesSelection: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
let lastSelection = '';
let tmuxSelectionPending = false;
let tmuxSelectionText = '';
let tmuxSelectionAt = 0;
// tmux emits OSC 52 when copy-mode commits a mouse selection. Do not write
// the system clipboard there; stash the decoded text and wait for Cmd/C.
const clipboardProvider = {
readText: (sel: string) => (sel !== 'p' && navigator.clipboard?.readText ? navigator.clipboard.readText().catch(() => '') : ''),
writeText: (sel: string, data: string) => {
const allowed = sel !== 'p' && consumeOsc52CopyPermission();
if (sel !== 'p') {
tmuxSelectionText = data;
tmuxSelectionAt = Date.now();
tmuxSelectionPending = true;
}
if (allowed) copyText(data);
},
};
// addon-clipboard@0.1.0 has a (base64, provider) runtime constructor but
// types it as (provider) — construct positionally to match the runtime.
term.loadAddon(new (ClipboardAddon as unknown as new (b: Base64, p: typeof clipboardProvider) => ClipboardAddon)(new Base64(), clipboardProvider));
term.open(hostRef.current!);
termRef.current = term;
// Track the committed local xterm selection so Cmd/Ctrl+C can copy it
// synchronously — deferring (setTimeout) would break execCommand's gesture.
const selSub = term.onSelectionChange(() => {
lastSelection = term.hasSelection() ? selectionText(term) : '';
if (lastSelection) tmuxSelectionPending = false;
});
const copySelection = () => {
const text = term.hasSelection() ? selectionText(term) : lastSelection;
if (text) copyText(text);
};
const copyTmuxSelection = (e?: ClipboardEvent): boolean => {
const fresh = tmuxSelectionText && Date.now() - tmuxSelectionAt < 120_000;
if (!tmuxSelectionPending || !fresh) return false;
if (e?.clipboardData) {
e.clipboardData.setData('text/plain', tmuxSelectionText);
e.preventDefault();
e.stopPropagation();
} else {
copyText(tmuxSelectionText);
}
return true;
};
const host = hostRef.current!;
let mouseDragStart: { x: number; y: number } | null = null;
let mouseDragged = false;
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== 'mouse' || e.button !== 0) return;
mouseDragStart = { x: e.clientX, y: e.clientY };
mouseDragged = false;
};
const onPointerMove = (e: PointerEvent) => {
if (!mouseDragStart || (e.buttons & 1) === 0) return;
if (Math.abs(e.clientX - mouseDragStart.x) > 3 || Math.abs(e.clientY - mouseDragStart.y) > 3) mouseDragged = true;
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== 'mouse' || e.button !== 0) return;
if (mouseDragged && !term.hasSelection()) {
tmuxSelectionPending = true;
} else if (!term.hasSelection()) {
tmuxSelectionPending = false;
}
mouseDragStart = null;
mouseDragged = false;
};
const onClick = (e: MouseEvent) => {
if (e.detail >= 2 && !term.hasSelection()) {
tmuxSelectionPending = true;
}
};
host.addEventListener('pointerdown', onPointerDown, true);
host.addEventListener('pointermove', onPointerMove, true);
host.addEventListener('pointerup', onPointerUp, true);
host.addEventListener('click', onClick, true);
const onDocumentPointerDown = (e: PointerEvent) => {
if (!host.contains(e.target as Node)) tmuxSelectionPending = false;
};
const onCopy = (e: ClipboardEvent) => {
copyTmuxSelection(e);
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
document.addEventListener('copy', onCopy, true);
let ws: WebSocket | null = null;
const send = (o: unknown) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o));
};
// ⌘/Ctrl+C copies selected text. With a tmux mouse selection, ask the server
// to run tmux's copy command; the resulting OSC 52 is accepted only because
// it follows this key gesture. Paste is left to xterm's native handler so
// bracketed-paste framing is preserved.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && term.hasSelection()) {
copySelection();
term.clearSelection();
tmuxSelectionPending = false;
return false;
}
if ((e.metaKey || e.ctrlKey) && (e.key === 'c' || e.key === 'C') && tmuxSelectionPending) {
if (!copyTmuxSelection()) {
allowNextOsc52Copy();
send({ t: 'copy' });
}
return false;
}
if (e.metaKey && (e.key === 'c' || e.key === 'C')) return false;
if (e.key === 'Escape') tmuxSelectionPending = false;
return true;
});
try { fit.fit(); } catch { /* layout not ready yet */ }
// Re-measure once the webfont is ready (glyph width changes vs the fallback).
document.fonts?.ready.then(() => { try { fit.fit(); } catch { /* ignore */ } });
let closedByUs = false;
let retry: ReturnType<typeof setTimeout> | null = null;
// Reconnect with backoff: a sleeping/unreachable Space shouldn't be hammered
// every second by every open pane. Reset once a connection succeeds.
let retryDelay = 1200;
// Does the visible screen show real text in its UPPER two-thirds? Agent
// TUIs paint their bottom input bar first and load the actual content
// (banner, resumed history) seconds later — only the upper region tells us
// the pane is genuinely ready. Shell prompts paint at the top anyway.
const screenHasContent = () => {
try {
const buf = term.buffer.active;
const upper = Math.max(2, Math.floor(term.rows * 2 / 3));
for (let y = 0; y < upper; y++) {
const line = buf.getLine(buf.baseY + y);
if (line && line.translateToString(true).trim().length >= 2) return true;
}
} catch { return true; } // never trap the cover on an internal error
return false;
};
let bootLive = false;
let bootTimer: ReturnType<typeof setTimeout> | null = null; // safety cap
let bootCheck: ReturnType<typeof setTimeout> | null = null; // throttled content probe
const endBoot = () => {
bootLive = false;
if (bootTimer) { clearTimeout(bootTimer); bootTimer = null; }
if (bootCheck) { clearTimeout(bootCheck); bootCheck = null; }
setBooting(false);
};
const connect = () => {
setConn('connecting');
setBooting(true);
bootLive = true;
if (bootTimer) clearTimeout(bootTimer);
bootTimer = setTimeout(endBoot, 20_000);
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${location.host}/ws?session=${encodeURIComponent(session.id)}&cols=${term.cols}&rows=${term.rows}`;
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
setConn('connected');
retryDelay = 1200;
try { fit.fit(); } catch { /* ignore */ }
send({ t: 'r', cols: term.cols, rows: term.rows });
};
ws.onmessage = (e) => {
const d = typeof e.data === 'string' ? e.data : new Uint8Array(e.data as ArrayBuffer);
term.write(d);
// Probe shortly after each burst (throttled; write() is async).
if (bootLive && !bootCheck) {
bootCheck = setTimeout(() => {
bootCheck = null;
if (bootLive && screenHasContent()) endBoot();
}, 150);
}
};
ws.onclose = (e) => {
// A real process exit: stop here and let the user relaunch. Anything
// else is a transient drop (sleep/wake, network) → auto-reconnect and
// reattach to the still-running tmux session.
endBoot();
if (e.code === EXIT_CODE) { setConn('exited'); return; }
setConn('closed');
if (!closedByUs) {
retry = setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 1.7, 15_000);
}
};
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
};
// Manual restart: clear the dead run's screen so the content probe watches
// the NEW process paint, not leftovers (tmux repaints the live screen).
reconnectRef.current = () => { if (retry) clearTimeout(retry); retryDelay = 1200; try { term.reset(); } catch { /* ignore */ } connect(); };
// Re-fit and tell the server our size. With tmux window-size=latest this
// makes the focused window own the size, so a duplicate window open at a
// different size doesn't leave us with tmux's "dots" filler.
const resync = () => {
try { fit.fit(); send({ t: 'r', cols: term.cols, rows: term.rows }); } catch { /* ignore */ }
};
const onVisible = () => { if (!document.hidden) resync(); };
resyncRef.current = resync; // so the zoom control can refit
// Typing means the user sees enough to interact — drop the boot cover.
// Real keystrokes only (onKey): onData ALSO fires for xterm's automatic
// replies to the TUI's terminal queries (DA/CPR), which arrive instantly
// on attach and must not count as "the user typed".
const keySub = term.onKey(() => endBoot());
const dataSub = term.onData((d) => send({ t: 'i', d }));
const ro = new ResizeObserver(resync);
ro.observe(hostRef.current!);
window.addEventListener('focus', resync);
document.addEventListener('visibilitychange', onVisible);
// Touch scrolling: xterm doesn't translate touch gestures for applications
// that grabbed the mouse (tmux always does here), so swipes did nothing on
// phones. Convert drags into wheel steps: SGR mouse-wheel sequences when
// the app tracks the mouse (tmux scrolls its history), local scrollLines
// otherwise.
let touchY: number | null = null;
const onTouchStart = (e: TouchEvent) => { touchY = e.touches[0].clientY; };
const onTouchMove = (e: TouchEvent) => {
if (touchY == null) return;
const y = e.touches[0].clientY;
const dy = y - touchY;
const steps = Math.trunc(dy / 24); // ~one wheel notch per 24px of drag
if (steps !== 0) {
touchY = y;
let tracking = false;
try { tracking = ((term as unknown as { modes?: { mouseTrackingMode?: string } }).modes?.mouseTrackingMode ?? 'none') !== 'none'; } catch { /* older xterm */ }
const btn = steps > 0 ? 64 : 65; // drag down reveals earlier output = wheel up
for (let i = 0; i < Math.abs(steps); i++) {
if (tracking) send({ t: 'i', d: `\x1b[<${btn};${Math.max(1, Math.floor(term.cols / 2))};${Math.max(1, Math.floor(term.rows / 2))}M` });
else term.scrollLines(steps > 0 ? -1 : 1);
}
}
if (Math.abs(dy) > 4) e.preventDefault(); // keep the page from rubber-banding
};
const onTouchEnd = () => { touchY = null; };
host.addEventListener('touchstart', onTouchStart, { passive: true });
host.addEventListener('touchmove', onTouchMove, { passive: false });
host.addEventListener('touchend', onTouchEnd);
connect();
return () => {
closedByUs = true;
if (retry) clearTimeout(retry);
if (bootTimer) clearTimeout(bootTimer);
if (bootCheck) clearTimeout(bootCheck);
ro.disconnect();
host.removeEventListener('pointerdown', onPointerDown, true);
host.removeEventListener('pointermove', onPointerMove, true);
host.removeEventListener('pointerup', onPointerUp, true);
host.removeEventListener('click', onClick, true);
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
document.removeEventListener('copy', onCopy, true);
host.removeEventListener('touchstart', onTouchStart);
host.removeEventListener('touchmove', onTouchMove);
host.removeEventListener('touchend', onTouchEnd);
window.removeEventListener('focus', resync);
document.removeEventListener('visibilitychange', onVisible);
dataSub.dispose();
keySub.dispose();
selSub.dispose();
try { ws?.close(); } catch { /* ignore */ }
term.dispose();
termRef.current = null;
};
}, [session.id]);
// Switch theme live without tearing down the terminal / connection.
useEffect(() => {
if (termRef.current) termRef.current.options.theme = THEMES[theme];
}, [theme]);
// Zoom: adjust font size (100% = 13px) and refit.
useEffect(() => {
const t = termRef.current;
if (!t) return;
t.options.fontSize = Math.round((13 * zoom) / 100);
resyncRef.current();
}, [zoom]);
// Move keyboard focus into the terminal whenever this pane becomes the active
// one (e.g. selected from the sidebar, or newly created).
useEffect(() => {
if (!active) return;
const t = setTimeout(() => termRef.current?.focus(), 0);
return () => clearTimeout(t);
}, [active]);
// Focused panes tint toward THEIR agent's brand color, not the app accent.
const tint = cli?.color;
const pathLabel = workspaceLabel(session.path);
return (
<div
className={`slot${focused ? ' focused' : ''}`}
style={focused && tint ? { borderColor: `color-mix(in srgb, ${tint} 45%, var(--border))` } : undefined}
onMouseDown={() => onFocus?.()}
>
{/* preventDefault so clicking the (non-focusable) header keeps keyboard
focus in the terminal instead of the browser blurring it — except when
the header doubles as a drag handle, where it would block dragstart. */}
<div
className={`pane-head${dragId ? ' draggable' : ''}`}
style={focused && tint ? { background: `color-mix(in srgb, ${tint} 8%, var(--panel))` } : undefined}
draggable={!!dragId}
onDragStart={dragId ? (e) => { e.dataTransfer.setData('text/plain', dragId); e.dataTransfer.effectAllowed = 'move'; onDragActive?.(true); } : undefined}
onDragEnd={dragId ? () => onDragActive?.(false) : undefined}
onMouseDown={(e) => { if (!dragId) e.preventDefault(); onFocus?.(); termRef.current?.focus(); }}
>
<div className="ph-left">
<Logo cli={session.cli} size={16} tint={tint} />
<span className={`status ${session.state}`} title={`${STATE_LABEL[session.state]} · ${conn}`} />
</div>
{editing ? (
<input
className="ph-title-input" autoFocus value={draft}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitName}
onKeyDown={(e) => { if (e.key === 'Enter') commitName(); if (e.key === 'Escape') setEditing(false); }}
/>
) : (
<span className="ph-title" title={`${pathLabel} · double-click to rename`} onDoubleClick={() => { setDraft(session.name); setEditing(true); }}>{session.name}</span>
)}
<div className="ph-right">
<span className="ph-path" title={pathLabel}>{pathLabel}</span>
<button className="mini-btn ph-close" title="Close" onClick={(e) => { e.stopPropagation(); onClose(); }}><CloseGlyph /></button>
</div>
</div>
<div className="term-host" ref={hostRef} />
{booting && conn !== 'exited' && (
<div className="term-boot mono">
{conn === 'connecting' ? 'connecting' : `starting ${cli?.label || session.cli}`}<span className="et-cursor" />
</div>
)}
{conn === 'exited' && (
<div className="term-exit mono">
<div className="tx-row">
<span>{cli?.label || session.cli} stopped · output preserved</span>
<button
className="tx-btn"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); reconnectRef.current(); }}
><RefreshGlyph /> restart</button>
</div>
</div>
)}
</div>
);
}