merge v1.10.3-booterm-ux

This commit is contained in:
2026-05-19 13:52:50 +00:00
6 changed files with 411 additions and 63 deletions

View File

@@ -1,6 +1,11 @@
set -g default-terminal "screen-256color"
set -g history-limit 50000
set -g mouse on
# v1.10.3: `set -g mouse on` removed. tmux's mouse mode captured wheel/touch
# events at the protocol level, so xterm.js never saw them and the viewport
# couldn't scroll on mobile. With mouse off, xterm.js handles scrollback
# natively (wheel on desktop, finger-drag on mobile via touch-action: pan-y).
# Tradeoff: lose tmux mouse pane-resize and scroll-inside-vim; acceptable for
# the homelab single-user setup.
setw -g mode-keys vi
set -g status off
set -g destroy-unattached off

View File

@@ -271,6 +271,7 @@ export function Workspace({
sessionId={sessionId}
paneId={pane.id}
label={terminalLabels.get(pane.id) ?? 'Terminal'}
active={idx === activePaneIdx}
/>
) : pane.kind === 'chat' && pane.chatId ? (
<ChatPane

View File

@@ -1,8 +1,10 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } 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 { RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { sendToTerminal, terminalsRegistry } from '@/lib/events';
@@ -10,12 +12,19 @@ interface Props {
sessionId: string;
paneId: string;
label: string;
// v1.10.3: tells the pane it's the currently visible/focused one in the
// grid. Drives a refit so a terminal that was hidden (e.g. behind a
// maximize toggle) snaps to the right dimensions when it reappears.
active?: boolean;
}
// 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.
// v1.10.3: terminal background matches the pane container's `bg-[#0b0f14]`
// so any sub-cell rounding gap at the right/bottom edge is invisible. If
// the workspace theme changes, update both this and the container className.
const TERM_BG = '#0b0f14';
const XTERM_THEME = {
background: '#0b0f14',
background: TERM_BG,
foreground: '#d6deeb',
cursor: '#82aaff',
selectionBackground: '#1d3b53',
@@ -37,9 +46,33 @@ const XTERM_THEME = {
brightWhite: '#ffffff',
};
export function TerminalPane({ sessionId, paneId, label }: Props) {
// v1.10.3 Issue 5: override xterm's default layout so the canvas stretches
// to the full container width. The `!important` is necessary because xterm's
// own stylesheet (loaded via `import 'xterm/css/xterm.css'`) sets inline
// width/height that we need to override. Also hides the scrollbar — touch /
// trackpad / wheel still scroll, the chrome just isn't drawn.
const XTERM_STYLE_OVERRIDES = `
.xterm { width: 100% !important; height: 100% !important; }
.xterm .xterm-screen { width: 100% !important; }
.xterm .xterm-viewport {
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.xterm .xterm-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
`;
type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';
const MAX_RECONNECT_ATTEMPTS = 3;
export function TerminalPane({ sessionId, paneId, label, active = false }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const reconnectRef = useRef<() => void>(() => {});
const [connState, setConnState] = useState<ConnState>('connecting');
useEffect(() => {
const container = containerRef.current;
@@ -47,6 +80,8 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
let disposed = false;
let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let attempts = 0;
const term = new Terminal({
fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace',
@@ -54,10 +89,14 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
lineHeight: 1.2,
cursorBlink: true,
scrollback: 10_000,
fastScrollModifier: 'shift',
altClickMovesCursor: false,
theme: XTERM_THEME,
allowProposedApi: true,
});
termRef.current = term;
const fit = new FitAddon();
fitRef.current = fit;
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
@@ -67,101 +106,279 @@ export function TerminalPane({ sessionId, paneId, label }: Props) {
/* 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 */
// v1.10.3 Issue 5: web-font metrics aren't final at mount. Re-fit after
// the JetBrains Mono variable font lands so column count matches what
// the canvas actually paints. document.fonts.ready resolves once the
// initial font set is loaded; chaining .fit() on it is a one-shot.
if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
document.fonts.ready
.then(() => {
if (disposed) return;
try {
fit.fit();
} catch {
/* ignore */
}
})
.catch(() => {
/* ignore */
});
}
// v1.10.3 copy/paste: xterm v5 ships no clipboard addon — bind manually.
// Cmd/Ctrl-C + selection → copy, swallow keystroke (no ^C)
// Cmd/Ctrl-C, no selection → fall through (xterm sends SIGINT)
// Cmd/Ctrl-Shift-C → ALWAYS swallow; copy if selection, no-op otherwise
// Cmd/Ctrl-V / Cmd/Ctrl-Shift-V → paste from clipboard, swallow
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
const mod = e.ctrlKey || e.metaKey;
if (!mod) return true;
const isC = e.key === 'c' || e.key === 'C';
const isV = e.key === 'v' || e.key === 'V';
if (isC) {
if (term.hasSelection()) {
const sel = term.getSelection();
if (sel) {
navigator.clipboard.writeText(sel).catch(() => {
toast.error('Clipboard write failed');
});
}
return false;
}
// No selection: Shift means "force-swallow" (no ^C); without Shift
// we let xterm send SIGINT.
return !e.shiftKey;
}
if (isV) {
navigator.clipboard
.readText()
.then((text) => {
if (!text) return;
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(text);
})
.catch(() => {
toast.error('Clipboard read failed');
});
return false;
}
return true;
});
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;
function buildUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const cols = term.cols;
const rows = term.rows;
return (
`${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` +
`?cols=${cols}&rows=${rows}`
);
}
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`);
function connect(): void {
if (disposed) return;
try {
const ws = new WebSocket(buildUrl());
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
if (disposed) return;
attempts = 0;
setConnState('open');
};
ws.onmessage = (e) => {
if (typeof e.data === 'string') {
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;
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
setConnState('disconnected');
return;
}
} catch {
/* not JSON — fall through and write as text */
}
term.write(e.data);
} else {
term.write(new Uint8Array(e.data));
}
};
attempts += 1;
setConnState('reconnecting');
const delay = 500 * Math.pow(2, attempts - 1);
reconnectTimer = setTimeout(connect, delay);
};
ws.onclose = () => {
ws.onerror = () => {
// onclose follows — let it own the retry decision.
};
} catch {
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
setConnState('disconnected');
return;
}
attempts += 1;
setConnState('reconnecting');
reconnectTimer = setTimeout(connect, 500 * Math.pow(2, attempts - 1));
}
}
function manualReconnect(): void {
if (disposed) return;
term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
};
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
attempts = 0;
setConnState('reconnecting');
try {
wsRef.current?.close();
} catch {
/* ignore */
}
reconnectTimer = setTimeout(connect, 50);
}
reconnectRef.current = manualReconnect;
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
const fireResize = () => {
const fireResize = (): void => {
try {
fit.fit();
} catch {
return;
}
const cols = term.cols;
const rows = term.rows;
api.terminals.resize(sessionId, paneId, cols, rows).catch(() => {
api.terminals.resize(sessionId, paneId, term.cols, term.rows).catch(() => {
/* transient — next resize will catch up */
});
};
const ro = new ResizeObserver(() => {
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(fireResize, 100);
// v1.10.3 Issue 5: defer the actual fit to the next frame so the
// browser has applied the new layout before we measure cell sizes.
resizeDebounceTimer = setTimeout(() => {
requestAnimationFrame(fireResize);
}, 100);
});
ro.observe(container);
const unregister = terminalsRegistry.register(paneId, label);
const onVis = (): void => {
if (document.visibilityState !== 'visible') return;
const ws = wsRef.current;
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
manualReconnect();
}
};
document.addEventListener('visibilitychange', onVis);
const unregister = terminalsRegistry.register(paneId, label, () => {
try {
term.focus();
} catch {
/* ignore */
}
});
const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
if (pane_id !== paneId) return;
if (ws.readyState !== WebSocket.OPEN) return;
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const payload = text.endsWith('\n') ? text : `${text}\n`;
ws.send(payload);
});
api.terminals.start(sessionId, paneId).catch(() => {});
connect();
return () => {
disposed = true;
document.removeEventListener('visibilitychange', onVis);
unsubscribe();
unregister();
if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer);
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
ro.disconnect();
try {
ws.close();
wsRef.current?.close();
} catch {
/* ignore */
}
wsRef.current = null;
term.dispose();
termRef.current = null;
fitRef.current = null;
reconnectRef.current = () => {};
};
}, [sessionId, paneId, label]);
// v1.10.3 Issue 5 refit trigger: when this pane becomes active (e.g. after
// a mobile pane switch or an unmaximize), the container's measured size
// may differ from when xterm last fit. rAF defers the measurement to after
// the layout pass that the visibility change triggered.
useEffect(() => {
if (!active) return;
const fit = fitRef.current;
if (!fit) return;
const raf = requestAnimationFrame(() => {
try {
fit.fit();
} catch {
/* ignore */
}
});
return () => cancelAnimationFrame(raf);
}, [active]);
return (
<div
ref={containerRef}
className="w-full h-full bg-[#0b0f14] overflow-hidden"
data-testid="terminal-pane"
/>
// v1.10.3 Issue 5: flex-1 + min-w-0 + self-stretch + w-full lets the
// pane absorb all available horizontal space without leaving a gap on
// either side of the xterm canvas.
className="relative flex-1 min-w-0 self-stretch w-full h-full bg-[#0b0f14]"
>
{/* v1.10.3 Issue 5: per-component CSS override (not global). React
deduplicates identical <style> bodies in modern browsers, so
multiple terminal panes don't bloat the head. */}
<style>{XTERM_STYLE_OVERRIDES}</style>
<div
ref={containerRef}
className="w-full h-full overflow-hidden"
style={{ touchAction: 'pan-y', background: TERM_BG }}
data-testid="terminal-pane"
/>
{connState === 'reconnecting' && (
<div className="absolute inset-x-0 top-0 bg-amber-900/85 text-amber-100 text-xs px-3 py-1 flex items-center gap-2 pointer-events-none">
<RefreshCw size={12} className="animate-spin" />
<span>Reconnecting</span>
</div>
)}
{connState === 'disconnected' && (
<div className="absolute inset-x-0 top-0 bg-destructive/85 text-destructive-foreground text-xs px-3 py-1 flex items-center gap-2">
<span>Disconnected from terminal server.</span>
<button
type="button"
onClick={() => reconnectRef.current()}
className="ml-auto inline-flex items-center gap-1 rounded bg-background/20 hover:bg-background/30 px-2 py-0.5"
>
<RefreshCw size={12} /> Reconnect
</button>
</div>
)}
</div>
);
}

View File

@@ -11,8 +11,11 @@ function generateId(): string {
return crypto.randomUUID();
}
function emptyPane(): WorkspacePane {
return { id: generateId(), kind: 'empty', chatIds: [], activeChatIdx: -1 };
// v1.10.3: optional id arg lets addSplitPane lift id generation out of the
// setPanes updater so the new pane's id can be returned synchronously to the
// caller (needed for mobile URL state).
function emptyPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'empty', chatIds: [], activeChatIdx: -1 };
}
function chatPane(chatId: string): WorkspacePane {
@@ -23,8 +26,8 @@ function chatPane(chatId: string): WorkspacePane {
// 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 };
function terminalPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
@@ -80,7 +83,11 @@ export interface UseWorkspacePanesResult {
closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
closeAllTabs: (paneIdx: number) => void;
showLandingPage: (paneIdx: number) => void;
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => void;
// v1.10.3: returns the new pane's id (or null if the operation was a no-op:
// 'agent' kind is a toast stub, or max panes reached). Callers can use the
// id to update mobile URL state so the URL-sync effect doesn't fight the
// freshly-set activePaneIdx.
addSplitPane: (kind: 'chat' | 'terminal' | 'agent') => string | null;
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
// falls back to an empty pane to preserve the "always one pane" invariant.
@@ -241,22 +248,29 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, []);
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent') => {
const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'agent'): string | null => {
if (kind === 'agent') {
toast('Agent panes coming in BooCoder');
return;
return null;
}
// Generate the id outside the updater so we can return it deterministically.
// setPanes's updater can be invoked twice in strict mode; using a fixed id
// ensures both invocations agree and the returned id matches what landed.
const newPaneId = generateId();
let success = false;
setPanes((prev) => {
// v1.9: settings panes are excluded from the MAX cap (decision c).
if (nonSettingsCount(prev) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const newPane = kind === 'terminal' ? terminalPane() : emptyPane();
const newPane = kind === 'terminal' ? terminalPane(newPaneId) : emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
success = true;
return next;
});
return success ? newPaneId : null;
}, []);
const toggleSettingsPane = useCallback(() => {

View File

@@ -44,6 +44,9 @@ export const sendToTerminal = createEvent<SendToTerminalPayload>();
export interface TerminalRegistration {
paneId: string;
label: string;
// v1.10.3 kbd-shortcuts: Cmd+` needs to focus the active terminal's xterm
// input layer. TerminalPane binds this to term.focus().
focus: () => void;
}
const terminalRegistry = new Map<string, TerminalRegistration>();
@@ -60,8 +63,8 @@ function notifyRegistry(): void {
}
export const terminalsRegistry = {
register(paneId: string, label: string): () => void {
terminalRegistry.set(paneId, { paneId, label });
register(paneId: string, label: string, focus: () => void): () => void {
terminalRegistry.set(paneId, { paneId, label, focus });
notifyRegistry();
return () => {
terminalRegistry.delete(paneId);
@@ -71,6 +74,9 @@ export const terminalsRegistry = {
list(): TerminalRegistration[] {
return Array.from(terminalRegistry.values());
},
get(paneId: string): TerminalRegistration | undefined {
return terminalRegistry.get(paneId);
},
subscribe(listener: Listener<void>): () => void {
registryListeners.add(listener);
return () => {

View File

@@ -10,6 +10,7 @@ import { ChevronRight, FolderTree, Menu } from 'lucide-react';
import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { terminalsRegistry } from '@/lib/events';
import { useActivePane } from '@/hooks/useActivePane';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
@@ -170,6 +171,110 @@ function SessionInner({ sessionId }: { sessionId: string }) {
[setActivePaneIdx, isMobile, panes, navigate, location.pathname, location.search],
);
// v1.10.3 fix: addSplitPane sets activePaneIdx, but on mobile the URL-sync
// effect below sees a stale ?pane= and immediately resets the index. Push
// the new pane's id to the URL atomically so the effect's next pass sees a
// matching id and is a no-op. Desktop has no URL pane state — fall through.
const addPaneAndSwitch = useCallback(
(kind: 'chat' | 'terminal' | 'agent') => {
const newPaneId = addSplitPane(kind);
if (newPaneId === null) return;
if (isMobile) {
const params = new URLSearchParams(location.search);
params.set('pane', newPaneId);
navigate(`${location.pathname}?${params.toString()}`);
}
},
[addSplitPane, isMobile, navigate, location.pathname, location.search],
);
// v1.10.3 keyboard shortcuts. Window-level keydown so they fire from
// anywhere in the session view. Only Cmd/Ctrl-Shift-C defers to the xterm
// (which has its own copy binding for that combo); everything else fires
// regardless of focus. Cmd-W and Cmd-T are typically reserved by the
// browser — preventDefault() works in most browsers but not all.
useEffect(() => {
function onKey(e: KeyboardEvent): void {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const key = e.key.toLowerCase();
const target = e.target;
const inXterm = target instanceof Element && target.closest('.xterm') !== null;
// Cmd/Ctrl + ` — focus the active terminal or jump to the most recent
// terminal pane and focus it. No-op if there are no terminal panes.
if (key === '`') {
e.preventDefault();
const activePane = panes[activePaneIdx];
if (activePane?.kind === 'terminal') {
terminalsRegistry.get(activePane.id)?.focus();
return;
}
let lastTermIdx = -1;
for (let i = panes.length - 1; i >= 0; i--) {
if (panes[i]?.kind === 'terminal') {
lastTermIdx = i;
break;
}
}
if (lastTermIdx < 0) return;
const target = panes[lastTermIdx];
switchActivePane(lastTermIdx);
if (target) {
// The terminal may have just mounted on mobile (it was return-null
// before the switch). Defer focus until the new render commits.
setTimeout(() => terminalsRegistry.get(target.id)?.focus(), 80);
}
return;
}
// Cmd/Ctrl + Shift + T — new terminal pane and switch to it.
if (key === 't' && e.shiftKey) {
e.preventDefault();
addPaneAndSwitch('terminal');
return;
}
// Cmd/Ctrl + Shift + C — new chat pane and switch to it. The xterm's
// own Shift-C binding is "copy selection" — defer to it when in xterm.
if (key === 'c' && e.shiftKey) {
if (inXterm) return;
e.preventDefault();
addPaneAndSwitch('chat');
return;
}
// Cmd/Ctrl + W — close the active pane.
if (key === 'w' && !e.shiftKey) {
e.preventDefault();
removePane(activePaneIdx);
return;
}
// Cmd/Ctrl + Tab / Shift+Tab — cycle through panes.
if (key === 'tab') {
if (panes.length <= 1) return;
e.preventDefault();
const dir = e.shiftKey ? -1 : 1;
const next = (activePaneIdx + dir + panes.length) % panes.length;
switchActivePane(next);
return;
}
// Cmd/Ctrl + 1..9 — direct jump to pane N.
if (/^[1-9]$/.test(key)) {
const idx = parseInt(key, 10) - 1;
if (idx < panes.length) {
e.preventDefault();
switchActivePane(idx);
}
return;
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [panes, activePaneIdx, switchActivePane, addPaneAndSwitch, removePane]);
async function saveName() {
if (!session) return;
const trimmed = name.trim();
@@ -264,7 +369,7 @@ function SessionInner({ sessionId }: { sessionId: string }) {
onRenameChat={renameChat}
/>
<NewPaneMenu
onAddPane={addSplitPane}
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
/>
</div>