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);
router.back();
});
xtermRef.current?.getRecentCommands(10).then((commands) => {
logger.info('recent commands', commands);
});
},
[shell, router, modifierKeysActive],
);

View File

@@ -45,6 +45,9 @@ export const useSshStore = create<SshRegistryStore>((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) => ({

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 {
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=<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<BridgeOutboundMessage>) => {
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({

View File

@@ -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<Omit<ITerminalOptions, keyof ITerminalInitOnlyOptions>>;
}
| { 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);

View File

@@ -40,6 +40,14 @@ export type XtermWebViewHandle = {
focus: () => void;
resize: (size: { cols: number; rows: number }) => 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 = {
@@ -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<WebView>(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
? `