diff --git a/package-lock.json b/package-lock.json index 7c05d4c..35f92af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,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", @@ -5713,6 +5714,15 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/addon-image": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.8.0.tgz", + "integrity": "sha512-b/dqpFn3jUad2pUP5UpF4scPIh0WdxRQL/1qyiahGfUI85XZTCXo0py9G6AcOR2QYUw8eJ8EowGspT7BQcgw6A==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.2.0" + } + }, "node_modules/@xterm/addon-web-links": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz", diff --git a/package.json b/package.json index 7e48abd..50ba0f1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/modules/websocket/services/shell-websocket.service.ts b/server/modules/websocket/services/shell-websocket.service.ts index 4d54a13..e53574d 100644 --- a/server/modules/websocket/services/shell-websocket.service.ts +++ b/server/modules/websocket/services/shell-websocket.service.ts @@ -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 }, diff --git a/server/modules/websocket/tests/shell-websocket-protocol.test.ts b/server/modules/websocket/tests/shell-websocket-protocol.test.ts index caaa5d5..4464aca 100644 --- a/server/modules/websocket/tests/shell-websocket-protocol.test.ts +++ b/server/modules/websocket/tests/shell-websocket-protocol.test.ts @@ -102,7 +102,7 @@ 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({ @@ -110,7 +110,7 @@ test('typed attach uses the server-built exact argv', async () => { 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 () => { @@ -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' }); diff --git a/src/components/shell/hooks/useShellTerminal.test.ts b/src/components/shell/hooks/useShellTerminal.test.ts new file mode 100644 index 0000000..09bd667 --- /dev/null +++ b/src/components/shell/hooks/useShellTerminal.test.ts @@ -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 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'); + } +}); diff --git a/src/components/shell/hooks/useShellTerminal.ts b/src/components/shell/hooks/useShellTerminal.ts index c9a5f9a..8701264 100644 --- a/src/components/shell/hooks/useShellTerminal.ts +++ b/src/components/shell/hooks/useShellTerminal.ts @@ -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'; @@ -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; terminalRef: MutableRefObject; @@ -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 { diff --git a/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx b/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx index 5c37bd4..0f39d41 100644 --- a/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx +++ b/src/components/standalone-shell/view/StandaloneShell.typedAttach.test.tsx @@ -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 {}'], ]);