@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,2507 @@
1
+ /**
2
+ * Pure terminal-layout arithmetic for the `gu` REPL — no I/O, so every rule below is
3
+ * testable without a terminal (see tests/terminal-layout.test.mjs, which replays the
4
+ * sequences through a small ANSI screen emulator).
5
+ *
6
+ * THE ONE INVARIANT (task #109, hit live at 40 cols; task #138): every row of an in-place
7
+ * block — the bottom bar, the live status block, a picker — must occupy exactly ONE physical
8
+ * terminal row, because every redraw walks the caret with ESC[nA / ESC[nB and `n` counts
9
+ * PHYSICAL rows. A row that wraps makes the walk land on the wrong line, the next erase
10
+ * clears the wrong row, and the block stacks/duplicates.
11
+ *
12
+ * A RESIZE breaks that invariant retroactively: rows were clipped to the width they were
13
+ * DRAWN at, and the terminal reflows them at the new width. So nothing here may cache a row
14
+ * count — `physicalRows(rows, width)` re-derives it from the row strings that are actually on
15
+ * screen, which is what makes a post-resize erase clear the right number of rows.
16
+ */
17
+
18
+ /**
19
+ * COLUMNS, not characters. `"✅ Done!".length` is 7 and a terminal draws it in 8 cells — an
20
+ * emoji is one code point and two columns wide, and CJK text is two columns per character.
21
+ * Counting UTF-16 units instead was a silent off-by-one-per-wide-char in every row/column sum
22
+ * here: a row we believed fit exactly soft-wrapped in the terminal, which breaks THE ONE
23
+ * INVARIANT above and desynchronises every later erase and repaint.
24
+ *
25
+ * Emoji PRESENTATION is the test, not "is it a symbol": ✅ 😊 🔥 default to emoji rendering and
26
+ * take two cells, while the glyphs this UI is built from — ✓ ✗ ● ◑ ↑ ↓ → └ ▝ ✎ — are text
27
+ * presentation and take one. Combining marks and zero-width joiners take none.
28
+ */
29
+ const WIDE_RANGES =
30
+ /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏ꥠ-꥿가-힣豈-﫿︐-︙︰-﹯＀-⦆¢-₩]/u;
31
+ const ZERO_WIDTH = /[\p{Mn}\p{Me}​-‏⁠]/u;
32
+ export function charWidth(ch) {
33
+ if (!ch) return 0;
34
+ if (ZERO_WIDTH.test(ch)) return 0;
35
+ if (WIDE_RANGES.test(ch)) return 2;
36
+ if (/\p{Emoji_Presentation}/u.test(ch)) return 2;
37
+ return 1;
38
+ }
39
+
40
+ // Visible width of a string: SGR colour escapes (and any other CSI) occupy no cells.
41
+ export function visibleLen(s) {
42
+ const plain = String(s ?? "").replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "");
43
+ let n = 0;
44
+ for (const ch of plain) n += charWidth(ch); // by CODE POINT — an emoji is one iteration
45
+ return n;
46
+ }
47
+
48
+ // Truncate to `max` VISIBLE columns, keeping the ANSI colour escapes intact. A wide character
49
+ // that would straddle the limit is dropped whole rather than half-drawn.
50
+ export function clipVisible(s, max) {
51
+ if (max <= 0) return "";
52
+ let out = "", seen = 0, sawEscape = false;
53
+ const chars = [...String(s ?? "")];
54
+ for (let i = 0; i < chars.length; i++) {
55
+ if (chars[i] === "\x1b") {
56
+ const m = /^\x1b\[[0-9;]*m/.exec(chars.slice(i).join(""));
57
+ if (m) { out += m[0]; i += [...m[0]].length - 1; sawEscape = true; continue; }
58
+ }
59
+ const w = charWidth(chars[i]);
60
+ if (seen + w > max) return out + (sawEscape ? "\x1b[0m" : "");
61
+ out += chars[i];
62
+ seen += w;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * The prompt breakdown as rows to print (task #142) — WHERE the coding model's context went.
69
+ *
70
+ * The ordering is deliberate: the two things the user can act on (their question, and what RAG
71
+ * pulled in) sit above the two they cannot (the static prompt, the model's own echoed code).
72
+ *
73
+ * ZERO IS AN ANSWER. RAG is a tool the model chooses to call, so `rag 0` means it never asked —
74
+ * the single most useful reading this table offers — and the row must therefore be rendered,
75
+ * with that explanation, rather than dropped for being empty.
76
+ */
77
+ /**
78
+ * WHY the RAG number is zero — the difference between a choice and a breakage.
79
+ *
80
+ * "rag 0" reads as "RAG did nothing", which is true but useless: the model declining to search
81
+ * an indexed project is a completely different situation from an embedder that is down, and
82
+ * only one of them is fixed by the user. Reported live: three turns of `rag 0` where the real
83
+ * causes were an empty index AND an unreachable embedder — neither visible in the number.
84
+ */
85
+ export function ragZeroReason(state) {
86
+ switch (state) {
87
+ case "unreachable":
88
+ return "the embedding server is DOWN — indexing and search would both fail";
89
+ case "no-index":
90
+ return "nothing indexed for this project yet — run rag_index_workspace()";
91
+ case "unavailable":
92
+ return "RAG is not configured for this workspace";
93
+ case "ready":
94
+ return "indexed and reachable — the model chose not to search";
95
+ default:
96
+ return "the model never called rag_search this turn";
97
+ }
98
+ }
99
+
100
+ /** The short form for the always-on token row: a state, or the share when it was used. */
101
+ export function ragSegmentLabel(b) {
102
+ if (!b || typeof b !== "object") return "";
103
+ const rag = Number.isFinite(b.rag) ? b.rag : 0;
104
+ if (rag > 0) return null; // …the caller renders the number + percent instead
105
+ switch (b.ragState) {
106
+ case "unreachable":
107
+ return "rag ✗ embedder down";
108
+ case "no-index":
109
+ return "rag — not indexed";
110
+ case "unavailable":
111
+ return "rag — off";
112
+ // BOTH OF THESE WERE FALLING THROUGH TO "rag 0" — python has computed them all along and the
113
+ // row threw them away. Each is a COMPLETE explanation of the zero, and showing the bare
114
+ // number instead is what made this read as "RAG is broken" when nothing was wrong.
115
+ case "too-small":
116
+ return "rag — project too small to index";
117
+ // NOT "not indexed", which reads as "still working on it". This folder will never have an
118
+ // index until it is re-scoped, and the row is the only place that says so — the user watched
119
+ // "rag — not indexed" for two hours while a 64-day job ran against a home directory.
120
+ case "too-big":
121
+ return "rag — folder too large; register a project folder";
122
+ case "indexing-later":
123
+ return "rag — indexing after this turn";
124
+ // Nothing has been measured yet (the state before any turn has run). "0" is a claim we
125
+ // cannot support; a dash says "no figure", which is the truth.
126
+ case "unknown":
127
+ case "":
128
+ case undefined:
129
+ return "rag —";
130
+ default:
131
+ // "ready" with nothing retrieved is an honest zero: RAG was available and the model either
132
+ // did not search or found nothing. Any NEW state python invents also lands here — visibly
133
+ // wrong rather than silently plausible, which is how "too-small" hid for so long.
134
+ return "rag 0";
135
+ }
136
+ }
137
+
138
+ // The six origin buckets. They are scaled to sum to the server's prompt_tokens for ONE
139
+ // request, so their sum IS the size of the newest context — the number to compare against
140
+ // the model's window. Deliberately excludes ragFresh/ragCarried (shares OF rag, not extra)
141
+ // and ragCalls/ragState (not tokens at all).
142
+ const PROMPT_BUCKETS = ["scaffold", "task", "code", "rag", "tools", "other"];
143
+
144
+ /**
145
+ * Tokens in the LATEST request to the coding model, from its breakdown.
146
+ *
147
+ * Distinct from the turn's running total, which sums every step and so climbs to many times
148
+ * the window: a turn showing "sent 65.3k" against a 49k model had a largest single request of
149
+ * about 10k. One number is what the turn COST; this one is what the model is actually HOLDING.
150
+ */
151
+ export function promptTotal(b) {
152
+ if (!b || typeof b !== "object") return 0;
153
+ return PROMPT_BUCKETS.reduce((sum, k) => sum + (Number.isFinite(b[k]) ? b[k] : 0), 0);
154
+ }
155
+
156
+ /**
157
+ * The token figures on the bottom bar's number row, from the raw measurements.
158
+ *
159
+ * TRUE NUMBERS, DELIBERATELY — nothing here is netted, offset or clamped. See
160
+ * tests/token-row-figures.test.mjs for the bug that rule replaces: each figure used to have a
161
+ * "scaffolding floor" subtracted so the row would show what the CONVERSATION added rather than
162
+ * the overhead every request pays, and the result was a row nobody could act on.
163
+ *
164
+ * Three reasons the subtraction had to go, in the order they bite:
165
+ *
166
+ * · A netted figure can be compared to NOTHING. The only reason to watch a context number is
167
+ * to ask "how close am I to being compacted?", which is a question about the model's
168
+ * window. A number whose zero point moves per folder and per resume cannot answer it, and
169
+ * cannot be compared to yesterday either.
170
+ * · The floor was read from the breakdown RESTORED FROM DISK at startup — a previous
171
+ * session's mature turn, not this session's scaffold — so it was several times too large.
172
+ * · `sent` is a SUM over N requests, so it lost N floors and clamped to 0 forever. The clamp
173
+ * is what hid all of this: max(0, …) renders a broken figure as a plausible one.
174
+ *
175
+ * The misreading the floor was invented to fix — "ctx 7.3k" read as "your question cost 7.3k"
176
+ * — is a labelling problem. It is answered by naming the figure (`ctx` is what the model HOLDS,
177
+ * `sent` is what the turn COST) and by showing the composition in /tokens, not by altering the
178
+ * measurement until it matches the misreading.
179
+ *
180
+ * Non-finite input becomes 0: the row is redrawn on every keystroke, and "NaNk" on the bar is
181
+ * worse than a figure that admits it has not been measured.
182
+ */
183
+ const finite = (n) => (Number.isFinite(n) && n > 0 ? n : 0);
184
+ export function tokenRowFigures({ ctxTrue, turnTrue, peakTrue } = {}) {
185
+ return { ctx: finite(ctxTrue), turn: finite(turnTrue), peak: finite(peakTrue) };
186
+ }
187
+
188
+ /**
189
+ * How THIS FOLDER's coding key should be described in /model (task #222).
190
+ *
191
+ * WHY IT IS NOT JUST THE STORED KEY. The line used to render whatever GET /coding-key returned
192
+ * with no job named — which is the ACCOUNT DEFAULT, not the folder's choice. So after picking
193
+ * "accounting" the next /model still read "API key sk-…3E66", the key it had replaced. Nothing
194
+ * was wrong underneath; the display simply asked a question whose answer could not depend on
195
+ * the folder, and the user reasonably concluded the selection had not taken.
196
+ *
197
+ * Returns { text, verb }. The verb matters as much as the text: "replace it" is the honest word
198
+ * when there is one key and choosing means overwriting, and the wrong one once several exist,
199
+ * where ctrl+k changes which is used and destroys nothing.
200
+ *
201
+ * Pure, so every branch can be asserted without a terminal or an API.
202
+ */
203
+ export function codingKeyLine({
204
+ keys = null,
205
+ selectedId = "",
206
+ storedMask = "",
207
+ backendId = "",
208
+ host = "",
209
+ } = {}) {
210
+ const fallback = { text: storedMask || "stored", verb: "replace" };
211
+ // No list means an older API: it cannot answer which key this folder uses, so say the only
212
+ // true thing available rather than inventing a name.
213
+ if (!Array.isArray(keys) || keys.length === 0) return fallback;
214
+ const verb = keys.length > 1 ? "change" : "replace";
215
+ /**
216
+ * IS THIS KEY EVEN FOR THIS PROVIDER? (task #223 follow-up.)
217
+ *
218
+ * REPORTED: a fresh folder on a newly added OpenAI provider showed
219
+ * API key Unknown ····exit · ctrl+k to change it
220
+ * and the turn died on a 401. The line was telling the truth — that key WAS the one used —
221
+ * but "Unknown ····exit" was the account's old ollama.com credential, and nothing on screen
222
+ * said so. The user could not have known, and the credential went to api.openai.com.
223
+ *
224
+ * An UNBOUND key is still legitimately usable anywhere (every key predating #223 is one), so
225
+ * this is a note and not a refusal. It fires only when there is something to notice: the key
226
+ * belongs to a DIFFERENT provider, or it belongs to none while this provider has one of its own.
227
+ */
228
+ const noteFor = (key) => {
229
+ if (!backendId || !key) return "";
230
+ const where = host || "this provider";
231
+ if (key.backendId && key.backendId !== backendId) return `that key is for another provider`;
232
+ if (key.backendId) return "";
233
+ // The key is UNBOUND. On an account where nothing is bound to anything — every account that
234
+ // predates #223 — that is simply how keys work, and a warning would fire on every /model
235
+ // while naming no action. Say something only where binding is actually in use.
236
+ if (keys.some((k) => k.backendId === backendId)) return `not tied to ${where} — one of your keys is`;
237
+ if (keys.some((k) => k.backendId)) return `not tied to ${where}`;
238
+ return "";
239
+ };
240
+ if (!selectedId) {
241
+ // NEVER PICKED — AND THAT IS WORTH SAYING OUT LOUD (reported). This folder rides the account
242
+ // default, which is simply the FIRST key in the list. With several stored, the one being used
243
+ // was chosen by ordering, not by anybody: the reporter's folder showed "Unknown ····exit",
244
+ // which was the account's old ollama.com credential, against an OpenAI coder — and the line
245
+ // read like a setting rather than a fallback. `isDefault` was already computed here and
246
+ // thrown away by every caller.
247
+ //
248
+ // Silent with ONE key, because there is then nothing to choose and "not chosen" would be
249
+ // noise on a correct setup.
250
+ const first = keys[0];
251
+ const notes = [];
252
+ if (keys.length > 1) notes.push("not chosen for this folder — using the first key");
253
+ const provider = noteFor(first);
254
+ if (provider) notes.push(provider);
255
+ return {
256
+ text: `${first.name} ····${first.tail}`,
257
+ verb,
258
+ isDefault: true,
259
+ ...(notes.length ? { note: notes.join(" · ") } : {}),
260
+ };
261
+ }
262
+ const hit = keys.find((k) => k.id === selectedId);
263
+ // THE SELECTED KEY IS GONE. Saying nothing would leave the folder looking correctly
264
+ // configured while every turn quietly used a different credential.
265
+ if (!hit) {
266
+ const first = keys[0];
267
+ return {
268
+ text: `${first.name} ····${first.tail}`,
269
+ verb,
270
+ missing: true,
271
+ note: "the key this folder chose has been removed",
272
+ };
273
+ }
274
+ return {
275
+ text: `${hit.name} ····${hit.tail}`,
276
+ verb,
277
+ ...(noteFor(hit) ? { note: noteFor(hit) } : {}),
278
+ };
279
+ }
280
+
281
+ export function promptBreakdownRows(b) {
282
+ if (!b || typeof b !== "object") return [];
283
+ const n = (k) => (Number.isFinite(b[k]) ? b[k] : 0);
284
+ const total = promptTotal(b);
285
+ if (!total) return [];
286
+ const rows = [
287
+ { key: "task", label: "your question", note: "" },
288
+ {
289
+ key: "rag",
290
+ label: "RAG chunks",
291
+ note: n("rag")
292
+ ? `${n("ragFresh")} fetched now · ${n("ragCarried")} carried from earlier steps`
293
+ : ragZeroReason(b.ragState),
294
+ },
295
+ { key: "tools", label: "other tool output", note: "greps, file reads, command output" },
296
+ { key: "scaffold", label: "static prompt", note: "tools + rules; mostly served from the model's prefix cache" },
297
+ { key: "code", label: "the model's own code", note: "" },
298
+ { key: "other", label: "unattributed", note: "" },
299
+ ];
300
+ return rows
301
+ .filter((r) => r.key === "rag" || n(r.key) > 0) // …but never hide the RAG row
302
+ .map((r) => ({
303
+ label: r.label,
304
+ tokens: n(r.key),
305
+ percent: Math.round((n(r.key) * 100) / total),
306
+ note: r.note,
307
+ }));
308
+ }
309
+
310
+ /**
311
+ * A MOUSE REPORT IS NOT TYPING — the window in which keypress events belong to the mouse.
312
+ *
313
+ * readline parses `\x1b[<` as the key code "[<" and then hands the REST of the report through
314
+ * as ordinary characters ("6", "4", ";", …, "M"), so one wheel notch arrives as a dozen
315
+ * keypresses. Listeners that react to typing must sit them out: the REPL's first rule is
316
+ * "typing means the user has finished reading, so come back to the live tail", and with the
317
+ * wheel counted as typing every notch scrolled up three lines and immediately jumped back —
318
+ * the view could never get further than one notch from the bottom.
319
+ *
320
+ * `track` is called ONCE per event by the first listener in the chain; `active` reports whether
321
+ * the current event was part of a report, so later listeners agree without re-advancing it.
322
+ * The terminator closes the run but is itself part of it, which is why the two are separate.
323
+ */
324
+ export function createMouseKeyWindow({ maxRun = 24 } = {}) {
325
+ let run = 0;
326
+ let current = false;
327
+ return {
328
+ track(str, key) {
329
+ if (key?.code === "[<") {
330
+ run = 1;
331
+ return (current = true);
332
+ }
333
+ if (!run) return (current = false);
334
+ run = str === "M" || str === "m" || run > maxRun ? 0 : run + 1;
335
+ return (current = true);
336
+ },
337
+ /** Was the event just tracked part of a mouse report? */
338
+ active: () => current,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * A failed turn's message, as something a person can act on.
344
+ *
345
+ * The worker hands back whatever killed the Python agent, and that is usually a full traceback:
346
+ * ten lines of OUR file paths and line numbers, ending in the one line that says what actually
347
+ * went wrong ("TimeoutError: Request timed out."). Printing all of it buries the cause and the
348
+ * frames tell the user nothing — so keep the final exception line plus the context before the
349
+ * traceback ("agent chat exited 1"), and leave the frames to `gu logs`.
350
+ *
351
+ * Lives here rather than in the REPL because the REPL runs `main()` on import — a pure function
352
+ * that cannot be imported cannot be tested.
353
+ *
354
+ * `authored:true` IS A DIFFERENT KIND OF MESSAGE and gets a different rule. Everything above
355
+ * describes an ACCIDENT — a Python exception nobody wrote for a reader — where the last line is
356
+ * the useful part and 300 characters is generous. A __TURN_FAILED__:: message is the opposite:
357
+ * the worker composed it FOR this moment, and its later sentences are the ones that say what to
358
+ * do about it. Clipping those is not tidying, it is deleting the answer.
359
+ *
360
+ * Reported live (2026-09-01): a coding model that could not answer inside its budget produced
361
+ * "…That pairing is too slow for this prompt: pick a smaller coding model in Settings →
362
+ * Agent, or raise the per-request budget with GONEXT_CODE_TIMEOUT_SECONDS."
363
+ * and the screen showed it cut dead at "Settings → Agent," — character 300 — so the two fixes
364
+ * were both gone and the user asked why the message was short. There is no traceback hunt for
365
+ * these either: they are prose, and searching them for an "Error:" line can only misfire.
366
+ *
367
+ * The cap does not disappear, it just stops being a sentence-length limit: 2000 characters is
368
+ * far more than any message we write and still bounds a bug that hands this a whole log.
369
+ */
370
+ const AUTHORED_MAX = 2000;
371
+ export function summarizeError(message, { authored = false } = {}) {
372
+ const text = String(message ?? "").trim();
373
+ if (!text) return { reason: "the turn failed", hasTrace: false };
374
+ if (authored) return { reason: text.slice(0, AUTHORED_MAX), hasTrace: false };
375
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
376
+ // The LAST "SomeError: …" line is the exception that actually ended the run, so a chained one
377
+ // ("During handling of the above exception…") wins — that is the one the user is facing.
378
+ const exc = [...lines]
379
+ .reverse()
380
+ .find((l) => /^[A-Za-z_][\w.]*(Error|Exception|Interrupt|Exit|Warning)\b\s*[:(]?/.test(l));
381
+ const at = text.search(/Traceback \(most recent call last\)/);
382
+ const hasTrace = at >= 0 || /^\s*File ".*", line \d+/m.test(text);
383
+ if (!hasTrace) return { reason: (exc || lines[0]).slice(0, 300), hasTrace: false };
384
+ const context = (at >= 0 ? text.slice(0, at) : "").replace(/[:\s]+$/, "").trim();
385
+ // A TRUNCATED traceback has no exception line at all — the tail is where it lives. Rather
386
+ // than quote whatever fragment came last (the "^^^^" caret marker under a frame, or half a
387
+ // line of source), name the deepest FRAME: the file, line and function that died.
388
+ let reason = exc;
389
+ if (!reason) {
390
+ const frame = [...lines].reverse().find((l) => /^File ".*", line \d+/.test(l));
391
+ const m = frame && /^File "(.*)", line (\d+)(?:, in (\S+))?/.exec(frame);
392
+ reason = m
393
+ ? `crashed in ${m[3] || "the agent"} (${m[1].split("/").pop()}:${m[2]}) — the error text was cut off`
394
+ : "the agent crashed";
395
+ }
396
+ return { reason: (context ? `${context} — ` : "") + reason.slice(0, 300), hasTrace: true };
397
+ }
398
+
399
+ /**
400
+ * How many PHYSICAL rows the given drawn rows occupy at `width` — i.e. how far up an erase
401
+ * has to walk. Equal to `rows.length` for a block drawn at the current width (everything is
402
+ * clipped), and larger once a resize has reflowed rows that no longer fit. A row exactly
403
+ * `width` wide still counts as one: the terminal defers the wrap (pending-wrap) instead of
404
+ * moving to the next row, which is why the full-width separator rule is safe.
405
+ */
406
+ export function physicalRows(rows, width) {
407
+ const w = Math.max(1, width || 80);
408
+ let n = 0;
409
+ for (const r of rows) n += Math.max(1, Math.ceil(visibleLen(r) / w));
410
+ return n;
411
+ }
412
+
413
+ // Physical rows the prompt + typed line occupies (it wraps past the terminal width).
414
+ export function inputPhysicalRows({ promptCols, lineLength, width }) {
415
+ const w = Math.max(20, width || 80);
416
+ return Math.floor((promptCols + lineLength) / w) + 1;
417
+ }
418
+
419
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
420
+
421
+ /**
422
+ * The bottom bar's rows as printable strings, one PHYSICAL row each, laid out for `width`.
423
+ * The rule spans the FULL width (it is the visual split); the text rows are clipped to
424
+ * width-1 because they can genuinely exceed it (the mode line is ~44 columns, the token row
425
+ * carries a model name) and would then wrap. Callers pass the already-styled text; `rag` is
426
+ * optional (verbose only), so the row COUNT is derived from the returned array — never
427
+ * hard-coded, or a resize/verbose toggle would move the caret by the wrong amount.
428
+ */
429
+ /**
430
+ * `identity` (who and where) is its OWN ROW, above the numbers (user, 2026-09-05: "can we add
431
+ * another line for ctx and turn and peak and out and total or so? it is so tight here").
432
+ *
433
+ * They were one row doing two jobs, and the numbers lost. Five measurements plus a coder name
434
+ * and a folder name is ~110 columns; every row here is clipped to width-1 to keep it ONE
435
+ * PHYSICAL row (#109/#132/#138 — a wrapped footer row corrupts every later ESC[nA walk), and
436
+ * the clip falls on the RIGHT, so on any normal window the totals were the part being cut. The
437
+ * identity was deliberately placed first for exactly that reason — it must never be the thing
438
+ * that vanishes — which meant the numbers were living on whatever was left.
439
+ *
440
+ * Splitting them costs one row of transcript and gives the measurements the full width. Both
441
+ * rows stay clipped: this is a layout change, not a licence to wrap.
442
+ *
443
+ * `identity` is optional so callers that have nothing to put there (and the tests that predate
444
+ * it) keep producing the same rows as before.
445
+ */
446
+ export function buildFooterRows({
447
+ width,
448
+ gutter = " ",
449
+ rag = "",
450
+ identity = "",
451
+ token = "",
452
+ mode = "",
453
+ }) {
454
+ const w = Math.max(20, width || 80);
455
+ return [
456
+ dim("─".repeat(w)),
457
+ ...(rag ? [clipVisible(gutter + rag, w - 1)] : []),
458
+ ...(identity ? [clipVisible(gutter + identity, w - 1)] : []),
459
+ clipVisible(gutter + token, w - 1),
460
+ clipVisible(gutter + mode, w - 1),
461
+ ];
462
+ }
463
+
464
+ /**
465
+ * Bytes that draw the bar below the input line and put the caret back exactly where readline
466
+ * left it. Relative moves only: the block must survive the terminal scrolling — writing past
467
+ * the last screen row scrolls the input line up TOO, so the matching up-move still lands on
468
+ * it. The up-move counts `rows.length`, so the count can never drift from what was drawn.
469
+ */
470
+ export function footerDrawSeq({ rows, caretRows, caretCols, inputRows }) {
471
+ const down = Math.max(0, inputRows - 1 - caretRows); // caret row → last input row
472
+ return (
473
+ (down ? `\x1b[${down}B` : "") +
474
+ rows.map((r) => "\r\n\x1b[2K" + r).join("") +
475
+ "\x1b[J" + // nothing stale below the bar
476
+ `\x1b[${rows.length + down}A\r` + // back to the caret's row
477
+ (caretCols > 0 ? `\x1b[${caretCols}C` : "")
478
+ );
479
+ }
480
+
481
+ // Bytes that wipe the bar (and anything under it) WITHOUT touching the input line.
482
+ export function footerEraseSeq({ caretRows, caretCols, inputRows }) {
483
+ const down = Math.max(0, inputRows - 1 - caretRows) + 1; // first footer row
484
+ return `\x1b[${down}B\r\x1b[J\x1b[${down}A\r` + (caretCols > 0 ? `\x1b[${caretCols}C` : "");
485
+ }
486
+
487
+ /**
488
+ * Bytes that wipe an in-place block the caret is sitting at the END of (the live status
489
+ * block, a picker): clear this row, then move-up-and-clear for each row above, leaving the
490
+ * caret at the block's origin ready for a redraw. `rows` are the exact strings on screen, so
491
+ * after a resize the walk covers the rows they have REFLOWED into rather than the count they
492
+ * were drawn with.
493
+ */
494
+ export function clearBlockSeq(rows, width) {
495
+ const n = physicalRows(rows, width);
496
+ let seq = "\r\x1b[K";
497
+ for (let i = 1; i < n; i++) seq += "\x1b[1A\r\x1b[K";
498
+ return seq;
499
+ }
500
+
501
+ /**
502
+ * The INPUT ZONE: the rows that live in the frozen area above the bar — a separator and the
503
+ * prompt line itself (task #141, second phase).
504
+ *
505
+ * Once the input line is frozen, readline can no longer draw it: readline draws wherever the
506
+ * caret happens to be, and the caret has to be inside the scrolling area for output to work.
507
+ * So gu renders the line and reports where the caret belongs, and readline is reduced to
508
+ * what it is actually good at — editing the string.
509
+ *
510
+ * THE LINE WRAPS (task #179). It used to scroll horizontally on one row, because a wrapped row
511
+ * changes the height of the frozen zone — and therefore the reserved-row count — on a keystroke.
512
+ * That turned out to be a cost worth paying rather than a reason not to: a long question was
513
+ * only ever visible through an 80-column porthole, so you could not read back what you had
514
+ * written, which is most of what an input line is for. paintBar already re-cut the region when
515
+ * the zone changed height (the --verbose RAG row does it), so the machinery was there.
516
+ *
517
+ * Continuation rows are indented by promptCols, so the text keeps ONE left edge under the
518
+ * prompt instead of stepping back to column 0.
519
+ *
520
+ * EVERY offset↔column conversion lives in here, against ONE set of wrap boundaries: the caret,
521
+ * a click, a double-click and a drag-selection are the same arithmetic asked four ways, and
522
+ * anywhere they disagree the user selects one thing and copies another. `spans` is that single
523
+ * source — each row's [from, to) offsets into the line.
524
+ *
525
+ * A row filled exactly to the edge is followed by an EMPTY row, which is what puts the caret on
526
+ * a fresh line the moment you type past the last column — the behaviour this was asked for.
527
+ *
528
+ * `maxRows` caps the height: past it the zone scrolls VERTICALLY to keep the caret in view,
529
+ * because a pasted essay must not eat the conversation it is about.
530
+ */
531
+ /**
532
+ * Rows the zone puts ABOVE the typed line: the separator rule, and — only when it carries a
533
+ * CONTROL — one blank under it.
534
+ *
535
+ * THE BLANK EARNS ITS PLACE OR IT DOES NOT GET ONE (2026-08-25, narrowed 2026-09-05). The rule
536
+ * used to sit directly on top of the input, so the affordance it carries — "⭯ update gu …",
537
+ * "2 agents running", "↓ jump to bottom" — was pressed against the line being typed and read as
538
+ * part of it. One row of air fixed that.
539
+ *
540
+ * But the rule is PLAIN most of the time, and then the air is lifting nothing: it just pushes
541
+ * the separator a row further from the input than it needs to be, which is how it was reported
542
+ * ("the top line above the input location is too far on top… seems like you accidentally add a
543
+ * new line above the input line?"). So the blank now appears only when there is a control to
544
+ * lift off the input, and the plain rule sits directly above it.
545
+ *
546
+ * INPUT_HEAD_ROWS is the MAXIMUM (rule + blank) and stays the number used to RESERVE capacity —
547
+ * promising less than the zone may need is how a row gets stolen from the transcript. The
548
+ * ACTUAL count is published as `headRows` on the returned object, and every offset must be
549
+ * computed from that; a caller still using the constant for arithmetic would put the caret on
550
+ * the blank and hit-test clicks one row out, the #109 family this constant exists to prevent.
551
+ *
552
+ * EXPORTED BECAUSE THE REPL DOES ARITHMETIC WITH IT. paintBar derives the input's absolute row
553
+ * from the zone's top, frozenRowCount reserves the region, and maxInputRows subtracts the head
554
+ * before handing what is left to the typed line. All three used a hand-written 1 for "the
555
+ * separator"; a second head row that only some of them knew about would put the caret on the
556
+ * blank, hit-test clicks one row out and reserve a row short — the #109 family of bug, which
557
+ * is what a single exported count exists to prevent.
558
+ */
559
+ export const INPUT_HEAD_ROWS = 2;
560
+
561
+ export function inputZoneRows({
562
+ width, prompt = ">> ", promptCols = 3, line = "", cursor = 0, rule,
563
+ maxRows = 10, selection = null, highlight = null,
564
+ // Does the separator carry a control this time? Defaults TRUE so any caller that has not been
565
+ // told about this keeps the old, roomier layout rather than silently losing a row.
566
+ headControl = true,
567
+ }) {
568
+ const w = Math.max(20, width || 80);
569
+ const avail = Math.max(8, w - 1 - promptCols); // text columns per row
570
+ const text = String(line ?? "");
571
+ const cur = Math.max(0, Math.min(cursor, text.length));
572
+
573
+ // Wrap by VISIBLE COLUMNS, walking by code point: an emoji is two cells and one iteration,
574
+ // and splitting it across rows would print half a character (the #109 width lesson).
575
+ //
576
+ // AT WORD BOUNDARIES where there is one — a sentence broken mid-word is markedly harder to
577
+ // read back, which is the whole reason the line stopped scrolling horizontally. A run with no
578
+ // space in it (a path, a URL, a base64 blob) still breaks at the edge, because the
579
+ // alternative is a row that overflows and the ONE-PHYSICAL-ROW invariant is not negotiable.
580
+ //
581
+ // The break keeps the space at the END of the row it belongs to rather than swallowing it, so
582
+ // the spans stay contiguous: every offset in the line is on exactly one row, which is what
583
+ // lets a selection be held as offsets and painted per row without a gap.
584
+ const spans = [];
585
+ let from = 0;
586
+ let cols = 0;
587
+ let off = 0;
588
+ let lastSpace = -1; // offset just AFTER the most recent space on this row
589
+ for (const ch of text) {
590
+ const cw = charWidth(ch);
591
+ if (cols + cw > avail) {
592
+ if (lastSpace > from) {
593
+ spans.push({ from, to: lastSpace });
594
+ cols = visibleLen(text.slice(lastSpace, off)); // the tail travels down with the caret
595
+ from = lastSpace;
596
+ } else {
597
+ spans.push({ from, to: off });
598
+ from = off;
599
+ cols = 0;
600
+ }
601
+ lastSpace = -1;
602
+ }
603
+ cols += cw;
604
+ off += ch.length;
605
+ if (ch === " ") lastSpace = off;
606
+ }
607
+ spans.push({ from, to: off });
608
+ // …and the empty row after a row that ends exactly at the edge — see above.
609
+ if (cols === avail && text.length) spans.push({ from: off, to: off });
610
+
611
+ // The row an offset belongs to. Searched from the BOTTOM so an offset sitting on a wrap
612
+ // boundary resolves to the row that STARTS there — which is how the caret moves down onto
613
+ // the new row rather than hanging off the end of the old one.
614
+ const rowOf = (o) => {
615
+ for (let r = spans.length - 1; r >= 0; r--) if (o >= spans[r].from) return r;
616
+ return 0;
617
+ };
618
+ const colOf = (o, r) => promptCols + visibleLen(text.slice(spans[r].from, o)) + 1; // 1-based
619
+
620
+ const caretRowAll = rowOf(cur);
621
+ // Vertical window: keep the caret on screen, and never show more than maxRows.
622
+ const cap = Math.max(1, maxRows | 0);
623
+ const top = spans.length > cap ? Math.min(Math.max(0, caretRowAll - cap + 1), spans.length - cap) : 0;
624
+ const shown = spans.slice(top, top + cap);
625
+
626
+ const rows = shown.map((s, i) => {
627
+ const head = top + i === 0 ? prompt : " ".repeat(promptCols);
628
+ let row = clipVisible(head + text.slice(s.from, s.to), w - 1);
629
+ // The selection is held in TEXT OFFSETS, so it survives a rewrap: the same characters stay
630
+ // selected at any width. Each row paints only its own slice of it.
631
+ if (selection && highlight && selection.to > selection.from) {
632
+ const a = Math.max(selection.from, s.from);
633
+ const b = Math.min(selection.to, s.to);
634
+ if (b > a) row = highlight(row, colOf(a, top + i) - 1, colOf(b, top + i) - 1);
635
+ }
636
+ return row;
637
+ });
638
+
639
+ const head = headControl ? [rule(w), ""] : [rule(w)];
640
+ return {
641
+ rows: [...head, ...rows],
642
+ // The ACTUAL count, not the constant — see INPUT_HEAD_ROWS.
643
+ headRows: head.length,
644
+ rowCount: rows.length,
645
+ caretRow: caretRowAll - top, // 0-based, within the TEXT rows
646
+ caretCol: Math.max(1, Math.min(w, colOf(cur, caretRowAll))),
647
+ /** Text offset a click at 1-based `col` on 0-based text `row` lands on. */
648
+ offsetAt: (row, col) => {
649
+ const s = shown[Math.max(0, Math.min(row | 0, shown.length - 1))];
650
+ if (!s) return 0;
651
+ const want = Math.max(0, (col | 0) - 1 - promptCols);
652
+ let o = s.from;
653
+ let c = 0;
654
+ for (const ch of text.slice(s.from, s.to)) {
655
+ if (c >= want) break;
656
+ c += charWidth(ch);
657
+ o += ch.length;
658
+ }
659
+ return o;
660
+ },
661
+ /** The [from, to) offsets shown on 0-based text `row` — the caller's row→text map. */
662
+ spanAt: (row) => shown[Math.max(0, Math.min(row | 0, shown.length - 1))] ?? { from: 0, to: 0 },
663
+ };
664
+ }
665
+
666
+ /**
667
+ * ---- Reserved rows at the bottom of the window (task #141) -----------------------------
668
+ *
669
+ * A DECSTBM scrolling region (`ESC [ top ; bottom r`) makes everything printed normally scroll
670
+ * INSIDE rows 1..bottom; the rows below the margin are never scrolled by anything, so the bar
671
+ * painted there simply stays. The region's TOP is always row 1, which is what keeps the
672
+ * terminal's own scrollback fed (a line enters scrollback when it scrolls off the FIRST row).
673
+ *
674
+ * Two details that corrupt the screen when missed:
675
+ * · Setting or resetting a region HOMES THE CURSOR, so every sequence here brackets itself
676
+ * with DECSC/DECRC (ESC 7 / ESC 8) — safe precisely because nothing scrolls in between.
677
+ * · Reserving rows does not move the content already in them, so `reserveRowsSeq` scrolls the
678
+ * screen up by N lines first and the bar lands on blank rows instead of eating output.
679
+ */
680
+ export function scrollRegionSeq(rows, reserved, { preserveCaret = true } = {}) {
681
+ const bottom = Math.max(1, rows - reserved);
682
+ // preserveCaret:false — for the caller that keeps its OUTPUT POSITION in the DECSC slot (the
683
+ // slot is a single register: saving the caret here would throw that position away). Such a
684
+ // caller repaints and re-parks the caret itself right after, so the homing DECSTBM does
685
+ // costs it nothing.
686
+ return preserveCaret ? `\x1b7\x1b[1;${bottom}r\x1b8` : `\x1b[1;${bottom}r`;
687
+ }
688
+
689
+ /** Give the whole screen back to scrolling (every exit path must send this). */
690
+ export const scrollRegionResetSeq = () => "\x1b7\x1b[r\x1b8";
691
+
692
+ /**
693
+ * Make room for the bar and reserve it: push existing content up by `reserved` lines, set the
694
+ * region, and leave the caret on the LAST usable row (the bottom of the region).
695
+ */
696
+ export function reserveRowsSeq(rows, reserved) {
697
+ return "\n".repeat(reserved) + `\x1b[1;${Math.max(1, rows - reserved)}r` +
698
+ `\x1b[${Math.max(1, rows - reserved)};1H`;
699
+ }
700
+
701
+ /**
702
+ * Paint rows at the BOTTOM of the window, absolutely positioned, caret restored. Each row is
703
+ * placed on its own — never joined with "\n", which at the last screen line is exactly the
704
+ * ambiguous case (a linefeed below the bottom margin) terminals disagree about.
705
+ */
706
+ export function paintFrozenRowsSeq(rows, viewportRows, { preserveCaret = true } = {}) {
707
+ const top = Math.max(1, viewportRows - rows.length + 1);
708
+ // DECSC/DECRC is ONE slot per terminal. A caller that keeps the output position in that slot
709
+ // (see the region-write discipline in the REPL) must pass preserveCaret:false and park the
710
+ // caret itself — otherwise this paint quietly overwrites where output was going, and the next
711
+ // printed line lands in the frozen rows instead of the scrolling area.
712
+ //
713
+ // AUTOWRAP OFF FOR THE WHOLE PAINT (DECAWM, \x1b[?7l … \x1b[?7h).
714
+ //
715
+ // Every row here is placed at an ABSOLUTE position, so wrapping is never wanted — and when it
716
+ // happens it does not merely look wrong, it MOVES THE SCREEN: a row one column too wide wraps
717
+ // onto the row below and overwrites it, and on the LAST row of the viewport the terminal
718
+ // scrolls everything up to make room. Repainted ~8×/second by the thinking ticker, that reads
719
+ // as the transcript jumping (reported on Windows 11, 2026-09-06).
720
+ //
721
+ // The rows are already clipped to width-1 by their callers, but that clip trusts charWidth(),
722
+ // and charWidth() cannot be right everywhere: `● … · ↓ ◐ ░` and most of the spinner
723
+ // glyphs are East Asian AMBIGUOUS width — one column in most terminals, TWO in others — so
724
+ // the same string measured here occupies a different number of cells there. No width table
725
+ // fixes that, because the answer legitimately differs per terminal and per font. Turning
726
+ // wrapping off makes the invariant hold without needing to know: an over-wide row is
727
+ // truncated at the right edge by the terminal instead of pushing the screen around.
728
+ let out = (preserveCaret ? "\x1b7" : "") + "\x1b[?7l";
729
+ rows.forEach((r, i) => {
730
+ out += `\x1b[${top + i};1H\x1b[2K` + r;
731
+ });
732
+ return out + "\x1b[?7h" + (preserveCaret ? "\x1b8" : "");
733
+ }
734
+
735
+ /**
736
+ * Hand the reserved rows back: reset the region, wipe the rows the bar used, and leave the
737
+ * caret on the first of them so a shell prompt continues cleanly from there.
738
+ */
739
+ export function releaseRowsSeq(rows, reserved) {
740
+ return `\x1b[r\x1b[${Math.max(1, rows - reserved + 1)};1H\x1b[J`;
741
+ }
742
+
743
+ /**
744
+ * Reader for the terminal's answer to a `\x1b[6n` cursor-position probe (`ESC [ row ; col R`).
745
+ *
746
+ * The answer is INPUT: every byte of it that reaches readline is TYPED INTO THE LINE. Node
747
+ * usually hands the whole thing over as ONE key (`key.code === "[38;3R"`), which is easy to
748
+ * drop — but only usually. When its parser doesn't recognise the exact shape it consumes the
749
+ * PREFIX it does recognise and re-emits the leftovers as ordinary characters, and terminals
750
+ * differ in shape. Observed against a real readline:
751
+ *
752
+ * ESC [ 38;3 R → key "[38;3 " then "R" ← a bare "R" in the line (reported live:
753
+ * ESC [ 38;3;1R → key "[38;3;" then "1", "R" a window drag stamped one per resize)
754
+ * ESC [ ?38;3R → key "[?" then "38;3R"
755
+ * ESC (timed out) [38;3R → "escape" then every character
756
+ *
757
+ * So this reader does not try to recognise a shape. While a probe is outstanding it HOLDS
758
+ * anything that still fits the reply's grammar (an unrecognised CSI head, then digits / ";" /
759
+ * "?" / " ") and consumes the run at its "R". The instant something doesn't fit, everything
760
+ * held is REPLAYED verbatim — a keystroke is only ever delayed, never lost.
761
+ *
762
+ * Pure and self-contained so the shapes above can be regression-tested (see
763
+ * tests/terminal-layout.test.mjs); the caller owns the terminal I/O and the flush timer.
764
+ */
765
+ export function createProbeReplyReader({ now = () => Date.now(), windowMs = 1200 } = {}) {
766
+ let held = null; // [str, key] pairs held back while a reply may still be forming
767
+ let deadline = 0; // a reply is expected until this timestamp
768
+ const rowOf = (text) => {
769
+ const m = /\[\??(\d+);/.exec(text);
770
+ return m ? Number(m[1]) : 0;
771
+ };
772
+ // What a held piece contributes to the reply text: a plain character, or the CSI prefix
773
+ // node already swallowed into a key of its own.
774
+ const textOf = (str, key) => (typeof str === "string" ? str : key?.code ?? "");
775
+ const looksLikeReply = (text) => /^\x1b?\[?[\d;? ]*$/.test(text);
776
+ const isHead = (str, key) =>
777
+ key?.name === "escape" || (key?.code != null && /^\[[\d;? ]*$/.test(key.code));
778
+
779
+ return {
780
+ /** A probe was just written: expect its answer. */
781
+ expect() {
782
+ deadline = now() + windowMs;
783
+ },
784
+ /** True while pieces are being held (the caller arms its flush timer on the transition). */
785
+ holding: () => held !== null,
786
+ /**
787
+ * Feed one keypress. Returns one of:
788
+ * { action: "pass" } — hand it to readline unchanged
789
+ * { action: "consume", row } — it was the reply (row = 0 if unreadable)
790
+ * { action: "hold" } — might be the reply; nothing to do yet
791
+ * { action: "replay", keys: [[str, key]] } — not a reply: replay these, in order
792
+ */
793
+ feed(str, key) {
794
+ // The whole answer as one key — the common shape.
795
+ if (key?.code && /^\[(\d+);\d+R$/.test(key.code)) {
796
+ const row = Number(key.code.match(/^\[(\d+);/)[1]);
797
+ const stale = held;
798
+ held = null;
799
+ deadline = 0;
800
+ // Anything held was NOT part of this answer; give it back before reporting.
801
+ return stale
802
+ ? { action: "replay", keys: stale, row }
803
+ : { action: "consume", row };
804
+ }
805
+ if (held) {
806
+ held.push([str, key]);
807
+ const text = held.map(([s, k]) => textOf(s, k)).join("");
808
+ if (text.endsWith("R")) {
809
+ const row = rowOf(text);
810
+ held = null;
811
+ deadline = 0;
812
+ return { action: "consume", row };
813
+ }
814
+ if (looksLikeReply(text) && held.length < 16) return { action: "hold" };
815
+ const keys = held;
816
+ held = null;
817
+ deadline = 0;
818
+ return { action: "replay", keys };
819
+ }
820
+ if (now() < deadline && isHead(str, key)) {
821
+ held = [[str, key]];
822
+ return { action: "hold" };
823
+ }
824
+ return { action: "pass" };
825
+ },
826
+ /** Timer fired / probe abandoned: hand back whatever is still held. */
827
+ flush() {
828
+ const keys = held ?? [];
829
+ held = null;
830
+ deadline = 0;
831
+ return keys;
832
+ },
833
+ };
834
+ }
835
+
836
+ /**
837
+ * Correct readline's own row bookkeeping before it repaints after a resize (task #138).
838
+ *
839
+ * readline redraws by moving up `prevRows` — the caret's row offset within the input, as
840
+ * measured at the width of the LAST refresh — then clearing to the end of the screen. After a
841
+ * reflow that number is stale in both directions: too small on a shrink (the walk stops inside
842
+ * the input and leaves an orphan half of it above), too large on a widen (it walks up into
843
+ * scrollback and erases a line that isn't ours). `getCursorPos()` recomputes the offset at the
844
+ * CURRENT width, and a reflowing terminal keeps the caret on the same character — so this is
845
+ * where the caret actually is. Called from the resize handler BEFORE readline's own listener
846
+ * runs, which makes readline's refresh land on the input's first row exactly.
847
+ */
848
+ export function syncPrevRowsForResize(rl) {
849
+ if (!rl || typeof rl.getCursorPos !== "function") return;
850
+ try {
851
+ rl.prevRows = rl.getCursorPos().rows;
852
+ } catch {
853
+ /* a readline without cursor tracking (terminal:false) has nothing to correct */
854
+ }
855
+ }
856
+
857
+
858
+ // The agent streams two kinds of out-of-fence line. A STEP SUMMARY is smolagents' own
859
+ // "tool(args) | → observation" trace: swallowed, because every tool emits its own
860
+ // readable line and printing both showed each action twice. The pipe-arrow is the whole
861
+ // signature — a tool's own line never contains it.
862
+ export const isToolStepSummary = (line) => / \| → /.test(line);
863
+
864
+ // Glyph vocabulary for an action line: a FAILURE pops (✗, full-bright), a completed
865
+ // action recedes (✓, dim), anything else is neutral (●, dim). Returns the KIND only —
866
+ // the REPL owns the colours, so this stays pure and testable.
867
+ export function bulletKind(text) {
868
+ if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
869
+ if (/^(?:Command finished|Server up|App launched)\b|→\s*(?:HTTP\s*)?2\d\d\b|\b(?:passed|succeeded|ready)\b/i.test(text))
870
+ return "ok";
871
+ return "info";
872
+ }
873
+
874
+
875
+ // ---------------------------------------------------------------------------
876
+ // The model's THOUGHT, printed into the transcript (task #148).
877
+ //
878
+ // The web app's token-usage page shows each step's full reply — the `Thought:` prose, then
879
+ // the <code> block. The terminal showed neither: the raw stream arrives inside a ~~~ fence
880
+ // that is suppressed from scrollback, leaving only a one-line live clause that vanishes when
881
+ // the step ends. So the reasoning existed everywhere except where the work was happening.
882
+ // ---------------------------------------------------------------------------
883
+
884
+ // Escaped newlines, braces, arrows, hex colours, a leading call — the shapes that mean this
885
+ // is code or a data blob, not a sentence. Same test the live one-liner uses, so the printed
886
+ // block and the blinking line never disagree about what counts as prose.
887
+ /**
888
+ * Does this read as code rather than reasoning?
889
+ *
890
+ * THE STRUCTURAL MARKERS ARE JUDGED ON THE OPENING, not the whole string. A thought routinely
891
+ * QUOTES code — "I see the mistake in my previous `run_command`…", "`db:prepare => db:load_config`
892
+ * failed" — and testing every character meant one `=>` anywhere in a paragraph discarded the
893
+ * paragraph. 43 real responses were lost that way, all of them prose a person would want.
894
+ *
895
+ * What a thing IS shows in how it starts: reasoning opens with words, code opens with syntax.
896
+ * So the markers are checked against the first sentence, while the anchored tests — starts with
897
+ * a bracket, ends with a semicolon — still see the whole string, because those already describe
898
+ * its shape rather than its contents.
899
+ */
900
+ const CODE_OPENING_CHARS = 120;
901
+ const _codey = (s) => {
902
+ // BACKTICKED SPANS ARE QUOTATIONS, not structure. Prose reasons ABOUT code and names it the
903
+ // way a person would — "`db:prepare => db:load_config` failed" — so the markers inside a
904
+ // `…` span say what is being discussed, not what this text is. Removed before the opening is
905
+ // judged; the surrounding sentence still gets tested on its own merits.
906
+ // …AND SO IS INLINE MATH. Found by running a real turn on the shared Ollama rather than by
907
+ // reading the corpus: gemma4:e4b reasons in LaTeX — "computing a mathematical expression:
908
+ // $2^{20} - 24$" — and the braces inside that span tripped the `[{}]` rule below, so BOTH of
909
+ // its thoughts were discarded and the turn showed no reasoning at all. Same mistake as the
910
+ // backticks, one notation along: `$…$` is how prose WRITES mathematics, not a sign that the
911
+ // text is code.
912
+ //
913
+ // BOUNDED AT 120 CHARS, and that bound is the only thing standing between this and a way to
914
+ // smuggle code past the judgement: "$" is common in shell and in prices, so two unrelated ones
915
+ // will pair up, and an unbounded span between them would delete every marker in between. A
916
+ // real inline formula is short. (No newline bound is needed — the only caller collapses
917
+ // whitespace before this runs, so there are none left to cross.)
918
+ const opening = String(s)
919
+ .replace(/`[^`]*`/g, " ")
920
+ .replace(/\$[^$]{1,120}\$/g, " ")
921
+ .slice(0, CODE_OPENING_CHARS);
922
+ return (
923
+ /\\n|[{}]|=>|="|#[0-9a-fA-F]{3,6}\b|-webkit-/.test(opening) ||
924
+ /;\s*$|^\s*[<([]/.test(s) ||
925
+ /^\s*[a-z_]\w*\s*\(/i.test(s) ||
926
+ (s.length > 24 && (s.split(" ").length - 1) / s.length < 0.06)
927
+ );
928
+ };
929
+
930
+ /**
931
+ * The prose a step reasoned in, or "" when there is none worth showing.
932
+ *
933
+ * Everything up to `<code` — the code itself is already rendered as edit cards and action
934
+ * bullets, and printing it twice is exactly what the bullet list replaced. Unlike the live
935
+ * line's version this is NOT capped to one clause: the whole point is the paragraphs the
936
+ * terminal was dropping.
937
+ */
938
+ /**
939
+ * A THOUGHT-OPENING TAG, which is not a code-opening tag.
940
+ *
941
+ * Models label their reasoning, and they do not agree on how. Measured over 1,279 real stored
942
+ * responses, the openers that carry prose behind them are `<thought>`, `<thought` (unclosed),
943
+ * `<code_thought` and `<codethought` — 188, and then a tail of variants.
944
+ *
945
+ * These were being DISCARDED. `_codey` treats a leading "<" as markup, correctly for a snippet
946
+ * of HTML and disastrously here: the tag is a label on the reasoning, not the reasoning. 221 of
947
+ * 1,279 responses — every one of them prose a person would want to read — printed nothing at
948
+ * all. Whole turns showed no reasoning in the terminal.
949
+ *
950
+ * DELIBERATELY NOT `<code>`. That IS a code boundary, handled below by cutting the string
951
+ * there, and the 533 responses opening with it correctly produce no prose — there is no thought
952
+ * in them to show. `code[_-]?` only matches when `thought` follows, so `<code>` cannot reach
953
+ * this. Task #144's lesson: check what the model ACTUALLY writes.
954
+ */
955
+ // `[^>\n]*`, NOT `[^>]*`: an unclosed `<thought` is followed by a newline and the reasoning,
956
+ // and a class that crosses newlines swallows everything up to the next ">" ANYWHERE in the
957
+ // text — which for prose containing no ">" is the whole thought. That mistake ate 82 of these
958
+ // on the first attempt, and it fails silently: the tag is gone and so is the sentence.
959
+ export const THOUGHT_TAG_OPEN = /^\s*<\/?(?:code[_-]?)?thoughts?[^>\n]*>?[ \t]*\r?\n?/i;
960
+ /** …and its closing form, wherever it turns up: `</thought>`, `</code_thought>`. */
961
+ const THOUGHT_TAG_CLOSE = /<\/(?:code[_-]?)?thoughts?\s*>/gi;
962
+
963
+ export function thoughtProse(buf) {
964
+ const t = String(buf || "");
965
+ const ci = t.search(/<code[\s>]/i);
966
+ const head = (ci >= 0 ? t.slice(0, ci) : t)
967
+ // The label comes off BEFORE anything judges whether this looks like code — that judgement
968
+ // is what the tag was failing.
969
+ .replace(THOUGHT_TAG_OPEN, "")
970
+ .replace(THOUGHT_TAG_CLOSE, " ")
971
+ // A leading pipe or quote survives some streams ("| Thought: …").
972
+ .replace(/^\s*[|>\-]+\s*/, "")
973
+ .replace(/^\s*Thought:\s*/i, "")
974
+ .replace(/```[a-z]*\n?/gi, " ")
975
+ .replace(/\s+/g, " ")
976
+ .trim();
977
+ if (head.length < 6) return "";
978
+ if (head.split(/\s+/).length < 2) return "";
979
+ if (_codey(head)) return "";
980
+ return head;
981
+ }
982
+
983
+ /**
984
+ * The rows to print for one thought block: plain text, no ANSI, no gutter — the caller owns
985
+ * colour and indentation.
986
+ *
987
+ * Collapsed by default because this is ON by default: a turn is many steps, and a page of
988
+ * reasoning per step would bury the action list and the answer under it. The last row carries
989
+ * the affordance, so the block always says it has more to give.
990
+ */
991
+ export function thoughtBlockRows(prose, { width = 76, collapsed = true, lines = 2 } = {}) {
992
+ const text = String(prose || "").trim();
993
+ if (!text) return [];
994
+ const w = Math.max(20, width);
995
+ const words = text.split(/\s+/);
996
+ const rows = [];
997
+ let cur = "";
998
+ for (const word of words) {
999
+ const next = cur ? cur + " " + word : word;
1000
+ if (next.length <= w) cur = next;
1001
+ else {
1002
+ if (cur) rows.push(cur);
1003
+ cur = word.length > w ? word.slice(0, w) : word;
1004
+ }
1005
+ }
1006
+ if (cur) rows.push(cur);
1007
+ if (!collapsed || rows.length <= lines) {
1008
+ // Nothing hidden ⇒ no affordance. A "click to expand" on a block with nothing more to
1009
+ // show is a promise the block cannot keep.
1010
+ return rows.length > lines ? [...rows, "(click to collapse)"] : rows;
1011
+ }
1012
+ const shown = rows.slice(0, Math.max(1, lines));
1013
+ const hidden = rows.length - shown.length;
1014
+ // Make ROOM for the affordance instead of appending past the width: a row that overflows is
1015
+ // a row the terminal soft-wraps, and an unaccounted wrapped row pushes the whole viewport
1016
+ // down by one (the lesson from the overlay work — rows must never wrap).
1017
+ //
1018
+ // In a narrow window the full wording cannot fit beside any useful amount of text, so it
1019
+ // steps down to a short form and finally onto a row of its own. It never wins an argument
1020
+ // with the text: the reasoning is the content, the hint is a label for it.
1021
+ const fitWith = (suffix) => {
1022
+ let last = shown[shown.length - 1];
1023
+ while (last.length + suffix.length > w && last.includes(" ")) {
1024
+ last = last.slice(0, last.lastIndexOf(" "));
1025
+ }
1026
+ return last.length + suffix.length <= w && last.length >= 12 ? last + suffix : null;
1027
+ };
1028
+ for (const suffix of [` … (+${hidden} more — click to expand)`, ` … (+${hidden} more)`, " …"]) {
1029
+ const row = fitWith(suffix);
1030
+ if (row) {
1031
+ shown[shown.length - 1] = row;
1032
+ return shown;
1033
+ }
1034
+ }
1035
+ return [...shown, `(+${hidden} more)`.slice(0, w)];
1036
+ }
1037
+
1038
+
1039
+ /**
1040
+ * What a submitted line should actually run, given a task left unfinished by the previous turn.
1041
+ *
1042
+ * A turn that ends without an answer — Ctrl+C, a provider refusal, a thrown error — pops its
1043
+ * question from history, because nothing answered it and a failure notice must never persist as
1044
+ * an assistant turn. That leaves "continue" meaning nothing, so the question is remembered here
1045
+ * instead and one bare continue-word re-runs it.
1046
+ *
1047
+ * Bare ONLY: a message carrying real instructions is a new task, not a resume. And one-shot —
1048
+ * the caller clears the pending task as it reads it, so "continue" twice does not run it twice.
1049
+ *
1050
+ * NOTHING TO RESUME IS NOT A TASK (task #201). "continue" with no pending question used to be
1051
+ * handed to the model AS THE QUESTION, and the failure path then remembered the word as the
1052
+ * unfinished task — so every later "continue" resumed the task "continue", forever:
1053
+ *
1054
+ * continue #1: task sent to model = "continue" resumed=false
1055
+ * continue #2: task sent to model = "continue" resumed=true <- self-referential
1056
+ *
1057
+ * Observed live: given no task, the model invented one, ran a setup script and EDITED a file
1058
+ * nobody asked it to touch. The word that should do the LEAST did the most. So a resume word
1059
+ * with nothing behind it now runs nothing, and `pending` is re-checked here as well — a
1060
+ * session file poisoned by the old build must not be able to restart the loop.
1061
+ */
1062
+ const RESUME_WORDS = /^(continue|resume|go on|keep going|carry on|proceed)\.?$/i;
1063
+
1064
+ /** Is this line a bare resume word? Shared so the guards cannot drift from the reader. */
1065
+ export function isResumeWord(line) {
1066
+ return RESUME_WORDS.test(String(line ?? "").trim());
1067
+ }
1068
+
1069
+ export function resumeTask(line, pending) {
1070
+ const text = String(line ?? "").trim();
1071
+ if (!RESUME_WORDS.test(text)) return { task: text, resumed: false, nothingToResume: false };
1072
+ // A pending task that is ITSELF a resume word is the poisoned state; treat it as absent.
1073
+ const real = isResumeWord(pending) ? "" : String(pending ?? "").trim();
1074
+ if (!real) return { task: "", resumed: false, nothingToResume: true };
1075
+ return { task: real, resumed: true, nothingToResume: false };
1076
+ }
1077
+
1078
+
1079
+ /**
1080
+ * Commands that still WORK but are no longer offered (task #153).
1081
+ *
1082
+ * Hiding and deleting are different promises. These keep their handlers — typing one does
1083
+ * exactly what it always did — they are simply absent from /help and Tab completion while the
1084
+ * command surface is trimmed. Kept as data rather than deleted lines so the contract is
1085
+ * assertable: the risk of "hidden" is that someone later removes the handler believing the
1086
+ * command is gone, and a test can catch that.
1087
+ */
1088
+ /**
1089
+ * Is this folder too broad to be a workspace? (home directory incident, 2026-08-12)
1090
+ *
1091
+ * WHAT HAPPENED. `gu` was run from `~`, the prompt asked "Register it?" defaulting to YES,
1092
+ * and the answer was yes. That made the workspace 243,516 files / ~4.5GB — every repo the user
1093
+ * owns, under one root. RAG then planned a 1,490,193-chunk index (≈64.8 days), the between-turns
1094
+ * pass started it, and it ran for hours against a SHARED embedder while the bar read
1095
+ * "rag — not indexed". Every downstream guard (an index ceiling, a clearer bar state) is a
1096
+ * mitigation; this is the place the mistake is actually made.
1097
+ *
1098
+ * DELIBERATELY A PATH TEST, NOT A FILE COUNT. Counting files under a home directory to decide
1099
+ * whether to warn about counting files under a home directory takes exactly as long as the thing
1100
+ * being warned about — and the prompt has to answer instantly. A path comparison is free and
1101
+ * catches the case that actually occurred.
1102
+ *
1103
+ * Returns a warning string, or "" when the folder looks like an ordinary project.
1104
+ */
1105
+ export function workspaceScopeWarning(cwd, home) {
1106
+ // An ABSENT path is not the root. `"" || "/"` collapsed the two, so a missing cwd warned
1107
+ // "this is the whole filesystem" and flipped the register default to No for nothing. Strip
1108
+ // trailing separators, but only call it "/" when that is what was actually passed.
1109
+ const norm = (p) => {
1110
+ const s = String(p ?? "").trim();
1111
+ if (!s) return "";
1112
+ return s.replace(/[\\/]+$/, "") || "/";
1113
+ };
1114
+ const c = norm(cwd);
1115
+ const h = norm(home);
1116
+ if (!c) return "";
1117
+ if (c === "/" || /^[A-Za-z]:$/.test(c)) {
1118
+ return "this is the whole filesystem — indexing and file tools would cover every file on this machine";
1119
+ }
1120
+ if (h && c === h) {
1121
+ return "this is your HOME directory — it contains every project you own, not one project";
1122
+ }
1123
+ // A parent of home (/Users, /home, C:\Users): broader still, and the same mistake one level up.
1124
+ if (h && h.startsWith(c + "/")) {
1125
+ return `this contains your home directory (${h}) — far broader than one project`;
1126
+ }
1127
+ return "";
1128
+ }
1129
+
1130
+ /**
1131
+ * ---- WHERE A FOLDER'S RAG INDEX LIVES (task #133 / #180) -------------------------------
1132
+ *
1133
+ * One choice with three answers, and the ONE place it becomes wire fields. It used to be two
1134
+ * independent booleans set by two commands that toggled the same flag — three states expressed
1135
+ * as two, whose fourth combination (cloud + mirror) is meaningless and had to be caught by a
1136
+ * guard in python. Deriving both from a single id means that combination cannot be built.
1137
+ *
1138
+ * Out here rather than in gu-repl.mjs because that file cannot be imported by a test, and
1139
+ * "does each of the three modes send the right thing" is precisely the question that has to be
1140
+ * answerable without a terminal.
1141
+ */
1142
+ export const RAG_STORAGE = [
1143
+ { id: "local", label: "local", note: "on this machine only (~/.gonext) — no AWS keys needed" },
1144
+ { id: "cloud", label: "cloud", note: "on your S3 bucket only — same index as the web app; needs RAG AWS keys" },
1145
+ { id: "both", label: "local + cloud", note: "local is read; each shard also mirrored to S3 — needs RAG AWS keys" },
1146
+ ];
1147
+ export const RAG_STORAGE_IDS = RAG_STORAGE.map((r) => r.id);
1148
+ export const ragStorageLabel = (id) =>
1149
+ RAG_STORAGE.find((r) => r.id === id)?.label ?? String(id ?? "");
1150
+
1151
+ /** The two fields /agent-ask carries, derived together so they can never contradict. */
1152
+ export function ragWireFields(id) {
1153
+ return { ragMode: id === "cloud" ? "cloud" : "local", ragCloudSync: id === "both" };
1154
+ }
1155
+
1156
+ /**
1157
+ * The stored choice for a folder, from whatever shape its session file is in.
1158
+ *
1159
+ * THREE GENERATIONS live on disk. `ragStorage` is current. `ragCloudSync` (a bool) came with
1160
+ * #133 and wins over the oldest, because a folder carrying both was last written by that
1161
+ * version. `ragMode: "cloud"` is the ORIGINAL exclusive choice — and it deliberately reads back
1162
+ * as "both", not "cloud": #133 made the terminal local-first, so such a folder has been running
1163
+ * as local+mirror ever since and its index is on this disk NOW. Honouring what was typed years
1164
+ * ago would strand that index behind a credentials check.
1165
+ */
1166
+ export function ragStorageFromSession(raw) {
1167
+ if (RAG_STORAGE_IDS.includes(raw?.ragStorage)) return raw.ragStorage;
1168
+ if (typeof raw?.ragCloudSync === "boolean") return raw.ragCloudSync ? "both" : "local";
1169
+ return raw?.ragMode === "cloud" ? "both" : "local";
1170
+ }
1171
+
1172
+ /**
1173
+ * The UNFINISHED task a bare "continue" should resume, out of a folder's session file (#189).
1174
+ *
1175
+ * Read back at startup so the resume survives closing the terminal. It used to live only in a
1176
+ * `let` inside the REPL loop, and the failure path that arms it POPS the user's question out of
1177
+ * conversation history (correct — nothing answered it, and a persisted failure banner gets
1178
+ * re-sent to the model for the rest of the session, task #144). Between those two facts, a
1179
+ * failed question existed in exactly one place, in memory, and closing the terminal destroyed
1180
+ * it. The reported symptom was the next session's "continue" having nothing to continue.
1181
+ *
1182
+ * Anything that is not a non-empty string reads back as "" — an older session file simply has
1183
+ * no such field, and resumeTask treats "" as "nothing pending", which is the right default for
1184
+ * every malformed case too.
1185
+ */
1186
+ /**
1187
+ * What the frozen input row should SHOW — the typed line, a mask, or nothing (task #191).
1188
+ *
1189
+ * The row is not readline's: gu renders it from rl.line, so readline's echo muting (which
1190
+ * is what keeps a secret out of the scrollback, task #127) has no reach here at all. While the
1191
+ * API-key prompt was up, the key was therefore painted into this row in plain text.
1192
+ *
1193
+ * MASKED, NOT BLANK. The prompt text sits up in the scrolling output while the caret lives in
1194
+ * this row, so blanking it would leave a terminal that reacts to a keystroke nowhere on screen
1195
+ * — which is exactly what got reported as confusing. One bullet per character says "you are
1196
+ * typing, and it is going in" without the row being able to carry the key.
1197
+ */
1198
+ export function inputLineFor({ line = "", visible = false, secret = false } = {}) {
1199
+ if (!visible) return "";
1200
+ return secret ? "\u2022".repeat(String(line).length) : String(line);
1201
+ }
1202
+
1203
+ /**
1204
+ * Drop a just-entered secret from readline's history (task #191).
1205
+ *
1206
+ * Muting the echo says nothing about rl.history, and readline unshifts every submitted line
1207
+ * onto it in terminal mode — so an API key came straight back, in plain text, on the next press
1208
+ * of Up at the ">> " prompt, and stayed there for the session.
1209
+ *
1210
+ * Only ever removes the entry that IS the secret, and only at the head where readline just put
1211
+ * it: a blind shift() would eat a real command whenever the secret was not recorded (an empty
1212
+ * entry, or a repeat of the previous line, both of which readline skips).
1213
+ */
1214
+ export function dropSecretFromHistory(history, secret) {
1215
+ if (!Array.isArray(history) || !secret) return history;
1216
+ if (history[0] === secret) history.shift();
1217
+ return history;
1218
+ }
1219
+
1220
+ export function unfinishedTaskFromSession(raw) {
1221
+ const t = raw?.unfinishedTask;
1222
+ if (typeof t !== "string" || !t.trim()) return "";
1223
+ // Never resume a stored resume word (task #201). The disk copy deliberately outlives the
1224
+ // in-memory one, so without this a session file written by an older build keeps the
1225
+ // "continue retries continue" loop alive across restarts — the failure survives the fix.
1226
+ return isResumeWord(t) ? "" : t;
1227
+ }
1228
+
1229
+ /**
1230
+ * Did this turn die because the BACKEND could not answer, rather than because the task was
1231
+ * bad? (task #201)
1232
+ *
1233
+ * The distinction changes the advice completely: a task that failed may be worth retrying as
1234
+ * typed, while a coder whose backend is refusing will fail identically however many times it
1235
+ * is retried. The observed shape was three "Streaming unavailable — retrying without
1236
+ * streaming…" lines and then AgentGenerationError, with no steps run at all.
1237
+ *
1238
+ * That wording is GONE from the worker as of the streaming-fallback narrowing: it now says
1239
+ * "Streaming unavailable" only when a server genuinely refuses a streamed request, which is
1240
+ * the one case an unstreamed retry fixes. Timeouts, gateway errors, overloads and auth/billing
1241
+ * failures all keep streaming and are classified upstream on their own merits — see
1242
+ * _is_request_timeout / _is_backend_unavailable / _is_backend_overloaded / _is_auth_failure in
1243
+ * gonext_agent_chat.py. Do not relabel any of them here; they arrive with their own wording.
1244
+ */
1245
+ /**
1246
+ * The model name a server said it does not have → "'gemma4:e4b'", or "" when it did not say.
1247
+ *
1248
+ * Quoted back with its own quotes kept, because the exact string IS the diagnosis: ":latest"
1249
+ * dropped, "qwen3.8" typed as "qwen3", a model pulled on the wrong box. Paraphrasing it would
1250
+ * throw away the only part of the message worth reading.
1251
+ */
1252
+ export function modelNameNotFound(message) {
1253
+ const t = String(message ?? "");
1254
+ // Ollama: model 'gemma4:e4b' not found
1255
+ const ollama = /model ['"“”]([^'"“”\n]+)['"“”] not found/i.exec(t);
1256
+ if (ollama) return `'${ollama[1]}'`;
1257
+ // OpenAI, which BACKTICKS the name and says what became of it (task #223 follow-up, reported):
1258
+ // The model `gpt-5.1-codex-mini` has been deprecated, learn more here: …
1259
+ // The model `x` does not exist or you do not have access to it.
1260
+ const openai = /\bmodel [`'"“”]([^`'"“”\n]+)[`'"“”](?= (?:has been deprecated|does not exist|is not))/i.exec(t);
1261
+ return openai ? `'${openai[1]}'` : "";
1262
+ }
1263
+
1264
+ /** Was the name rejected because the provider RETIRED it, rather than never having had it?
1265
+ * Same action either way — pick another — but "deprecated" is the fact that explains it. */
1266
+ export function modelDeprecated(message) {
1267
+ return /has been deprecated/i.test(String(message ?? ""));
1268
+ }
1269
+
1270
+
1271
+ export function classifyTurnFailure(message) {
1272
+ const t = String(message ?? "");
1273
+ // BUDGET FIRST — and this ORDER is the whole point. The first version of this file matched
1274
+ // /timed out/ for the backend and so labelled "agent chat timed out after 3600000 ms" — the
1275
+ // agent's OWN 60-minute budget (gu-cli.mjs:1973, task #81) — as a backend failure. A user
1276
+ // whose turn had just done an hour of real work was told "the coding model's backend failed
1277
+ // … retrying will not help", and retrying was in fact exactly what worked. Anchored to the
1278
+ // budget's literal shape so the model client's own "Request timed out." still reads as the
1279
+ // backend.
1280
+ if (/timed out after \d+\s*ms|the time budget/i.test(t)) return "budget";
1281
+ // INFRASTRUCTURE — gu's own machinery, not the model and not the question (task #207
1282
+ // requirement 4, first needed by #209). Without this kind these fell into "task" and inherited
1283
+ // advice about coders: "Worker key lookup failed" — the API's Mongo lookup throwing — was
1284
+ // answered with "e.g. /model to switch coder", which cannot help and sends the reader looking
1285
+ // in the wrong place entirely. Checked BEFORE the backend patterns, since a stopped worker and
1286
+ // a database that will not answer are neither the coder's fault nor the user's.
1287
+ if (
1288
+ /worker was stopped|background worker was stopped|Worker key lookup failed|cannot reach its database|MongoNetworkError|MongoServerSelectionError/i.test(
1289
+ t
1290
+ )
1291
+ ) {
1292
+ return "infra";
1293
+ }
1294
+ // CONFIG — the server is up and answering; it just has no model by that name (task #213).
1295
+ // Ollama says: model 'gemma4:e4b' not found (and "…, try pulling it first" for embeddings).
1296
+ // Checked BEFORE the backend patterns because it arrives as a 404 from a perfectly healthy
1297
+ // box, and "the backend failed, retrying will not help" is both wrong and unactionable: the
1298
+ // backend is fine, and one picker away is a model that works. The name the box rejected is
1299
+ // the single most useful thing in the whole message, so the advice repeats it back.
1300
+ if (/model ['"“”]?[^'"“”\n]+['"“”]? not found|model .* not found, try pulling it first/i.test(t)) {
1301
+ return "config";
1302
+ }
1303
+ // …AND THE SAME FACT IN OPENAI'S WORDS (task #223 follow-up). Reported from a live turn:
1304
+ // 404 - {'error': {'message': 'The model `gpt-5.1-codex-mini` has been deprecated, …',
1305
+ // 'code': 'model_not_found'}}
1306
+ // The provider is healthy and the key is good; the model was RETIRED. That fell through to
1307
+ // "backend", so the terminal said the backend had failed and invited a retry — which could
1308
+ // never work, and sent the reader to look at a server that was answering perfectly. The
1309
+ // `model_not_found` code is the unambiguous signal; the prose forms are for endpoints that
1310
+ // send one without the other.
1311
+ if (/model_not_found|has been deprecated|model [`'"“”][^`'"“”\n]+[`'"“”] does not exist/i.test(t)) {
1312
+ return "config";
1313
+ }
1314
+ if (/AgentGenerationError|Error in generating model output|APIConnectionError|Connection error|Request timed out|Bad Gateway|Service Unavailable|\b50[234]\b/i.test(t)) {
1315
+ return "backend";
1316
+ }
1317
+ return "task";
1318
+ }
1319
+
1320
+ /**
1321
+ * A run budget in words. → "6 hours", "45 minutes", "1 hour"; "" for nothing.
1322
+ *
1323
+ * Exists because the budget stopped being a round hour: at 360 minutes both the mid-run picker
1324
+ * ("The agent has run for 360 min") and the advice under a stopped turn ("its 360-minute time
1325
+ * budget") read as machine output. Whole hours are said as hours.
1326
+ *
1327
+ * `hyphenated` gives the adjectival form for "its 6-hour time budget"; the plain form is for
1328
+ * "has run for 6 hours". Shared by the CLI's picker and the REPL's advice so the two cannot
1329
+ * describe the same number differently.
1330
+ */
1331
+ export function formatBudget(minutes, { hyphenated = false } = {}) {
1332
+ const m = Math.max(0, Math.round(Number(minutes) || 0));
1333
+ if (!m) return "";
1334
+ if (m % 60 === 0) {
1335
+ const h = m / 60;
1336
+ return hyphenated ? `${h}-hour` : `${h} hour${h === 1 ? "" : "s"}`;
1337
+ }
1338
+ return hyphenated ? `${m}-minute` : `${m} minute${m === 1 ? "" : "s"}`;
1339
+ }
1340
+
1341
+ /** How long the budget was, in minutes, from the timeout message. 0 when it cannot be read. */
1342
+ export function budgetMinutes(message) {
1343
+ const m = /timed out after (\d+)\s*ms/i.exec(String(message ?? ""));
1344
+ return m ? Math.round(Number(m[1]) / 60000) : 0;
1345
+ }
1346
+
1347
+ /** Kept as the narrow question the name asks; the classifier is the single source of truth. */
1348
+ export function isBackendFailure(message) {
1349
+ return classifyTurnFailure(message) === "backend";
1350
+ }
1351
+
1352
+ /**
1353
+ * What to say under a failed turn — the retry invitation, or the admission that retrying is
1354
+ * not working. (task #201)
1355
+ *
1356
+ * WHY THIS STOPS BEING AN INVITATION. The old line was unconditional: *type "continue" to
1357
+ * retry the same question*. Nothing counted how often the same task had already failed the
1358
+ * same way, so a user typed "continue" four times against a backend that was down; the third
1359
+ * attempt produced no steps whatsoever. Advice that cannot notice it is not working is not
1360
+ * advice.
1361
+ *
1362
+ * `failures` counts CONSECUTIVE failures of THIS task, so the second one is what changes the
1363
+ * message — the first failure genuinely might be transient, the second in a row is a pattern.
1364
+ * Returns null when the turn is not resumable at all.
1365
+ *
1366
+ * A BUDGET STOP IS NOT A FAILURE and must never be talked about as one. Hitting the time
1367
+ * budget means the turn was working and ran out of clock; "continue" is the DESIGNED response,
1368
+ * and the escalation that tells people to stop retrying would be advice to abandon work that
1369
+ * is progressing. Observed exactly that way: four budget stops in a row, each making real
1370
+ * progress, and the task completed once the user kept going.
1371
+ */
1372
+ export function retryAdvice({ resumable = true, failures = 1, kind = "task", minutes = 0, message = "" } = {}) {
1373
+ const rawMessage = message;
1374
+ if (!resumable) return null;
1375
+ const budget = formatBudget(minutes, { hyphenated: true });
1376
+ if (kind === "budget") {
1377
+ // Counted separately from failures: repetition here means the TASK is too big for one
1378
+ // turn, which is a different problem from a task that keeps breaking.
1379
+ return failures >= 3
1380
+ ? `the agent has hit its ${budget} budget ${failures} times on this task — it is too large for one turn. "continue" still resumes it; a smaller step each time will get there sooner, or raise agentTimeoutMinutes.`
1381
+ : `the agent hit its ${budget} time budget — it was still working, so "continue" picks up where it stopped.`;
1382
+ }
1383
+ if (kind === "infra") {
1384
+ // The question was fine and the coder was fine — something under both of them stopped. The
1385
+ // work is resumable (#201 remembers the question), so lead with that; only name where to
1386
+ // look once it has happened more than once, because a single restart needs no diagnosis.
1387
+ return failures >= 2
1388
+ ? `gu's own machinery has failed ${failures} times in a row — not your question, and not the coding model. Check it before retrying: gu-cli api status, and gu-cli start if the worker is not running.`
1389
+ : 'that was gu\'s own machinery stopping, not your question — "continue" runs it again';
1390
+ }
1391
+ if (kind === "config") {
1392
+ // Not resumable by repetition, and saying "continue" here would be a lie — the same
1393
+ // question with the same model name fails identically every time. Retry count is
1394
+ // irrelevant for the same reason, so this branch sits above it.
1395
+ const named = modelNameNotFound(rawMessage);
1396
+ // RETIRED IS NOT THE SAME STORY AS NEVER-EXISTED, even though the action is identical. A
1397
+ // model that worked yesterday and is gone today reads as a gu fault unless the reason is
1398
+ // said out loud — and the provider is the one that changed, not this machine.
1399
+ if (modelDeprecated(rawMessage)) {
1400
+ return (
1401
+ (named
1402
+ ? `${named} has been RETIRED by the provider — nothing here is broken, and retrying cannot bring it back.`
1403
+ : "that model has been RETIRED by the provider — nothing here is broken, and retrying cannot bring it back.") +
1404
+ ' Pick another: /provider to change this provider\'s model, or /model to switch coder. Then "continue".'
1405
+ );
1406
+ }
1407
+ return (
1408
+ (named
1409
+ ? `the model server has no model called ${named} — nothing is broken, it is the name.`
1410
+ : "the model server does not have the model that was asked for — nothing is broken, it is the name.") +
1411
+ ' Pick one it does have: /agent-model for the chat model, /model for the coder, /provider to change a provider\'s model. Then "continue".'
1412
+ );
1413
+ }
1414
+ if (failures >= 2) {
1415
+ return kind === "backend"
1416
+ ? `this task has now failed ${failures} times in a row and the coding model's backend is what is failing — not your question. Retrying will not help until it answers: /model to switch coder.`
1417
+ : `this task has now failed ${failures} times in a row, the same way. Another "continue" will most likely do the same — change something first: /model to switch coder, or ask for a smaller step.`;
1418
+ }
1419
+ return kind === "backend"
1420
+ ? 'the coding model\'s backend failed, not your question — type "continue" to retry it, or /model to switch coder'
1421
+ : 'type "continue" to retry the same question — fix the cause first (e.g. /model to switch coder)';
1422
+ }
1423
+
1424
+ /**
1425
+ * `gu <something>` — is that a subcommand, a typo, or the REPL? (task #181)
1426
+ *
1427
+ * `gu logz` used to START THE REPL. Silently: the argument was not in the subcommand list,
1428
+ * so it fell through to "no subcommand given" and a full agent session opened in that folder.
1429
+ * That is the same failure the CLI_SUBCOMMANDS list was added to fix for `gu status` —
1430
+ * the list caught the KNOWN names and left every unknown one falling through the same hole.
1431
+ *
1432
+ * Returns { kind: "run" } for a real subcommand, { kind: "repl" } for no arguments or flags,
1433
+ * and { kind: "unknown", typed, suggestion } otherwise. Pure, so every row below is a test
1434
+ * rather than something to try at a prompt.
1435
+ *
1436
+ * The suggestion is a PREFIX match first, then a one-edit neighbour. Both are conservative on
1437
+ * purpose: "did you mean X?" is only useful if it is usually right, and a wrong guess in front
1438
+ * of the correct list is worse than no guess at all.
1439
+ */
1440
+ export function classifyCliArg(argv, subcommands) {
1441
+ const args = Array.isArray(argv) ? argv : [];
1442
+ const first = String(args[0] ?? "");
1443
+ if (!first || first.startsWith("-")) return { kind: "repl" };
1444
+ const known = Array.isArray(subcommands) ? subcommands : [];
1445
+ if (known.includes(first)) return { kind: "run" };
1446
+ const typed = first.toLowerCase();
1447
+ const suggestion =
1448
+ known.find((c) => c.startsWith(typed) || typed.startsWith(c)) ??
1449
+ known.find((c) => _editDistance(c, typed) <= (typed.length <= 4 ? 1 : 2)) ??
1450
+ "";
1451
+ return { kind: "unknown", typed: first, suggestion };
1452
+ }
1453
+
1454
+ /** Levenshtein, small and iterative — the inputs are single words. */
1455
+ function _editDistance(a, b) {
1456
+ const m = a.length;
1457
+ const n = b.length;
1458
+ if (!m || !n) return Math.max(m, n);
1459
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
1460
+ for (let i = 1; i <= m; i++) {
1461
+ const cur = [i];
1462
+ for (let j = 1; j <= n; j++) {
1463
+ cur[j] = Math.min(
1464
+ prev[j] + 1,
1465
+ cur[j - 1] + 1,
1466
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
1467
+ );
1468
+ }
1469
+ prev = cur;
1470
+ }
1471
+ return prev[n];
1472
+ }
1473
+
1474
+ /**
1475
+ * The "something newer exists" control, right-aligned on the separator row above the input.
1476
+ *
1477
+ * ONLY WHEN SOMETHING IS ACTUALLY NEWER (task #181). With nothing available this returns null
1478
+ * and the row stays the plain rule it has always been — a permanent control there would be a
1479
+ * standing claim on the one row separating the conversation from the input, for a thing that
1480
+ * is true a few times a year.
1481
+ *
1482
+ * IT NAMES WHICH ONE, because the two have different consequences: the API upgrade restarts the
1483
+ * local API and drops what it was serving; the CLI upgrade rewrites the files of the program
1484
+ * currently running, so it cannot take effect until gu is restarted. One label saying
1485
+ * "update available" would hide exactly the distinction the user needs to decide.
1486
+ *
1487
+ * Returns { text, from, to } like jumpToBottomRow — the same shape, because it lands on the
1488
+ * same row and the caller hit-tests both by COLUMN. `from`/`to` are visible columns, 0-based.
1489
+ */
1490
+ export function updateRow({ cli = "", api = "", width = 80, rule = "─" } = {}) {
1491
+ const w = Math.max(20, width | 0);
1492
+ const label =
1493
+ cli && api ? `⭯ updates: gu ${cli} · API ${api} (click)`
1494
+ : cli ? `⭯ update gu ${cli} (click)`
1495
+ : api ? `⭯ update API ${api} (click)`
1496
+ : "";
1497
+ if (!label) return null;
1498
+ // Right-aligned, with the rule running up to it so the row still reads as a separator rather
1499
+ // than as a floating fragment. One space of air each side so the glyph is not welded to it.
1500
+ const len = visibleLen(label);
1501
+ if (len + 4 > w - 1) return null; // …too narrow to say it honestly: say nothing
1502
+ const from = w - 1 - len;
1503
+ return {
1504
+ text: String(rule).repeat(Math.max(0, from - 1)) + " " + label,
1505
+ from,
1506
+ to: from + len,
1507
+ };
1508
+ }
1509
+
1510
+ /**
1511
+ * The arrow-key picker's two byte sequences — drawing the block, and taking it away.
1512
+ *
1513
+ * Extracted from gu-repl.mjs so a screen emulator can replay them (tests/ansi-screen.mjs).
1514
+ * This is row ARITHMETIC, which is the thing that cannot be checked by reading: the bytes
1515
+ * always look plausible and only the resulting grid shows a block erased one row short, or an
1516
+ * output position parked below where the next line should start. Every "#109 family" bug in
1517
+ * this codebase has had that shape.
1518
+ *
1519
+ * THE INVARIANT BOTH HALVES EXIST TO KEEP: after the wipe, the cursor is back exactly where
1520
+ * the block began — same row, COLUMN 0 — because the caller saves that spot as the position
1521
+ * the next printed line starts from. One column off indents the next line; one row off leaves
1522
+ * a gap above it.
1523
+ */
1524
+ export function listBlockSeq({ items, sel = 0, width = 80, gutter = "", redraw = false, prevRows = [] } = {}) {
1525
+ const w = Math.max(20, width | 0);
1526
+ // Clipped so no row can WRAP: the walk back up counts logical rows, so a row occupying two
1527
+ // physical ones would land the next redraw a row short of where it started.
1528
+ const rows = items.map((it, i) =>
1529
+ clipVisible(gutter + (i === sel ? `▸ ${it}` : ` ${it}`), w - 1)
1530
+ );
1531
+ const up = redraw ? physicalRows(prevRows, w) : 0;
1532
+ const seq =
1533
+ (up ? `\x1b[${up}A` : "") +
1534
+ rows.map((r) => "\r\x1b[K" + r).join("\n") +
1535
+ "\x1b[J" +
1536
+ "\n";
1537
+ return { seq, rows };
1538
+ }
1539
+
1540
+ /** Take the block away and leave the cursor where it began. */
1541
+ export function listWipeSeq({ prevRows = [], width = 80 } = {}) {
1542
+ const up = physicalRows(prevRows, Math.max(20, width | 0));
1543
+ // \r is not decoration: ESC[nA preserves the COLUMN, so without it the cursor keeps whatever
1544
+ // column the last row left it on and the next printed line starts there.
1545
+ return (up ? `\x1b[${up}A` : "") + "\r\x1b[J";
1546
+ }
1547
+
1548
+ /**
1549
+ * Does this action bullet open the FINAL phase of a turn? → boolean.
1550
+ *
1551
+ * NO LONGER CALLED BY THE REPL (2026-08-22). It existed because action bullets were one tight
1552
+ * group (task #41) with a single blank above "Composing answer…" — the moment the turn stops
1553
+ * working and starts producing the reply. Every bullet now gets that blank (openBullet in
1554
+ * gu-repl.mjs), so this one's blank is no longer special and the call site is gone.
1555
+ *
1556
+ * Kept, not deleted: the predicate is still the honest answer to "is this the answer phase",
1557
+ * it is pure and tested, and the spacing decision it encodes has now been reversed twice
1558
+ * (#129 spaced → #41 tight → 2026-08-22 spaced again). If it goes tight a third time this is
1559
+ * what comes back. Anything that needs to KNOW about the phase — not just space it — should
1560
+ * call this rather than re-derive the string match.
1561
+ */
1562
+ export function opensAnswerPhase(line) {
1563
+ return /^\s*composing answer/i.test(String(line ?? ""));
1564
+ }
1565
+
1566
+ /**
1567
+ * The end-of-turn answer block: how long it took, then the reply. → { text, rows }
1568
+ *
1569
+ * `rows` is returned rather than recounted by the caller, and that is the point of the
1570
+ * function existing at all: the copy map (task #158) translates SCREEN ROWS back to the
1571
+ * source text so a double-click yields something runnable, and it used to be maintained by
1572
+ * hand right next to the template string that laid the block out — `rendered.split("\n").length
1573
+ * + 1`, where the `+ 1` was the "thought for" row. Add a row to the layout and forget the
1574
+ * count and nothing looks wrong; the copy range is just silently off by one. One function now
1575
+ * owns both.
1576
+ *
1577
+ * THE EMPTY LINE BESIDE "thought for …" is the change this was extracted for. That line is a
1578
+ * FOOTNOTE about the turn that has just ended, and the reply is the thing being read. Touching,
1579
+ * they read as one paragraph — reported from a real screen, where "thought for 17s" sat directly
1580
+ * on top of "Hello! How can I assist you today?".
1581
+ *
1582
+ * THE FOOTNOTE GOES UNDER THE REPLY (user, 2026-09-06). It was above it, which put a fact about
1583
+ * the turn in the position the eye starts from — you read the cost before the thing you asked
1584
+ * for. Below, the reply leads and the timings close it out, which is also where a reader looks
1585
+ * for "when did this finish".
1586
+ *
1587
+ * `doneAt` is a parameter rather than a Date.now() inside, so the block a test asserts on is the
1588
+ * same block at any hour — otherwise the wall clock makes the output untestable.
1589
+ */
1590
+ export function answerBlock({
1591
+ elapsedMs = 0,
1592
+ answer = "",
1593
+ worked = false,
1594
+ gutter = "",
1595
+ dim = (x) => x,
1596
+ render = (x) => x,
1597
+ doneAt = Date.now(),
1598
+ } = {}) {
1599
+ const rendered = render(worked ? `recap: ${answer}` : answer);
1600
+ const clock = formatClockTime(doneAt);
1601
+ // The stamp is DROPPED, not defaulted, when the time is unusable — "done Invalid Date" beside
1602
+ // a correct duration is worse than no stamp at all.
1603
+ const foot =
1604
+ gutter + dim(`thought for ${formatThinkTime(elapsedMs)}${clock ? ` · done ${clock}` : ""}`);
1605
+ const text = `${rendered}\n\n${foot}`;
1606
+ return { text, rows: text.split("\n").length };
1607
+ }
1608
+
1609
+ /** The one command that installs a newer gu. One definition, so the notice and any
1610
+ * documentation of it cannot drift from what the update control actually runs. */
1611
+ export const CLI_INSTALL_COMMAND = "npm install -g @tiens.nguyen/gu-cli@latest";
1612
+
1613
+ /**
1614
+ * The "a newer gu exists" notice, as transcript lines. → [{ text, kind }] (empty = say
1615
+ * nothing), where kind is `head` | `cmd` | `note` and the caller owns the colour.
1616
+ *
1617
+ * WHY A NOTICE AND NOT JUST THE BAR CONTROL (task #181 follow-up). The clickable `⭯ update`
1618
+ * on the separator row is easy to miss and impossible to act on if you do not already know
1619
+ * it is a button — it never says what it would do, and nothing tells you a new version
1620
+ * exists at all if you do not look down there. The complaint was exactly that: the terminal
1621
+ * knew and did not say.
1622
+ *
1623
+ * THREE LINES, because the user needs three different facts and they are not interchangeable:
1624
+ * · WHAT — a newer version exists, and which, against the one running. "An update is
1625
+ * available" without the numbers cannot be checked or reported.
1626
+ * · HOW — the literal command. Printed on its OWN line with nothing else on it, so that a
1627
+ * double-click copies the command and not a sentence wrapped around it. This is also the
1628
+ * escape hatch: the click control needs npm to be writable by this user, and when a
1629
+ * global install needs sudo the command is the only route that works.
1630
+ * · WHEN — that it takes effect only after a restart. Omitting this is how "I updated and
1631
+ * nothing changed" happens: npm rewrites the files of the program currently running, and
1632
+ * the running process keeps its own copy until it is started again.
1633
+ *
1634
+ * Shown once per session, by the caller. A notice that reappears on a timer is an
1635
+ * interruption; this one is a fact that does not change until acted on.
1636
+ */
1637
+ export function updateNotice({ cli = "", running = "", platform = process.platform } = {}) {
1638
+ if (!cli) return [];
1639
+ const have = running ? ` (you have ${running})` : "";
1640
+ return [
1641
+ { kind: "head", text: `gu ${cli} is available${have}` },
1642
+ { kind: "cmd", text: CLI_INSTALL_COMMAND },
1643
+ { kind: "note", text: updateRestartNote(platform) },
1644
+ ];
1645
+ }
1646
+
1647
+ /**
1648
+ * Did the install actually happen, and if not, what does the user need to know?
1649
+ * → null when it succeeded, otherwise the sentence to show.
1650
+ *
1651
+ * THIS EXISTS BECAUSE spawnSync DOES NOT THROW ON A FAILED COMMAND. It returns a status,
1652
+ * which nothing checked — so `✓ gu installed` printed whether npm had worked or not.
1653
+ * Rare on macOS; the NORMAL outcome on Windows, where a global install cannot replace the
1654
+ * files of the running program, and common on Linux, where a non-root `npm install -g`
1655
+ * hits EACCES on a root-owned prefix. Three platforms, one silent lie.
1656
+ *
1657
+ * THE REASON IS KEPT, and that is the point of the parsing. "The update failed" leaves the
1658
+ * user with nothing to act on, while the codes name completely different fixes: EACCES
1659
+ * wants sudo or a user-owned prefix, EPERM/EBUSY wants gu closed first, ETARGET means
1660
+ * the version does not exist, ENOTFOUND means no network. npm puts that line on stderr and
1661
+ * buries it in banner noise, so the last couple of substantive lines are what survive.
1662
+ */
1663
+ export function describeInstallFailure({ status = 0, stderr = "", error = null } = {}) {
1664
+ // The command could not be started at all (npm not on PATH) — `error` is set and there is
1665
+ // no status to read. Distinct from "npm ran and refused".
1666
+ if (error) return `could not run npm — ${error.message ?? error}`;
1667
+ if (status === 0) return null;
1668
+
1669
+ // SEARCH FOR THE CAUSE, DO NOT TAKE THE TAIL. This used to keep the last two substantive
1670
+ // lines, which is wrong for the npm people actually have: verified against npm 10 in an
1671
+ // Ubuntu container, a permission failure ends with EIGHT lines of generic advice ("The
1672
+ // operation was rejected by your operating system", "You can rerun the command with
1673
+ // --loglevel=verbose"), so the tail is boilerplate and the cause is ~14 lines up. The
1674
+ // prefix moved too — `npm ERR!` became `npm error` in npm 10 — which is exactly the drift
1675
+ // a parser built from recorded strings cannot notice.
1676
+ const lines = String(stderr ?? "")
1677
+ .split("\n")
1678
+ .map((l) => l.replace(/^npm\s+(ERR!|error|WARN|warn)\s*/i, "").trim())
1679
+ .filter(Boolean);
1680
+
1681
+ // The code names the FIX, and the codes mean different ones: EACCES → sudo or a user-owned
1682
+ // prefix; EPERM/EBUSY → close gu first; ETARGET/E404 → that version does not exist;
1683
+ // ENOTFOUND/ETIMEDOUT → no network. Taken from `code EACCES` or `code: 'EACCES'`.
1684
+ let code = lines.map((l) => /^code:?\s*'?([A-Z][A-Z0-9_]{2,})'?,?$/.exec(l)?.[1])
1685
+ .find(Boolean);
1686
+ // npm 11 DOES NOT ALWAYS EMIT A `code X` LINE. Verified on Windows 11 / npm 11.19.0: a
1687
+ // missing version prints `npm error notarget No matching version found …` with the code as a
1688
+ // lowercase per-line PREFIX and no code line at all, so the reason lost the one token that
1689
+ // names the fix. Recovered from a known set rather than "any leading lowercase word", which
1690
+ // would promote ordinary prose into a code.
1691
+ if (!code) {
1692
+ const known = new Set(["notarget", "eresolve", "enoent", "eacces", "eperm", "ebusy",
1693
+ "etarget", "e404", "enotfound", "elifecycle", "enospc", "enotempty"]);
1694
+ const hit = lines.map((l) => /^([a-z0-9]{4,12})\s+\S/.exec(l)?.[1]).find((w) => w && known.has(w));
1695
+ if (hit) code = hit.toUpperCase().replace(/^NOTARGET$/, "ETARGET");
1696
+ }
1697
+ // The one human sentence npm writes about what went wrong, minus the stack and the advice.
1698
+ const detail = lines.map((l) => l.replace(/^(notarget|nospc|enoent)\s+/i, ""))
1699
+ .find((l) =>
1700
+ /^Error: |permission denied|operation not permitted|No matching version|not found|EACCES|EPERM|EBUSY/i.test(l) &&
1701
+ // …but never the code line itself, or the reason reads "EACCES: code EACCES".
1702
+ !/^code:?\s*'?[A-Z][A-Z0-9_]{2,}'?,?$/.test(l) &&
1703
+ !/^(errno|syscall|path):?\s/i.test(l) &&
1704
+ !/^at /.test(l) &&
1705
+ !/rejected by your operating system/i.test(l) &&
1706
+ !/do not have the permissions to access this file/i.test(l) &&
1707
+ !/^If you believe this might be/i.test(l));
1708
+
1709
+ // Never say the code twice. npm's own sentence usually LEADS with it ("EBUSY: resource busy
1710
+ // or locked, rename …"), which read back as "EBUSY: EBUSY: resource busy or locked".
1711
+ const dup = code && detail && new RegExp(`^${code}\\b:?\\s*`, "i").test(detail);
1712
+ const why = [code, detail && detail !== code && !dup ? detail
1713
+ : dup ? detail.replace(new RegExp(`^${code}\\b:?\\s*`, "i"), "") : ""]
1714
+ .filter(Boolean).join(": ");
1715
+ return `npm exited ${status}${why ? ` — ${_clipReason(why)}` : ""}`;
1716
+ }
1717
+
1718
+ /** One line, bounded — a reason is for reading, and npm's can carry a whole stack frame. */
1719
+ function _clipReason(s, max = 160) {
1720
+ const one = String(s).replace(/\s+/g, " ").trim();
1721
+ return one.length > max ? one.slice(0, max - 1) + "…" : one;
1722
+ }
1723
+
1724
+ /**
1725
+ * The last line of the notice — and it is NOT the same sentence on every platform (#157).
1726
+ *
1727
+ * On macOS and Linux a global install replaces files that are open, because unlink there
1728
+ * detaches the name from an inode the running process keeps. Install now, restart when
1729
+ * convenient; the order does not matter.
1730
+ *
1731
+ * WINDOWS CANNOT DO THAT. A file that is in use cannot be replaced, so `npm install -g`
1732
+ * against the gu that is currently running is liable to fail outright — EPERM/EBUSY,
1733
+ * partway through, with the shims possibly already rewritten. The steps are therefore in a
1734
+ * REQUIRED ORDER there: close it, install, reopen. Printing the POSIX sentence on Windows
1735
+ * tells the user to do the one thing that breaks, which is worse than saying nothing.
1736
+ */
1737
+ export function updateRestartNote(platform = process.platform) {
1738
+ return platform === "win32"
1739
+ ? "Close gu first (Windows can't replace files in use), run that, then reopen."
1740
+ : "Then restart gu for it to take effect.";
1741
+ }
1742
+
1743
+ /**
1744
+ * A stored API key, shown as enough to TELL IT APART and no more — `sk-…4f2a`.
1745
+ *
1746
+ * The point is the one question a user actually has: "is the key in there the one I think it
1747
+ * is?" A bare "stored" cannot answer it, which is exactly what made a rejected key confusing —
1748
+ * you could see that something was saved and not whether it was the stale one.
1749
+ *
1750
+ * NEVER THE WHOLE KEY. It would land in scrollback, in a screenshot, in a pasted transcript —
1751
+ * all places a live credential should not be. The prefix is kept because providers put meaning
1752
+ * there (`sk-`, `sk-ant-`), the tail because that is what people compare against.
1753
+ */
1754
+ export function maskKey(key) {
1755
+ const k = String(key ?? "").trim();
1756
+ if (!k) return "";
1757
+ // Too short to split safely: anything under ~10 chars would leak most of itself, so it gets
1758
+ // no tail at all rather than a nearly-complete key.
1759
+ if (k.length < 10) return "\u2022".repeat(k.length);
1760
+ // BOUNDED, and this is the whole difficulty: a greedy `^sk-[a-z-]*` swallows every lowercase
1761
+ // character of a key like "sk-abcdefghijklmnop4f2a" and prints nearly all of it. The prefix
1762
+ // is `sk-` plus at most ONE short vendor segment ("sk-ant-"), which is where the meaning is;
1763
+ // anything longer is key material, not a label.
1764
+ const head = /^sk-(?:[a-z]{2,4}-)?/i.exec(k)?.[0] ?? k.slice(0, 3);
1765
+ return `${head}\u2026${k.slice(-4)}`;
1766
+ }
1767
+
1768
+ /**
1769
+ * ---- OTHER RUNNING AGENTS (task #195) -------------------------------------------------
1770
+ *
1771
+ * The same account's turns running somewhere else — another terminal, another machine, the web
1772
+ * app. They are real today and invisible from here. It began as a MONITOR; since task #211 the
1773
+ * list also hands each agent to an action — stop it, or switch this terminal to following it —
1774
+ * which is what agentPickerItems below exists for.
1775
+ */
1776
+
1777
+ /** A coarse, relative age — "9s", "12m", "3d". The exact second is never the question. */
1778
+ export function shortAge(fromIso, now = Date.now()) {
1779
+ const t = Date.parse(String(fromIso ?? ""));
1780
+ if (!Number.isFinite(t)) return "";
1781
+ const s = Math.max(0, Math.round((now - t) / 1000));
1782
+ if (s < 60) return `${s}s`;
1783
+ if (s < 3600) return `${Math.round(s / 60)}m`;
1784
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
1785
+ return `${Math.round(s / 86400)}d`;
1786
+ }
1787
+
1788
+ /**
1789
+ * The jobs to SHOW, from what the API returned. → [{ id, status, title, detail, age }]
1790
+ *
1791
+ * `mine` is this terminal's own jobId and is excluded: counting the turn you are watching as
1792
+ * "another agent" makes the number read as duplicated work the moment you ask anything.
1793
+ */
1794
+ export function otherAgentRows(jobs, { mine = "", now = Date.now() } = {}) {
1795
+ return (Array.isArray(jobs) ? jobs : [])
1796
+ .filter((j) => j && j.jobId && j.jobId !== mine)
1797
+ .map((j) => ({
1798
+ id: j.jobId,
1799
+ status: String(j.jobStatus ?? ""),
1800
+ title: String(j.title ?? "").trim() || "(no question recorded)",
1801
+ detail: String(j.modelKey ?? "").trim(),
1802
+ age: shortAge(j.updatedAt || j.createdAt, now),
1803
+ }));
1804
+ }
1805
+
1806
+ /**
1807
+ * The indicator for the separator row: "2 agents", or "" when there is nothing to say.
1808
+ *
1809
+ * Conditional on purpose — that row is scarce, and "0 agents" is noise. Singular/plural because
1810
+ * "1 agents" is the kind of small wrongness that makes a UI feel unfinished.
1811
+ */
1812
+ export function agentsIndicator(rows) {
1813
+ const n = Array.isArray(rows) ? rows.length : 0;
1814
+ return n ? `${n} agent${n === 1 ? "" : "s"}` : "";
1815
+ }
1816
+
1817
+ /**
1818
+ * The list itself, as rows to print — grouped by what the reader can act on.
1819
+ *
1820
+ * RUNNING first, then WAITING (queued, nobody has claimed it). A job that is merely queued is a
1821
+ * different situation from one a worker is executing, and the screenshot's grouping is what
1822
+ * makes that legible at a glance.
1823
+ *
1824
+ * Every row is clipped to ONE physical row (#109/#138): title elided from the right, the age
1825
+ * right-aligned, and the middle detail given whatever is left over — dropped entirely when the
1826
+ * window is too narrow to carry it honestly.
1827
+ */
1828
+ export function agentListRows(rows, { width = 80, gutter = " " } = {}) {
1829
+ const w = Math.max(20, width | 0);
1830
+ const groups = [
1831
+ ["Running", (r) => r.status === "running" || r.status === "claimed"],
1832
+ ["Waiting", (r) => r.status === "pending"],
1833
+ ];
1834
+ const out = [];
1835
+ for (const [label, match] of groups) {
1836
+ const hit = (rows ?? []).filter(match);
1837
+ if (!hit.length) continue;
1838
+ out.push({ kind: "header", text: label });
1839
+ for (const r of hit) {
1840
+ const age = r.age ?? "";
1841
+ // Columns: gutter + marker + title + detail + age. The age is the only part that must
1842
+ // never be cut — it is the reason to look.
1843
+ const fixed = gutter.length + 2 + age.length + 2;
1844
+ const room = Math.max(8, w - 1 - fixed);
1845
+ const titleW = Math.max(8, Math.floor(room * (room > 40 ? 0.55 : 1)));
1846
+ const detailW = room - titleW - (room > 40 ? 2 : 0);
1847
+ const cut = (t, n) => (t.length > n ? t.slice(0, Math.max(1, n - 1)) + "\u2026" : t);
1848
+ out.push({
1849
+ kind: "row",
1850
+ id: r.id,
1851
+ // Carried through so a caller acting on the row (stop / switch to) can tell a job a
1852
+ // worker is EXECUTING from one still queued, without re-deriving it from the header.
1853
+ status: r.status ?? "",
1854
+ marker: "\u2022",
1855
+ title: cut(r.title, titleW),
1856
+ detail: detailW > 4 ? cut(r.detail ?? "", detailW) : "",
1857
+ age,
1858
+ titleW,
1859
+ detailW: detailW > 4 ? detailW : 0,
1860
+ });
1861
+ }
1862
+ }
1863
+ return out;
1864
+ }
1865
+
1866
+ /**
1867
+ * The same agents, as PICKER items — one line each, so they can be acted on (task #211).
1868
+ *
1869
+ * A picker has no group headers (drawList paints a flat list), and losing the Running/Waiting
1870
+ * distinction would be losing the most important thing on the row: stopping a queued job and
1871
+ * stopping one that is halfway through a deploy are different decisions. So the group is
1872
+ * carried on each item and shown inline on the ones that need it — "queued" is stated, and
1873
+ * "running" is the unmarked default, because marking both makes neither stand out.
1874
+ *
1875
+ * Returns [{ id, status, group, label }] in the same order agentListRows lays out.
1876
+ */
1877
+ export function agentPickerItems(rows, { width = 80 } = {}) {
1878
+ const QUEUED = " · queued";
1879
+ // The picker's own chrome — drawList adds a gutter and a 2-column "▸ " marker to every row —
1880
+ // plus the widest suffix, so the columns are laid out inside the room that is actually left
1881
+ // rather than the full width. Getting this wrong does not wrap (drawList clips), it TRUNCATES
1882
+ // the age off the right-hand end, which is the one column the reader came for.
1883
+ const room = Math.max(20, (width | 0) - 5 - QUEUED.length);
1884
+ const out = [];
1885
+ let group = "";
1886
+ for (const r of agentListRows(rows, { width: room, gutter: "" })) {
1887
+ if (r.kind === "header") {
1888
+ group = r.text;
1889
+ continue;
1890
+ }
1891
+ const label =
1892
+ r.title.padEnd(r.titleW) +
1893
+ (r.detail ? " " + r.detail.padEnd(r.detailW) : "") +
1894
+ " " + r.age +
1895
+ (group === "Waiting" ? QUEUED : "");
1896
+ out.push({ id: r.id, status: r.status ?? "", group, label });
1897
+ }
1898
+ return out;
1899
+ }
1900
+
1901
+ export const HIDDEN_COMMANDS = [
1902
+ "/provider", // and /providers — /model's picker carries "add or change an
1903
+ "/providers", // OpenAI-compatible provider…" in every branch, so listing this
1904
+ // too offered two doors into one setting (reported as confusing)
1905
+ "/rag-local", // superseded by /rag's picker; still work, still mean what
1906
+ "/rag-cloud", // they say — unlisted so /help offers one way into the setting
1907
+ "/thought", // the reasoning display stays ON; only the toggle is unlisted
1908
+ "/hover", // in-turn mouse capture — behaviour unchanged, just hidden
1909
+ "/reset",
1910
+ "/new",
1911
+ "/reset-output-token-global", // pending a rename to /reset-global-token that also resets
1912
+ // the INPUT total — which does not exist yet as a counter
1913
+ ];
1914
+
1915
+ /**
1916
+ * Does this API base point at a server on THIS machine? (task #154)
1917
+ *
1918
+ * That is the definition of Client Expert — the API and database are local — and it is read
1919
+ * from the live configuration rather than the setup notes on disk, so it stays true after a
1920
+ * `gu clear` of the notes and false on a machine that was only ever pointed at the hosted
1921
+ * API. The banner previously said plain "client" on a fully set-up Client Expert box, which is
1922
+ * the one thing that line exists to make explicit.
1923
+ */
1924
+ export function isLocalApiBase(apiBase) {
1925
+ return /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(String(apiBase ?? "").trim());
1926
+ }
1927
+
1928
+ /**
1929
+ * The banner's "mode" row, or null when there is nothing worth saying.
1930
+ *
1931
+ * ORDER IS THE WHOLE POINT: a Client Expert machine is ALSO a client for models, so testing
1932
+ * `clientMode` first makes it read as an ordinary client and the local API + database — the
1933
+ * entire distinction — go unmentioned. Reported live on a fully set-up Ubuntu box that said
1934
+ * plain "client".
1935
+ */
1936
+ export function modeRow({ clientExpert, clientMode }) {
1937
+ if (clientExpert) {
1938
+ return { label: "client expert", detail: "(API + database here · models remote)" };
1939
+ }
1940
+ if (clientMode) {
1941
+ return { label: "client", detail: "(agent runs here · models remote)" };
1942
+ }
1943
+ return null;
1944
+ }
1945
+
1946
+ /**
1947
+ * After a window resize settles, WHO repaints the transcript — and with which painter?
1948
+ *
1949
+ * "scrollback" = the user is scrolled back, repaint that slice. "live" = repaint the tail.
1950
+ * null = leave the screen alone, because someone else owns it.
1951
+ *
1952
+ * The ownership rules are the point, and this must agree with the resize handler's own
1953
+ * early-returns — a test is cheaper than remembering that it should.
1954
+ *
1955
+ * A MODAL draws itself and the handler already redrew it, so repainting under it is the race
1956
+ * the early-returns exist to prevent.
1957
+ *
1958
+ * A BUSY TURN used to decline for the same reason, and that was the bug (reported live:
1959
+ * resizing while the agent thinks corrupts the screen, resizing at the prompt does not). The
1960
+ * live status block is drawn in place and erased by walking the caret up from the saved output
1961
+ * position — an absolute row recorded BEFORE the reflow, which the resize invalidates. Nothing
1962
+ * else repainted during a turn, so the damage stayed. The handler now forgets that block
1963
+ * without erasing it and leaves the healing here: the repaint rewrites every row of the
1964
+ * scrolling area, so the stale block goes with it, and the 120ms ticker draws a fresh one.
1965
+ */
1966
+ export function resizeRepaintTarget({ barPinned, modal, busy, scrolledBack }) {
1967
+ // Not pinned = gu is not rendering the view at all; the terminal owns the screen.
1968
+ if (!barPinned) return null;
1969
+ if (modal) return null;
1970
+ return scrolledBack ? "scrollback" : "live";
1971
+ }
1972
+
1973
+ /**
1974
+ * Should the workspace "sync chat history to the cloud?" question be ASKED? (task #154)
1975
+ *
1976
+ * Not on a Client Expert machine. There is no cloud there: the API and the database are on
1977
+ * this box, "your account" is a local row, and there is no web app to see it on — so every
1978
+ * clause of the question is false. Asking a question whose premise is wrong is worse than not
1979
+ * asking, because the answer cannot mean what the reader thinks it means.
1980
+ *
1981
+ * The sync itself still happens — it writes to the local database, which is where that
1982
+ * machine's conversations already live — so the value is true and it is stated rather than
1983
+ * asked. Reported live: a Client Expert machine on Windows offering to send chat to a cloud
1984
+ * it does not have.
1985
+ */
1986
+ export function workspaceSyncQuestion({ clientExpert }) {
1987
+ return clientExpert
1988
+ ? {
1989
+ ask: false,
1990
+ value: true,
1991
+ note: "chat history is kept in the local database on this machine (Local mode)",
1992
+ }
1993
+ : { ask: true, value: null, note: "" };
1994
+ }
1995
+
1996
+ /**
1997
+ * A counter that CLIMBS to its new value instead of jumping (user, 2026-09-05: "lets make it
1998
+ * dynamically going up so we can see it increase slowly… like 400 then 410, then 440, then 470
1999
+ * then 550, better right?").
2000
+ *
2001
+ * For the "↓ N" output-token figure beside the thinking line. Eases by a FRACTION of the
2002
+ * remaining distance, so it moves fast when the jump is big and settles as it arrives — the
2003
+ * shape that reads as counting rather than as a progress bar. The status block is already
2004
+ * redrawn ~8x/second for its spinner while the agent is thinking, so this costs no extra
2005
+ * repaints; it gives the ones already happening something to say.
2006
+ *
2007
+ * THREE RULES IT MUST NOT BREAK, because this is a MEASUREMENT and not decoration:
2008
+ *
2009
+ * · NEVER OVERSTATE. The eased value approaches from below and is clamped to the target, so
2010
+ * what is on screen is always tokens that have genuinely been produced. A counter that
2011
+ * overshot and settled back would be inventing output.
2012
+ * · ALWAYS ARRIVE. A pure fraction converges without reaching, leaving "549" on screen forever
2013
+ * against a real 550. minStep guarantees a whole token of progress per tick, so it lands
2014
+ * exactly on the target.
2015
+ * · SNAP DOWNWARD. A target BELOW the shown value is a new turn resetting to 0, not a decrease
2016
+ * to animate — easing that would show the previous question's tail draining away.
2017
+ */
2018
+ export function easeCount(shown, target, { rate = 0.3, minStep = 0 } = {}) {
2019
+ const from = Math.max(0, Math.floor(Number(shown) || 0));
2020
+ const to = Math.max(0, Math.floor(Number(target) || 0));
2021
+ if (to <= from) return to; // arrived, or reset by a new turn
2022
+ // THE FLOOR SCALES WITH THE TARGET, so the tail does not crawl. A flat minimum of 1 made the
2023
+ // last stretch of a 550-token climb take six ticks to cover seven tokens — visibly fussy, and
2024
+ // at four figures fmtTokens rounds them all to the same "0.5k" anyway, so those ticks paint a
2025
+ // number that never changes. 1% of the target lands the same climb in nine ticks (~1s) with
2026
+ // the ease-out shape intact.
2027
+ const floor = minStep || Math.max(1, Math.ceil(to / 100));
2028
+ const step = Math.max(floor, Math.ceil((to - from) * rate));
2029
+ return Math.min(to, from + step);
2030
+ }
2031
+
2032
+ /**
2033
+ * WHICH CODER IS ACTIVE — the one and only answer, for every place that shows it.
2034
+ *
2035
+ * THIS EXISTS BECAUSE THE RULE WAS WRITTEN THREE TIMES AND DRIFTED THREE WAYS. The bar said
2036
+ * `sessionCodingLabel || defaultCodingModel`, the banner said `override || payload.codingModelId`,
2037
+ * and the stale-pick warning said `sessionCodingLabel || sessionCodingModel` — mixing the label
2038
+ * with the raw id. Each was right on its own and they disagreed with each other, so the screen
2039
+ * showed two different coders at once (2026-09-05, twice: the banner naming the account default
2040
+ * while the bar named the folder's pick, then the bar naming the old coder after a reconfigure
2041
+ * had changed it).
2042
+ *
2043
+ * Three sites re-deriving one rule is three chances to update two of them. A fourth display
2044
+ * would have been a fourth bug. So: one function, and a test that nothing re-derives it.
2045
+ *
2046
+ * THE ORDER, and why each step is there:
2047
+ * 1. the /model pick's LABEL — this folder's choice, and what the next turn will use;
2048
+ * 2. its raw ID — a session saved before labels were stored still has one, and a qualified
2049
+ * "ollama::qwen3:14b" beats showing nothing;
2050
+ * 3. the ACCOUNT DEFAULT — no pick, so the account's coder is the answer;
2051
+ * 4. "" — genuinely unknown. The caller decides how to render that; inventing a name here
2052
+ * would put a model on screen that nothing is configured to use.
2053
+ */
2054
+ export function activeCoderName({
2055
+ sessionLabel = "",
2056
+ sessionModel = "",
2057
+ accountDefault = "",
2058
+ } = {}) {
2059
+ const s = (v) => String(v ?? "").trim();
2060
+ return s(sessionLabel) || s(sessionModel) || s(accountDefault) || "";
2061
+ }
2062
+
2063
+ /**
2064
+ * The bar's identity row — "coder | folder", plus a mark when no key has been chosen.
2065
+ *
2066
+ * REPORTED: "can we show this warning outside, like at the bottom bar, need to notify the user
2067
+ * BEFORE HAND, not only when user do slash model." Correct — a warning you have to go looking for
2068
+ * is one you find after the turn that needed it.
2069
+ *
2070
+ * IT GOES INSIDE THE EXISTING ROW, NOT ON A NEW ONE. The bar lives in the frozen zone, and a zone
2071
+ * whose height changes is what broke every screen-row mapping in task #222 — so the mark rides
2072
+ * beside the coder, where the reader is already looking to answer "what is this pointed at".
2073
+ *
2074
+ * SHORT ON PURPOSE. The row is clipped to the terminal width and the folder name sits after it;
2075
+ * a sentence here would push the folder off a narrow screen. The bar says THAT something is
2076
+ * unchosen, /model and ctrl+k say what to do about it.
2077
+ */
2078
+ const passthrough = (v) => v;
2079
+
2080
+ export function identityRow(
2081
+ { coder = "", folder = "", keyUnchosen = false } = {},
2082
+ { orange = passthrough, cyan = passthrough, dim = passthrough, red = passthrough } = {},
2083
+ ) {
2084
+ const name = String(coder ?? "").trim();
2085
+ const where = String(folder ?? "").trim();
2086
+ // The mark only means anything beside a named coder: with none shown there is nothing for it
2087
+ // to be about, and it would read as a fault in the folder.
2088
+ const mark = name && keyUnchosen ? red(" ⚠ key not chosen") : "";
2089
+ return (name ? orange(name) + mark + dim(" | ") : "") + cyan(where);
2090
+ }
2091
+
2092
+ /**
2093
+ * What to say about syncing, next to a registered workspace.
2094
+ *
2095
+ * ON CLIENT EXPERT, NOTHING. That mode's whole promise is that the machine keeps everything —
2096
+ * API, database, models — and shares none of it, so "cloud sync on" is not merely noise, it
2097
+ * contradicts the mode the user chose (reported 2026-09-04). workspaceSyncQuestion already
2098
+ * knows this and stores `true` meaning "persist it", but the LABEL read that value as "to the
2099
+ * cloud", which is the one place it does not go.
2100
+ *
2101
+ * The chat history is still saved — to the local MongoDB on that machine, which the setup
2102
+ * banner already says. Saying nothing here is therefore not hiding anything: it is declining to
2103
+ * repeat a fact under a name that is wrong.
2104
+ */
2105
+ export function workspaceSyncLabel({ allowSync, clientExpert }) {
2106
+ if (clientExpert) return "";
2107
+ return allowSync ? " · cloud sync on" : " · cloud sync off";
2108
+ }
2109
+
2110
+ // ---------------------------------------------------------------------------
2111
+ // The blinking status line's text — what the agent is doing RIGHT NOW (#68, #170 follow-up).
2112
+ //
2113
+ // The model often emits <code> with NO "Thought:" prose (or degenerates into a runaway), so
2114
+ // the raw stream is a create_file(...) blob or escaped CSS, which used to render verbatim on
2115
+ // the line ("◐ , #94a3b8);\n -webkit-background-clip…"). Show a genuine thought ONLY when the
2116
+ // model actually wrote prose; otherwise name the TOOL it is calling; otherwise show nothing
2117
+ // and let the caller fall back to "Thinking…". Never raw code.
2118
+ //
2119
+ // Moved here from gu-repl.mjs so it can be tested: that file starts a REPL on import.
2120
+ // ---------------------------------------------------------------------------
2121
+
2122
+ const _baseName = (p) => (String(p || "").split(/[\\/]/).pop() || "").trim();
2123
+
2124
+ /** Trim a long argument for a one-line status without cutting mid-word where avoidable. */
2125
+ const _arg = (s, limit = 48) => {
2126
+ const t = String(s || "").trim();
2127
+ if (t.length <= limit) return t;
2128
+ const cut = t.slice(0, limit).replace(/\s+\S*$/, "");
2129
+ return (cut.length >= limit * 0.6 ? cut : t.slice(0, limit)) + "…";
2130
+ };
2131
+
2132
+ /**
2133
+ * WHAT the tool is doing, said in the terms the user cares about.
2134
+ *
2135
+ * The SEARCH tools quote their argument. They used to be bare ("Searching the code"), which
2136
+ * told you the agent was busy and nothing about what it was looking for — the one detail worth
2137
+ * having while you wait, and the one that makes a wrong search obvious before it finishes.
2138
+ * Reads and commands already did this; grep and RAG were the odd ones out.
2139
+ */
2140
+ export const THOUGHT_TOOL_LABELS = {
2141
+ create_file: (a) => `Writing ${_baseName(a) || "a file"}`,
2142
+ create_folder: (a) => `Creating ${_baseName(a) || "a folder"}`,
2143
+ edit_lines: (a) => `Editing ${_baseName(a) || "a file"}`,
2144
+ edit_file: (a) => `Editing ${_baseName(a) || "a file"}`,
2145
+ read_file_lines: (a) => `Reading ${_baseName(a) || "a file"}`,
2146
+ read_text_file: (a) => `Reading ${_baseName(a) || "a file"}`,
2147
+ list_dir: (a) => `Listing ${_baseName(a) || "files"}`,
2148
+ grep_repo: (a) => (a ? `Searching the code for ${_arg(a)}` : "Searching the code"),
2149
+ run_command: (a) => `Running ${_arg(a, 60) || "a command"}`,
2150
+ stop_server: () => "Stopping the server",
2151
+ deploy_web: () => "Deploying",
2152
+ fetch_url: (a) => `Fetching ${_baseName(a) || "a page"}`,
2153
+ web_search: (a) => (a ? `Searching the web for ${_arg(a)}` : "Searching the web"),
2154
+ http_request: () => "Calling an API",
2155
+ create_pdf: () => "Building a PDF",
2156
+ rag_search: (a) => (a ? `Searching the knowledge base for ${_arg(a)}` : "Searching the knowledge base"),
2157
+ rag_search_workspace: (a) =>
2158
+ a ? `Searching the knowledge base for ${_arg(a)}` : "Searching the knowledge base",
2159
+ rag_index: () => "Indexing the knowledge base",
2160
+ index_project: () => "Indexing the knowledge base",
2161
+ download_file: () => "Downloading a file",
2162
+ unzip_file: () => "Unzipping",
2163
+ send_email: () => "Preparing an email",
2164
+ open_url: (a) => `Opening ${a || "a link"}`,
2165
+ final_answer: () => "Composing the answer",
2166
+ };
2167
+
2168
+ // Does this candidate look like CODE / a data blob rather than a human thought? (Escaped
2169
+ // newlines, braces, hex colors, -webkit-, a leading function call, or too few spaces.)
2170
+ const _thoughtLooksCodey = (s) =>
2171
+ /\\n|[{}]|=>|="|#[0-9a-fA-F]{3,6}\b|-webkit-|;\s*$|^\s*[<([]/.test(s) ||
2172
+ /^\s*[a-z_]\w*\s*\(/i.test(s) ||
2173
+ (s.length > 24 && (s.split(" ").length - 1) / s.length < 0.06);
2174
+
2175
+ /**
2176
+ * The FIRST argument of a tool call, whether it was passed positionally or by keyword.
2177
+ *
2178
+ * The old pattern only understood a bare quoted value or `path=`, so every OTHER keyword fell
2179
+ * through and captured the keyword itself: `grep_repo(pattern="Route|Router")` yielded the
2180
+ * string "pattern=", which is why quoting the argument had to wait for this. Any `name=` is
2181
+ * accepted now, and the value is read whether it is single-quoted, double-quoted or bare.
2182
+ */
2183
+ export function firstToolArg(codePart) {
2184
+ const m = /\b([a-z_]\w*)\s*\(\s*(?:[a-z_]\w*\s*=\s*)?(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\n,)]*))/i
2185
+ .exec(String(codePart || ""));
2186
+ if (!m) return null;
2187
+ const value = m[2] ?? m[3] ?? m[4] ?? "";
2188
+ return { name: m[1], arg: String(value).trim() };
2189
+ }
2190
+
2191
+ /**
2192
+ * Raw in-fence stream → a short, meaningful status: a real Thought clause if the model wrote
2193
+ * one, else a label naming the tool it is calling, else "" (the caller shows "Thinking…").
2194
+ */
2195
+ export function normalizeThought(buf) {
2196
+ const t = String(buf || "");
2197
+ const ci = t.search(/<code[\s>]/i);
2198
+ const head = ci >= 0 ? t.slice(0, ci) : t;
2199
+ // 1) a genuine Thought prose line (before the code) — only if it isn't itself code AND
2200
+ // reads like an actual clause. A streaming token boundary can leave the first
2201
+ // non-empty line as a single bare word (e.g. "string", when the model is rambling
2202
+ // about "multi-line strings"); shown alone on the blinking line it's meaningless and,
2203
+ // because liveThought only updates on a truthy value, it can FREEZE there through a
2204
+ // long silent prompt-eval ("string… (777s)"). Require ≥2 words and a little length so
2205
+ // a lone fragment falls through to the tool label / "Thinking…" instead.
2206
+ const prose = head
2207
+ .replace(/^\s*Thought:\s*/i, "")
2208
+ .split(/\n/)
2209
+ .map((x) => x.trim())
2210
+ .find(Boolean);
2211
+ const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
2212
+ if (proseIsClause && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
2213
+ // 2) otherwise name the tool being called (from the code part).
2214
+ const codePart = ci >= 0 ? t.slice(ci) : t;
2215
+ const call = firstToolArg(codePart);
2216
+ if (call && THOUGHT_TOOL_LABELS[call.name]) {
2217
+ return THOUGHT_TOOL_LABELS[call.name](call.arg).slice(0, 100);
2218
+ }
2219
+ // 3) nothing human to show.
2220
+ return "";
2221
+ }
2222
+
2223
+ /**
2224
+ * Should the status block be REBUILT this tick, or held frozen? (#170 follow-up)
2225
+ *
2226
+ * While tokens flow the block is deliberately parked: `lastContentAt` is bumped by nearly every
2227
+ * stream line, so a live rebuild would run 8×/second and the playful word would strobe. Freezing
2228
+ * the last rendered rows is what stopped that.
2229
+ *
2230
+ * But the thought line lives in that same frozen block, so freezing it also froze the ONE place
2231
+ * that says what the agent is doing. A step that starts and finishes inside the quiet window
2232
+ * never repainted at all: measured on a live turn, 24 consecutive samples all read "Thinking…"
2233
+ * while the model was in fact running a grep whose pattern was already computed and sitting in
2234
+ * `liveThought`, unpainted.
2235
+ *
2236
+ * So: park, EXCEPT when the thought itself changed. That fires a handful of times per step
2237
+ * rather than eight times a second, so it cannot strobe — the spinner and word are keyed to the
2238
+ * wall clock and do not re-roll on an extra repaint.
2239
+ */
2240
+ export function statusNeedsLiveRepaint({
2241
+ msSinceContent = 0,
2242
+ quietMs = 700,
2243
+ liveThought = "",
2244
+ paintedThought = "",
2245
+ } = {}) {
2246
+ if (msSinceContent >= quietMs) return true; // quiet: the normal animated path
2247
+ return String(liveThought ?? "") !== String(paintedThought ?? "");
2248
+ }
2249
+
2250
+ /**
2251
+ * The FULLER thought behind the live line (#96 click-to-expand).
2252
+ *
2253
+ * NOT clipped to one clause/100 chars — collapsed to a single spaced string and capped so the
2254
+ * expanded view has a few lines to show. Returns "" when the live line is only a tool label
2255
+ * (nothing extra to unfold). Bounded by fenceThought's own 400-char cap.
2256
+ *
2257
+ * Lives here, beside _thoughtLooksCodey, because it SHARES that gate with normalizeThought.
2258
+ * It was left behind in gu-repl.mjs when the rest moved, and since that file cannot be
2259
+ * imported, nothing caught the dangling reference: `node --check` validates syntax, not
2260
+ * resolution, and this line only runs once a stream fence is open. It reached a user as
2261
+ * "✗ _thoughtLooksCodey is not defined" on a live turn.
2262
+ */
2263
+ export function thoughtFull(buf) {
2264
+ const t = String(buf || "");
2265
+ const ci = t.search(/<code[\s>]/i);
2266
+ const head = (ci >= 0 ? t.slice(0, ci) : t)
2267
+ .replace(/^\s*Thought:\s*/i, "")
2268
+ .replace(/\s+/g, " ")
2269
+ .trim();
2270
+ // Only worth expanding when there's a genuine prose clause (same gate as normalizeThought
2271
+ // path 1) — a bare fragment or pure code has nothing meaningful to unfold.
2272
+ const first = head.split(/\s+/).length;
2273
+ if (head.length < 6 || first < 2 || _thoughtLooksCodey(head)) return "";
2274
+ return head.slice(0, 400);
2275
+ }
2276
+
2277
+ /**
2278
+ * The LEXICAL half of the token row: "grep 2", beside the rag segment (asked for directly).
2279
+ *
2280
+ * Both routes find code and they behave nothing alike — RAG is semantic and approximate, grep is
2281
+ * lexical and exact — so a row naming only one of them invites the reading that the other never
2282
+ * happens. A turn that answered entirely by grep showed "rag 0" and nothing else, which looks
2283
+ * like a turn that found nothing at all.
2284
+ *
2285
+ * COUNTS OF SEARCHES, not tokens, and deliberately not folded into PROMPT_BUCKETS: those must
2286
+ * sum to the size of the newest request, and an action count is not a share of anything. Returns
2287
+ * "" when nothing was searched, so a chat turn keeps its short row.
2288
+ */
2289
+ export function searchSegmentLabel(b) {
2290
+ if (!b || typeof b !== "object") return "";
2291
+ const n = (k) => (Number.isFinite(b[k]) ? b[k] : 0);
2292
+ const grep = n("grepCalls");
2293
+ if (!grep) return "";
2294
+ // WIDTH IS THE CONSTRAINT HERE, not information. The row is clipped to the terminal width so
2295
+ // it can never wrap (the #109/#138 invariant), and a first cut carrying "grep 1 · 10 hits ·
2296
+ // read 5" pushed "↓ out" and the coder name straight off the edge — measured on a real turn.
2297
+ // So the bar shows the COUNT only, and annotates just the case a bare count cannot express:
2298
+ // searches that found NOTHING, the signature of a model guessing patterns. Hits and file
2299
+ // reads stay on the inline "Found code by →" line, which has a whole row to itself.
2300
+ return n("grepHits") ? `grep ${grep}` : `grep ${grep} · no hits`;
2301
+ }
2302
+
2303
+ /**
2304
+ * The detail row(s) under the live status line: WHAT is executing right now (task #177).
2305
+ *
2306
+ * run_command has always shown its command while it ran ("◑ Running ls …"); every other tool
2307
+ * announced itself only after finishing, because the bullet carries the result count. So the
2308
+ * pattern was invisible during the one stretch where someone is watching and wondering. Asked
2309
+ * for directly: "show the grep command before it actually run".
2310
+ *
2311
+ * ONE detail on screen, belonging to whatever is running. When the tool returns, the live block
2312
+ * is erased and replaced by the finished bullet, so nothing accumulates and there is nothing to
2313
+ * click.
2314
+ *
2315
+ * THE ROWS MUST NOT WRAP. This block is redrawn in place by walking the caret with ESC[nA, and
2316
+ * `n` counts PHYSICAL rows — a soft-wrapped row desynchronises every later erase (#109, #138).
2317
+ * Hence a hard cap of two rows and a clip to the width, with the break preferring an argument
2318
+ * boundary so a split call still reads as a call.
2319
+ */
2320
+ export function runningDetailRows(detail, { width = 80, indent = 5, maxRows = 2 } = {}) {
2321
+ const text = String(detail || "").replace(/\s+/g, " ").trim();
2322
+ if (!text) return [];
2323
+ const pad = " ".repeat(Math.max(0, indent));
2324
+ const room = Math.max(12, width - pad.length - 1);
2325
+ if (text.length <= room) return [pad + text];
2326
+ if (maxRows <= 1) return [pad + text.slice(0, room - 1) + "…"];
2327
+ // Prefer breaking after an argument separator, so row 2 starts at `name=` rather than
2328
+ // mid-token. Only accept a break that is not absurdly early, or a long first argument would
2329
+ // strand the rest on one over-full row.
2330
+ let cut = -1;
2331
+ for (const sep of [", ", "="]) {
2332
+ const at = text.lastIndexOf(sep, room);
2333
+ if (at > room * 0.45) { cut = at + (sep === ", " ? 2 : 1); break; }
2334
+ }
2335
+ if (cut < 0) cut = room;
2336
+ const rest = text.slice(cut);
2337
+ return [
2338
+ pad + text.slice(0, cut).replace(/\s+$/, ""),
2339
+ pad + " " + (rest.length <= room - 2 ? rest : rest.slice(0, room - 3) + "…"),
2340
+ ];
2341
+ }
2342
+
2343
+ /**
2344
+ * How long the turn took, for the line above the answer (task #178).
2345
+ *
2346
+ * Minutes only once there are any: "12s" reads better than "0m 12s", and an hour-long turn
2347
+ * should not be reported as "63m".
2348
+ */
2349
+ /**
2350
+ * A wall-clock stamp for the end of a turn: 1546003800000 → "3:30 PM".
2351
+ *
2352
+ * Written out rather than delegated to toLocaleTimeString, which varies by ICU build and by the
2353
+ * machine's locale — a Vietnamese box would render this in 24-hour form, and the format was
2354
+ * asked for by name. Returns "" for a time that is not one, so a caller can omit the segment
2355
+ * rather than print "Invalid Date" beside a real duration.
2356
+ */
2357
+ export function formatClockTime(at = Date.now()) {
2358
+ const d = at instanceof Date ? at : new Date(at);
2359
+ if (Number.isNaN(d.getTime())) return "";
2360
+ const suffix = d.getHours() >= 12 ? "PM" : "AM";
2361
+ // 0 and 12 are both "12" — midnight is 12 AM, noon is 12 PM.
2362
+ const hour = d.getHours() % 12 || 12;
2363
+ return `${hour}:${String(d.getMinutes()).padStart(2, "0")} ${suffix}`;
2364
+ }
2365
+
2366
+ export function formatThinkTime(ms) {
2367
+ const total = Math.max(0, Math.round(Number(ms) || 0) / 1000);
2368
+ if (total < 60) return `${Math.round(total)}s`;
2369
+ const mins = Math.floor(total / 60);
2370
+ const secs = Math.round(total - mins * 60);
2371
+ if (mins < 60) return secs ? `${mins}m ${secs}s` : `${mins}m`;
2372
+ const hrs = Math.floor(mins / 60);
2373
+ const rem = mins - hrs * 60;
2374
+ return rem ? `${hrs}h ${rem}m` : `${hrs}h`;
2375
+ }
2376
+
2377
+ // Emoji, variation selectors, skin-tone modifiers, the joiner AND regional indicators, plus
2378
+ // ONE following space so "✅ Done" becomes "Done" rather than " Done".
2379
+ //
2380
+ // Regional indicators are the easy miss: a flag is two of them and matches neither
2381
+ // Extended_Pictographic nor Emoji_Modifier, so 🇬🇧 sailed straight through the first version.
2382
+ const _PICTOGRAPH =
2383
+ /[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}️‍]+[ \t]?/gu;
2384
+
2385
+ /**
2386
+ * The answer as PLAIN TEXT: decorative emoji removed (task #178).
2387
+ *
2388
+ * Asked for directly — "remove all the special icon like WEb icon or a Big check icon, in
2389
+ * general just text in the summary". A model reaching for 🌐 and ✅ is decorating, and the
2390
+ * decoration competes with this UI's own glyph vocabulary, where ✓ and ✗ and ● each mean one
2391
+ * specific thing (#41: a red ✗ is "the one thing you must not miss"). Borrowed emphasis from
2392
+ * the model dilutes the marks the terminal itself uses.
2393
+ *
2394
+ * CODE IS NEVER TOUCHED — neither fenced blocks nor inline spans. An emoji inside a string
2395
+ * literal is data: removing it changes what the code does, and the answer is routinely copied
2396
+ * straight out of the transcript (#158 exists because a copied answer must still run).
2397
+ */
2398
+ export function plainAnswer(text) {
2399
+ const src = String(text ?? "");
2400
+ // Odd indices are fenced blocks — kept verbatim.
2401
+ return src
2402
+ .split(/(```[\s\S]*?```)/g)
2403
+ .map((part, i) =>
2404
+ i % 2
2405
+ ? part
2406
+ : // …and within prose, odd indices are inline code spans.
2407
+ part
2408
+ .split(/(`[^`\n]*`)/g)
2409
+ .map((seg, j) => (j % 2 ? seg : seg.replace(_PICTOGRAPH, "")))
2410
+ .join("")
2411
+ )
2412
+ .join("")
2413
+ .replace(/[ \t]+$/gm, "");
2414
+ }
2415
+
2416
+ /**
2417
+ * Which slice of a long picker list to draw, and how far it has scrolled (task #223 follow-up).
2418
+ *
2419
+ * REPORTED: choosing a model from OpenAI's /v1/models — about eighty ids — rendered EVERY row.
2420
+ * The block was taller than the terminal, so the screen scrolled, the highlighted row was off
2421
+ * the top, and the `ESC[nA` walk that redraws the block in place then counted rows that were no
2422
+ * longer where it thought. From the outside: "cannot scroll up or down, stuck here."
2423
+ *
2424
+ * THE BLOCK HEIGHT IS CONSTANT WHILE WINDOWING, and that is deliberate rather than tidy. Every
2425
+ * redraw walks up exactly as many rows as it drew; a block that changed height as the ends came
2426
+ * into view would leave that walk one or two rows out, which is precisely the class of bug this
2427
+ * file's physicalRows() exists to prevent. So the two indicator rows are always present once the
2428
+ * list is windowed — they simply render empty at the ends.
2429
+ *
2430
+ * total how many items there are
2431
+ * sel the highlighted index
2432
+ * top the first visible index from the previous draw (0 on open)
2433
+ * height rows available for the WHOLE block, indicators included
2434
+ *
2435
+ * Returns { windowed, top, from, to, above, below } — `from`/`to` a half-open range.
2436
+ */
2437
+ export function listWindow(total, sel, top, height) {
2438
+ const n = Math.max(0, Math.floor(total));
2439
+ const h = Math.max(1, Math.floor(height));
2440
+ if (n <= h) return { windowed: false, top: 0, from: 0, to: n, above: 0, below: 0 };
2441
+ // Two rows go to the indicators, so at least one item is always visible even on a tiny screen.
2442
+ const itemRows = Math.max(1, h - 2);
2443
+ const last = n - itemRows;
2444
+ let t = Math.min(Math.max(0, Math.floor(top) || 0), last);
2445
+ // FOLLOW THE SELECTION, INCLUDING A WRAP. Arrowing up from the first row jumps to the last,
2446
+ // and a window that only ever crept by one would take eighty presses to catch up.
2447
+ const s = Math.min(Math.max(0, Math.floor(sel) || 0), n - 1);
2448
+ if (s < t) t = s;
2449
+ else if (s >= t + itemRows) t = s - itemRows + 1;
2450
+ t = Math.min(Math.max(0, t), last);
2451
+ return { windowed: true, top: t, from: t, to: t + itemRows, above: t, below: n - (t + itemRows) };
2452
+ }
2453
+
2454
+ /**
2455
+ * THE LINE THAT MEANS "the reader pressed Esc instead of answering".
2456
+ *
2457
+ * A NUL-prefixed sentinel for the same reason the attach sentinel is one: nothing that can be
2458
+ * typed, pasted or recalled from history produces a NUL, so it can never collide with a real
2459
+ * answer to a real question. Exported so the REPL and its tests agree on the value rather than
2460
+ * each spelling it out.
2461
+ */
2462
+ export const CANCEL_LINE = "\u0000cancel";
2463
+
2464
+ /**
2465
+ * Should this keypress cancel the question that is waiting? (reported live)
2466
+ *
2467
+ * "it should allow user to escape ... also make sure that the user can cancel in any screen like
2468
+ * this" - the pickers have offered Esc since PICK_HINT was written; the TYPED prompts offered
2469
+ * nothing, except the one that said `type "no"`, which you had to already know.
2470
+ *
2471
+ * A FUNCTION RATHER THAN A CONDITION INLINE IN _ttyWrite, because three separate paths have to
2472
+ * reach the identical answer - the live keypress, the keys the CPR reader replays when they turn
2473
+ * out not to be a probe answer, and the ones its timer flushes 300ms later - and a rule written
2474
+ * three times is a rule that will differ in one of them. It is also the only part of this that
2475
+ * can be tested without a terminal.
2476
+ *
2477
+ * THE SEQUENCE IS THE TEST, NOT `meta` — and this cost a working feature to learn. Node sets
2478
+ * `meta: true` on a LONE Esc (it is how its parser marks "this began with ESC"), so the obvious
2479
+ * rule, `!key.ctrl && !key.meta`, rejects the one key the whole feature exists to catch. Written
2480
+ * that way it passed a unit test built from a hand-made `{name:"escape"}` object and would have
2481
+ * done nothing whatsoever in a terminal. MEASURED against a real pty, node emits:
2482
+ *
2483
+ * lone Esc name=escape sequence="\u001b" meta=true
2484
+ * alt+Esc name=escape sequence="\u001b\u001b" meta=true
2485
+ * up arrow name=up sequence="\u001b[A" meta=false
2486
+ * alt+a name=a sequence="\u001ba" meta=true
2487
+ * Home name=home sequence="\u001b[H" meta=false
2488
+ * F1 name=f1 sequence="\u001bOP" meta=false
2489
+ * CPR reply name=undefined meta=false
2490
+ *
2491
+ * So: `escape` names the lone key and alt+Esc alike, `meta` cannot separate them, and only the
2492
+ * SEQUENCE — exactly one ESC byte and nothing else — identifies the key a person pressed.
2493
+ *
2494
+ * WHAT IS DELIBERATELY NOT A CANCEL:
2495
+ * - anything at all while no question is waiting. The main ">> " prompt parks on the same
2496
+ * queue, and Esc there has always meant nothing; making it throw would abort a turn.
2497
+ * - alt+Esc and ctrl+Esc. A terminal may send those for something else, and a cancel should be
2498
+ * the key the reader meant rather than one they passed through.
2499
+ * - every other key. The arrows, Home/End and the function keys all BEGIN with ESC but reach
2500
+ * readline already parsed into named keys, so none of them can be mistaken for this one.
2501
+ */
2502
+ export function shouldCancelPrompt(awaiting, key) {
2503
+ if (!awaiting) return false;
2504
+ if (key?.name !== "escape") return false;
2505
+ if (key.ctrl) return false;
2506
+ return key.sequence === "\u001b";
2507
+ }