@hncode/hncode 0.5.6 → 0.6.1

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 (59) hide show
  1. package/package.json +2 -3
  2. package/src/agent.js +67 -2
  3. package/src/clipboard.js +221 -33
  4. package/src/image-row.js +111 -0
  5. package/src/latex.js +1557 -0
  6. package/src/llm.js +19 -4
  7. package/src/mermaid/ansi.js +55 -0
  8. package/src/mermaid/canvas.js +449 -0
  9. package/src/mermaid/class-style.js +57 -0
  10. package/src/mermaid/css-colors.js +153 -0
  11. package/src/mermaid/diagrams/class.js +289 -0
  12. package/src/mermaid/diagrams/er.js +201 -0
  13. package/src/mermaid/diagrams/flowchart.js +488 -0
  14. package/src/mermaid/diagrams/gitgraph.js +205 -0
  15. package/src/mermaid/diagrams/mindmap.js +101 -0
  16. package/src/mermaid/diagrams/pie.js +104 -0
  17. package/src/mermaid/diagrams/sequence.js +282 -0
  18. package/src/mermaid/diagrams/state.js +292 -0
  19. package/src/mermaid/diagrams/timeline.js +99 -0
  20. package/src/mermaid/graph.js +138 -0
  21. package/src/mermaid/index.js +59 -0
  22. package/src/mermaid/labels.js +339 -0
  23. package/src/mermaid/layout-seq.js +225 -0
  24. package/src/mermaid/layout.js +1454 -0
  25. package/src/mermaid/registry.js +54 -0
  26. package/src/mermaid/source-box.js +102 -0
  27. package/src/mermaid/statements.js +235 -0
  28. package/src/mermaid/types.js +1 -0
  29. package/src/mermaid/width.js +29 -0
  30. package/src/screen-diff.js +512 -0
  31. package/src/screen-paint.js +128 -0
  32. package/src/screen.js +277 -0
  33. package/src/subagent-types.js +136 -147
  34. package/src/term-caps.js +10 -4
  35. package/src/tex/font.js +292 -0
  36. package/src/tex/fonts/LICENSE.txt +11 -0
  37. package/src/tex/fonts/STIX2Math.ttf +0 -0
  38. package/src/tex/index.js +293 -0
  39. package/src/tex/kitty.js +186 -0
  40. package/src/tex/layout.js +446 -0
  41. package/src/tex/parse.js +331 -0
  42. package/src/tex/raster.js +216 -0
  43. package/src/tex/render.js +167 -0
  44. package/src/tex/sixel.js +326 -0
  45. package/src/tex/svg.js +237 -0
  46. package/src/tool-result.js +2 -2
  47. package/src/tools/agent-swarm.js +3 -15
  48. package/src/tools/agent.js +454 -464
  49. package/src/tools/ask-user-question.js +195 -206
  50. package/src/tools/bash.js +39 -3
  51. package/src/tools/edit.js +10 -112
  52. package/src/tools/fetch-url.js +130 -120
  53. package/src/tools/goal.js +93 -0
  54. package/src/tools/index.js +2 -0
  55. package/src/tools/memory.js +80 -88
  56. package/src/tools/read.js +27 -23
  57. package/src/tools/todo.js +8 -13
  58. package/src/tools/validate-args.js +78 -0
  59. package/src/tui.js +1718 -585
