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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ PRUNE_WS_SESSIONS=true
AGENTBOARD_PREFER_WINDOW_NAME=false
TERMINAL_MODE=pty
AGENTBOARD_CLAUDE_NO_FLICKER=true
AGENTBOARD_TMUX_SET_CLIPBOARD=true
TERMINAL_MONITOR_TARGETS=true
VITE_ALLOWED_HOSTS=nuc,myserver
AGENTBOARD_DB_PATH=~/.agentboard/agentboard.db
Expand Down Expand Up @@ -186,6 +187,8 @@ AGENTBOARD_PASTE_IMAGE_MAX_BYTES=41943040

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.

`AGENTBOARD_TMUX_SET_CLIPBOARD` controls whether Agentboard enables tmux's `set-clipboard on` when it attaches. By default it does, so copies made inside a session land in a tmux paste buffer that Agentboard can relay to the browser clipboard (important on iOS Safari, where the async OSC 52 path can't satisfy the user-gesture rule). Because `set-clipboard` is a server-global tmux option, this affects your whole tmux server and is not reverted on disconnect — set `AGENTBOARD_TMUX_SET_CLIPBOARD=0` (or `false`) to leave your tmux configuration untouched.

`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.
Expand Down
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.1",
"version": "0.4.2",
"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.1",
"@gbasin/agentboard-darwin-x64": "0.4.1",
"@gbasin/agentboard-linux-x64": "0.4.1",
"@gbasin/agentboard-linux-arm64": "0.4.1"
"@gbasin/agentboard-darwin-arm64": "0.4.2",
"@gbasin/agentboard-darwin-x64": "0.4.2",
"@gbasin/agentboard-linux-x64": "0.4.2",
"@gbasin/agentboard-linux-arm64": "0.4.2"
},
"scripts": {
"dev": "concurrently -k \"bun run dev:server\" \"bun run dev:client\"",
Expand Down
293 changes: 289 additions & 4 deletions src/server/__tests__/isolated/indexHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,8 @@ function createWs() {
clipboardPollInFlight: false,
lastClipboardBufferKey: null as string | null,
clipboardBufferArmedUntil: 0,
clipboardArmedAtSec: 0,
clipboardWatchGen: 0,
},
send: (payload: string) => {
sent.push(JSON.parse(payload) as ServerMessage)
Expand Down Expand Up @@ -2454,7 +2456,10 @@ describe('server message handlers', () => {
}) as unknown as typeof globalThis.setInterval
globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval

let bufferCreated = 1
// Buffer creation times are real unix seconds: an offer requires the buffer
// to have been created at/after the arming gesture.
const nowSec = Math.floor(Date.now() / 1000)
let bufferCreated = nowSec - 10
spawnSyncImpl = ((...args: Parameters<typeof Bun.spawnSync>) => {
const command = Array.isArray(args[0]) ? args[0] : [String(args[0])]
const tmuxArgs = getTmuxArgs(command as string[])
Expand Down Expand Up @@ -2514,7 +2519,8 @@ describe('server message handlers', () => {
sent.some((message) => message.type === 'clipboard-offer')
).toBe(false)

bufferCreated = 2
// Unarmed buffer change: watermarked, not offered.
bufferCreated = nowSec - 5
pollClipboard()
await new Promise((r) => setTimeout(r, 0))
expect(
Expand All @@ -2529,7 +2535,8 @@ describe('server message handlers', () => {
data: '\x1b[<0;10;4M\x1b[<32;11;4M\x1b[<0;11;4m',
})
)
bufferCreated = 3
// Buffer created after the arming gesture: offered.
bufferCreated = nowSec + 30
pollClipboard()
await new Promise((r) => setTimeout(r, 0))
expect(sent).toContainEqual({
Expand All @@ -2540,7 +2547,7 @@ describe('server message handlers', () => {
})

const offerCount = sent.filter((message) => message.type === 'clipboard-offer').length
bufferCreated = 4
bufferCreated = nowSec + 31
pollClipboard()
await new Promise((r) => setTimeout(r, 0))
expect(sent.filter((message) => message.type === 'clipboard-offer')).toHaveLength(offerCount)
Expand All @@ -2550,6 +2557,284 @@ describe('server message handlers', () => {
}
})

test('arms on a click-only selection (double/triple-click, no drag) and offers the copy', 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<typeof setInterval>
}) as unknown as typeof globalThis.setInterval
globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval

const nowSec = Math.floor(Date.now() / 1000)
let bufferCreated = nowSec - 10
spawnSyncImpl = ((...args: Parameters<typeof Bun.spawnSync>) => {
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<typeof Bun.spawnSync>
}
if (tmuxArgs[0] === 'show-buffer') {
return {
exitCode: 0,
stdout: Buffer.from('word-from-double-click'),
stderr: Buffer.from(''),
} as ReturnType<typeof Bun.spawnSync>
}
return {
exitCode: 0,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
} as ReturnType<typeof Bun.spawnSync>
}) 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')
}

// Double-click word selection: left-button press + release with NO motion
// bit (button 32). This must still arm — it's a real selection that copies.
websocket.message?.(
ws as never,
JSON.stringify({
type: 'terminal-input',
sessionId: baseSession.id,
data: '\x1b[<0;10;4M\x1b[<0;10;4m',
})
)
bufferCreated = nowSec + 30
pollClipboard()
await new Promise((r) => setTimeout(r, 0))

expect(sent).toContainEqual({
type: 'clipboard-offer',
sessionId: baseSession.id,
text: 'word-from-double-click',
source: 'tmux-buffer',
})
} finally {
globalThis.setInterval = originalSetIntervalForTest
globalThis.clearInterval = originalClearInterval
}
})

test('does not offer a buffer created before the arming gesture', 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<typeof setInterval>
}) as unknown as typeof globalThis.setInterval
globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval

const nowSec = Math.floor(Date.now() / 1000)
let bufferCreated = nowSec
spawnSyncImpl = ((...args: Parameters<typeof Bun.spawnSync>) => {
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<typeof Bun.spawnSync>
}
if (tmuxArgs[0] === 'show-buffer') {
return {
exitCode: 0,
stdout: Buffer.from('stale-unrelated-buffer'),
stderr: Buffer.from(''),
} as ReturnType<typeof Bun.spawnSync>
}
return {
exitCode: 0,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
} as ReturnType<typeof Bun.spawnSync>
}) 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')
}

// Arm via a drag selection.
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',
})
)
// A different buffer becomes "latest" but was created well before the
// gesture (e.g. an unrelated copy in another window). It must NOT be
// offered, even though the watch is armed.
bufferCreated = nowSec - 100
pollClipboard()
await new Promise((r) => setTimeout(r, 0))

expect(sent.some((message) => message.type === 'clipboard-offer')).toBe(false)
} finally {
globalThis.setInterval = originalSetIntervalForTest
globalThis.clearInterval = originalClearInterval
}
})

test('a poll from a superseded watch generation is inert after re-attach', async () => {
const { serveOptions, registryInstance } = await loadIndex()
registryInstance.sessions = [baseSession]

const intervalCallbacks: Array<() => void> = []
const originalSetIntervalForTest = globalThis.setInterval
const originalClearInterval = globalThis.clearInterval
globalThis.setInterval = ((callback: TimerHandler) => {
intervalCallbacks.push(callback as () => void)
return intervalCallbacks.length as unknown as ReturnType<typeof setInterval>
}) as unknown as typeof globalThis.setInterval
globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval

const nowSec = Math.floor(Date.now() / 1000)
let bufferCreated = nowSec - 10
spawnSyncImpl = ((...args: Parameters<typeof Bun.spawnSync>) => {
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<typeof Bun.spawnSync>
}
if (tmuxArgs[0] === 'show-buffer') {
return {
exitCode: 0,
stdout: Buffer.from('fresh-copy'),
stderr: Buffer.from(''),
} as ReturnType<typeof Bun.spawnSync>
}
return {
exitCode: 0,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
} as ReturnType<typeof Bun.spawnSync>
}) 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)
// First attach establishes watch generation N.
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))

// Re-attach supersedes it with generation N+1 (and a fresh poll closure).
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))

expect(intervalCallbacks.length).toBeGreaterThanOrEqual(2)
const stalePoll = intervalCallbacks[0]
const freshPoll = intervalCallbacks[intervalCallbacks.length - 1]

// Arm and produce a post-gesture buffer.
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 = nowSec + 30

// The poll closure from the superseded generation must do nothing.
stalePoll()
await new Promise((r) => setTimeout(r, 0))
expect(sent.some((message) => message.type === 'clipboard-offer')).toBe(false)

// The current-generation poll delivers the offer.
freshPoll()
await new Promise((r) => setTimeout(r, 0))
expect(sent.some((message) => message.type === 'clipboard-offer')).toBe(true)
} 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]
Expand Down
Loading
Loading