diff --git a/.bun-version b/.bun-version index e05cb332..085c0f26 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.3.8 +1.3.14 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76edc172..7627c697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Type check run: bun run typecheck + - name: Test + run: bun run test:ci + - name: Test (coverage) run: bun run test:coverage diff --git a/README.md b/README.md index 80b921ab..a166da58 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ Run your desktop/server, then connect from your phone or laptop over Tailscale/L - iOS Safari mobile experience with: - Paste support (including images) - - Touch scrolling + - Touch scrolling, tap-to-click, and long-press selection in fullscreen Claude Code sessions + - Mobile-friendly copy prompts when browser clipboard writes need a user gesture - Virtual arrow keys / d-pad - Quick keys toolbar (ctrl, esc, etc.) +- Claude Code fullscreen renderer support by default, with mouse scrolling, click handling, in-app selection, and clipboard forwarding through the browser terminal - Out-of-the-box log tracking and matching for Claude, Codex, and Pi — auto-matches sessions to active tmux windows, with one-click Wake for Hibernating and History sessions. - Shows the last user prompt for each session, so you can remember what each agent is working on - Hibernate sessions to close their tmux window while keeping them visible across restarts for manual Wake @@ -85,7 +87,7 @@ For persistent deployment, see [systemd/README.md](systemd/README.md) (Linux) or ### From source -Requires **Bun 1.3.6+** (see [Troubleshooting](#troubleshooting)). +Requires **Bun 1.3.14+** (see [Troubleshooting](#troubleshooting)). ```bash bun install @@ -150,6 +152,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 +182,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..d6d0b44d 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", @@ -17,13 +17,13 @@ "README.md" ], "engines": { - "bun": ">=1.3.6" + "bun": ">=1.3.14" }, "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\"", @@ -34,10 +34,11 @@ "lint": "oxlint .", "typecheck": "tsc --noEmit", "test": "bun scripts/test-runner.ts", + "test:ci": "bun scripts/test-runner.ts --skip-real-tmux", "deps:risk": "bun scripts/dependency-risk.ts", "deps:risk:ci": "bun scripts/dependency-risk.ts --threshold critical", "deps:risk:json": "bun scripts/dependency-risk.ts --json", - "test:coverage": "bun scripts/test-runner.ts --coverage --coverage-reporter=lcov --skip-isolated && bun run coverage:all", + "test:coverage": "bun scripts/test-runner.ts --coverage --coverage-reporter=lcov --skip-isolated --skip-real-tmux && bun run coverage:all", "coverage:all": "bun scripts/coverage-all.ts", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", diff --git a/scripts/test-runner.ts b/scripts/test-runner.ts index cbd2860e..32eb0598 100644 --- a/scripts/test-runner.ts +++ b/scripts/test-runner.ts @@ -4,7 +4,10 @@ import path from 'node:path' const args = process.argv.slice(2) const skipIsolated = args.includes('--skip-isolated') -const passthroughArgs = args.filter((arg) => arg !== '--skip-isolated') +const skipRealTmux = args.includes('--skip-real-tmux') +const passthroughArgs = args.filter( + (arg) => arg !== '--skip-isolated' && arg !== '--skip-real-tmux' +) function createTempLogDirs() { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agentboard-tests-')) @@ -58,6 +61,12 @@ async function main() { // Bun.serve / setInterval mock; isolation keeps that mock window from // overlapping with any other test that captures globals at module load. const ISOLATED_FILES = new Set([ + // Entry-point tests patch Bun.serve/Bun.spawnSync/process.exit while + // importing the server. Keep them away from real server/tmux tests. + 'directories.test.ts', + 'index.test.ts', + 'indexPortCheck.test.ts', + 'slug-supersede.integration.test.ts', 'sessionRefreshWorker.test.ts', 'pipePaneTerminalProxy.test.ts', 'hydrateSessionsEmptyGuard.test.ts', @@ -70,6 +79,16 @@ async function main() { 'terminalProxyFactory.test.ts', ]) + // These spawn real servers, PTYs, and tmux clients. They still need process + // isolation from global Bun.* mocks, but running them under coverage on + // Linux CI can stall PTY attach readiness. + const ISOLATED_REAL_TMUX_FILES = new Set([ + 'double-attach.integration.test.ts', + 'hibernation.integration.test.ts', + 'integration.test.ts', + 'throttled-reconnect.integration.test.ts', + ]) + // Client tests that install top-level mock.module(...) hooks must run in a // separate process — Bun's module mocks persist for the lifetime of the // test process, so they leak into any subsequent file that imports the @@ -83,7 +102,8 @@ async function main() { const serverTests: string[] = [] const serverGlob = new Bun.Glob('src/server/__tests__/*.test.ts') for await (const file of serverGlob.scan({ onlyFiles: true })) { - if (!ISOLATED_FILES.has(path.basename(file))) { + const basename = path.basename(file) + if (!ISOLATED_FILES.has(basename) && !ISOLATED_REAL_TMUX_FILES.has(basename)) { serverTests.push(file) } } @@ -115,6 +135,16 @@ async function main() { ) } + if (!skipRealTmux) { + const argsWithoutCoverage = stripCoverageArgs(passthroughArgs) + for (const file of ISOLATED_REAL_TMUX_FILES) { + await runCommand( + ['bun', 'test', ...argsWithoutCoverage, `src/server/__tests__/${file}`], + env + ) + } + } + for (const file of ISOLATED_CLIENT_FILES) { await runCommand( ['bun', 'test', ...passthroughArgs, `src/client/__tests__/${file}`], @@ -137,3 +167,19 @@ main().catch((error) => { console.error(error) process.exit(1) }) + +function stripCoverageArgs(args: string[]) { + const stripped: string[] = [] + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--coverage') continue + if (arg.startsWith('--coverage=')) continue + if (arg.startsWith('--coverage-reporter=')) continue + if (arg === '--coverage-reporter') { + index += 1 + continue + } + stripped.push(arg) + } + return stripped +} 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 d48cb5f5..eea51653 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,598 @@ 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=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, + 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..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' @@ -156,7 +157,17 @@ export default function Terminal({ !session.remote && !!session.agentSessionId?.trim() - const { containerRef, terminalRef, inTmuxCopyModeRef, setTmuxCopyMode, isSwitching } = useTerminal({ + const { + containerRef, + terminalRef, + inTmuxCopyModeRef, + appMouseRef, + setTmuxCopyMode, + isSwitching, + pendingClipboardOffer, + copyPendingClipboardOffer, + dismissPendingClipboardOffer, + } = useTerminal({ sessionId: session?.id ?? null, tmuxTarget: session?.tmuxWindow ?? null, agentType: session?.agentType, @@ -546,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) @@ -560,6 +574,31 @@ export default function Terminal({ const getTextarea = () => container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null + const getTouchCell = (touch: Pick): { 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 @@ -599,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 => { @@ -625,14 +687,65 @@ 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 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 @@ -684,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) + } } } @@ -697,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 @@ -705,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() } @@ -739,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 @@ -763,6 +906,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() @@ -829,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) { @@ -1171,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/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 */}