@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.
- package/README.md +52 -0
- package/agent-model-command.mjs +259 -0
- package/agent-model-label.mjs +159 -0
- package/clear-state.mjs +149 -0
- package/client-expert-api.mjs +736 -0
- package/client-expert-run.mjs +892 -0
- package/client-expert-setup.mjs +616 -0
- package/coding-choice-tags.mjs +69 -0
- package/coding-key-prompt.mjs +229 -0
- package/coding-provider-setup.mjs +808 -0
- package/completed-flush.mjs +105 -0
- package/daemon-control.mjs +462 -0
- package/device-login.mjs +212 -0
- package/doctor-check.mjs +239 -0
- package/embed-model-command.mjs +157 -0
- package/first-run-steps.mjs +171 -0
- package/gonext_agent_chat.py +12299 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/gu-cli.mjs +4930 -0
- package/gu-repl.mjs +10326 -0
- package/job-pools.mjs +89 -0
- package/model-doctor.mjs +1494 -0
- package/node-version.mjs +40 -0
- package/ollama-setup.mjs +832 -0
- package/package.json +100 -0
- package/platform-tools.mjs +520 -0
- package/poll-errors.mjs +141 -0
- package/proxy-command.mjs +165 -0
- package/proxy-config.mjs +255 -0
- package/proxy-dispatcher.mjs +132 -0
- package/proxy-selftest.mjs +234 -0
- package/proxy-store.mjs +69 -0
- package/rag-job-config.mjs +59 -0
- package/rag-selftest.mjs +215 -0
- package/s3-setup.mjs +85 -0
- package/terminal-copy.mjs +248 -0
- package/terminal-hover.mjs +153 -0
- package/terminal-layout.mjs +2507 -0
- package/terminal-viewport.mjs +602 -0
- package/thinking_words.txt +1003 -0
- 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
|
+
}
|