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
2 changes: 1 addition & 1 deletion .bun-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3.8
1.3.14
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
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.

15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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\"",
Expand All @@ -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",
Expand Down
50 changes: 48 additions & 2 deletions scripts/test-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-'))
Expand Down Expand Up @@ -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',
Expand All @@ -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
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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}`],
Expand All @@ -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
}
106 changes: 105 additions & 1 deletion src/client/__tests__/terminal.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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(
<Terminal
session={baseSession}
sessions={[baseSession]}
connectionStatus="connected"
sendMessage={() => {}}
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
Expand Down
Loading
Loading