@yolo-labs/yolobridge 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ansi-replay-state.js +661 -0
- package/dist/api-client.js +16 -1
- package/dist/attach-cmd.js +55 -3
- package/dist/frame-actions.js +3 -0
- package/dist/local-agent.js +264 -7
- package/dist/output-stream.js +33 -2
- package/dist/screen-digest.js +129 -0
- package/package.json +1 -1
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two things a RAW-BYTE replay cannot get right on its own
|
|
3
|
+
* (docs/YOLOBRIDGE_PLAN.md, "Live terminal streaming").
|
|
4
|
+
*
|
|
5
|
+
* `local-agent.ts`'s ring gives a remote viewer the daemon's last
|
|
6
|
+
* `RAW_RING_MAX_BYTES` of PTY output to replay, and replaying bytes is exact
|
|
7
|
+
* where reconstructing state is not. But a ring is a WINDOW onto a byte
|
|
8
|
+
* stream, and a window has two edges that raw replay alone gets wrong:
|
|
9
|
+
*
|
|
10
|
+
* 1. **The window can open mid-escape-sequence.** The ring's `baseOffset`
|
|
11
|
+
* (and the stream prime's start point) is wherever the trim happened to
|
|
12
|
+
* land — an arbitrary byte. Land it inside `ESC [ 3 8 ; 5 ; 1 9 6 m` and
|
|
13
|
+
* the viewer's xterm parses `8;5;196m` as TEXT, prints it, and carries on
|
|
14
|
+
* with whatever SGR/mode state that left behind — possibly forever. This
|
|
15
|
+
* is not a rare case: escapes are a large fraction of a TUI's bytes.
|
|
16
|
+
*
|
|
17
|
+
* Fixed by `AnsiScanner`, an escape-state machine run over the SAME byte
|
|
18
|
+
* stream the terminal consumed, which records the absolute offsets at
|
|
19
|
+
* which the parser is in GROUND state. A replay that starts at a ground
|
|
20
|
+
* offset starts where a fresh parser and the daemon's parser agree.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT "scan back to the last `\n`": an OSC string (a window
|
|
23
|
+
* title, an OSC 8 hyperlink, an OSC 52 clipboard payload) can contain a
|
|
24
|
+
* newline, so the newline heuristic is wrong in exactly the cases that
|
|
25
|
+
* matter. This is exact.
|
|
26
|
+
*
|
|
27
|
+
* 2. **The window can have rolled past a STICKY MODE.** The escape that
|
|
28
|
+
* entered the alternate screen, set the scroll region (DECSTBM), hid the
|
|
29
|
+
* cursor or turned autowrap off may be older than the oldest retained
|
|
30
|
+
* byte. Replay cannot restore what it no longer holds, and every one of
|
|
31
|
+
* those changes where subsequent output LANDS.
|
|
32
|
+
*
|
|
33
|
+
* Fixed by `TerminalModeTracker`, which watches the same byte stream for
|
|
34
|
+
* exactly those sticky sequences and can emit a short, idempotent MODE
|
|
35
|
+
* PROLOGUE ahead of the replay tail. See `buildModePrologue` for the
|
|
36
|
+
* split between what is read from the daemon's own `Terminal` (the
|
|
37
|
+
* authority) and what this tracker supplies because the public API does
|
|
38
|
+
* not expose it.
|
|
39
|
+
*
|
|
40
|
+
* PURE. No terminal, no timers, no I/O — every function here is a fold over a
|
|
41
|
+
* string, which is what makes the whole thing testable byte by byte.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ UTF-8 BYTE OFFSETS, not UTF-16 code units. Every offset this module
|
|
44
|
+
* produces or consumes is an absolute UTF-8 byte position in the PTY's byte
|
|
45
|
+
* stream, because that is what the ring, the chunk protocol and the viewer's
|
|
46
|
+
* splice arithmetic are all measured in.
|
|
47
|
+
*
|
|
48
|
+
* KNOWN LIMIT, stated rather than hidden: 8-bit C1 control bytes (a bare 0x9B
|
|
49
|
+
* as CSI, 0x9D as OSC) are not recognised. In a UTF-8 PTY — which is what
|
|
50
|
+
* every agent this daemon runs produces — those byte values only ever occur
|
|
51
|
+
* as continuation bytes inside a multi-byte character, where treating them as
|
|
52
|
+
* ordinary text is the correct reading.
|
|
53
|
+
*/
|
|
54
|
+
/** Cap on collected CSI parameter bytes. A real sequence is a few dozen bytes;
|
|
55
|
+
* this bounds what a hostile or corrupted stream can make us retain. Past the
|
|
56
|
+
* cap the parameters are truncated, which makes the sequence unrecognised —
|
|
57
|
+
* the safe failure, since an unrecognised sticky mode is simply not restored
|
|
58
|
+
* rather than restored wrongly. */
|
|
59
|
+
const MAX_CSI_PARAM_CHARS = 256;
|
|
60
|
+
/**
|
|
61
|
+
* A resumable escape-state machine over a UTF-8 byte stream.
|
|
62
|
+
*
|
|
63
|
+
* Resumable is the point: the ring's oldest retained byte is not the stream's
|
|
64
|
+
* first byte, so answering "is offset X ground?" requires knowing the parser
|
|
65
|
+
* state at the start of what is retained. `local-agent.ts` keeps one scanner
|
|
66
|
+
* fed with the bytes it TRIMS (giving the state at `baseOffset`) and one fed
|
|
67
|
+
* with every byte it PUSHES (driving the mode tracker).
|
|
68
|
+
*/
|
|
69
|
+
export class AnsiScanner {
|
|
70
|
+
state = 'ground';
|
|
71
|
+
collected = '';
|
|
72
|
+
/** Start over from a known state — used to fork a scan from a saved
|
|
73
|
+
* position without replaying the stream that led to it. */
|
|
74
|
+
reset(state = 'ground') {
|
|
75
|
+
this.state = state;
|
|
76
|
+
this.collected = '';
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Consume `text`, whose first byte sits at absolute offset `startOffset`.
|
|
80
|
+
* Returns the absolute offset one past its last byte.
|
|
81
|
+
*
|
|
82
|
+
* Iterates CODE POINTS while accounting in BYTES: every byte that can change
|
|
83
|
+
* parser state is ASCII, so a multi-byte character is always a single
|
|
84
|
+
* indivisible "printable" — which is exactly what makes it safe to walk the
|
|
85
|
+
* JS string the ring actually stores instead of encoding it to a Buffer.
|
|
86
|
+
*/
|
|
87
|
+
feed(text, startOffset, sink) {
|
|
88
|
+
let offset = startOffset;
|
|
89
|
+
for (let i = 0; i < text.length;) {
|
|
90
|
+
const code = text.codePointAt(i);
|
|
91
|
+
const units = code > 0xffff ? 2 : 1;
|
|
92
|
+
const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
|
|
93
|
+
// Reported BEFORE consuming: a replay that begins at this offset begins
|
|
94
|
+
// by parsing this code point, which is only safe from ground.
|
|
95
|
+
if (this.state === 'ground')
|
|
96
|
+
sink?.ground?.(offset);
|
|
97
|
+
this.step(code, sink);
|
|
98
|
+
offset += size;
|
|
99
|
+
i += units;
|
|
100
|
+
}
|
|
101
|
+
// The position after the last byte is a valid start too, when it is ground.
|
|
102
|
+
if (this.state === 'ground')
|
|
103
|
+
sink?.ground?.(offset);
|
|
104
|
+
return offset;
|
|
105
|
+
}
|
|
106
|
+
step(code, sink) {
|
|
107
|
+
switch (this.state) {
|
|
108
|
+
case 'ground':
|
|
109
|
+
if (code === 0x1b)
|
|
110
|
+
this.enterEsc();
|
|
111
|
+
return;
|
|
112
|
+
case 'esc':
|
|
113
|
+
this.stepEsc(code, sink);
|
|
114
|
+
return;
|
|
115
|
+
case 'esc-intermediate':
|
|
116
|
+
if (code >= 0x20 && code <= 0x2f)
|
|
117
|
+
return; // more intermediates
|
|
118
|
+
if (code === 0x1b)
|
|
119
|
+
return this.enterEsc();
|
|
120
|
+
// A final byte (or anything else) ends the sequence. Nothing here is
|
|
121
|
+
// a sticky mode, so no sink callback.
|
|
122
|
+
this.state = 'ground';
|
|
123
|
+
return;
|
|
124
|
+
case 'csi':
|
|
125
|
+
if (code === 0x1b)
|
|
126
|
+
return this.enterEsc();
|
|
127
|
+
// CAN / SUB abort a sequence in progress, everywhere.
|
|
128
|
+
if (code === 0x18 || code === 0x1a) {
|
|
129
|
+
this.state = 'ground';
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
133
|
+
const params = this.collected;
|
|
134
|
+
this.collected = '';
|
|
135
|
+
this.state = 'ground';
|
|
136
|
+
sink?.csi?.(params, String.fromCharCode(code));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (code >= 0x20 && code <= 0x3f) {
|
|
140
|
+
if (this.collected.length < MAX_CSI_PARAM_CHARS) {
|
|
141
|
+
this.collected += String.fromCharCode(code);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// A C0 control inside a CSI is EXECUTED and the sequence continues —
|
|
146
|
+
// so this is still not a ground boundary.
|
|
147
|
+
return;
|
|
148
|
+
case 'string':
|
|
149
|
+
if (code === 0x07) {
|
|
150
|
+
this.state = 'ground'; // BEL terminates an OSC
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (code === 0x1b) {
|
|
154
|
+
this.state = 'string-esc';
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (code === 0x18 || code === 0x1a) {
|
|
158
|
+
this.state = 'ground';
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// Everything else — INCLUDING `\n` — is string content. This is the
|
|
162
|
+
// whole reason "scan back to the last newline" is not a substitute.
|
|
163
|
+
return;
|
|
164
|
+
case 'string-esc':
|
|
165
|
+
if (code === 0x5c) {
|
|
166
|
+
this.state = 'ground'; // ST (`ESC \`)
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// The string ended and a fresh escape sequence began.
|
|
170
|
+
this.enterEsc();
|
|
171
|
+
this.stepEsc(code, sink);
|
|
172
|
+
return;
|
|
173
|
+
case 'single-shift':
|
|
174
|
+
// Exactly one character is shifted; whatever it was, we are back.
|
|
175
|
+
this.state = 'ground';
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
enterEsc() {
|
|
180
|
+
this.state = 'esc';
|
|
181
|
+
this.collected = '';
|
|
182
|
+
}
|
|
183
|
+
stepEsc(code, sink) {
|
|
184
|
+
if (code === 0x1b)
|
|
185
|
+
return this.enterEsc();
|
|
186
|
+
if (code === 0x5b) {
|
|
187
|
+
// `[` — CSI
|
|
188
|
+
this.state = 'csi';
|
|
189
|
+
this.collected = '';
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// `]` OSC, `P` DCS, `X` SOS, `^` PM, `_` APC — all string sequences.
|
|
193
|
+
if (code === 0x5d || code === 0x50 || code === 0x58 || code === 0x5e || code === 0x5f) {
|
|
194
|
+
this.state = 'string';
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (code === 0x4e || code === 0x4f) {
|
|
198
|
+
// `N` SS2 / `O` SS3
|
|
199
|
+
this.state = 'single-shift';
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (code >= 0x20 && code <= 0x2f) {
|
|
203
|
+
this.state = 'esc-intermediate';
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (code >= 0x30 && code <= 0x7e) {
|
|
207
|
+
this.state = 'ground';
|
|
208
|
+
sink?.esc?.(String.fromCharCode(code));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// A control byte (or a stray continuation byte) after ESC: xterm executes
|
|
212
|
+
// it and abandons the sequence.
|
|
213
|
+
this.state = 'ground';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The absolute offset a replay of `text` should actually start at, given that
|
|
218
|
+
* the caller WANTS to start at `target`.
|
|
219
|
+
*
|
|
220
|
+
* Prefers the greatest ground offset **at or before** `target`, because
|
|
221
|
+
* starting earlier only ever replays a few extra bytes the viewer would have
|
|
222
|
+
* applied anyway. Falls back to the smallest ground offset AFTER `target` when
|
|
223
|
+
* nothing earlier is retained — which is the ring's own case: `baseOffset` is
|
|
224
|
+
* the oldest byte in existence, so a partial escape sitting on it can only be
|
|
225
|
+
* skipped, never completed.
|
|
226
|
+
*
|
|
227
|
+
* Returns the end of `text` when the whole retained window is inside one
|
|
228
|
+
* unterminated sequence (a multi-hundred-kilobyte OSC). An empty replay is the
|
|
229
|
+
* honest answer there: every byte in the window is a fragment of something the
|
|
230
|
+
* viewer cannot parse the same way the daemon did.
|
|
231
|
+
*/
|
|
232
|
+
export function resolveGroundStart(text, textStartOffset, stateAtStart, target) {
|
|
233
|
+
const scanner = new AnsiScanner();
|
|
234
|
+
scanner.reset(stateAtStart);
|
|
235
|
+
let before = -1;
|
|
236
|
+
let after = -1;
|
|
237
|
+
const end = scanner.feed(text, textStartOffset, {
|
|
238
|
+
ground(offset) {
|
|
239
|
+
// Ground offsets arrive in increasing order, so the first one past the
|
|
240
|
+
// target is the smallest one past it.
|
|
241
|
+
if (offset <= target)
|
|
242
|
+
before = offset;
|
|
243
|
+
else if (after < 0)
|
|
244
|
+
after = offset;
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
if (before >= 0)
|
|
248
|
+
return before;
|
|
249
|
+
if (after >= 0)
|
|
250
|
+
return after;
|
|
251
|
+
return end;
|
|
252
|
+
}
|
|
253
|
+
export const DEFAULT_SGR = {
|
|
254
|
+
bold: false,
|
|
255
|
+
dim: false,
|
|
256
|
+
italic: false,
|
|
257
|
+
underline: false,
|
|
258
|
+
blink: false,
|
|
259
|
+
inverse: false,
|
|
260
|
+
invisible: false,
|
|
261
|
+
strikethrough: false,
|
|
262
|
+
fg: null,
|
|
263
|
+
bg: null,
|
|
264
|
+
};
|
|
265
|
+
export function sgrIsDefault(s) {
|
|
266
|
+
return (!s.bold && !s.dim && !s.italic && !s.underline && !s.blink &&
|
|
267
|
+
!s.inverse && !s.invisible && !s.strikethrough && s.fg === null && s.bg === null);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Watches the PTY byte stream for the sticky modes the daemon's own
|
|
271
|
+
* `Terminal` does not expose through its PUBLIC API.
|
|
272
|
+
*
|
|
273
|
+
* ⚠️ SCOPE, AND WHY THIS IS NOT "STATE INFERENCE". This does not look at a
|
|
274
|
+
* rendered screen and guess how it got that way — the thing the raw-replay
|
|
275
|
+
* design exists to eliminate. It reads the identical byte stream the
|
|
276
|
+
* authoritative parser read, and understands exactly four sequence families
|
|
277
|
+
* (DECSTBM, DECTCEM, the alternate-screen DEC private modes, and SGR) plus
|
|
278
|
+
* the two resets that clear them. Anything else it sees, it ignores.
|
|
279
|
+
*
|
|
280
|
+
* It exists only because `@xterm/headless`'s `IModes`/`IBuffer` expose neither
|
|
281
|
+
* the scroll region, nor cursor visibility, nor the current SGR attributes.
|
|
282
|
+
* Where the public API DOES expose a mode, `buildModePrologue` reads it from
|
|
283
|
+
* the terminal instead of from here — see that function.
|
|
284
|
+
*/
|
|
285
|
+
export class TerminalModeTracker {
|
|
286
|
+
rows;
|
|
287
|
+
normalRegion;
|
|
288
|
+
altRegion;
|
|
289
|
+
altActive = false;
|
|
290
|
+
cursorHidden = false;
|
|
291
|
+
sgr = { ...DEFAULT_SGR };
|
|
292
|
+
constructor(rows) {
|
|
293
|
+
this.rows = Math.max(1, Math.floor(rows) || 1);
|
|
294
|
+
this.normalRegion = this.fullRegion();
|
|
295
|
+
this.altRegion = this.fullRegion();
|
|
296
|
+
}
|
|
297
|
+
/** Feed this to `AnsiScanner.feed`. */
|
|
298
|
+
sink = {
|
|
299
|
+
csi: (params, final) => this.onCsi(params, final),
|
|
300
|
+
esc: (final) => this.onEsc(final),
|
|
301
|
+
};
|
|
302
|
+
/** The scroll region in force on the named buffer. The caller passes the
|
|
303
|
+
* buffer the TERMINAL reports as active, so the two sources cannot drift
|
|
304
|
+
* into reporting a region for a screen that is not showing. */
|
|
305
|
+
regionFor(buffer) {
|
|
306
|
+
return buffer === 'alternate' ? this.altRegion : this.normalRegion;
|
|
307
|
+
}
|
|
308
|
+
/** DECTCEM: `CSI ? 25 l` hides, `CSI ? 25 h` shows. Default: visible. */
|
|
309
|
+
get isCursorHidden() {
|
|
310
|
+
return this.cursorHidden;
|
|
311
|
+
}
|
|
312
|
+
/** The SGR attributes the next printed character would be drawn with. */
|
|
313
|
+
get sgrState() {
|
|
314
|
+
return this.sgr;
|
|
315
|
+
}
|
|
316
|
+
/** Whether the tracker believes the alternate screen is active. Only used
|
|
317
|
+
* to route DECSTBM to the right buffer; the PROLOGUE takes the flag itself
|
|
318
|
+
* from the terminal. */
|
|
319
|
+
get isAltScreen() {
|
|
320
|
+
return this.altActive;
|
|
321
|
+
}
|
|
322
|
+
isFullRegion(region) {
|
|
323
|
+
return region.top === 1 && region.bottom === this.rows;
|
|
324
|
+
}
|
|
325
|
+
fullRegion() {
|
|
326
|
+
return { top: 1, bottom: this.rows };
|
|
327
|
+
}
|
|
328
|
+
onEsc(final) {
|
|
329
|
+
if (final === 'c')
|
|
330
|
+
this.hardReset(); // RIS
|
|
331
|
+
}
|
|
332
|
+
onCsi(params, final) {
|
|
333
|
+
// DECSTR (`CSI ! p`) — a soft reset.
|
|
334
|
+
if (final === 'p' && params === '!') {
|
|
335
|
+
this.softReset();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const isPrivate = params.charCodeAt(0) === 0x3f; // '?'
|
|
339
|
+
const body = isPrivate ? params.slice(1) : params;
|
|
340
|
+
if (final === 'h' || final === 'l') {
|
|
341
|
+
// Only DEC PRIVATE modes are tracked here. The ANSI ones this prologue
|
|
342
|
+
// cares about (IRM) are readable from the terminal's public `modes`.
|
|
343
|
+
if (!isPrivate)
|
|
344
|
+
return;
|
|
345
|
+
const on = final === 'h';
|
|
346
|
+
for (const token of body.split(';')) {
|
|
347
|
+
const n = Number(token);
|
|
348
|
+
if (!Number.isFinite(n))
|
|
349
|
+
continue;
|
|
350
|
+
if (n === 25)
|
|
351
|
+
this.cursorHidden = !on;
|
|
352
|
+
else if (n === 1049 || n === 1047 || n === 47)
|
|
353
|
+
this.setAltScreen(on);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (final === 'r' && !isPrivate) {
|
|
358
|
+
this.setScrollRegion(body);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (final === 'm' && !isPrivate) {
|
|
362
|
+
this.applySgr(body);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Entering OR leaving the alternate screen resets the ALT buffer's scroll
|
|
367
|
+
* region to the full screen, while the normal buffer's survives the round
|
|
368
|
+
* trip. That is xterm's observed behaviour, and it is why the two regions
|
|
369
|
+
* are tracked separately rather than as one value.
|
|
370
|
+
*/
|
|
371
|
+
setAltScreen(on) {
|
|
372
|
+
if (this.altActive === on)
|
|
373
|
+
return;
|
|
374
|
+
this.altActive = on;
|
|
375
|
+
this.altRegion = this.fullRegion();
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* DECSTBM. Absent parameters mean "the whole screen"; a bottom past the last
|
|
379
|
+
* row is clamped; a degenerate or inverted region is IGNORED and leaves the
|
|
380
|
+
* previous one in force — all three matching what xterm does with the same
|
|
381
|
+
* bytes.
|
|
382
|
+
*/
|
|
383
|
+
setScrollRegion(body) {
|
|
384
|
+
const parts = body.split(';');
|
|
385
|
+
const rawTop = parts[0] ? Number(parts[0]) : 1;
|
|
386
|
+
const rawBottom = parts.length > 1 && parts[1] ? Number(parts[1]) : this.rows;
|
|
387
|
+
if (!Number.isFinite(rawTop) || !Number.isFinite(rawBottom))
|
|
388
|
+
return;
|
|
389
|
+
const top = rawTop < 1 ? 1 : Math.floor(rawTop);
|
|
390
|
+
const bottom = Math.floor(rawBottom > this.rows ? this.rows : rawBottom);
|
|
391
|
+
if (bottom <= top)
|
|
392
|
+
return;
|
|
393
|
+
const region = { top, bottom };
|
|
394
|
+
if (this.altActive)
|
|
395
|
+
this.altRegion = region;
|
|
396
|
+
else
|
|
397
|
+
this.normalRegion = region;
|
|
398
|
+
}
|
|
399
|
+
/** RIS (`ESC c`) — everything back to power-on. */
|
|
400
|
+
hardReset() {
|
|
401
|
+
this.normalRegion = this.fullRegion();
|
|
402
|
+
this.altRegion = this.fullRegion();
|
|
403
|
+
this.altActive = false;
|
|
404
|
+
this.cursorHidden = false;
|
|
405
|
+
this.sgr = { ...DEFAULT_SGR };
|
|
406
|
+
}
|
|
407
|
+
/** DECSTR (`CSI ! p`) — scroll region, cursor visibility and SGR reset; the
|
|
408
|
+
* active buffer is NOT switched. */
|
|
409
|
+
softReset() {
|
|
410
|
+
this.normalRegion = this.fullRegion();
|
|
411
|
+
this.altRegion = this.fullRegion();
|
|
412
|
+
this.cursorHidden = false;
|
|
413
|
+
this.sgr = { ...DEFAULT_SGR };
|
|
414
|
+
}
|
|
415
|
+
applySgr(body) {
|
|
416
|
+
const tokens = body === '' ? ['0'] : body.split(';');
|
|
417
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
418
|
+
const token = tokens[i];
|
|
419
|
+
// Colon sub-parameters (`4:3` for curly underline, `38:5:196` for an
|
|
420
|
+
// indexed colour) are a single parameter as far as `;` splitting is
|
|
421
|
+
// concerned, so they are unpacked here rather than in the loop below.
|
|
422
|
+
if (token.includes(':')) {
|
|
423
|
+
this.applySgrSubParam(token);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const n = Number(token === '' ? '0' : token);
|
|
427
|
+
if (!Number.isFinite(n))
|
|
428
|
+
continue;
|
|
429
|
+
if (n === 38 || n === 48) {
|
|
430
|
+
i = this.applyExtendedColor(tokens, i, n === 38);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
this.applySgrCode(n);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
applySgrCode(n) {
|
|
437
|
+
switch (true) {
|
|
438
|
+
case n === 0:
|
|
439
|
+
this.sgr = { ...DEFAULT_SGR };
|
|
440
|
+
return;
|
|
441
|
+
case n === 1:
|
|
442
|
+
this.sgr = { ...this.sgr, bold: true };
|
|
443
|
+
return;
|
|
444
|
+
case n === 2:
|
|
445
|
+
this.sgr = { ...this.sgr, dim: true };
|
|
446
|
+
return;
|
|
447
|
+
case n === 3:
|
|
448
|
+
this.sgr = { ...this.sgr, italic: true };
|
|
449
|
+
return;
|
|
450
|
+
// 21 is doubly-underlined in xterm, not "bold off".
|
|
451
|
+
case n === 4 || n === 21:
|
|
452
|
+
this.sgr = { ...this.sgr, underline: true };
|
|
453
|
+
return;
|
|
454
|
+
case n === 5 || n === 6:
|
|
455
|
+
this.sgr = { ...this.sgr, blink: true };
|
|
456
|
+
return;
|
|
457
|
+
case n === 7:
|
|
458
|
+
this.sgr = { ...this.sgr, inverse: true };
|
|
459
|
+
return;
|
|
460
|
+
case n === 8:
|
|
461
|
+
this.sgr = { ...this.sgr, invisible: true };
|
|
462
|
+
return;
|
|
463
|
+
case n === 9:
|
|
464
|
+
this.sgr = { ...this.sgr, strikethrough: true };
|
|
465
|
+
return;
|
|
466
|
+
case n === 22:
|
|
467
|
+
this.sgr = { ...this.sgr, bold: false, dim: false };
|
|
468
|
+
return;
|
|
469
|
+
case n === 23:
|
|
470
|
+
this.sgr = { ...this.sgr, italic: false };
|
|
471
|
+
return;
|
|
472
|
+
case n === 24:
|
|
473
|
+
this.sgr = { ...this.sgr, underline: false };
|
|
474
|
+
return;
|
|
475
|
+
case n === 25:
|
|
476
|
+
this.sgr = { ...this.sgr, blink: false };
|
|
477
|
+
return;
|
|
478
|
+
case n === 27:
|
|
479
|
+
this.sgr = { ...this.sgr, inverse: false };
|
|
480
|
+
return;
|
|
481
|
+
case n === 28:
|
|
482
|
+
this.sgr = { ...this.sgr, invisible: false };
|
|
483
|
+
return;
|
|
484
|
+
case n === 29:
|
|
485
|
+
this.sgr = { ...this.sgr, strikethrough: false };
|
|
486
|
+
return;
|
|
487
|
+
case n === 39:
|
|
488
|
+
this.sgr = { ...this.sgr, fg: null };
|
|
489
|
+
return;
|
|
490
|
+
case n === 49:
|
|
491
|
+
this.sgr = { ...this.sgr, bg: null };
|
|
492
|
+
return;
|
|
493
|
+
case (n >= 30 && n <= 37) || (n >= 90 && n <= 97):
|
|
494
|
+
this.sgr = { ...this.sgr, fg: String(n) };
|
|
495
|
+
return;
|
|
496
|
+
case (n >= 40 && n <= 47) || (n >= 100 && n <= 107):
|
|
497
|
+
this.sgr = { ...this.sgr, bg: String(n) };
|
|
498
|
+
return;
|
|
499
|
+
default:
|
|
500
|
+
// Deliberately unhandled: 10-20 (fonts), 26/50-55 (proportional,
|
|
501
|
+
// framed, overlined), 58/59 (underline colour), 73-75 (super/sub).
|
|
502
|
+
// Not restoring an attribute we do not model is honest; restoring a
|
|
503
|
+
// guessed one is not. Divergence from an unmodelled attribute is what
|
|
504
|
+
// the viewer's integrity check exists to catch.
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/** `38:5:196`, `48:2::10:20:30`, `4:3`, … */
|
|
509
|
+
applySgrSubParam(token) {
|
|
510
|
+
const parts = token.split(':');
|
|
511
|
+
const head = Number(parts[0]);
|
|
512
|
+
if (!Number.isFinite(head))
|
|
513
|
+
return;
|
|
514
|
+
if (head === 4) {
|
|
515
|
+
// `4:0` is underline off; every other sub-value is some underline style.
|
|
516
|
+
this.sgr = { ...this.sgr, underline: parts[1] !== '0' };
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (head === 38 || head === 48) {
|
|
520
|
+
// Re-spell in the semicolon form, dropping the colon form's empty
|
|
521
|
+
// colour-space slot (`38:2::r:g:b`).
|
|
522
|
+
const rest = parts.slice(1).filter((p) => p !== '');
|
|
523
|
+
if (rest.length === 0)
|
|
524
|
+
return;
|
|
525
|
+
const fragment = `${head};${rest.join(';')}`;
|
|
526
|
+
this.sgr = head === 38 ? { ...this.sgr, fg: fragment } : { ...this.sgr, bg: fragment };
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
this.applySgrCode(head);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* `38;5;n` (indexed) / `38;2;r;g;b` (direct), and the same for `48`.
|
|
533
|
+
* Returns the index of the LAST token consumed so the caller's loop resumes
|
|
534
|
+
* after the whole colour, never re-reading a channel value as a new
|
|
535
|
+
* attribute (which is how `38;2;1;7;0` would silently turn on inverse).
|
|
536
|
+
*/
|
|
537
|
+
applyExtendedColor(tokens, i, isFg) {
|
|
538
|
+
const kind = Number(tokens[i + 1]);
|
|
539
|
+
let consumed;
|
|
540
|
+
if (kind === 5)
|
|
541
|
+
consumed = 2;
|
|
542
|
+
else if (kind === 2)
|
|
543
|
+
consumed = 4;
|
|
544
|
+
else
|
|
545
|
+
return i; // malformed — ignore the introducer and keep parsing
|
|
546
|
+
const slice = tokens.slice(i, i + consumed + 1);
|
|
547
|
+
if (slice.length < consumed + 1)
|
|
548
|
+
return tokens.length; // truncated
|
|
549
|
+
const fragment = slice.join(';');
|
|
550
|
+
this.sgr = isFg ? { ...this.sgr, fg: fragment } : { ...this.sgr, bg: fragment };
|
|
551
|
+
return i + consumed;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const ESC = '\x1b';
|
|
555
|
+
/**
|
|
556
|
+
* A short escape prologue that puts a fresh terminal into the daemon's sticky
|
|
557
|
+
* mode state, to be written IMMEDIATELY BEFORE a truncated raw replay tail.
|
|
558
|
+
*
|
|
559
|
+
* ⚠️ EMIT THIS ONLY WHEN THE REPLAY IS TRUNCATED. When the ring still holds
|
|
560
|
+
* the whole session the replay is self-contained by construction and a
|
|
561
|
+
* prologue is pure risk for no gain — it would be re-asserting state the tail
|
|
562
|
+
* is about to set anyway, and two of these sequences (DECOM, DECSTBM) move the
|
|
563
|
+
* cursor as a side effect.
|
|
564
|
+
*
|
|
565
|
+
* ONLY NON-DEFAULT MODES ARE EMITTED, for that same reason: a viewer's
|
|
566
|
+
* terminal has just been `reset()`, so it is already at power-on defaults, and
|
|
567
|
+
* `CSI ? 6 l` on an already-unset origin mode is not a no-op — it homes the
|
|
568
|
+
* cursor. When the daemon is in a wholly default state this returns `''`.
|
|
569
|
+
*
|
|
570
|
+
* WHAT IS RESTORED, and from where:
|
|
571
|
+
* - alternate screen ← the terminal (`buffer.active.type`)
|
|
572
|
+
* - autowrap (DECAWM) ← the terminal (`modes.wraparoundMode`)
|
|
573
|
+
* - origin mode (DECOM) ← the terminal (`modes.originMode`)
|
|
574
|
+
* - insert mode (IRM) ← the terminal (`modes.insertMode`)
|
|
575
|
+
* - reverse wraparound ← the terminal (`modes.reverseWraparoundMode`)
|
|
576
|
+
* - scroll region (DECSTBM) ← `TerminalModeTracker` (NOT on the public API)
|
|
577
|
+
* - cursor visibility ← `TerminalModeTracker` (NOT on the public API)
|
|
578
|
+
* - current SGR ← `TerminalModeTracker` (NOT on the public API)
|
|
579
|
+
*
|
|
580
|
+
* WHAT IS NOT RESTORED, deliberately:
|
|
581
|
+
* - the daemon's CURRENT cursor position. The tail replays bytes the daemon
|
|
582
|
+
* emitted in the PAST, from a cursor position that is also in the past;
|
|
583
|
+
* homing to where the cursor is NOW would put the tail's first relative
|
|
584
|
+
* move in the wrong place. The tail's own moves re-establish it.
|
|
585
|
+
* - the saved cursor (DECSC), character sets (SCS), tab stops, the palette
|
|
586
|
+
* (OSC 4), and the input-affecting modes (DECCKM, bracketed paste, mouse
|
|
587
|
+
* reporting) — the last group because this tile has no input path at all.
|
|
588
|
+
*
|
|
589
|
+
* ⚠️ THE ONE CURSOR MOVE THAT IS EMITTED, and why it is not the above. When a
|
|
590
|
+
* SCROLL REGION is in force, the replay must not begin OUTSIDE it. A region
|
|
591
|
+
* confines scrolling to a band of rows; the rows outside that band are static,
|
|
592
|
+
* so anything the replay writes there before it converges stays there
|
|
593
|
+
* FOREVER — the tail will never scroll it away or paint over it. And a fresh
|
|
594
|
+
* terminal starts at row 1, which under a region like `3;10` is exactly one of
|
|
595
|
+
* those static rows.
|
|
596
|
+
*
|
|
597
|
+
* So the prologue parks the cursor at the region's bottom-left. That is not a
|
|
598
|
+
* guess at where the cursor WAS: it is the one thing actually known about it —
|
|
599
|
+
* the output being replayed scrolled within this region, so it was produced
|
|
600
|
+
* with the cursor inside it. Bottom-left is the position from which the first
|
|
601
|
+
* line feed scrolls rather than descending through rows the daemon was not
|
|
602
|
+
* writing to. With no region in force (the common full-screen TUI) nothing is
|
|
603
|
+
* emitted, because then every row scrolls and residue cannot persist.
|
|
604
|
+
*
|
|
605
|
+
* Ordering is load-bearing: the alternate-screen switch comes FIRST because
|
|
606
|
+
* DECSTBM is per-buffer; DECOM comes before the cursor park because setting it
|
|
607
|
+
* homes the cursor; SGR comes LAST because no mode set touches it.
|
|
608
|
+
*/
|
|
609
|
+
export function buildModePrologue(observed, tracker) {
|
|
610
|
+
let out = '';
|
|
611
|
+
if (observed.altScreen)
|
|
612
|
+
out += `${ESC}[?1049h`;
|
|
613
|
+
const region = tracker.regionFor(observed.altScreen ? 'alternate' : 'normal');
|
|
614
|
+
const scoped = !tracker.isFullRegion(region);
|
|
615
|
+
if (scoped)
|
|
616
|
+
out += `${ESC}[${region.top};${region.bottom}r`;
|
|
617
|
+
if (!observed.wraparound)
|
|
618
|
+
out += `${ESC}[?7l`;
|
|
619
|
+
if (observed.origin)
|
|
620
|
+
out += `${ESC}[?6h`;
|
|
621
|
+
if (observed.reverseWraparound)
|
|
622
|
+
out += `${ESC}[?45h`;
|
|
623
|
+
if (observed.insert)
|
|
624
|
+
out += `${ESC}[4h`;
|
|
625
|
+
if (tracker.isCursorHidden)
|
|
626
|
+
out += `${ESC}[?25l`;
|
|
627
|
+
if (scoped) {
|
|
628
|
+
// Bottom-left OF THE REGION — see the header. Under origin mode a CUP row
|
|
629
|
+
// is region-relative, so the same row has two spellings and using the
|
|
630
|
+
// wrong one would park the cursor outside the very band this exists to
|
|
631
|
+
// stay inside.
|
|
632
|
+
const row = observed.origin ? region.bottom - region.top + 1 : region.bottom;
|
|
633
|
+
out += `${ESC}[${row};1H`;
|
|
634
|
+
}
|
|
635
|
+
const sgr = tracker.sgrState;
|
|
636
|
+
if (!sgrIsDefault(sgr)) {
|
|
637
|
+
const codes = ['0'];
|
|
638
|
+
if (sgr.bold)
|
|
639
|
+
codes.push('1');
|
|
640
|
+
if (sgr.dim)
|
|
641
|
+
codes.push('2');
|
|
642
|
+
if (sgr.italic)
|
|
643
|
+
codes.push('3');
|
|
644
|
+
if (sgr.underline)
|
|
645
|
+
codes.push('4');
|
|
646
|
+
if (sgr.blink)
|
|
647
|
+
codes.push('5');
|
|
648
|
+
if (sgr.inverse)
|
|
649
|
+
codes.push('7');
|
|
650
|
+
if (sgr.invisible)
|
|
651
|
+
codes.push('8');
|
|
652
|
+
if (sgr.strikethrough)
|
|
653
|
+
codes.push('9');
|
|
654
|
+
if (sgr.fg)
|
|
655
|
+
codes.push(sgr.fg);
|
|
656
|
+
if (sgr.bg)
|
|
657
|
+
codes.push(sgr.bg);
|
|
658
|
+
out += `${ESC}[${codes.join(';')}m`;
|
|
659
|
+
}
|
|
660
|
+
return out;
|
|
661
|
+
}
|
package/dist/api-client.js
CHANGED
|
@@ -191,13 +191,25 @@ export async function postHeartbeat(cfg, workspaceId, attachmentId) {
|
|
|
191
191
|
const body = await postEvent(cfg, workspaceId, { attachmentId, type: 'heartbeat' });
|
|
192
192
|
return Boolean(body?.recorded);
|
|
193
193
|
}
|
|
194
|
-
export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy) {
|
|
194
|
+
export async function postReadOutputReply(cfg, workspaceId, attachmentId, requestId, output, busy, extra) {
|
|
195
195
|
const body = await postEvent(cfg, workspaceId, {
|
|
196
196
|
attachmentId,
|
|
197
197
|
type: 'read-output-reply',
|
|
198
198
|
requestId,
|
|
199
199
|
output,
|
|
200
200
|
busy,
|
|
201
|
+
...(extra?.cols && extra?.rows ? { cols: extra.cols, rows: extra.rows } : {}),
|
|
202
|
+
...(extra?.raw
|
|
203
|
+
? {
|
|
204
|
+
raw: extra.raw.data,
|
|
205
|
+
epoch: extra.raw.epoch,
|
|
206
|
+
baseOffset: extra.raw.baseOffset,
|
|
207
|
+
endOffset: extra.raw.endOffset,
|
|
208
|
+
truncated: extra.raw.truncated,
|
|
209
|
+
...(extra.raw.prologue ? { prologue: extra.raw.prologue } : {}),
|
|
210
|
+
}
|
|
211
|
+
: {}),
|
|
212
|
+
...(extra?.screenDigest ? { screenDigest: extra.screenDigest } : {}),
|
|
201
213
|
});
|
|
202
214
|
return Boolean(body?.resolved);
|
|
203
215
|
}
|
|
@@ -230,6 +242,9 @@ export async function postOutputChunk(cfg, workspaceId, attachmentId, chunk) {
|
|
|
230
242
|
seq: chunk.seq,
|
|
231
243
|
data: chunk.data,
|
|
232
244
|
droppedBytes: chunk.droppedBytes,
|
|
245
|
+
...(chunk.epoch ? { epoch: chunk.epoch } : {}),
|
|
246
|
+
...(typeof chunk.startOffset === 'number' ? { startOffset: chunk.startOffset } : {}),
|
|
247
|
+
...(chunk.cols && chunk.rows ? { cols: chunk.cols, rows: chunk.rows } : {}),
|
|
233
248
|
});
|
|
234
249
|
return Boolean(body?.relayed);
|
|
235
250
|
}
|