Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"@vscode/ripgrep": "^1.17.1",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-image": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
Expand Down
7 changes: 6 additions & 1 deletion server/modules/websocket/services/shell-websocket.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,12 @@ function buildTypedAttachCommand(tmux: {
windowId: string;
paneId: string;
}): string {
return `tmux -S ${shellQuote(tmux.socketPath)} select-window -t ${shellQuote(tmux.windowId)} \\; select-pane -t ${shellQuote(tmux.paneId)} \\; attach-session -t ${shellQuote(tmux.sessionId)}`;
const socket = shellQuote(tmux.socketPath);
const attach = `tmux -S ${socket} select-window -t ${shellQuote(tmux.windowId)} \\; select-pane -t ${shellQuote(tmux.paneId)} \\; attach-session -t ${shellQuote(tmux.sessionId)}`;
if (os.platform() === 'win32') {
return `tmux -S ${socket} set-option -g allow-passthrough on *> $null; ${attach}`;
}
return `tmux -S ${socket} set-option -g allow-passthrough on >/dev/null 2>&1 || true; exec ${attach}`;
}
async function readTmuxSessionName(
tmux: { socketPath: string; paneId: string },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,15 @@ test('local-agent validation failure never spawns a PTY', async () => {
assert.equal(spawned, 0);
});

test('typed attach uses the server-built exact argv', async () => {
test('typed attach best-effort enables passthrough before the server-built exact argv', async () => {
const commands: string[][] = [];
const ws = new FakeWebSocket();
handleShellConnection(ws as never, dependencies({
spawn: ((_shell: string, args: string[]) => { commands.push(args); return fakePty(); }) as never,
assertFreshExternalTmuxTarget: async () => createVerifiedTmuxActionTarget(tmux, { pid: 9, startedAtMs: 1 }, 'claude', 'agent'),
}));
await sendInit(ws, { shellProtocolVersion: SHELL_PROTOCOL_VERSION, mode: 'typed-attach', targetClass: 'local-agent', tmux, process: { pid: 9, startedAtMs: 1 } });
assert.deepEqual(commands, [['-c', "tmux -S '/tmp/tmux.sock' select-window -t '@2' \\; select-pane -t '%3' \\; attach-session -t '$1'"]]);
assert.deepEqual(commands, [['-c', "tmux -S '/tmp/tmux.sock' set-option -g allow-passthrough on >/dev/null 2>&1 || true; exec tmux -S '/tmp/tmux.sock' select-window -t '@2' \\; select-pane -t '%3' \\; attach-session -t '$1'"]]);
});

test('plain shell preserves its command after protocol negotiation', async () => {
Expand Down Expand Up @@ -264,7 +264,7 @@ test('attach-only uses a valid capability with a matching generation to spawn th
tmux,
capability,
});
assert.deepEqual(commands, [['-c', "tmux -S '/tmp/tmux.sock' select-window -t '@2' \\; select-pane -t '%3' \\; attach-session -t '$1'"]]);
assert.deepEqual(commands, [['-c', "tmux -S '/tmp/tmux.sock' set-option -g allow-passthrough on >/dev/null 2>&1 || true; exec tmux -S '/tmp/tmux.sock' select-window -t '@2' \\; select-pane -t '%3' \\; attach-session -t '$1'"]]);
});
test('attach-only permits a server hosted outside tmux', async () => {
const capabilities = createAttachCapabilityService({ readPaneGeneration: async () => '101' });
Expand Down
198 changes: 198 additions & 0 deletions src/components/shell/hooks/useShellTerminal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import test from 'node:test';

import { createElement, useRef } from 'react';
import TestRenderer, { act } from 'react-test-renderer';

const xtermModules = new Map([
['@xterm/xterm', `
export class Terminal {
constructor(options) {
this.options = { ...options };
this.cols = 80;
this.rows = 24;
globalThis.__shellTerminalHarness.terminals.push(this);
}
loadAddon(addon) {
globalThis.__shellTerminalHarness.loadedAddons.push(addon.constructor.name);
if (addon.constructor.name === 'ImageAddon' && globalThis.__shellTerminalHarness.imageLoadFails) {
throw new Error('image addon unavailable');
}
}
open() {}
dispose() {}
clear() {}
write() {}
onData() { return { dispose() {} }; }
attachCustomKeyEventHandler() {}
hasSelection() { return false; }
getSelection() { return ''; }
refresh() {}
}
`],
['@xterm/addon-clipboard', 'export class ClipboardAddon { constructor(...args) { this.args = args; } }'],
['@xterm/addon-fit', 'export class FitAddon { fit() {} }'],
['@xterm/addon-image', `
export class ImageAddon {
constructor(options) {
globalThis.__shellTerminalHarness.imageOptions.push(options);
if (globalThis.__shellTerminalHarness.imageConstructorFails) {
throw new Error('image addon unavailable');
}
}
}
`],
['@xterm/addon-web-links', 'export class WebLinksAddon {}'],
['@xterm/addon-webgl', 'export class WebglAddon {}'],
]);
const moduleHooks = registerHooks({
resolve(specifier, context, nextResolve) {
const source = xtermModules.get(specifier);
return source
? { url: `data:text/javascript,${encodeURIComponent(source)}`, shortCircuit: true }
: nextResolve(specifier, context);
},
});
const { useShellTerminal } = await import('./useShellTerminal');
moduleHooks.deregister();

type Harness = {
terminals: unknown[];
loadedAddons: string[];
imageOptions: Array<{ pixelLimit: number; storageLimit: number }>;
imageConstructorFails: boolean;
imageLoadFails: boolean;
};

type BrowserHarness = {
restore: () => void;
};

function installBrowserGlobals(): BrowserHarness {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver');
const timers = new Map<number, () => void>();
let nextTimer = 1;

class TestResizeObserver {
observe() {}
disconnect() {}
}

Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
setTimeout(callback: () => void) {
const id = nextTimer++;
timers.set(id, callback);
return id;
},
clearTimeout(id: number) {
timers.delete(id);
},
},
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: TestResizeObserver,
});

return {
restore: () => {
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (originalResizeObserver) Object.defineProperty(globalThis, 'ResizeObserver', originalResizeObserver);
else Reflect.deleteProperty(globalThis, 'ResizeObserver');
},
};
}

function TerminalProbe({ minimal }: { minimal: boolean }) {
const terminalContainerRef = useRef({
style: { setProperty() {}, removeProperty() {} },
addEventListener() {},
removeEventListener() {},
} as never);
const terminalRef = useRef(null);
const fitAddonRef = useRef(null);
const wsRef = useRef(null);

useShellTerminal({
terminalContainerRef,
terminalRef,
fitAddonRef,
wsRef,
terminalIdentityKey: 'terminal-1',
minimal,
isRestarting: false,
closeSocket: () => undefined,
});

return null;
}

async function mountTerminal(harness: Harness, minimal = false) {
Object.defineProperty(globalThis, '__shellTerminalHarness', {
configurable: true,
value: harness,
});
let renderer: TestRenderer.ReactTestRenderer | null = null;
await act(async () => {
renderer = TestRenderer.create(createElement(TerminalProbe, { minimal }));
});
return renderer!;
}

test('loads bounded image support before WebGL rendering', async () => {
const browser = installBrowserGlobals();
const harness: Harness = {
terminals: [],
loadedAddons: [],
imageOptions: [],
imageConstructorFails: false,
imageLoadFails: false,
};
let renderer: TestRenderer.ReactTestRenderer | null = null;
try {
renderer = await mountTerminal(harness);

assert.ok(harness.imageOptions.length > 0);
assert.ok(harness.imageOptions.every((options) =>
options.pixelLimit === 2048 * 2048 && options.storageLimit === 64,
));
assert.ok(harness.loadedAddons.indexOf('ImageAddon') < harness.loadedAddons.indexOf('WebglAddon'));
assert.ok(harness.loadedAddons.includes('WebglAddon'));
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
browser.restore();
Reflect.deleteProperty(globalThis, '__shellTerminalHarness');
}
});

test('keeps WebGL rendering when image addon loading fails quietly', async () => {
const browser = installBrowserGlobals();
const harness: Harness = {
terminals: [],
loadedAddons: [],
imageOptions: [],
imageConstructorFails: false,
imageLoadFails: true,
};
const originalWarn = console.warn;
const warnings: unknown[][] = [];
console.warn = (...args: unknown[]) => warnings.push(args);
let renderer: TestRenderer.ReactTestRenderer | null = null;
try {
renderer = await mountTerminal(harness);

assert.ok(harness.loadedAddons.includes('ImageAddon'));
assert.ok(harness.loadedAddons.includes('WebglAddon'));
assert.deepEqual(warnings, []);
} finally {
console.warn = originalWarn;
if (renderer) await act(async () => { renderer!.unmount(); });
browser.restore();
Reflect.deleteProperty(globalThis, '__shellTerminalHarness');
}
});
15 changes: 15 additions & 0 deletions src/components/shell/hooks/useShellTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject, RefObject } from 'react';
import { ClipboardAddon, type IClipboardProvider } from '@xterm/addon-clipboard';
import { FitAddon } from '@xterm/addon-fit';
import { ImageAddon } from '@xterm/addon-image';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { Terminal } from '@xterm/xterm';
Expand Down Expand Up @@ -57,6 +58,11 @@ const ClipboardAddonCtor = ClipboardAddon as unknown as new (
provider?: IClipboardProvider,
) => ClipboardAddon;

