v1.10: booterm container — xterm.js + tmux + node-pty

This commit is contained in:
2026-05-18 14:06:46 +00:00
parent d85b17081e
commit 7486e7d3e0
25 changed files with 1515 additions and 29 deletions

View File

@@ -261,4 +261,26 @@ export const api = {
sidebar: {
get: () => request<SidebarResponse>('/api/sidebar'),
},
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
// TerminalPane). All three endpoints are tolerant of empty bodies on the
// POSTs that don't take parameters.
terminals: {
start: (sessionId: string, paneId: string) =>
request<{ tmux_window: string }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
{ method: 'POST' },
),
resize: (sessionId: string, paneId: string, cols: number, rows: number) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/resize`,
{ method: 'POST', body: JSON.stringify({ cols, rows }) },
),
kill: (sessionId: string, paneId: string) =>
request<{ ok: true }>(
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
{ method: 'POST' },
),
},
};

View File

@@ -1,4 +1,4 @@
import { Children, cloneElement, isValidElement, useState } from 'react';
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -7,9 +7,19 @@ import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
@@ -19,6 +29,57 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
// v1.10 booterm: tiny subscription hook for the mounted-terminals registry.
// Used by the right-click "Send to terminal" submenu so it always reflects
// currently-open terminal panes without prop drilling from Workspace.
function useTerminals(): TerminalRegistration[] {
const [list, setList] = useState(() => terminalsRegistry.list());
useEffect(() => terminalsRegistry.subscribe(() => setList(terminalsRegistry.list())), []);
return list;
}
// Wrap a message body with a right-click context menu offering "Send to
// terminal → <pane name>". The submenu is disabled when nothing is selected
// or no terminal panes are open; clicking a target emits a sendToTerminal
// event that TerminalPane subscribes to (filtered by pane_id).
function SendToTerminalMenu({ children }: { children: ReactNode }) {
const [selection, setSelection] = useState('');
const terminals = useTerminals();
const canSend = selection.length > 0 && terminals.length > 0;
return (
<ContextMenu
onOpenChange={(open) => {
if (open) {
const sel = typeof window !== 'undefined' ? window.getSelection()?.toString() ?? '' : '';
setSelection(sel);
}
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!canSend}>Send to terminal</ContextMenuSubTrigger>
<ContextMenuSubContent>
{terminals.length === 0 ? (
<ContextMenuItem disabled>No terminal panes open</ContextMenuItem>
) : (
terminals.map((t) => (
<ContextMenuItem
key={t.paneId}
onSelect={() => sendToTerminal.emit({ pane_id: t.paneId, text: selection })}
>
{t.label}
</ContextMenuItem>
))
)}
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
);
}
// v1.8.2: human labels for the machine-readable error reasons that ride on
// failed assistant messages via metadata.kind === 'error'. Kept short so the
// inline render under "message failed" stays a single muted line.
@@ -507,9 +568,11 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
if (message.role === 'user') {
return (
<div className="group flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
<SendToTerminalMenu>
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
</SendToTerminalMenu>
<ActionRow message={message} />
</div>
);
@@ -529,12 +592,14 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
return (
<div className="group flex flex-col gap-2">
{(hasContent || isStreaming) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasContent ? <MarkdownBody content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
</SendToTerminalMenu>
)}
{failed && (
<div className="text-xs text-destructive">

View File

@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { PanelRight, MessageSquare, Terminal, Bot, X } from 'lucide-react';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { ChatPane } from '@/components/panes/ChatPane';
import { SettingsPane } from '@/components/panes/SettingsPane';
import { TerminalPane } from '@/components/panes/TerminalPane';
import { ChatTabBar } from '@/components/ChatTabBar';
import { SessionLandingPage } from '@/components/SessionLandingPage';
import {
@@ -115,6 +116,20 @@ export function Workspace({
.filter((c): c is Chat => c !== undefined);
}
// v1.10 booterm: per-terminal label used by the registry that powers the
// MessageBubble "Send to terminal" submenu. Numbered in workspace order.
const terminalLabels = useMemo(() => {
const out = new Map<string, string>();
let n = 0;
for (const p of panes) {
if (p.kind === 'terminal') {
n += 1;
out.set(p.id, `Terminal ${n}`);
}
}
return out;
}, [panes]);
return (
<div className="flex flex-col h-full min-h-0">
{!isMobile && (
@@ -165,6 +180,7 @@ export function Workspace({
>
{panes.map((pane, idx) => {
const isSettings = pane.kind === 'settings';
const isTerminal = pane.kind === 'terminal';
// v1.9: when maximized, hide every pane except the settings one.
// display:none keeps the React tree mounted so streams / drafts
// survive the toggle without re-mount cost.
@@ -176,6 +192,9 @@ export function Workspace({
}
return null;
}
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
// are not drag-reorderable for now — keeps the layout grid simple.
const isChromeless = isSettings || isTerminal;
return (
<div
key={pane.id}
@@ -187,19 +206,18 @@ export function Workspace({
'before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary before:z-10'
)}
onClick={() => setActivePaneIdx(idx)}
onDragOver={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isSettings && panes.length > 1 ? handlePaneDrop(idx) : undefined}
onDragOver={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragOver(idx) : undefined}
onDragLeave={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragLeave : undefined}
onDrop={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDrop(idx) : undefined}
>
<div
draggable={!isMobile && !isSettings && panes.length > 1}
onDragStart={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isSettings && panes.length > 1 ? handlePaneDragEnd : undefined}
draggable={!isMobile && !isChromeless && panes.length > 1}
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile per v1.8; settings panes own their own
section nav / maximize toggle so they skip ChatTabBar
entirely. */}
{!isMobile && !isSettings && (
{/* Hidden on mobile per v1.8; settings + terminal panes own
their own header (no chats, so no ChatTabBar). */}
{!isMobile && !isChromeless && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
@@ -214,6 +232,28 @@ export function Workspace({
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
{isTerminal && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<Terminal size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{terminalLabels.get(pane.id) ?? 'Terminal'}
</span>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
className="ml-auto inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close terminal pane"
title="Close terminal pane"
>
<X size={12} />
</button>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
@@ -226,6 +266,12 @@ export function Workspace({
onClose={() => removePane(idx)}
isMobile={isMobile}
/>
) : isTerminal ? (
<TerminalPane
sessionId={sessionId}
paneId={pane.id}
label={terminalLabels.get(pane.id) ?? 'Terminal'}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane
sessionId={sessionId}

View File

@@ -0,0 +1,167 @@
import { useEffect, useRef } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css';
import { api } from '@/api/client';
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
interface Props {
sessionId: string;
paneId: string;
label: string;
}
// Minimal dark theme. xterm.js renders against its own canvas; CSS variables
// don't reach it, so we hardcode. Matches the obsidian-dark base in spirit.
const XTERM_THEME = {
background: '#0b0f14',
foreground: '#d6deeb',
cursor: '#82aaff',
selectionBackground: '#1d3b53',
black: '#011627',
red: '#ef5350',
green: '#22da6e',
yellow: '#c5e478',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#7fdbca',
white: '#d6deeb',
brightBlack: '#575656',
brightRed: '#ef5350',
brightGreen: '#22da6e',
brightYellow: '#ffeb95',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#7fdbca',
brightWhite: '#ffffff',
};
export function TerminalPane({ sessionId, paneId, label }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let disposed = false;
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const term = new Terminal({
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
scrollback: 10_000,
theme: XTERM_THEME,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
try {
fit.fit();
} catch {
/* container not yet sized */
}
// POST start kicks the tmux window into existence before the WS upgrade.
// It's idempotent so a refresh just no-ops. Failures fall through to the
// WS handler which will also call ensureWindow.
api.terminals.start(sessionId, paneId).catch(() => {
/* surfaced by WS error if it matters */
});
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const initialCols = term.cols;
const initialRows = term.rows;
const wsUrl =
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
`?cols=${initialCols}&rows=${initialRows}`;
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
// Control frame from server (e.g. {"type":"exit","code":0}).
try {
const parsed = JSON.parse(e.data) as { type?: string; code?: number };
if (parsed.type === 'exit') {
term.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
return;
}
} catch {
/* not JSON — fall through and write as text */
}
term.write(e.data);
} else {
term.write(new Uint8Array(e.data));
}
};
ws.onclose = () => {
if (disposed) return;
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
const fireResize = () => {
try {
fit.fit();
} catch {
return;
}
const cols = term.cols;
const rows = term.rows;
api.terminals.resize(sessionId, paneId, cols, rows).catch(() => {
/* transient — next resize will catch up */
});
};
const ro = new ResizeObserver(() => {
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(fireResize, 100);
});
ro.observe(container);
const unregister = terminalsRegistry.register(paneId, label);
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
if (pane_id !== paneId) return;
if (ws.readyState !== WebSocket.OPEN) return;
const payload = text.endsWith('\n') ? text : `${text}\n`;
ws.send(payload);
});
return () => {
disposed = true;
unsubscribe();
unregister();
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
ro.disconnect();
try {
ws.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
};
}, [sessionId, paneId, label]);
return (
<div
ref={containerRef}
className="w-full h-full bg-[#0b0f14] overflow-hidden"
data-testid="terminal-pane"
/>
);
}

View File

@@ -19,6 +19,14 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
// persist in localStorage along with chat panes so a refresh resumes the
// same tmux window via the idempotent start endpoint.
function terminalPane(): WorkspacePane {
return { id: generateId(), kind: 'terminal', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
@@ -234,10 +242,6 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
if (kind === 'terminal') {
toast('Terminal panes coming in BooTerm');
return;
}
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
@@ -248,7 +252,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...prev, emptyPane()];
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
return next;
});

View File

@@ -0,0 +1,80 @@
// Minimal pub/sub for ephemeral UI events that don't belong on the sessionEvents
// bus (sessionEvents is for DB-state changes; this file is for UI-only signals
// like "user clicked send-to-terminal on selected text").
//
// Also exposes a tiny registry of currently-mounted terminal panes so the
// MessageBubble context menu can list them. TerminalPane registers on mount,
// unregisters on unmount.
type Listener<T> = (payload: T) => void;
interface EventBus<T> {
emit(payload: T): void;
subscribe(listener: Listener<T>): () => void;
}
function createEvent<T>(): EventBus<T> {
const listeners = new Set<Listener<T>>();
return {
emit(payload) {
for (const l of listeners) {
try {
l(payload);
} catch {
/* one bad listener shouldn't break others */
}
}
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
export interface SendToTerminalPayload {
pane_id: string;
text: string;
}
export const sendToTerminal = createEvent<SendToTerminalPayload>();
export interface TerminalRegistration {
paneId: string;
label: string;
}
const terminalRegistry = new Map<string, TerminalRegistration>();
const registryListeners = new Set<Listener<void>>();
function notifyRegistry(): void {
for (const l of registryListeners) {
try {
l();
} catch {
/* ignore */
}
}
}
export const terminalsRegistry = {
register(paneId: string, label: string): () => void {
terminalRegistry.set(paneId, { paneId, label });
notifyRegistry();
return () => {
terminalRegistry.delete(paneId);
notifyRegistry();
};
},
list(): TerminalRegistration[] {
return Array.from(terminalRegistry.values());
},
subscribe(listener: Listener<void>): () => void {
registryListeners.add(listener);
return () => {
registryListeners.delete(listener);
};
},
};