shell integration

This commit is contained in:
EthanShoeDev
2025-10-04 22:28:34 -04:00
parent 8b38104373
commit fb3582d218
6 changed files with 497 additions and 5 deletions

View File

@@ -141,6 +141,10 @@ function ShellDetail() {
logger.warn('sendData failed', e); logger.warn('sendData failed', e);
router.back(); router.back();
}); });
xtermRef.current?.getRecentCommands(10).then((commands) => {
logger.info('recent commands', commands);
});
}, },
[shell, router, modifierKeysActive], [shell, router, modifierKeysActive],
); );

View File

@@ -45,6 +45,9 @@ export const useSshStore = create<SshRegistryStore>((set) => ({
return { shells: rest }; return { shells: rest };
}); });
}, },
}).catch((e) => {
logger.error('error starting shell', e.name, e.message);
throw e;
}); });
const storeKey = `${connection.connectionId}-${shell.channelId}`; const storeKey = `${connection.connectionId}-${shell.channelId}`;
set((s) => ({ set((s) => ({

View File

@@ -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 commands 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;<escapedCmd>[;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)

View File

@@ -3,8 +3,11 @@ import { Terminal, type ITerminalOptions } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
import { import {
bStrToBinary, bStrToBinary,
binaryToBStr,
type BridgeInboundMessage, type BridgeInboundMessage,
type BridgeOutboundMessage, type BridgeOutboundMessage,
type CommandMeta,
type OutputMeta,
} from '../src/bridge'; } from '../src/bridge';
declare global { declare global {
@@ -52,7 +55,11 @@ window.onload = () => {
window.__FRESSH_XTERM_BRIDGE__ = true; window.__FRESSH_XTERM_BRIDGE__ = true;
const injectedObject = JSON.parse(injectedObjectJson) as ITerminalOptions; const injectedObject = JSON.parse(
injectedObjectJson,
) as ITerminalOptions & {
__fresshEnableCommandHistory?: boolean;
};
// ---- Xterm setup // ---- Xterm setup
const term = new Terminal(injectedObject); const term = new Terminal(injectedObject);
@@ -67,10 +74,169 @@ window.onload = () => {
window.terminal = term; window.terminal = term;
window.fitAddon = fitAddon; window.fitAddon = fitAddon;
let sawOsc633 = false;
let inputBuffer = '';
term.onData((data) => { term.onData((data) => {
sendToRn({ type: 'input', str: 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=<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) // Remove old handler if any (just in case)
if (window.__FRESSH_XTERM_MSG_HANDLER__) if (window.__FRESSH_XTERM_MSG_HANDLER__)
window.removeEventListener( window.removeEventListener(
@@ -78,7 +244,7 @@ window.onload = () => {
window.__FRESSH_XTERM_MSG_HANDLER__!, 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<BridgeOutboundMessage>) => { const handler = (e: MessageEvent<BridgeOutboundMessage>) => {
try { try {
const msg = e.data; const msg = e.data;
@@ -89,6 +255,7 @@ window.onload = () => {
const termWrite = (bStr: string) => { const termWrite = (bStr: string) => {
const bytes = bStrToBinary(bStr); const bytes = bStrToBinary(bStr);
term.write(bytes); term.write(bytes);
if (enableHistory) appendOutput(bytes);
}; };
switch (msg.type) { switch (msg.type) {
@@ -140,6 +307,59 @@ window.onload = () => {
term.focus(); term.focus();
break; 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) { } catch (err) {
sendToRn({ sendToRn({

View File

@@ -1,11 +1,42 @@
import { Base64 } from 'js-base64'; import { Base64 } from 'js-base64';
type ITerminalOptions = import('@xterm/xterm').ITerminalOptions; type ITerminalOptions = import('@xterm/xterm').ITerminalOptions;
type ITerminalInitOnlyOptions = import('@xterm/xterm').ITerminalInitOnlyOptions; 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 // Messages posted from the WebView (xterm page) to React Native
export type BridgeInboundMessage = export type BridgeInboundMessage =
| { type: 'initialized' } | { type: 'initialized' }
| { type: 'input'; str: string } | { 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) // Messages injected from React Native into the WebView (xterm page)
export type BridgeOutboundMessage = export type BridgeOutboundMessage =
@@ -18,7 +49,11 @@ export type BridgeOutboundMessage =
opts: Partial<Omit<ITerminalOptions, keyof ITerminalInitOnlyOptions>>; opts: Partial<Omit<ITerminalOptions, keyof ITerminalInitOnlyOptions>>;
} }
| { type: 'clear' } | { 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 => export const binaryToBStr = (binary: Uint8Array): string =>
Base64.fromUint8Array(binary); Base64.fromUint8Array(binary);

View File

@@ -40,6 +40,14 @@ export type XtermWebViewHandle = {
focus: () => void; focus: () => void;
resize: (size: { cols: number; rows: number }) => void; resize: (size: { cols: number; rows: number }) => void;
fit: () => void; fit: () => void;
getRecentCommands: (
limit?: number,
) => Promise<import('./bridge').CommandMeta[]>;
getRecentOutputs: (
limit?: number,
) => Promise<import('./bridge').OutputMeta[]>;
getCommandOutput: (id: string) => Promise<Uint8Array | null>;
clearHistory: () => Promise<void>;
}; };
const defaultWebViewProps: WebViewOptions = { const defaultWebViewProps: WebViewOptions = {
@@ -95,6 +103,7 @@ export type XtermJsWebViewProps = {
rows: number; rows: number;
}; };
autoFit?: boolean; autoFit?: boolean;
enableCommandHistory?: boolean; // default true
}; };
function xTermOptionsEquals( function xTermOptionsEquals(
@@ -126,10 +135,30 @@ export function XtermJsWebView({
logger, logger,
size, size,
autoFit = true, autoFit = true,
enableCommandHistory = true,
}: XtermJsWebViewProps) { }: XtermJsWebViewProps) {
const webRef = useRef<WebView>(null); const webRef = useRef<WebView>(null);
const [initialized, setInitialized] = useState(false); 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 // ---- RN -> WebView message sender
const sendToWebView = useCallback( const sendToWebView = useCallback(
(obj: BridgeOutboundMessage) => { (obj: BridgeOutboundMessage) => {
@@ -243,6 +272,53 @@ export function XtermJsWebView({
appliedSizeRef.current = size; appliedSizeRef.current = size;
}, },
fit, 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( const mergedXTermOptions = useMemo(
@@ -287,6 +363,25 @@ export function XtermJsWebView({
logger?.log?.(`received debug msg from webview: `, msg.message); logger?.log?.(`received debug msg from webview: `, msg.message);
return; 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); webViewOptions?.onMessage?.(e);
} catch (error) { } catch (error) {
logger?.warn?.( logger?.warn?.(
@@ -349,7 +444,10 @@ export function XtermJsWebView({
source={{ html: htmlString }} source={{ html: htmlString }}
onMessage={onMessage} onMessage={onMessage}
style={style} style={style}
injectedJavaScriptObject={mergedXTermOptions} injectedJavaScriptObject={{
...mergedXTermOptions,
__fresshEnableCommandHistory: enableCommandHistory,
}}
injectedJavaScriptBeforeContentLoaded={ injectedJavaScriptBeforeContentLoaded={
mergedXTermOptions.theme?.background mergedXTermOptions.theme?.background
? ` ? `