Skip to content

Commit d49d4ce

Browse files
author
JosXa
committed
feat: add pty_snapshot tool for parsed terminal screen capture
Add a headless terminal emulator (@xterm/headless) to each PTY session that processes raw output in parallel with the existing ring buffer. This enables clean, ANSI-free screen capture via a new pty_snapshot tool. The snapshot returns the visible terminal screen text, cursor position, terminal size, and a content hash for efficient change detection - useful for monitoring TUI applications during development. Also improves the web UI plain-text buffer endpoint to use the parsed screen instead of naive ANSI regex stripping.
1 parent 3720bcb commit d49d4ce

12 files changed

Lines changed: 184 additions & 7 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"dependencies": {
7979
"@opencode-ai/plugin": "^1.1.51",
8080
"@opencode-ai/sdk": "^1.1.51",
81+
"@xterm/headless": "^6.0.0",
8182
"bun-pty": "^0.4.8",
8283
"moment": "^2.30.1",
8384
"open": "^11.0.0"

src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ptyWrite } from './plugin/pty/tools/write.ts'
66
import { ptyRead } from './plugin/pty/tools/read.ts'
77
import { ptyList } from './plugin/pty/tools/list.ts'
88
import { ptyKill } from './plugin/pty/tools/kill.ts'
9+
import { ptySnapshot } from './plugin/pty/tools/snapshot.ts'
910
import { PTYServer } from './web/server/server.ts'
1011
import open from 'open'
1112

@@ -48,6 +49,7 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P
4849
pty_spawn: ptySpawn,
4950
pty_write: ptyWrite,
5051
pty_read: ptyRead,
52+
pty_snapshot: ptySnapshot,
5153
pty_list: ptyList,
5254
pty_kill: ptyKill,
5355
},

src/plugin/pty/manager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { version as bunPtyVersion } from 'bun-pty/package.json'
55
import { NotificationManager } from './notification-manager.ts'
66
import { OutputManager } from './output-manager.ts'
77
import { SessionLifecycleManager } from './session-lifecycle.ts'
8-
import type { PTYSessionInfo, ReadResult, SearchResult, SpawnOptions } from './types.ts'
8+
import type {
9+
PTYSessionInfo,
10+
ReadResult,
11+
SearchResult,
12+
SnapshotResult,
13+
SpawnOptions,
14+
} from './types.ts'
915
import { withSession } from './utils.ts'
1016

1117
// Monkey-patch bun-pty to fix race condition in _startReadLoop
@@ -159,6 +165,10 @@ class PTYManager {
159165
)
160166
}
161167

168+
snapshot(id: string): SnapshotResult | null {
169+
return withSession(this.lifecycleManager, id, (session) => this.outputManager.snapshot(session), null)
170+
}
171+
162172
kill(id: string, cleanup: boolean = false): boolean {
163173
return this.lifecycleManager.kill(id, cleanup)
164174
}

src/plugin/pty/output-manager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PTYSession, ReadResult, SearchResult } from './types.ts'
1+
import type { PTYSession, ReadResult, SearchResult, SnapshotResult } from './types.ts'
22

