From fb3582d21815ad80e98c19cda20f0cc34f146d29 Mon Sep 17 00:00:00 2001 From: EthanShoeDev <13422990+EthanShoeDev@users.noreply.github.com> Date: Sat, 4 Oct 2025 22:28:34 -0400 Subject: [PATCH] shell integration --- apps/mobile/src/app/(tabs)/shell/detail.tsx | 4 + apps/mobile/src/lib/ssh-store.ts | 3 + docs/projects/shell-integration.md | 132 +++++++++++ .../src-internal/main.tsx | 224 +++++++++++++++++- .../src/bridge.ts | 39 ++- .../src/index.tsx | 100 +++++++- 6 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 docs/projects/shell-integration.md diff --git a/apps/mobile/src/app/(tabs)/shell/detail.tsx b/apps/mobile/src/app/(tabs)/shell/detail.tsx index 4814ffb..e8fef28 100644 --- a/apps/mobile/src/app/(tabs)/shell/detail.tsx +++ b/apps/mobile/src/app/(tabs)/shell/detail.tsx @@ -141,6 +141,10 @@ function ShellDetail() { logger.warn('sendData failed', e); router.back(); }); + + xtermRef.current?.getRecentCommands(10).then((commands) => { + logger.info('recent commands', commands); + }); }, [shell, router, modifierKeysActive], ); diff --git a/apps/mobile/src/lib/ssh-store.ts b/apps/mobile/src/lib/ssh-store.ts index 5e980bb..f05083c 100644 --- a/apps/mobile/src/lib/ssh-store.ts +++ b/apps/mobile/src/lib/ssh-store.ts @@ -45,6 +45,9 @@ export const useSshStore = create((set) => ({ return { shells: rest }; }); }, + }).catch((e) => { + logger.error('error starting shell', e.name, e.message); + throw e; }); const storeKey = `${connection.connectionId}-${shell.channelId}`; set((s) => ({ diff --git a/docs/projects/shell-integration.md b/docs/projects/shell-integration.md new file mode 100644 index 0000000..28aa771 --- /dev/null +++ b/docs/projects/shell-integration.md @@ -0,0 +1,132 @@ +# Add command detection and history to the RN xterm WebView + +## What we'll build + +- In-WebView OSC-633 parser for VS Code shell integration sequences (A/B/C/D/E, + P=Cwd) to get exact command boundaries when present. Falls back to heuristics + (Enter-based + prompt learning + alt-screen guard) when sequences are absent. + No persistent server install required; optional ephemeral per-session sourcing + is supported later. +- Ring buffers in WebView to store last N commands and their outputs, with size + caps. +- New bridge messages and imperative methods so RN can query: last N commands, + last N outputs, and a specific command’s output. + +## Key files to change + +- packages/react-native-xtermjs-webview/src-internal/main.tsx + - Register xterm OSC handler for 633; parse “A/B/C/D/E” and “P;Cwd=…”. + - Track command state and outputs; implement a capped in-memory store. + - Respect a runtime flag from injected options to fully disable command + tracking/history. + - Add message handler for queries (from RN) and send responses. + +- packages/react-native-xtermjs-webview/src/bridge.ts + - Extend `BridgeOutboundMessage` (RN→WebView) with query messages. + - Extend `BridgeInboundMessage` (WebView→RN) with responses and optional + events. + +- packages/react-native-xtermjs-webview/src/index.tsx + - Add prop `enableCommandHistory?: boolean` (default true). When false, do not + enable OSC handlers/heuristics or allocate history in the WebView. + - Extend `XtermWebViewHandle` with: + - `getRecentCommands(limit?: number)` + - `getRecentOutputs(limit?: number)` + - `getCommandOutput(id: string)` + - `clearHistory()` + - Implement a simple request/response over `injectJavaScript` using + correlation IDs. + +- apps/mobile/src/app/(tabs)/shell/detail.tsx + - Show example usage via the existing `xtermRef` to fetch recent + commands/outputs on demand. + +## Implementation details + +- OSC-633 parsing + - Use xterm proposed API to register an OSC handler when available: + `terminal.parser.registerOscHandler(633, handler)`. + - Handle sequences per VS Code docs: `A` (prompt start), `B` (prompt end), `C` + (pre-exec), `D[;code]` (post-exec), `E;[;nonce]`, `P;Cwd=…`. + - References: VS Code docs and sources. + +- Heuristic fallback (no sequences) + - Track local keystrokes (we already have them) to build a transient “input + line buffer”. + - On Enter (\r or \r\n) outside alt-screen (CSI ? 1049/47/1047 toggles), emit + a best-effort “command started”. + - Detect new prompt by screen change patterns (stable prompt prefix on the + left) to close a command when possible; otherwise time out and roll forward. + - Disable detection while in full-screen TUIs (alt screen), and ignore + bracketed paste blocks. + +- Storage + - Maintain two ring buffers with caps (defaults: 100 commands, 1 MB output per + command; configurable via injected options later). + - Each entry: + `{ id, command, startTime, endTime?, exitCode?, cwd?, outputBytes[] }`. + +- Bridge extensions + - RN→WebView: `{ type: 'history:getCommands', limit? }`, + `{ type: 'history:getOutputs', limit? }`, + `{ type: 'history:getOutput', id }`, `{ type: 'history:clear' }`. + - WebView→RN responses: `{ type: 'history:commands', corr, items }`, + `{ type: 'history:outputs', corr, items }`, + `{ type: 'history:output', corr, item }`, + `{ type: 'history:cleared', corr }`. + - Optional event stream for live updates: `{ type: 'history:event', event }`. + +- Imperative handle + - Implement methods that send queries with a correlation ID and await the + matching response via `onMessage`. + +## Critical code touchpoints + +- Where to hook OSC parsing and input/output + +```150:176:packages/react-native-xtermjs-webview/src-internal/main.tsx + term.onData((data) => { + sendToRn({ type: 'input', str: data }); + }); +``` + +- Where to expose new methods + +```231:246:packages/react-native-xtermjs-webview/src/index.tsx + useImperativeHandle(ref, () => ({ + write, + writeMany, + flush, + clear: () => sendToWebView({ type: 'clear' }), + focus: () => { + sendToWebView({ type: 'focus' }); + webRef.current?.requestFocus(); + }, + resize: (size: { cols: number; rows: number }) => { + sendToWebView({ type: 'resize', cols: size.cols, rows: size.rows }); + autoFitFn(); + appliedSizeRef.current = size; + }, + fit, + })); +``` + +## Optional enhancement (no install, ephemeral session-only) + +- On session open, send a one-shot, in-memory sourced shell snippet + (bash/zsh/fish/pwsh) to enable OSC 633 for that session only. No files written + server-side. If disabled by user, fallback to heuristics. + +## References + +- VS Code Shell Integration docs (OSC 633, iTerm/FinalTerm sequences) + [Terminal Shell Integration](https://code.visualstudio.com/docs/terminal/shell-integration) +- VS Code sources: `shellIntegrationAddon.ts`, `commandDetectionCapability.ts`, + `terminalEnvironment.ts`: + - [shellIntegrationAddon.ts](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts) + - [commandDetectionCapability.ts](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/common/capabilities/commandDetectionCapability.ts) + - [terminalEnvironment.ts](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/terminalEnvironment.ts) +- Shell scripts (for optional ephemeral sourcing): + - [shellIntegration-bash.sh](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh) + - [shellIntegration.ps1](https://cocalc.com/github/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1?utm_source=chatgpt.com) + - [Fish integration discussion](https://github.com/microsoft/vscode/issues/184659?utm_source=chatgpt.com) diff --git a/packages/react-native-xtermjs-webview/src-internal/main.tsx b/packages/react-native-xtermjs-webview/src-internal/main.tsx index b07b820..73af439 100644 --- a/packages/react-native-xtermjs-webview/src-internal/main.tsx +++ b/packages/react-native-xtermjs-webview/src-internal/main.tsx @@ -3,8 +3,11 @@ import { Terminal, type ITerminalOptions } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; import { bStrToBinary, + binaryToBStr, type BridgeInboundMessage, type BridgeOutboundMessage, + type CommandMeta, + type OutputMeta, } from '../src/bridge'; declare global { @@ -52,7 +55,11 @@ window.onload = () => { window.__FRESSH_XTERM_BRIDGE__ = true; - const injectedObject = JSON.parse(injectedObjectJson) as ITerminalOptions; + const injectedObject = JSON.parse( + injectedObjectJson, + ) as ITerminalOptions & { + __fresshEnableCommandHistory?: boolean; + }; // ---- Xterm setup const term = new Terminal(injectedObject); @@ -67,10 +74,169 @@ window.onload = () => { window.terminal = term; window.fitAddon = fitAddon; + let sawOsc633 = false; + let inputBuffer = ''; term.onData((data) => { sendToRn({ type: 'input', str: data }); + if (enableHistory && !sawOsc633) { + for (let i = 0; i < data.length; i++) { + const ch = data[i]; + if (ch === '\\r' || ch === '\\n') { + if (current) finishCommand(undefined); + const cmd = inputBuffer.trim(); + inputBuffer = ''; + if (cmd.length > 0) startCommand(cmd); + } else { + inputBuffer += ch; + } + } + } }); + // ---- Command history tracking (OSC 633 and minimal heuristics) + const enableHistory = injectedObject.__fresshEnableCommandHistory ?? true; + + type MutableCommand = CommandMeta & { + _output: Uint8Array; + _truncated: boolean; + }; + const maxCommands = 100; + const maxBytesPerCommand = 1 * 1024 * 1024; // 1MB + + const commands: MutableCommand[] = []; + let current: MutableCommand | null = null; + let cwd: string | undefined = undefined; + + function pushCommand(cmd: MutableCommand) { + if (commands.length >= maxCommands) commands.shift(); + commands.push(cmd); + } + + function startCommand(command?: string) { + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + current = { + id, + command, + startTime: Date.now(), + cwd, + _output: new Uint8Array(0), + _truncated: false, + }; + sendToRn({ + type: 'history:event', + event: { kind: 'commandStarted', meta: { ...current } }, + }); + } + + function finishCommand(exitCode?: number) { + if (!current) return; + current.endTime = Date.now(); + if (exitCode != null) current.exitCode = exitCode; + pushCommand(current); + sendToRn({ + type: 'history:event', + event: { kind: 'commandFinished', meta: { ...current } }, + }); + current = null; + } + + function appendOutput(bytes: Uint8Array) { + if (!current) return; + if (current._truncated) return; + const newLen = current._output.length + bytes.length; + if (newLen > maxBytesPerCommand) { + const allowed = Math.max( + 0, + maxBytesPerCommand - current._output.length, + ); + if (allowed > 0) { + const merged = new Uint8Array(current._output.length + allowed); + merged.set(current._output, 0); + merged.set(bytes.subarray(0, allowed), current._output.length); + current._output = merged; + } + current._truncated = true; + return; + } + const merged = new Uint8Array(newLen); + merged.set(current._output, 0); + merged.set(bytes, current._output.length); + current._output = merged; + } + + if (enableHistory) { + // OSC 633 handler + try { + term.parser.registerOscHandler(633, (data: string) => { + sawOsc633 = true; + // data like: 'A' | 'B' | 'C' | 'D;0' | 'E;...escapedCmd[;nonce]' | 'P;Cwd=/path' + if (!data) return true; + const semi = data.indexOf(';'); + const tag = semi === -1 ? data : data.slice(0, semi); + const rest = semi === -1 ? '' : data.slice(semi + 1); + switch (tag) { + case 'P': { + // property + // format: Cwd= + const eq = rest.indexOf('='); + if (eq !== -1) { + const key = rest.slice(0, eq); + const value = rest.slice(eq + 1); + if (key === 'Cwd') { + cwd = value; + sendToRn({ + type: 'history:event', + event: { kind: 'cwdChanged', cwd: value }, + }); + } + } + return true; + } + case 'E': { + // explicit command line + // command is escaped: requires unescaping \ and \xAB (hex) + let cmdRaw = rest; + const semi2 = cmdRaw.indexOf(';'); + if (semi2 !== -1) cmdRaw = cmdRaw.slice(0, semi2); + // unescape + const unescaped = cmdRaw + .replace(/\\x([0-9a-fA-F]{2})/g, (_m, p1) => + String.fromCharCode(parseInt(p1, 16)), + ) + .replace(/\\\\/g, '\\'); + // save for upcoming run if we start immediately + if (current) current.command = unescaped; + else startCommand(unescaped); + return true; + } + case 'C': { + // pre-execution + if (!current) startCommand(); + return true; + } + case 'D': { + // execution finished + let code: number | undefined = undefined; + if (rest) { + const n = Number(rest); + if (!Number.isNaN(n)) code = n; + } + finishCommand(code); + return true; + } + // 'A' and 'B' (prompt markers) are ignored for storage here + default: + return true; // swallow + } + }); + } catch (e) { + sendToRn({ + type: 'debug', + message: `OSC633 handler error: ${String(e)}`, + }); + } + } + // Remove old handler if any (just in case) if (window.__FRESSH_XTERM_MSG_HANDLER__) window.removeEventListener( @@ -78,7 +244,7 @@ window.onload = () => { window.__FRESSH_XTERM_MSG_HANDLER__!, ); - // RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus) + // RN -> WebView handler (write, resize, setFont, setTheme, setOptions, clear, focus, history queries) const handler = (e: MessageEvent) => { try { const msg = e.data; @@ -89,6 +255,7 @@ window.onload = () => { const termWrite = (bStr: string) => { const bytes = bStrToBinary(bStr); term.write(bytes); + if (enableHistory) appendOutput(bytes); }; switch (msg.type) { @@ -140,6 +307,59 @@ window.onload = () => { term.focus(); break; } + case 'history:getCommands': { + const items: CommandMeta[] = commands.map((c) => ({ + id: c.id, + command: c.command, + startTime: c.startTime, + endTime: c.endTime, + exitCode: c.exitCode, + cwd: c.cwd, + })); + const lim = msg.limit ?? items.length; + sendToRn({ + type: 'history:commands', + corr: msg.corr, + items: items.slice(-lim), + }); + break; + } + case 'history:getOutputs': { + const items: OutputMeta[] = commands.map((c) => ({ + id: c.id, + byteLength: c._output.length, + })); + const lim = msg.limit ?? items.length; + sendToRn({ + type: 'history:outputs', + corr: msg.corr, + items: items.slice(-lim), + }); + break; + } + case 'history:getOutput': { + const found = commands.find((c) => c.id === msg.id); + if (!found) { + sendToRn({ + type: 'history:output', + corr: msg.corr, + item: undefined, + }); + break; + } + sendToRn({ + type: 'history:output', + corr: msg.corr, + item: { id: found.id, bytesB64: binaryToBStr(found._output) }, + }); + break; + } + case 'history:clear': { + commands.length = 0; + current = null; + sendToRn({ type: 'history:cleared', corr: msg.corr }); + break; + } } } catch (err) { sendToRn({ diff --git a/packages/react-native-xtermjs-webview/src/bridge.ts b/packages/react-native-xtermjs-webview/src/bridge.ts index 3265dfd..57cb468 100644 --- a/packages/react-native-xtermjs-webview/src/bridge.ts +++ b/packages/react-native-xtermjs-webview/src/bridge.ts @@ -1,11 +1,42 @@ import { Base64 } from 'js-base64'; type ITerminalOptions = import('@xterm/xterm').ITerminalOptions; type ITerminalInitOnlyOptions = import('@xterm/xterm').ITerminalInitOnlyOptions; + +// ---- History payload shapes +export type CommandMeta = { + id: string; + command?: string; + startTime: number; // epoch ms + endTime?: number; // epoch ms + exitCode?: number; + cwd?: string; +}; + +export type OutputMeta = { + id: string; + byteLength: number; +}; + +export type OutputItemB64 = { + id: string; + bytesB64: string; // base64-encoded bytes +}; + +export type HistoryEvent = + | { kind: 'commandStarted'; meta: CommandMeta } + | { kind: 'commandFinished'; meta: CommandMeta } + | { kind: 'cwdChanged'; cwd: string }; + // Messages posted from the WebView (xterm page) to React Native export type BridgeInboundMessage = | { type: 'initialized' } | { type: 'input'; str: string } - | { type: 'debug'; message: string }; + | { type: 'debug'; message: string } + | { type: 'history:commands'; corr: string; items: CommandMeta[] } + | { type: 'history:outputs'; corr: string; items: OutputMeta[] } + | { type: 'history:output'; corr: string; item?: OutputItemB64 } + | { type: 'history:cleared'; corr: string } + | { type: 'history:event'; event: HistoryEvent }; // Messages injected from React Native into the WebView (xterm page) export type BridgeOutboundMessage = @@ -18,7 +49,11 @@ export type BridgeOutboundMessage = opts: Partial>; } | { type: 'clear' } - | { type: 'focus' }; + | { type: 'focus' } + | { type: 'history:getCommands'; corr: string; limit?: number } + | { type: 'history:getOutputs'; corr: string; limit?: number } + | { type: 'history:getOutput'; corr: string; id: string } + | { type: 'history:clear'; corr: string }; export const binaryToBStr = (binary: Uint8Array): string => Base64.fromUint8Array(binary); diff --git a/packages/react-native-xtermjs-webview/src/index.tsx b/packages/react-native-xtermjs-webview/src/index.tsx index ea50f33..ce1d5c8 100644 --- a/packages/react-native-xtermjs-webview/src/index.tsx +++ b/packages/react-native-xtermjs-webview/src/index.tsx @@ -40,6 +40,14 @@ export type XtermWebViewHandle = { focus: () => void; resize: (size: { cols: number; rows: number }) => void; fit: () => void; + getRecentCommands: ( + limit?: number, + ) => Promise; + getRecentOutputs: ( + limit?: number, + ) => Promise; + getCommandOutput: (id: string) => Promise; + clearHistory: () => Promise; }; const defaultWebViewProps: WebViewOptions = { @@ -95,6 +103,7 @@ export type XtermJsWebViewProps = { rows: number; }; autoFit?: boolean; + enableCommandHistory?: boolean; // default true }; function xTermOptionsEquals( @@ -126,10 +135,30 @@ export function XtermJsWebView({ logger, size, autoFit = true, + enableCommandHistory = true, }: XtermJsWebViewProps) { const webRef = useRef(null); const [initialized, setInitialized] = useState(false); + // Request/response correlation for history APIs + const pendingRef = useRef( + new Map< + string, + { + resolve: (v: unknown) => void; + reject: (e: unknown) => void; + type: + | 'history:commands' + | 'history:outputs' + | 'history:output' + | 'history:cleared'; + } + >(), + ); + const genCorr = useCallback(() => { + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + }, []); + // ---- RN -> WebView message sender const sendToWebView = useCallback( (obj: BridgeOutboundMessage) => { @@ -243,6 +272,53 @@ export function XtermJsWebView({ appliedSizeRef.current = size; }, fit, + getRecentCommands: (limit?: number) => { + return new Promise((resolve, reject) => { + const corr = genCorr(); + pendingRef.current.set(corr, { + resolve: (v: unknown) => + resolve(v as unknown as import('./bridge').CommandMeta[]), + reject: (e: unknown) => reject(e as unknown as never), + type: 'history:commands', + }); + sendToWebView({ type: 'history:getCommands', corr, limit }); + }); + }, + getRecentOutputs: (limit?: number) => { + return new Promise((resolve, reject) => { + const corr = genCorr(); + pendingRef.current.set(corr, { + resolve: (v: unknown) => + resolve(v as unknown as import('./bridge').OutputMeta[]), + reject: (e: unknown) => reject(e as unknown as never), + type: 'history:outputs', + }); + sendToWebView({ type: 'history:getOutputs', corr, limit }); + }); + }, + getCommandOutput: (id: string) => { + return new Promise((resolve, reject) => { + const corr = genCorr(); + pendingRef.current.set(corr, { + resolve: (v: unknown) => + resolve((v as unknown as Uint8Array | null) ?? null), + reject: (e: unknown) => reject(e as unknown as never), + type: 'history:output', + }); + sendToWebView({ type: 'history:getOutput', corr, id }); + }); + }, + clearHistory: () => { + return new Promise((resolve, reject) => { + const corr = genCorr(); + pendingRef.current.set(corr, { + resolve: () => resolve(), + reject: (e: unknown) => reject(e as unknown as never), + type: 'history:cleared', + }); + sendToWebView({ type: 'history:clear', corr }); + }); + }, })); const mergedXTermOptions = useMemo( @@ -287,6 +363,25 @@ export function XtermJsWebView({ logger?.log?.(`received debug msg from webview: `, msg.message); return; } + if ( + msg.type === 'history:commands' || + msg.type === 'history:outputs' || + msg.type === 'history:output' || + msg.type === 'history:cleared' + ) { + const pending = pendingRef.current.get(msg.corr); + if (pending && pending.type === msg.type) { + pendingRef.current.delete(msg.corr); + if (msg.type === 'history:commands') pending.resolve(msg.items); + else if (msg.type === 'history:outputs') pending.resolve(msg.items); + else if (msg.type === 'history:output') { + if (!msg.item) pending.resolve(null); + else pending.resolve(bStrToBinary(msg.item.bytesB64)); + } else if (msg.type === 'history:cleared') + pending.resolve(undefined); + return; + } + } webViewOptions?.onMessage?.(e); } catch (error) { logger?.warn?.( @@ -349,7 +444,10 @@ export function XtermJsWebView({ source={{ html: htmlString }} onMessage={onMessage} style={style} - injectedJavaScriptObject={mergedXTermOptions} + injectedJavaScriptObject={{ + ...mergedXTermOptions, + __fresshEnableCommandHistory: enableCommandHistory, + }} injectedJavaScriptBeforeContentLoaded={ mergedXTermOptions.theme?.background ? `