@actana/sdk 0.2.2
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/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/core-client.d.ts +616 -0
- package/dist/core-client.d.ts.map +1 -0
- package/dist/core-client.js +1036 -0
- package/dist/core-client.js.map +1 -0
- package/dist/core-link-cursor-storage.d.ts +32 -0
- package/dist/core-link-cursor-storage.d.ts.map +1 -0
- package/dist/core-link-cursor-storage.js +66 -0
- package/dist/core-link-cursor-storage.js.map +1 -0
- package/dist/core-link-frames.d.ts +1123 -0
- package/dist/core-link-frames.d.ts.map +1 -0
- package/dist/core-link-frames.js +349 -0
- package/dist/core-link-frames.js.map +1 -0
- package/dist/core-link-socket.d.ts +54 -0
- package/dist/core-link-socket.d.ts.map +1 -0
- package/dist/core-link-socket.js +74 -0
- package/dist/core-link-socket.js.map +1 -0
- package/dist/core-link-transport.d.ts +177 -0
- package/dist/core-link-transport.d.ts.map +1 -0
- package/dist/core-link-transport.js +432 -0
- package/dist/core-link-transport.js.map +1 -0
- package/dist/core-registration-blob.d.ts +52 -0
- package/dist/core-registration-blob.d.ts.map +1 -0
- package/dist/core-registration-blob.js +61 -0
- package/dist/core-registration-blob.js.map +1 -0
- package/dist/core-session.d.ts +321 -0
- package/dist/core-session.d.ts.map +1 -0
- package/dist/core-session.js +660 -0
- package/dist/core-session.js.map +1 -0
- package/dist/durable-core-client.d.ts +172 -0
- package/dist/durable-core-client.d.ts.map +1 -0
- package/dist/durable-core-client.js +264 -0
- package/dist/durable-core-client.js.map +1 -0
- package/dist/terminal-screen.d.ts +139 -0
- package/dist/terminal-screen.d.ts.map +1 -0
- package/dist/terminal-screen.js +807 -0
- package/dist/terminal-screen.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
// A terminal, small enough to live in an SDK and faithful enough to read a
|
|
2
|
+
// harness with (#129 D2, issue 155).
|
|
3
|
+
//
|
|
4
|
+
// The reason this file exists rather than a call to `stripAnsi`: a harness does
|
|
5
|
+
// not lay text out with spaces and newlines, it lays it out with cursor moves.
|
|
6
|
+
// Claude Code draws its folder-trust dialog as
|
|
7
|
+
// `ESC[4G1.ESC[7GYes,ESC[12GIESC[14Gtrust`, repaints its spinner by walking the
|
|
8
|
+
// cursor back up and erasing lines, and rewrites the same row eighty times a
|
|
9
|
+
// second. Delete the escapes from that stream and you get one very long line of
|
|
10
|
+
// concatenated spinner frames with the words jammed together — not a screen, and
|
|
11
|
+
// nothing a caller can read an answer out of. The only way to know what a
|
|
12
|
+
// terminal would be showing is to be one.
|
|
13
|
+
//
|
|
14
|
+
// So: a grid, a cursor, a scroll region, two buffers, and the subset of the
|
|
15
|
+
// escape vocabulary a TUI actually uses to position and erase — cursor movement,
|
|
16
|
+
// erase display/line/characters, insert and delete lines and characters, scroll
|
|
17
|
+
// up and down, the alternate screen. Colour is parsed and dropped, because the
|
|
18
|
+
// answer this returns is text.
|
|
19
|
+
//
|
|
20
|
+
// **Scrolled-off lines are kept, and that is the point.** A harness's
|
|
21
|
+
// conversation is not on the screen — it went past the top of it minutes ago.
|
|
22
|
+
// The transcript *is* the scrollback, so a line that leaves the top of the grid
|
|
23
|
+
// is joined, trimmed and pushed onto {@link TerminalScreen.scrollbackLines}
|
|
24
|
+
// rather than dropped. `experiment/action.md` §4 is where that was learned the
|
|
25
|
+
// hard way.
|
|
26
|
+
//
|
|
27
|
+
// Two rules about what is kept, and they are not the same rule:
|
|
28
|
+
//
|
|
29
|
+
// - **An erased screen is erased.** `ESC[2J` wipes the grid in place and adds
|
|
30
|
+
// nothing to the scrollback, because that is what a terminal does with it —
|
|
31
|
+
// erasing is not scrolling. Content reaches the scrollback by scrolling off
|
|
32
|
+
// the top, which is how a harness that prints a conversation produces one.
|
|
33
|
+
// (Claude Code 2.1.228 never emits `ED2` at all; it clears by walking
|
|
34
|
+
// line-erases up the screen, and that path scrolls nothing either.)
|
|
35
|
+
// - **The alternate screen keeps its scrolled-off lines too**, which a real
|
|
36
|
+
// terminal does not, and the difference is deliberate. A terminal drops them
|
|
37
|
+
// because a human can scroll a full-screen app with the app's own keys; a
|
|
38
|
+
// script cannot, and the lines are the transcript it was asked to read. Each
|
|
39
|
+
// buffer keeps its own history, so switching back to the main screen does not
|
|
40
|
+
// mix a full-screen app's output into the shell's. Everything else about the
|
|
41
|
+
// alternate screen is ordinary: it starts blank and the main buffer is
|
|
42
|
+
// untouched underneath it.
|
|
43
|
+
//
|
|
44
|
+
// One consequence worth knowing before reading `screen()`: a harness that
|
|
45
|
+
// switches to the alternate screen and then *exits* it (`ESC[?1049l`, which
|
|
46
|
+
// Claude Code sends on quit) puts the main buffer back, and the main buffer is
|
|
47
|
+
// whatever was on it before the harness started — near enough empty. Read the
|
|
48
|
+
// screen while the Session is alive, not after killing it.
|
|
49
|
+
//
|
|
50
|
+
// Nothing here reads or writes a file descriptor. It is fed strings and hands
|
|
51
|
+
// back strings; the process it runs in may have no terminal at all, which is
|
|
52
|
+
// D11 and the whole reason the session layer can be used from a script.
|
|
53
|
+
//
|
|
54
|
+
// ## Deferred: reverse video is dropped with the rest of the colour (#156)
|
|
55
|
+
//
|
|
56
|
+
// **Known gap, deliberately open.** `ESC[7m` is not decoration on a harness
|
|
57
|
+
// menu — it is how the harnesses observed here say *this is the row Enter would
|
|
58
|
+
// take*, and the Core reads exactly that when it answers the folder-trust
|
|
59
|
+
// dialog on a Session's behalf (`harness-prompt-delivery.ts`, D3: a pointer
|
|
60
|
+
// glyph in front of the row, **or reverse video on it**). Dropping SGR here
|
|
61
|
+
// means a caller answering a dialog through `send()` can read the options and
|
|
62
|
+
// cannot read the selection: `send("2")` then `send("\r")` is answerable,
|
|
63
|
+
// "press Enter on whatever is highlighted" is not.
|
|
64
|
+
//
|
|
65
|
+
// Left open rather than fixed, on three counts:
|
|
66
|
+
//
|
|
67
|
+
// - **Nothing in this build needs it.** The Panel does not use this class at
|
|
68
|
+
// all — its terminals are xterm.js in the browser, which renders the
|
|
69
|
+
// highlight for an operator to see, and the one dialog the product answers
|
|
70
|
+
// unattended is answered in the Core off the raw stream. The Panel's
|
|
71
|
+
// migration onto this package (#156) does not read a screen, so shipping a
|
|
72
|
+
// fix with it would be shipping it blind.
|
|
73
|
+
// - **The fix has a shape, and it is a surface decision.** The Core marks the
|
|
74
|
+
// highlight with a sentinel character before stripping the escapes
|
|
75
|
+
// (`HIGHLIGHT_MARK`), which works precisely because its output is consumed
|
|
76
|
+
// by one caller in the same package. `screen()` here returns text to
|
|
77
|
+
// somebody else's script, so putting a `\u0001` in it — or growing a second
|
|
78
|
+
// accessor, or a per-line attribute — changes a published shape, and that
|
|
79
|
+
// is the session layer's call to make with its caller in front of it
|
|
80
|
+
// (#129 D11, issue 155), not this one's.
|
|
81
|
+
// - **The honest failure is the safe one.** A caller that cannot see a
|
|
82
|
+
// highlight reads "no option is selected" and answers by number, which
|
|
83
|
+
// works on every harness. A half-done version that marked *some* highlights
|
|
84
|
+
// would have it confidently answer the wrong row on the rest — and the row
|
|
85
|
+
// it would be wrong about is a trust dialog whose first option is "No,
|
|
86
|
+
// exit".
|
|
87
|
+
//
|
|
88
|
+
// So: colour is parsed and dropped, reverse video with it, and a caller that
|
|
89
|
+
// needs the selection answers by number until this is designed.
|
|
90
|
+
/** How many scrolled-off lines are kept by default. */
|
|
91
|
+
export const DEFAULT_SCROLLBACK_LINES = 5_000;
|
|
92
|
+
/** Grid width used when a caller names none — the Core's own PTY default. */
|
|
93
|
+
export const DEFAULT_COLS = 100;
|
|
94
|
+
/** Grid height used when a caller names none — the Core's own PTY default. */
|
|
95
|
+
export const DEFAULT_ROWS = 30;
|
|
96
|
+
/**
|
|
97
|
+
* A terminal screen fed one PTY's bytes.
|
|
98
|
+
*
|
|
99
|
+
* Feed it every chunk in order with {@link write} and read {@link lines},
|
|
100
|
+
* {@link text} or {@link viewportText}. Chunk boundaries are not respected by
|
|
101
|
+
* the stream — an escape sequence is routinely split across two `data` frames —
|
|
102
|
+
* so the parser is a state machine that carries its position between calls
|
|
103
|
+
* rather than anything that re-scans a buffer.
|
|
104
|
+
*/
|
|
105
|
+
export class TerminalScreen {
|
|
106
|
+
colsValue;
|
|
107
|
+
rowsValue;
|
|
108
|
+
scrollbackLimit;
|
|
109
|
+
main;
|
|
110
|
+
alt;
|
|
111
|
+
buffer;
|
|
112
|
+
onAlt = false;
|
|
113
|
+
row = 0;
|
|
114
|
+
col = 0;
|
|
115
|
+
/**
|
|
116
|
+
* The cursor is parked on the last column with a character already written
|
|
117
|
+
* there, and the *next* printable wraps before it lands (DEC's deferred wrap).
|
|
118
|
+
* Without this, a line exactly `cols` wide scrolls one line too early and
|
|
119
|
+
* every subsequent row is off by one.
|
|
120
|
+
*/
|
|
121
|
+
pendingWrap = false;
|
|
122
|
+
/** Scroll region, inclusive, 0-based. Set by DECSTBM; full screen by default. */
|
|
123
|
+
scrollTop = 0;
|
|
124
|
+
scrollBottom;
|
|
125
|
+
state = "ground";
|
|
126
|
+
csiBuf = "";
|
|
127
|
+
constructor(opts = {}) {
|
|
128
|
+
this.colsValue = Math.max(1, Math.floor(opts.cols ?? DEFAULT_COLS));
|
|
129
|
+
this.rowsValue = Math.max(1, Math.floor(opts.rows ?? DEFAULT_ROWS));
|
|
130
|
+
this.scrollbackLimit = Math.max(0, Math.floor(opts.scrollback ?? DEFAULT_SCROLLBACK_LINES));
|
|
131
|
+
this.main = this.freshBuffer();
|
|
132
|
+
this.alt = this.freshBuffer();
|
|
133
|
+
this.buffer = this.main;
|
|
134
|
+
this.scrollBottom = this.rowsValue - 1;
|
|
135
|
+
}
|
|
136
|
+
get cols() {
|
|
137
|
+
return this.colsValue;
|
|
138
|
+
}
|
|
139
|
+
get rows() {
|
|
140
|
+
return this.rowsValue;
|
|
141
|
+
}
|
|
142
|
+
/** Where the cursor is, 0-based, for a caller that needs to know. */
|
|
143
|
+
get cursor() {
|
|
144
|
+
return { row: this.row, col: this.col };
|
|
145
|
+
}
|
|
146
|
+
/** True while a full-screen TUI has switched to the alternate buffer. */
|
|
147
|
+
get alternateScreen() {
|
|
148
|
+
return this.onAlt;
|
|
149
|
+
}
|
|
150
|
+
// ─── Feeding it ────────────────────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* Write one chunk of PTY output. Call it with every chunk, in order.
|
|
153
|
+
*
|
|
154
|
+
* Iterated by code point rather than by UTF-16 unit, so an astral character
|
|
155
|
+
* (an emoji, and harnesses print plenty) occupies one cell rather than two
|
|
156
|
+
* halves of a surrogate pair in two.
|
|
157
|
+
*/
|
|
158
|
+
write(data) {
|
|
159
|
+
for (const ch of data) {
|
|
160
|
+
switch (this.state) {
|
|
161
|
+
case "ground":
|
|
162
|
+
this.ground(ch);
|
|
163
|
+
break;
|
|
164
|
+
case "esc":
|
|
165
|
+
this.escape(ch);
|
|
166
|
+
break;
|
|
167
|
+
case "csi":
|
|
168
|
+
this.csi(ch);
|
|
169
|
+
break;
|
|
170
|
+
case "osc":
|
|
171
|
+
this.osc(ch);
|
|
172
|
+
break;
|
|
173
|
+
case "string":
|
|
174
|
+
this.stringSequence(ch);
|
|
175
|
+
break;
|
|
176
|
+
case "consume-one":
|
|
177
|
+
// A charset designator's payload (`ESC ( B`) or a DEC private
|
|
178
|
+
// sequence's (`ESC # 8`). One character, dropped.
|
|
179
|
+
this.state = "ground";
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Change the grid's size. A caller that resizes the PTY resizes this too, or
|
|
186
|
+
* the two disagree about where every line wraps.
|
|
187
|
+
*
|
|
188
|
+
* Rows are added at the bottom and removed from the bottom; lines that a
|
|
189
|
+
* shrink pushes off the top go to the scrollback, exactly as scrolling would.
|
|
190
|
+
* Reflowing the existing text to a new width is deliberately not attempted —
|
|
191
|
+
* a real terminal's reflow is its own subsystem, and a harness repaints on
|
|
192
|
+
* `SIGWINCH` anyway, so the next frame is correct either way.
|
|
193
|
+
*/
|
|
194
|
+
resize(cols, rows) {
|
|
195
|
+
const nextCols = Math.max(1, Math.floor(cols));
|
|
196
|
+
const nextRows = Math.max(1, Math.floor(rows));
|
|
197
|
+
for (const buffer of [this.main, this.alt]) {
|
|
198
|
+
for (const line of buffer.grid) {
|
|
199
|
+
while (line.length < nextCols)
|
|
200
|
+
line.push(" ");
|
|
201
|
+
line.length = nextCols;
|
|
202
|
+
}
|
|
203
|
+
while (buffer.grid.length < nextRows)
|
|
204
|
+
buffer.grid.push(this.blankLine(nextCols));
|
|
205
|
+
// The cursor lives on the active buffer, so only that one's shrink moves
|
|
206
|
+
// it. What each buffer loses off the top it keeps, in its own scrollback —
|
|
207
|
+
// the alternate screen included, which is this module's deliberate
|
|
208
|
+
// departure from a real terminal (see the header).
|
|
209
|
+
const active = buffer === this.buffer;
|
|
210
|
+
while (buffer.grid.length > nextRows) {
|
|
211
|
+
// Take the blank rows below the cursor first. A terminal shrinking a
|
|
212
|
+
// half-empty screen loses the padding, not the text — pushing those
|
|
213
|
+
// blanks onto the scrollback instead would put empty lines in the middle
|
|
214
|
+
// of a transcript, which is the one place they are read as content.
|
|
215
|
+
const last = buffer.grid.length - 1;
|
|
216
|
+
if ((!active || last > this.row) && isBlank(buffer.grid[last])) {
|
|
217
|
+
buffer.grid.pop();
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const dropped = buffer.grid.shift();
|
|
221
|
+
if (dropped)
|
|
222
|
+
this.pushScrollback(buffer, dropped);
|
|
223
|
+
if (active && this.row > 0)
|
|
224
|
+
this.row -= 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.colsValue = nextCols;
|
|
228
|
+
this.rowsValue = nextRows;
|
|
229
|
+
this.scrollTop = 0;
|
|
230
|
+
this.scrollBottom = nextRows - 1;
|
|
231
|
+
this.row = Math.min(this.row, nextRows - 1);
|
|
232
|
+
this.col = Math.min(this.col, nextCols - 1);
|
|
233
|
+
this.pendingWrap = false;
|
|
234
|
+
}
|
|
235
|
+
// ─── Reading it ────────────────────────────────────────────────────────────
|
|
236
|
+
/** The rows a terminal would be displaying now, top to bottom, right-trimmed. */
|
|
237
|
+
viewportLines() {
|
|
238
|
+
return this.buffer.grid.map((line) => trimEnd(line.join("")));
|
|
239
|
+
}
|
|
240
|
+
/** Every line that has scrolled off the top, oldest first. The transcript. */
|
|
241
|
+
scrollbackLines() {
|
|
242
|
+
return [...this.buffer.scrollback];
|
|
243
|
+
}
|
|
244
|
+
/** Scrollback then viewport: everything this terminal has been shown. */
|
|
245
|
+
lines() {
|
|
246
|
+
return [...this.buffer.scrollback, ...this.viewportLines()];
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* {@link lines} as one string, with the blank rows below the last written one
|
|
250
|
+
* trimmed off — a viewport is 30 rows whether or not a harness filled them,
|
|
251
|
+
* and a caller reading a screen wants what is on it, not the padding.
|
|
252
|
+
*/
|
|
253
|
+
text() {
|
|
254
|
+
return joinTrimmed(this.lines());
|
|
255
|
+
}
|
|
256
|
+
/** The visible rows only, as one string. Same trailing-blank trim. */
|
|
257
|
+
viewportText() {
|
|
258
|
+
return joinTrimmed(this.viewportLines());
|
|
259
|
+
}
|
|
260
|
+
// ─── The parser ────────────────────────────────────────────────────────────
|
|
261
|
+
ground(ch) {
|
|
262
|
+
switch (ch) {
|
|
263
|
+
case "\u001B":
|
|
264
|
+
this.state = "esc";
|
|
265
|
+
return;
|
|
266
|
+
case "\r":
|
|
267
|
+
this.col = 0;
|
|
268
|
+
this.pendingWrap = false;
|
|
269
|
+
return;
|
|
270
|
+
case "\n":
|
|
271
|
+
case "\u000B": // vertical tab
|
|
272
|
+
case "\u000C": // form feed
|
|
273
|
+
this.lineFeed();
|
|
274
|
+
this.pendingWrap = false;
|
|
275
|
+
return;
|
|
276
|
+
case "\b":
|
|
277
|
+
this.col = Math.max(0, this.col - 1);
|
|
278
|
+
this.pendingWrap = false;
|
|
279
|
+
return;
|
|
280
|
+
case "\t":
|
|
281
|
+
this.col = Math.min(this.colsValue - 1, (Math.floor(this.col / 8) + 1) * 8);
|
|
282
|
+
this.pendingWrap = false;
|
|
283
|
+
return;
|
|
284
|
+
default:
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
288
|
+
// Remaining C0 controls and DEL paint nothing. A bell in the middle of a
|
|
289
|
+
// word must not become a character, or every alert shifts the line.
|
|
290
|
+
if (code < 0x20 || code === 0x7f)
|
|
291
|
+
return;
|
|
292
|
+
this.putChar(ch);
|
|
293
|
+
}
|
|
294
|
+
escape(ch) {
|
|
295
|
+
switch (ch) {
|
|
296
|
+
case "[":
|
|
297
|
+
this.state = "csi";
|
|
298
|
+
this.csiBuf = "";
|
|
299
|
+
return;
|
|
300
|
+
case "]":
|
|
301
|
+
this.state = "osc";
|
|
302
|
+
return;
|
|
303
|
+
// DCS / SOS / PM / APC — a string until its terminator, none of which is
|
|
304
|
+
// screen content. Consumed so its payload is not printed as text.
|
|
305
|
+
case "P":
|
|
306
|
+
case "X":
|
|
307
|
+
case "^":
|
|
308
|
+
case "_":
|
|
309
|
+
this.state = "string";
|
|
310
|
+
return;
|
|
311
|
+
case "7":
|
|
312
|
+
this.saveCursor();
|
|
313
|
+
this.state = "ground";
|
|
314
|
+
return;
|
|
315
|
+
case "8":
|
|
316
|
+
this.restoreCursor();
|
|
317
|
+
this.state = "ground";
|
|
318
|
+
return;
|
|
319
|
+
case "D": // IND — down one line, scrolling at the bottom
|
|
320
|
+
this.lineFeed();
|
|
321
|
+
this.state = "ground";
|
|
322
|
+
return;
|
|
323
|
+
case "E": // NEL — carriage return and down one
|
|
324
|
+
this.col = 0;
|
|
325
|
+
this.lineFeed();
|
|
326
|
+
this.state = "ground";
|
|
327
|
+
return;
|
|
328
|
+
case "M": // RI — up one line, scrolling at the top
|
|
329
|
+
this.reverseIndex();
|
|
330
|
+
this.state = "ground";
|
|
331
|
+
return;
|
|
332
|
+
case "c": // RIS — full reset
|
|
333
|
+
this.reset();
|
|
334
|
+
this.state = "ground";
|
|
335
|
+
return;
|
|
336
|
+
// Charset designators and DEC private sequences take one more byte.
|
|
337
|
+
case "(":
|
|
338
|
+
case ")":
|
|
339
|
+
case "*":
|
|
340
|
+
case "+":
|
|
341
|
+
case "-":
|
|
342
|
+
case ".":
|
|
343
|
+
case "/":
|
|
344
|
+
case "#":
|
|
345
|
+
this.state = "consume-one";
|
|
346
|
+
return;
|
|
347
|
+
default:
|
|
348
|
+
// Keypad modes and everything else with no payload.
|
|
349
|
+
this.state = "ground";
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
csi(ch) {
|
|
354
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
355
|
+
// Parameter and intermediate bytes accumulate; the first byte in the final
|
|
356
|
+
// range ends the sequence.
|
|
357
|
+
if (code >= 0x30 && code <= 0x3f) {
|
|
358
|
+
this.csiBuf += ch;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (code >= 0x20 && code <= 0x2f) {
|
|
362
|
+
this.csiBuf += ch;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.state = "ground";
|
|
366
|
+
if (code >= 0x40 && code <= 0x7e)
|
|
367
|
+
this.dispatchCsi(ch, this.csiBuf);
|
|
368
|
+
this.csiBuf = "";
|
|
369
|
+
}
|
|
370
|
+
osc(ch) {
|
|
371
|
+
// BEL-terminated, or ST (`ESC \`). The ESC is enough to end it here: no OSC
|
|
372
|
+
// payload this cares about contains one, and treating it as the terminator
|
|
373
|
+
// means a truncated sequence cannot swallow the rest of the stream.
|
|
374
|
+
// Consumed and dropped either way: a window title, a hyperlink target and a
|
|
375
|
+
// colour query are not screen content.
|
|
376
|
+
if (ch === "\u0007") {
|
|
377
|
+
this.state = "ground";
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (ch === "\u001B")
|
|
381
|
+
this.state = "consume-one";
|
|
382
|
+
}
|
|
383
|
+
stringSequence(ch) {
|
|
384
|
+
if (ch === "\u0007") {
|
|
385
|
+
this.state = "ground";
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (ch === "\u001B")
|
|
389
|
+
this.state = "consume-one";
|
|
390
|
+
}
|
|
391
|
+
dispatchCsi(final, raw) {
|
|
392
|
+
const isPrivate = raw.startsWith("?");
|
|
393
|
+
const body = isPrivate ? raw.slice(1) : raw;
|
|
394
|
+
const params = body
|
|
395
|
+
.split(";")
|
|
396
|
+
.map((p) => (p === "" ? NaN : Number.parseInt(p, 10)))
|
|
397
|
+
.map((n) => (Number.isFinite(n) ? n : NaN));
|
|
398
|
+
const p = (index, fallback) => {
|
|
399
|
+
const value = params[index];
|
|
400
|
+
return value === undefined || Number.isNaN(value) ? fallback : value;
|
|
401
|
+
};
|
|
402
|
+
if (isPrivate) {
|
|
403
|
+
// The alternate screen, in and out. 1049 also saves/restores the cursor;
|
|
404
|
+
// 47 and 1047 are the older spellings and swap the buffer alone.
|
|
405
|
+
if (final === "h" || final === "l") {
|
|
406
|
+
const mode = p(0, 0);
|
|
407
|
+
if (mode === 47 || mode === 1047 || mode === 1049) {
|
|
408
|
+
this.setAlternateScreen(final === "h", mode === 1049);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Cursor visibility, bracketed paste, mouse reporting: nothing that
|
|
412
|
+
// changes what the screen says.
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
switch (final) {
|
|
416
|
+
case "A":
|
|
417
|
+
this.row = Math.max(this.scrollTop, this.row - Math.max(1, p(0, 1)));
|
|
418
|
+
this.pendingWrap = false;
|
|
419
|
+
return;
|
|
420
|
+
case "B":
|
|
421
|
+
this.row = Math.min(this.scrollBottom, this.row + Math.max(1, p(0, 1)));
|
|
422
|
+
this.pendingWrap = false;
|
|
423
|
+
return;
|
|
424
|
+
case "C":
|
|
425
|
+
this.col = Math.min(this.colsValue - 1, this.col + Math.max(1, p(0, 1)));
|
|
426
|
+
this.pendingWrap = false;
|
|
427
|
+
return;
|
|
428
|
+
case "D":
|
|
429
|
+
this.col = Math.max(0, this.col - Math.max(1, p(0, 1)));
|
|
430
|
+
this.pendingWrap = false;
|
|
431
|
+
return;
|
|
432
|
+
case "E":
|
|
433
|
+
this.col = 0;
|
|
434
|
+
this.row = Math.min(this.scrollBottom, this.row + Math.max(1, p(0, 1)));
|
|
435
|
+
this.pendingWrap = false;
|
|
436
|
+
return;
|
|
437
|
+
case "F":
|
|
438
|
+
this.col = 0;
|
|
439
|
+
this.row = Math.max(this.scrollTop, this.row - Math.max(1, p(0, 1)));
|
|
440
|
+
this.pendingWrap = false;
|
|
441
|
+
return;
|
|
442
|
+
case "G":
|
|
443
|
+
case "`": // HPA, the same move under another name
|
|
444
|
+
this.col = clamp(p(0, 1) - 1, 0, this.colsValue - 1);
|
|
445
|
+
this.pendingWrap = false;
|
|
446
|
+
return;
|
|
447
|
+
case "d": // VPA
|
|
448
|
+
this.row = clamp(p(0, 1) - 1, 0, this.rowsValue - 1);
|
|
449
|
+
this.pendingWrap = false;
|
|
450
|
+
return;
|
|
451
|
+
case "H":
|
|
452
|
+
case "f":
|
|
453
|
+
this.row = clamp(p(0, 1) - 1, 0, this.rowsValue - 1);
|
|
454
|
+
this.col = clamp(p(1, 1) - 1, 0, this.colsValue - 1);
|
|
455
|
+
this.pendingWrap = false;
|
|
456
|
+
return;
|
|
457
|
+
case "J":
|
|
458
|
+
this.eraseInDisplay(p(0, 0));
|
|
459
|
+
return;
|
|
460
|
+
case "K":
|
|
461
|
+
this.eraseInLine(p(0, 0));
|
|
462
|
+
return;
|
|
463
|
+
case "L":
|
|
464
|
+
this.insertLines(Math.max(1, p(0, 1)));
|
|
465
|
+
return;
|
|
466
|
+
case "M":
|
|
467
|
+
this.deleteLines(Math.max(1, p(0, 1)));
|
|
468
|
+
return;
|
|
469
|
+
case "@":
|
|
470
|
+
this.insertChars(Math.max(1, p(0, 1)));
|
|
471
|
+
return;
|
|
472
|
+
case "P":
|
|
473
|
+
this.deleteChars(Math.max(1, p(0, 1)));
|
|
474
|
+
return;
|
|
475
|
+
case "X":
|
|
476
|
+
this.eraseChars(Math.max(1, p(0, 1)));
|
|
477
|
+
return;
|
|
478
|
+
case "S":
|
|
479
|
+
this.scrollUp(Math.max(1, p(0, 1)));
|
|
480
|
+
return;
|
|
481
|
+
case "T":
|
|
482
|
+
this.scrollDown(Math.max(1, p(0, 1)));
|
|
483
|
+
return;
|
|
484
|
+
case "r": {
|
|
485
|
+
// DECSTBM. An out-of-order or out-of-range region is ignored rather
|
|
486
|
+
// than clamped into something the TUI did not ask for.
|
|
487
|
+
const top = clamp(p(0, 1) - 1, 0, this.rowsValue - 1);
|
|
488
|
+
const bottom = clamp(p(1, this.rowsValue) - 1, 0, this.rowsValue - 1);
|
|
489
|
+
if (top >= bottom)
|
|
490
|
+
return;
|
|
491
|
+
this.scrollTop = top;
|
|
492
|
+
this.scrollBottom = bottom;
|
|
493
|
+
this.row = 0;
|
|
494
|
+
this.col = 0;
|
|
495
|
+
this.pendingWrap = false;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
case "s":
|
|
499
|
+
this.saveCursor();
|
|
500
|
+
return;
|
|
501
|
+
case "u":
|
|
502
|
+
this.restoreCursor();
|
|
503
|
+
return;
|
|
504
|
+
default:
|
|
505
|
+
// SGR (`m`), device reports, mode sets: colour and protocol, not layout.
|
|
506
|
+
//
|
|
507
|
+
// **Reverse video goes with the colour, and that is a known gap — see
|
|
508
|
+
// the header.** `ESC[7m` is how the harnesses observed here mark the
|
|
509
|
+
// selected row of a menu, so a caller reading a dialog off this screen
|
|
510
|
+
// is told what the options are and not which one an Enter would take.
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// ─── The grid ──────────────────────────────────────────────────────────────
|
|
515
|
+
putChar(ch) {
|
|
516
|
+
const width = charWidth(ch);
|
|
517
|
+
if (width === 0) {
|
|
518
|
+
// A combining mark belongs to the character before it, not to a cell of
|
|
519
|
+
// its own — otherwise an accent shifts the rest of the line one column.
|
|
520
|
+
const prev = this.col - 1;
|
|
521
|
+
if (prev >= 0) {
|
|
522
|
+
const line = this.buffer.grid[this.row];
|
|
523
|
+
if (line)
|
|
524
|
+
line[prev] = (line[prev] ?? " ") + ch;
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (this.pendingWrap) {
|
|
529
|
+
this.col = 0;
|
|
530
|
+
this.lineFeed();
|
|
531
|
+
this.pendingWrap = false;
|
|
532
|
+
}
|
|
533
|
+
if (this.col + width > this.colsValue) {
|
|
534
|
+
this.col = 0;
|
|
535
|
+
this.lineFeed();
|
|
536
|
+
}
|
|
537
|
+
const line = this.buffer.grid[this.row];
|
|
538
|
+
if (!line)
|
|
539
|
+
return;
|
|
540
|
+
line[this.col] = ch;
|
|
541
|
+
// A wide character owns two columns; the second holds nothing so that
|
|
542
|
+
// joining the row reproduces the glyph once and the width still lines up.
|
|
543
|
+
if (width === 2 && this.col + 1 < this.colsValue)
|
|
544
|
+
line[this.col + 1] = "";
|
|
545
|
+
this.col += width;
|
|
546
|
+
if (this.col >= this.colsValue) {
|
|
547
|
+
this.col = this.colsValue - 1;
|
|
548
|
+
this.pendingWrap = true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
lineFeed() {
|
|
552
|
+
if (this.row === this.scrollBottom) {
|
|
553
|
+
this.scrollUp(1);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (this.row < this.rowsValue - 1)
|
|
557
|
+
this.row += 1;
|
|
558
|
+
}
|
|
559
|
+
reverseIndex() {
|
|
560
|
+
if (this.row === this.scrollTop) {
|
|
561
|
+
this.scrollDown(1);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (this.row > 0)
|
|
565
|
+
this.row -= 1;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Scroll the region up by `n`, which is where the scrollback comes from.
|
|
569
|
+
*
|
|
570
|
+
* A line only reaches the scrollback when the *whole screen* scrolls: a TUI
|
|
571
|
+
* scrolling a three-row region in the middle of its layout is animating a
|
|
572
|
+
* pane, not producing history, and a terminal keeps none of it either. Which
|
|
573
|
+
* buffer it scrolled on does not matter — see the header on why the alternate
|
|
574
|
+
* screen keeps its history here when a terminal would not.
|
|
575
|
+
*/
|
|
576
|
+
scrollUp(n) {
|
|
577
|
+
const keepsHistory = this.scrollTop === 0 && this.scrollBottom === this.rowsValue - 1;
|
|
578
|
+
for (let i = 0; i < n; i += 1) {
|
|
579
|
+
const dropped = this.buffer.grid.splice(this.scrollTop, 1)[0];
|
|
580
|
+
if (dropped && keepsHistory)
|
|
581
|
+
this.pushScrollback(this.buffer, dropped);
|
|
582
|
+
this.buffer.grid.splice(this.scrollBottom, 0, this.blankLine(this.colsValue));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
scrollDown(n) {
|
|
586
|
+
for (let i = 0; i < n; i += 1) {
|
|
587
|
+
this.buffer.grid.splice(this.scrollBottom, 1);
|
|
588
|
+
this.buffer.grid.splice(this.scrollTop, 0, this.blankLine(this.colsValue));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
insertLines(n) {
|
|
592
|
+
if (this.row < this.scrollTop || this.row > this.scrollBottom)
|
|
593
|
+
return;
|
|
594
|
+
for (let i = 0; i < n; i += 1) {
|
|
595
|
+
this.buffer.grid.splice(this.scrollBottom, 1);
|
|
596
|
+
this.buffer.grid.splice(this.row, 0, this.blankLine(this.colsValue));
|
|
597
|
+
}
|
|
598
|
+
this.col = 0;
|
|
599
|
+
this.pendingWrap = false;
|
|
600
|
+
}
|
|
601
|
+
deleteLines(n) {
|
|
602
|
+
if (this.row < this.scrollTop || this.row > this.scrollBottom)
|
|
603
|
+
return;
|
|
604
|
+
for (let i = 0; i < n; i += 1) {
|
|
605
|
+
this.buffer.grid.splice(this.row, 1);
|
|
606
|
+
this.buffer.grid.splice(this.scrollBottom, 0, this.blankLine(this.colsValue));
|
|
607
|
+
}
|
|
608
|
+
this.col = 0;
|
|
609
|
+
this.pendingWrap = false;
|
|
610
|
+
}
|
|
611
|
+
insertChars(n) {
|
|
612
|
+
const line = this.buffer.grid[this.row];
|
|
613
|
+
if (!line)
|
|
614
|
+
return;
|
|
615
|
+
for (let i = 0; i < n; i += 1) {
|
|
616
|
+
line.splice(this.col, 0, " ");
|
|
617
|
+
line.length = this.colsValue;
|
|
618
|
+
}
|
|
619
|
+
this.pendingWrap = false;
|
|
620
|
+
}
|
|
621
|
+
deleteChars(n) {
|
|
622
|
+
const line = this.buffer.grid[this.row];
|
|
623
|
+
if (!line)
|
|
624
|
+
return;
|
|
625
|
+
for (let i = 0; i < n; i += 1) {
|
|
626
|
+
line.splice(this.col, 1);
|
|
627
|
+
line.push(" ");
|
|
628
|
+
}
|
|
629
|
+
this.pendingWrap = false;
|
|
630
|
+
}
|
|
631
|
+
eraseChars(n) {
|
|
632
|
+
const line = this.buffer.grid[this.row];
|
|
633
|
+
if (!line)
|
|
634
|
+
return;
|
|
635
|
+
for (let i = 0; i < n && this.col + i < this.colsValue; i += 1)
|
|
636
|
+
line[this.col + i] = " ";
|
|
637
|
+
this.pendingWrap = false;
|
|
638
|
+
}
|
|
639
|
+
eraseInLine(mode) {
|
|
640
|
+
const line = this.buffer.grid[this.row];
|
|
641
|
+
if (!line)
|
|
642
|
+
return;
|
|
643
|
+
if (mode === 1) {
|
|
644
|
+
for (let i = 0; i <= this.col && i < this.colsValue; i += 1)
|
|
645
|
+
line[i] = " ";
|
|
646
|
+
}
|
|
647
|
+
else if (mode === 2) {
|
|
648
|
+
for (let i = 0; i < this.colsValue; i += 1)
|
|
649
|
+
line[i] = " ";
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
for (let i = this.col; i < this.colsValue; i += 1)
|
|
653
|
+
line[i] = " ";
|
|
654
|
+
}
|
|
655
|
+
this.pendingWrap = false;
|
|
656
|
+
}
|
|
657
|
+
eraseInDisplay(mode) {
|
|
658
|
+
if (mode === 1) {
|
|
659
|
+
for (let r = 0; r < this.row; r += 1)
|
|
660
|
+
this.buffer.grid[r] = this.blankLine(this.colsValue);
|
|
661
|
+
const line = this.buffer.grid[this.row];
|
|
662
|
+
if (line)
|
|
663
|
+
for (let i = 0; i <= this.col && i < this.colsValue; i += 1)
|
|
664
|
+
line[i] = " ";
|
|
665
|
+
this.pendingWrap = false;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (mode === 2) {
|
|
669
|
+
// Erased, not scrolled: nothing goes to the scrollback. See the header.
|
|
670
|
+
for (let r = 0; r < this.rowsValue; r += 1)
|
|
671
|
+
this.buffer.grid[r] = this.blankLine(this.colsValue);
|
|
672
|
+
this.pendingWrap = false;
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (mode === 3) {
|
|
676
|
+
// ED3 is the one sequence whose whole job is to discard the scrollback.
|
|
677
|
+
this.buffer.scrollback.length = 0;
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const line = this.buffer.grid[this.row];
|
|
681
|
+
if (line)
|
|
682
|
+
for (let i = this.col; i < this.colsValue; i += 1)
|
|
683
|
+
line[i] = " ";
|
|
684
|
+
for (let r = this.row + 1; r < this.rowsValue; r += 1) {
|
|
685
|
+
this.buffer.grid[r] = this.blankLine(this.colsValue);
|
|
686
|
+
}
|
|
687
|
+
this.pendingWrap = false;
|
|
688
|
+
}
|
|
689
|
+
setAlternateScreen(on, withCursor) {
|
|
690
|
+
if (on === this.onAlt)
|
|
691
|
+
return;
|
|
692
|
+
if (on) {
|
|
693
|
+
if (withCursor)
|
|
694
|
+
this.saveCursor();
|
|
695
|
+
this.alt = this.freshBuffer();
|
|
696
|
+
this.buffer = this.alt;
|
|
697
|
+
this.onAlt = true;
|
|
698
|
+
this.row = 0;
|
|
699
|
+
this.col = 0;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
this.buffer = this.main;
|
|
703
|
+
this.onAlt = false;
|
|
704
|
+
if (withCursor)
|
|
705
|
+
this.restoreCursor();
|
|
706
|
+
}
|
|
707
|
+
this.scrollTop = 0;
|
|
708
|
+
this.scrollBottom = this.rowsValue - 1;
|
|
709
|
+
this.pendingWrap = false;
|
|
710
|
+
}
|
|
711
|
+
saveCursor() {
|
|
712
|
+
this.buffer.savedRow = this.row;
|
|
713
|
+
this.buffer.savedCol = this.col;
|
|
714
|
+
}
|
|
715
|
+
restoreCursor() {
|
|
716
|
+
this.row = clamp(this.buffer.savedRow, 0, this.rowsValue - 1);
|
|
717
|
+
this.col = clamp(this.buffer.savedCol, 0, this.colsValue - 1);
|
|
718
|
+
this.pendingWrap = false;
|
|
719
|
+
}
|
|
720
|
+
reset() {
|
|
721
|
+
this.main = this.freshBuffer();
|
|
722
|
+
this.alt = this.freshBuffer();
|
|
723
|
+
this.buffer = this.main;
|
|
724
|
+
this.onAlt = false;
|
|
725
|
+
this.row = 0;
|
|
726
|
+
this.col = 0;
|
|
727
|
+
this.scrollTop = 0;
|
|
728
|
+
this.scrollBottom = this.rowsValue - 1;
|
|
729
|
+
this.pendingWrap = false;
|
|
730
|
+
}
|
|
731
|
+
pushScrollback(buffer, line) {
|
|
732
|
+
if (this.scrollbackLimit === 0)
|
|
733
|
+
return;
|
|
734
|
+
buffer.scrollback.push(trimEnd(line.join("")));
|
|
735
|
+
const overflow = buffer.scrollback.length - this.scrollbackLimit;
|
|
736
|
+
if (overflow > 0)
|
|
737
|
+
buffer.scrollback.splice(0, overflow);
|
|
738
|
+
}
|
|
739
|
+
blankLine(cols) {
|
|
740
|
+
return new Array(cols).fill(" ");
|
|
741
|
+
}
|
|
742
|
+
freshBuffer() {
|
|
743
|
+
return {
|
|
744
|
+
grid: Array.from({ length: this.rowsValue }, () => this.blankLine(this.colsValue)),
|
|
745
|
+
scrollback: [],
|
|
746
|
+
savedRow: 0,
|
|
747
|
+
savedCol: 0,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
function clamp(value, low, high) {
|
|
752
|
+
return Math.min(high, Math.max(low, value));
|
|
753
|
+
}
|
|
754
|
+
function isBlank(line) {
|
|
755
|
+
return line === undefined || line.every((cell) => cell === " " || cell === "");
|
|
756
|
+
}
|
|
757
|
+
function trimEnd(line) {
|
|
758
|
+
return line.replace(/\s+$/u, "");
|
|
759
|
+
}
|
|
760
|
+
/** Join lines, dropping the run of blank ones at the end. */
|
|
761
|
+
function joinTrimmed(lines) {
|
|
762
|
+
let end = lines.length;
|
|
763
|
+
while (end > 0 && lines[end - 1] === "")
|
|
764
|
+
end -= 1;
|
|
765
|
+
return lines.slice(0, end).join("\n");
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* How many columns one character occupies: 0, 1 or 2.
|
|
769
|
+
*
|
|
770
|
+
* Not a full Unicode width table — that is a data file, and this is an SDK. It
|
|
771
|
+
* is the three cases a harness's output actually contains: combining marks and
|
|
772
|
+
* the zero-width joiners that build emoji sequences take no column, the East
|
|
773
|
+
* Asian wide blocks and the emoji planes take two, and everything else takes
|
|
774
|
+
* one. Getting this wrong does not corrupt the text, it shifts a line by a
|
|
775
|
+
* column — which is why a box-drawing character (narrow, and everywhere in a
|
|
776
|
+
* TUI) matters more here than a rare script does.
|
|
777
|
+
*/
|
|
778
|
+
export function charWidth(ch) {
|
|
779
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
780
|
+
if (code === 0x200b || code === 0x200d || code === 0xfeff)
|
|
781
|
+
return 0;
|
|
782
|
+
if ((code >= 0x0300 && code <= 0x036f) || // combining diacriticals
|
|
783
|
+
(code >= 0x1ab0 && code <= 0x1aff) ||
|
|
784
|
+
(code >= 0x20d0 && code <= 0x20ff) || // combining marks for symbols
|
|
785
|
+
(code >= 0xfe00 && code <= 0xfe0f) || // variation selectors
|
|
786
|
+
(code >= 0xfe20 && code <= 0xfe2f)) {
|
|
787
|
+
return 0;
|
|
788
|
+
}
|
|
789
|
+
if ((code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
|
|
790
|
+
(code >= 0x2e80 && code <= 0x303e) || // CJK radicals, Kangxi, punctuation
|
|
791
|
+
(code >= 0x3041 && code <= 0x33ff) || // kana through CJK compatibility
|
|
792
|
+
(code >= 0x3400 && code <= 0x4dbf) ||
|
|
793
|
+
(code >= 0x4e00 && code <= 0x9fff) || // CJK unified ideographs
|
|
794
|
+
(code >= 0xa000 && code <= 0xa4cf) ||
|
|
795
|
+
(code >= 0xac00 && code <= 0xd7a3) || // Hangul syllables
|
|
796
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
797
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
798
|
+
(code >= 0xff00 && code <= 0xff60) || // fullwidth forms
|
|
799
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
800
|
+
(code >= 0x1f300 && code <= 0x1f64f) || // emoji
|
|
801
|
+
(code >= 0x1f900 && code <= 0x1f9ff) ||
|
|
802
|
+
(code >= 0x20000 && code <= 0x3fffd)) {
|
|
803
|
+
return 2;
|
|
804
|
+
}
|
|
805
|
+
return 1;
|
|
806
|
+
}
|
|
807
|
+
//# sourceMappingURL=terminal-screen.js.map
|