33
export class OutputManager {
44
write(session: PTYSession, data: string): boolean {
@@ -26,4 +26,12 @@ export class OutputManager {
2626
const hasMore = offset + paginatedMatches.length < totalMatches
2727
return { matches: paginatedMatches, totalMatches, totalLines, offset, hasMore }
2828
}
29+
30+
snapshot(session: PTYSession): SnapshotResult {
31+
return {
32+
id: session.id,
33+
status: session.status,
34+
...session.snapshot.getState(),
35+
}
36+
}
2937
}

src/plugin/pty/session-lifecycle.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn, type IPty } from 'bun-pty'
22
import { RingBuffer } from './buffer.ts'
3+
import { TerminalSnapshot } from './snapshot.ts'
34
import type { PTYSession, PTYSessionInfo, SpawnOptions } from './types.ts'
45
import { DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS } from '../constants.ts'
56
import moment from 'moment'
@@ -24,6 +25,7 @@ export class SessionLifecycleManager {
2425
opts.title ?? (`${opts.command} ${args.join(' ')}`.trim() || `Terminal ${id.slice(-4)}`)
2526

2627
const buffer = new RingBuffer()
28+
const snapshot = new TerminalSnapshot(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS)
2729
return {
2830
id,
2931
title,
@@ -39,6 +41,7 @@ export class SessionLifecycleManager {
3941
parentAgent: opts.parentAgent,
4042
notifyOnExit: opts.notifyOnExit ?? false,
4143
buffer,
44+
snapshot,
4245
process: null, // will be set
4346
}
4447
}
@@ -63,6 +66,7 @@ export class SessionLifecycleManager {
6366
): void {
6467
session.process?.onData((data: string) => {
6568
session.buffer.append(data)
69+
session.snapshot.write(data)
6670
onData(session, data)
6771
})
6872

@@ -143,6 +147,8 @@ export class SessionLifecycleManager {
143147
}
144148

145149
toInfo(session: PTYSession): PTYSessionInfo {
150+
const snapshot = session.snapshot.getState()
151+
146152
return {
147153
id: session.id,
148154
title: session.title,
@@ -156,6 +162,7 @@ export class SessionLifecycleManager {
156162
pid: session.pid,
157163
createdAt: session.createdAt.toISOString(true),
158164
lineCount: session.buffer.length,
165+
size: snapshot.size,
159166
}
160167
}
161168
}

src/plugin/pty/snapshot.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Terminal } from '@xterm/headless'
2+
3+
// FNV-1a 64-bit constants for content hashing
4+
const FNV_OFFSET_BASIS = BigInt('14695981039346656037')
5+
const FNV_PRIME = BigInt('1099511628211')
6+
const FNV_MASK = BigInt('18446744073709551615')
7+
8+
export interface SnapshotCursor {
9+
row: number
10+
col: number
11+
visible: boolean
12+
}
13+
14+
export interface SnapshotSize {
15+
cols: number
16+
rows: number
17+
}
18+
19+
export interface SnapshotState {
20+
size: SnapshotSize
21+
cursor: SnapshotCursor
22+
text: string
23+
contentHash: string
24+
}
25+
26+
/**
27+
* Maintains a headless xterm.js terminal that mirrors PTY output,
28+
* providing parsed screen state (visible text, cursor, dimensions)
29+
* without any ANSI escape code noise.
30+
*/
31+
export class TerminalSnapshot {
32+
private readonly terminal: Terminal
33+
private pendingWrite: Promise<void> = Promise.resolve()
34+
private cachedState: SnapshotState
35+
36+
constructor(cols: number, rows: number, scrollback: number = rows * 10) {
37+
this.terminal = new Terminal({
38+
cols,
39+
rows,
40+
scrollback,
41+
allowProposedApi: true,
42+
})
43+
this.cachedState = this.buildState()
44+
}
45+
46+
/**
47+
* Queues data for the headless terminal to parse.
48+
* xterm.js processes writes asynchronously via time-slicing,
49+
* so we chain each write's completion callback to ensure the
50+
* cached state is always built from fully-parsed output.
51+
*/
52+
write(data: string): void {
53+
this.pendingWrite = this.pendingWrite.then(
54+
() =>
55+
new Promise<void>((resolve) => {
56+
this.terminal.write(data, resolve)
57+
})
58+
).then(() => {
59+
this.cachedState = this.buildState()
60+
})
61+
}
62+
63+
getState(): SnapshotState {
64+
return this.cachedState
65+
}
66+
67+
async getSettledState(): Promise<SnapshotState> {
68+
await this.pendingWrite
69+
return this.cachedState
70+
}
71+
72+
private buildState(): SnapshotState {
73+
const buffer = this.terminal.buffer.active
74+
const startLine = buffer.viewportY
75+
const lines: string[] = []
76+
77+
for (let row = 0; row < this.terminal.rows; row++) {
78+
const line = buffer.getLine(startLine + row)
79+
lines.push(line?.translateToString(false) ?? '')
80+
}
81+
82+
const text = lines.join('\n').replace(/\s+$/u, '')
83+
84+
return {
85+
size: {
86+
cols: this.terminal.cols,
87+
rows: this.terminal.rows,
88+
},
89+
cursor: {
90+
row: buffer.cursorY,
91+
col: buffer.cursorX,
92+
// Local @xterm/headless typings omit showCursor from modes
93+
visible: (this.terminal.modes as { showCursor?: boolean }).showCursor ?? true,
94+
},
95+
text,
96+
contentHash: computeContentHash(text),
97+
}
98+
}
99+
}
100+
101+
/** FNV-1a 64-bit hash for fast, stable change detection of screen content. */
102+
function computeContentHash(text: string): string {
103+
let hash = FNV_OFFSET_BASIS
104+
for (let index = 0; index < text.length; index++) {
105+
hash ^= BigInt(text.charCodeAt(index))
106+
hash = (hash * FNV_PRIME) & FNV_MASK
107+
}
108+
return hash.toString()
109+
}

src/plugin/pty/tools/snapshot.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { tool } from '@opencode-ai/plugin'
2+
import { manager } from '../manager.ts'
3+
import { buildSessionNotFoundError } from '../utils.ts'
4+
import DESCRIPTION from './snapshot.txt'
5+
6+
export const ptySnapshot = tool({
7+
description: DESCRIPTION,
8+
args: {
9+
id: tool.schema.string().describe('The PTY session ID (e.g., pty_a1b2c3d4)'),
10+
},
11+
async execute(args) {
12+
const snapshot = manager.snapshot(args.id)
13+
if (!snapshot) {
14+
throw buildSessionNotFoundError(args.id)
15+
}
16+
17+
return [
18+
`<pty_snapshot id="${snapshot.id}" status="${snapshot.status}" hash="${snapshot.contentHash}">`,
19+
`Size: ${snapshot.size.cols}x${snapshot.size.rows}`,
20+
`Cursor: (${snapshot.cursor.row}, ${snapshot.cursor.col}) visible=${snapshot.cursor.visible}`,
21+
'---',
22+
snapshot.text || '(Screen is empty)',
23+
`</pty_snapshot>`,
24+
].join('\n')
25+
},
26+
})

src/plugin/pty/tools/snapshot.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

src/plugin/pty/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { IPty } from 'bun-pty'
22
import type { RingBuffer } from './buffer.ts'
3+
import type { SnapshotState, TerminalSnapshot } from './snapshot.ts'
34
import type moment from 'moment'
45

56
export type PTYStatus = 'running' | 'exited' | 'killing' | 'killed'
@@ -21,6 +22,7 @@ export interface PTYSession {
2122
parentAgent?: string
2223
notifyOnExit: boolean
2324
buffer: RingBuffer
25+
snapshot: TerminalSnapshot
2426
process: IPty | null
2527
}
2628

@@ -37,6 +39,7 @@ export interface PTYSessionInfo {
3739
pid: number
3840
createdAt: string
3941
lineCount: number
42+
size?: SnapshotState['size']
4043
}
4144

4245
export interface SpawnOptions {
@@ -65,3 +68,8 @@ export interface SearchResult {
6568
offset: number
6669
hasMore: boolean
6770
}
71+
72+
export interface SnapshotResult extends SnapshotState {
73+
id: string
74+
status: PTYStatus
75+
}

0 commit comments

Comments
 (0)