@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/core-client.d.ts +616 -0
  4. package/dist/core-client.d.ts.map +1 -0
  5. package/dist/core-client.js +1036 -0
  6. package/dist/core-client.js.map +1 -0
  7. package/dist/core-link-cursor-storage.d.ts +32 -0
  8. package/dist/core-link-cursor-storage.d.ts.map +1 -0
  9. package/dist/core-link-cursor-storage.js +66 -0
  10. package/dist/core-link-cursor-storage.js.map +1 -0
  11. package/dist/core-link-frames.d.ts +1123 -0
  12. package/dist/core-link-frames.d.ts.map +1 -0
  13. package/dist/core-link-frames.js +349 -0
  14. package/dist/core-link-frames.js.map +1 -0
  15. package/dist/core-link-socket.d.ts +54 -0
  16. package/dist/core-link-socket.d.ts.map +1 -0
  17. package/dist/core-link-socket.js +74 -0
  18. package/dist/core-link-socket.js.map +1 -0
  19. package/dist/core-link-transport.d.ts +177 -0
  20. package/dist/core-link-transport.d.ts.map +1 -0
  21. package/dist/core-link-transport.js +432 -0
  22. package/dist/core-link-transport.js.map +1 -0
  23. package/dist/core-registration-blob.d.ts +52 -0
  24. package/dist/core-registration-blob.d.ts.map +1 -0
  25. package/dist/core-registration-blob.js +61 -0
  26. package/dist/core-registration-blob.js.map +1 -0
  27. package/dist/core-session.d.ts +321 -0
  28. package/dist/core-session.d.ts.map +1 -0
  29. package/dist/core-session.js +660 -0
  30. package/dist/core-session.js.map +1 -0
  31. package/dist/durable-core-client.d.ts +172 -0
  32. package/dist/durable-core-client.d.ts.map +1 -0
  33. package/dist/durable-core-client.js +264 -0
  34. package/dist/durable-core-client.js.map +1 -0
  35. package/dist/terminal-screen.d.ts +139 -0
  36. package/dist/terminal-screen.d.ts.map +1 -0
  37. package/dist/terminal-screen.js +807 -0
  38. package/dist/terminal-screen.js.map +1 -0
  39. 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