@@ -0,0 +1,512 @@
1
+ /**
2
+ * Two screens in, the bytes that turn one into the other.
3
+ *
4
+ * This is the writer the TUI paints through. Its whole job is to emit as little as possible: for each row that
5
+ * differs, jump to its first changed column and write only the run that changed, carrying styles across the
6
+ * run and resetting between them.
7
+ *
8
+ * WHY NOT REWRITE WHOLE ROWS
9
+ * --------------------------
10
+ * The previous painter rewrote a changed row from column 1. On a 200 column terminal a one-character change
11
+ * cost 200 characters plus a style preamble, which is what kept the frame expensive enough that a periodic
12
+ * full repaint looked attractive — and that periodic repaint is what shredded the screen whenever a bitmap was
13
+ * present. Writing only the changed cell range removes the reason for a full repaint to exist.
14
+ *
15
+ * BITMAPS OUTLIVE CELL WRITES, SO THEY ARE RECONCILED, NOT DRAWN
16
+ * --------------------------------------------------------------
17
+ * Once Sixel has painted a rectangle, those pixels are on the screen and no cell write removes them: writing
18
+ * characters there draws text ON TOP of the picture, and writing nothing leaves the picture showing through
19
+ * whatever the model says is there. That asymmetry is the whole difficulty, and the shape below is dsh-tui's
20
+ * (src/ink/sixel-graphics.ts there), because it is the only arrangement that survives being used:
21
+ *
22
+ * - a placement is identified by WHAT IT DRAWS and WHERE. Two pictures with the same raster at different
23
+ * rows are different placements, and the old one must be erased;
24
+ * - an erase is ALWAYS paired with a draw of the thing that replaces it, in the same write. Erasing alone
25
+ * leaves a hole with no draw, and a frame that erases and then draws nothing is a black rectangle;
26
+ * - a placement that is unchanged in raster AND position costs zero bytes;
27
+ * - an erase uses Erase Character (`ESC [ n X`) over the placement's OWN COLUMNS, never the whole row. A
28
+ * full-row blank took out the scrollbar, the panel borders and the composer's frame.
29
+ */
30
+
31
+ /**
32
+ * How many rows of six pixels a Sixel sequence advances the cursor by.
33
+ *
34
+ * Windows Terminal leaves the cursor on the row of the LAST band — its `SixelParser::_updateTextCursor`
35
+ * preserves the column and takes the final band — so the painter that addresses rows absolutely does not need
36
+ * the number. It is needed by callers that write text after a picture relative to the cursor.
37
+ *
38
+ * @param {string} sequence
39
+ * @returns {number}
40
+ */
41
+ export function sixelCursorAdvance(sequence) {
42
+ let advance = 0;
43
+ for (let i = 0; i < sequence.length; i++) if (sequence.charCodeAt(i) === 0x2d) advance += 6; // '-'
44
+ return advance;
45
+ }
46
+
47
+ /**
48
+ * Erase a rectangle of a bitmap: real SPACE CHARACTERS over its cells, not just Erase Character.
49
+ *
50
+ * `ESC [ n X` asks the terminal to drop the characters in that span, and terminals disagree about whether that
51
+ * also drops the PIXELS underneath — on Windows Terminal it does not reliably, so `ESC[nX` alone left the
52
+ * previous formula visible through the new frame. Spaces are unambiguous: a cell written with a space has
53
+ * nothing on it, and writing a cell is the one operation every terminal implements the same way.
54
+ *
55
+ * Erase Character still goes first. It is what clears a cell whose background must not be painted, and on the
56
+ * terminals that DO drop pixels with it this is then a no-op.
57
+ *
58
+ * @param {{row:number, col:number, rows:number, cols:number}} rect
59
+ * @returns {string}
60
+ */
61
+ function eraseRect(rect) {
62
+ if (!rect || rect.cols <= 0 || rect.rows <= 0) return '';
63
+ const blanks = ' '.repeat(rect.cols);
64
+ let out = '\x1b[0m';
65
+ for (let r = 0; r < rect.rows; r++) {
66
+ const y = rect.row + r;
67
+ if (y < 0) continue;
68
+ out += `\x1b[${y + 1};${rect.col + 1}H\x1b[${rect.cols}X` + blanks;
69
+ }
70
+ return out;
71
+ }
72
+
73
+
74
+ /**
75
+ * A placement's identity: WHERE it is, WHAT SHAPE it claims, and WHAT IT DEPICTS.
76
+ *
77
+ * All three are needed, and each one's absence caused a visible bug:
78
+ * - the ROW, because scrolling moves a picture to a different row and a picture that is only re-addressed
79
+ * leaves its pixels behind at the old one;
80
+ * - the CELL BOX, because a crop shortens the picture and the erase has to know the new extent;
81
+ * - a hash of the PAYLOAD, because the same formula can be drawn at two different cuts — `skipTop` changes
82
+ * the pixels while the row and the box may stay the same, and without the hash the second one was never
83
+ * sent.
84
+ *
85
+ * A full string comparison would be correct too, but the payload is up to a kilobyte and this key is computed
86
+ * per picture per frame; the hash keeps it cheap and keeps the key small enough to read in a log.
87
+ *
88
+ * @param {{row:number, col:number, rows:number, cols:number, seq:string, srcHash?:string}} img
89
+ * @returns {string}
90
+ */
91
+ export function placementKey(img) {
92
+ const hash = img.srcHash
93
+ || `${img.seq.length.toString(36)}${hashSeq(img.seq)}`;
94
+ return `${img.row}:${img.col}:${img.rows}:${img.cols}\u0000${hash}`;
95
+ }
96
+
97
+ /**
98
+ * A cheap, stable digest of a payload.
99
+ *
100
+ * FNV-1a over the characters: three operations per byte, no allocation, and collision odds that do not matter
101
+ * for a handful of formulas compared every frame. It is not a security hash and must not be used as one.
102
+ *
103
+ * @param {string} s
104
+ * @returns {string}
105
+ */
106
+ function hashSeq(s) {
107
+ let h = 0x811c9dc5;
108
+ for (let i = 0; i < s.length; i++) {
109
+ h ^= s.charCodeAt(i);
110
+ h = Math.imul(h, 0x01000193) >>> 0;
111
+ }
112
+ return h.toString(36);
113
+ }
114
+
115
+
116
+ /**
117
+ * Reconcile this frame's placements against what the terminal is showing, and paint.
118
+ *
119
+ * @param {import('./screen.js').Screen|null} prev The frame the terminal is currently showing, or null when
120
+ * the terminal's contents are unknown.
121
+ * @param {import('./screen.js').Screen} next The frame to show.
122
+ * @param {Array<{row:number, col:number, rows:number, cols:number, seq:string}>} images Every placement this
123
+ * frame wants on screen. Whether one is actually DRAWN is decided here.
124
+ * @param {object} [opts]
125
+ * @param {string} [opts.before] Prefix written before any cell, for synchronized output.
126
+ * @param {string} [opts.after] Suffix written after the last cell.
127
+ * @param {string} [opts.cursor] Cursor placement written last.
128
+ * @param {Map<string, boolean>} [opts.shown] What the terminal is believed to be showing, from the previous
129
+ * call. Mutated in place: entries whose placement this frame no longer wants are dropped, so the map cannot
130
+ * grow without bound.
131
+ * @param {number} [opts.budget] Bytes of picture payload this write may carry.
132
+ * @param {number} [opts.maxImages] Placements this write may carry.
133
+ * @returns {string}
134
+ */
135
+ export function diffScreens(prev, next, images = [], opts = {}) {
136
+ const before = opts.before || '';
137
+ const after = opts.after || '';
138
+ const cursor = opts.cursor || '';
139
+ const shown = opts.shown;
140
+ const budget = opts.budget === undefined ? Infinity : opts.budget;
141
+ const maxImages = opts.maxImages === undefined ? Infinity : opts.maxImages;
142
+ // The terminal's cell height, in pixels: a crop has to convert CELL ROWS to six-pixel BANDS, and it cannot
143
+ // do that without knowing how tall a row is.
144
+ const cellHeight = opts.cellHeight > 0 ? opts.cellHeight : 20;
145
+
146
+ // ---- what the terminal has, and what it should have ---------------------------
147
+ // `has` is the record of what was painted last time. It survives because the ERASE is computed from it: a
148
+ // rectangle the frame no longer wants has to be blanked, and only this record says where it was.
149
+ const has = prev && shown ? shown : new Map();
150
+ const want = new Map();
151
+ let spent = 0;
152
+ for (const img of images) {
153
+ if (img.row + img.rows <= 0 || img.row >= next.height) continue; // scrolled out of the window
154
+ const key = placementKey(img);
155
+ if (img.draw === false) continue; // the caller refused it (no protocol, no cell size, too large)
156
+ if (want.size >= maxImages) continue;
157
+ if (img.seq.length > budget - spent) continue; // over this frame's budget
158
+ spent += img.seq.length;
159
+ want.set(key, img);
160
+ }
161
+
162
+ // WHICH OF THOSE ACTUALLY HAVE TO BE DRAWN AGAIN.
163
+ //
164
+ // A picture whose placement key is unchanged — same row, same cell box, same payload hash — is already on
165
+ // screen exactly as this frame wants it, so sending it again would cost bytes and buy nothing. The key holds
166
+ // the row precisely so that SCROLLING always misses it: a formula that moved is a different placement, and
167
+ // the terminal does not carry sixel pixels across rows.
168
+ //
169
+ // The old version of this check was wrong twice: it left the row out of the key, so a formula that had
170
+ // scrolled was considered "already there" and was never redrawn into its new row; and it was consulted
171
+ // before the erase, so the rectangle the picture had vacated was never blanked either. Now the key carries
172
+ // the position and the hash, and the erase below is computed from `has` regardless of what is skipped.
173
+ const drawNeeded = new Set();
174
+ for (const [key, img] of want) if (!has.has(key)) drawNeeded.add(key);
175
+
176
+ // ---- erase what is going away ------------------------------------------------
177
+ // A placement leaves for two reasons: the content changed, or IT MOVED. Both need the old pixels gone, and
178
+ // both need them gone in the same write as the replacement — an erase with no draw is a blank rectangle,
179
+ // and that rectangle is what covered the transcript, the scrollbar and the composer.
180
+ //
181
+ // THE ERASE ALSO CLEARS THE RECORD. Emitting the blank is only half of it: `prev` is the frame the NEXT
182
+ // paint compares against, and it still says those cells held the old text. The next diff then finds "no
183
+ // change" and writes nothing, so the old glyphs stay on screen *under* the new ones — the overlap. dsh-tui
184
+ // does the same thing (src/ink/sixel-graphics.ts, `clearRegion(baseline, …)` over every erased rect) and
185
+ // for the same reason.
186
+ let erase = '';
187
+ for (const [key, entry] of has) {
188
+ if (want.has(key)) continue; // still wanted, exactly where it is
189
+ erase += eraseRect({ row: entry.row, col: entry.col, cols: entry.cols, rows: entry.rows });
190
+ if (prev && typeof prev.clear === 'function') {
191
+ const from = Math.min(prev.width, Math.max(0, entry.col || 0));
192
+ const width = Math.max(0, Math.min(prev.width - from, (entry.cols || 0) + 2));
193
+ for (let r = 0; r < (entry.rows || 0); r++) {
194
+ const y = (entry.row || 0) + r;
195
+ if (y < 0 || y >= prev.height) continue;
196
+ if (width > 0) prev.clear(from, y, width, 1, 0);
197
+ }
198
+ }
199
+ }
200
+ if (shown) {
201
+ shown.clear();
202
+ for (const [key, img] of want) shown.set(key, img);
203
+ }
204
+
205
+ const covered = new Set();
206
+ for (const img of want.values()) {
207
+ for (let r = 0; r < img.rows; r++) covered.add(img.row + r);
208
+ }
209
+ const changes = [];
210
+ next.diffRows(prev, (from, y, to) => {
211
+ if (covered.has(y)) return;
212
+ changes.push({ y, from, to });
213
+ });
214
+ if (prev && Array.isArray(prev.imageRows)) {
215
+ for (const row of prev.imageRows) {
216
+ if (covered.has(row)) continue;
217
+ if (row < 0 || row >= next.height) continue;
218
+ if (!changes.some((c) => c.y === row)) changes.push({ y: row, from: 0, to: next.width });
219
+ }
220
+ }
221
+
222
+ const events = changes.map((c) => ({ ...c, kind: 'text' }));
223
+ for (const img of want.values()) events.push({ y: img.row, kind: 'image', img });
224
+ events.sort((a, b) => a.y - b.y);
225
+
226
+ // AUTO-WRAP OFF FOR THE WHOLE FRAME.
227
+ //
228
+ // A sixel draw leaves the cursor somewhere inside the picture. If that lands on the last column, the
229
+ // terminal's auto-wrap pushes a line — and once the screen is full, that scrolls it. Every row here is
230
+ // placed by absolute address, so wrapping buys nothing and costs the one thing that must not move: the
231
+ // screen. Turning it off for the frame is what makes re-sending every picture on every frame safe.
232
+ const WRAP_OFF = '\x1b[?7l';
233
+ const WRAP_ON = '\x1b[?7h';
234
+ let out = before + WRAP_OFF + erase;
235
+ const sizeChanged = !prev || prev.width !== next.width || prev.height !== next.height;
236
+
237
+ if (sizeChanged) {
238
+ // The terminal's contents are unknown or the wrong shape: paint every row, in order, then every picture
239
+ // by absolute address at the END — the same shape as the diff path, for the same reason.
240
+ //
241
+ // A picture whose FIRST VISIBLE row is 0 can carry a NEGATIVE `row`: it is cropped, not absent. Looking
242
+ // pictures up by `img.row` therefore missed it whenever it straddled the top of the window, which is most
243
+ // of the time a formula is being scrolled past — it simply never got drawn on the first frame.
244
+ //
245
+ // A row a picture covers is written only up to the picture's RIGHT EDGE: the rest of that row is the
246
+ // interface's, and the scrollbar lives in its last column.
247
+ const rightEdge = new Map();
248
+ for (const img of want.values()) {
249
+ const from = Math.max(0, img.row);
250
+ const to = Math.min(img.row + img.rows, next.height);
251
+ const right = Math.min(next.width, (img.col || 0) + 1 + Math.max(0, img.cols));
252
+ for (let y = from; y < to; y++) rightEdge.set(y, Math.min(rightEdge.get(y) ?? next.width, right));
253
+ }
254
+ for (let y = 0; y < next.height; y++) {
255
+ const right = rightEdge.get(y) ?? next.width;
256
+ out += `\x1b[${y + 1};1H` + rowText(next, y, 0, right) + '\x1b[0m';
257
+ }
258
+ for (const img of want.values()) out += placeImage(img, next.height, cellHeight);
259
+ return out + WRAP_ON + after + cursor;
260
+ }
261
+
262
+ // Text rows and pictures in screen order, so a later row never lands on top of an earlier picture.
263
+ //
264
+ // WHETHER A PICTURE IS REDRAWN IS DECIDED BY WHAT THE TEXT DID, not by whether the picture itself changed.
265
+ // That is dsh-tui's rule, and this is its code (src/ink/sixel-graphics.ts there, `paint`):
266
+ //
267
+ // if (!this.dirty && !this.repaint.has(node)) {
268
+ // if (damage === undefined) damage = this.textDamage(diff)
269
+ // if (damage !== null && !damage.some(rect => overlaps(rect, next))) continue
270
+ // }
271
+ //
272
+ // The reason is what a Sixel does to the terminal: each draw moves the screen, so a redraw that was not
273
+ // required is a visible jump. A picture therefore goes back on screen only when the text around it moved —
274
+ // when a changed row overlaps the picture's own rectangle. An idle tick, a status-line number, a spinner:
275
+ // none of them touch a formula's rows, and none of them may cost a draw.
276
+ const imageRows = new Set();
277
+ // Pictures are COLLECTED here and drawn at the very END of the frame. A sixel sequence interleaved with
278
+ // cursor moves and text is the fragile arrangement: any byte the terminal misreads, any address it takes
279
+ // relatively, and everything after the picture lands somewhere else. Writing all the text first, then
280
+ // every picture by absolute address, means a picture can only ever affect its own rectangle.
281
+ const pending = [];
282
+ for (const ev of events) {
283
+ if (ev.kind === 'image') {
284
+ const span = placementRows(ev.img, next.height);
285
+ const redraw = drawNeeded.has(placementKey(ev.img));
286
+ // THE TEXT IS REWRITTEN ONLY WHEN THE PICTURE IS REWRITTEN — otherwise the rewrite ERASES it.
287
+ //
288
+ // The rows a picture owns are blank in the cell model, so writing their text means writing spaces, and
289
+ // a space over a cell destroys the pixels on it. Doing that on a frame whose picture was SKIPPED (the
290
+ // cache hit) blanked a picture that was still on screen: it vanished for a frame, came back on the next
291
+ // one, went away again — the flicker. The two decisions are one decision: either this rectangle is
292
+ // repainted end to end, or nothing touches it.
293
+ //
294
+ // AND ONLY UP TO THE LAST COLUMN, BUT SKIPPING THE SCROLLBAR'S.
295
+ //
296
+ // The row belongs to the interface, not to the picture: everything to the right of the formula is
297
+ // ordinary text and has to be rewritten, or the previous frame's characters stay there — a horizontal
298
+ // rule left over from a code fence hanging off the end of every formula. Writing the FULL row instead
299
+ // erased the scrollbar, whose track sits in the last column of exactly these rows, so it stops one short.
300
+ if (redraw) {
301
+ const right = Math.max(1, next.width - 1);
302
+ for (let y = span.from; y < span.to; y++) {
303
+ out += `\x1b[${y + 1};1H` + rowText(next, y, 0, right) + '\x1b[0m';
304
+ }
305
+ pending.push(ev.img);
306
+ }
307
+ // A row the picture owns is never written as ordinary text, drawn this frame or not: the characters
308
+ // there belong to the picture, and the model has them blank anyway.
309
+ for (let y = span.from; y < span.to; y++) imageRows.add(y);
310
+ continue;
311
+ }
312
+ if (imageRows.has(ev.y)) continue;
313
+ out += `\x1b[${ev.y + 1};${ev.from + 1}H` + rowText(next, ev.y, ev.from, ev.to) + '\x1b[0m';
314
+ }
315
+
316
+ // EVERY PICTURE LAST, each at its own absolute address.
317
+ for (const img of pending) out += placeImage(img, next.height, cellHeight);
318
+
319
+ return out + WRAP_ON + after + cursor;
320
+ }
321
+
322
+
323
+
324
+ /**
325
+ * Rows and bands are DIFFERENT UNITS, and confusing them is what made a formula jump into place mid-scroll.
326
+ *
327
+ * A placement is measured in terminal CELL ROWS (`img.rows`). The sixel payload it carries is measured in
328
+ * BANDS of six PIXEL rows. With a 20-pixel cell one cell row is 3.3 bands, so dropping `hidden` bands to
329
+ * account for `hidden` rows threw away a sixth of the picture that should have stayed: the image was drawn
330
+ * from the wrong point in its own payload, and because the offset changed by whole rows as the user scrolled,
331
+ * the formula appeared to slide in and out of existence.
332
+ *
333
+ * A crop can only land on a band boundary, so the drop is rounded DOWN to a whole band. Dropping less than a
334
+ * full cell leaves at most one band of pixels above the window — invisible — and never removes pixels that
335
+ * belong on screen.
336
+ *
337
+ * @param {number} hiddenRows Cell rows of the placement that sit above the window.
338
+ * @param {number} cellHeight The terminal's cell height in pixels.
339
+ * @returns {number} Bands to drop from the top of the payload.
340
+ */
341
+ function bandsToDrop(hiddenRows, cellHeight) {
342
+ if (hiddenRows <= 0) return 0;
343
+ const height = cellHeight > 0 ? cellHeight : 20;
344
+ return Math.floor((hiddenRows * height) / 6);
345
+ }
346
+
347
+ /**
348
+ * Drop whole bands from the top of a sixel payload, keeping the DCS header and the terminator.
349
+ *
350
+ * THE HEADER IS PART OF THE FIRST SEGMENT. A payload is `ESC P ... q "..." #...` followed by one segment per
351
+ * band separated by `-`, so slicing the split array without re-attaching the first element throws away the
352
+ * DCS introducer, the raster attributes and the palette — and the terminal then has no idea what the bytes
353
+ * are and reads them as text.
354
+ *
355
+ * @param {string} seq The full sequence.
356
+ * @param {number} drop Bands to drop from the top.
357
+ * @returns {string} The cropped sequence, or '' when nothing would remain.
358
+ */
359
+ function cropBands(seq, drop) {
360
+ if (drop <= 0) return seq;
361
+ const terminator = seq.endsWith('\x1b\\') ? '\x1b\\' : '';
362
+ const body = terminator ? seq.slice(0, -terminator.length) : seq;
363
+ const parts = body.split('-');
364
+ if (parts.length <= 1) return '';
365
+ // `parts[0]` carries the header AND the first band. Splitting the header off it lets both be handled
366
+ // correctly: the header is always kept, the first band is droppable like every other one.
367
+ const bandAt = parts[0].search(/#\d/);
368
+ if (bandAt < 0) return '';
369
+ const head = parts[0].slice(0, bandAt);
370
+ const bands = [parts[0].slice(bandAt), ...parts.slice(1)];
371
+ if (drop >= bands.length) return '';
372
+ return head + bands.slice(drop).join('-') + terminator;
373
+ }
374
+
375
+ function placeImage(img, height, cellHeight) {
376
+ const { from: top, to: bottom } = placementRows(img, height);
377
+ if (bottom <= top) return '';
378
+ const cellH = cellHeight > 0 ? cellHeight : 20;
379
+ const hiddenRows = top - img.row; // cell rows of the picture clipped away at the TOP
380
+
381
+ // CROP THE PIXELS, NOT THE PAYLOAD — AND CROP BOTH EDGES.
382
+ //
383
+ // A sixel payload can only be cut on a six-pixel band boundary, and a cell is 20 pixels, so cropping the
384
+ // encoded bytes always leaves 0..5 pixels of the picture above the window — up to a quarter of a cell. That
385
+ // leftover is the slide: the formula shifts as the view scrolls and snaps back when the leftover happens to
386
+ // be zero, which reads as "it only appears at certain scroll positions".
387
+ //
388
+ // The BOTTOM edge matters just as much. The transcript panel ends where the composer begins, and a picture
389
+ // clipped to a single visible row still carried its full payload: the remaining rows were drawn OVER the
390
+ // composer. Only the top was ever cut, so the bottom of every formula at the panel's edge spilled out.
391
+ const visibleRows = bottom - top;
392
+ if (visibleRows < img.rows && img.source) {
393
+ const regenerated = regenerate(img.source, hiddenRows * cellH, img.cols, visibleRows * cellH);
394
+ if (regenerated) {
395
+ return `\x1b[?80l\x1b[${top + 1};${(img.col || 0) + 1}H\x1b[0m${regenerated}\x1b[?80h`
396
+ + `\x1b[${top + 1};${(img.col || 0) + 1}H`;
397
+ }
398
+ }
399
+
400
+ let seq = img.seq;
401
+ if (hiddenRows > 0) seq = cropBands(img.seq, bandsToDrop(hiddenRows, cellH));
402
+ // A band crop cannot express a bottom edge either, so the tail bands are dropped by the same conversion.
403
+ const hiddenBelow = (img.row + img.rows) - bottom;
404
+ if (hiddenBelow > 0) {
405
+ const drop = bandsToDrop(hiddenBelow, cellH);
406
+ const parts = seq.split('-');
407
+ if (drop > 0 && parts.length > drop) seq = parts.slice(0, parts.length - drop).join('-') + (seq.endsWith('\x1b\\') ? '\x1b\\' : '');
408
+ }
409
+ if (!seq) return '';
410
+
411
+ // A BITMAP STARTS WHERE THE TEXT DOES: every transcript row opens with a one-column pad, so the picture
412
+ // goes one column in, where its own text would have started.
413
+ //
414
+ // THE CURSOR IS RE-ANCHORED AFTER THE DRAW. A Sixel leaves the cursor inside the picture — on Windows
415
+ // Terminal it is the last band's row at the drawing column (`SixelParser::_updateTextCursor`), other
416
+ // terminals differ — and every byte written after it is positioned relative to that. Re-anchoring costs
417
+ // nine bytes and means nothing downstream has to know where a Sixel left the cursor.
418
+ return `\x1b[?80l\x1b[${top + 1};${(img.col || 0) + 1}H\x1b[0m${seq}\x1b[?80h`
419
+ + `\x1b[${top + 1};${(img.col || 0) + 1}H`;
420
+ }
421
+
422
+
423
+ /**
424
+ * Re-render a formula cut at `skipTop` pixel rows, as a terminal sequence.
425
+ *
426
+ * A HOOK, so this module does not have to know about TeX. A placement carries the source it was made from,
427
+ * and whoever knows how to turn that source into pixels again plugs the function in here. Without one the
428
+ * payload is cropped on a band boundary instead, which is approximate but never wrong in position.
429
+ *
430
+ * @type {((source: string, skipTop: number, maxColumns: number, maxHeight?: number) => string|null)|null}
431
+ */
432
+ let regenerateHook = null;
433
+
434
+ /** @param {((source: string, skipTop: number, maxColumns: number, maxHeight?: number) => string|null)|null} fn */
435
+ export function setImageRegenerator(fn) {
436
+ regenerateHook = fn;
437
+ }
438
+
439
+ /**
440
+ * Regenerate a payload cropped at both edges, or null when no renderer is installed.
441
+ *
442
+ * `skipTop` and `maxHeight` are PIXEL rows: a picture clipped by the window has to be re-typeset from its
443
+ * first visible pixel and cut again at its last one, because a band crop can express neither.
444
+ *
445
+ * @param {string} source
446
+ * @param {number} skipTop Pixels to remove from the top.
447
+ * @param {number} maxColumns Columns the result may occupy.
448
+ * @param {number} [maxHeight] Pixels the result may occupy, or undefined for no bottom limit.
449
+ * @returns {string|null}
450
+ */
451
+ function regenerate(source, skipTop, maxColumns, maxHeight) {
452
+ if (typeof regenerateHook !== 'function') return null;
453
+ try {
454
+ return regenerateHook(source, skipTop, maxColumns, maxHeight) || null;
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+
461
+
462
+ /**
463
+ * The first and last screen row a placement may touch, clipped to both the screen and its own panel.
464
+ *
465
+ * `clipTop`/`clipBottom` come from the composer: a formula belongs to the transcript, which ends where the
466
+ * input box begins. Clipping to the screen alone let a picture's last rows spill into the composer — the
467
+ * formula slid down into the input box before it left the view.
468
+ *
469
+ * @param {{row:number, rows:number, clipTop?:number, clipBottom?:number}} img
470
+ * @param {number} height Rows the screen has.
471
+ * @returns {{from:number, to:number}} Half-open row range in SCREEN coordinates.
472
+ */
473
+ export function placementRows(img, height) {
474
+ const limitTop = img.clipTop != null ? Math.max(0, img.clipTop) : 0;
475
+ const limitBottom = img.clipBottom != null ? Math.min(height, img.clipBottom) : height;
476
+ const top = Math.max(limitTop, img.row);
477
+ const bottom = Math.min(img.row + img.rows, limitBottom);
478
+ return { from: top, to: Math.max(top, bottom) };
479
+ }
480
+
481
+
482
+
483
+
484
+ /**
485
+ * One row's cells from `from` up to `to`, with the style changes inside that range.
486
+ *
487
+ * A style is represented as the accumulated SGR string that establishes it; the writer resets and re-applies
488
+ * rather than computing a minimal difference, because a wrong minimal difference is a visible bug and a reset
489
+ * costs six bytes.
490
+ */
491
+ function rowText(screen, y, from, to) {
492
+ let out = '';
493
+ let styleId = -1;
494
+ for (let x = from; x < to; x++) {
495
+ if (screen.isSpacer(x, y)) continue;
496
+ const cell = screen.at(x, y);
497
+ if (cell.styleId !== styleId) {
498
+ out += '\x1b[0m' + screen.stylePool.get(cell.styleId);
499
+ styleId = cell.styleId;
500
+ }
501
+ out += cell.ch;
502
+ }
503
+ return out;
504
+ }
505
+
506
+ export { eraseRect, eraseRegion as eraseRegionRow };
507
+
508
+ /** Kept for callers that erase a single row span. */
509
+ function eraseRegion(x, y, width) {
510
+ if (width <= 0) return '';
511
+ return `\x1b[${y + 1};${x + 1}H\x1b[${width}X`;
512
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Text and styled strings → a Screen.
3
+ *
4
+ * The frame composer produces rows of ANSI-decorated strings, because that is how every surface in this app was
5
+ * written. Rather than rewrite all of them at once, this module paints those rows into a cell grid: it parses
6
+ * the SGR runs, interns each style once, and writes the characters with their widths.
7
+ *
8
+ * The result is what the painter wants — a grid it can diff cell by cell, and in which a bitmap is a region
9
+ * with no characters in it rather than a row with bytes in it.
10
+ */
11
+ import { charWidth, CELL_WIDTH } from './screen.js';
12
+
13
+ /** Matches an SGR sequence: `ESC [ params m`. */
14
+ const SGR = /\x1b\[([0-9;]*)m/g;
15
+
16
+ /** Matches any other escape sequence, which the painter does not place as text. */
17
+ const OTHER_ESCAPE = /\x1b(\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])/g;
18
+
19
+ /**
20
+ * Split a line into runs of `{ text, styleId }`, interning each style.
21
+ *
22
+ * Styles are tracked as the CURRENT sgr string in effect, accumulated until a reset. Two runs that end up with
23
+ * the same accumulated string share an id, so a row of plain text costs one lookup.
24
+ *
25
+ * @param {string} line
26
+ * @param {import('./screen.js').StylePool} stylePool
27
+ * @returns {Array<{text: string, styleId: number}>}
28
+ */
29
+ export function styledRuns(line, stylePool) {
30
+ const runs = [];
31
+ let current = '';
32
+ let pos = 0;
33
+ SGR.lastIndex = 0;
34
+
35
+ const push = (text) => {
36
+ if (!text) return;
37
+ runs.push({ text, styleId: stylePool.intern(current) });
38
+ };
39
+
40
+ let m;
41
+ while ((m = SGR.exec(line))) {
42
+ push(line.slice(pos, m.index));
43
+ const params = m[1];
44
+ // `0` and an empty parameter both mean "reset everything".
45
+ if (params === '' || params === '0') current = '';
46
+ else current += `\x1b[${params}m`;
47
+ pos = m.index + m[0].length;
48
+ }
49
+ push(line.slice(pos));
50
+ return runs;
51
+ }
52
+
53
+ /** Strip every escape sequence from a string, leaving what the terminal would draw as text. */
54
+ export function stripEscapes(line) {
55
+ return String(line)
56
+ .replace(SGR, '')
57
+ .replace(OTHER_ESCAPE, '')
58
+ .replace(/\x1b/g, '');
59
+ }
60
+
61
+ /** Terminal width of a string once its escapes are ignored. */
62
+ export function displayWidth(line) {
63
+ let w = 0;
64
+ for (const ch of stripEscapes(line)) {
65
+ const cw = charWidth(ch);
66
+ if (cw > 0) w += cw;
67
+ }
68
+ return w;
69
+ }
70
+
71
+ /**
72
+ * Paint rows of styled text into a screen.
73
+ *
74
+ * @param {import('./screen.js').Screen} screen Destination, already the right size.
75
+ * @param {string[]} rows One string per row; extra rows are ignored, missing ones stay blank.
76
+ * @param {import('./screen.js').StylePool} stylePool
77
+ */
78
+ export function paintRows(screen, rows, stylePool) {
79
+ screen._rowCount = rows.length;
80
+ const n = Math.min(rows.length, screen.height);
81
+ for (let y = 0; y < n; y++) {
82
+ const line = rows[y];
83
+ if (!line) continue;
84
+ let x = 0;
85
+ for (const run of styledRuns(line, stylePool)) {
86
+ for (const ch of run.text) {
87
+ const w = charWidth(ch);
88
+ if (w === 0) continue; // a combining mark has no column of its own
89
+ if (x + w > screen.width) break;
90
+ screen.set(x, y, ch, run.styleId, w);
91
+ x += w;
92
+ }
93
+ if (x >= screen.width) break;
94
+ }
95
+ screen._mark(0, y, screen.width);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Turn a screen back into rows of ANSI strings, for the paths that still expect text.
101
+ *
102
+ * This is the inverse of `paintRows` and is meant for the web mirror and for plugins that read the frame's
103
+ * lines; the terminal itself is written from the cell diff, not from these strings.
104
+ *
105
+ * @param {import('./screen.js').Screen} screen
106
+ * @returns {string[]}
107
+ */
108
+ export function screenToRows(screen) {
109
+ const rows = [];
110
+ for (let y = 0; y < screen.height; y++) {
111
+ let out = '';
112
+ let styleId = -1;
113
+ for (let x = 0; x < screen.width; x++) {
114
+ if (screen.isSpacer(x, y)) continue;
115
+ const cell = screen.at(x, y);
116
+ if (cell.styleId !== styleId) {
117
+ out += '\x1b[0m' + screen.stylePool.get(cell.styleId);
118
+ styleId = cell.styleId;
119
+ }
120
+ out += cell.ch;
121
+ }
122
+ if (styleId !== -1) out += '\x1b[0m';
123
+ rows.push(out);
124
+ }
125
+ return rows;
126
+ }
127
+
128
+ export { CELL_WIDTH };