// Decoded images are RGBA8888: this limits one image to 16 MiB and the
// addon's FIFO-backed scrollback storage to 64 MiB.
const IMAGE_PIXEL_LIMIT = 2048 * 2048;
const IMAGE_STORAGE_LIMIT_MB = 64;

type UseShellTerminalOptions = {
terminalContainerRef: RefObject<HTMLDivElement>;
terminalRef: MutableRefObject<Terminal | null>;
Expand Down Expand Up @@ -139,6 +145,15 @@ export function useShellTerminal({
nextTerminal.loadAddon(new WebLinksAddon());
}

try {
nextTerminal.loadAddon(new ImageAddon({
pixelLimit: IMAGE_PIXEL_LIMIT,
storageLimit: IMAGE_STORAGE_LIMIT_MB,
}));
} catch {
// Image protocols are optional; retain the terminal's existing renderer.
}

try {
nextTerminal.loadAddon(new WebglAddon());
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const xtermModules = new Map([
['@xterm/xterm', 'export class Terminal {}'],
['@xterm/addon-clipboard', 'export class ClipboardAddon {}'],
['@xterm/addon-fit', 'export class FitAddon {}'],
['@xterm/addon-image', 'export class ImageAddon {}'],
['@xterm/addon-web-links', 'export class WebLinksAddon {}'],
['@xterm/addon-webgl', 'export class WebglAddon {}'],
]);
Expand Down