diff --git a/README.md b/README.md index f91e0a4..f88d69c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A plugin for [OpenCode](https://opencode.ai) that provides interactive PTY (pseu ## Why? -OpenCode's built-in `bash` tool runs commands synchronously—the agent waits for completion. This works for quick commands, but not for: +OpenCode's built-in `bash` tool runs commands synchronously -- the agent waits for completion. This works for quick commands, but not for: - **Dev servers** (`npm run dev`, `cargo watch`) - **Watch modes** (`npm test -- --watch`) @@ -20,6 +20,9 @@ This plugin gives the agent full control over multiple terminal sessions, like t - **Interactive Input**: Send keystrokes, Ctrl+C, arrow keys, etc. - **Output Buffer**: Read output anytime with pagination (offset/limit) - **Pattern Filtering**: Search output using regex (like `grep`) +- **Terminal Snapshots**: Capture clean, parsed terminal screen state (no ANSI noise) +- **Screen Diffing**: Seq-based history with line-level diffs between snapshots +- **Conditional Waiting**: Block until screen matches a regex or stabilizes - **Exit Notifications**: Get notified when processes finish (eliminates polling) - **Permission Support**: Respects OpenCode's bash permission settings - **Session Lifecycle**: Sessions persist until explicitly killed @@ -53,13 +56,15 @@ opencode ## Tools Provided -| Tool | Description | -| ----------- | --------------------------------------------------------------------------- | -| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title, notifyOnExit) | -| `pty_write` | Send input to a PTY (text, escape sequences like `\x03` for Ctrl+C) | -| `pty_read` | Read output buffer with pagination and optional regex filtering | -| `pty_list` | List all PTY sessions with status, PID, line count | -| `pty_kill` | Terminate a PTY, optionally cleanup the buffer | +| Tool | Description | +| -------------------- | --------------------------------------------------------------------------- | +| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title, notifyOnExit) | +| `pty_write` | Send input to a PTY (text, escape sequences like `\x03` for Ctrl+C) | +| `pty_read` | Read raw output buffer with pagination and optional regex filtering | +| `pty_snapshot` | Capture parsed terminal screen as clean text with cursor, size, and hash | +| `pty_snapshot_wait` | Block until screen matches a regex or content stabilizes | +| `pty_list` | List all PTY sessions with status, PID, line count | +| `pty_kill` | Terminate a PTY, optionally cleanup the buffer | ## Slash Commands @@ -215,7 +220,81 @@ Last Line: Build completed successfully. Use pty_read to check the full output. ``` -This eliminates the need for polling—perfect for long-running processes like builds, tests, or deployment scripts. If the process fails (non-zero exit code), the notification will suggest using `pty_read` with the `pattern` parameter to search for errors. +This eliminates the need for polling -- perfect for long-running processes like builds, tests, or deployment scripts. If the process fails (non-zero exit code), the notification will suggest using `pty_read` with the `pattern` parameter to search for errors. + +### Capture a clean terminal snapshot + +`pty_read` returns raw output including ANSI escape sequences, which is fine for line-oriented programs like `npm install`. But for TUI apps (interactive UIs with cursor movement, colors, screen clearing), the raw buffer is an unreadable flood of control codes. `pty_snapshot` solves this: + +``` +pty_snapshot: id="pty_a1b2c3d4" +→ Returns clean screen text with cursor position, size, seq number, and content hash +``` + +### Track screen changes with seq-based diffs + +Every snapshot gets a monotonically increasing sequence number (`seq`) that increments only when the screen content actually changes. Pass `since` to get only the lines that changed: + +``` +pty_snapshot: id="pty_a1b2c3d4", since=5 +→ Returns only changed/added/removed lines since seq 5 +``` + +### Wait for specific screen content + +Instead of polling in a loop, use `pty_snapshot_wait` to block until a condition is met: + +``` +pty_snapshot_wait: id="pty_a1b2c3d4", search="error|Error", timeout=30000 +→ Blocks until "error" or "Error" appears on screen, or times out + +pty_snapshot_wait: id="pty_a1b2c3d4", hashStableMs=2000, timeout=30000 +→ Blocks until screen content is unchanged for 2 seconds (useful for "wait until done") +``` + +Both parameters can be combined with `since` to get a diff on return. + +### Example: Debugging OpenCode itself + +One compelling use case is running OpenCode inside OpenCode to observe TUI behavior during development. The agent can interact with the inner instance, send prompts, open menus, and watch exactly how the screen updates: + +``` +# 1. Launch OpenCode as a background TUI process +pty_spawn: command="opencode", args=["path/to/project"], title="Inner OpenCode" +→ pty_abc123 + +# 2. Wait for it to render, get initial screen state +pty_snapshot_wait: id="pty_abc123", hashStableMs=2000, timeout=15000 +→ seq=2, shows OpenCode banner + input field + +# 3. Type a prompt and submit +pty_write: id="pty_abc123", data="explain this codebase" +pty_write: id="pty_abc123", data="\n" + +# 4. Watch the response stream in, frame by frame +pty_snapshot_wait: id="pty_abc123", hashStableMs=300, since=2 +→ seq=15, diff shows partial response text appearing + +pty_snapshot_wait: id="pty_abc123", hashStableMs=300, since=15 +→ seq=28, more text streamed in + +pty_snapshot_wait: id="pty_abc123", hashStableMs=3000, since=28 +→ seq=46, response complete (stable for 3s) + +# 5. Open the command palette +pty_write: id="pty_abc123", data="\x10" + +# 6. See all available commands +pty_snapshot: id="pty_abc123", since=46 +→ Shows command palette overlay with menu items + +# 7. Toggle the sidebar +pty_write: id="pty_abc123", data="show sidebar\r" +pty_snapshot: id="pty_abc123", since=48 +→ Sidebar appears with session info, MCP connections, context usage +``` + +This works because `pty_snapshot` maintains a headless terminal emulator ([xterm.js](https://xtermjs.org/)) alongside each PTY session, producing the same parsed screen a human would see -- without any ANSI escape sequence noise. ## Configuration @@ -269,13 +348,16 @@ This plugin respects OpenCode's [permission settings](https://opencode.ai/docs/p ## How It Works 1. **Spawn**: Creates a PTY using [bun-pty](https://github.com/nicksrandall/bun-pty), runs command in background -2. **Buffer**: Output is captured into a rolling line buffer (ring buffer) -3. **Read**: Agent can read buffer anytime with offset/limit pagination -4. **Filter**: Optional regex pattern filters lines before pagination -5. **Write**: Agent can send any input including escape sequences -6. **Lifecycle**: Sessions track status (running/exited/killed), persist until cleanup -7. **Notify**: When `notifyOnExit` is true, sends a message to the session when the process exits -8. **Web UI**: React frontend connects via WebSocket for real-time updates +2. **Buffer**: Output is captured into both a rolling line buffer (ring buffer) and a headless terminal emulator +3. **Read**: Agent can read raw buffer anytime with offset/limit pagination +4. **Snapshot**: Agent can capture the parsed visible screen (clean text, no ANSI codes) via the headless terminal +5. **Diff**: Each content change gets a sequence number; agent can request line-level diffs between any two states +6. **Wait**: Agent can block until screen content matches a regex or stabilizes (no polling needed) +7. **Filter**: Optional regex pattern filters raw buffer lines before pagination +8. **Write**: Agent can send any input including escape sequences +9. **Lifecycle**: Sessions track status (running/exited/killed), persist until cleanup +10. **Notify**: When `notifyOnExit` is true, sends a message to the session when the process exits +11. **Web UI**: React frontend connects via WebSocket for real-time updates ## Session Lifecycle @@ -525,3 +607,4 @@ Contributions are welcome! Please open an issue or submit a PR. - [OpenCode](https://opencode.ai) - The AI coding assistant this plugin extends - [bun-pty](https://github.com/nicksrandall/bun-pty) - Cross-platform PTY for Bun +- [xterm.js](https://xtermjs.org/) - Headless terminal emulator powering `pty_snapshot` diff --git a/bun.lock b/bun.lock index 3e3682b..15e1b3f 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@opencode-ai/plugin": "^1.1.51", "@opencode-ai/sdk": "^1.1.51", + "@xterm/headless": "^6.0.0", "bun-pty": "^0.4.8", "moment": "^2.30.1", "open": "^11.0.0", @@ -268,6 +269,8 @@ "@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="], + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/package.json b/package.json index 6e3feb8..744568c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "name": "opencode-pty", - "module": "index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "module": "dist/index.js", "version": "0.2.3", "description": "OpenCode plugin for interactive PTY management - run background processes, send input, read output with regex filtering", "author": "shekohex", @@ -26,27 +28,33 @@ }, "homepage": "https://github.com/shekohex/opencode-pty#readme", "files": [ - "index.ts", - "src", "dist" ], "license": "MIT", "type": "module", "exports": { - "./*": "./src/*.ts", - "./*/*": "./src/*/*.ts", - "./*/*/*": "./src/*/*/*.ts", - "./*/*/*/*": "./src/*/*/*/*.ts", - "./*/*/*/*/*": "./src/*/*/*/*/*.ts" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./*": { + "types": "./dist/src/*.d.ts", + "default": "./dist/src/*.js" + }, + "./*/*": { + "types": "./dist/src/*/*.d.ts", + "default": "./dist/src/*/*.js" + } }, "scripts": { "typecheck": "tsc --noEmit", "unittest": "bun test", "test:e2e": "PW_DISABLE_TS_ESM=1 NODE_ENV=test bun --bun playwright test", "test:all": "bun unittest && bun test:e2e", - "build:dev": "bun clean && vite build --mode development", - "build:prod": "bun clean && vite build --mode production", - "prepack": "bun build:prod", + "build:plugin": "tsc -p tsconfig.build.json && bun x copyfiles -u 0 \"src/**/*.txt\" dist", + "build:dev": "bun clean && bun build:plugin && vite build --mode development", + "build:prod": "bun clean && bun build:plugin && vite build --mode production", + "prepublishOnly": "bun build:prod", "clean": "rm -rf dist playwright-report test-results", "lint": "biome lint .", "lint:fix": "biome lint --write .", @@ -78,6 +86,7 @@ "dependencies": { "@opencode-ai/plugin": "^1.1.51", "@opencode-ai/sdk": "^1.1.51", + "@xterm/headless": "^6.0.0", "bun-pty": "^0.4.8", "moment": "^2.30.1", "open": "^11.0.0" diff --git a/src/plugin.ts b/src/plugin.ts index 9f9f35c..1460d65 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,6 +6,8 @@ import { ptyWrite } from './plugin/pty/tools/write.ts' import { ptyRead } from './plugin/pty/tools/read.ts' import { ptyList } from './plugin/pty/tools/list.ts' import { ptyKill } from './plugin/pty/tools/kill.ts' +import { ptySnapshot } from './plugin/pty/tools/snapshot.ts' +import { ptySnapshotWait } from './plugin/pty/tools/snapshot-wait.ts' import { PTYServer } from './web/server/server.ts' import open from 'open' @@ -48,6 +50,8 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise

this.outputManager.snapshot(session), null) + } + + snapshotDiff(id: string, sinceSeq: number): (SnapshotDiff & { id: string; status: PTYStatus }) | null { + return withSession( + this.lifecycleManager, + id, + (session) => this.outputManager.snapshotDiff(session, sinceSeq), + null + ) + } + + async snapshotWait( + id: string, + condition: WaitCondition + ): Promise<(WaitResult & { id: string; status: string }) | null> { + const session = this.lifecycleManager.getSession(id) + if (!session) return null + return this.outputManager.snapshotWait(session, condition) + } + kill(id: string, cleanup: boolean = false): boolean { return this.lifecycleManager.kill(id, cleanup) } diff --git a/src/plugin/pty/output-manager.ts b/src/plugin/pty/output-manager.ts index 5db4163..c9d807d 100644 --- a/src/plugin/pty/output-manager.ts +++ b/src/plugin/pty/output-manager.ts @@ -1,4 +1,5 @@ -import type { PTYSession, ReadResult, SearchResult } from './types.ts' +import type { SnapshotDiff, WaitCondition, WaitResult } from './snapshot.ts' +import type { PTYSession, PTYStatus, ReadResult, SearchResult, SnapshotResult } from './types.ts' export class OutputManager { write(session: PTYSession, data: string): boolean { @@ -26,4 +27,36 @@ export class OutputManager { const hasMore = offset + paginatedMatches.length < totalMatches return { matches: paginatedMatches, totalMatches, totalLines, offset, hasMore } } + + snapshot(session: PTYSession): SnapshotResult { + return { + id: session.id, + status: session.status, + ...session.snapshot.getState(), + } + } + + snapshotDiff( + session: PTYSession, + sinceSeq: number + ): SnapshotDiff & { id: string; status: PTYStatus } { + const diff = session.snapshot.getDiff(sinceSeq) + return { + ...diff, + id: session.id, + status: session.status, + } + } + + async snapshotWait( + session: PTYSession, + condition: WaitCondition + ): Promise { + const result = await session.snapshot.waitForCondition(condition) + return { + ...result, + id: session.id, + status: session.status, + } + } } diff --git a/src/plugin/pty/session-lifecycle.ts b/src/plugin/pty/session-lifecycle.ts index 9ee0e17..553b000 100644 --- a/src/plugin/pty/session-lifecycle.ts +++ b/src/plugin/pty/session-lifecycle.ts @@ -1,5 +1,6 @@ import { spawn, type IPty } from 'bun-pty' import { RingBuffer } from './buffer.ts' +import { TerminalSnapshot } from './snapshot.ts' import type { PTYSession, PTYSessionInfo, SpawnOptions } from './types.ts' import { DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS } from '../constants.ts' import moment from 'moment' @@ -24,6 +25,7 @@ export class SessionLifecycleManager { opts.title ?? (`${opts.command} ${args.join(' ')}`.trim() || `Terminal ${id.slice(-4)}`) const buffer = new RingBuffer() + const snapshot = new TerminalSnapshot(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS) return { id, title, @@ -39,6 +41,7 @@ export class SessionLifecycleManager { parentAgent: opts.parentAgent, notifyOnExit: opts.notifyOnExit ?? false, buffer, + snapshot, process: null, // will be set } } @@ -63,6 +66,7 @@ export class SessionLifecycleManager { ): void { session.process?.onData((data: string) => { session.buffer.append(data) + session.snapshot.write(data) onData(session, data) }) @@ -143,6 +147,8 @@ export class SessionLifecycleManager { } toInfo(session: PTYSession): PTYSessionInfo { + const snapshot = session.snapshot.getState() + return { id: session.id, title: session.title, @@ -156,6 +162,7 @@ export class SessionLifecycleManager { pid: session.pid, createdAt: session.createdAt.toISOString(true), lineCount: session.buffer.length, + size: snapshot.size, } } } diff --git a/src/plugin/pty/snapshot.ts b/src/plugin/pty/snapshot.ts new file mode 100644 index 0000000..d9cae1e --- /dev/null +++ b/src/plugin/pty/snapshot.ts @@ -0,0 +1,328 @@ +import { Terminal } from '@xterm/headless' + +// FNV-1a 64-bit constants for content hashing +const FNV_OFFSET_BASIS = BigInt('14695981039346656037') +const FNV_PRIME = BigInt('1099511628211') +const FNV_MASK = BigInt('18446744073709551615') + +export interface SnapshotCursor { + row: number + col: number + visible: boolean +} + +export interface SnapshotSize { + cols: number + rows: number +} + +export interface SnapshotState { + size: SnapshotSize + cursor: SnapshotCursor + text: string + contentHash: string + /** Monotonically increasing sequence number. Increments only on content changes. */ + seq: number + /** Per-line content at this snapshot, indexed by line number. */ + lines: string[] +} + +/** A single changed line in a diff. */ +export interface LineDiff { + line: number + /** 'changed' = content differs, 'added' = line exists in new but not old, 'removed' = line existed in old but not new */ + type: 'changed' | 'added' | 'removed' + /** The new content (undefined for 'removed' lines). */ + content?: string + /** The old content (undefined for 'added' lines). */ + old?: string +} + +export interface SnapshotDiff { + /** The snapshot state at the current moment. */ + state: SnapshotState + /** The seq we diffed against. */ + sinceSeq: number + /** Individual line-level changes. */ + changes: LineDiff[] + /** True if the requested sinceSeq was not found in history (full snapshot returned instead). */ + historyTruncated: boolean +} + +export interface WaitCondition { + /** RegExp to match against screen text. Resolves on first match. */ + search?: RegExp + /** Resolve when the content hash stays unchanged for this many ms. */ + hashStableMs?: number + /** Maximum time to wait before giving up (default: 30000). */ + timeoutMs?: number +} + +export interface WaitResult { + /** Whether the condition was met (false = timed out). */ + matched: boolean + /** Total wall-clock time spent waiting, in ms. */ + waitedMs: number + /** The snapshot state at the moment of match or timeout. */ + state: SnapshotState +} + +/** Stored frame in the history ring buffer. */ +interface HistoryFrame { + seq: number + contentHash: string + lines: string[] +} + +const DEFAULT_HISTORY_CAPACITY = 200 + +/** + * Maintains a headless xterm.js terminal that mirrors PTY output, + * providing parsed screen state (visible text, cursor, dimensions) + * without any ANSI escape code noise. + * + * Also maintains a ring buffer of deduped snapshot frames (keyed by + * content hash) for seq-based diffing. + */ +export class TerminalSnapshot { + private readonly terminal: Terminal + private pendingWrite: Promise = Promise.resolve() + private cachedState: SnapshotState + + // Seq-based history + private seq = 0 + private history: HistoryFrame[] = [] + private historyCapacity: number + + constructor( + cols: number, + rows: number, + scrollback: number = rows * 10, + historyCapacity: number = DEFAULT_HISTORY_CAPACITY + ) { + this.terminal = new Terminal({ + cols, + rows, + scrollback, + allowProposedApi: true, + }) + this.historyCapacity = historyCapacity + this.cachedState = this.buildState() + this.pushHistory(this.cachedState) + } + + /** + * Queues data for the headless terminal to parse. + * xterm.js processes writes asynchronously via time-slicing, + * so we chain each write's completion callback to ensure the + * cached state is always built from fully-parsed output. + */ + write(data: string): void { + this.pendingWrite = this.pendingWrite + .then( + () => + new Promise((resolve) => { + this.terminal.write(data, resolve) + }) + ) + .then(() => { + const newState = this.buildState() + if (newState.contentHash !== this.cachedState.contentHash) { + this.seq++ + newState.seq = this.seq + this.pushHistory(newState) + } + this.cachedState = newState + }) + } + + getState(): SnapshotState { + return this.cachedState + } + + async getSettledState(): Promise { + await this.pendingWrite + return this.cachedState + } + + /** + * Get the current snapshot with a line-level diff against a previous seq. + * If `sinceSeq` is not found in history, returns the full state with + * `historyTruncated: true`. + */ + getDiff(sinceSeq: number): SnapshotDiff { + const current = this.cachedState + const oldFrame = this.history.find((f) => f.seq === sinceSeq) + + if (!oldFrame) { + // History truncated or invalid seq - return full state as "all added" + const changes: LineDiff[] = [] + for (let i = 0; i < current.lines.length; i++) { + const content = current.lines[i]! + if (content.trim()) { + changes.push({ line: i, type: 'added', content }) + } + } + + return { + state: current, + sinceSeq, + changes, + historyTruncated: true, + } + } + + const changes = computeLineDiff(oldFrame.lines, current.lines) + + return { + state: current, + sinceSeq: oldFrame.seq, + changes, + historyTruncated: false, + } + } + + /** + * Polls the terminal state until a condition is met or timeout is reached. + * The polling loop runs server-side so the calling agent pays for only one + * tool invocation instead of many. + * + * Supported conditions (at least one must be provided): + * - `search`: resolves when screen text matches the regex + * - `hashStableMs`: resolves when content hash is unchanged for N ms + * + * If both are provided, the first condition to match wins. + */ + async waitForCondition(condition: WaitCondition): Promise { + const POLL_INTERVAL_MS = 100 + const timeoutMs = condition.timeoutMs ?? 30_000 + const start = Date.now() + + let lastHash = this.cachedState.contentHash + let lastHashChangeTime = start + + const check = (): WaitResult | null => { + const state = this.cachedState + + // search condition + if (condition.search && condition.search.test(state.text)) { + return { matched: true, waitedMs: Date.now() - start, state } + } + + // hashStable condition: track when hash last changed + if (condition.hashStableMs != null) { + if (state.contentHash !== lastHash) { + lastHash = state.contentHash + lastHashChangeTime = Date.now() + } else if (Date.now() - lastHashChangeTime >= condition.hashStableMs) { + return { matched: true, waitedMs: Date.now() - start, state } + } + } + + return null + } + + // Check immediately before entering the poll loop + const immediate = check() + if (immediate) return immediate + + return new Promise((resolve) => { + const interval = setInterval(() => { + const result = check() + if (result) { + clearInterval(interval) + resolve(result) + return + } + if (Date.now() - start >= timeoutMs) { + clearInterval(interval) + resolve({ + matched: false, + waitedMs: Date.now() - start, + state: this.cachedState, + }) + } + }, POLL_INTERVAL_MS) + }) + } + + private pushHistory(state: SnapshotState): void { + this.history.push({ + seq: state.seq, + contentHash: state.contentHash, + lines: state.lines, + }) + // Evict oldest if over capacity + while (this.history.length > this.historyCapacity) { + this.history.shift() + } + } + + private buildState(): SnapshotState { + const buffer = this.terminal.buffer.active + const startLine = buffer.viewportY + const lines: string[] = [] + + for (let row = 0; row < this.terminal.rows; row++) { + const line = buffer.getLine(startLine + row) + lines.push(line?.translateToString(false) ?? '') + } + + const text = lines.join('\n').replace(/\s+$/u, '') + + return { + size: { + cols: this.terminal.cols, + rows: this.terminal.rows, + }, + cursor: { + row: buffer.cursorY, + col: buffer.cursorX, + // Local @xterm/headless typings omit showCursor from modes + visible: (this.terminal.modes as { showCursor?: boolean }).showCursor ?? true, + }, + text, + contentHash: computeContentHash(text), + seq: this.seq, + lines, + } + } +} + +/** Compute line-level diff between old and new screen content. */ +function computeLineDiff(oldLines: string[], newLines: string[]): LineDiff[] { + const changes: LineDiff[] = [] + const maxLen = Math.max(oldLines.length, newLines.length) + + for (let i = 0; i < maxLen; i++) { + const oldLine = i < oldLines.length ? oldLines[i] : undefined + const newLine = i < newLines.length ? newLines[i] : undefined + + if (oldLine === undefined && newLine !== undefined) { + // Line added (screen grew) + if (newLine.trim()) { + changes.push({ line: i, type: 'added', content: newLine }) + } + } else if (oldLine !== undefined && newLine === undefined) { + // Line removed (screen shrank) + if (oldLine.trim()) { + changes.push({ line: i, type: 'removed', old: oldLine }) + } + } else if (oldLine !== newLine) { + // Content changed + changes.push({ line: i, type: 'changed', content: newLine, old: oldLine }) + } + } + + return changes +} + +/** FNV-1a 64-bit hash for fast, stable change detection of screen content. */ +function computeContentHash(text: string): string { + let hash = FNV_OFFSET_BASIS + for (let index = 0; index < text.length; index++) { + hash ^= BigInt(text.charCodeAt(index)) + hash = (hash * FNV_PRIME) & FNV_MASK + } + return hash.toString() +} diff --git a/src/plugin/pty/tools/snapshot-wait.ts b/src/plugin/pty/tools/snapshot-wait.ts new file mode 100644 index 0000000..e7fe066 --- /dev/null +++ b/src/plugin/pty/tools/snapshot-wait.ts @@ -0,0 +1,88 @@ +import { tool } from '@opencode-ai/plugin' +import { manager } from '../manager.ts' +import { buildSessionNotFoundError } from '../utils.ts' +import DESCRIPTION from './snapshot-wait.txt' + +export const ptySnapshotWait = tool({ + description: DESCRIPTION, + args: { + id: tool.schema.string().describe('The PTY session ID (e.g., pty_a1b2c3d4)'), + search: tool.schema + .string() + .optional() + .describe('Regex pattern to match against screen text. Resolves on first match.'), + hashStableMs: tool.schema + .number() + .optional() + .describe( + 'Wait until the screen content hash is unchanged for this many milliseconds (e.g., 2000 for "screen settled").' + ), + timeout: tool.schema + .number() + .optional() + .describe('Maximum time to wait in milliseconds (default: 30000).'), + since: tool.schema + .number() + .optional() + .describe( + 'Sequence number to diff against. When provided, returns only changed lines since that seq instead of full screen.' + ), + }, + async execute(args) { + if (args.search == null && args.hashStableMs == null) { + throw new Error('At least one condition must be provided: search or hashStableMs.') + } + + const result = await manager.snapshotWait(args.id, { + search: args.search != null ? new RegExp(args.search) : undefined, + hashStableMs: args.hashStableMs, + timeoutMs: args.timeout, + }) + + if (!result) { + throw buildSessionNotFoundError(args.id) + } + + const status = result.matched ? 'matched' : 'timed_out' + + // If since was provided, return diff format + if (args.since != null) { + const diff = manager.snapshotDiff(args.id, args.since) + if (!diff) { + throw buildSessionNotFoundError(args.id) + } + + if (diff.changes.length === 0) { + return [ + ``, + 'No changes', + ``, + ].join('\n') + } + + const changeLines = diff.changes.map((c) => { + if (c.type === 'removed') return ` ${c.line}: [removed] ${c.old}` + if (c.type === 'added') return ` ${c.line}: [+] ${c.content}` + return ` ${c.line}: ${c.content}` + }) + + return [ + ``, + `Cursor: (${result.state.cursor.row}, ${result.state.cursor.col}) visible=${result.state.cursor.visible}`, + `Changed lines:`, + ...changeLines, + ``, + ].join('\n') + } + + // Full snapshot + return [ + ``, + `Size: ${result.state.size.cols}x${result.state.size.rows}`, + `Cursor: (${result.state.cursor.row}, ${result.state.cursor.col}) visible=${result.state.cursor.visible}`, + '---', + result.state.text || '(Screen is empty)', + ``, + ].join('\n') + }, +}) diff --git a/src/plugin/pty/tools/snapshot-wait.txt b/src/plugin/pty/tools/snapshot-wait.txt new file mode 100644 index 0000000..7c7b58e --- /dev/null +++ b/src/plugin/pty/tools/snapshot-wait.txt @@ -0,0 +1 @@ +Blocks until the PTY screen matches a condition: text search (regex) or screen stability (hash unchanged for N ms). Returns the matching snapshot. Use instead of polling pty_snapshot in a loop. \ No newline at end of file diff --git a/src/plugin/pty/tools/snapshot.ts b/src/plugin/pty/tools/snapshot.ts new file mode 100644 index 0000000..c9116e3 --- /dev/null +++ b/src/plugin/pty/tools/snapshot.ts @@ -0,0 +1,64 @@ +import { tool } from '@opencode-ai/plugin' +import { manager } from '../manager.ts' +import { buildSessionNotFoundError } from '../utils.ts' +import DESCRIPTION from './snapshot.txt' + +export const ptySnapshot = tool({ + description: DESCRIPTION, + args: { + id: tool.schema.string().describe('The PTY session ID (e.g., pty_a1b2c3d4)'), + since: tool.schema + .number() + .optional() + .describe( + 'Sequence number to diff against. Returns only changed lines since that seq. Omit for full snapshot.' + ), + }, + async execute(args) { + if (args.since != null) { + const diff = manager.snapshotDiff(args.id, args.since) + if (!diff) { + throw buildSessionNotFoundError(args.id) + } + + if (diff.changes.length === 0) { + return [ + ``, + 'No changes', + ``, + ].join('\n') + } + + const changeLines = diff.changes.map((c) => { + if (c.type === 'removed') return ` ${c.line}: [removed] ${c.old}` + if (c.type === 'added') return ` ${c.line}: [+] ${c.content}` + return ` ${c.line}: ${c.content}` + }) + + const parts = [ + ``, + `Cursor: (${diff.state.cursor.row}, ${diff.state.cursor.col}) visible=${diff.state.cursor.visible}`, + `Changed lines:`, + ...changeLines, + ``, + ] + + return parts.join('\n') + } + + // Full snapshot (no since) + const snapshot = manager.snapshot(args.id) + if (!snapshot) { + throw buildSessionNotFoundError(args.id) + } + + return [ + ``, + `Size: ${snapshot.size.cols}x${snapshot.size.rows}`, + `Cursor: (${snapshot.cursor.row}, ${snapshot.cursor.col}) visible=${snapshot.cursor.visible}`, + '---', + snapshot.text || '(Screen is empty)', + ``, + ].join('\n') + }, +}) diff --git a/src/plugin/pty/tools/snapshot.txt b/src/plugin/pty/tools/snapshot.txt new file mode 100644 index 0000000..b4cee1e --- /dev/null +++ b/src/plugin/pty/tools/snapshot.txt @@ -0,0 +1 @@ +Captures the current visible terminal screen as clean text (ANSI-free) with cursor position, size, and a contentHash for change detection. Prefer over pty_read for TUI apps. Each snapshot has a seq number that increments on content changes. Pass `since` with a previous seq to get only changed lines instead of the full screen. diff --git a/src/plugin/pty/types.ts b/src/plugin/pty/types.ts index 41cd263..855d0f2 100644 --- a/src/plugin/pty/types.ts +++ b/src/plugin/pty/types.ts @@ -1,5 +1,6 @@ import type { IPty } from 'bun-pty' import type { RingBuffer } from './buffer.ts' +import type { SnapshotState, TerminalSnapshot } from './snapshot.ts' import type moment from 'moment' export type PTYStatus = 'running' | 'exited' | 'killing' | 'killed' @@ -21,6 +22,7 @@ export interface PTYSession { parentAgent?: string notifyOnExit: boolean buffer: RingBuffer + snapshot: TerminalSnapshot process: IPty | null } @@ -37,6 +39,7 @@ export interface PTYSessionInfo { pid: number createdAt: string lineCount: number + size?: SnapshotState['size'] } export interface SpawnOptions { @@ -65,3 +68,8 @@ export interface SearchResult { offset: number hasMore: boolean } + +export interface SnapshotResult extends SnapshotState { + id: string + status: PTYStatus +} diff --git a/src/web/server/handlers/sessions.ts b/src/web/server/handlers/sessions.ts index a13de20..78bf203 100644 --- a/src/web/server/handlers/sessions.ts +++ b/src/web/server/handlers/sessions.ts @@ -90,14 +90,13 @@ export function getRawBuffer(req: BunRequest) { - const bufferData = manager.getRawBuffer(req.params.id) - if (!bufferData) { + const snapshot = manager.snapshot(req.params.id) + if (!snapshot) { return new ErrorResponse('Session not found', 404) } - const plainText = Bun.stripANSI(bufferData.raw) return new JsonResponse({ - plain: plainText, - byteLength: new TextEncoder().encode(plainText).length, + plain: snapshot.text, + byteLength: new TextEncoder().encode(snapshot.text).length, }) } diff --git a/src/web/server/handlers/static.ts b/src/web/server/handlers/static.ts index c62d018..1c354c7 100644 --- a/src/web/server/handlers/static.ts +++ b/src/web/server/handlers/static.ts @@ -1,10 +1,11 @@ -import { resolve } from 'node:path' import { readdirSync, statSync } from 'node:fs' -import { join, extname } from 'node:path' +import { extname, join, resolve } from 'node:path' import { ASSET_CONTENT_TYPES } from '../../shared/constants.ts' // ----- MODULE-SCOPE CONSTANTS ----- -const PROJECT_ROOT = resolve(import.meta.dir, '../../../..') +// Resolve project root regardless of whether we're running from source or dist/ +const MODULE_DIR = resolve(import.meta.dir, '../../../..') +const PROJECT_ROOT = MODULE_DIR.replace(/[\\/]dist$/, '') const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', diff --git a/test/notification-manager.test.ts b/test/notification-manager.test.ts index 2b4ad09..fd492e4 100644 --- a/test/notification-manager.test.ts +++ b/test/notification-manager.test.ts @@ -3,6 +3,7 @@ import type { OpencodeClient } from '@opencode-ai/sdk' import moment from 'moment' import { RingBuffer } from '../src/plugin/pty/buffer.ts' import { NotificationManager } from '../src/plugin/pty/notification-manager.ts' +import { TerminalSnapshot } from '../src/plugin/pty/snapshot.ts' import type { PTYSession } from '../src/plugin/pty/types.ts' type PromptPayload = { @@ -15,6 +16,7 @@ type PromptPayload = { function createSession(overrides: Partial = {}): PTYSession { const buffer = new RingBuffer() + const snapshot = new TerminalSnapshot(120, 40) buffer.append('line 1\nline 2\n') return { @@ -31,6 +33,7 @@ function createSession(overrides: Partial = {}): PTYSession { parentAgent: 'agent-two', notifyOnExit: true, buffer, + snapshot, process: null, ...overrides, } diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..c371385 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": ".", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowImportingTsExtensions": false, + "rewriteRelativeImportExtensions": true + }, + "include": ["index.ts", "src/**/*.ts"], + "exclude": ["src/web/client/**", "**/*.test.ts", "**/*.spec.ts"] +}