v1.10.3: booterm mobile/UX fixes + global keyboard shortcuts

Five issues + keyboard shortcuts across booterm and the workspace shell.

Auto-switch on create (mobile): addSplitPane now returns the new pane id;
Session.tsx wraps it with addPaneAndSwitch which pushes ?pane=<newId> on
mobile so the URL-sync effect doesn't fight the just-set activePaneIdx.
NewPaneMenu uses the wrapper; desktop Split dropdown is unaffected.

Tab-away reconnect: TerminalPane has a connect()/manualReconnect() state
machine. ws.onclose backs off 500ms/1s/2s × 3 attempts, then surfaces a
[Disconnected] banner with a Reconnect button. visibilitychange listener
calls manualReconnect when the tab returns and the WS isn't OPEN. tmux
session persists server-side so scrollback is intact on resume.

Copy/paste: attachCustomKeyEventHandler binds Cmd/Ctrl-C (copy if
selection, else send ^C), Cmd/Ctrl-Shift-C (always swallow — copy if any,
no-op otherwise — never sends ^C), Cmd/Ctrl-V and Cmd/Ctrl-Shift-V
(navigator.clipboard.readText → ws.send). No custom right-click menu —
browser's native menu is preserved.

Scroll: removed `set -g mouse on` from tmux.conf so xterm.js sees wheel
and touch events natively. scrollback: 10_000, fastScrollModifier: 'shift',
altClickMovesCursor: false. Container has touch-action: pan-y for mobile.

Right-edge gap: inline <style> overrides xterm's defaults to width:100%
height:100% and hides the scrollbar chrome. Host container is
flex-1 min-w-0 self-stretch w-full. Three refit triggers: ResizeObserver
(rAF-wrapped), document.fonts.ready, and useEffect on the new active prop.
Background color matched between outer div, inner div, and xterm theme.

Keyboard shortcuts in Session.tsx (window-level keydown):
  Cmd/Ctrl+`              focus active terminal, else jump to last
  Cmd/Ctrl+Shift+T        new terminal pane
  Cmd/Ctrl+Shift+C        new chat pane (defers to xterm copy if focused)
  Cmd/Ctrl+W              close active pane
  Cmd/Ctrl+Tab/Shift+Tab  cycle next / prev pane
  Cmd/Ctrl+1..9           jump to pane N
terminalsRegistry gains a focus() callback per registration so Cmd+`
can call term.focus() on the active terminal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 13:52:44 +00:00
parent 8eaf9591dc
commit 875db86e31
6 changed files with 411 additions and 63 deletions

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>
);
}