@tiens.nguyen/gu-cli 1.0.686

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 (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,602 @@
1
+ /**
2
+ * App-owned scrollback for the `gu` terminal (task #141, second attempt).
3
+ *
4
+ * WHY THIS EXISTS. A bottom bar cannot survive the user scrolling as long as SCROLLING BELONGS
5
+ * TO THE TERMINAL: it repaints its own scrollback over the whole window and never tells the
6
+ * application. The way out is not the alternate screen — it is taking the wheel. With mouse
7
+ * reporting on, a wheel notch arrives as an INPUT EVENT and the terminal does not move, so the
8
+ * application decides what the window shows. Keep every printed line in a buffer, paint the
9
+ * slice the user has scrolled to, and the reserved bottom rows never move.
10
+ *
11
+ * That is exactly why an app that does this can offer "jump to bottom": it knows where you are.
12
+ *
13
+ * The view FOLLOWS THE BOTTOM: anything printed brings it home first, so scrolling up is the
14
+ * only way to leave the live tail — and the "jump to bottom" line exists precisely for the
15
+ * moments when you have.
16
+ *
17
+ * Everything here is pure — a buffer plus arithmetic — so the scroll behaviour is testable
18
+ * without a terminal. The REPL owns the mouse, the writes, and when to render.
19
+ */
20
+ import { charWidth } from "./terminal-layout.mjs";
21
+
22
+ /**
23
+ * The printed transcript, as physical lines.
24
+ *
25
+ * `append` takes raw output exactly as it was written to stdout: text may arrive in fragments
26
+ * with no trailing newline (a streamed answer), so the last line stays OPEN until its newline
27
+ * lands. Carriage returns rewrite the open line, which is how in-place progress text behaves.
28
+ * ANSI colour is kept — it is what the line looked like.
29
+ */
30
+ export function createTranscript({ max = 20000 } = {}) {
31
+ // Colour is kept; anything that MOVES or ERASES is dropped. A recorded line is replayed
32
+ // verbatim when the user scrolls back, so a stray ESC[2A in it would move the caret and
33
+ // scramble the repaint — the line has to be inert text.
34
+ const sanitize = (s) =>
35
+ String(s)
36
+ .replace(/\x1b\[[0-9;?]*[A-LN-Za-ln-z]/g, "") // every CSI except SGR ("m")
37
+ .replace(/\x1b[78]/g, "") // DECSC / DECRC
38
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, ""); // OSC (title sets)
39
+
40
+ /** @type {string[]} */
41
+ let lines = [""];
42
+ let total = 0; // lines that have scrolled out of the buffer, so line numbers stay meaningful
43
+
44
+ const trim = () => {
45
+ if (lines.length <= max) return;
46
+ const drop = lines.length - max;
47
+ lines = lines.slice(drop);
48
+ total += drop;
49
+ };
50
+
51
+ return {
52
+ append(text) {
53
+ const s = sanitize(text ?? "");
54
+ if (!s) return;
55
+ for (const ch of s) {
56
+ if (ch === "\n") lines.push("");
57
+ else if (ch === "\r") lines[lines.length - 1] = "";
58
+ else lines[lines.length - 1] += ch;
59
+ }
60
+ trim();
61
+ },
62
+ /** All lines, oldest first. The last one may still be open (no newline yet). */
63
+ lines: () => lines,
64
+ /** How many lines are available to scroll through. */
65
+ length: () => lines.length,
66
+ dropped: () => total,
67
+ /**
68
+ * Replace recorded lines [fromAbs..toAbs] (ABSOLUTE indices, i.e. including lines already
69
+ * dropped) with `next`. The one mutation the transcript allows, and it exists for exactly
70
+ * one reason: a printed block that can be expanded and collapsed in place. Absolute
71
+ * indices because the buffer trims from the front, so a stored position must survive that.
72
+ * Returns the new absolute end, or null when the range has scrolled out of the buffer.
73
+ */
74
+ replaceRange(fromAbs, toAbs, next) {
75
+ const from = fromAbs - total;
76
+ const to = toAbs - total;
77
+ if (from < 0 || to >= lines.length || from > to) return null;
78
+ lines.splice(from, to - from + 1, ...next.map(sanitize));
79
+ trim();
80
+ return fromAbs + next.length - 1;
81
+ },
82
+ clear() {
83
+ lines = [""];
84
+ total = 0;
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Wrap ONE recorded line into the display rows a terminal would soft-wrap it into.
91
+ *
92
+ * The transcript holds logical lines; the terminal wrapped the long ones when they were first
93
+ * printed. A repaint has to reproduce that or the text is simply lost (a clip drops everything
94
+ * past the right edge) AND the arithmetic drifts, because one logical line can be three rows on
95
+ * screen. Colour is carried onto continuation rows so a wrapped coloured line stays coloured.
96
+ *
97
+ * Counted in COLUMNS by code point (charWidth): iterating UTF-16 units cut emoji in half at a
98
+ * row boundary and mis-measured every wide character, so the wrap fell out of step with the
99
+ * terminal's own — which is what makes a repaint lose a line.
100
+ */
101
+ export function wrapVisible(line, width) {
102
+ const w = Math.max(1, width);
103
+ const rows = [];
104
+ let row = "";
105
+ let seen = 0;
106
+ let sgr = ""; // the colour state to re-open on the next row
107
+ const chars = [...line];
108
+ for (let i = 0; i < chars.length; i++) {
109
+ if (chars[i] === "\x1b") {
110
+ const m = /^\x1b\[[0-9;]*m/.exec(chars.slice(i).join(""));
111
+ if (m) {
112
+ row += m[0];
113
+ sgr = m[0] === "\x1b[0m" ? "" : sgr + m[0];
114
+ i += [...m[0]].length - 1;
115
+ continue;
116
+ }
117
+ }
118
+ const cw = charWidth(chars[i]);
119
+ // A wide character that would straddle the edge moves down whole, exactly as the terminal
120
+ // does — leaving the last cell of the row blank rather than splitting the glyph.
121
+ if (seen + cw > w) {
122
+ rows.push(row);
123
+ row = sgr;
124
+ seen = 0;
125
+ }
126
+ row += chars[i];
127
+ seen += cw;
128
+ }
129
+ rows.push(row);
130
+ return rows;
131
+ }
132
+
133
+ /** Every recorded line as the display rows the screen actually shows. */
134
+ export function displayRows(lines, width) {
135
+ const out = [];
136
+ for (const l of lines) for (const r of wrapVisible(l, width)) out.push(r);
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * For every display row, the index of the recorded line it came from.
142
+ *
143
+ * The screen and the transcript do not agree on what a "row" is: one long line is several
144
+ * rows once wrapped. Anything that has to turn a CLICKED ROW back into a piece of content —
145
+ * expanding a printed thought block (task #148) — needs that correspondence, and computing it
146
+ * from the same wrapping the viewport uses is the only way it stays right at every width.
147
+ */
148
+ export function displayRowOwners(lines, width) {
149
+ const out = [];
150
+ lines.forEach((l, i) => {
151
+ for (let r = 0; r < wrapVisible(l, width).length; r++) out.push(i);
152
+ });
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * The FIRST display row on screen, given how many rows the transcript is painted into.
158
+ *
159
+ * `height` IS NOT ALWAYS THE HEIGHT OF THE SCROLLING AREA, and that is the whole reason this
160
+ * is one named function rather than a line repeated at each call site. An in-place block drawn
161
+ * UNDER the transcript — the live "Thinking… (12s)" status, which is deliberately never
162
+ * recorded in the transcript — owns the rows it covers, and the transcript stops above them.
163
+ * Hand this the full height while such a block is up and every screen row resolves to a line
164
+ * that many rows too early: hovering a printed thought highlighted the block a few lines above
165
+ * the pointer, and a repaint dropped transcript text on top of the thinking line.
166
+ *
167
+ * `total` is the number of display rows the transcript has (displayRowOwners(...).length).
168
+ *
169
+ * `offset` IS HOW FAR THE USER HAS SCROLLED BACK, counted from the bottom exactly as
170
+ * viewportSlice counts it — and it must be, because these two answer the same question from
171
+ * opposite ends: viewportSlice says which rows get PAINTED, this says which row the top of the
172
+ * paint is. Defaulting it to 0 is what the live tail means, and every caller that only ever
173
+ * looks at the live tail can keep passing two arguments.
174
+ *
175
+ * IT USED TO TAKE NO OFFSET AT ALL, and that is the whole of the scrolled-back selection bug
176
+ * (reported 2026-09-01: "when i scroll up and copy the question it is not selected; if i scroll
177
+ * down to bottom it is allowed to select"). Every screen-row → transcript-row mapping in the
178
+ * REPL runs through here, so while scrolled back they all resolved to rows near the END of the
179
+ * transcript — the text at the live tail, not the text under the pointer. Rather than fix the
180
+ * arithmetic, the callers had been gated off with `!scrolledBack()`, which is why dragging up
181
+ * there highlighted nothing and copied nothing.
182
+ *
183
+ * Clamped the same way viewportSlice clamps, so a wheel spun past the top and this function
184
+ * cannot disagree about where the view actually stopped.
185
+ */
186
+ export function firstVisibleRow(total, height, offset = 0) {
187
+ const h = Math.max(1, height);
188
+ const maxOffset = Math.max(0, total - h);
189
+ const clamped = Math.max(0, Math.min(Math.max(0, offset | 0), maxOffset));
190
+ return Math.max(0, total - h - clamped);
191
+ }
192
+
193
+ /**
194
+ * Which recorded line viewport row `row` (1-based) is showing — or -1 when that row holds no
195
+ * transcript (above the first line, or below the last one, where an in-place block lives).
196
+ *
197
+ * `offset` is the scroll position, as in firstVisibleRow: omit it for the live tail.
198
+ */
199
+ export function lineAtViewportRow(owners, row, height, offset = 0) {
200
+ // ROWS ARE 1-BASED. Without this guard row 0 quietly resolves to the line one row ABOVE the
201
+ // top of the view — a real index, so nothing downstream can tell it was never on screen.
202
+ if (!(row >= 1)) return -1;
203
+ const idx = firstVisibleRow(owners.length, height, offset) + (row - 1);
204
+ return idx < 0 || idx >= owners.length ? -1 : owners[idx];
205
+ }
206
+
207
+ /**
208
+ * The 1-based viewport rows currently showing recorded lines `from..to`, in order.
209
+ *
210
+ * The inverse of lineAtViewportRow, and it must stay that way — it is what lets a change that
211
+ * only RECOLOURS a block repaint the block's own rows instead of the whole view. Rows scrolled
212
+ * off the top, and rows below the transcript, are simply absent.
213
+ */
214
+ export function viewportRowsForLines(owners, from, to, height) {
215
+ const first = firstVisibleRow(owners.length, height);
216
+ const out = [];
217
+ for (let i = first; i < owners.length; i++) {
218
+ if (owners[i] < from || owners[i] > to) continue;
219
+ const row = i - first + 1;
220
+ if (row >= 1 && row <= Math.max(1, height)) out.push({ row, index: i });
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * The slice of `lines` to show, given how far back the user has scrolled.
227
+ *
228
+ * `offset` counts lines from the BOTTOM: 0 is live (the newest `height` lines). It is clamped
229
+ * here rather than by the caller, so a wheel spun past the top or bottom simply stops — the
230
+ * caller can always add its delta blindly and ask what actually happened.
231
+ */
232
+ export function viewportSlice(lines, offset, height) {
233
+ const h = Math.max(1, height);
234
+ const maxOffset = Math.max(0, lines.length - h);
235
+ const clamped = Math.max(0, Math.min(offset, maxOffset));
236
+ const end = lines.length - clamped;
237
+ const rows = lines.slice(Math.max(0, end - h), end);
238
+ return {
239
+ rows,
240
+ offset: clamped,
241
+ atBottom: clamped === 0,
242
+ /** Lines hidden BELOW the viewport — what "jump to bottom" would bring back. */
243
+ below: clamped,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Bytes that repaint the scrolling area (rows 1..height) with `rows`, top-aligned, each row
249
+ * cleared first and clipped so nothing wraps into the next one — a wrapped row here would push
250
+ * the whole view down by one and desynchronise every later repaint.
251
+ *
252
+ * The caret is left on the LAST painted row: while the user is scrolled back nothing types
253
+ * there, and parking it inside the region keeps the terminal from scrolling on its own.
254
+ *
255
+ * `preserveCaret:false` is what a caller that keeps its OUTPUT POSITION in the terminal's DECSC
256
+ * slot must pass — there is only one such slot, so bracketing this repaint with DECSC/DECRC
257
+ * overwrites the output position with wherever the caret happened to be (the input line), and
258
+ * the next printed line lands in the frozen zone, indented by the width of the prompt. Same
259
+ * hazard, same flag as `paintFrozenRowsSeq`.
260
+ */
261
+ export function paintViewportSeq(rows, height, width, clip, { preserveCaret = true } = {}) {
262
+ const h = Math.max(1, height);
263
+ let out = preserveCaret ? "\x1b7" : "";
264
+ for (let i = 0; i < h; i++) {
265
+ const row = rows[i] ?? "";
266
+ out += `\x1b[${i + 1};1H\x1b[2K` + clip(row, Math.max(1, width - 1));
267
+ }
268
+ return out + (preserveCaret ? "\x1b8" : "");
269
+ }
270
+
271
+ /**
272
+ * The bar's top row while the user is scrolled back: it replaces the plain rule with the
273
+ * affordance that tells them where they are AND that a click brings them back. Returned as
274
+ * plain text plus the click target, so the caller styles it and hit-tests the row itself.
275
+ */
276
+ export function jumpToBottomRow({ below, width }) {
277
+ const label = `↓ jump to bottom (click) · ${below} line${below === 1 ? "" : "s"} below`;
278
+ const w = Math.max(20, width);
279
+ const pad = Math.max(0, Math.floor((w - label.length) / 2));
280
+ return { text: " ".repeat(pad) + label, from: pad, to: pad + label.length };
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------------------
284
+ // SELECTION (task #176) — turning a drag on the screen back into text.
285
+ //
286
+ // The terminal cannot help here. While gu reports mouse motion (?1003h), the terminal does
287
+ // NOT run its own drag-select, and no escape sequence reports what a terminal has selected. So
288
+ // a selection has to be tracked and extracted by us. That is also why this is worth doing at
289
+ // all: macOS Terminal.app has no copy-on-select setting and never has, so "select and it is
290
+ // copied" cannot be delegated to the emulator for half the users.
291
+ //
292
+ // Everything below is PURE — screen coordinates plus the recorded lines in, text out — so the
293
+ // arithmetic can be argued with in a test instead of by dragging a mouse.
294
+ // ---------------------------------------------------------------------------------------
295
+
296
+ /** Colour escapes occupy no cells; selection maths is over VISIBLE characters only. */
297
+ const _plain = (s) => String(s ?? "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
298
+
299
+ /**
300
+ * The two ends of a drag, in the order the text reads.
301
+ *
302
+ * A drag upward or right-to-left produces an anchor AFTER the focus, and every consumer would
303
+ * otherwise have to re-check. Ordered once, here.
304
+ */
305
+ export function orderSelection(a, b) {
306
+ const A = { row: Math.max(0, a?.row | 0), col: Math.max(0, a?.col | 0) };
307
+ const B = { row: Math.max(0, b?.row | 0), col: Math.max(0, b?.col | 0) };
308
+ const backwards = B.row < A.row || (B.row === A.row && B.col < A.col);
309
+ return backwards ? { start: B, end: A } : { start: A, end: B };
310
+ }
311
+
312
+ /** Nothing was dragged — the two ends are the same cell. A plain click, not a selection. */
313
+ export function isEmptySelection(sel) {
314
+ if (!sel?.start || !sel?.end) return true;
315
+ return sel.start.row === sel.end.row && sel.start.col === sel.end.col;
316
+ }
317
+
318
+ /**
319
+ * The text inside a selection, given the recorded lines and the width they are shown at.
320
+ *
321
+ * `start`/`end` are DISPLAY rows — what the user dragged over — and columns are visible cells.
322
+ * Two things make the result text rather than a screenshot:
323
+ *
324
+ * · SOFT WRAP IS UNDONE. wrapVisible breaks a long line at the width with no word logic, so
325
+ * display rows belonging to one recorded line rejoin with nothing between them and the
326
+ * sentence is whole again. Rows from DIFFERENT recorded lines join with a newline.
327
+ * · THE GUTTER IS DROPPED. Every line in this UI is indented into a shared text column; that
328
+ * indent is layout, not content, and pasting it back into an editor reindents the world.
329
+ *
330
+ * What it deliberately does NOT do is undo a HARD wrap: renderAnswer emits each wrapped row as
331
+ * its own recorded line, so selecting an answer yields those rows. Task #158 is the reason the
332
+ * caller should prefer a registered block's SOURCE text when the selection covers one —
333
+ * selectionCoversLines() answers that — because a copied answer has to still run.
334
+ */
335
+ export function selectionText(lines, sel, width, { gutter = "" } = {}) {
336
+ const all = Array.isArray(lines) ? lines : [];
337
+ const w = Math.max(1, width | 0);
338
+ if (!sel?.start || !sel?.end || !all.length) return "";
339
+ const { start, end } = orderSelection(sel.start, sel.end);
340
+ const rows = displayRows(all, w);
341
+ const owners = displayRowOwners(all, w);
342
+ if (!rows.length) return "";
343
+ // A drag that BEGINS past the last row selected nothing — clamping both ends would instead
344
+ // return the final row's text, which was never under the pointer. A drag that begins in the
345
+ // content and runs off the bottom is different, and clamps normally below.
346
+ if (start.row >= rows.length) return "";
347
+ const first = Math.min(start.row, rows.length - 1);
348
+ const last = Math.min(end.row, rows.length - 1);
349
+ if (last < first) return "";
350
+
351
+ const out = [];
352
+ let curOwner = null;
353
+ for (let r = first; r <= last; r++) {
354
+ const text = _plain(rows[r]);
355
+ const from = r === first ? Math.min(start.col, text.length) : 0;
356
+ const to = r === last ? Math.min(end.col, text.length) : text.length;
357
+ const piece = to > from ? text.slice(from, to) : "";
358
+ if (owners[r] === curOwner) out[out.length - 1] += piece; // same recorded line: soft wrap
359
+ else {
360
+ out.push(piece);
361
+ curOwner = owners[r];
362
+ }
363
+ }
364
+ const g = String(gutter || "");
365
+ return out
366
+ .map((l) => (g && l.startsWith(g) ? l.slice(g.length) : l))
367
+ .join("\n")
368
+ .replace(/[ \t]+$/gm, "");
369
+ }
370
+
371
+ /**
372
+ * A drag in SCREEN coordinates → the selection in ABSOLUTE display rows (task #176 → #219).
373
+ *
374
+ * The two spaces are different and the difference is the scroll position: screen rows are
375
+ * 1-based and count from the top of the viewport, absolute rows are 0-based and count from the
376
+ * top of the whole transcript. Everything downstream — the highlight painted by
377
+ * applySelectionHighlight, the text recovered by selectionText, the block lookup in
378
+ * selectionCoversLines — works in absolute rows, so this conversion is the single place the
379
+ * scroll position enters the selection at all. Getting it wrong does not fail loudly: it
380
+ * selects real text from the wrong part of the transcript.
381
+ *
382
+ * Lives here, pure, rather than in the REPL, because the REPL runs main() on import and so
383
+ * cannot be imported by a test — which is exactly how it went wrong. The version in the REPL
384
+ * open-coded `total - height`, could not see `offset`, and was therefore correct only at the
385
+ * live tail; the response had been to switch the whole feature off while scrolled back rather
386
+ * than to pass the offset in.
387
+ *
388
+ * THE COLUMN -1 BELONGS TO THE EARLIER END. It turns a 1-based mouse column into an inclusive
389
+ * 0-based one, and "earlier" means earlier ON SCREEN, not the anchor — which is the LATER end
390
+ * whenever the pointer was dragged right-to-left. Applying it to the anchor dropped a
391
+ * character off each end of every backwards drag, so the ordering has to happen first.
392
+ */
393
+ export function absoluteSelection(sel, { total, height, offset = 0 } = {}) {
394
+ if (!sel?.start || !sel?.end) return null;
395
+ const firstVisible = firstVisibleRow(total | 0, height, offset);
396
+ const { start, end } = orderSelection(sel.start, sel.end);
397
+ return {
398
+ start: { row: firstVisible + (start.row - 1), col: Math.max(0, start.col - 1) },
399
+ end: { row: firstVisible + (end.row - 1), col: end.col },
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Does this selection cover every display row of these recorded lines?
405
+ *
406
+ * The caller uses it to hand back a registered block's SOURCE instead of the painted rows
407
+ * (#158). Partial selections deliberately do not qualify: half a block cannot be mapped back
408
+ * into half a source string, and guessing would paste something that was never on screen.
409
+ */
410
+ export function selectionCoversLines(sel, { fromLine, toLine, lines, width }) {
411
+ if (!sel?.start || !sel?.end) return false;
412
+ const owners = displayRowOwners(Array.isArray(lines) ? lines : [], Math.max(1, width | 0));
413
+ const { start, end } = orderSelection(sel.start, sel.end);
414
+ let firstRow = -1;
415
+ let lastRow = -1;
416
+ owners.forEach((owner, row) => {
417
+ if (owner >= fromLine && owner <= toLine) {
418
+ if (firstRow < 0) firstRow = row;
419
+ lastRow = row;
420
+ }
421
+ });
422
+ if (firstRow < 0) return false;
423
+ return start.row <= firstRow && end.row >= lastRow;
424
+ }
425
+
426
+ // A "word" for double-click: a run of characters a programmer would expect to travel together.
427
+ // Deliberately WIDER than \w — a path, a dotted call and a kebab flag are each one thing to
428
+ // someone double-clicking them, and splitting "src/api/graph.js" into five selections is the
429
+ // behaviour that makes people stop using the feature.
430
+ const _WORD_CHAR = /[A-Za-z0-9_\-./@:+~$]/;
431
+
432
+ /**
433
+ * The word around a column, as {from, to} — or null when the column is not on one.
434
+ *
435
+ * Clicking whitespace selects nothing rather than the nearest word: guessing which side the
436
+ * user meant is wrong half the time, and an empty result is easy to retry.
437
+ */
438
+ export function wordRangeAt(text, col) {
439
+ const s = _plain(text);
440
+ const i = Math.max(0, Math.min(col | 0, s.length - 1));
441
+ if (!s.length || !_WORD_CHAR.test(s[i] ?? "")) return null;
442
+ let from = i;
443
+ let to = i + 1;
444
+ while (from > 0 && _WORD_CHAR.test(s[from - 1])) from--;
445
+ while (to < s.length && _WORD_CHAR.test(s[to])) to++;
446
+ return { from, to };
447
+ }
448
+
449
+ /**
450
+ * Drag tracking, as a pure reducer (task #176).
451
+ *
452
+ * The three facts an SGR report carries — press, motion, release — arrive at different times and
453
+ * mean nothing individually; a selection is the shape they make together. Keeping that shape in
454
+ * a reducer means the rules can be argued with in a test, and the mouse handler is left doing
455
+ * only decode-and-dispatch.
456
+ *
457
+ * `state` is null (nothing in progress) or { anchor, focus, moved }. Returns { state, action }:
458
+ *
459
+ * null nothing to do
460
+ * {type:"update"} the selection changed and should be repainted
461
+ * {type:"commit"} the button came up on a real selection — copy it
462
+ * {type:"cancel"} the button came up without moving: a CLICK, not a selection — `at` is
463
+ * the cell it went down on, so the caller can run the click behaviours
464
+ * from the release rather than guessing at press time (task #177)
465
+ *
466
+ * The commit/cancel split is the whole safety of auto-copy. Copying on every release would
467
+ * overwrite the clipboard on every stray click with an empty string, which is destructive and
468
+ * not undoable. `moved` is what separates the two, and it is set by comparing cells rather than
469
+ * by a timer, so a slow careful drag is still a drag.
470
+ */
471
+ export function dragReduce(state, ev) {
472
+ const at = { row: Math.max(0, ev?.row | 0), col: Math.max(0, ev?.col | 0) };
473
+ switch (ev?.kind) {
474
+ case "press":
475
+ // A fresh press always restarts. A press arriving mid-drag means the previous release was
476
+ // never delivered (a lost focus, another window) — the old anchor is stale either way.
477
+ return { state: { anchor: at, focus: at, moved: false }, action: null };
478
+ case "motion": {
479
+ if (!state) return { state, action: null }; // hover, not a drag — the caller owns that
480
+ const moved = state.moved || at.row !== state.anchor.row || at.col !== state.anchor.col;
481
+ const next = { anchor: state.anchor, focus: at, moved };
482
+ return {
483
+ state: next,
484
+ action: moved ? { type: "update", sel: { start: next.anchor, end: next.focus } } : null,
485
+ };
486
+ }
487
+ case "release": {
488
+ if (!state) return { state: null, action: null };
489
+ const sel = { start: state.anchor, end: state.moved ? at : state.focus };
490
+ const moved = state.moved || at.row !== state.anchor.row || at.col !== state.anchor.col;
491
+ return {
492
+ state: null,
493
+ // `at` is where the button went DOWN, which is the cell a click is aimed at — the
494
+ // caller cannot recover it once the state is cleared, and the release cell is not the
495
+ // same thing (a click that slid within one cell still belongs to its anchor).
496
+ action: moved ? { type: "commit", sel } : { type: "cancel", at: state.anchor },
497
+ };
498
+ }
499
+ default:
500
+ return { state, action: null };
501
+ }
502
+ }
503
+
504
+ /**
505
+ * What an SGR button byte means for dragging: "press" | "motion" | "wheel" | null.
506
+ *
507
+ * Bit 5 (32) is motion, bit 6 (64) is the wheel, and the low two bits name the button — 0 being
508
+ * left. Anything else (right, middle, a modifier-only report) is not ours, and saying so here
509
+ * keeps the reducer from having to know about terminal encodings at all.
510
+ */
511
+ export function dragEventKind(btn, press) {
512
+ const b = Number(btn) | 0;
513
+ if (b & 64) return "wheel";
514
+ if (press === "m") return "release";
515
+ if (b & 32) return (b & 0b11) === 0 ? "motion" : null; // motion with the LEFT button held
516
+ return (b & 0b11) === 0 ? "press" : null;
517
+ }
518
+
519
+ // The selection's colours (task #178). THE LOGO'S CYAN, as a BACKGROUND — not as text.
520
+ //
521
+ // 46 is the background of `\x1b[36m`, the exact colour the startup chevron is drawn in, so the
522
+ // selection is the product's own colour rather than a fourth one invented for it. It is the
523
+ // user's palette cyan, which means it still inherits their theme — unlike a 256-colour or
524
+ // truecolor background, which would fight every custom scheme and every light terminal, the
525
+ // same argument that kept syntax colour out of the edit cards.
526
+ //
527
+ // A BACKGROUND, because the cyan FOREGROUND is already spoken for: the ">> " prompt, where it
528
+ // means "this is the line you are typing", and the jump-to-bottom hint. A foreground also
529
+ // REPLACES the text's own — dragging across a red ✗ would recolour it, so the one signal #41
530
+ // reserves for "you must not miss this" would be erased by looking at it. A background leaves
531
+ // every foreground meaning intact and reads as a REGION, which is what a selection is.
532
+ //
533
+ // The explicit 30 (black) is not decoration: the prompt and the jump hint are CYAN TEXT, and
534
+ // cyan on cyan is invisible. Flattening the foreground inside the selection costs colour
535
+ // meaning for as long as the selection lasts, and is the price of legibility. Reverse video
536
+ // (\x1b[7m) avoids it by letting the terminal swap whatever is there; a named colour was chosen
537
+ // deliberately over it, so the selection looks like gu rather than like the terminal.
538
+ export const SELECTION_SGR = "\x1b[46;30m";
539
+
540
+ /**
541
+ * One row with the visible columns [from, to) highlighted.
542
+ *
543
+ * THE NESTING TRAP, which is why this walks the row instead of slicing it: the row already
544
+ * carries SGR runs, and every one of them ends in `\x1b[0m`. Wrapping a span in our background
545
+ * and letting an inner reset through would terminate the highlight at the first coloured token
546
+ * and silently lose it for the rest of the span — the same failure recorded against colourising
547
+ * the edit cards. So escapes inside the span are DROPPED, and the colour state that was active
548
+ * is re-opened after it.
549
+ *
550
+ * Adds only zero-width escapes, so visibleLen is unchanged and the row still occupies exactly
551
+ * one physical row (#109/#138).
552
+ */
553
+ export function highlightRow(line, from, to, sgr = SELECTION_SGR) {
554
+ const text = String(line ?? "");
555
+ const lo = Math.max(0, from | 0);
556
+ const hi = to === Infinity ? Infinity : Math.max(lo, to | 0);
557
+ if (hi <= lo) return text;
558
+ const chars = [...text];
559
+ let out = "";
560
+ let col = 0;
561
+ let state = ""; // SGR runs active here, to re-open after the highlight
562
+ let open = false;
563
+ for (let i = 0; i < chars.length; i++) {
564
+ if (chars[i] === "\x1b") {
565
+ const m = /^\x1b\[[0-9;]*m/.exec(chars.slice(i).join(""));
566
+ if (m) {
567
+ state = m[0] === "\x1b[0m" ? "" : state + m[0];
568
+ // Inside the highlight the escape is dropped — see THE NESTING TRAP above.
569
+ if (!(col >= lo && col < hi)) out += m[0];
570
+ i += [...m[0]].length - 1;
571
+ continue;
572
+ }
573
+ }
574
+ const inSpan = col >= lo && col < hi;
575
+ if (inSpan && !open) { out += sgr; open = true; }
576
+ if (!inSpan && open) { out += "\x1b[0m" + state; open = false; }
577
+ out += chars[i];
578
+ col += charWidth(chars[i]);
579
+ }
580
+ if (open) out += "\x1b[0m" + state;
581
+ return out;
582
+ }
583
+
584
+ /**
585
+ * Every display row with the selection painted on it.
586
+ *
587
+ * `sel` is in ABSOLUTE display-row coordinates — the same space displayRows produces — so the
588
+ * highlight stays on the text it was drawn over when the view scrolls, rather than on whatever
589
+ * has moved under that screen row.
590
+ */
591
+ export function applySelectionHighlight(rows, sel, sgr = SELECTION_SGR) {
592
+ const all = Array.isArray(rows) ? rows : [];
593
+ if (!sel?.start || !sel?.end || isEmptySelection(sel)) return all;
594
+ const { start, end } = orderSelection(sel.start, sel.end);
595
+ if (end.row < 0 || start.row >= all.length) return all;
596
+ return all.map((row, i) => {
597
+ if (i < start.row || i > end.row) return row;
598
+ const from = i === start.row ? start.col : 0;
599
+ const to = i === end.row ? end.col : Infinity;
600
+ return highlightRow(row, from, to, sgr);
601
+ });
602
+ }