From 3478419615f1170c59ecd5682d545c36f1a224ed Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 09:58:12 -0400 Subject: [PATCH 01/11] feat(shared): add altScreen/appMouse to tmux-copy-mode-status for fullscreen detection --- src/shared/types.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/shared/types.ts b/src/shared/types.ts index 5095de3f..669068c6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -108,7 +108,18 @@ export type ServerMessage = retryable: boolean } | { type: 'terminal-ready'; sessionId: string } - | { type: 'tmux-copy-mode-status'; sessionId: string; inCopyMode: boolean } + | { + type: 'tmux-copy-mode-status' + sessionId: string + inCopyMode: boolean + // Fullscreen-app (Claude /tui no-flicker) detection, per-pane. + // altScreen: pane is showing the alternate screen buffer (#{alternate_on}). + // appMouse: the in-pane app has requested mouse tracking (#{mouse_any_flag}). + // When appMouse is true the app owns the mouse, so the client must NOT + // hijack wheel/clicks into tmux copy-mode. Optional for back-compat. + altScreen?: boolean + appMouse?: boolean + } | { type: 'server-config'; remoteAllowControl: boolean; remoteAllowAttach: boolean; hostLabel: string; preferWindowName: boolean; clientLogLevel?: string } | { type: 'pong'; seq?: number } | { type: 'error'; message: string } From bd9eaa1196cb7e55f87417269926dd6992891ad2 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 10:23:36 -0400 Subject: [PATCH 02/11] feat: support Claude Code fullscreen (no-flicker) mouse mode Detect when a pane's app owns the mouse (alternate screen + mouse tracking) and stop hijacking wheel/clicks into tmux copy-mode, so Claude /tui fullscreen click-to-expand / click-to-position-cursor / wheel work in the browser terminal. Per-window: classic and fullscreen agents can coexist. Covers desktop + iOS touch. - server: report #{alternate_on}/#{mouse_any_flag} on tmux-copy-mode-status; launch agentboard-created windows with CLAUDE_CODE_NO_FLICKER=1 (tmux -e), gated by config.claudeNoFlicker (AGENTBOARD_CLAUDE_NO_FLICKER=0 to disable). - client: appMouseRef gates setTmuxCopyMode + wheel + iOS touch-scroll; re-assert mouse tracking on false->true; 750ms poll keeps state fresh; iOS tap->SGR click. --- src/client/__tests__/useTerminal.test.tsx | 573 +++++++++++++++++- src/client/components/Terminal.tsx | 57 +- src/client/hooks/useTerminal.ts | 45 +- src/server/SessionManager.ts | 7 + .../__tests__/isolated/indexHandlers.test.ts | 20 +- src/server/__tests__/sessionManager.test.ts | 34 +- src/server/config.ts | 7 + src/server/index.ts | 27 +- 8 files changed, 750 insertions(+), 20 deletions(-) diff --git a/src/client/__tests__/useTerminal.test.tsx b/src/client/__tests__/useTerminal.test.tsx index d48cb5f5..91f3333c 100644 --- a/src/client/__tests__/useTerminal.test.tsx +++ b/src/client/__tests__/useTerminal.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, jest, test, mock } from 'bun:test' import TestRenderer, { act } from 'react-test-renderer' -import type { AgentType, ServerMessage } from '@shared/types' +import type { AgentType, ServerMessage, Session } from '@shared/types' import type { ITheme } from '@xterm/xterm' import type { ConnectionStatus } from '../stores/sessionStore' @@ -161,6 +161,7 @@ mock.module('@xterm/addon-progress', () => ({ ProgressAddon: ProgressAddonMock } mock.module('@xterm/addon-web-links', () => ({ WebLinksAddon: class {} })) const { forceTextPresentation, sanitizeLink, useTerminal, invalidateSnapshotCache, clearSnapshotCache } = await import('../hooks/useTerminal') +const { default: TerminalComponent } = await import('../components/Terminal') // Tracks a registered event listener with its capture flag interface ListenerEntry { @@ -192,7 +193,12 @@ function createContainerMock() { // Track display style changes for iOS compositor repaint tests const displayLog: string[] = [] - const containerStyle = { cssText: '' } as Record + const containerStyle = { + cssText: '', + setProperty(name: string, value: string) { + ;(this as Record)[name] = value + }, + } as CSSStyleDeclaration & Record Object.defineProperty(containerStyle, 'display', { get() { return displayLog.length ? displayLog[displayLog.length - 1] : '' }, set(v: string) { displayLog.push(v) }, @@ -200,10 +206,54 @@ function createContainerMock() { configurable: true, }) + const screen = { + getBoundingClientRect: () => ({ + left: 0, + top: 0, + right: 800, + bottom: 240, + width: 800, + height: 240, + x: 0, + y: 0, + toJSON: () => {}, + }), + } + const xtermRoot = { + getBoundingClientRect: () => ({ + left: 0, + top: 0, + right: 800, + bottom: 240, + width: 800, + height: 240, + x: 0, + y: 0, + toJSON: () => {}, + }), + querySelector: (selector: string) => { + if (selector === '.xterm-screen') return screen + return null + }, + } + const container = { innerHTML: 'existing', style: containerStyle, get offsetHeight() { return 100 }, + closest: () => null, + contains: () => false, + getBoundingClientRect: () => ({ + left: 0, + top: 0, + right: 800, + bottom: 240, + width: 800, + height: 240, + x: 0, + y: 0, + toJSON: () => {}, + }), addEventListener: ( event: string, handler: EventListener, @@ -235,8 +285,12 @@ function createContainerMock() { listeners.delete(event) } }, - querySelector: (selector: string) => - selector === '.xterm-helper-textarea' ? textarea : null, + querySelector: (selector: string) => { + if (selector === '.xterm-helper-textarea') return textarea + if (selector === '.xterm') return xtermRoot + if (selector === '.xterm-screen') return screen + return null + }, } as unknown as HTMLDivElement /** @@ -288,6 +342,17 @@ function TerminalHarness(props: { return
} +const terminalSession: Session = { + id: 'session-1', + name: 'alpha', + tmuxWindow: 'agentboard:@1', + projectPath: '/tmp/alpha', + status: 'working', + lastActivity: new Date().toISOString(), + createdAt: new Date().toISOString(), + source: 'managed', +} + beforeEach(() => { TerminalMock.instances = [] FitAddonMock.instances = [] @@ -299,6 +364,24 @@ beforeEach(() => { return 1 as unknown as ReturnType }) as typeof setTimeout, clearTimeout: (() => {}) as typeof clearTimeout, + setInterval: (() => 1 as unknown as ReturnType) as unknown as typeof setInterval, + clearInterval: (() => {}) as typeof clearInterval, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + }), + getSelection: () => ({ isCollapsed: true }), + getComputedStyle: () => ({ + getPropertyValue: (name: string) => name === '--xterm-cell-height' ? '10px' : '', + }), + requestAnimationFrame: ((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) as typeof requestAnimationFrame, + cancelAnimationFrame: (() => {}) as typeof cancelAnimationFrame, devicePixelRatio: 1, addEventListener: () => {}, removeEventListener: () => {}, @@ -1358,6 +1441,488 @@ describe('useTerminal', () => { }) }) + test('appMouse=true scroll-up forwards wheel without disabling mouse tracking or entering copy-mode', async () => { + 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 } = 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') + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: true, + }) + }) + + expect(terminal.writes).toContain('\x1b[?1000h\x1b[?1002h\x1b[?1006h') + terminal.writes.length = 0 + sendCalls.length = 0 + + act(() => { + terminal.emitWheel({ deltaY: -30, target: container } as unknown as WheelEvent) + }) + + expect(sendCalls).toContainEqual({ + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[<64;40;12M', + }) + expect(sendCalls.some((call) => call.type === 'tmux-check-copy-mode')).toBe(false) + expect(sendCalls.some((call) => call.type === 'tmux-cancel-copy-mode')).toBe(false) + expect(terminal.writes.some((write) => write.includes('\x1b[?1000l'))).toBe(false) + + act(() => { + terminal.emitData('x') + }) + expect(sendCalls).toContainEqual({ + type: 'terminal-input', + sessionId: 'session-1', + data: 'x', + }) + expect(sendCalls.some((call) => call.type === 'tmux-cancel-copy-mode')).toBe(false) + + act(() => { + renderer.unmount() + }) + }) + + test('appMouse=true after a scroll re-enables tracking and forwards mouse SGR onData', async () => { + 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 } = 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') + + act(() => { + terminal.emitWheel({ deltaY: -30, target: container } as unknown as WheelEvent) + }) + expect(terminal.writes).toContain('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l') + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: true, + }) + }) + expect(terminal.writes).toContain('\x1b[?1000h\x1b[?1002h\x1b[?1006h') + + sendCalls.length = 0 + act(() => { + terminal.emitData('\x1b[<0;10;5M') + }) + + expect(sendCalls).toEqual([{ + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[<0;10;5M', + }]) + + act(() => { + renderer.unmount() + }) + }) + + test('appMouse false-to-true status reasserts xterm mouse tracking', async () => { + globalAny.navigator = { + userAgent: 'Chrome', + platform: 'MacIntel', + maxTouchPoints: 0, + clipboard: { writeText: () => Promise.resolve(), readText: () => Promise.resolve('') }, + } as unknown as Navigator + + const listeners: Array<(message: ServerMessage) => void> = [] + const { container } = createContainerMock() + + let renderer!: TestRenderer.ReactTestRenderer + await act(async () => { + renderer = TestRenderer.create( + {}} + 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') + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: false, + }) + }) + expect(terminal.writes).not.toContain('\x1b[?1000h\x1b[?1002h\x1b[?1006h') + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: true, + }) + }) + expect(terminal.writes).toContain('\x1b[?1000h\x1b[?1002h\x1b[?1006h') + + act(() => { + renderer.unmount() + }) + }) + + test('appMouse=false keeps classic scroll-up copy-mode behavior', async () => { + 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 } = 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') + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: false, + }) + }) + terminal.writes.length = 0 + sendCalls.length = 0 + + act(() => { + terminal.emitWheel({ deltaY: -30, target: container } as unknown as WheelEvent) + }) + + expect(sendCalls).toContainEqual({ + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[<64;40;12M', + }) + expect(sendCalls).toContainEqual({ + type: 'tmux-check-copy-mode', + sessionId: 'session-1', + }) + expect(terminal.writes).toContain('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l') + + act(() => { + renderer.unmount() + }) + }) + + test('polls copy-mode status for the currently attached session', async () => { + const intervals = new Map void>() + let nextIntervalId = 1 + const originalSetInterval = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + globalThis.setInterval = ((callback: () => void) => { + const id = nextIntervalId++ + intervals.set(id, callback) + return id as unknown as ReturnType + }) as typeof setInterval + globalThis.clearInterval = ((id: ReturnType) => { + intervals.delete(id as unknown as number) + }) as typeof clearInterval + globalAny.navigator = { + userAgent: 'Chrome', + platform: 'MacIntel', + maxTouchPoints: 0, + clipboard: { writeText: () => Promise.resolve(), readText: () => Promise.resolve('') }, + } as unknown as Navigator + + const sendCalls: Array> = [] + const { container } = createContainerMock() + + let renderer!: TestRenderer.ReactTestRenderer + await act(async () => { + renderer = TestRenderer.create( + sendCalls.push(message)} + subscribe={() => () => {}} + theme={{ background: '#000' }} + fontSize={12} + />, + { createNodeMock: () => container }, + ) + await Promise.resolve() + }) + + sendCalls.length = 0 + act(() => { + intervals.get(1)?.() + }) + + expect(sendCalls).toEqual([{ + type: 'tmux-check-copy-mode', + sessionId: 'session-1', + }]) + + act(() => { + renderer.unmount() + }) + expect(intervals.size).toBe(0) + globalThis.setInterval = originalSetInterval + globalThis.clearInterval = originalClearInterval + }) + + test('iOS appMouse=true tap emits SGR mouse press and release at tapped cell', async () => { + globalAny.window = { + ...globalAny.window, + matchMedia: () => ({ + matches: true, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + }), + } as unknown as Window & typeof globalThis + globalAny.navigator = { + userAgent: 'iPhone', + platform: 'iPhone', + maxTouchPoints: 5, + clipboard: { writeText: () => Promise.resolve(), readText: () => Promise.resolve('') }, + vibrate: () => true, + } as unknown as Navigator + + const sendCalls: Array> = [] + const listeners: Array<(message: ServerMessage) => void> = [] + const { container, dispatchEvent } = createContainerMock() + let preventDefaultCalls = 0 + let stopPropagationCalls = 0 + + let renderer!: TestRenderer.ReactTestRenderer + await act(async () => { + renderer = TestRenderer.create( + sendCalls.push(message)} + subscribe={(listener) => { + listeners.push(listener) + return () => {} + }} + onClose={() => {}} + onSelectSession={() => {}} + onNewSession={() => {}} + onKillSession={() => {}} + onRenameSession={() => {}} + onResumeSession={() => {}} + onOpenSettings={() => {}} + />, + { createNodeMock: () => container }, + ) + await Promise.resolve() + }) + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: true, + }) + }) + sendCalls.length = 0 + + const touch = { clientX: 25, clientY: 25 } as Touch + act(() => { + dispatchEvent('touchstart', { + touches: [touch], + }) + dispatchEvent('touchend', { + changedTouches: [touch], + preventDefault: () => { preventDefaultCalls += 1 }, + stopPropagation: () => { stopPropagationCalls += 1 }, + }) + }) + + expect(sendCalls).toEqual([{ + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[<0;3;3M\x1b[<0;3;3m', + }]) + expect(preventDefaultCalls).toBe(1) + expect(stopPropagationCalls).toBe(1) + + act(() => { + renderer.unmount() + }) + }) + + test('iOS appMouse=false tap keeps existing focus-only behavior', async () => { + globalAny.window = { + ...globalAny.window, + matchMedia: () => ({ + matches: true, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + }), + } as unknown as Window & typeof globalThis + globalAny.navigator = { + userAgent: 'iPhone', + platform: 'iPhone', + maxTouchPoints: 5, + clipboard: { writeText: () => Promise.resolve(), readText: () => Promise.resolve('') }, + vibrate: () => true, + } 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 () => {} + }} + onClose={() => {}} + onSelectSession={() => {}} + onNewSession={() => {}} + onKillSession={() => {}} + onRenameSession={() => {}} + onResumeSession={() => {}} + onOpenSettings={() => {}} + />, + { createNodeMock: () => container }, + ) + await Promise.resolve() + }) + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: false, + }) + }) + sendCalls.length = 0 + + const touch = { clientX: 25, clientY: 25 } as Touch + act(() => { + dispatchEvent('touchstart', { + touches: [touch], + }) + dispatchEvent('touchend', { + changedTouches: [touch], + preventDefault: () => {}, + stopPropagation: () => {}, + }) + }) + + expect(sendCalls.some((call) => call.type === 'terminal-input')).toBe(false) + + act(() => { + renderer.unmount() + }) + }) + test('falls back to clipboard API when paste event never fires', async () => { jest.useFakeTimers() const originalFetch = globalThis.fetch diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 7111cf3c..0eef156f 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -156,7 +156,14 @@ export default function Terminal({ !session.remote && !!session.agentSessionId?.trim() - const { containerRef, terminalRef, inTmuxCopyModeRef, setTmuxCopyMode, isSwitching } = useTerminal({ + const { + containerRef, + terminalRef, + inTmuxCopyModeRef, + appMouseRef, + setTmuxCopyMode, + isSwitching, + } = useTerminal({ sessionId: session?.id ?? null, tmuxTarget: session?.tmuxWindow ?? null, agentType: session?.agentType, @@ -560,6 +567,31 @@ export default function Terminal({ const getTextarea = () => container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null + const getTouchCell = (touch: Touch): { col: number; row: number } | null => { + const terminal = terminalRef.current + if (!terminal) return null + + const root = container.querySelector('.xterm') as HTMLElement | null + const screen = root?.querySelector('.xterm-screen') as HTMLElement | null + const screenRect = screen?.getBoundingClientRect() + if (!screenRect || screenRect.width <= 0 || screenRect.height <= 0) return null + + const cellW = screenRect.width / terminal.cols + const cellH = screenRect.height / terminal.rows + if (cellW <= 0 || cellH <= 0) return null + + const col = Math.min( + terminal.cols, + Math.max(1, Math.floor((touch.clientX - screenRect.left) / cellW) + 1) + ) + const row = Math.min( + terminal.rows, + Math.max(1, Math.floor((touch.clientY - screenRect.top) / cellH) + 1) + ) + + return { col, row } + } + const disableTextareaIfIdle = () => { const current = getTextarea() if (!current) return @@ -625,13 +657,26 @@ export default function Terminal({ } // Only enter copy-mode when scrolling UP (into history), not when scrolling down - if (scrolledUp) { + if (scrolledUp && !appMouseRef.current) { setTmuxCopyMode(true) } return scrolledUp } + const sendTapClickToApp = (touch: Touch): boolean => { + const currentSessionId = sessionIdRef.current + const cell = getTouchCell(touch) + if (!currentSessionId || !cell) return false + + sendMessageRef.current({ + type: 'terminal-input', + sessionId: currentSessionId, + data: `\x1b[<0;${cell.col};${cell.row}M\x1b[<0;${cell.col};${cell.row}m`, + }) + return true + } + const resetTouchState = () => { lastTouchY = null velocity = 0 @@ -763,6 +808,14 @@ export default function Terminal({ if (!hasMoved) { if (isiOS && touchDuration >= LONG_PRESS_MS) return + if (appMouseRef.current) { + const touch = e.changedTouches[0] + if (touch && sendTapClickToApp(touch)) { + e.preventDefault() + e.stopPropagation() + return + } + } // If in copy-mode, prevent tap from reaching xterm.js (which would send click to tmux and exit copy-mode) if (inTmuxCopyModeRef.current) { e.preventDefault() diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index 0f0b6dc6..b4f8319a 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -70,6 +70,8 @@ const getIsMac = () => typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod /** Empty bracket paste sequence — signals a paste event without text content. */ const BRACKET_PASTE_EMPTY = '\x1b[200~\x1b[201~' const CTRL_V = '\x16' +const ENABLE_MOUSE_TRACKING = '\x1b[?1000h\x1b[?1002h\x1b[?1006h' +const DISABLE_MOUSE_TRACKING = '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l' function getImagePasteSignal(agentType: AgentType | undefined): string { return agentType === 'codex' ? CTRL_V : BRACKET_PASTE_EMPTY @@ -213,7 +215,10 @@ export function useTerminal({ // Wheel event handling for tmux scrollback const wheelAccumRef = useRef(0) const inTmuxCopyModeRef = useRef(false) + const appMouseRef = useRef(false) + const altScreenRef = useRef(false) const copyModeCheckTimer = useRef(null) + const copyModePollIntervalRef = useRef | null>(null) // Track the currently attached session to prevent race conditions const attachedSessionRef = useRef(null) @@ -324,6 +329,7 @@ export function useTerminal({ }, []) const setTmuxCopyMode = useCallback((nextValue: boolean) => { + if (nextValue && appMouseRef.current) return if (inTmuxCopyModeRef.current === nextValue) return inTmuxCopyModeRef.current = nextValue @@ -333,7 +339,7 @@ export function useTerminal({ const terminal = terminalRef.current if (terminal && nextValue) { // Disable all mouse tracking modes (1000=X10, 1002=button-event, 1003=any-event, 1006=SGR) - terminal.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l') + terminal.write(DISABLE_MOUSE_TRACKING) } checkScrollPosition() @@ -818,11 +824,11 @@ export function useTerminal({ } // Optimistically show button when scrolling up - if (scrolledUp) { + if (scrolledUp && !appMouseRef.current) { setTmuxCopyMode(true) } // Request actual copy-mode status from tmux (debounced) - only if we sent scroll - if (didScroll) { + if (didScroll && !appMouseRef.current) { requestCopyModeCheck() } return false // We handled it, prevent xterm local scroll @@ -1178,6 +1184,27 @@ export function useTerminal({ } }, [sessionId, tmuxTarget, allowAttach, connectionStatus, connectionEpoch, checkScrollPosition]) + useEffect(() => { + if (copyModePollIntervalRef.current !== null) { + globalThis.clearInterval(copyModePollIntervalRef.current) + copyModePollIntervalRef.current = null + } + + if (!sessionId || !allowAttach || connectionStatus !== 'connected') return + + copyModePollIntervalRef.current = globalThis.setInterval(() => { + if (attachedSessionRef.current !== sessionId) return + sendMessageRef.current({ type: 'tmux-check-copy-mode', sessionId }) + }, 750) + + return () => { + if (copyModePollIntervalRef.current !== null) { + globalThis.clearInterval(copyModePollIntervalRef.current) + copyModePollIntervalRef.current = null + } + } + }, [sessionId, allowAttach, connectionStatus]) + // Subscribe to terminal output with idle-based buffering // Batches chunks until the stream goes idle to avoid splitting escape sequences useEffect(() => { @@ -1342,7 +1369,16 @@ export function useTerminal({ attachedSession && message.sessionId === attachedSession ) { - setTmuxCopyMode(message.inCopyMode) + const nextAppMouse = message.appMouse === true + const wasAppMouse = appMouseRef.current + appMouseRef.current = nextAppMouse + altScreenRef.current = message.altScreen === true + + if (!wasAppMouse && nextAppMouse) { + terminalRef.current?.write(ENABLE_MOUSE_TRACKING) + } + + setTmuxCopyMode(nextAppMouse ? false : message.inCopyMode) } }) @@ -1489,6 +1525,7 @@ export function useTerminal({ serializeAddonRef, progressAddonRef, inTmuxCopyModeRef, + appMouseRef, setTmuxCopyMode, isSwitching, } diff --git a/src/server/SessionManager.ts b/src/server/SessionManager.ts index 17bb32e5..a14ec04b 100644 --- a/src/server/SessionManager.ts +++ b/src/server/SessionManager.ts @@ -392,10 +392,16 @@ export class SessionManager { const finalCommand = command?.trim() || 'claude' const finalName = this.findAvailableName(baseName, existingWindowNames, nameExists) + // Start the agent with Claude Code fullscreen ("no-flicker") rendering so its + // mouse features work in the browser terminal. tmux `-e` sets the pane env + // (tmux >= 3.0); the launched command inherits it. Harmless for non-Claude commands. + const noFlickerEnv = config.claudeNoFlicker ? ['-e', 'CLAUDE_CODE_NO_FLICKER=1'] : [] + if (!sessionExisted) { // Create session + window in one step to avoid orphan shell window this.runTmux([ 'new-session', '-d', + ...noFlickerEnv, '-s', this.sessionName, '-n', finalName, '-c', resolvedPath, @@ -406,6 +412,7 @@ export class SessionManager { const nextIndex = this.findNextAvailableWindowIndex() this.runTmux([ 'new-window', + ...noFlickerEnv, '-t', `${this.sessionName}:${nextIndex}`, '-n', finalName, '-c', resolvedPath, diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index c9b8ecd3..c8cbdbdf 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -2459,9 +2459,11 @@ describe('server message handlers', () => { } if (tmuxArgs[0] === 'display-message') { copyModeTarget = tmuxArgs[3] ?? '' + // Format: pane_in_mode,alternate_on,mouse_any_flag — here: a fullscreen app + // (not in copy-mode, alt screen on, app owns the mouse). return { exitCode: 0, - stdout: Buffer.from('0\n'), + stdout: Buffer.from('0,1,1\n'), stderr: Buffer.from(''), } as ReturnType } @@ -2511,6 +2513,17 @@ describe('server message handlers', () => { JSON.stringify({ type: 'tmux-check-copy-mode', sessionId: baseSession.id }) ) expect(copyModeTarget).toBe(groupedTarget) + + // The fullscreen flags from tmux propagate to the client (appMouse drives the + // client's decision to stop hijacking mouse events into copy-mode). + const fullscreenStatus = sent.find( + (message) => message.type === 'tmux-copy-mode-status' + ) + expect(fullscreenStatus).toMatchObject({ + inCopyMode: false, + altScreen: true, + appMouse: true, + }) }) test('terminal attach continues when local history capture times out', async () => { @@ -2625,9 +2638,10 @@ describe('server message handlers', () => { } if (tmuxArgs[0] === 'display-message') { displayTarget = tmuxArgs[3] ?? '' + // Format: pane_in_mode,alternate_on,mouse_any_flag — classic copy-mode here. return { exitCode: 0, - stdout: Buffer.from('1\n'), + stdout: Buffer.from('1,0,0\n'), stderr: Buffer.from(''), } as ReturnType } @@ -2662,6 +2676,8 @@ describe('server message handlers', () => { type: 'tmux-copy-mode-status', sessionId: baseSession.id, inCopyMode: true, + altScreen: false, + appMouse: false, }) }) diff --git a/src/server/__tests__/sessionManager.test.ts b/src/server/__tests__/sessionManager.test.ts index 6577cd57..c2dd6e24 100644 --- a/src/server/__tests__/sessionManager.test.ts +++ b/src/server/__tests__/sessionManager.test.ts @@ -85,7 +85,10 @@ function createTmuxRunner(sessions: SessionState[], baseIndex = 0) { const runTmux = (args: string[]) => { calls.push(args) - const normalizedArgs = normalizeParsedTmuxArgs(args) + // Strip `-e VAR=VAL` pairs (tmux pane-env injection) so the positional parsers + // below stay stable; `calls` still records the original args for assertions. + const rawArgs = normalizeParsedTmuxArgs(args) + const normalizedArgs = rawArgs.filter((arg, i) => arg !== '-e' && rawArgs[i - 1] !== '-e') const command = normalizedArgs[0] if (command === 'has-session') { @@ -1515,6 +1518,35 @@ describe('SessionManager', () => { fs.rmSync(tempDir, { recursive: true, force: true }) }) + test('createWindow injects CLAUDE_CODE_NO_FLICKER=1 via tmux -e (fullscreen default)', () => { + const sessionName = 'agentboard-noflicker' + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentboard-')) + const runner = createTmuxRunner([], 0) + + const manager = new SessionManager(sessionName, { + runTmux: runner.runTmux, + capturePaneContent: () => makePaneCapture(''), + now: () => 1700000000000, + }) + + // Default config (no AGENTBOARD_CLAUDE_NO_FLICKER override) -> injection on. + const created = manager.createWindow(tempDir, 'fs-window', 'claude') + + const newSessionCall = runner.calls.find( + (call) => getTmuxCommand(call) === 'new-session' + ) + if (!newSessionCall) throw new Error('expected a new-session call') + // The `-e VAR=VAL` pair enables Claude fullscreen rendering in the new pane. + const eIdx = newSessionCall.indexOf('-e') + expect(eIdx).toBeGreaterThanOrEqual(0) + expect(newSessionCall[eIdx + 1]).toBe('CLAUDE_CODE_NO_FLICKER=1') + // The window command is still parsed correctly despite the injected env flag. + expect(newSessionCall).toContain('claude') + expect(created.command).toBe('claude') + + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + test('createWindow uses new-window when session already exists', () => { const sessionName = 'agentboard-existing' const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentboard-')) diff --git a/src/server/config.ts b/src/server/config.ts index cd7d6d5a..7bf141ce 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -162,6 +162,13 @@ export const config = { // process under tmux `automatic-rename on`. Enable when each window has a stable, // user-chosen name (e.g. one window per project in a shared session). preferWindowName: process.env.AGENTBOARD_PREFER_WINDOW_NAME === 'true', + // Launch agentboard-created windows with Claude Code fullscreen ("no-flicker") + // rendering enabled, so its mouse features (click-to-expand, click-to-position + // cursor, wheel scroll) work in the browser terminal. Set + // AGENTBOARD_CLAUDE_NO_FLICKER=0 (or =false) to disable. + claudeNoFlicker: + process.env.AGENTBOARD_CLAUDE_NO_FLICKER !== '0' && + process.env.AGENTBOARD_CLAUDE_NO_FLICKER !== 'false', terminalMode, terminalMonitorTargets: process.env.TERMINAL_MONITOR_TARGETS !== 'false', // Allow killing external (discovered) sessions from UI diff --git a/src/server/index.ts b/src/server/index.ts index ffc626c4..749a0fad 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -2143,10 +2143,15 @@ async function handleRemoteCreate( : windowCommand let createResult: { exitCode: number; stdout: string; stderr: string } + // Launch with Claude Code fullscreen ("no-flicker") rendering enabled so its + // mouse features work in the browser terminal. The login shell inherits the env. + const noFlickerEnv = config.claudeNoFlicker ? ['-e', 'CLAUDE_CODE_NO_FLICKER=1'] : [] + if (sessionExists) { // Session exists — add a new window to it createResult = await runRemoteTmux(host, [ 'new-window', '-P', + ...noFlickerEnv, '-F', buildTmuxFormat(['#{window_index}', '#{window_id}']), '-t', tmuxSession, '-n', windowName, '-c', trimmedPath, wrappedCommand, ]) @@ -2155,6 +2160,7 @@ async function handleRemoteCreate( // (avoids an orphan window 0 from a separate new-session call) createResult = await runRemoteTmux(host, [ 'new-session', '-d', '-P', + ...noFlickerEnv, '-F', buildTmuxFormat(['#{window_index}', '#{window_id}']), '-s', tmuxSession, '-n', windowName, '-c', trimmedPath, wrappedCommand, ]) @@ -2258,23 +2264,30 @@ async function handleCheckCopyMode(sessionId: string, ws: ServerWebSocket tmux copy-mode active + // alternate_on -> pane on the alternate screen buffer + // mouse_any_flag-> the in-pane app requested mouse tracking (owns the mouse) + const fmt = '#{pane_in_mode},#{alternate_on},#{mouse_any_flag}' let output: string if (session.remote && session.host) { - const result = await runRemoteTmux(session.host, ['display-message', '-p', '-t', target, '#{pane_in_mode}']) + const result = await runRemoteTmux(session.host, ['display-message', '-p', '-t', target, fmt]) output = result.stdout?.trim() ?? '' } else { const result = Bun.spawnSync( - ['tmux', ...withTmuxUtf8Flag(['display-message', '-p', '-t', target, '#{pane_in_mode}'])], + ['tmux', ...withTmuxUtf8Flag(['display-message', '-p', '-t', target, fmt])], { stdout: 'pipe', stderr: 'pipe', timeout: 5000 } ) output = result.stdout?.toString().trim() ?? '' } - const inCopyMode = output === '1' - send(ws, { type: 'tmux-copy-mode-status', sessionId, inCopyMode }) + const [inCopyModeField, altScreenField, appMouseField] = output.split(',') + const inCopyMode = inCopyModeField === '1' + const altScreen = altScreenField === '1' + const appMouse = appMouseField === '1' + send(ws, { type: 'tmux-copy-mode-status', sessionId, inCopyMode, altScreen, appMouse }) } catch { - // On error, assume not in copy mode - send(ws, { type: 'tmux-copy-mode-status', sessionId, inCopyMode: false }) + // On error, assume not in copy mode and no fullscreen app + send(ws, { type: 'tmux-copy-mode-status', sessionId, inCopyMode: false, altScreen: false, appMouse: false }) } } From c6426a70e410b5f6538a0e08405aa8c8ed0bad19 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 12:50:40 -0400 Subject: [PATCH 03/11] fix: harden fullscreen clipboard and touch selection --- src/client/__tests__/terminal.test.tsx | 106 ++++++++- src/client/__tests__/useTerminal.test.tsx | 110 ++++++++++ src/client/components/Terminal.tsx | 137 +++++++++++- src/client/hooks/useTerminal.ts | 99 ++++++++- .../__tests__/isolated/indexHandlers.test.ts | 115 ++++++++++ .../__tests__/isolated/terminalProxy.test.ts | 29 +++ src/server/__tests__/testEnvironment.ts | 5 + src/server/index.ts | 203 +++++++++++++++++- src/server/terminal/PtyTerminalProxy.ts | 133 +++++++++++- src/shared/types.ts | 7 + 10 files changed, 923 insertions(+), 21 deletions(-) diff --git a/src/client/__tests__/terminal.test.tsx b/src/client/__tests__/terminal.test.tsx index 59c599f0..8d16f0ab 100644 --- a/src/client/__tests__/terminal.test.tsx +++ b/src/client/__tests__/terminal.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, mock } from 'bun:test' import TestRenderer, { act } from 'react-test-renderer' -import type { AgentSession, Session } from '@shared/types' +import type { AgentSession, ServerMessage, Session } from '@shared/types' import { useThemeStore } from '../stores/themeStore' import { useSessionStore } from '../stores/sessionStore' @@ -597,6 +597,110 @@ describe('Terminal', () => { }) }) + test('shows pending clipboard offer and copies it on tap', () => { + globalAny.navigator = { + userAgent: 'iPhone', + platform: 'iPhone', + maxTouchPoints: 5, + clipboard: { writeText: () => Promise.reject(new Error('gesture required')) }, + vibrate: () => true, + } as unknown as Navigator + globalAny.window = { + ...globalAny.window, + addEventListener: () => {}, + removeEventListener: () => {}, + requestAnimationFrame: ((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) as typeof requestAnimationFrame, + cancelAnimationFrame: (() => {}) as typeof cancelAnimationFrame, + } as unknown as Window & typeof globalThis + + const listeners: Array<(message: ServerMessage) => void> = [] + const { createNodeMock } = createContainerMock() + let renderer!: TestRenderer.ReactTestRenderer + + act(() => { + renderer = TestRenderer.create( + {}} + subscribe={(listener) => { + listeners.push(listener) + return () => {} + }} + onClose={() => {}} + onSelectSession={() => {}} + onNewSession={() => {}} + onKillSession={() => {}} + onRenameSession={() => {}} + onResumeSession={() => {}} + onOpenSettings={() => {}} + />, + { createNodeMock } + ) + }) + + act(() => { + listeners[0]?.({ + type: 'clipboard-offer', + sessionId: baseSession.id, + text: 'copied-from-claude', + source: 'tmux-buffer', + }) + }) + + let copiedText = '' + let appendedTextarea: { value: string } | null = null + globalAny.document = { + ...globalAny.document, + createElement: ((tagName: string) => { + if (tagName === 'textarea') { + return { + value: '', + style: {}, + focus: () => {}, + select: () => {}, + } + } + return { + className: '', + style: {}, + appendChild: () => {}, + remove: () => {}, + textContent: '', + } + }) as unknown as Document['createElement'], + body: { + appendChild: (node: Node) => { + appendedTextarea = node as unknown as { value: string } + return node + }, + removeChild: (node: Node) => node, + } as unknown as HTMLElement, + execCommand: ((command: string) => { + if (command === 'copy' && appendedTextarea) { + copiedText = appendedTextarea.value + return true + } + return false + }) as unknown as Document['execCommand'], + } as unknown as Document + + const copyButton = renderer.root.findByProps({ 'aria-label': 'Copy selection' }) + act(() => { + copyButton.props.onClick() + }) + + expect(copiedText).toBe('copied-from-claude') + + act(() => { + renderer.unmount() + }) + }) + test('shows connection status and new session button triggers callback', () => { const { createNodeMock } = createContainerMock() let newSessionCalls = 0 diff --git a/src/client/__tests__/useTerminal.test.tsx b/src/client/__tests__/useTerminal.test.tsx index 91f3333c..eea51653 100644 --- a/src/client/__tests__/useTerminal.test.tsx +++ b/src/client/__tests__/useTerminal.test.tsx @@ -1846,6 +1846,116 @@ describe('useTerminal', () => { }) }) + test('iOS appMouse=true long press drag emits SGR mouse selection events', async () => { + jest.useFakeTimers() + globalAny.window = { + ...globalAny.window, + matchMedia: () => ({ + matches: true, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + }), + } as unknown as Window & typeof globalThis + globalAny.navigator = { + userAgent: 'iPhone', + platform: 'iPhone', + maxTouchPoints: 5, + clipboard: { writeText: () => Promise.resolve(), readText: () => Promise.resolve('') }, + vibrate: () => true, + } as unknown as Navigator + + const sendCalls: Array> = [] + const listeners: Array<(message: ServerMessage) => void> = [] + const { container, dispatchEvent } = createContainerMock() + let preventDefaultCalls = 0 + let stopPropagationCalls = 0 + + let renderer!: TestRenderer.ReactTestRenderer + await act(async () => { + renderer = TestRenderer.create( + sendCalls.push(message)} + subscribe={(listener) => { + listeners.push(listener) + return () => {} + }} + onClose={() => {}} + onSelectSession={() => {}} + onNewSession={() => {}} + onKillSession={() => {}} + onRenameSession={() => {}} + onResumeSession={() => {}} + onOpenSettings={() => {}} + />, + { createNodeMock: () => container }, + ) + await Promise.resolve() + }) + + act(() => { + listeners[0]?.({ + type: 'tmux-copy-mode-status', + sessionId: 'session-1', + inCopyMode: false, + appMouse: true, + }) + }) + sendCalls.length = 0 + + const startTouch = { clientX: 25, clientY: 25 } as Touch + const dragTouch = { clientX: 45, clientY: 35 } as Touch + + act(() => { + dispatchEvent('touchstart', { + touches: [startTouch], + }) + }) + act(() => { + jest.advanceTimersByTime(350) + }) + act(() => { + dispatchEvent('touchmove', { + touches: [dragTouch], + preventDefault: () => { preventDefaultCalls += 1 }, + stopPropagation: () => { stopPropagationCalls += 1 }, + }) + dispatchEvent('touchend', { + changedTouches: [dragTouch], + preventDefault: () => { preventDefaultCalls += 1 }, + stopPropagation: () => { stopPropagationCalls += 1 }, + }) + }) + + expect(sendCalls).toEqual([ + { + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[<0;3;3M', + }, + { + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[<32;5;4M', + }, + { + type: 'terminal-input', + sessionId: 'session-1', + data: '\x1b[<0;5;4m', + }, + ]) + expect(preventDefaultCalls).toBe(2) + expect(stopPropagationCalls).toBe(2) + + act(() => { + renderer.unmount() + }) + }) + test('iOS appMouse=false tap keeps existing focus-only behavior', async () => { globalAny.window = { ...globalAny.window, diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 0eef156f..232d7c6a 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -23,6 +23,7 @@ import SessionDrawer from './SessionDrawer' import SessionPreviewContent from './SessionPreviewContent' import { PlusIcon, XCloseIcon, DotsVerticalIcon, Menu01Icon } from '@untitledui-icons/react/line' import AlertTriangleIcon from '@untitledui-icons/react/line/esm/AlertTriangleIcon' +import Copy01Icon from '@untitledui-icons/react/line/esm/Copy01Icon' import Edit05Icon from '@untitledui-icons/react/line/esm/Edit05Icon' import Moon01Icon from '@untitledui-icons/react/line/esm/Moon01Icon' import Settings01Icon from '@untitledui-icons/react/line/esm/Settings01Icon' @@ -163,6 +164,9 @@ export default function Terminal({ appMouseRef, setTmuxCopyMode, isSwitching, + pendingClipboardOffer, + copyPendingClipboardOffer, + dismissPendingClipboardOffer, } = useTerminal({ sessionId: session?.id ?? null, tmuxTarget: session?.tmuxWindow ?? null, @@ -553,6 +557,9 @@ export default function Terminal({ let accumulatedDelta = 0 let lineHeightPx = Math.round(fontSize * lineHeight) let momentumAnimationId: number | null = null + let longPressTimer: ReturnType | null = null + let remoteSelectionActive = false + let remoteSelectionLastCell: { col: number; row: number } | null = null const resolveLineHeight = () => { const computed = window.getComputedStyle(container) @@ -567,7 +574,7 @@ export default function Terminal({ const getTextarea = () => container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null - const getTouchCell = (touch: Touch): { col: number; row: number } | null => { + const getTouchCell = (touch: Pick): { col: number; row: number } | null => { const terminal = terminalRef.current if (!terminal) return null @@ -631,6 +638,29 @@ export default function Terminal({ velocity = 0 } + const clearLongPressTimer = () => { + if (longPressTimer !== null) { + clearTimeout(longPressTimer) + longPressTimer = null + } + } + + const sendMouseToApp = ( + button: number, + cell: { col: number; row: number }, + final: 'M' | 'm' + ): boolean => { + const currentSessionId = sessionIdRef.current + if (!currentSessionId) return false + + sendMessageRef.current({ + type: 'terminal-input', + sessionId: currentSessionId, + data: `\x1b[<${button};${cell.col};${cell.row}${final}`, + }) + return true + } + // Send scroll events to tmux as SGR mouse sequences (like desktop wheel handler) // Returns true if scrolled up (into history), false otherwise const sendScrollToTmux = (lines: number): boolean => { @@ -677,7 +707,45 @@ export default function Terminal({ return true } + const startRemoteSelection = (point: Pick): boolean => { + if (!isiOS || !appMouseRef.current) return false + const cell = getTouchCell(point) + if (!cell) return false + + remoteSelectionActive = true + remoteSelectionLastCell = cell + disableTextareaIfIdle() + triggerHaptic() + return sendMouseToApp(0, cell, 'M') + } + + const dragRemoteSelection = (touch: Touch): boolean => { + const cell = getTouchCell(touch) + if (!cell) return false + if ( + remoteSelectionLastCell && + remoteSelectionLastCell.col === cell.col && + remoteSelectionLastCell.row === cell.row + ) { + return true + } + + remoteSelectionLastCell = cell + return sendMouseToApp(32, cell, 'M') + } + + const endRemoteSelection = (touch: Touch | undefined): boolean => { + const cell = touch ? getTouchCell(touch) : remoteSelectionLastCell + remoteSelectionActive = false + remoteSelectionLastCell = null + if (!cell) return false + return sendMouseToApp(0, cell, 'm') + } + const resetTouchState = () => { + clearLongPressTimer() + remoteSelectionActive = false + remoteSelectionLastCell = null lastTouchY = null velocity = 0 accumulatedDelta = 0 @@ -729,6 +797,16 @@ export default function Terminal({ // Enable textarea on touch start so iOS long-press paste menu works enableTextarea() + + if (isiOS && appMouseRef.current) { + const startPoint = { clientX: touch.clientX, clientY: touch.clientY } + longPressTimer = setTimeout(() => { + longPressTimer = null + if (hasMoved || isEdgeSwipingRef.current || isDrawerOpen) return + if (isSelectingTextRef.current || hasActiveSelection()) return + startRemoteSelection(startPoint) + }, LONG_PRESS_MS) + } } } @@ -742,6 +820,20 @@ export default function Terminal({ return } + const now = performance.now() + const touch = e.touches[0] + const x = touch.clientX + const y = touch.clientY + + if (remoteSelectionActive) { + e.preventDefault() + e.stopPropagation() + dragRemoteSelection(touch) + lastTouchY = y + lastTouchTime = now + return + } + if (isSelectingTextRef.current || hasActiveSelection()) { resetTouchState() return @@ -750,15 +842,11 @@ export default function Terminal({ e.preventDefault() e.stopPropagation() - const now = performance.now() - const touch = e.touches[0] - const x = touch.clientX - const y = touch.clientY - const dx = Math.abs(x - touchStartPos.x) const dy = Math.abs(y - touchStartPos.y) if (!hasMoved && (dx > TAP_MOVE_THRESHOLD || dy > TAP_MOVE_THRESHOLD)) { hasMoved = true + clearLongPressTimer() disableTextareaIfIdle() } @@ -784,6 +872,16 @@ export default function Terminal({ const handleTouchEnd = (e: TouchEvent) => { const endVelocity = velocity const touchDuration = performance.now() - touchStartTime + clearLongPressTimer() + + if (remoteSelectionActive) { + endRemoteSelection(e.changedTouches[0]) + resetTouchState() + e.preventDefault() + e.stopPropagation() + return + } + resetTouchState() // Skip if edge swiping (opening drawer) or drawer is open - let document handler process @@ -882,6 +980,7 @@ export default function Terminal({ container.removeEventListener('touchmove', handleTouchMove, moveOptions) container.removeEventListener('touchend', handleTouchEnd, endOptions) container.removeEventListener('mousedown', handleMouseDown, mouseOptions) + clearLongPressTimer() // Re-enable textarea on cleanup const cleanupTextarea = getTextarea() if (cleanupTextarea) { @@ -1224,6 +1323,32 @@ export default function Terminal({ Loading
)} + {pendingClipboardOffer && session && ( +
+ + Selection ready + + + +
+ )} {!session && (
{hibernatingSession ? null : 'Select a session to view terminal'} diff --git a/src/client/hooks/useTerminal.ts b/src/client/hooks/useTerminal.ts index b4f8319a..ad074769 100644 --- a/src/client/hooks/useTerminal.ts +++ b/src/client/hooks/useTerminal.ts @@ -7,9 +7,10 @@ import { ClipboardAddon, type ClipboardSelectionType, type IClipboardProvider } import { SearchAddon } from '@xterm/addon-search' import { SerializeAddon } from '@xterm/addon-serialize' import { ProgressAddon } from '@xterm/addon-progress' -import type { AgentType, SendClientMessage, ServerMessageWithDiagnostics, SubscribeServerMessage } from '@shared/types' +import type { AgentType, ClipboardOfferSource, SendClientMessage, ServerMessageWithDiagnostics, SubscribeServerMessage } from '@shared/types' import { clientLog } from '../utils/clientLog' import type { ConnectionStatus } from '../stores/sessionStore' +import { copyText } from '../utils/copyText' // Module-level snapshot cache: sessionId → serialized terminal content. // Survives component remounts and avoids stale-closure issues in effects. @@ -89,6 +90,16 @@ interface PastePayload { hasImage: boolean } +interface PendingClipboardOffer { + id: number + text: string + source: ClipboardOfferSource +} + +interface SafeClipboardCallbacks { + onWriteFailed?: (text: string, source: ClipboardOfferSource) => void +} + function clipboardHasImage(clipboardData: DataTransfer | null | undefined): boolean { if (!clipboardData) return false @@ -108,6 +119,8 @@ function clipboardHasImage(clipboardData: DataTransfer | null | undefined): bool * accidentally wipe images or other non-text content the user has copied. */ class SafeClipboardProvider implements IClipboardProvider { + constructor(private callbacks: SafeClipboardCallbacks = {}) {} + async readText(selection: ClipboardSelectionType): Promise { if (selection !== 'c') return '' try { @@ -121,10 +134,24 @@ class SafeClipboardProvider implements IClipboardProvider { // Only write to system clipboard, and only if there's actual non-whitespace content // This prevents OSC 52 sequences from clearing images/rich content from the clipboard if (selection !== 'c' || !text?.trim()) return + const startedAt = performance.now() try { + if (!navigator.clipboard?.writeText) { + throw new Error('navigator.clipboard.writeText unavailable') + } await navigator.clipboard.writeText(text) - } catch { - // Clipboard write failed (permissions, etc.) + clientLog('clipboard_write_success', { + source: 'osc52', + chars: text.length, + durationMs: Math.round(performance.now() - startedAt), + }) + } catch (error) { + clientLog('clipboard_write_failed', { + source: 'osc52', + chars: text.length, + error: error instanceof Error ? error.message : String(error), + }, 'warn') + this.callbacks.onWriteFailed?.(text, 'osc52') } } } @@ -218,6 +245,32 @@ export function useTerminal({ const appMouseRef = useRef(false) const altScreenRef = useRef(false) const copyModeCheckTimer = useRef(null) + const clipboardOfferIdRef = useRef(0) + const [pendingClipboardOffer, setPendingClipboardOffer] = useState(null) + + const offerClipboardCopy = useCallback((text: string, source: ClipboardOfferSource) => { + if (!text?.trim()) return + clipboardOfferIdRef.current += 1 + setPendingClipboardOffer({ + id: clipboardOfferIdRef.current, + text, + source, + }) + }, []) + + const copyPendingClipboardOffer = useCallback(() => { + if (!pendingClipboardOffer) return + copyText(pendingClipboardOffer.text) + clientLog('clipboard_manual_copy', { + source: pendingClipboardOffer.source, + chars: pendingClipboardOffer.text.length, + }, 'info') + setPendingClipboardOffer(null) + }, [pendingClipboardOffer]) + + const dismissPendingClipboardOffer = useCallback(() => { + setPendingClipboardOffer(null) + }, []) const copyModePollIntervalRef = useRef | null>(null) // Track the currently attached session to prevent race conditions @@ -415,7 +468,9 @@ export function useTerminal({ const fitAddon = new FitAddon() terminal.loadAddon(fitAddon) - terminal.loadAddon(new ClipboardAddon(undefined, new SafeClipboardProvider())) + terminal.loadAddon(new ClipboardAddon(undefined, new SafeClipboardProvider({ + onWriteFailed: offerClipboardCopy, + }))) // Load search addon for terminal buffer search const searchAddon = new SearchAddon() @@ -1363,6 +1418,37 @@ export function useTerminal({ setIsSwitching(false) } + if ( + message.type === 'clipboard-offer' && + attachedSession && + message.sessionId === attachedSession + ) { + const text = message.text + if (!text?.trim()) return + + if (!isiOS && navigator.clipboard?.writeText) { + const startedAt = performance.now() + void navigator.clipboard.writeText(text) + .then(() => { + clientLog('clipboard_write_success', { + source: message.source, + chars: text.length, + durationMs: Math.round(performance.now() - startedAt), + }) + }) + .catch((error) => { + clientLog('clipboard_write_failed', { + source: message.source, + chars: text.length, + error: error instanceof Error ? error.message : String(error), + }, 'warn') + offerClipboardCopy(text, message.source) + }) + } else { + offerClipboardCopy(text, message.source) + } + } + // Handle tmux copy-mode status response if ( message.type === 'tmux-copy-mode-status' && @@ -1388,7 +1474,7 @@ export function useTerminal({ flush() cancelIosRepaint() } - }, [subscribe, checkScrollPosition, setTmuxCopyMode]) + }, [subscribe, checkScrollPosition, setTmuxCopyMode, offerClipboardCopy]) // Handle resize - with longer debounce to prevent flickering useEffect(() => { @@ -1528,5 +1614,8 @@ export function useTerminal({ appMouseRef, setTmuxCopyMode, isSwitching, + pendingClipboardOffer, + copyPendingClipboardOffer, + dismissPendingClipboardOffer, } } diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index c8cbdbdf..065825bd 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -570,6 +570,12 @@ function createWs() { userAgent: 'test-agent', terminalHost: null as string | null, terminalAttachSeq: 0, + lastAttachKey: null as string | null, + lastAttachTs: 0, + clipboardPollTimer: null as ReturnType | null, + clipboardPollInFlight: false, + lastClipboardBufferKey: null as string | null, + clipboardBufferArmedUntil: 0, }, send: (payload: string) => { sent.push(JSON.parse(payload) as ServerMessage) @@ -2435,6 +2441,115 @@ describe('server message handlers', () => { expect(outputCountAfter).toBe(outputCount) }) + test('offers changed tmux paste buffer for the active local terminal', async () => { + const { serveOptions, registryInstance } = await loadIndex() + registryInstance.sessions = [baseSession] + + let intervalCallback: (() => void) | null = null + const originalSetIntervalForTest = globalThis.setInterval + const originalClearInterval = globalThis.clearInterval + globalThis.setInterval = ((callback: TimerHandler) => { + intervalCallback = callback as () => void + return 123 as unknown as ReturnType + }) as unknown as typeof globalThis.setInterval + globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval + + let bufferCreated = 1 + spawnSyncImpl = ((...args: Parameters) => { + const command = Array.isArray(args[0]) ? args[0] : [String(args[0])] + const tmuxArgs = getTmuxArgs(command as string[]) + if (tmuxArgs[0] === 'list-buffers') { + return { + exitCode: 0, + stdout: Buffer.from(tmuxLine('buffer0', String(bufferCreated), '18') + '\n'), + stderr: Buffer.from(''), + } as ReturnType + } + if (tmuxArgs[0] === 'show-buffer') { + return { + exitCode: 0, + stdout: Buffer.from('copied-from-tmux'), + stderr: Buffer.from(''), + } as ReturnType + } + if (tmuxArgs[0] === 'capture-pane') { + return { + exitCode: 0, + stdout: Buffer.from('visible pane line\n'), + stderr: Buffer.from(''), + } as ReturnType + } + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + }) as typeof Bun.spawnSync + + try { + const { ws, sent } = createWs() + const websocket = serveOptions.websocket + if (!websocket) { + throw new Error('WebSocket handlers not configured') + } + + websocket.open?.(ws as never) + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-attach', + sessionId: baseSession.id, + tmuxTarget: baseSession.tmuxWindow, + }) + ) + + await new Promise((r) => setTimeout(r, 0)) + await new Promise((r) => setTimeout(r, 0)) + + const pollClipboard = intervalCallback as (() => void) | null + if (!pollClipboard) { + throw new Error('Expected clipboard poll interval') + } + expect( + sent.some((message) => message.type === 'clipboard-offer') + ).toBe(false) + + bufferCreated = 2 + pollClipboard() + await new Promise((r) => setTimeout(r, 0)) + expect( + sent.some((message) => message.type === 'clipboard-offer') + ).toBe(false) + + websocket.message?.( + ws as never, + JSON.stringify({ + type: 'terminal-input', + sessionId: baseSession.id, + data: '\x1b[<0;10;4M\x1b[<32;11;4M\x1b[<0;11;4m', + }) + ) + bufferCreated = 3 + pollClipboard() + await new Promise((r) => setTimeout(r, 0)) + expect(sent).toContainEqual({ + type: 'clipboard-offer', + sessionId: baseSession.id, + text: 'copied-from-tmux', + source: 'tmux-buffer', + }) + + const offerCount = sent.filter((message) => message.type === 'clipboard-offer').length + bufferCreated = 4 + pollClipboard() + await new Promise((r) => setTimeout(r, 0)) + expect(sent.filter((message) => message.type === 'clipboard-offer')).toHaveLength(offerCount) + } finally { + globalThis.setInterval = originalSetIntervalForTest + globalThis.clearInterval = originalClearInterval + } + }) + test('session-only attach replays the current grouped view and keeps copy-mode targeting aligned in pty mode', async () => { const { serveOptions, registryInstance } = await loadIndex() registryInstance.sessions = [baseSession] diff --git a/src/server/__tests__/isolated/terminalProxy.test.ts b/src/server/__tests__/isolated/terminalProxy.test.ts index b4cb3990..cbeca9a6 100644 --- a/src/server/__tests__/isolated/terminalProxy.test.ts +++ b/src/server/__tests__/isolated/terminalProxy.test.ts @@ -28,6 +28,7 @@ function createSpawnHarness() { null let exitHandler: ((terminal: Bun.Terminal, code: number, signal: string | null) => void) | null = null + let activeTarget = 'agentboard-ws-abc:@1' const exited = new Promise((resolve) => { exitResolver = resolve @@ -74,6 +75,34 @@ function createSpawnHarness() { stderr: Buffer.from(''), } as ReturnType } + if (command === 'switch-client') { + const tmuxArgs = args[0] === 'tmux' ? args.slice(1) : args + const targetIndex = tmuxArgs.indexOf('-t') + if (targetIndex >= 0 && tmuxArgs[targetIndex + 1]) { + activeTarget = tmuxArgs[targetIndex + 1] + } + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + } + if (command === 'display-message') { + const tmuxArgs = args[0] === 'tmux' ? args.slice(1) : args + const clientIndex = tmuxArgs.indexOf('-c') + const targetIndex = tmuxArgs.indexOf('-t') + const target = + clientIndex >= 0 ? activeTarget : targetIndex >= 0 ? tmuxArgs[targetIndex + 1] : '' + const [sessionName, windowTarget = '@1'] = target.split(':') + const windowId = windowTarget.includes('.') + ? windowTarget.slice(0, windowTarget.indexOf('.')) + : windowTarget + return { + exitCode: 0, + stdout: Buffer.from(buildTmuxFormat([sessionName, windowId || '@1']) + '\n'), + stderr: Buffer.from(''), + } as ReturnType + } return { exitCode: 0, stdout: Buffer.from(''), diff --git a/src/server/__tests__/testEnvironment.ts b/src/server/__tests__/testEnvironment.ts index cfac6df9..6ff5f9e1 100644 --- a/src/server/__tests__/testEnvironment.ts +++ b/src/server/__tests__/testEnvironment.ts @@ -31,6 +31,11 @@ export function canBindLocalhost(): boolean { } export function createTmuxTmpDir(prefix = 'agentboard-tmux-'): string { + // These integration tests create a dedicated tmux server via TMUX_TMPDIR. + // If the test process itself is running inside tmux, an inherited TMUX value + // points tmux commands back at the parent server and silently defeats that + // isolation. + delete process.env.TMUX const baseDir = fs.existsSync('/tmp') ? '/tmp' : os.tmpdir() return fs.mkdtempSync(path.join(baseDir, prefix)) } diff --git a/src/server/index.ts b/src/server/index.ts index 749a0fad..b1b09c1b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -547,10 +547,28 @@ interface WSData { terminalAttachSeq: number lastAttachKey: string | null lastAttachTs: number + clipboardPollTimer: ReturnType | null + clipboardPollInFlight: boolean + lastClipboardBufferKey: string | null + clipboardBufferArmedUntil: number } const sockets = new Set>() const localHostLabel = config.hostLabel +const CLIPBOARD_BUFFER_POLL_MS = 750 +const CLIPBOARD_BUFFER_MAX_BYTES = 512 * 1024 +const CLIPBOARD_BUFFER_ARM_MS = 5000 +const SGR_MOUSE_INPUT_RE = new RegExp( + `${String.fromCharCode(0x1b)}\\[<(\\d+);\\d+;\\d+[Mm]`, + 'g' +) + +interface TmuxBufferSummary { + name: string + created: number + size: number + key: string +} function stampLocalSession(session: Session): Session { return { @@ -1816,6 +1834,10 @@ function serverFetch(req: Request, server: Server) { terminalAttachSeq: 0, lastAttachKey: null, lastAttachTs: 0, + clipboardPollTimer: null, + clipboardPollInFlight: false, + lastClipboardBufferKey: null, + clipboardBufferArmedUntil: 0, }, }) ) { @@ -1943,7 +1965,163 @@ function clearAttachDedup(ws: ServerWebSocket) { ws.data.lastAttachTs = 0 } +function parseTmuxBufferSummaries(output: string): TmuxBufferSummary[] { + const summaries: TmuxBufferSummary[] = [] + for (const line of splitTmuxLines(output)) { + const parts = splitTmuxFields(line, 3) + if (!parts) continue + const name = parts[0]?.trim() + const created = Number.parseInt(parts[1] ?? '', 10) + const size = Number.parseInt(parts[2] ?? '', 10) + if (!name || !Number.isFinite(created) || !Number.isFinite(size)) continue + summaries.push({ + name, + created, + size, + key: `${name}:${created}:${size}`, + }) + } + summaries.sort((a, b) => b.created - a.created || b.size - a.size) + return summaries +} + +function readLatestTmuxBufferSummary(): TmuxBufferSummary | null { + try { + const result = Bun.spawnSync( + [ + 'tmux', + ...withTmuxUtf8Flag([ + 'list-buffers', + '-F', + buildTmuxFormat(['#{buffer_name}', '#{buffer_created}', '#{buffer_size}']), + ]), + ], + { stdout: 'pipe', stderr: 'pipe', timeout: config.tmuxTimeoutMs } + ) + if (result.exitCode !== 0) return null + return parseTmuxBufferSummaries(result.stdout?.toString() ?? '')[0] ?? null + } catch { + return null + } +} + +function readTmuxBufferText(summary: TmuxBufferSummary): string | null { + if (summary.size <= 0 || summary.size > CLIPBOARD_BUFFER_MAX_BYTES) { + return null + } + + try { + const result = Bun.spawnSync( + ['tmux', ...withTmuxUtf8Flag(['show-buffer', '-b', summary.name])], + { stdout: 'pipe', stderr: 'pipe', timeout: config.tmuxTimeoutMs } + ) + if (result.exitCode !== 0) return null + const text = result.stdout?.toString() ?? '' + return text.trim() ? text : null + } catch { + return null + } +} + +function stopClipboardBufferWatch(ws: ServerWebSocket) { + if (ws.data.clipboardPollTimer !== null) { + clearInterval(ws.data.clipboardPollTimer) + } + ws.data.clipboardPollTimer = null + ws.data.clipboardPollInFlight = false + ws.data.lastClipboardBufferKey = null + ws.data.clipboardBufferArmedUntil = 0 +} + +function startClipboardBufferWatch( + ws: ServerWebSocket, + sessionId: string, + session: Session +) { + stopClipboardBufferWatch(ws) + + if (session.remote) { + return + } + + const initial = readLatestTmuxBufferSummary() + ws.data.lastClipboardBufferKey = initial?.key ?? null + ws.data.clipboardPollTimer = setInterval(() => { + fireAndForget(pollClipboardBuffer(ws, sessionId), 'pollClipboardBuffer') + }, CLIPBOARD_BUFFER_POLL_MS) +} + +async function pollClipboardBuffer( + ws: ServerWebSocket, + sessionId: string +) { + if (ws.data.clipboardPollInFlight) return + if (!sockets.has(ws)) return + if (ws.data.currentSessionId !== sessionId) return + + const session = registry.get(sessionId) + if (!session || session.remote) return + + ws.data.clipboardPollInFlight = true + try { + const latest = readLatestTmuxBufferSummary() + if (!latest) { + ws.data.lastClipboardBufferKey = null + return + } + if (latest.key === ws.data.lastClipboardBufferKey) { + return + } + + ws.data.lastClipboardBufferKey = latest.key + if (Date.now() > ws.data.clipboardBufferArmedUntil) { + logger.debug('clipboard_buffer_watermarked', { + sessionId, + bufferName: latest.name, + bytes: latest.size, + connectionId: ws.data.connectionId, + }) + return + } + + const text = readTmuxBufferText(latest) + if (!text) { + logger.debug('clipboard_buffer_skipped', { + sessionId, + bufferName: latest.name, + bytes: latest.size, + connectionId: ws.data.connectionId, + }) + return + } + + logger.debug('clipboard_buffer_offer', { + sessionId, + bufferName: latest.name, + bytes: latest.size, + connectionId: ws.data.connectionId, + }) + send(ws, { type: 'clipboard-offer', sessionId, text, source: 'tmux-buffer' }) + ws.data.clipboardBufferArmedUntil = 0 + } finally { + ws.data.clipboardPollInFlight = false + } +} + +function containsClipboardArmingMouseInput(data: string): boolean { + SGR_MOUSE_INPUT_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SGR_MOUSE_INPUT_RE.exec(data)) !== null) { + const button = Number(match[1]) + if (button < 64 && (button & 3) === 0) { + return true + } + } + return false +} + function cleanupTerminals(ws: ServerWebSocket) { + stopClipboardBufferWatch(ws) if (ws.data.terminal) { void ws.data.terminal.dispose() ws.data.terminal = null @@ -3657,6 +3835,7 @@ async function attachTerminalPersistent( } return } + stopClipboardBufferWatch(ws) const target = tmuxTarget ?? session.tmuxWindow if (!isValidTmuxTarget(target)) { @@ -3705,10 +3884,20 @@ async function attachTerminalPersistent( elapsedMs: Math.round(now - ws.data.lastAttachTs), connectionId: ws.data.connectionId, }) - // Still need to set currentSessionId so input works - ws.data.currentSessionId = sessionId - ws.data.currentTmuxTarget = effectiveTarget - send(ws, { type: 'terminal-ready', sessionId }) + try { + await terminal.switchTo(target) + if (!isTerminalAttachCurrent(ws, attachSeq)) { + return + } + ws.data.currentSessionId = sessionId + ws.data.currentTmuxTarget = effectiveTarget + startClipboardBufferWatch(ws, sessionId, session) + send(ws, { type: 'terminal-ready', sessionId }) + } catch (error) { + if (sockets.has(ws) && isTerminalAttachCurrent(ws, attachSeq)) { + handleTerminalError(ws, sessionId, error, 'ERR_TMUX_SWITCH_FAILED') + } + } return } @@ -3769,6 +3958,7 @@ async function attachTerminalPersistent( // attach from hitting the dedup fast-path while the first is still in flight. ws.data.lastAttachKey = attachKey ws.data.lastAttachTs = performance.now() + startClipboardBufferWatch(ws, sessionId, session) logger.info('terminal_attach_profile', { sessionId, target, @@ -3885,6 +4075,7 @@ function detachTerminalPersistent(ws: ServerWebSocket, sessionId: string // Cancel any in-flight attach/switch operations so stale completions don't // clobber the newly-selected session. ws.data.terminalAttachSeq += 1 + stopClipboardBufferWatch(ws) if (ws.data.currentSessionId === sessionId) { ws.data.currentSessionId = null ws.data.currentTmuxTarget = null @@ -3905,6 +4096,10 @@ function handleTerminalInputPersistent( const session = registry.get(sessionId) if (session?.remote && !config.remoteAllowAttach) return + if (!session?.remote && containsClipboardArmingMouseInput(data)) { + ws.data.clipboardBufferArmedUntil = Date.now() + CLIPBOARD_BUFFER_ARM_MS + } + ws.data.terminal?.write(data) // On Enter key: immediately set "working" status and schedule refresh diff --git a/src/server/terminal/PtyTerminalProxy.ts b/src/server/terminal/PtyTerminalProxy.ts index 9128a9b3..d40e79bc 100644 --- a/src/server/terminal/PtyTerminalProxy.ts +++ b/src/server/terminal/PtyTerminalProxy.ts @@ -7,6 +7,15 @@ const CLIENT_TTY_FORMAT = buildTmuxFormat([ '#{client_tty}', '#{client_pid}', ]) +const TARGET_IDENTITY_FORMAT = buildTmuxFormat([ + '#{session_name}', + '#{window_id}', +]) + +interface TmuxTargetIdentity { + sessionName: string + windowId: string | null +} class PtyTerminalProxy extends TerminalProxyBase { private process: ReturnType | null = null @@ -149,6 +158,21 @@ class PtyTerminalProxy extends TerminalProxyBase { } } + try { + this.runTmuxMutation([ + 'set-option', + '-t', + this.options.sessionName, + 'allow-passthrough', + 'on', + ]) + } catch (error) { + this.logEvent('terminal_passthrough_enable_failed', { + sessionName: this.options.sessionName, + error: error instanceof Error ? error.message : String(error), + }) + } + if (attemptId !== this.startAttemptId) { await this.dispose() return @@ -258,7 +282,22 @@ class PtyTerminalProxy extends TerminalProxyBase { }) try { + const expectedIdentity = this.readTargetIdentity(effectiveTarget) this.runTmux(['switch-client', '-c', this.clientTty, '-t', effectiveTarget]) + const actualIdentity = await this.verifyClientTarget( + effectiveTarget, + expectedIdentity + ) + try { + this.process?.terminal?.resize(this.cols, this.rows) + } catch { + // Ignore resize errors; the PTY may already be closing. + } + try { + this.runTmux(['refresh-client', '-t', this.clientTty]) + } catch { + // Ignore refresh failures + } if (onReady) { try { onReady() @@ -267,11 +306,10 @@ class PtyTerminalProxy extends TerminalProxyBase { } } this.outputSuppressed = false - this.setCurrentWindow(effectiveTarget) - try { - this.runTmux(['refresh-client', '-t', this.clientTty]) - } catch { - // Ignore refresh failures + if (effectiveTarget.includes(':') && actualIdentity.windowId) { + this.setCurrentWindow(`${actualIdentity.sessionName}:${actualIdentity.windowId}`) + } else { + this.setCurrentWindow(effectiveTarget) } const durationMs = this.now() - startedAt this.logEvent('terminal_switch_success', { @@ -303,6 +341,91 @@ class PtyTerminalProxy extends TerminalProxyBase { } } + private readTargetIdentity(target: string): TmuxTargetIdentity { + const output = this.runParsedTmux([ + 'display-message', + '-p', + '-t', + target, + TARGET_IDENTITY_FORMAT, + ]).trim() + const identity = this.parseTargetIdentity(output) + if (!identity) { + throw new Error(`Unable to resolve tmux target identity for ${target}`) + } + return identity + } + + private readClientIdentity(): TmuxTargetIdentity { + if (!this.clientTty) { + throw new Error('Terminal client not ready') + } + const output = this.runParsedTmux([ + 'display-message', + '-p', + '-c', + this.clientTty, + TARGET_IDENTITY_FORMAT, + ]).trim() + const identity = this.parseTargetIdentity(output) + if (!identity) { + throw new Error(`Unable to resolve tmux client identity for ${this.clientTty}`) + } + return identity + } + + private parseTargetIdentity(output: string): TmuxTargetIdentity | null { + const parts = splitTmuxFields(output, 2) + if (!parts) return null + const sessionName = parts[0]?.trim() + const windowId = parts[1]?.trim() ?? '' + if (!sessionName) return null + return { + sessionName, + windowId: windowId || null, + } + } + + private identitiesMatch( + actual: TmuxTargetIdentity, + expected: TmuxTargetIdentity + ): boolean { + if (actual.sessionName !== expected.sessionName) return false + if (!expected.windowId) return true + return actual.windowId === expected.windowId + } + + private async verifyClientTarget( + effectiveTarget: string, + expected: TmuxTargetIdentity + ): Promise { + const retryDelays = [0, 25, 50, 100, 150] + let lastActual: TmuxTargetIdentity | null = null + + for (const delay of retryDelays) { + if (delay > 0) { + await this.wait(delay) + try { + this.runTmux(['switch-client', '-c', this.clientTty!, '-t', effectiveTarget]) + } catch { + // The final identity check below will surface a precise switch failure. + } + } + + const actual = this.readClientIdentity() + lastActual = actual + if (this.identitiesMatch(actual, expected)) { + return actual + } + } + + throw new Error( + `tmux client attached to ${lastActual?.sessionName ?? ''}:` + + `${lastActual?.windowId ?? ''}, expected ` + + `${expected.sessionName}:${expected.windowId ?? ''}` + ) + } + private async discoverClientTty(pid: number): Promise { const start = this.now() let delay = 50 diff --git a/src/shared/types.ts b/src/shared/types.ts index 669068c6..e911eb8d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -6,6 +6,7 @@ export type SessionStatus = 'working' | 'waiting' | 'permission' | 'unknown' export type SessionSource = 'managed' | 'external' export type AgentType = 'claude' | 'claude-rp' | 'codex' | 'pi' +export type ClipboardOfferSource = 'tmux-buffer' | 'osc52' export type SessionKillSource = | 'keyboard_shortcut' | 'session_list_context_menu' @@ -108,6 +109,12 @@ export type ServerMessage = retryable: boolean } | { type: 'terminal-ready'; sessionId: string } + | { + type: 'clipboard-offer' + sessionId: string + text: string + source: ClipboardOfferSource + } | { type: 'tmux-copy-mode-status' sessionId: string From d30d3abbd5ef52314c5eecdc25cfc558216271b4 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 12:57:40 -0400 Subject: [PATCH 04/11] chore: bump version to 0.4.0 --- README.md | 5 +++++ bun.lock | 8 ++++---- package.json | 10 +++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 80b921ab..d9d268ed 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ DISCOVER_PREFIXES=work,external PRUNE_WS_SESSIONS=true AGENTBOARD_PREFER_WINDOW_NAME=false TERMINAL_MODE=pty +AGENTBOARD_CLAUDE_NO_FLICKER=true TERMINAL_MONITOR_TARGETS=true VITE_ALLOWED_HOSTS=nuc,myserver AGENTBOARD_DB_PATH=~/.agentboard/agentboard.db @@ -179,6 +180,10 @@ AGENTBOARD_PASTE_IMAGE_MAX_BYTES=41943040 `TERMINAL_MODE` selects terminal I/O strategy: `pty` (default, grouped session) or `pipe-pane` (PTY-less, works in daemon/systemd/docker without `-t`). +`AGENTBOARD_CLAUDE_NO_FLICKER` controls how Agentboard launches new Claude Code windows. By default, Agentboard sets `CLAUDE_CODE_NO_FLICKER=1` on newly-created panes so Claude uses its fullscreen renderer with mouse scrolling, click handling, in-app selection, and flat memory usage for long conversations. Set `AGENTBOARD_CLAUDE_NO_FLICKER=0` (or `false`) before starting Agentboard to launch Claude without that env var. Fullscreen rendering requires Claude Code v2.1.89 or later; older Claude Code versions should ignore the env var and use the classic renderer. + +Reasons to opt out: fullscreen rendering keeps the conversation in the alternate screen buffer instead of native terminal scrollback, so terminal-level search/copy workflows behave differently; Claude also captures mouse events unless you disable its mouse capture. Inside Claude Code, run `/tui default` to switch a session back to the classic renderer, or launch Claude with `CLAUDE_CODE_DISABLE_MOUSE=1` if you want fullscreen rendering but native mouse selection. + `TERMINAL_MONITOR_TARGETS` (pipe-pane only) polls tmux to detect closed targets (set to `false` to disable). `VITE_ALLOWED_HOSTS` allows access to the Vite dev server from other hostnames. Useful with Tailscale MagicDNS - add your machine name (e.g., `nuc`) to access the dev server at `http://nuc:5173` from other devices on your tailnet. diff --git a/bun.lock b/bun.lock index d7999d8e..7f488a0a 100644 --- a/bun.lock +++ b/bun.lock @@ -52,10 +52,10 @@ "vite-plugin-pwa": "^1.2.0", }, "optionalDependencies": { - "@gbasin/agentboard-darwin-arm64": "0.2.45", - "@gbasin/agentboard-darwin-x64": "0.2.45", - "@gbasin/agentboard-linux-arm64": "0.2.45", - "@gbasin/agentboard-linux-x64": "0.2.45", + "@gbasin/agentboard-darwin-arm64": "0.4.0", + "@gbasin/agentboard-darwin-x64": "0.4.0", + "@gbasin/agentboard-linux-arm64": "0.4.0", + "@gbasin/agentboard-linux-x64": "0.4.0", }, }, }, diff --git a/package.json b/package.json index aed4f998..43bb228f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gbasin/agentboard", - "version": "0.3.3", + "version": "0.4.0", "type": "module", "description": "Web GUI for tmux optimized for AI agent TUIs", "author": "gbasin", @@ -20,10 +20,10 @@ "bun": ">=1.3.6" }, "optionalDependencies": { - "@gbasin/agentboard-darwin-arm64": "0.2.45", - "@gbasin/agentboard-darwin-x64": "0.2.45", - "@gbasin/agentboard-linux-x64": "0.2.45", - "@gbasin/agentboard-linux-arm64": "0.2.45" + "@gbasin/agentboard-darwin-arm64": "0.4.0", + "@gbasin/agentboard-darwin-x64": "0.4.0", + "@gbasin/agentboard-linux-x64": "0.4.0", + "@gbasin/agentboard-linux-arm64": "0.4.0" }, "scripts": { "dev": "concurrently -k \"bun run dev:server\" \"bun run dev:client\"", From 3ff159fd56c15b0fca4ed52749d7f041029744fb Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 25 Jun 2026 13:17:52 -0400 Subject: [PATCH 05/11] fix: wrap mobile terminal controls --- src/client/components/TerminalControls.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/components/TerminalControls.tsx b/src/client/components/TerminalControls.tsx index 082d455b..255106c0 100644 --- a/src/client/components/TerminalControls.tsx +++ b/src/client/components/TerminalControls.tsx @@ -459,7 +459,7 @@ export default function TerminalControls({
)} {/* Key row */} -
+
{/* Ctrl toggle */}