From 8cad328c158a6b33d9779ce1748bfe725ecd0d1c Mon Sep 17 00:00:00 2001 From: Jonathan Park Date: Mon, 13 Apr 2026 16:58:18 -0700 Subject: [PATCH] Support factory terminal orchestration --- package-lock.json | 3 + server/agent-api/layout-store.ts | 11 + server/agent-api/router.ts | 64 ++ server/terminal-view/service.ts | 312 ++++++---- server/terminal-view/types.ts | 116 ++-- server/ws-handler.ts | 77 ++- src/lib/ui-commands.ts | 1 + test/server/agent-tabs-write.test.ts | 374 ++++++++---- test/server/terminals-api.test.ts | 845 ++++++++++++++------------- test/unit/client/ui-commands.test.ts | 18 + 10 files changed, 1151 insertions(+), 670 deletions(-) diff --git a/package-lock.json b/package-lock.json index 75df5123d..e5166f884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,9 @@ "typescript": "^5.7.2", "vite": "^6.4.1", "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.5.0" } }, "node_modules/@adobe/css-tools": { diff --git a/server/agent-api/layout-store.ts b/server/agent-api/layout-store.ts index 467564d2a..00d148372 100644 --- a/server/agent-api/layout-store.ts +++ b/server/agent-api/layout-store.ts @@ -311,6 +311,17 @@ export class LayoutStore { return undefined } + findPaneByTerminalId(terminalId: string): { tabId: string; paneId: string } | undefined { + if (!this.snapshot) return undefined + for (const tab of this.snapshot.tabs) { + const root = this.snapshot.layouts?.[tab.id] + const leaves = this.collectLeaves(root, []) + const match = leaves.find((leaf) => leaf.content?.terminalId === terminalId) + if (match) return { tabId: tab.id, paneId: match.id } + } + return undefined + } + getPaneSnapshot(paneId: string): PaneSnapshot | undefined { if (!this.snapshot) return undefined for (const tab of this.snapshot.tabs) { diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts index 63f647074..3f05d2f4b 100644 --- a/server/agent-api/router.ts +++ b/server/agent-api/router.ts @@ -112,6 +112,14 @@ export function createAgentApiRouter({ }) { const router = Router() + const broadcastReplayableUiCommand = (command: { command: string; payload?: any }) => { + if (typeof wsHandler?.broadcastUiCommandWithReplay === 'function') { + wsHandler.broadcastUiCommandWithReplay(command) + return + } + wsHandler?.broadcastUiCommand?.(command) + } + const resolvePaneTarget = (raw: string) => { if (layoutStore.resolveTarget) { const resolved = layoutStore.resolveTarget(raw) @@ -338,6 +346,62 @@ export function createAgentApiRouter({ res.json(ok({ tabs, activeTabId })) }) + router.post('/terminals/:id/open', (req, res) => { + const terminalId = typeof req.params.id === 'string' ? req.params.id.trim() : '' + if (!terminalId) return res.status(400).json(fail('terminal id required')) + const term = registry.get?.(terminalId) + if (!term) return res.status(404).json(fail('terminal not found')) + + const existing = layoutStore.findPaneByTerminalId?.(terminalId) + if (existing?.tabId && existing?.paneId) { + const result = layoutStore.selectPane?.(existing.tabId, existing.paneId) || existing + const paneId = result?.paneId || existing.paneId + const tabId = result?.tabId || existing.tabId + if (tabId && paneId) { + broadcastReplayableUiCommand({ + command: 'tab.select', + payload: { id: tabId }, + }) + broadcastReplayableUiCommand({ + command: 'pane.select', + payload: { tabId, paneId }, + }) + } + return res.json(ok({ tabId, paneId, terminalId, reused: true }, result?.message || 'terminal selected')) + } + + if (!layoutStore.createTab || !layoutStore.attachPaneContent) { + return res.status(503).json(fail('layout store does not support terminal attach')) + } + + const title = parseRequiredName(req.body?.name) || term.title || terminalId + const { tabId, paneId } = layoutStore.createTab({ title }) + const paneContent = { + kind: 'terminal', + terminalId, + status: term.status || 'running', + mode: term.mode || 'shell', + initialCwd: term.cwd, + resumeSessionId: term.resumeSessionId, + } + layoutStore.attachPaneContent(tabId, paneId, paneContent) + broadcastReplayableUiCommand({ + command: 'tab.create', + payload: { + id: tabId, + title, + mode: term.mode || 'shell', + terminalId, + initialCwd: term.cwd, + resumeSessionId: term.resumeSessionId, + paneId, + paneContent, + status: term.status || 'running', + }, + }) + res.json(ok({ tabId, paneId, terminalId, reused: false }, 'terminal opened')) + }) + router.get('/panes', (req, res) => { const tabId = req.query.tabId as string | undefined const panes = layoutStore.listPanes?.(tabId) || [] diff --git a/server/terminal-view/service.ts b/server/terminal-view/service.ts index eb14c4fbb..f00c003a4 100644 --- a/server/terminal-view/service.ts +++ b/server/terminal-view/service.ts @@ -1,9 +1,9 @@ -import type { TerminalMode } from '../terminal-registry.js' +import type { TerminalMode } from "../terminal-registry.js"; import { MAX_DIRECTORY_PAGE_ITEMS, type TerminalDirectoryQuery, -} from '../../shared/read-models.js' -import { TerminalViewMirror } from './mirror.js' +} from "../../shared/read-models.js"; +import { TerminalViewMirror } from "./mirror.js"; import type { TerminalDirectoryItem, TerminalDirectoryPage, @@ -11,206 +11,284 @@ import type { TerminalSearchPage, TerminalViewService, TerminalViewportRuntime, -} from './types.js' +} from "./types.js"; type CursorPayload = { - lastActivityAt: number - terminalId: string -} + lastActivityAt: number; + terminalId: string; +}; -type TerminalListRecord = TerminalDirectoryItem +type TerminalListRecord = TerminalDirectoryItem; type TerminalRecord = { - terminalId: string - title: string - description?: string - mode: TerminalMode - resumeSessionId?: string - createdAt: number - lastActivityAt: number - status: 'running' | 'exited' - cwd?: string - cols: number - rows: number - clients: Set - pty?: { pid?: number } - buffer: { snapshot: () => string } -} + terminalId: string; + title: string; + description?: string; + mode: TerminalMode; + resumeSessionId?: string; + createdAt: number; + lastActivityAt: number; + status: "running" | "exited"; + cwd?: string; + cols: number; + rows: number; + clients: Set; + pty?: { pid?: number }; + buffer: { snapshot: () => string }; +}; type TerminalViewServiceDeps = { configStore: { snapshot: () => Promise<{ - terminalOverrides?: Record - }> - } + terminalOverrides?: Record< + string, + { + titleOverride?: string | null; + descriptionOverride?: string | null; + deleted?: boolean; + } + >; + }>; + }; registry: { - list: () => TerminalListRecord[] - get: (terminalId: string) => TerminalRecord | undefined - on?: (event: string, listener: (...args: any[]) => void) => void - } -} + list: () => TerminalListRecord[]; + get: (terminalId: string) => TerminalRecord | undefined; + on?: (event: string, listener: (...args: any[]) => void) => void; + }; +}; function buildRuntime(record: TerminalRecord): TerminalViewportRuntime { return { title: record.title, - status: record.status === 'exited' - ? 'exited' - : (record.clients.size > 0 ? 'running' : 'detached'), + status: + record.status === "exited" + ? "exited" + : record.clients.size > 0 + ? "running" + : "detached", ...(record.cwd ? { cwd: record.cwd } : {}), - ...(typeof record.pty?.pid === 'number' ? { pid: record.pty.pid } : {}), - } + ...(typeof record.pty?.pid === "number" ? { pid: record.pty.pid } : {}), + }; +} + +const ANSI_ESCAPE_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; +const MAX_LAST_LINE_CHARS = 500; + +function isShellPromptLine(line: string): boolean { + return /^[^\s@:]+@[^\s:]+:.+[#$%]\s*$/.test(line); +} + +function lastEmittedLine(snapshot: string): string | undefined { + const cleaned = snapshot.replace(ANSI_ESCAPE_RE, "").replace(/\r/g, "\n"); + const lastLine = cleaned + .split("\n") + .map((line) => line.trim()) + .filter((line) => !isShellPromptLine(line)) + .filter(Boolean) + .at(-1); + if (!lastLine) return undefined; + if (lastLine.length <= MAX_LAST_LINE_CHARS) return lastLine; + return `${lastLine.slice(0, MAX_LAST_LINE_CHARS - 3)}...`; } function encodeCursor(payload: CursorPayload): string { - return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); } function decodeCursor(cursor: string): CursorPayload { try { - const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Partial + const parsed = JSON.parse( + Buffer.from(cursor, "base64url").toString("utf8"), + ) as Partial; if ( - typeof parsed.lastActivityAt !== 'number' || + typeof parsed.lastActivityAt !== "number" || !Number.isFinite(parsed.lastActivityAt) || - typeof parsed.terminalId !== 'string' || + typeof parsed.terminalId !== "string" || parsed.terminalId.length === 0 ) { - throw new Error('invalid') + throw new Error("invalid"); } return { lastActivityAt: parsed.lastActivityAt, terminalId: parsed.terminalId, - } + }; } catch { - throw new Error('Invalid terminal-directory cursor') + throw new Error("Invalid terminal-directory cursor"); } } -function compareTerminals(a: TerminalDirectoryItem, b: TerminalDirectoryItem): number { - const activityDiff = b.lastActivityAt - a.lastActivityAt - if (activityDiff !== 0) return activityDiff - return b.terminalId.localeCompare(a.terminalId) +function compareTerminals( + a: TerminalDirectoryItem, + b: TerminalDirectoryItem, +): number { + const activityDiff = b.lastActivityAt - a.lastActivityAt; + if (activityDiff !== 0) return activityDiff; + return b.terminalId.localeCompare(a.terminalId); } function throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) { - throw signal.reason instanceof Error ? signal.reason : new Error('Terminal view request aborted') + throw signal.reason instanceof Error + ? signal.reason + : new Error("Terminal view request aborted"); } } -export function createTerminalViewService(deps: TerminalViewServiceDeps): TerminalViewService { - const mirrors = new Map() +export function createTerminalViewService( + deps: TerminalViewServiceDeps, +): TerminalViewService { + const mirrors = new Map(); - const ensureMirror = (record: TerminalRecord, options: { seedSnapshot?: boolean } = {}): TerminalViewMirror => { - let mirror = mirrors.get(record.terminalId) + const ensureMirror = ( + record: TerminalRecord, + options: { seedSnapshot?: boolean } = {}, + ): TerminalViewMirror => { + let mirror = mirrors.get(record.terminalId); if (!mirror) { mirror = new TerminalViewMirror({ terminalId: record.terminalId, cols: record.cols, rows: record.rows, runtime: buildRuntime(record), - }) - mirrors.set(record.terminalId, mirror) + }); + mirrors.set(record.terminalId, mirror); } if (options.seedSnapshot !== false) { - mirror.seedSnapshot(record.buffer.snapshot()) + mirror.seedSnapshot(record.buffer.snapshot()); } - mirror.setLayout({ cols: record.cols, rows: record.rows }) - mirror.setRuntime(buildRuntime(record)) - return mirror - } + mirror.setLayout({ cols: record.cols, rows: record.rows }); + mirror.setRuntime(buildRuntime(record)); + return mirror; + }; - deps.registry.on?.('terminal.output.raw', (event: { terminalId?: string; data?: string }) => { - if (typeof event.terminalId !== 'string' || typeof event.data !== 'string') return - const record = deps.registry.get(event.terminalId) - if (!record) return - ensureMirror(record, { seedSnapshot: false }).applyOutput(event.data) - }) + deps.registry.on?.( + "terminal.output.raw", + (event: { terminalId?: string; data?: string }) => { + if ( + typeof event.terminalId !== "string" || + typeof event.data !== "string" + ) + return; + const record = deps.registry.get(event.terminalId); + if (!record) return; + ensureMirror(record, { seedSnapshot: false }).applyOutput(event.data); + }, + ); - deps.registry.on?.('terminal.exit', (event: { terminalId?: string }) => { - if (typeof event.terminalId !== 'string') return - const record = deps.registry.get(event.terminalId) - if (!record) return - ensureMirror(record).setRuntime(buildRuntime(record)) - }) + deps.registry.on?.("terminal.exit", (event: { terminalId?: string }) => { + if (typeof event.terminalId !== "string") return; + const record = deps.registry.get(event.terminalId); + if (!record) return; + ensureMirror(record).setRuntime(buildRuntime(record)); + }); async function listTerminalDirectory(): Promise { - const config = await deps.configStore.snapshot() - return deps.registry.list() - .filter((terminal) => !config.terminalOverrides?.[terminal.terminalId]?.deleted) + const config = await deps.configStore.snapshot(); + return deps.registry + .list() + .filter( + (terminal) => !config.terminalOverrides?.[terminal.terminalId]?.deleted, + ) .map((terminal) => { - const override = config.terminalOverrides?.[terminal.terminalId] + const override = config.terminalOverrides?.[terminal.terminalId]; + const lastLine = lastEmittedLine( + deps.registry.get(terminal.terminalId)?.buffer.snapshot() || "", + ); return { ...terminal, title: override?.titleOverride || terminal.title, description: override?.descriptionOverride || terminal.description, - } + lastLine, + last_line: lastLine, + }; }) - .sort(compareTerminals) + .sort(compareTerminals); } return { listTerminalDirectory, - async getTerminalDirectoryPage(query: TerminalDirectoryQuery & { signal?: AbortSignal }): Promise { - throwIfAborted(query.signal) - const limit = Math.min(query.limit ?? MAX_DIRECTORY_PAGE_ITEMS, MAX_DIRECTORY_PAGE_ITEMS) - const cursor = query.cursor ? decodeCursor(query.cursor) : null - const items = await listTerminalDirectory() - throwIfAborted(query.signal) - const revision = items.reduce((maxRevision, item) => Math.max(maxRevision, item.lastActivityAt), 0) + async getTerminalDirectoryPage( + query: TerminalDirectoryQuery & { signal?: AbortSignal }, + ): Promise { + throwIfAborted(query.signal); + const limit = Math.min( + query.limit ?? MAX_DIRECTORY_PAGE_ITEMS, + MAX_DIRECTORY_PAGE_ITEMS, + ); + const cursor = query.cursor ? decodeCursor(query.cursor) : null; + const items = await listTerminalDirectory(); + throwIfAborted(query.signal); + const revision = items.reduce( + (maxRevision, item) => Math.max(maxRevision, item.lastActivityAt), + 0, + ); const filtered = cursor - ? items.filter((item) => ( - item.lastActivityAt < cursor.lastActivityAt || - (item.lastActivityAt === cursor.lastActivityAt && item.terminalId.localeCompare(cursor.terminalId) < 0) - )) - : items + ? items.filter( + (item) => + item.lastActivityAt < cursor.lastActivityAt || + (item.lastActivityAt === cursor.lastActivityAt && + item.terminalId.localeCompare(cursor.terminalId) < 0), + ) + : items; - const pageItems = filtered.slice(0, limit) - const tail = pageItems.at(-1) + const pageItems = filtered.slice(0, limit); + const tail = pageItems.at(-1); return { items: pageItems, - nextCursor: filtered.length > limit && tail - ? encodeCursor({ lastActivityAt: tail.lastActivityAt, terminalId: tail.terminalId }) - : null, + nextCursor: + filtered.length > limit && tail + ? encodeCursor({ + lastActivityAt: tail.lastActivityAt, + terminalId: tail.terminalId, + }) + : null, revision, - } + }; }, async getViewportSnapshot({ terminalId, signal }) { - throwIfAborted(signal) - const record = deps.registry.get(terminalId) - if (!record) return null - throwIfAborted(signal) - return ensureMirror(record).getViewportSnapshot() + throwIfAborted(signal); + const record = deps.registry.get(terminalId); + if (!record) return null; + throwIfAborted(signal); + return ensureMirror(record).getViewportSnapshot(); }, - async getScrollbackPage({ terminalId, cursor, limit, signal }): Promise { - throwIfAborted(signal) - const record = deps.registry.get(terminalId) - if (!record) return null - throwIfAborted(signal) + async getScrollbackPage({ + terminalId, + cursor, + limit, + signal, + }): Promise { + throwIfAborted(signal); + const record = deps.registry.get(terminalId); + if (!record) return null; + throwIfAborted(signal); return ensureMirror(record).getScrollbackPage({ cursor: cursor !== undefined ? Number(cursor) : undefined, limit, - }) + }); }, - async searchTerminal({ terminalId, query, cursor, limit, signal }): Promise { - throwIfAborted(signal) - const record = deps.registry.get(terminalId) - if (!record) return null - throwIfAborted(signal) + async searchTerminal({ + terminalId, + query, + cursor, + limit, + signal, + }): Promise { + throwIfAborted(signal); + const record = deps.registry.get(terminalId); + if (!record) return null; + throwIfAborted(signal); return ensureMirror(record).search(query, { cursor: cursor !== undefined ? Number(cursor) : undefined, limit, - }) + }); }, - } + }; } diff --git a/server/terminal-view/types.ts b/server/terminal-view/types.ts index 77b3c0611..81e020753 100644 --- a/server/terminal-view/types.ts +++ b/server/terminal-view/types.ts @@ -1,67 +1,85 @@ -import type { TerminalMode } from '../terminal-registry.js' -import type { TerminalDirectoryQuery } from '../../shared/read-models.js' +import type { TerminalMode } from "../terminal-registry.js"; +import type { TerminalDirectoryQuery } from "../../shared/read-models.js"; export type TerminalDirectoryItem = { - terminalId: string - title: string - description?: string - mode: TerminalMode - resumeSessionId?: string - createdAt: number - lastActivityAt: number - status: 'running' | 'exited' - hasClients: boolean - cwd?: string -} + terminalId: string; + title: string; + description?: string; + mode: TerminalMode; + resumeSessionId?: string; + createdAt: number; + lastActivityAt: number; + status: "running" | "exited"; + hasClients: boolean; + cwd?: string; + lastLine?: string; + last_line?: string; +}; export type TerminalDirectoryPage = { - items: TerminalDirectoryItem[] - nextCursor: string | null - revision: number -} + items: TerminalDirectoryItem[]; + nextCursor: string | null; + revision: number; +}; export type TerminalViewportRuntime = { - title: string - status: 'running' | 'detached' | 'exited' - cwd?: string - pid?: number -} + title: string; + status: "running" | "detached" | "exited"; + cwd?: string; + pid?: number; +}; export type TerminalViewportSnapshot = { - terminalId: string - revision: number - serialized: string - cols: number - rows: number - tailSeq: number - runtime: TerminalViewportRuntime -} + terminalId: string; + revision: number; + serialized: string; + cols: number; + rows: number; + tailSeq: number; + runtime: TerminalViewportRuntime; +}; export type TerminalScrollbackItem = { - line: number - text: string -} + line: number; + text: string; +}; export type TerminalScrollbackPage = { - items: TerminalScrollbackItem[] - nextCursor: string | null -} + items: TerminalScrollbackItem[]; + nextCursor: string | null; +}; export type TerminalSearchMatch = { - line: number - column: number - text: string -} + line: number; + column: number; + text: string; +}; export type TerminalSearchPage = { - matches: TerminalSearchMatch[] - nextCursor: string | null -} + matches: TerminalSearchMatch[]; + nextCursor: string | null; +}; export type TerminalViewService = { - listTerminalDirectory: () => Promise - getTerminalDirectoryPage: (query: TerminalDirectoryQuery & { signal?: AbortSignal }) => Promise - getViewportSnapshot: (input: { terminalId: string; signal?: AbortSignal }) => Promise - getScrollbackPage: (input: { terminalId: string; cursor?: string; limit?: number; signal?: AbortSignal }) => Promise - searchTerminal: (input: { terminalId: string; query: string; cursor?: string; limit?: number; signal?: AbortSignal }) => Promise -} + listTerminalDirectory: () => Promise; + getTerminalDirectoryPage: ( + query: TerminalDirectoryQuery & { signal?: AbortSignal }, + ) => Promise; + getViewportSnapshot: (input: { + terminalId: string; + signal?: AbortSignal; + }) => Promise; + getScrollbackPage: (input: { + terminalId: string; + cursor?: string; + limit?: number; + signal?: AbortSignal; + }) => Promise; + searchTerminal: (input: { + terminalId: string; + query: string; + cursor?: string; + limit?: number; + signal?: AbortSignal; + }) => Promise; +}; diff --git a/server/ws-handler.ts b/server/ws-handler.ts index d6f1be6df..86e09d63a 100644 --- a/server/ws-handler.ts +++ b/server/ws-handler.ts @@ -335,6 +335,10 @@ type PendingScreenshot = { } type ScreenshotErrorCode = 'NO_SCREENSHOT_CLIENT' | 'SCREENSHOT_TIMEOUT' | 'SCREENSHOT_CONNECTION_CLOSED' +type UiCommand = { command: string; payload?: any } +type PendingUiCommand = { command: UiCommand; expiresAt: number } +const UI_COMMAND_REPLAY_TTL_MS = 15_000 +const UI_COMMAND_RECENT_CONNECTION_MS = 3_000 function createScreenshotError(code: ScreenshotErrorCode, message: string): Error & { code: ScreenshotErrorCode } { const err = new Error(message) as Error & { code: ScreenshotErrorCode } @@ -366,6 +370,7 @@ export class WsHandler { private terminalCreateLocks = new Map>() private createdTerminalByRequestId = new Map() private screenshotRequests = new Map() + private pendingUiCommands: PendingUiCommand[] = [] private sessionsRevision = 0 private terminalsRevision = 0 @@ -1359,6 +1364,7 @@ export class WsHandler { bootId: this.bootId, }) this.scheduleHandshakeSnapshot(ws, state) + this.flushPendingUiCommands(ws) return } @@ -2495,8 +2501,75 @@ export class WsHandler { } } - broadcastUiCommand(command: { command: string; payload?: any }) { - this.broadcast({ type: 'ui.command', ...command }) + private authenticatedUiConnections(): LiveWebSocket[] { + return [...this.connections].filter((ws) => { + if (ws.readyState !== WebSocket.OPEN) return false + return !!this.clientStates.get(ws)?.authenticated + }) + } + + private uiCommandKey(command: UiCommand): string { + return JSON.stringify(command) + } + + private queueUiCommand(command: UiCommand, now = Date.now()): void { + const key = this.uiCommandKey(command) + this.pendingUiCommands = this.pendingUiCommands.filter((item) => ( + item.expiresAt > now && this.uiCommandKey(item.command) !== key + )) + this.pendingUiCommands.push({ command, expiresAt: now + UI_COMMAND_REPLAY_TTL_MS }) + } + + private flushPendingUiCommands(target?: LiveWebSocket): void { + const now = Date.now() + const pending = this.pendingUiCommands.filter((item) => item.expiresAt > now) + this.pendingUiCommands = [] + if (!pending.length) return + + const targets = target ? [target] : this.authenticatedUiConnections() + if (!targets.length) { + this.pendingUiCommands.push(...pending) + return + } + + for (const item of pending) { + for (const ws of targets) { + if (ws.readyState === WebSocket.OPEN) { + this.send(ws, { type: 'ui.command', ...item.command }) + } + } + } + } + + broadcastUiCommand(command: UiCommand) { + const targets = this.authenticatedUiConnections() + if (!targets.length) { + this.queueUiCommand(command) + return + } + for (const ws of targets) { + this.send(ws, { type: 'ui.command', ...command }) + } + } + + broadcastUiCommandWithReplay(command: UiCommand) { + const now = Date.now() + const targets = this.authenticatedUiConnections() + if (!targets.length) { + this.queueUiCommand(command, now) + return + } + + const hasRecentTarget = targets.some((ws) => ( + typeof ws.connectedAt === 'number' && now - ws.connectedAt <= UI_COMMAND_RECENT_CONNECTION_MS + )) + if (!hasRecentTarget) { + this.queueUiCommand(command, now) + } + + for (const ws of targets) { + this.send(ws, { type: 'ui.command', ...command }) + } } broadcastSessionsChanged(revision: number): void { diff --git a/src/lib/ui-commands.ts b/src/lib/ui-commands.ts index b67352507..43d65207a 100644 --- a/src/lib/ui-commands.ts +++ b/src/lib/ui-commands.ts @@ -121,6 +121,7 @@ export function handleUiCommand(msg: any, runtimeOrDispatch: UiCommandRuntime | case 'pane.close': return dispatch(closePaneWithCleanup({ tabId: msg.payload.tabId, paneId: msg.payload.paneId })) case 'pane.select': + dispatch(setActiveTab(msg.payload.tabId)) return dispatch(setActivePane({ tabId: msg.payload.tabId, paneId: msg.payload.paneId })) case 'pane.rename': return dispatch(applyPaneRename({ diff --git a/test/server/agent-tabs-write.test.ts b/test/server/agent-tabs-write.test.ts index 77095448c..26d1d4fdb 100644 --- a/test/server/agent-tabs-write.test.ts +++ b/test/server/agent-tabs-write.test.ts @@ -1,37 +1,44 @@ -import { describe, it, expect, vi } from 'vitest' -import express from 'express' -import request from 'supertest' -import { createAgentApiRouter } from '../../server/agent-api/router' +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import { createAgentApiRouter } from "../../server/agent-api/router"; class FakeRegistry { - create = vi.fn(() => ({ terminalId: 'term_1' })) + create = vi.fn(() => ({ terminalId: "term_1" })); } -describe('tab endpoints', () => { - it('creates a new tab and returns ids', async () => { - const app = express() - app.use(express.json()) +describe("tab endpoints", () => { + it("creates a new tab and returns ids", async () => { + const app = express(); + app.use(express.json()); const layoutStore = { - createTab: () => ({ tabId: 'tab_1', paneId: 'pane_1' }), + createTab: () => ({ tabId: "tab_1", paneId: "pane_1" }), attachPaneContent: () => {}, selectTab: () => ({}), renameTab: () => ({}), closeTab: () => ({}), hasTab: () => true, - selectNextTab: () => ({ tabId: 'tab_1' }), - selectPrevTab: () => ({ tabId: 'tab_1' }), - } - app.use('/api', createAgentApiRouter({ layoutStore, registry: new FakeRegistry(), wsHandler: { broadcastUiCommand: () => {} } })) - const res = await request(app).post('/api/tabs').send({ name: 'alpha' }) - expect(res.body.status).toBe('ok') - expect(res.body.data.tabId).toBe('tab_1') - }) - - it('creates browser tabs without spawning a terminal', async () => { - const app = express() - app.use(express.json()) - const registry = new FakeRegistry() - const createTab = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) + selectNextTab: () => ({ tabId: "tab_1" }), + selectPrevTab: () => ({ tabId: "tab_1" }), + }; + app.use( + "/api", + createAgentApiRouter({ + layoutStore, + registry: new FakeRegistry(), + wsHandler: { broadcastUiCommand: () => {} }, + }), + ); + const res = await request(app).post("/api/tabs").send({ name: "alpha" }); + expect(res.body.status).toBe("ok"); + expect(res.body.data.tabId).toBe("tab_1"); + }); + + it("creates browser tabs without spawning a terminal", async () => { + const app = express(); + app.use(express.json()); + const registry = new FakeRegistry(); + const createTab = vi.fn(() => ({ tabId: "tab_1", paneId: "pane_1" })); const layoutStore = { createTab, attachPaneContent: vi.fn(), @@ -39,100 +46,243 @@ describe('tab endpoints', () => { renameTab: () => ({}), closeTab: () => ({}), hasTab: () => true, - selectNextTab: () => ({ tabId: 'tab_1' }), - selectPrevTab: () => ({ tabId: 'tab_1' }), - } - app.use('/api', createAgentApiRouter({ layoutStore, registry, wsHandler: { broadcastUiCommand: () => {} } })) - const res = await request(app).post('/api/tabs').send({ name: 'web', browser: 'https://example.com' }) - - expect(res.body.status).toBe('ok') - expect(createTab).toHaveBeenCalled() - expect(registry.create).not.toHaveBeenCalled() - expect(layoutStore.attachPaneContent).toHaveBeenCalled() - }) - - it('allocates and passes an OpenCode control endpoint when creating an opencode tab', async () => { - const app = express() - app.use(express.json()) - const registry = new FakeRegistry() + selectNextTab: () => ({ tabId: "tab_1" }), + selectPrevTab: () => ({ tabId: "tab_1" }), + }; + app.use( + "/api", + createAgentApiRouter({ + layoutStore, + registry, + wsHandler: { broadcastUiCommand: () => {} }, + }), + ); + const res = await request(app) + .post("/api/tabs") + .send({ name: "web", browser: "https://example.com" }); + + expect(res.body.status).toBe("ok"); + expect(createTab).toHaveBeenCalled(); + expect(registry.create).not.toHaveBeenCalled(); + expect(layoutStore.attachPaneContent).toHaveBeenCalled(); + }); + + it("allocates and passes an OpenCode control endpoint when creating an opencode tab", async () => { + const app = express(); + app.use(express.json()); + const registry = new FakeRegistry(); const layoutStore = { - createTab: () => ({ tabId: 'tab_1', paneId: 'pane_1' }), + createTab: () => ({ tabId: "tab_1", paneId: "pane_1" }), attachPaneContent: () => {}, selectTab: () => ({}), renameTab: () => ({}), closeTab: () => ({}), hasTab: () => true, - selectNextTab: () => ({ tabId: 'tab_1' }), - selectPrevTab: () => ({ tabId: 'tab_1' }), - } - app.use('/api', createAgentApiRouter({ layoutStore, registry, wsHandler: { broadcastUiCommand: () => {} } })) - - const res = await request(app).post('/api/tabs').send({ mode: 'opencode', name: 'OpenCode' }) - - expect(res.body.status).toBe('ok') - expect(registry.create).toHaveBeenCalledWith(expect.objectContaining({ - mode: 'opencode', - providerSettings: expect.objectContaining({ - opencodeServer: { - hostname: '127.0.0.1', - port: expect.any(Number), + selectNextTab: () => ({ tabId: "tab_1" }), + selectPrevTab: () => ({ tabId: "tab_1" }), + }; + app.use( + "/api", + createAgentApiRouter({ + layoutStore, + registry, + wsHandler: { broadcastUiCommand: () => {} }, + }), + ); + + const res = await request(app) + .post("/api/tabs") + .send({ mode: "opencode", name: "OpenCode" }); + + expect(res.body.status).toBe("ok"); + expect(registry.create).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "opencode", + providerSettings: expect.objectContaining({ + opencodeServer: { + hostname: "127.0.0.1", + port: expect.any(Number), + }, + }), + }), + ); + }); + + it("opens an existing terminal in a new tab when it is detached", async () => { + const app = express(); + app.use(express.json()); + const createTab = vi.fn(() => ({ tabId: "tab_1", paneId: "pane_1" })); + const attachPaneContent = vi.fn(); + const broadcastUiCommand = vi.fn(); + const broadcastUiCommandWithReplay = vi.fn(); + app.use( + "/api", + createAgentApiRouter({ + layoutStore: { + createTab, + attachPaneContent, + findPaneByTerminalId: () => undefined, + }, + registry: { + get: () => ({ + terminalId: "term_1", + title: "Shell", + mode: "shell", + status: "running", + cwd: "/workspace/project", + }), }, + wsHandler: { broadcastUiCommand, broadcastUiCommandWithReplay }, + }), + ); + + const res = await request(app) + .post("/api/terminals/term_1/open") + .send({ name: "Work shell" }); + + expect(res.status).toBe(200); + expect(createTab).toHaveBeenCalledWith({ title: "Work shell" }); + expect(attachPaneContent).toHaveBeenCalledWith( + "tab_1", + "pane_1", + expect.objectContaining({ + kind: "terminal", + terminalId: "term_1", + mode: "shell", + }), + ); + expect(broadcastUiCommandWithReplay).toHaveBeenCalledWith( + expect.objectContaining({ + command: "tab.create", + payload: expect.objectContaining({ + id: "tab_1", + paneId: "pane_1", + terminalId: "term_1", + }), }), - })) - }) - - it('rejects blank tab rename payloads', async () => { - const app = express() - app.use(express.json()) - const renameTab = vi.fn() - app.use('/api', createAgentApiRouter({ - layoutStore: { renameTab }, - registry: {} as any, - wsHandler: { broadcastUiCommand: vi.fn() }, - })) - - const res = await request(app).patch('/api/tabs/tab_1').send({ name: ' ' }) - - expect(res.status).toBe(400) - expect(renameTab).not.toHaveBeenCalled() - }) - - it('trims tab rename payloads before writing and broadcasts only successful renames', async () => { - const app = express() - app.use(express.json()) - const renameTab = vi.fn(() => ({ tabId: 'tab_1' })) - const broadcastUiCommand = vi.fn() - app.use('/api', createAgentApiRouter({ - layoutStore: { renameTab }, - registry: {} as any, - wsHandler: { broadcastUiCommand }, - })) - - const res = await request(app).patch('/api/tabs/tab_1').send({ name: ' Release prep ' }) - - expect(res.status).toBe(200) - expect(renameTab).toHaveBeenCalledWith('tab_1', 'Release prep') + ); + expect(broadcastUiCommand).not.toHaveBeenCalled(); + expect(res.body.data).toMatchObject({ + tabId: "tab_1", + paneId: "pane_1", + terminalId: "term_1", + reused: false, + }); + }); + + it("selects the existing pane when opening an already-attached terminal", async () => { + const app = express(); + app.use(express.json()); + const selectPane = vi.fn(() => ({ tabId: "tab_1", paneId: "pane_1" })); + const broadcastUiCommand = vi.fn(); + const broadcastUiCommandWithReplay = vi.fn(); + app.use( + "/api", + createAgentApiRouter({ + layoutStore: { + findPaneByTerminalId: () => ({ tabId: "tab_1", paneId: "pane_1" }), + selectPane, + }, + registry: { + get: () => ({ + terminalId: "term_1", + title: "Shell", + mode: "shell", + status: "running", + }), + }, + wsHandler: { broadcastUiCommand, broadcastUiCommandWithReplay }, + }), + ); + + const res = await request(app).post("/api/terminals/term_1/open").send({}); + + expect(res.status).toBe(200); + expect(selectPane).toHaveBeenCalledWith("tab_1", "pane_1"); + expect(broadcastUiCommandWithReplay).toHaveBeenCalledWith({ + command: "tab.select", + payload: { id: "tab_1" }, + }); + expect(broadcastUiCommandWithReplay).toHaveBeenCalledWith({ + command: "pane.select", + payload: { tabId: "tab_1", paneId: "pane_1" }, + }); + expect(broadcastUiCommand).not.toHaveBeenCalled(); + expect(res.body.data).toMatchObject({ + tabId: "tab_1", + paneId: "pane_1", + terminalId: "term_1", + reused: true, + }); + }); + + it("rejects blank tab rename payloads", async () => { + const app = express(); + app.use(express.json()); + const renameTab = vi.fn(); + app.use( + "/api", + createAgentApiRouter({ + layoutStore: { renameTab }, + registry: {} as any, + wsHandler: { broadcastUiCommand: vi.fn() }, + }), + ); + + const res = await request(app) + .patch("/api/tabs/tab_1") + .send({ name: " " }); + + expect(res.status).toBe(400); + expect(renameTab).not.toHaveBeenCalled(); + }); + + it("trims tab rename payloads before writing and broadcasts only successful renames", async () => { + const app = express(); + app.use(express.json()); + const renameTab = vi.fn(() => ({ tabId: "tab_1" })); + const broadcastUiCommand = vi.fn(); + app.use( + "/api", + createAgentApiRouter({ + layoutStore: { renameTab }, + registry: {} as any, + wsHandler: { broadcastUiCommand }, + }), + ); + + const res = await request(app) + .patch("/api/tabs/tab_1") + .send({ name: " Release prep " }); + + expect(res.status).toBe(200); + expect(renameTab).toHaveBeenCalledWith("tab_1", "Release prep"); expect(broadcastUiCommand).toHaveBeenCalledWith({ - command: 'tab.rename', - payload: { id: 'tab_1', title: 'Release prep' }, - }) - }) - - it('does not broadcast tab.rename when the tab does not exist', async () => { - const app = express() - app.use(express.json()) - const renameTab = vi.fn(() => ({ message: 'tab not found' })) - const broadcastUiCommand = vi.fn() - app.use('/api', createAgentApiRouter({ - layoutStore: { renameTab }, - registry: {} as any, - wsHandler: { broadcastUiCommand }, - })) - - const res = await request(app).patch('/api/tabs/missing').send({ name: 'Ghost' }) - - expect(res.status).toBe(200) - expect(renameTab).toHaveBeenCalledWith('missing', 'Ghost') - expect(broadcastUiCommand).not.toHaveBeenCalled() - }) -}) + command: "tab.rename", + payload: { id: "tab_1", title: "Release prep" }, + }); + }); + + it("does not broadcast tab.rename when the tab does not exist", async () => { + const app = express(); + app.use(express.json()); + const renameTab = vi.fn(() => ({ message: "tab not found" })); + const broadcastUiCommand = vi.fn(); + app.use( + "/api", + createAgentApiRouter({ + layoutStore: { renameTab }, + registry: {} as any, + wsHandler: { broadcastUiCommand }, + }), + ); + + const res = await request(app) + .patch("/api/tabs/missing") + .send({ name: "Ghost" }); + + expect(res.status).toBe(200); + expect(renameTab).toHaveBeenCalledWith("missing", "Ghost"); + expect(broadcastUiCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/test/server/terminals-api.test.ts b/test/server/terminals-api.test.ts index b233ee0f6..9494181e8 100644 --- a/test/server/terminals-api.test.ts +++ b/test/server/terminals-api.test.ts @@ -1,13 +1,21 @@ // @vitest-environment node -import { EventEmitter } from 'node:events' -import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest' -import express, { type Express } from 'express' -import request from 'supertest' - -const SLOW_TEST_TIMEOUT_MS = 20000 +import { EventEmitter } from "node:events"; +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + vi, +} from "vitest"; +import express, { type Express } from "express"; +import request from "supertest"; + +const SLOW_TEST_TIMEOUT_MS = 20000; // Mock the config-store module before importing auth -vi.mock('../../server/config-store', () => ({ +vi.mock("../../server/config-store", () => ({ configStore: { snapshot: vi.fn().mockResolvedValue({ version: 1, @@ -19,10 +27,10 @@ vi.mock('../../server/config-store', () => ({ patchTerminalOverride: vi.fn().mockResolvedValue({}), deleteTerminal: vi.fn().mockResolvedValue(undefined), }, -})) +})); // Mock logger to avoid unnecessary output -vi.mock('../../server/logger', () => { +vi.mock("../../server/logger", () => { const logger = { info: vi.fn(), warn: vi.fn(), @@ -31,108 +39,112 @@ vi.mock('../../server/logger', () => { trace: vi.fn(), fatal: vi.fn(), child: vi.fn(), - } - logger.child.mockReturnValue(logger) - return { logger } -}) + }; + logger.child.mockReturnValue(logger); + return { logger }; +}); // Import after mocks are set up -import { httpAuthMiddleware } from '../../server/auth' -import { configStore } from '../../server/config-store' -import { createTerminalsRouter } from '../../server/terminals-router' +import { httpAuthMiddleware } from "../../server/auth"; +import { configStore } from "../../server/config-store"; +import { createTerminalsRouter } from "../../server/terminals-router"; class FakeBuffer { - private chunks: string[] = [] + private chunks: string[] = []; append(chunk: string) { - this.chunks.push(chunk) + this.chunks.push(chunk); } snapshot() { - return this.chunks.join('') + return this.chunks.join(""); } } /** Fake registry that returns controlled terminal data without spawning real PTYs */ class FakeRegistry extends EventEmitter { private terminals: Array<{ - terminalId: string - title: string - description?: string - mode: 'shell' | 'claude' | 'codex' - createdAt: number - lastActivityAt: number - status: 'running' | 'exited' - hasClients: boolean - cwd?: string - cols: number - rows: number - clients: Set - pty: { pid: number } - buffer: FakeBuffer - }> = [] - - addTerminal(overrides: Partial<{ - terminalId: string - title: string - description?: string - mode: 'shell' | 'claude' | 'codex' - status: 'running' | 'exited' - cwd?: string - cols: number - rows: number - pid: number - }> = {}) { - const buffer = new FakeBuffer() + terminalId: string; + title: string; + description?: string; + mode: "shell" | "claude" | "codex"; + createdAt: number; + lastActivityAt: number; + status: "running" | "exited"; + hasClients: boolean; + cwd?: string; + cols: number; + rows: number; + clients: Set; + pty: { pid: number }; + buffer: FakeBuffer; + }> = []; + + addTerminal( + overrides: Partial<{ + terminalId: string; + title: string; + description?: string; + mode: "shell" | "claude" | "codex"; + status: "running" | "exited"; + cwd?: string; + cols: number; + rows: number; + pid: number; + }> = {}, + ) { + const buffer = new FakeBuffer(); const terminal = { - terminalId: overrides.terminalId || `term_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`, - title: overrides.title || 'Shell', + terminalId: + overrides.terminalId || + `term_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`, + title: overrides.title || "Shell", description: overrides.description, - mode: overrides.mode || 'shell', + mode: overrides.mode || "shell", createdAt: Date.now(), lastActivityAt: Date.now(), - status: overrides.status || 'running', + status: overrides.status || "running", hasClients: false, - cwd: overrides.cwd || '/home/user', + cwd: overrides.cwd || "/home/user", cols: overrides.cols || 120, rows: overrides.rows || 30, clients: new Set(), pty: { pid: overrides.pid || 4242 }, buffer, - } - this.terminals.push(terminal) - return terminal + }; + this.terminals.push(terminal); + return terminal; } clear() { - this.terminals = [] + this.terminals = []; } list() { - return [...this.terminals] + return [...this.terminals]; } get(terminalId: string) { - return this.terminals.find(t => t.terminalId === terminalId) + return this.terminals.find((t) => t.terminalId === terminalId); } updateTitle(terminalId: string, title: string) { - const terminal = this.terminals.find(t => t.terminalId === terminalId) - if (terminal) terminal.title = title - return !!terminal + const terminal = this.terminals.find((t) => t.terminalId === terminalId); + if (terminal) terminal.title = title; + return !!terminal; } updateDescription(terminalId: string, description: string | undefined) { - const terminal = this.terminals.find(t => t.terminalId === terminalId) - if (terminal) terminal.description = description - return !!terminal + const terminal = this.terminals.find((t) => t.terminalId === terminalId); + if (terminal) terminal.description = description; + return !!terminal; } emitOutput(terminalId: string, data: string) { - const terminal = this.terminals.find(t => t.terminalId === terminalId) - terminal?.buffer.append(data) - if (terminal) terminal.lastActivityAt += 1 - this.emit('terminal.output.raw', { terminalId, data, at: Date.now() }) + const terminal = this.terminals.find((t) => t.terminalId === terminalId); + terminal?.buffer.append(data); + if (terminal) terminal.lastActivityAt += 1; + this.emit("terminal.output.raw", { terminalId, data, at: Date.now() }); } } @@ -140,45 +152,48 @@ class FakeRegistry extends EventEmitter { function createTestApp( registry: FakeRegistry, wsHandler: { - broadcast: ReturnType - broadcastTerminalsChanged?: ReturnType + broadcast: ReturnType; + broadcastTerminalsChanged?: ReturnType; }, ): Express { - const app = express() - app.disable('x-powered-by') - app.use(express.json()) - app.use('/api', httpAuthMiddleware) - - app.use('/api/terminals', createTerminalsRouter({ - configStore, - registry, - wsHandler, - })) - - return app + const app = express(); + app.disable("x-powered-by"); + app.use(express.json()); + app.use("/api", httpAuthMiddleware); + + app.use( + "/api/terminals", + createTerminalsRouter({ + configStore, + registry, + wsHandler, + }), + ); + + return app; } -describe('Terminals API', () => { - const AUTH_TOKEN = 'test-auth-token-16chars' - let app: Express - let registry: FakeRegistry +describe("Terminals API", () => { + const AUTH_TOKEN = "test-auth-token-16chars"; + let app: Express; + let registry: FakeRegistry; let wsHandler: { - broadcast: ReturnType - broadcastTerminalsChanged: ReturnType - } + broadcast: ReturnType; + broadcastTerminalsChanged: ReturnType; + }; beforeAll(() => { - process.env.AUTH_TOKEN = AUTH_TOKEN - }) + process.env.AUTH_TOKEN = AUTH_TOKEN; + }); beforeEach(() => { - vi.clearAllMocks() - registry = new FakeRegistry() + vi.clearAllMocks(); + registry = new FakeRegistry(); wsHandler = { broadcast: vi.fn(), broadcastTerminalsChanged: vi.fn(), - } - app = createTestApp(registry, wsHandler) + }; + app = createTestApp(registry, wsHandler); // Reset config store mock to default behavior vi.mocked(configStore.snapshot).mockResolvedValue({ @@ -187,435 +202,485 @@ describe('Terminals API', () => { sessionOverrides: {}, terminalOverrides: {}, projectColors: {}, - }) - }) + }); + }); afterAll(() => { - delete process.env.AUTH_TOKEN - }) + delete process.env.AUTH_TOKEN; + }); - describe('GET /api/terminals', () => { - it('returns 401 when no auth token provided', async () => { - const response = await request(app) - .get('/api/terminals') - .expect(401) + describe("GET /api/terminals", () => { + it("returns 401 when no auth token provided", async () => { + const response = await request(app).get("/api/terminals").expect(401); - expect(response.body).toEqual({ error: 'Unauthorized' }) - }) + expect(response.body).toEqual({ error: "Unauthorized" }); + }); - it('returns 401 when invalid auth token provided', async () => { + it("returns 401 when invalid auth token provided", async () => { const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', 'wrong-token') - .expect(401) + .get("/api/terminals") + .set("x-auth-token", "wrong-token") + .expect(401); - expect(response.body).toEqual({ error: 'Unauthorized' }) - }) + expect(response.body).toEqual({ error: "Unauthorized" }); + }); - it('returns empty array when no terminals exist', async () => { + it("returns empty array when no terminals exist", async () => { const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(response.body).toEqual([]) - }) + expect(response.body).toEqual([]); + }); - it('lists all terminals with required fields', async () => { + it("lists all terminals with required fields", async () => { registry.addTerminal({ - terminalId: 'term_123', - title: 'Shell', - mode: 'shell', - status: 'running', - cwd: '/home/user/project', - }) + terminalId: "term_123", + title: "Shell", + mode: "shell", + status: "running", + cwd: "/home/user/project", + }); registry.addTerminal({ - terminalId: 'term_456', - title: 'Claude CLI', - mode: 'claude', - status: 'running', - }) + terminalId: "term_456", + title: "Claude CLI", + mode: "claude", + status: "running", + }); const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(response.body).toHaveLength(2) + expect(response.body).toHaveLength(2); // Verify first terminal has all required fields - const firstTerminal = response.body.find((t: any) => t.terminalId === 'term_123') - expect(firstTerminal).toBeDefined() - expect(firstTerminal.terminalId).toBe('term_123') - expect(firstTerminal.title).toBe('Shell') - expect(firstTerminal.mode).toBe('shell') - expect(firstTerminal.status).toBe('running') - expect(firstTerminal).toHaveProperty('createdAt') - expect(firstTerminal).toHaveProperty('lastActivityAt') + const firstTerminal = response.body.find( + (t: any) => t.terminalId === "term_123", + ); + expect(firstTerminal).toBeDefined(); + expect(firstTerminal.terminalId).toBe("term_123"); + expect(firstTerminal.title).toBe("Shell"); + expect(firstTerminal.mode).toBe("shell"); + expect(firstTerminal.status).toBe("running"); + expect(firstTerminal).toHaveProperty("createdAt"); + expect(firstTerminal).toHaveProperty("lastActivityAt"); // Verify second terminal - const secondTerminal = response.body.find((t: any) => t.terminalId === 'term_456') - expect(secondTerminal).toBeDefined() - expect(secondTerminal.terminalId).toBe('term_456') - expect(secondTerminal.title).toBe('Claude CLI') - expect(secondTerminal.mode).toBe('claude') - }) - - it('applies title override from config', async () => { + const secondTerminal = response.body.find( + (t: any) => t.terminalId === "term_456", + ); + expect(secondTerminal).toBeDefined(); + expect(secondTerminal.terminalId).toBe("term_456"); + expect(secondTerminal.title).toBe("Claude CLI"); + expect(secondTerminal.mode).toBe("claude"); + }); + + it("includes the last emitted terminal line", async () => { + const terminal = registry.addTerminal({ + terminalId: "term_last_line", + title: "Repo push", + mode: "shell", + }); + terminal.buffer.append( + "first line\nsecond line\nvagrant@gf-software-factory-vm:/workspace/project$ ", + ); + + const response = await request(app) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); + + const item = response.body.find( + (t: any) => t.terminalId === "term_last_line", + ); + expect(item.lastLine).toBe("second line"); + expect(item.last_line).toBe("second line"); + }); + + it("applies title override from config", async () => { registry.addTerminal({ - terminalId: 'term_with_override', - title: 'Original Title', - mode: 'shell', - }) + terminalId: "term_with_override", + title: "Original Title", + mode: "shell", + }); vi.mocked(configStore.snapshot).mockResolvedValue({ version: 1, settings: {} as any, sessionOverrides: {}, terminalOverrides: { - 'term_with_override': { - titleOverride: 'Custom Title', + term_with_override: { + titleOverride: "Custom Title", }, }, projectColors: {}, - }) + }); const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(response.body[0].title).toBe('Custom Title') - }) + expect(response.body[0].title).toBe("Custom Title"); + }); - it('applies description override from config', async () => { + it("applies description override from config", async () => { registry.addTerminal({ - terminalId: 'term_with_desc', - title: 'Shell', - description: 'Original description', - mode: 'shell', - }) + terminalId: "term_with_desc", + title: "Shell", + description: "Original description", + mode: "shell", + }); vi.mocked(configStore.snapshot).mockResolvedValue({ version: 1, settings: {} as any, sessionOverrides: {}, terminalOverrides: { - 'term_with_desc': { - descriptionOverride: 'Custom description', + term_with_desc: { + descriptionOverride: "Custom description", }, }, projectColors: {}, - }) + }); const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(response.body[0].description).toBe('Custom description') - }) + expect(response.body[0].description).toBe("Custom description"); + }); - it('filters out deleted terminals', async () => { + it("filters out deleted terminals", async () => { registry.addTerminal({ - terminalId: 'visible_term', - title: 'Visible', - mode: 'shell', - }) + terminalId: "visible_term", + title: "Visible", + mode: "shell", + }); registry.addTerminal({ - terminalId: 'deleted_term', - title: 'Deleted', - mode: 'shell', - }) + terminalId: "deleted_term", + title: "Deleted", + mode: "shell", + }); vi.mocked(configStore.snapshot).mockResolvedValue({ version: 1, settings: {} as any, sessionOverrides: {}, terminalOverrides: { - 'deleted_term': { + deleted_term: { deleted: true, }, }, projectColors: {}, - }) + }); const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(response.body).toHaveLength(1) - expect(response.body[0].terminalId).toBe('visible_term') - }) + expect(response.body).toHaveLength(1); + expect(response.body[0].terminalId).toBe("visible_term"); + }); - it('includes all terminal modes: shell, claude, codex', async () => { - registry.addTerminal({ terminalId: 'shell_term', title: 'Shell', mode: 'shell' }) - registry.addTerminal({ terminalId: 'claude_term', title: 'Claude CLI', mode: 'claude' }) - registry.addTerminal({ terminalId: 'codex_term', title: 'Codex CLI', mode: 'codex' }) + it("includes all terminal modes: shell, claude, codex", async () => { + registry.addTerminal({ + terminalId: "shell_term", + title: "Shell", + mode: "shell", + }); + registry.addTerminal({ + terminalId: "claude_term", + title: "Claude CLI", + mode: "claude", + }); + registry.addTerminal({ + terminalId: "codex_term", + title: "Codex CLI", + mode: "codex", + }); const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(response.body).toHaveLength(3) - const modes = response.body.map((t: any) => t.mode) - expect(modes).toContain('shell') - expect(modes).toContain('claude') - expect(modes).toContain('codex') - }) + expect(response.body).toHaveLength(3); + const modes = response.body.map((t: any) => t.mode); + expect(modes).toContain("shell"); + expect(modes).toContain("claude"); + expect(modes).toContain("codex"); + }); - it('includes both running and exited terminals', async () => { - registry.addTerminal({ terminalId: 'running_term', status: 'running' }) - registry.addTerminal({ terminalId: 'exited_term', status: 'exited' }) + it("includes both running and exited terminals", async () => { + registry.addTerminal({ terminalId: "running_term", status: "running" }); + registry.addTerminal({ terminalId: "exited_term", status: "exited" }); const response = await request(app) - .get('/api/terminals') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(response.body).toHaveLength(2) - const statuses = response.body.map((t: any) => t.status) - expect(statuses).toContain('running') - expect(statuses).toContain('exited') - }) + expect(response.body).toHaveLength(2); + const statuses = response.body.map((t: any) => t.status); + expect(statuses).toContain("running"); + expect(statuses).toContain("exited"); + }); - it('returns a paged terminal directory when visible-first query params are provided', async () => { + it("returns a paged terminal directory when visible-first query params are provided", async () => { registry.addTerminal({ - terminalId: 'term_newer', - title: 'Newer terminal', - mode: 'shell', - }) + terminalId: "term_newer", + title: "Newer terminal", + mode: "shell", + }); registry.addTerminal({ - terminalId: 'term_older', - title: 'Older terminal', - mode: 'shell', - }) + terminalId: "term_older", + title: "Older terminal", + mode: "shell", + }); const response = await request(app) - .get('/api/terminals?priority=visible&limit=1') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals?priority=visible&limit=1") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); expect(response.body).toEqual({ items: [ expect.objectContaining({ - terminalId: 'term_older', - title: 'Older terminal', + terminalId: "term_older", + title: "Older terminal", }), ], nextCursor: expect.any(String), revision: expect.any(Number), - }) - }) + }); + }); - it('keeps scrollback and search on separate server-owned routes', async () => { + it("keeps scrollback and search on separate server-owned routes", async () => { registry.addTerminal({ - terminalId: 'term_view', - title: 'Searchable terminal', - mode: 'shell', - }) - registry.emitOutput('term_view', 'alpha\nbeta\nalpha') + terminalId: "term_view", + title: "Searchable terminal", + mode: "shell", + }); + registry.emitOutput("term_view", "alpha\nbeta\nalpha"); const scrollback = await request(app) - .get('/api/terminals/term_view/scrollback?limit=2') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals/term_view/scrollback?limit=2") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); expect(scrollback.body).toEqual({ items: [ - { line: 0, text: 'alpha' }, - { line: 1, text: 'beta' }, + { line: 0, text: "alpha" }, + { line: 1, text: "beta" }, ], - nextCursor: '2', - }) + nextCursor: "2", + }); const search = await request(app) - .get('/api/terminals/term_view/search?query=alpha&limit=1') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .get("/api/terminals/term_view/search?query=alpha&limit=1") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); expect(search.body).toEqual({ - matches: [ - { line: 0, column: 0, text: 'alpha' }, - ], - nextCursor: '1', - }) - }) - }) - - describe('PATCH /api/terminals/:terminalId', () => { - it('returns 401 without auth token', async () => { + matches: [{ line: 0, column: 0, text: "alpha" }], + nextCursor: "1", + }); + }); + }); + + describe("PATCH /api/terminals/:terminalId", () => { + it("returns 401 without auth token", async () => { const response = await request(app) - .patch('/api/terminals/term_123') - .send({ titleOverride: 'New Title' }) - .expect(401) - - expect(response.body).toEqual({ error: 'Unauthorized' }) - }) - - it('updates terminal title override', async () => { - registry.addTerminal({ terminalId: 'term_to_update' }) - - vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({ - titleOverride: 'Updated Title', - }) + .patch("/api/terminals/term_123") + .send({ titleOverride: "New Title" }) + .expect(401); + + expect(response.body).toEqual({ error: "Unauthorized" }); + }); + + it( + "updates terminal title override", + async () => { + registry.addTerminal({ terminalId: "term_to_update" }); + + vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({ + titleOverride: "Updated Title", + }); + + const response = await request(app) + .patch("/api/terminals/term_to_update") + .set("x-auth-token", AUTH_TOKEN) + .send({ titleOverride: "Updated Title" }) + .expect(200); + + expect(configStore.patchTerminalOverride).toHaveBeenCalledWith( + "term_to_update", + { + titleOverride: "Updated Title", + descriptionOverride: undefined, + deleted: undefined, + }, + ); + expect(response.body).toEqual({ titleOverride: "Updated Title" }); + }, + SLOW_TEST_TIMEOUT_MS, + ); - const response = await request(app) - .patch('/api/terminals/term_to_update') - .set('x-auth-token', AUTH_TOKEN) - .send({ titleOverride: 'Updated Title' }) - .expect(200) - - expect(configStore.patchTerminalOverride).toHaveBeenCalledWith('term_to_update', { - titleOverride: 'Updated Title', - descriptionOverride: undefined, - deleted: undefined, - }) - expect(response.body).toEqual({ titleOverride: 'Updated Title' }) - }, SLOW_TEST_TIMEOUT_MS) - - it('updates terminal description override', async () => { - registry.addTerminal({ terminalId: 'term_desc' }) + it("updates terminal description override", async () => { + registry.addTerminal({ terminalId: "term_desc" }); vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({ - descriptionOverride: 'New description', - }) + descriptionOverride: "New description", + }); await request(app) - .patch('/api/terminals/term_desc') - .set('x-auth-token', AUTH_TOKEN) - .send({ descriptionOverride: 'New description' }) - .expect(200) - - expect(configStore.patchTerminalOverride).toHaveBeenCalledWith('term_desc', { - titleOverride: undefined, - descriptionOverride: 'New description', - deleted: undefined, - }) - }) + .patch("/api/terminals/term_desc") + .set("x-auth-token", AUTH_TOKEN) + .send({ descriptionOverride: "New description" }) + .expect(200); + + expect(configStore.patchTerminalOverride).toHaveBeenCalledWith( + "term_desc", + { + titleOverride: undefined, + descriptionOverride: "New description", + deleted: undefined, + }, + ); + }); - it('marks terminal as deleted', async () => { - registry.addTerminal({ terminalId: 'term_to_delete' }) + it("marks terminal as deleted", async () => { + registry.addTerminal({ terminalId: "term_to_delete" }); vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({ deleted: true, - }) + }); await request(app) - .patch('/api/terminals/term_to_delete') - .set('x-auth-token', AUTH_TOKEN) + .patch("/api/terminals/term_to_delete") + .set("x-auth-token", AUTH_TOKEN) .send({ deleted: true }) - .expect(200) - - expect(configStore.patchTerminalOverride).toHaveBeenCalledWith('term_to_delete', { - titleOverride: undefined, - descriptionOverride: undefined, - deleted: true, - }) - }) + .expect(200); + + expect(configStore.patchTerminalOverride).toHaveBeenCalledWith( + "term_to_delete", + { + titleOverride: undefined, + descriptionOverride: undefined, + deleted: true, + }, + ); + }); - it('broadcasts terminals.changed after successful patch', async () => { - vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({}) + it("broadcasts terminals.changed after successful patch", async () => { + vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({}); await request(app) - .patch('/api/terminals/term_123') - .set('x-auth-token', AUTH_TOKEN) - .send({ titleOverride: 'Test' }) - .expect(200) + .patch("/api/terminals/term_123") + .set("x-auth-token", AUTH_TOKEN) + .send({ titleOverride: "Test" }) + .expect(200); - expect(wsHandler.broadcastTerminalsChanged).toHaveBeenCalledOnce() - expect(wsHandler.broadcast).not.toHaveBeenCalled() - }) + expect(wsHandler.broadcastTerminalsChanged).toHaveBeenCalledOnce(); + expect(wsHandler.broadcast).not.toHaveBeenCalled(); + }); - it('rejects non-boolean deleted field', async () => { + it("rejects non-boolean deleted field", async () => { const response = await request(app) - .patch('/api/terminals/term_123') - .set('x-auth-token', AUTH_TOKEN) - .send({ deleted: 'true' }) - .expect(400) + .patch("/api/terminals/term_123") + .set("x-auth-token", AUTH_TOKEN) + .send({ deleted: "true" }) + .expect(400); - expect(response.body.error).toBe('Invalid request') - expect(response.body.details).toBeDefined() - }) + expect(response.body.error).toBe("Invalid request"); + expect(response.body.details).toBeDefined(); + }); - it('rejects titleOverride exceeding 500 characters', async () => { + it("rejects titleOverride exceeding 500 characters", async () => { const response = await request(app) - .patch('/api/terminals/term_123') - .set('x-auth-token', AUTH_TOKEN) - .send({ titleOverride: 'a'.repeat(501) }) - .expect(400) + .patch("/api/terminals/term_123") + .set("x-auth-token", AUTH_TOKEN) + .send({ titleOverride: "a".repeat(501) }) + .expect(400); - expect(response.body.error).toBe('Invalid request') - expect(response.body.details).toBeDefined() - }) + expect(response.body.error).toBe("Invalid request"); + expect(response.body.details).toBeDefined(); + }); - it('rejects descriptionOverride exceeding 2000 characters', async () => { + it("rejects descriptionOverride exceeding 2000 characters", async () => { const response = await request(app) - .patch('/api/terminals/term_123') - .set('x-auth-token', AUTH_TOKEN) - .send({ descriptionOverride: 'a'.repeat(2001) }) - .expect(400) + .patch("/api/terminals/term_123") + .set("x-auth-token", AUTH_TOKEN) + .send({ descriptionOverride: "a".repeat(2001) }) + .expect(400); - expect(response.body.error).toBe('Invalid request') - expect(response.body.details).toBeDefined() - }) + expect(response.body.error).toBe("Invalid request"); + expect(response.body.details).toBeDefined(); + }); - it('accepts empty body as no-op', async () => { - vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({}) + it("accepts empty body as no-op", async () => { + vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({}); await request(app) - .patch('/api/terminals/term_123') - .set('x-auth-token', AUTH_TOKEN) + .patch("/api/terminals/term_123") + .set("x-auth-token", AUTH_TOKEN) .send({}) - .expect(200) - }) + .expect(200); + }); - it('accepts null titleOverride to clear override', async () => { - vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({}) + it("accepts null titleOverride to clear override", async () => { + vi.mocked(configStore.patchTerminalOverride).mockResolvedValue({}); await request(app) - .patch('/api/terminals/term_123') - .set('x-auth-token', AUTH_TOKEN) + .patch("/api/terminals/term_123") + .set("x-auth-token", AUTH_TOKEN) .send({ titleOverride: null }) - .expect(200) - - expect(configStore.patchTerminalOverride).toHaveBeenCalledWith('term_123', { - titleOverride: undefined, - descriptionOverride: undefined, - deleted: undefined, - }) - }) - }) - - describe('DELETE /api/terminals/:terminalId', () => { - it('returns 401 without auth token', async () => { + .expect(200); + + expect(configStore.patchTerminalOverride).toHaveBeenCalledWith( + "term_123", + { + titleOverride: undefined, + descriptionOverride: undefined, + deleted: undefined, + }, + ); + }); + }); + + describe("DELETE /api/terminals/:terminalId", () => { + it("returns 401 without auth token", async () => { const response = await request(app) - .delete('/api/terminals/term_123') - .expect(401) + .delete("/api/terminals/term_123") + .expect(401); - expect(response.body).toEqual({ error: 'Unauthorized' }) - }) + expect(response.body).toEqual({ error: "Unauthorized" }); + }); - it('marks terminal as deleted and returns ok', async () => { + it("marks terminal as deleted and returns ok", async () => { const response = await request(app) - .delete('/api/terminals/term_to_remove') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) + .delete("/api/terminals/term_to_remove") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); - expect(configStore.deleteTerminal).toHaveBeenCalledWith('term_to_remove') - expect(response.body).toEqual({ ok: true }) - }) + expect(configStore.deleteTerminal).toHaveBeenCalledWith("term_to_remove"); + expect(response.body).toEqual({ ok: true }); + }); - it('broadcasts terminals.changed after successful delete', async () => { + it("broadcasts terminals.changed after successful delete", async () => { await request(app) - .delete('/api/terminals/term_123') - .set('x-auth-token', AUTH_TOKEN) - .expect(200) - - expect(wsHandler.broadcastTerminalsChanged).toHaveBeenCalledOnce() - expect(wsHandler.broadcast).not.toHaveBeenCalled() - }) - }) -}) + .delete("/api/terminals/term_123") + .set("x-auth-token", AUTH_TOKEN) + .expect(200); + + expect(wsHandler.broadcastTerminalsChanged).toHaveBeenCalledOnce(); + expect(wsHandler.broadcast).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/client/ui-commands.test.ts b/test/unit/client/ui-commands.test.ts index 6fd5cae6c..8aaa726b9 100644 --- a/test/unit/client/ui-commands.test.ts +++ b/test/unit/client/ui-commands.test.ts @@ -80,6 +80,24 @@ describe('handleUiCommand', () => { expect(actions[1].type).toBe('panes/swapPanes') }) + it('selects the tab when selecting a pane', () => { + const actions: any[] = [] + const dispatch = (action: any) => { + actions.push(action) + return action + } + + handleUiCommand({ + type: 'ui.command', + command: 'pane.select', + payload: { tabId: 't1', paneId: 'p1' }, + }, dispatch) + + expect(actions.map((a) => a.type)).toEqual(['tabs/setActiveTab', 'panes/setActivePane']) + expect(actions[0].payload).toBe('t1') + expect(actions[1].payload).toEqual({ tabId: 't1', paneId: 'p1' }) + }) + it('handles pane.rename', () => { const actions: any[] = [] const dispatch = (action: any) => {