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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gbasin/agentboard",
"version": "0.4.3",
"version": "0.4.4",
"type": "module",
"description": "Web GUI for tmux optimized for AI agent TUIs",
"author": "gbasin",
Expand All @@ -20,10 +20,10 @@
"bun": ">=1.3.14"
},
"optionalDependencies": {
"@gbasin/agentboard-darwin-arm64": "0.4.3",
"@gbasin/agentboard-darwin-x64": "0.4.3",
"@gbasin/agentboard-linux-x64": "0.4.3",
"@gbasin/agentboard-linux-arm64": "0.4.3"
"@gbasin/agentboard-darwin-arm64": "0.4.4",
"@gbasin/agentboard-darwin-x64": "0.4.4",
"@gbasin/agentboard-linux-x64": "0.4.4",
"@gbasin/agentboard-linux-arm64": "0.4.4"
},
"scripts": {
"dev": "concurrently -k \"bun run dev:server\" \"bun run dev:client\"",
Expand Down
5 changes: 4 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export default defineConfig({
headless: true,
},
webServer: {
command: `[ -d dist/client ] || bun run build && PORT=${port} TMUX_SESSION=${tmuxSession} bun src/server/index.ts`,
// AGENTBOARD_STATIC_DIR is pinned to the repo build: when e2e runs from a
// shell inside a live agentboard session, the inherited env points at the
// installed npm package's bundle and the tests would exercise stale code.
command: `[ -d dist/client ] || bun run build && PORT=${port} TMUX_SESSION=${tmuxSession} AGENTBOARD_STATIC_DIR=dist/client bun src/server/index.ts`,
url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI,
timeout: 120000,
Expand Down
41 changes: 39 additions & 2 deletions src/client/__tests__/terminalControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ describe('TerminalControls', () => {
expect(selections).toEqual(['session-2'])
})

test('paste button uses clipboard text fallback and refocuses', async () => {
test('paste button routes clipboard text through onPasteText and refocuses', async () => {
let refocused = false
const sent: string[] = []
const pasted: string[] = []

globalAny.navigator = {
vibrate: () => true,
Expand All @@ -133,6 +134,7 @@ describe('TerminalControls', () => {
const renderer = TestRenderer.create(
<TerminalControls
onSendKey={(key) => sent.push(key)}
onPasteText={(text) => pasted.push(text)}
sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]}
currentSessionId="session-1"
onSelectSession={() => {}}
Expand All @@ -152,10 +154,45 @@ describe('TerminalControls', () => {
await pasteButton.props.onClick()
})

expect(sent).toEqual(['pasted text'])
// Pasted text goes through the explicit paste path (bracketed server-side),
// never the raw keystroke path — so multi-line pastes aren't auto-submitted.
expect(pasted).toEqual(['pasted text'])
expect(sent).toEqual([])
expect(refocused).toBe(true)
})

test('paste button falls back to onSendKey when onPasteText is not provided', async () => {
const sent: string[] = []

globalAny.navigator = {
vibrate: () => true,
clipboard: {
read: () => Promise.reject(new Error('no clipboard')),
readText: () => Promise.resolve('pasted text'),
},
} as unknown as Navigator

const renderer = TestRenderer.create(
<TerminalControls
onSendKey={(key) => sent.push(key)}
sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]}
currentSessionId="session-1"
onSelectSession={() => {}}
/>
)

const pasteButton = findPasteButton(renderer)
if (!pasteButton) {
throw new Error('Expected paste button')
}

await act(async () => {
await pasteButton.props.onClick()
})

expect(sent).toEqual(['pasted text'])
})

test('manual paste input sends text on enter', async () => {
const sent: string[] = []

Expand Down
100 changes: 96 additions & 4 deletions src/client/__tests__/useTerminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1242,7 +1242,15 @@ describe('useTerminal', () => {
jest.advanceTimersByTime(100)
})

expect(terminal.pasteCalls).toContain('pasted-text')
// Pasted text is delivered as an explicit terminal-paste message (bracketed
// server-side via tmux), NOT via terminal.paste() — which would only bracket
// when the browser's own bracketedPasteMode is on, and otherwise auto-submit.
expect(sendCalls).toContainEqual({
type: 'terminal-paste',
sessionId: 'session-1',
data: 'pasted-text',
})
expect(terminal.pasteCalls).toEqual([])
// The capture-phase listener should have prevented the default to block ClipboardAddon
expect(pasteEvent.preventDefault).toHaveBeenCalled()
expect(pasteEvent.stopPropagation).toHaveBeenCalled()
Expand Down Expand Up @@ -1540,6 +1548,83 @@ describe('useTerminal', () => {
globalThis.fetch = originalFetch
})

test('Cmd+V text paste while in tmux copy-mode exits copy-mode before pasting', async () => {
jest.useFakeTimers()
globalAny.navigator = {
userAgent: 'Chrome',
platform: 'MacIntel',
maxTouchPoints: 0,
clipboard: {
writeText: () => Promise.resolve(),
readText: () => Promise.resolve(''),
},
} as unknown as Navigator

const sendCalls: Array<Record<string, unknown>> = []
const listeners: Array<(message: ServerMessage) => void> = []
const { container, dispatchEvent } = createContainerMock()

let renderer!: TestRenderer.ReactTestRenderer

await act(async () => {
renderer = TestRenderer.create(
<TerminalHarness
sessionId="session-1"
tmuxTarget="agentboard:@1"
sendMessage={(message) => sendCalls.push(message)}
subscribe={(listener) => {
listeners.push(listener)
return () => {}
}}
theme={{ background: '#000' }}
fontSize={12}
/>,
{ createNodeMock: () => container },
)
await Promise.resolve()
})

const terminal = TerminalMock.instances[0]
if (!terminal) throw new Error('Expected terminal instance')

// Enter copy-mode via server status, then paste multi-line text.
act(() => {
listeners[0]?.({
type: 'tmux-copy-mode-status',
sessionId: 'session-1',
inCopyMode: true,
})
})
sendCalls.length = 0

terminal.emitKey({ key: 'v', type: 'keydown', metaKey: true, ctrlKey: false })
dispatchEvent('paste', {
type: 'paste',
preventDefault: mock(() => {}),
stopPropagation: mock(() => {}),
clipboardData: { getData: () => 'line-a\nline-b' },
})
await act(async () => { jest.advanceTimersByTime(100) })

// copy-mode must be cancelled BEFORE the paste is delivered, otherwise the
// paste-buffer lands in copy-mode and never reaches the program.
const cancelIdx = sendCalls.findIndex((c) => c.type === 'tmux-cancel-copy-mode')
const pasteIdx = sendCalls.findIndex((c) => c.type === 'terminal-paste')
expect(cancelIdx).toBeGreaterThanOrEqual(0)
expect(pasteIdx).toBeGreaterThan(cancelIdx)
expect(sendCalls[pasteIdx]).toEqual({
type: 'terminal-paste',
sessionId: 'session-1',
data: 'line-a\nline-b',
})
expect(terminal.pasteCalls).toEqual([])

act(() => {
renderer.unmount()
})
jest.useRealTimers()
})

test('appMouse=true scroll-up forwards wheel without disabling mouse tracking or entering copy-mode', async () => {
globalAny.navigator = {
userAgent: 'Chrome',
Expand Down Expand Up @@ -2155,6 +2240,7 @@ describe('useTerminal', () => {
} as unknown as Navigator

const { container } = createContainerMock()
const sendCalls: Array<Record<string, unknown>> = []

let renderer!: TestRenderer.ReactTestRenderer

Expand All @@ -2163,7 +2249,7 @@ describe('useTerminal', () => {
<TerminalHarness
sessionId="session-1"
tmuxTarget="agentboard:@1"
sendMessage={() => {}}
sendMessage={(message) => sendCalls.push(message)}
subscribe={() => () => {}}
theme={{ background: '#000' }}
fontSize={12}
Expand All @@ -2184,8 +2270,14 @@ describe('useTerminal', () => {
jest.advanceTimersByTime(100)
})

// Should have fallen back to navigator.clipboard.readText()
expect(terminal.pasteCalls).toContain('clipboard-api-text')
// Should have fallen back to navigator.clipboard.readText() and delivered it
// as an explicit terminal-paste (bracketed server-side), not terminal.paste().
expect(sendCalls).toContainEqual({
type: 'terminal-paste',
sessionId: 'session-1',
data: 'clipboard-api-text',
})
expect(terminal.pasteCalls).toEqual([])

act(() => { renderer.unmount() })
globalThis.fetch = originalFetch
Expand Down
17 changes: 17 additions & 0 deletions src/client/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,22 @@ export default function Terminal({
[session, isReadOnly, sendMessage]
)

// Deliver pasted text as a single bracketed paste (server routes it through
// tmux paste-buffer -p) so multi-line content isn't auto-submitted line-by-line.
const handlePasteText = useCallback(
(text: string) => {
if (!session || isReadOnly) return
// Exit tmux copy-mode first: a paste-buffer into a pane still in copy-mode
// is swallowed by the copy-mode key table instead of reaching the program.
if (inTmuxCopyModeRef.current) {
sendMessage({ type: 'tmux-cancel-copy-mode', sessionId: session.id })
setTmuxCopyMode(false)
}
sendMessage({ type: 'terminal-paste', sessionId: session.id, data: text })
},
[session, isReadOnly, sendMessage, inTmuxCopyModeRef, setTmuxCopyMode]
)

const handleRefocus = useCallback(() => {
const container = containerRef.current
if (!container) return
Expand Down Expand Up @@ -1455,6 +1471,7 @@ export default function Terminal({
{session && (
<TerminalControls
onSendKey={handleSendKey}
onPasteText={handlePasteText}
disabled={connectionStatus !== 'connected' || isReadOnly}
sessions={sessions.map(s => ({ id: s.id, name: s.name, status: s.status }))}
currentSessionId={session.id}
Expand Down
23 changes: 20 additions & 3 deletions src/client/components/TerminalControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ interface SessionInfo {

interface TerminalControlsProps {
onSendKey: (key: string) => void
/**
* Deliver pasted text as an explicit paste (bracketed via tmux) instead of raw
* keystrokes, so multi-line content isn't auto-submitted line-by-line. Falls
* back to onSendKey when not provided.
*/
onPasteText?: (text: string) => void
disabled?: boolean
sessions: SessionInfo[]
currentSessionId: string | null
Expand Down Expand Up @@ -149,6 +155,7 @@ async function uploadPasteImage(

export default function TerminalControls({
onSendKey,
onPasteText,
disabled = false,
sessions,
currentSessionId,
Expand Down Expand Up @@ -297,6 +304,16 @@ export default function TerminalControls({
onSendKey(output)
}

// Route pasted text through the explicit paste path (bracketed via tmux) so
// multi-line content isn't auto-submitted line-by-line; fall back to raw keys.
const sendPasteText = (text: string) => {
if (onPasteText) {
onPasteText(text)
} else {
onSendKey(text)
}
}

const handlePasteButtonClick = async () => {
if (disabled) return
// Check if keyboard was visible before we do anything
Expand Down Expand Up @@ -337,7 +354,7 @@ export default function TerminalControls({
const blob = await item.getType('text/plain')
const text = await blob.text()
if (text) {
onSendKey(text)
sendPasteText(text)
if (wasKeyboardVisible) {
onRefocus?.()
}
Expand All @@ -350,7 +367,7 @@ export default function TerminalControls({
try {
const text = await navigator.clipboard.readText()
if (text) {
onSendKey(text)
sendPasteText(text)
if (wasKeyboardVisible) {
onRefocus?.()
}
Expand All @@ -369,7 +386,7 @@ export default function TerminalControls({
const handlePasteSubmit = () => {
if (pasteValue) {
triggerHaptic()
onSendKey(pasteValue)
sendPasteText(pasteValue)
}
setShowPasteInput(false)
setPasteValue('')
Expand Down
19 changes: 18 additions & 1 deletion src/client/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,7 +863,24 @@ export function useTerminal({
}

if (text && !isMacSharedPasteboardPathText(text)) {
terminal.paste(text)
// Deliver as an explicit paste so the server can bracket it via
// tmux (paste-buffer -p). Relying on xterm's terminal.paste()
// would only bracket when the browser's own bracketedPasteMode is
// on — which it isn't when we attach to an already-running app
// (e.g. Claude Code in no-flicker/fullscreen), causing multi-line
// pastes to auto-submit on the first newline. Guard on the live
// ref (like sendInputIfStillAttached) so a session switch during
// the async clipboard read doesn't paste into the wrong session.
if (attachedSessionRef.current === attached) {
// Exit tmux copy-mode first (as onData does for typed input):
// paste-buffer into a pane still in copy-mode is swallowed by
// the copy-mode key table instead of reaching the program.
if (inTmuxCopyModeRef.current) {
sendMessageRef.current({ type: 'tmux-cancel-copy-mode', sessionId: attached })
setTmuxCopyMode(false)
}
sendMessageRef.current({ type: 'terminal-paste', sessionId: attached, data: text })
}
return
}

Expand Down
Loading
Loading