diff --git a/package.json b/package.json index 51d2ee61..f7344d83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gbasin/agentboard", - "version": "0.4.3", + "version": "0.4.4", "type": "module", "description": "Web GUI for tmux optimized for AI agent TUIs", "author": "gbasin", @@ -20,10 +20,10 @@ "bun": ">=1.3.14" }, "optionalDependencies": { - "@gbasin/agentboard-darwin-arm64": "0.4.3", - "@gbasin/agentboard-darwin-x64": "0.4.3", - "@gbasin/agentboard-linux-x64": "0.4.3", - "@gbasin/agentboard-linux-arm64": "0.4.3" + "@gbasin/agentboard-darwin-arm64": "0.4.4", + "@gbasin/agentboard-darwin-x64": "0.4.4", + "@gbasin/agentboard-linux-x64": "0.4.4", + "@gbasin/agentboard-linux-arm64": "0.4.4" }, "scripts": { "dev": "concurrently -k \"bun run dev:server\" \"bun run dev:client\"", diff --git a/playwright.config.ts b/playwright.config.ts index 7f0cb3fe..e3e0eaac 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,7 +17,10 @@ export default defineConfig({ headless: true, }, webServer: { - command: `[ -d dist/client ] || bun run build && PORT=${port} TMUX_SESSION=${tmuxSession} bun src/server/index.ts`, + // AGENTBOARD_STATIC_DIR is pinned to the repo build: when e2e runs from a + // shell inside a live agentboard session, the inherited env points at the + // installed npm package's bundle and the tests would exercise stale code. + command: `[ -d dist/client ] || bun run build && PORT=${port} TMUX_SESSION=${tmuxSession} AGENTBOARD_STATIC_DIR=dist/client bun src/server/index.ts`, url: `http://localhost:${port}`, reuseExistingServer: !process.env.CI, timeout: 120000, diff --git a/src/client/__tests__/terminalControls.test.tsx b/src/client/__tests__/terminalControls.test.tsx index f20ec4f9..19695151 100644 --- a/src/client/__tests__/terminalControls.test.tsx +++ b/src/client/__tests__/terminalControls.test.tsx @@ -118,9 +118,10 @@ describe('TerminalControls', () => { expect(selections).toEqual(['session-2']) }) - test('paste button uses clipboard text fallback and refocuses', async () => { + test('paste button routes clipboard text through onPasteText and refocuses', async () => { let refocused = false const sent: string[] = [] + const pasted: string[] = [] globalAny.navigator = { vibrate: () => true, @@ -133,6 +134,7 @@ describe('TerminalControls', () => { const renderer = TestRenderer.create( sent.push(key)} + onPasteText={(text) => pasted.push(text)} sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]} currentSessionId="session-1" onSelectSession={() => {}} @@ -152,10 +154,45 @@ describe('TerminalControls', () => { await pasteButton.props.onClick() }) - expect(sent).toEqual(['pasted text']) + // Pasted text goes through the explicit paste path (bracketed server-side), + // never the raw keystroke path — so multi-line pastes aren't auto-submitted. + expect(pasted).toEqual(['pasted text']) + expect(sent).toEqual([]) expect(refocused).toBe(true) }) + test('paste button falls back to onSendKey when onPasteText is not provided', async () => { + const sent: string[] = [] + + globalAny.navigator = { + vibrate: () => true, + clipboard: { + read: () => Promise.reject(new Error('no clipboard')), + readText: () => Promise.resolve('pasted text'), + }, + } as unknown as Navigator + + const renderer = TestRenderer.create( + sent.push(key)} + sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]} + currentSessionId="session-1" + onSelectSession={() => {}} + /> + ) + + const pasteButton = findPasteButton(renderer) + if (!pasteButton) { + throw new Error('Expected paste button') + } + + await act(async () => { + await pasteButton.props.onClick() + }) + + expect(sent).toEqual(['pasted text']) + }) + test('manual paste input sends text on enter', async () => { const sent: string[] = [] diff --git a/src/client/__tests__/useTerminal.test.tsx b/src/client/__tests__/useTerminal.test.tsx index ca05c85a..d63eaeef 100644 --- a/src/client/__tests__/useTerminal.test.tsx +++ b/src/client/__tests__/useTerminal.test.tsx @@ -1242,7 +1242,15 @@ describe('useTerminal', () => { jest.advanceTimersByTime(100) }) - expect(terminal.pasteCalls).toContain('pasted-text') + // Pasted text is delivered as an explicit terminal-paste message (bracketed + // server-side via tmux), NOT via terminal.paste() — which would only bracket + // when the browser's own bracketedPasteMode is on, and otherwise auto-submit. + expect(sendCalls).toContainEqual({ + type: 'terminal-paste', + sessionId: 'session-1', + data: 'pasted-text', + }) + expect(terminal.pasteCalls).toEqual([]) // The capture-phase listener should have prevented the default to block ClipboardAddon expect(pasteEvent.preventDefault).toHaveBeenCalled() expect(pasteEvent.stopPropagation).toHaveBeenCalled() @@ -1540,6 +1548,83 @@ describe('useTerminal', () => { globalThis.fetch = originalFetch }) + test('Cmd+V text paste while in tmux copy-mode exits copy-mode before pasting', async () => { + jest.useFakeTimers() + globalAny.navigator = { + userAgent: 'Chrome', + platform: 'MacIntel', + maxTouchPoints: 0, + clipboard: { + writeText: () => Promise.resolve(), + readText: () => Promise.resolve(''), + }, + } as unknown as Navigator + + const sendCalls: Array> = [] + const listeners: Array<(message: ServerMessage) => void> = [] + const { container, dispatchEvent } = createContainerMock() + + let renderer!: TestRenderer.ReactTestRenderer + + await act(async () => { + renderer = TestRenderer.create( + sendCalls.push(message)} + subscribe={(listener) => { + listeners.push(listener) + return () => {} + }} + theme={{ background: '#000' }} + fontSize={12} + />, + { createNodeMock: () => container }, + ) + await Promise.resolve() + }) + + const terminal = TerminalMock.instances[0] + if (!terminal) throw new Error('Expected terminal instance') + + // Enter copy-mode via server status, then paste multi-line text. + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: true, + }) + }) + sendCalls.length = 0 + + terminal.emitKey({ key: 'v', type: 'keydown', metaKey: true, ctrlKey: false }) + dispatchEvent('paste', { + type: 'paste', + preventDefault: mock(() => {}), + stopPropagation: mock(() => {}), + clipboardData: { getData: () => 'line-a\nline-b' }, + }) + await act(async () => { jest.advanceTimersByTime(100) }) + + // copy-mode must be cancelled BEFORE the paste is delivered, otherwise the + // paste-buffer lands in copy-mode and never reaches the program. + const cancelIdx = sendCalls.findIndex((c) => c.type === 'tmux-cancel-copy-mode') + const pasteIdx = sendCalls.findIndex((c) => c.type === 'terminal-paste') + expect(cancelIdx).toBeGreaterThanOrEqual(0) + expect(pasteIdx).toBeGreaterThan(cancelIdx) + expect(sendCalls[pasteIdx]).toEqual({ + type: 'terminal-paste', + sessionId: 'session-1', + data: 'line-a\nline-b', + }) + expect(terminal.pasteCalls).toEqual([]) + + act(() => { + renderer.unmount() + }) + jest.useRealTimers() + }) + test('appMouse=true scroll-up forwards wheel without disabling mouse tracking or entering copy-mode', async () => { globalAny.navigator = { userAgent: 'Chrome', @@ -2155,6 +2240,7 @@ describe('useTerminal', () => { } as unknown as Navigator const { container } = createContainerMock() + const sendCalls: Array> = [] let renderer!: TestRenderer.ReactTestRenderer @@ -2163,7 +2249,7 @@ describe('useTerminal', () => { {}} + sendMessage={(message) => sendCalls.push(message)} subscribe={() => () => {}} theme={{ background: '#000' }} fontSize={12} @@ -2184,8 +2270,14 @@ describe('useTerminal', () => { jest.advanceTimersByTime(100) }) - // Should have fallen back to navigator.clipboard.readText() - expect(terminal.pasteCalls).toContain('clipboard-api-text') + // Should have fallen back to navigator.clipboard.readText() and delivered it + // as an explicit terminal-paste (bracketed server-side), not terminal.paste(). + expect(sendCalls).toContainEqual({ + type: 'terminal-paste', + sessionId: 'session-1', + data: 'clipboard-api-text', + }) + expect(terminal.pasteCalls).toEqual([]) act(() => { renderer.unmount() }) globalThis.fetch = originalFetch diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index dfbb92cd..d573cea5 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -999,6 +999,22 @@ export default function Terminal({ [session, isReadOnly, sendMessage] ) + // Deliver pasted text as a single bracketed paste (server routes it through + // tmux paste-buffer -p) so multi-line content isn't auto-submitted line-by-line. + const handlePasteText = useCallback( + (text: string) => { + if (!session || isReadOnly) return + // Exit tmux copy-mode first: a paste-buffer into a pane still in copy-mode + // is swallowed by the copy-mode key table instead of reaching the program. + if (inTmuxCopyModeRef.current) { + sendMessage({ type: 'tmux-cancel-copy-mode', sessionId: session.id }) + setTmuxCopyMode(false) + } + sendMessage({ type: 'terminal-paste', sessionId: session.id, data: text }) + }, + [session, isReadOnly, sendMessage, inTmuxCopyModeRef, setTmuxCopyMode] + ) + const handleRefocus = useCallback(() => { const container = containerRef.current if (!container) return @@ -1455,6 +1471,7 @@ export default function Terminal({ {session && ( ({ id: s.id, name: s.name, status: s.status }))} currentSessionId={session.id} diff --git a/src/client/components/TerminalControls.tsx b/src/client/components/TerminalControls.tsx index 38f8cf12..97d5e5a9 100644 --- a/src/client/components/TerminalControls.tsx +++ b/src/client/components/TerminalControls.tsx @@ -21,6 +21,12 @@ interface SessionInfo { interface TerminalControlsProps { onSendKey: (key: string) => void + /** + * Deliver pasted text as an explicit paste (bracketed via tmux) instead of raw + * keystrokes, so multi-line content isn't auto-submitted line-by-line. Falls + * back to onSendKey when not provided. + */ + onPasteText?: (text: string) => void disabled?: boolean sessions: SessionInfo[] currentSessionId: string | null @@ -149,6 +155,7 @@ async function uploadPasteImage( export default function TerminalControls({ onSendKey, + onPasteText, disabled = false, sessions, currentSessionId, @@ -297,6 +304,16 @@ export default function TerminalControls({ onSendKey(output) } + // Route pasted text through the explicit paste path (bracketed via tmux) so + // multi-line content isn't auto-submitted line-by-line; fall back to raw keys. + const sendPasteText = (text: string) => { + if (onPasteText) { + onPasteText(text) + } else { + onSendKey(text) + } + } + const handlePasteButtonClick = async () => { if (disabled) return // Check if keyboard was visible before we do anything @@ -337,7 +354,7 @@ export default function TerminalControls({ const blob = await item.getType('text/plain') const text = await blob.text() if (text) { - onSendKey(text) + sendPasteText(text) if (wasKeyboardVisible) { onRefocus?.() } @@ -350,7 +367,7 @@ export default function TerminalControls({ try { const text = await navigator.clipboard.readText() if (text) { - onSendKey(text) + sendPasteText(text) if (wasKeyboardVisible) { onRefocus?.() } @@ -369,7 +386,7 @@ export default function TerminalControls({ const handlePasteSubmit = () => { if (pasteValue) { triggerHaptic() - onSendKey(pasteValue) + sendPasteText(pasteValue) } setShowPasteInput(false) setPasteValue('') diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index e5a0930a..75708c08 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -863,7 +863,24 @@ export function useTerminal({ } if (text && !isMacSharedPasteboardPathText(text)) { - terminal.paste(text) + // Deliver as an explicit paste so the server can bracket it via + // tmux (paste-buffer -p). Relying on xterm's terminal.paste() + // would only bracket when the browser's own bracketedPasteMode is + // on — which it isn't when we attach to an already-running app + // (e.g. Claude Code in no-flicker/fullscreen), causing multi-line + // pastes to auto-submit on the first newline. Guard on the live + // ref (like sendInputIfStillAttached) so a session switch during + // the async clipboard read doesn't paste into the wrong session. + if (attachedSessionRef.current === attached) { + // Exit tmux copy-mode first (as onData does for typed input): + // paste-buffer into a pane still in copy-mode is swallowed by + // the copy-mode key table instead of reaching the program. + if (inTmuxCopyModeRef.current) { + sendMessageRef.current({ type: 'tmux-cancel-copy-mode', sessionId: attached }) + setTmuxCopyMode(false) + } + sendMessageRef.current({ type: 'terminal-paste', sessionId: attached, data: text }) + } return } diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index 15484415..044865cc 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -246,6 +246,7 @@ class TerminalProxyMock { } starts = 0 writes: string[] = [] + pastes: string[] = [] resizes: Array<{ cols: number; rows: number }> = [] disposed = false switchTargets: string[] = [] @@ -293,6 +294,10 @@ class TerminalProxyMock { this.writes.push(data) } + paste(data: string) { + this.pastes.push(data) + } + resize(cols: number, rows: number) { this.resizes.push({ cols, rows }) } @@ -2410,6 +2415,14 @@ describe('server message handlers', () => { data: 'ls', }) ) + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-paste', + sessionId: baseSession.id, + data: 'pasted\nmultiline', + }) + ) websocket.message?.( ws as never, JSON.stringify({ @@ -2420,7 +2433,10 @@ describe('server message handlers', () => { }) ) + // Typed input goes to write(); pasted text goes to paste() (bracketed via + // tmux) and must not leak into the raw keystroke path. expect(attached?.writes).toEqual(['ls']) + expect(attached?.pastes).toEqual(['pasted\nmultiline']) expect(attached?.resizes).toEqual([{ cols: 120, rows: 40 }]) attached?.emitData('output') diff --git a/src/server/__tests__/isolated/terminalProxy.test.ts b/src/server/__tests__/isolated/terminalProxy.test.ts index 692b4ed6..dbeb1d71 100644 --- a/src/server/__tests__/isolated/terminalProxy.test.ts +++ b/src/server/__tests__/isolated/terminalProxy.test.ts @@ -213,6 +213,56 @@ describe('TerminalProxy', () => { expect(proxy.getCurrentWindow()).toBe('@2') }) + test('paste stages via load-buffer stdin and replays into the grouped session', async () => { + const harness = createSpawnHarness() + const proxy = new TerminalProxy({ + connectionId: 'abc', + sessionName: 'agentboard-ws-abc', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + wait: async () => {}, + }) + + await proxy.start() + proxy.paste('line1\r\nline2\nline3') + + // Payload travels via stdin (no argv size limit), CRLF normalized to LF. + const loadCall = harness.spawnSyncCalls.find( + (call) => getTmuxCommand(call.args) === 'load-buffer' + ) + expect(loadCall?.args).toEqual([ + 'tmux', + 'load-buffer', + '-b', + 'agentboard-paste-abc-1', + '-', + ]) + expect( + (loadCall?.options as { stdin?: Buffer } | undefined)?.stdin?.toString() + ).toBe('line1\nline2\nline3') + + // Replayed into the grouped session's active pane; -p defers bracketing to + // the real pane's mode, -d deletes the staged buffer. + expect(harness.spawnSyncCalls).toContainEqual({ + args: [ + 'tmux', + 'paste-buffer', + '-d', + '-p', + '-b', + 'agentboard-paste-abc-1', + '-t', + 'agentboard-ws-abc', + ], + options: expect.objectContaining({ timeout: 3000 }), + }) + + // Paste must never reach the raw pty write path (auto-submit risk). + expect(harness.writes).toEqual([]) + }) + test('switchTo rewrites base-session targets to grouped session targets', async () => { const harness = createSpawnHarness() const proxy = new TerminalProxy({ diff --git a/src/server/__tests__/pipePaneTerminalProxy.test.ts b/src/server/__tests__/pipePaneTerminalProxy.test.ts index aa7bc435..608c3174 100644 --- a/src/server/__tests__/pipePaneTerminalProxy.test.ts +++ b/src/server/__tests__/pipePaneTerminalProxy.test.ts @@ -153,6 +153,78 @@ describe('PipePaneTerminalProxy', () => { await proxy.dispose() }) + test('paste delivers a bracketed paste-buffer and never splits into Enter keys', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-paste', + sessionName: 'agentboard-ws-conn-paste', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + await proxy.switchTo('agentboard:@1') + + proxy.paste('multi\nline\npaste') + + // Staged via load-buffer stdin (no argv size limit) into a unique buffer, + // then replayed with paste-buffer -p (bracketed iff the real pane asked). + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'load-buffer', + '-b', + 'agentboard-paste-conn-paste-1', + '-', + ]) + expect(harness.tmuxCalls).toContainEqual([ + 'tmux', + 'paste-buffer', + '-d', + '-p', + '-b', + 'agentboard-paste-conn-paste-1', + '-t', + 'agentboard:@1', + ]) + // The bug being fixed: pasted newlines must NOT become literal Enter keys. + expect(harness.tmuxCalls).not.toContainEqual([ + 'tmux', + 'send-keys', + '-t', + 'agentboard:@1', + 'Enter', + ]) + + await proxy.dispose() + }) + + test('paste is a no-op before a target is selected', async () => { + const harness = createPipeHarness() + + const proxy = new PipePaneTerminalProxy({ + connectionId: 'conn-paste-2', + sessionName: 'agentboard-ws-conn-paste-2', + baseSession: 'agentboard', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + monitorTargets: false, + }) + + await proxy.start() + proxy.paste('hello') + + expect( + harness.tmuxCalls.some((call) => getTmuxCommand(call) === 'load-buffer') + ).toBe(false) + + await proxy.dispose() + }) + test('marks dead and calls onExit when tail exits', async () => { const harness = createPipeHarness() let exitCalls = 0 diff --git a/src/server/__tests__/sshTerminalProxy.test.ts b/src/server/__tests__/sshTerminalProxy.test.ts index 067986b2..b80f939e 100644 --- a/src/server/__tests__/sshTerminalProxy.test.ts +++ b/src/server/__tests__/sshTerminalProxy.test.ts @@ -13,7 +13,9 @@ function createSshHarness(options?: { const spawnCalls: Array<{ args: string[] mode: 'terminal' | 'pipe' + stdin?: string }> = [] + const terminalWrites: string[] = [] let killed = false let terminalClosed = false let exitResolver: ((code: number) => void) | null = null @@ -38,13 +40,20 @@ function createSshHarness(options?: { const isTerminalMode = spawnOpts && typeof spawnOpts === 'object' && 'terminal' in spawnOpts - spawnCalls.push({ args, mode: isTerminalMode ? 'terminal' : 'pipe' }) + const stdinOpt = (spawnOpts as { stdin?: Buffer } | undefined)?.stdin + spawnCalls.push({ + args, + mode: isTerminalMode ? 'terminal' : 'pipe', + ...(stdinOpt !== undefined ? { stdin: stdinOpt.toString() } : {}), + }) if (isTerminalMode) { // Terminal mode — used by doStartCore for SSH attach return { terminal: { - write: () => {}, + write: (data: string) => { + terminalWrites.push(data) + }, resize: () => {}, close: () => { terminalClosed = true @@ -95,12 +104,20 @@ function createSshHarness(options?: { spawn, spawnSync, spawnCalls, + terminalWrites, wasKilled: () => killed, wasTerminalClosed: () => terminalClosed, resolveExit: (code = 0) => exitResolver?.(code), } } +/** Flush the fire-and-forget async paste chain (each await = one microtask hop). */ +async function flushAsync(rounds = 6) { + for (let i = 0; i < rounds; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + describe('SshTerminalProxy', () => { let savedTimeout: number @@ -340,4 +357,123 @@ describe('SshTerminalProxy', () => { code: 'ERR_SESSION_CREATE_FAILED', }) }) + + test('paste stages via load-buffer stdin and replays with paste-buffer', async () => { + const harness = createSshHarness({ ttyAvailable: true }) + const proxy = new SshTerminalProxy({ + connectionId: 'conn-paste', + sessionName: 'test-paste-session', + baseSession: 'agentboard', + host: 'remote-host', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + }) + + await proxy.start() + proxy.paste('line1\r\nline2\nline3') + await flushAsync() + + // Payload rides ssh stdin into remote `tmux load-buffer -` (no argv limit), + // CRLF normalized to LF, staged in a unique per-paste buffer. + const loadCall = harness.spawnCalls.find( + (c) => c.mode === 'pipe' && c.args.some((a) => a.includes('load-buffer')) + ) + expect(loadCall).toBeDefined() + const loadCmd = loadCall!.args[loadCall!.args.length - 1] + expect(loadCmd).toContain('agentboard-paste-conn-paste-1') + expect(loadCall!.stdin).toBe('line1\nline2\nline3') + + // Replayed with -d -p against the remote session's active pane. + const pasteCall = harness.spawnCalls.find( + (c) => c.mode === 'pipe' && c.args.some((a) => a.includes('paste-buffer')) + ) + expect(pasteCall).toBeDefined() + const pasteCmd = pasteCall!.args[pasteCall!.args.length - 1] + expect(pasteCmd).toContain('-d') + expect(pasteCmd).toContain('-p') + expect(pasteCmd).toContain('agentboard-paste-conn-paste-1') + expect(pasteCmd).toContain('test-paste-session') + + // Paste must never reach the raw interactive pty (auto-submit risk). + expect(harness.terminalWrites).toEqual([]) + + await proxy.dispose() + }) + + test('paste load-buffer failure drops the paste without a raw pty write', async () => { + const harness = createSshHarness({ + spawnPipeOverride: (args) => { + const cmd = args.join(' ') + if (cmd.includes('list-clients')) { + return { exitCode: 0, stdout: '/dev/pts/42\n', stderr: '' } + } + if (cmd.includes('load-buffer')) { + return { exitCode: 1, stdout: '', stderr: 'tmux gone' } + } + return { exitCode: 0, stdout: '', stderr: '' } + }, + }) + const proxy = new SshTerminalProxy({ + connectionId: 'conn-paste-fail', + sessionName: 'test-paste-fail', + baseSession: 'agentboard', + host: 'remote-host', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + }) + + await proxy.start() + proxy.paste('a\nb') + await flushAsync() + + // No replay after the failed staging, and no fallback into the pty — + // a raw multi-line write is the auto-submit bug. + expect( + harness.spawnCalls.some( + (c) => c.mode === 'pipe' && c.args.some((a) => a.includes('paste-buffer')) + ) + ).toBe(false) + expect(harness.terminalWrites).toEqual([]) + + await proxy.dispose() + }) + + test('paste-buffer failure cleans up the staged buffer', async () => { + const harness = createSshHarness({ + spawnPipeOverride: (args) => { + const cmd = args.join(' ') + if (cmd.includes('list-clients')) { + return { exitCode: 0, stdout: '/dev/pts/42\n', stderr: '' } + } + if (cmd.includes('paste-buffer')) { + return { exitCode: 1, stdout: '', stderr: 'no such target' } + } + return { exitCode: 0, stdout: '', stderr: '' } + }, + }) + const proxy = new SshTerminalProxy({ + connectionId: 'conn-paste-clean', + sessionName: 'test-paste-clean', + baseSession: 'agentboard', + host: 'remote-host', + onData: () => {}, + spawn: harness.spawn, + spawnSync: harness.spawnSync, + }) + + await proxy.start() + proxy.paste('a\nb') + await flushAsync() + + expect( + harness.spawnCalls.some( + (c) => c.mode === 'pipe' && c.args.some((a) => a.includes('delete-buffer')) + ) + ).toBe(true) + expect(harness.terminalWrites).toEqual([]) + + await proxy.dispose() + }) }) diff --git a/src/server/__tests__/terminalProxyBase.test.ts b/src/server/__tests__/terminalProxyBase.test.ts index 2c0aad81..b099da16 100644 --- a/src/server/__tests__/terminalProxyBase.test.ts +++ b/src/server/__tests__/terminalProxyBase.test.ts @@ -54,6 +54,7 @@ class TestProxy extends TerminalProxyBase { } write(): void {} + paste(): void {} resize(): void {} async dispose(): Promise {} getClientTty(): string | null { @@ -66,6 +67,18 @@ class TestProxy extends TerminalProxyBase { runTmuxCommand(args: string[]): string { return this.runTmux(args) } + + deliverPaste(target: string, data: string): void { + this.deliverPasteViaTmux(target, data) + } + + nextBufferName(): string { + return this.nextPasteBufferName() + } + + normalize(data: string): string { + return this.normalizePasteData(data) + } } class FlakyProxy extends TerminalProxyBase { @@ -83,6 +96,7 @@ class FlakyProxy extends TerminalProxyBase { } write(): void {} + paste(): void {} resize(): void {} async dispose(): Promise {} getClientTty(): string | null { @@ -133,4 +147,69 @@ describe('TerminalProxyBase', () => { 'tmux list-windows timed out after 1234ms' ) }) + + test('deliverPasteViaTmux stages via load-buffer stdin then paste-buffer -p', () => { + const calls: Array<{ args: string[]; options?: { stdin?: Buffer } }> = [] + const recordingSpawnSync: SpawnSyncFn = (args, options) => { + calls.push({ args, options: options as { stdin?: Buffer } }) + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + } + const proxy = new TestProxy(makeOptions(recordingSpawnSync)) + proxy.deliverPaste('agentboard:@1', 'line1\r\nline2\rline3\nline4') + + // 1) load-buffer reads the payload from stdin (no argv size limit), with + // CRLF/CR collapsed to LF so tmux's LF->CR yields one CR per line. + expect(calls[0]?.args).toEqual([ + 'tmux', + 'load-buffer', + '-b', + 'agentboard-paste-conn-1-1', + '-', + ]) + expect(calls[0]?.options?.stdin?.toString()).toBe('line1\nline2\nline3\nline4') + // 2) paste-buffer -p brackets only when the real pane requested bracketed + // paste; -d cleans up the buffer. + expect(calls[1]?.args).toEqual([ + 'tmux', + 'paste-buffer', + '-d', + '-p', + '-b', + 'agentboard-paste-conn-1-1', + '-t', + 'agentboard:@1', + ]) + }) + + test('nextPasteBufferName is unique per paste and sanitizes the connection id', () => { + const proxy = new TestProxy(makeOptions(okSpawnSync)) + expect(proxy.nextBufferName()).toBe('agentboard-paste-conn-1-1') + expect(proxy.nextBufferName()).toBe('agentboard-paste-conn-1-2') + + const dirty = new TestProxy({ ...makeOptions(okSpawnSync), connectionId: 'a/b c:1' }) + expect(dirty.nextBufferName()).toBe('agentboard-paste-abc1-1') + }) + + test('normalizePasteData collapses CRLF and lone CR to LF', () => { + const proxy = new TestProxy(makeOptions(okSpawnSync)) + expect(proxy.normalize('a\r\nb\rc\nd')).toBe('a\nb\nc\nd') + }) + + test('deliverPasteViaTmux does not fall back to a raw write when load-buffer fails', () => { + let wrote = false + class GuardProxy extends TestProxy { + write(): void { + wrote = true + } + } + const proxy = new GuardProxy(makeOptions(errorSpawnSync)) + // load-buffer throws (errorSpawnSync exit 1); a raw write() fallback would + // reintroduce the line-by-line auto-submit, so it must NOT happen. + expect(() => proxy.deliverPaste('t', 'a\nb')).not.toThrow() + expect(wrote).toBe(false) + }) }) diff --git a/src/server/index.ts b/src/server/index.ts index 147104d5..8fd5faf7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2325,6 +2325,9 @@ function handleMessage( case 'terminal-input': handleTerminalInputPersistent(ws, message.sessionId, message.data) return + case 'terminal-paste': + handleTerminalPastePersistent(ws, message.sessionId, message.data) + return case 'terminal-resize': handleTerminalResizePersistent( ws, @@ -4226,6 +4229,23 @@ function handleTerminalInputPersistent( } } +function handleTerminalPastePersistent( + ws: ServerWebSocket, + sessionId: string, + data: string +) { + if (sessionId !== ws.data.currentSessionId) { + return + } + + const session = registry.get(sessionId) + if (session?.remote && !config.remoteAllowAttach) return + + // Pasting stages text in the pane's input; it is NOT a submit, so unlike + // terminal-input we deliberately skip the Enter/"working" status heuristics. + ws.data.terminal?.paste(data) +} + function handleTerminalResizePersistent( ws: ServerWebSocket, sessionId: string, diff --git a/src/server/terminal/PipePaneTerminalProxy.ts b/src/server/terminal/PipePaneTerminalProxy.ts index ee39d3a6..b13f0ce3 100644 --- a/src/server/terminal/PipePaneTerminalProxy.ts +++ b/src/server/terminal/PipePaneTerminalProxy.ts @@ -135,6 +135,13 @@ class PipePaneTerminalProxy extends TerminalProxyBase { } } + paste(data: string): void { + if (!data || !this.currentTarget || this.state === TerminalState.DEAD) { + return + } + this.deliverPasteViaTmux(this.currentTarget, data) + } + resize(cols: number, rows: number): void { this.cols = cols this.rows = rows diff --git a/src/server/terminal/PtyTerminalProxy.ts b/src/server/terminal/PtyTerminalProxy.ts index afe2ebeb..bed7eb7a 100644 --- a/src/server/terminal/PtyTerminalProxy.ts +++ b/src/server/terminal/PtyTerminalProxy.ts @@ -53,6 +53,15 @@ class PtyTerminalProxy extends TerminalProxyBase { this.process?.terminal?.write(data) } + paste(data: string): void { + if (!data || this.state === TerminalState.DEAD) { + return + } + // Target the grouped session's active pane (the window this client switched + // to). tmux decides bracketing from that real pane's mode. + this.deliverPasteViaTmux(this.options.sessionName, data) + } + resize(cols: number, rows: number): void { this.cols = cols this.rows = rows diff --git a/src/server/terminal/SshTerminalProxy.ts b/src/server/terminal/SshTerminalProxy.ts index 2cde4c33..9670c49a 100644 --- a/src/server/terminal/SshTerminalProxy.ts +++ b/src/server/terminal/SshTerminalProxy.ts @@ -43,6 +43,43 @@ class SshTerminalProxy extends TerminalProxyBase { this.process?.terminal?.write(data) } + paste(data: string): void { + if (!data || this.state === TerminalState.DEAD) { + return + } + // Stage via load-buffer stdin (no argv size limit) into a unique buffer + // (fire-and-forget async delivery means two rapid pastes must not share a + // buffer), then paste-buffer -p. tmux gates bracketing on the real pane. + // Remote tmux runs over SSH (async) so this is fire-and-forget; the caller + // is a synchronous WebSocket handler that must not block on the round-trip. + // NOTE: because typed input (write) goes straight to the interactive pty + // while paste takes this side-channel, a paste immediately followed by + // Enter can, on a slow SSH link, submit before the paste lands — a + // remote-only ordering caveat inherent to the side-channel. + const bufferName = this.nextPasteBufferName() + const normalized = this.normalizePasteData(data) + const target = this.options.sessionName + void (async () => { + try { + await this.runTmuxAsync(['load-buffer', '-b', bufferName, '-'], normalized) + } catch (error) { + // Never fall back to write(): raw multi-line delivery auto-submits. + this.logPasteFailure(target, 'load-buffer', normalized.length, error) + return + } + try { + await this.runTmuxAsync(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', target]) + } catch (error) { + this.logPasteFailure(target, 'paste-buffer', normalized.length, error) + try { + await this.runTmuxAsync(['delete-buffer', '-b', bufferName]) + } catch { + // ignore best-effort cleanup failure + } + } + })() + } + resize(cols: number, rows: number): void { this.cols = cols this.rows = rows @@ -91,11 +128,12 @@ class SshTerminalProxy extends TerminalProxyBase { * Uses Bun.spawn instead of spawnSync so the event loop is not blocked * while waiting for SSH round-trips. */ - protected async runTmuxAsync(args: string[]): Promise { + protected async runTmuxAsync(args: string[], input?: string): Promise { const remoteCmd = `tmux ${args.map(a => shellQuote(a)).join(' ')}` const proc = this.spawn([...this.sshArgs, remoteCmd], { stdout: 'pipe', stderr: 'pipe', + ...(input !== undefined ? { stdin: Buffer.from(input) } : {}), }) const timeout = setTimeout(() => { diff --git a/src/server/terminal/TerminalProxyBase.ts b/src/server/terminal/TerminalProxyBase.ts index 6f849084..8fc129a7 100644 --- a/src/server/terminal/TerminalProxyBase.ts +++ b/src/server/terminal/TerminalProxyBase.ts @@ -25,6 +25,7 @@ abstract class TerminalProxyBase implements ITerminalProxy { protected startPromise: Promise | null = null protected outputSuppressed = false + private pasteSeq = 0 private switchQueue: Promise = Promise.resolve() private pendingTarget: string | null = null private pendingOnReady: (() => void) | undefined @@ -97,13 +98,14 @@ abstract class TerminalProxyBase implements ITerminalProxy { protected runTmux( args: string[], - options: { timeoutMs?: number } = {} + options: { timeoutMs?: number; stdin?: string } = {} ): string { const timeoutMs = options.timeoutMs ?? this.commandTimeoutMs const result = this.spawnSync(['tmux', ...args], { stdout: 'pipe', stderr: 'pipe', timeout: timeoutMs, + ...(options.stdin !== undefined ? { stdin: Buffer.from(options.stdin) } : {}), }) if (result.signalCode === 'SIGTERM' || result.exitCode === null) { @@ -122,6 +124,88 @@ abstract class TerminalProxyBase implements ITerminalProxy { return this.runTmux(args, { timeoutMs: this.mutationTimeoutMs }) } + /** + * A unique-per-paste tmux buffer name. The monotonic suffix matters for the + * SSH proxy, whose paste is fire-and-forget async: two rapid pastes would + * otherwise stage into (and delete) the same shared buffer and race. Sync + * proxies (pty/pipe-pane) can't interleave, but a unique name is harmless. + */ + protected nextPasteBufferName(): string { + const sanitized = + this.options.connectionId.replace(/[^A-Za-z0-9_-]/g, '') || 'connection' + this.pasteSeq += 1 + return `agentboard-paste-${sanitized}-${this.pasteSeq}` + } + + /** + * Normalize CRLF/CR to LF so tmux's paste-buffer LF->CR conversion yields + * exactly one carriage return per line (no doubled blank lines). + */ + protected normalizePasteData(data: string): string { + return data.replace(/\r\n?/g, '\n') + } + + /** + * Deliver `data` to `target` as a single bracketed paste (synchronous; used + * by the pty and pipe-pane proxies). + * + * The text is staged with `load-buffer -` (read from stdin) rather than + * `set-buffer -- `: passing the payload as one argv element hits the + * OS single-argument limit (~128 KB on Linux) for large pastes, and a failed + * spawn would fall back to the raw write path — which splits newlines into + * Enter keys and reintroduces the line-by-line auto-submit this fix exists to + * prevent. `paste-buffer -p` then replays it, wrapping the payload in + * bracketed-paste markers *only if the target pane's program requested + * bracketed paste mode* (ESC[?2004h). That gate is keyed on the real pane — + * not the browser xterm's view — so it works even when the browser attached + * after the program (e.g. Claude Code) enabled the mode and never re-emitted + * it (the no-flicker/fullscreen case). `-d` deletes the buffer after pasting. + * + * On failure we deliberately do NOT fall back to write(): a partial/raw + * delivery of multi-line text is exactly the auto-submit bug. + */ + protected deliverPasteViaTmux(target: string, data: string): void { + const bufferName = this.nextPasteBufferName() + const normalized = this.normalizePasteData(data) + try { + this.runTmux(['load-buffer', '-b', bufferName, '-'], { stdin: normalized }) + } catch (error) { + // The paste is dropped (never fall back to a raw write — that's the + // auto-submit bug); log so the drop isn't silent. + this.logPasteFailure(target, 'load-buffer', normalized.length, error) + return + } + try { + this.runTmux(['paste-buffer', '-d', '-p', '-b', bufferName, '-t', target]) + } catch (error) { + this.logPasteFailure(target, 'paste-buffer', normalized.length, error) + // paste failed after the buffer was staged; clean it up (best-effort). + try { + this.runTmux(['delete-buffer', '-b', bufferName]) + } catch { + // ignore + } + } + } + + protected logPasteFailure( + target: string, + stage: 'load-buffer' | 'paste-buffer', + bytes: number, + error: unknown + ): void { + // warn (not the debug-level logEvent): a dropped paste is user-visible. + logger.warn('terminal_paste_failed', { + connectionId: this.options.connectionId, + sessionName: this.options.sessionName, + target, + stage, + bytes, + mode: this.getMode(), + error: error instanceof Error ? error.message : String(error), + }) + } + protected runParsedTmux( args: string[], options: { timeoutMs?: number } = {} @@ -155,6 +239,7 @@ abstract class TerminalProxyBase implements ITerminalProxy { } abstract write(data: string): void + abstract paste(data: string): void abstract resize(cols: number, rows: number): void abstract dispose(): Promise abstract getClientTty(): string | null diff --git a/src/server/terminal/types.ts b/src/server/terminal/types.ts index f798bba3..0d296a5b 100644 --- a/src/server/terminal/types.ts +++ b/src/server/terminal/types.ts @@ -55,6 +55,7 @@ interface ITerminalProxy { switchTo(target: string, onReady?: () => void): Promise resolveEffectiveTarget(target: string): string write(data: string): void + paste(data: string): void resize(cols: number, rows: number): void dispose(): Promise isReady(): boolean diff --git a/src/shared/types.ts b/src/shared/types.ts index e911eb8d..2b06a056 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -147,6 +147,9 @@ export type ClientMessage = } | { type: 'terminal-detach'; sessionId: string } | { type: 'terminal-input'; sessionId: string; data: string } + // Pasted text delivered as a single bracketed paste (via tmux paste-buffer), + // so multi-line content isn't auto-submitted line-by-line by the pane's app. + | { type: 'terminal-paste'; sessionId: string; data: string } | { type: 'terminal-resize'; sessionId: string; cols: number; rows: number } | { type: 'session-create'; projectPath: string; name?: string; command?: string; host?: string } | { type: 'session-kill'; sessionId: string; source?: SessionKillSource } diff --git a/tests/e2e/fixtures/paste-repl.py b/tests/e2e/fixtures/paste-repl.py new file mode 100644 index 00000000..656b62e2 --- /dev/null +++ b/tests/e2e/fixtures/paste-repl.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Bracketed-paste REPL fixture for the paste e2e test. + +Models the terminal behavior of an agent CLI (e.g. Claude Code) that +agentboard attaches to AFTER startup: + - enables bracketed paste (ESC[?2004h) once at startup and never re-emits + it, so a browser xterm attaching later never observes the mode; + - a bracketed paste (ESC[200~ ... ESC[201~) is HELD in an edit buffer; + - a bare CR/LF outside a paste SUBMITS the buffer. + +State is printed to the pane as single-line markers (newlines rendered as +'|') so the test can assert via `tmux capture-pane`: + PASTE-REPL READY / HELD: / SUBMITTED: +""" +import os +import select +import sys +import termios +import tty + +START = b"\x1b[200~" +END = b"\x1b[201~" + +fd = sys.stdin.fileno() +old = termios.tcgetattr(fd) + + +def render(content: bytes) -> bytes: + return content.replace(b"\r", b"|").replace(b"\n", b"|") + + +def main() -> None: + tty.setraw(fd) + os.write(1, b"\x1b[?2004h") + os.write(1, b"PASTE-REPL READY\r\n") + buf = b"" + raw = b"" + in_paste = False + try: + while True: + readable, _, _ = select.select([fd], [], [], 0.05) + if not readable: + continue + chunk = os.read(fd, 65536) + if not chunk: + break + raw += chunk + while raw: + if in_paste: + idx = raw.find(END) + if idx == -1: + break # END not yet received; wait for more bytes + content = raw[:idx] + buf += content + raw = raw[idx + len(END):] + in_paste = False + os.write(1, b"HELD:" + render(content) + b"\r\n") + continue + idx = raw.find(START) + typed = raw if idx == -1 else raw[:idx] + for byte_value in typed: + byte = bytes([byte_value]) + if byte in (b"\r", b"\n"): + os.write(1, b"SUBMITTED:" + render(buf) + b"\r\n") + buf = b"" + elif byte == b"\x03": # Ctrl-C exits + raise KeyboardInterrupt + else: + buf += byte + if idx == -1: + raw = b"" + break + raw = raw[idx + len(START):] + in_paste = True + except KeyboardInterrupt: + pass + finally: + os.write(1, b"\x1b[?2004l") + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/paste.spec.ts b/tests/e2e/paste.spec.ts new file mode 100644 index 00000000..f5b03d10 --- /dev/null +++ b/tests/e2e/paste.spec.ts @@ -0,0 +1,104 @@ +// E2E: pasted multi-line text must be delivered as ONE bracketed paste (held +// for editing), never auto-submitted line-by-line. Regression test for the +// no-flicker/fullscreen auto-send bug: the REPL fixture enables bracketed +// paste BEFORE the browser attaches, so the browser xterm never sees ?2004h +// and the server-side tmux paste-buffer path is the only thing bracketing. +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { test, expect } from '@playwright/test' + +const WINDOW_NAME = 'paste-repl' +const REPL_PATH = fileURLToPath(new URL('./fixtures/paste-repl.py', import.meta.url)) + +function tmux(args: string[]): { status: number | null; stdout: string } { + const result = spawnSync('tmux', args, { encoding: 'utf-8' }) + return { status: result.status, stdout: result.stdout ?? '' } +} + +function capturePane(target: string): string { + return tmux(['capture-pane', '-t', target, '-p']).stdout +} + +async function waitForPaneText( + target: string, + needle: string, + timeoutMs = 10000 +): Promise { + const deadline = Date.now() + timeoutMs + let content = '' + while (Date.now() < deadline) { + content = capturePane(target) + if (content.includes(needle)) return content + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error( + `Timed out waiting for ${JSON.stringify(needle)} in pane ${target}. Last content:\n${content}` + ) +} + +test('multi-line text paste is held for editing, not auto-submitted', async ({ page }) => { + const session = process.env.E2E_TMUX_SESSION + test.skip(!session, 'E2E_TMUX_SESSION not set') + const target = `${session}:${WINDOW_NAME}` + + // Start the bracketed-paste REPL in a fresh window of the e2e session. + // It enables ?2004h now — before the browser attaches — which is the exact + // scenario where client-side bracketing (xterm terminal.paste) fails. + const created = tmux([ + 'new-window', + '-t', + session!, + '-n', + WINDOW_NAME, + `python3 ${REPL_PATH}`, + ]) + expect(created.status).toBe(0) + + try { + await waitForPaneText(target, 'PASTE-REPL READY') + + // Attach to the REPL window through the real UI. + await page.goto('/') + const card = page.getByTestId('session-card').filter({ hasText: WINDOW_NAME }).first() + await expect(card).toBeVisible({ timeout: 20000 }) + await card.click() + await expect(page.locator('.xterm')).toBeVisible() + await page.waitForTimeout(2000) // let the terminal attach settle + + // Synthesize a real paste: modifier+V keydown on the xterm helper + // textarea, then a ClipboardEvent carrying multi-line text/plain (the + // capture-phase listener reads clipboardData synchronously). + await page.evaluate(() => { + const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement + const isMac = /Mac/.test(navigator.platform) + textarea.focus() + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'v', + code: 'KeyV', + metaKey: isMac, + ctrlKey: !isMac, + bubbles: true, + cancelable: true, + }) + ) + const dt = new DataTransfer() + dt.setData('text/plain', 'e2e_alpha\ne2e_beta') + textarea.dispatchEvent( + new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }) + ) + }) + + // The paste must arrive as ONE held bracketed paste (newlines rendered + // as '|' by the fixture) with nothing submitted. + const afterPaste = await waitForPaneText(target, 'HELD:e2e_alpha|e2e_beta') + expect(afterPaste).not.toContain('SUBMITTED:') + + // An explicit Enter still submits the held buffer. + await page.locator('.xterm-helper-textarea').focus() + await page.keyboard.press('Enter') + await waitForPaneText(target, 'SUBMITTED:e2e_alpha|e2e_beta') + } finally { + tmux(['kill-window', '-t', target]) + } +})