- Vertical spacing (1.0 = compact, 2.0 = spacious)
+
+
+
Letter Spacing
+
+ Horizontal spacing between characters in pixels
+
+
+
+ setDraftLetterSpacing(parseInt(e.target.value, 10))}
+ className="w-20 h-1 bg-border rounded-lg appearance-none cursor-pointer accent-accent"
+ />
+ {draftLetterSpacing}px
-
- setDraftLineHeight(parseFloat(e.target.value))}
- className="w-20 h-1 bg-border rounded-lg appearance-none cursor-pointer accent-accent"
- />
- {draftLineHeight.toFixed(1)}
-
-
-
-
Letter Spacing
-
- Horizontal spacing between characters in pixels
+
+
+
+
Font Family
+
+ Terminal typeface
+
+
+
+ {draftFontOption === 'custom' && (
+
setDraftCustomFontFamily(e.target.value)}
+ placeholder='"Fira Code", monospace'
+ className="input text-xs mt-2 font-mono"
+ />
+ )}
-
- setDraftLetterSpacing(parseInt(e.target.value, 10))}
- className="w-20 h-1 bg-border rounded-lg appearance-none cursor-pointer accent-accent"
- />
- {draftLetterSpacing}px
-
-
-
-
+
-
Font Family
+
Dark Mode
- Terminal typeface
+ Switch between dark and light themes.
-
-
- {draftFontOption === 'custom' && (
-
setDraftCustomFontFamily(e.target.value)}
- placeholder='"Fira Code", monospace'
- className="input text-xs mt-2 font-mono"
+
setDraftTheme(checked ? 'dark' : 'light')}
/>
- )}
-
-
-
-
-
Dark Mode
-
- Switch between dark and light themes.
-
-
setDraftTheme(checked ? 'dark' : 'light')}
- />
-
-
-
-
- {(
- ['auto', 'ctrl-option', 'ctrl-shift', 'cmd-option', 'cmd-shift'] as const
- ).map((mod) => (
-
- ))}
+
+
+
+ {(
+ ['auto', 'ctrl-option', 'ctrl-shift', 'cmd-option', 'cmd-shift'] as const
+ ).map((mod) => (
+
+ ))}
+
+
+ {draftShortcutModifier === 'auto'
+ ? `Platform default: ${getModifierDisplay(getEffectiveModifier('auto'))}`
+ : `Shortcuts: ${getModifierDisplay(draftShortcutModifier)}+[N/X/[/]]`}
+
-
- {draftShortcutModifier === 'auto'
- ? `Platform default: ${getModifierDisplay(getEffectiveModifier('auto'))}`
- : `Shortcuts: ${getModifierDisplay(draftShortcutModifier)}+[N/X/[/]]`}
-
-
diff --git a/src/client/components/StatusFilterDropdown.tsx b/src/client/components/StatusFilterDropdown.tsx
new file mode 100644
index 00000000..ef1811f8
--- /dev/null
+++ b/src/client/components/StatusFilterDropdown.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useId, useMemo, useRef, useState } from 'react'
+import ChevronDownIcon from '@untitledui-icons/react/line/esm/ChevronDownIcon'
+import type { SessionStatus } from '@shared/types'
+
+const STATUS_OPTIONS: { value: SessionStatus; label: string; dotClass: string }[] = [
+ { value: 'working', label: 'Working', dotClass: 'bg-green-500' },
+ { value: 'waiting', label: 'Waiting', dotClass: 'bg-zinc-400' },
+ { value: 'permission', label: 'Permission', dotClass: 'bg-amber-500' },
+ { value: 'unknown', label: 'Unknown', dotClass: 'bg-zinc-400' },
+]
+
+interface StatusFilterDropdownProps {
+ selectedStatuses: SessionStatus[]
+ onSelect: (statuses: SessionStatus[]) => void
+}
+
+export default function StatusFilterDropdown({
+ selectedStatuses,
+ onSelect,
+}: StatusFilterDropdownProps) {
+ const [open, setOpen] = useState(false)
+ const menuId = useId()
+ const containerRef = useRef
(null)
+ const selectedSet = useMemo(() => new Set(selectedStatuses), [selectedStatuses])
+
+ const selectedTitle = useMemo(() => {
+ if (selectedStatuses.length === 0) return 'All Status'
+ return selectedStatuses
+ .map((s) => STATUS_OPTIONS.find((o) => o.value === s)?.label ?? s)
+ .join(', ')
+ }, [selectedStatuses])
+
+ const selectedLabel = useMemo(() => {
+ if (selectedStatuses.length === 0) return 'All Status'
+ if (selectedStatuses.length === 1) {
+ return STATUS_OPTIONS.find((o) => o.value === selectedStatuses[0])?.label ?? selectedStatuses[0]
+ }
+ return `${selectedStatuses.length} statuses`
+ }, [selectedStatuses])
+
+ useEffect(() => {
+ if (!open || typeof document === 'undefined') return
+ if (!document.addEventListener || !document.removeEventListener) return
+ const handlePointer = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null
+ if (target && containerRef.current?.contains(target)) return
+ setOpen(false)
+ }
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('mousedown', handlePointer)
+ document.addEventListener('touchstart', handlePointer, { passive: true })
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('mousedown', handlePointer)
+ document.removeEventListener('touchstart', handlePointer)
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [open])
+
+ const toggleStatus = (status: SessionStatus) => {
+ const next = new Set(selectedSet)
+ if (next.has(status)) {
+ next.delete(status)
+ } else {
+ next.add(status)
+ }
+ const ordered = STATUS_OPTIONS
+ .map((o) => o.value)
+ .filter((v) => next.has(v))
+ onSelect(ordered)
+ }
+
+ return (
+
+
+ {open && (
+
+ )}
+
+ )
+}
diff --git a/src/client/stores/sessionStore.ts b/src/client/stores/sessionStore.ts
index 8d62079b..c6280dfb 100644
--- a/src/client/stores/sessionStore.ts
+++ b/src/client/stores/sessionStore.ts
@@ -56,6 +56,8 @@ interface SessionState {
// Sessions being animated out - keyed by session ID, value is the session data
exitingSessions: Map
selectedSessionId: string | null
+ // Sessions with new completed output the user hasn't viewed yet
+ unreadSessionIds: Set
hasLoaded: boolean
connectionStatus: ConnectionStatus
connectionError: string | null
@@ -72,6 +74,10 @@ interface SessionState {
setRemoteAllowAttach: (value: boolean) => void
hostLabel: string | null
setHostLabel: (value: string | null) => void
+ // Mark a session as having new unread output
+ markSessionUnread: (sessionId: string) => void
+ // Clear unread state (user has viewed the session)
+ markSessionRead: (sessionId: string) => void
// Mark a session as exiting (preserves data for exit animation)
markSessionExiting: (sessionId: string) => void
// Clear a session from exiting state (after animation completes)
@@ -86,6 +92,7 @@ export const useSessionStore = create()(
hostStatuses: [],
exitingSessions: new Map(),
selectedSessionId: null,
+ unreadSessionIds: new Set(),
hasLoaded: false,
connectionStatus: 'connecting',
connectionError: null,
@@ -112,6 +119,17 @@ export const useSessionStore = create()(
(s) => !newSessionIds.has(s.id) && !exitingSessions.has(s.id)
)
+ // Clean up unread IDs for removed sessions
+ const unreadSessionIds = state.unreadSessionIds
+ let nextUnread = unreadSessionIds
+ if (unreadSessionIds.size > 0) {
+ const staleIds = [...unreadSessionIds].filter((id) => !newSessionIds.has(id))
+ if (staleIds.length > 0) {
+ nextUnread = new Set(unreadSessionIds)
+ for (const id of staleIds) nextUnread.delete(id)
+ }
+ }
+
let newSelectedId: string | null = selected
if (
selected !== null &&
@@ -127,6 +145,13 @@ export const useSessionStore = create()(
newSelectedId = sorted[0]?.id ?? null
}
+ const baseUpdate = {
+ sessions,
+ hasLoaded: true,
+ selectedSessionId: newSelectedId,
+ ...(nextUnread !== unreadSessionIds ? { unreadSessionIds: nextUnread } : {}),
+ }
+
// Only update exitingSessions if there are newly removed sessions
if (removedSessions.length > 0) {
const nextExitingSessions = new Map(exitingSessions)
@@ -134,17 +159,11 @@ export const useSessionStore = create()(
nextExitingSessions.set(session.id, session)
}
set({
- sessions,
- hasLoaded: true,
- selectedSessionId: newSelectedId,
+ ...baseUpdate,
exitingSessions: nextExitingSessions,
})
} else {
- set({
- sessions,
- hasLoaded: true,
- selectedSessionId: newSelectedId,
- })
+ set(baseUpdate)
}
},
setAgentSessions: (active, inactive) =>
@@ -164,6 +183,18 @@ export const useSessionStore = create()(
setRemoteAllowControl: (value) => set({ remoteAllowControl: value }),
setRemoteAllowAttach: (value) => set({ remoteAllowAttach: value }),
setHostLabel: (value) => set({ hostLabel: value }),
+ markSessionUnread: (sessionId) => {
+ const next = new Set(get().unreadSessionIds)
+ next.add(sessionId)
+ set({ unreadSessionIds: next })
+ },
+ markSessionRead: (sessionId) => {
+ const current = get().unreadSessionIds
+ if (!current.has(sessionId)) return
+ const next = new Set(current)
+ next.delete(sessionId)
+ set({ unreadSessionIds: next })
+ },
markSessionExiting: (sessionId) => {
const session = get().sessions.find((s) => s.id === sessionId)
if (session) {
@@ -181,7 +212,22 @@ export const useSessionStore = create()(
{
name: SESSION_PERSIST_KEY,
storage: createJSONStorage(() => tabStorage),
- partialize: (state) => ({ selectedSessionId: state.selectedSessionId }),
+ partialize: (state) => ({
+ selectedSessionId: state.selectedSessionId,
+ unreadSessionIds: [...state.unreadSessionIds],
+ }),
+ merge: (persisted, current) => {
+ const data = persisted as Record | undefined
+ return {
+ ...current,
+ ...(data ?? {}),
+ unreadSessionIds: new Set(
+ Array.isArray(data?.unreadSessionIds)
+ ? (data.unreadSessionIds as string[])
+ : []
+ ),
+ }
+ },
}
)
)
diff --git a/src/client/stores/settingsStore.ts b/src/client/stores/settingsStore.ts
index 8cd80888..81aeb75f 100644
--- a/src/client/stores/settingsStore.ts
+++ b/src/client/stores/settingsStore.ts
@@ -1,5 +1,6 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
+import type { SessionStatus } from '@shared/types'
import { safeStorage } from '../utils/storage'
const DEFAULT_PROJECT_DIR = '~/Documents/GitHub'
@@ -51,11 +52,11 @@ export interface CommandPreset {
label: string
command: string
isBuiltIn: boolean
- agentType?: 'claude' | 'codex' | 'pi'
+ agentType?: 'claude' | 'codex' | 'gemini' | 'pi'
}
export const DEFAULT_PRESETS: CommandPreset[] = [
- { id: 'claude', label: 'Claude', command: 'claude', isBuiltIn: true, agentType: 'claude' },
+ { id: 'claude', label: 'Claude', command: 'mlflow', isBuiltIn: true, agentType: 'claude' },
{ id: 'codex', label: 'Codex', command: 'codex', isBuiltIn: true, agentType: 'codex' },
{ id: 'pi', label: 'Pi', command: 'pi', isBuiltIn: true, agentType: 'pi' },
]
@@ -69,7 +70,7 @@ export function isValidPreset(p: unknown): p is CommandPreset {
typeof obj.label === 'string' && obj.label.trim().length >= 1 && obj.label.length <= 64 &&
typeof obj.command === 'string' && obj.command.trim().length >= 1 && obj.command.length <= 1024 &&
typeof obj.isBuiltIn === 'boolean' &&
- (obj.agentType === undefined || obj.agentType === 'claude' || obj.agentType === 'codex' || obj.agentType === 'pi')
+ (obj.agentType === undefined || obj.agentType === 'claude' || obj.agentType === 'codex' || obj.agentType === 'gemini' || obj.agentType === 'pi')
)
}
@@ -149,6 +150,8 @@ interface SettingsState {
setProjectFilters: (filters: string[]) => void
hostFilters: string[]
setHostFilters: (filters: string[]) => void
+ statusFilters: SessionStatus[]
+ setStatusFilters: (filters: SessionStatus[]) => void
// Sound notifications
soundOnPermission: boolean
setSoundOnPermission: (enabled: boolean) => void
@@ -193,9 +196,9 @@ export const useSettingsStore = create()(
setLineHeight: (height) => set({ lineHeight: Math.max(1.0, Math.min(2.0, height)) }),
letterSpacing: 0,
setLetterSpacing: (spacing) => set({ letterSpacing: Math.max(-3, Math.min(3, spacing)) }),
- fontOption: 'jetbrains-mono',
+ fontOption: 'custom',
setFontOption: (option) => set({ fontOption: option }),
- customFontFamily: '',
+ customFontFamily: '"Hack Nerd Font"',
setCustomFontFamily: (family) => set({ customFontFamily: family.slice(0, 256) }),
shortcutModifier: 'auto',
setShortcutModifier: (modifier) => set({ shortcutModifier: modifier }),
@@ -216,6 +219,8 @@ export const useSettingsStore = create()(
setProjectFilters: (filters) => set({ projectFilters: filters }),
hostFilters: [],
setHostFilters: (filters) => set({ hostFilters: filters }),
+ statusFilters: [] as SessionStatus[],
+ setStatusFilters: (filters) => set({ statusFilters: filters }),
// Sound notifications
soundOnPermission: false,
setSoundOnPermission: (enabled) => set({ soundOnPermission: enabled }),
@@ -286,7 +291,7 @@ export const useSettingsStore = create()(
label: p.label as string,
command: command || 'claude',
isBuiltIn: p.isBuiltIn as boolean,
- agentType: p.agentType as 'claude' | 'codex' | 'pi' | undefined,
+ agentType: p.agentType as 'claude' | 'codex' | 'gemini' | 'pi' | undefined,
}
}
@@ -329,7 +334,7 @@ export const useSettingsStore = create()(
// Validate migrated presets
const validPresets = migratedPresets.filter(isValidPreset)
const hasBuiltIns = validPresets.some(p => p.id === 'claude') &&
- validPresets.some(p => p.id === 'codex')
+ validPresets.some(p => p.id === 'codex')
if (!hasBuiltIns || validPresets.length === 0) {
console.warn('[agentboard:settings] Invalid presets, resetting to defaults')
@@ -343,7 +348,7 @@ export const useSettingsStore = create()(
// Trim to max if needed
const trimmedPresets = validPresets.length > MAX_PRESETS
? [...validPresets.filter(p => p.isBuiltIn),
- ...validPresets.filter(p => !p.isBuiltIn).slice(0, MAX_PRESETS - 2)]
+ ...validPresets.filter(p => !p.isBuiltIn).slice(0, MAX_PRESETS - 2)]
: validPresets
// Ensure all built-in presets from DEFAULT_PRESETS are present
diff --git a/src/client/styles/index.css b/src/client/styles/index.css
index ea8a213f..ad3ee7eb 100644
--- a/src/client/styles/index.css
+++ b/src/client/styles/index.css
@@ -309,6 +309,21 @@ body {
animation: pulse-complete 5s ease-out;
}
+/* Unread indicator dot for newly completed sessions */
+@keyframes unread-fade-in {
+ from { opacity: 0; transform: scale(0.5); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+.unread-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--accent);
+ animation: unread-fade-in 0.2s ease-out;
+ flex-shrink: 0;
+}
+
/* Terminal styles */
.xterm {
font-family: "JetBrains Mono Variable", "JetBrains Mono", "SF Mono", "Fira Code", ui-monospace, monospace;
@@ -559,4 +574,8 @@ body {
.pulse-complete {
animation: none;
}
+
+ .unread-dot {
+ animation: none;
+ }
}
diff --git a/src/server/__tests__/db.test.ts b/src/server/__tests__/db.test.ts
index 6844c439..0dd170c1 100644
--- a/src/server/__tests__/db.test.ts
+++ b/src/server/__tests__/db.test.ts
@@ -2,12 +2,33 @@ import { describe, expect, test, afterEach } from 'bun:test'
import { Database as SQLiteDatabase } from 'bun:sqlite'
import { initDatabase } from '../db'
import fs from 'node:fs'
+import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import type { AgentType } from '../../shared/types'
const now = new Date('2026-01-01T00:00:00.000Z').toISOString()
+async function removeDirWithRetries(dirPath: string): Promise {
+ let lastError: unknown = null
+
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ try {
+ await fsp.rm(dirPath, { recursive: true, force: true })
+ return
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code
+ if (code !== 'EBUSY' && code !== 'ENOTEMPTY') {
+ throw error
+ }
+ lastError = error
+ await Bun.sleep(25 * (attempt + 1))
+ }
+ }
+
+ throw lastError
+}
+
function makeSession(overrides: Partial<{
sessionId: string
logFilePath: string
@@ -140,7 +161,7 @@ describe('db', () => {
expect(db.displayNameExists('definitely-nonexistent-xyz123')).toBe(false)
})
- test('migrates legacy schema without session_source', () => {
+ test('migrates legacy schema without session_source', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agentboard-'))
const dbPath = path.join(tempDir, 'agentboard.db')
const legacyDb = new SQLiteDatabase(dbPath)
@@ -189,7 +210,7 @@ describe('db', () => {
expect(migrated.getSessionById('session-synthetic')).toBeNull()
migrated.close()
- fs.rmSync(tempDir, { recursive: true, force: true })
+ await removeDirWithRetries(tempDir)
})
test('app settings get/set', () => {
diff --git a/src/server/__tests__/index.test.ts b/src/server/__tests__/index.test.ts
index 3a8d9e1d..21977ea2 100644
--- a/src/server/__tests__/index.test.ts
+++ b/src/server/__tests__/index.test.ts
@@ -53,6 +53,11 @@ describe('server entrypoint', () => {
bunAny.serve = originalServe
bunAny.spawnSync = originalSpawnSync
globalThis.setInterval = originalSetInterval
+ if (originalMatchWorker === undefined) {
+ delete process.env.AGENTBOARD_LOG_MATCH_WORKER
+ } else {
+ process.env.AGENTBOARD_LOG_MATCH_WORKER = originalMatchWorker
+ }
})
test('starts server without side effects', async () => {
diff --git a/src/server/__tests__/logger.test.ts b/src/server/__tests__/logger.test.ts
index a8fe7097..c95cb2b1 100644
--- a/src/server/__tests__/logger.test.ts
+++ b/src/server/__tests__/logger.test.ts
@@ -1,8 +1,29 @@
import { afterEach, describe, expect, test } from 'bun:test'
import fs from 'node:fs'
+import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
+async function removeDirWithRetries(dirPath: string): Promise {
+ let lastError: unknown = null
+
+ for (let attempt = 0; attempt < 5; attempt += 1) {
+ try {
+ await fsp.rm(dirPath, { recursive: true, force: true })
+ return
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code
+ if (code !== 'EBUSY' && code !== 'ENOTEMPTY') {
+ throw error
+ }
+ lastError = error
+ await Bun.sleep(25 * (attempt + 1))
+ }
+ }
+
+ throw lastError
+}
+
describe('logger', () => {
const ORIGINAL_LOG_LEVEL = process.env.LOG_LEVEL
const ORIGINAL_LOG_FILE = process.env.LOG_FILE
@@ -68,7 +89,7 @@ describe('logger', () => {
mod.closeLogger()
closeLogger = null
- fs.rmSync(tmpDir, { recursive: true })
+ await removeDirWithRetries(tmpDir)
})
test('logger respects log level from env', async () => {
@@ -94,7 +115,7 @@ describe('logger', () => {
mod.closeLogger()
closeLogger = null
- fs.rmSync(tmpDir, { recursive: true })
+ await removeDirWithRetries(tmpDir)
})
test('defaults to info level when LOG_LEVEL not set', async () => {
@@ -118,7 +139,7 @@ describe('logger', () => {
mod.closeLogger()
closeLogger = null
- fs.rmSync(tmpDir, { recursive: true })
+ await removeDirWithRetries(tmpDir)
})
test('handles invalid LOG_LEVEL gracefully', async () => {
@@ -143,7 +164,7 @@ describe('logger', () => {
mod.closeLogger()
closeLogger = null
- fs.rmSync(tmpDir, { recursive: true })
+ await removeDirWithRetries(tmpDir)
})
test('log entries include event name in output', async () => {
@@ -170,7 +191,7 @@ describe('logger', () => {
mod.closeLogger()
closeLogger = null
- fs.rmSync(tmpDir, { recursive: true })
+ await removeDirWithRetries(tmpDir)
})
test('event field is not overwritten by data', async () => {
@@ -196,6 +217,6 @@ describe('logger', () => {
mod.closeLogger()
closeLogger = null
- fs.rmSync(tmpDir, { recursive: true })
+ await removeDirWithRetries(tmpDir)
})
})
diff --git a/src/server/__tests__/terminalProxyFactory.test.ts b/src/server/__tests__/terminalProxyFactory.test.ts
index b5847371..b7e82539 100644
--- a/src/server/__tests__/terminalProxyFactory.test.ts
+++ b/src/server/__tests__/terminalProxyFactory.test.ts
@@ -1,20 +1,6 @@
-import { afterAll, describe, expect, test, mock } from 'bun:test'
+import { afterEach, beforeEach, describe, expect, test, mock } from 'bun:test'
import type { TerminalProxyOptions } from '../terminal/types'
-const constructed: Array<{ mode: string; options: TerminalProxyOptions }> = []
-
-class PtyTerminalProxyMock {
- constructor(options: TerminalProxyOptions) {
- constructed.push({ mode: 'pty', options })
- }
-}
-
-class PipePaneTerminalProxyMock {
- constructor(options: TerminalProxyOptions) {
- constructed.push({ mode: 'pipe-pane', options })
- }
-}
-
const configMock = {
port: 4040,
hostname: '0.0.0.0',
@@ -39,32 +25,33 @@ const configMock = {
enterRefreshDelayMs: 50,
}
-mock.module('../config', () => ({
- config: configMock,
-}))
-
-mock.module('../terminal/PtyTerminalProxy', () => ({
- PtyTerminalProxy: PtyTerminalProxyMock,
-}))
+const HOSTNAME_REGEX = /^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]))*$/
-mock.module('../terminal/PipePaneTerminalProxy', () => ({
- PipePaneTerminalProxy: PipePaneTerminalProxyMock,
-}))
+function isValidHostname(hostname: string): boolean {
+ return hostname.length > 0 && hostname.length <= 253 && HOSTNAME_REGEX.test(hostname)
+}
const originalIsTTY = process.stdin.isTTY
-let createTerminalProxy: typeof import('../terminal/TerminalProxyFactory').createTerminalProxy
-let resolveTerminalMode: typeof import('../terminal/TerminalProxyFactory').resolveTerminalMode
+let importCounter = 0
+
+function setupModuleMocks(): void {
+ mock.module('../config', () => ({
+ config: configMock,
+ isValidHostname,
+ }))
+}
async function loadFactory() {
- if (!createTerminalProxy || !resolveTerminalMode) {
- const module = await import('../terminal/TerminalProxyFactory')
- createTerminalProxy = module.createTerminalProxy
- resolveTerminalMode = module.resolveTerminalMode
- }
+ importCounter += 1
+ return import(`../terminal/TerminalProxyFactory?terminal-proxy-factory=${importCounter}`)
}
-afterAll(() => {
+beforeEach(() => {
+ setupModuleMocks()
+})
+
+afterEach(() => {
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
@@ -74,7 +61,7 @@ afterAll(() => {
describe('TerminalProxyFactory', () => {
test('resolveTerminalMode respects config overrides', async () => {
- await loadFactory()
+ const { resolveTerminalMode } = await loadFactory()
configMock.terminalMode = 'pipe-pane'
expect(resolveTerminalMode()).toBe('pipe-pane')
@@ -84,7 +71,7 @@ describe('TerminalProxyFactory', () => {
})
test('resolveTerminalMode falls back to stdin tty', async () => {
- await loadFactory()
+ const { resolveTerminalMode } = await loadFactory()
configMock.terminalMode = 'auto'
Object.defineProperty(process.stdin, 'isTTY', {
@@ -101,7 +88,7 @@ describe('TerminalProxyFactory', () => {
})
test('createTerminalProxy instantiates correct proxy', async () => {
- await loadFactory()
+ const { createTerminalProxy } = await loadFactory()
const options: TerminalProxyOptions = {
connectionId: 'conn-1',
@@ -110,14 +97,10 @@ describe('TerminalProxyFactory', () => {
onData: () => {},
}
- constructed.length = 0
configMock.terminalMode = 'pty'
- createTerminalProxy(options)
- expect(constructed[0]?.mode).toBe('pty')
+ expect(createTerminalProxy(options).getMode()).toBe('pty')
- constructed.length = 0
configMock.terminalMode = 'pipe-pane'
- createTerminalProxy(options)
- expect(constructed[0]?.mode).toBe('pipe-pane')
+ expect(createTerminalProxy(options).getMode()).toBe('pipe-pane')
})
})
diff --git a/src/server/agentDetection.ts b/src/server/agentDetection.ts
index c0730a20..6fd43fb1 100644
--- a/src/server/agentDetection.ts
+++ b/src/server/agentDetection.ts
@@ -115,12 +115,15 @@ export function inferAgentType(command: string): AgentType | undefined {
// Extract base name from path
const baseName = part.split('/').pop() || part
- if (baseName === 'claude') {
+ if (baseName === 'claude' || baseName === 'mlflow') {
return 'claude'
}
if (baseName === 'codex') {
return 'codex'
}
+ if (baseName === 'gemini') {
+ return 'gemini'
+ }
if (baseName === 'pi') {
return 'pi'
}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 18bd5348..fa70f11e 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -5,7 +5,7 @@ export const INACTIVE_MAX_AGE_MAX_HOURS = 168 // 7 days
export type SessionStatus = 'working' | 'waiting' | 'permission' | 'unknown'
export type SessionSource = 'managed' | 'external'
-export type AgentType = 'claude' | 'claude-rp' | 'codex' | 'pi'
+export type AgentType = 'claude' | 'claude-rp' | 'codex' | 'gemini' | 'pi'
export type TerminalErrorCode =
| 'ERR_INVALID_WINDOW'
| 'ERR_SESSION_CREATE_FAILED'
@@ -89,12 +89,12 @@ export type ServerMessage =
| { type: 'session-resurrection-failed'; sessionId: string; displayName: string; error: string }
| { type: 'terminal-output'; sessionId: string; data: string }
| {
- type: 'terminal-error'
- sessionId: string | null
- code: TerminalErrorCode
- message: string
- retryable: boolean
- }
+ type: 'terminal-error'
+ sessionId: string | null
+ code: TerminalErrorCode
+ message: string
+ retryable: boolean
+ }
| { type: 'terminal-ready'; sessionId: string }
| { type: 'tmux-copy-mode-status'; sessionId: string; inCopyMode: boolean }
| { type: 'server-config'; remoteAllowControl: boolean; remoteAllowAttach: boolean; hostLabel: string; clientLogLevel?: string }
@@ -109,12 +109,12 @@ export interface ResumeError {
export type ClientMessage =
| {
- type: 'terminal-attach'
- sessionId: string
- tmuxTarget?: string
- cols?: number
- rows?: number
- }
+ type: 'terminal-attach'
+ sessionId: string
+ tmuxTarget?: string
+ cols?: number
+ rows?: number
+ }
| { type: 'terminal-detach'; sessionId: string }
| { type: 'terminal-input'; sessionId: string; data: string }
| { type: 'terminal-resize'; sessionId: string; cols: number; rows: number }