@@ -21,26 +21,94 @@ export interface SnapshotState {
2121 cursor : SnapshotCursor
2222 text : string
2323 contentHash : string
24+ /** Monotonically increasing sequence number. Increments only on content changes. */
25+ seq : number
26+ /** Per-line content at this snapshot, indexed by line number. */
27+ lines : string [ ]
2428}
2529
30+ /** A single changed line in a diff. */
31+ export interface LineDiff {
32+ line : number
33+ /** 'changed' = content differs, 'added' = line exists in new but not old, 'removed' = line existed in old but not new */
34+ type : 'changed' | 'added' | 'removed'
35+ /** The new content (undefined for 'removed' lines). */
36+ content ?: string
37+ /** The old content (undefined for 'added' lines). */
38+ old ?: string
39+ }
40+
41+ export interface SnapshotDiff {
42+ /** The snapshot state at the current moment. */
43+ state : SnapshotState
44+ /** The seq we diffed against. */
45+ sinceSeq : number
46+ /** Individual line-level changes. */
47+ changes : LineDiff [ ]
48+ /** True if the requested sinceSeq was not found in history (full snapshot returned instead). */
49+ historyTruncated : boolean
50+ }
51+
52+ export interface WaitCondition {
53+ /** RegExp to match against screen text. Resolves on first match. */
54+ search ?: RegExp
55+ /** Resolve when the content hash stays unchanged for this many ms. */
56+ hashStableMs ?: number
57+ /** Maximum time to wait before giving up (default: 30000). */
58+ timeoutMs ?: number
59+ }
60+
61+ export interface WaitResult {
62+ /** Whether the condition was met (false = timed out). */
63+ matched : boolean
64+ /** Total wall-clock time spent waiting, in ms. */
65+ waitedMs : number
66+ /** The snapshot state at the moment of match or timeout. */
67+ state : SnapshotState
68+ }
69+
70+ /** Stored frame in the history ring buffer. */
71+ interface HistoryFrame {
72+ seq : number
73+ contentHash : string
74+ lines : string [ ]
75+ }
76+
77+ const DEFAULT_HISTORY_CAPACITY = 200
78+
2679/**
2780 * Maintains a headless xterm.js terminal that mirrors PTY output,
2881 * providing parsed screen state (visible text, cursor, dimensions)
2982 * without any ANSI escape code noise.
83+ *
84+ * Also maintains a ring buffer of deduped snapshot frames (keyed by
85+ * content hash) for seq-based diffing.
3086 */
3187export class TerminalSnapshot {
3288 private readonly terminal : Terminal
3389 private pendingWrite : Promise < void > = Promise . resolve ( )
3490 private cachedState : SnapshotState
3591
36- constructor ( cols : number , rows : number , scrollback : number = rows * 10 ) {
92+ // Seq-based history
93+ private seq = 0
94+ private history : HistoryFrame [ ] = [ ]
95+ private historyCapacity : number
96+
97+ constructor (
98+ cols : number ,
99+ rows : number ,
100+ scrollback : number = rows * 10 ,
101+ historyCapacity : number = DEFAULT_HISTORY_CAPACITY
102+ ) {
37103 this . terminal = new Terminal ( {
38104 cols,
39105 rows,
40106 scrollback,
41107 allowProposedApi : true ,
42108 } )
109+ this . historyCapacity = historyCapacity
43110 this . cachedState = this . buildState ( )
111+ this . pushHistory ( this . cachedState )
44112 }
45113
46114 /**
@@ -50,14 +118,22 @@ export class TerminalSnapshot {
50118 * cached state is always built from fully-parsed output.
51119 */
52120 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- } )
121+ this . pendingWrite = this . pendingWrite
122+ . then (
123+ ( ) =>
124+ new Promise < void > ( ( resolve ) => {
125+ this . terminal . write ( data , resolve )
126+ } )
127+ )
128+ . then ( ( ) => {
129+ const newState = this . buildState ( )
130+ if ( newState . contentHash !== this . cachedState . contentHash ) {
131+ this . seq ++
132+ newState . seq = this . seq
133+ this . pushHistory ( newState )
134+ }
135+ this . cachedState = newState
136+ } )
61137 }
62138
63139 getState ( ) : SnapshotState {
@@ -69,6 +145,119 @@ export class TerminalSnapshot {
69145 return this . cachedState
70146 }
71147
148+ /**
149+ * Get the current snapshot with a line-level diff against a previous seq.
150+ * If `sinceSeq` is not found in history, returns the full state with
151+ * `historyTruncated: true`.
152+ */
153+ getDiff ( sinceSeq : number ) : SnapshotDiff {
154+ const current = this . cachedState
155+ const oldFrame = this . history . find ( ( f ) => f . seq === sinceSeq )
156+
157+ if ( ! oldFrame ) {
158+ // History truncated or invalid seq - return full state as "all added"
159+ const changes : LineDiff [ ] = [ ]
160+ for ( let i = 0 ; i < current . lines . length ; i ++ ) {
161+ const content = current . lines [ i ] !
162+ if ( content . trim ( ) ) {
163+ changes . push ( { line : i , type : 'added' , content } )
164+ }
165+ }
166+
167+ return {
168+ state : current ,
169+ sinceSeq,
170+ changes,
171+ historyTruncated : true ,
172+ }
173+ }
174+
175+ const changes = computeLineDiff ( oldFrame . lines , current . lines )
176+
177+ return {
178+ state : current ,
179+ sinceSeq : oldFrame . seq ,
180+ changes,
181+ historyTruncated : false ,
182+ }
183+ }
184+
185+ /**
186+ * Polls the terminal state until a condition is met or timeout is reached.
187+ * The polling loop runs server-side so the calling agent pays for only one
188+ * tool invocation instead of many.
189+ *
190+ * Supported conditions (at least one must be provided):
191+ * - `search`: resolves when screen text matches the regex
192+ * - `hashStableMs`: resolves when content hash is unchanged for N ms
193+ *
194+ * If both are provided, the first condition to match wins.
195+ */
196+ async waitForCondition ( condition : WaitCondition ) : Promise < WaitResult > {
197+ const POLL_INTERVAL_MS = 100
198+ const timeoutMs = condition . timeoutMs ?? 30_000
199+ const start = Date . now ( )
200+
201+ let lastHash = this . cachedState . contentHash
202+ let lastHashChangeTime = start
203+
204+ const check = ( ) : WaitResult | null => {
205+ const state = this . cachedState
206+
207+ // search condition
208+ if ( condition . search && condition . search . test ( state . text ) ) {
209+ return { matched : true , waitedMs : Date . now ( ) - start , state }
210+ }
211+
212+ // hashStable condition: track when hash last changed
213+ if ( condition . hashStableMs != null ) {
214+ if ( state . contentHash !== lastHash ) {
215+ lastHash = state . contentHash
216+ lastHashChangeTime = Date . now ( )
217+ } else if ( Date . now ( ) - lastHashChangeTime >= condition . hashStableMs ) {
218+ return { matched : true , waitedMs : Date . now ( ) - start , state }
219+ }
220+ }
221+
222+ return null
223+ }
224+
225+ // Check immediately before entering the poll loop
226+ const immediate = check ( )
227+ if ( immediate ) return immediate
228+
229+ return new Promise < WaitResult > ( ( resolve ) => {
230+ const interval = setInterval ( ( ) => {
231+ const result = check ( )
232+ if ( result ) {
233+ clearInterval ( interval )
234+ resolve ( result )
235+ return
236+ }
237+ if ( Date . now ( ) - start >= timeoutMs ) {
238+ clearInterval ( interval )
239+ resolve ( {
240+ matched : false ,
241+ waitedMs : Date . now ( ) - start ,
242+ state : this . cachedState ,
243+ } )
244+ }
245+ } , POLL_INTERVAL_MS )
246+ } )
247+ }
248+
249+ private pushHistory ( state : SnapshotState ) : void {
250+ this . history . push ( {
251+ seq : state . seq ,
252+ contentHash : state . contentHash ,
253+ lines : state . lines ,
254+ } )
255+ // Evict oldest if over capacity
256+ while ( this . history . length > this . historyCapacity ) {
257+ this . history . shift ( )
258+ }
259+ }
260+
72261 private buildState ( ) : SnapshotState {
73262 const buffer = this . terminal . buffer . active
74263 const startLine = buffer . viewportY
@@ -94,8 +283,38 @@ export class TerminalSnapshot {
94283 } ,
95284 text,
96285 contentHash : computeContentHash ( text ) ,
286+ seq : this . seq ,
287+ lines,
288+ }
289+ }
290+ }
291+
292+ /** Compute line-level diff between old and new screen content. */
293+ function computeLineDiff ( oldLines : string [ ] , newLines : string [ ] ) : LineDiff [ ] {
294+ const changes : LineDiff [ ] = [ ]
295+ const maxLen = Math . max ( oldLines . length , newLines . length )
296+
297+ for ( let i = 0 ; i < maxLen ; i ++ ) {
298+ const oldLine = i < oldLines . length ? oldLines [ i ] : undefined
299+ const newLine = i < newLines . length ? newLines [ i ] : undefined
300+
301+ if ( oldLine === undefined && newLine !== undefined ) {
302+ // Line added (screen grew)
303+ if ( newLine . trim ( ) ) {
304+ changes . push ( { line : i , type : 'added' , content : newLine } )
305+ }
306+ } else if ( oldLine !== undefined && newLine === undefined ) {
307+ // Line removed (screen shrank)
308+ if ( oldLine . trim ( ) ) {
309+ changes . push ( { line : i , type : 'removed' , old : oldLine } )
310+ }
311+ } else if ( oldLine !== newLine ) {
312+ // Content changed
313+ changes . push ( { line : i , type : 'changed' , content : newLine , old : oldLine } )
97314 }
98315 }
316+
317+ return changes
99318}
100319
101320/** FNV-1a 64-bit hash for fast, stable change detection of screen content. */
@@ -106,4 +325,4 @@ function computeContentHash(text: string): string {
106325 hash = ( hash * FNV_PRIME ) & FNV_MASK
107326 }
108327 return hash . toString ( )
109- }
328+ }
0 commit comments