@tiens.nguyen/gu-cli 1.0.686

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +52 -0
  2. package/agent-model-command.mjs +259 -0
  3. package/agent-model-label.mjs +159 -0
  4. package/clear-state.mjs +149 -0
  5. package/client-expert-api.mjs +736 -0
  6. package/client-expert-run.mjs +892 -0
  7. package/client-expert-setup.mjs +616 -0
  8. package/coding-choice-tags.mjs +69 -0
  9. package/coding-key-prompt.mjs +229 -0
  10. package/coding-provider-setup.mjs +808 -0
  11. package/completed-flush.mjs +105 -0
  12. package/daemon-control.mjs +462 -0
  13. package/device-login.mjs +212 -0
  14. package/doctor-check.mjs +239 -0
  15. package/embed-model-command.mjs +157 -0
  16. package/first-run-steps.mjs +171 -0
  17. package/gonext_agent_chat.py +12299 -0
  18. package/gonext_mlx_embed.py +155 -0
  19. package/gonext_probe_agent.py +93 -0
  20. package/gonext_transcribe.py +130 -0
  21. package/gu-cli.mjs +4930 -0
  22. package/gu-repl.mjs +10326 -0
  23. package/job-pools.mjs +89 -0
  24. package/model-doctor.mjs +1494 -0
  25. package/node-version.mjs +40 -0
  26. package/ollama-setup.mjs +832 -0
  27. package/package.json +100 -0
  28. package/platform-tools.mjs +520 -0
  29. package/poll-errors.mjs +141 -0
  30. package/proxy-command.mjs +165 -0
  31. package/proxy-config.mjs +255 -0
  32. package/proxy-dispatcher.mjs +132 -0
  33. package/proxy-selftest.mjs +234 -0
  34. package/proxy-store.mjs +69 -0
  35. package/rag-job-config.mjs +59 -0
  36. package/rag-selftest.mjs +215 -0
  37. package/s3-setup.mjs +85 -0
  38. package/terminal-copy.mjs +248 -0
  39. package/terminal-hover.mjs +153 -0
  40. package/terminal-layout.mjs +2507 -0
  41. package/terminal-viewport.mjs +602 -0
  42. package/thinking_words.txt +1003 -0
  43. package/version-check.mjs +72 -0
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Can THIS machine embed and retrieve? (`gu-cli rag --test`)
3
+ *
4
+ * Ships with the package so it runs anywhere gu is installed — Windows, macOS or Linux —
5
+ * without copying files about. There is nothing platform-specific in it: one Node module beats
6
+ * three per-platform archives that have to be kept in step.
7
+ *
8
+ * It asks the question that kept having a different answer in practice: can this machine reach
9
+ * an embedder, and can that embedder tell one part of a real project from another? Both halves
10
+ * have failed — the embed URL pointed at an Apple-only MLX port that was never running, and
11
+ * before that the API withheld RAG entirely unless S3 credentials existed. Neither was visible
12
+ * from any status output; both are visible here.
13
+ *
14
+ * The configuration comes from THIS machine (worker.env → the API's settings), never from
15
+ * defaults invented here: a check that supplies its own embedder passes happily on a machine
16
+ * where the agent cannot embed at all.
17
+ */
18
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
19
+ import { tmpdir, homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ /** One needle, many haystack files. The needle is what a semantic search must find. */
23
+ export const NEEDLE_FILE = "invoices.js";
24
+ export const NEEDLE_QUERY = "where are invoice totals computed";
25
+
26
+ /**
27
+ * The needle deliberately never contains the query's words in a greppable form: the file says
28
+ * "computeInvoiceTotal" and "subtotal", the query asks "where are invoice totals computed".
29
+ * A search that finds this by substring is not doing what RAG is for.
30
+ */
31
+ const NEEDLE = `
32
+ // Billing rules for customer orders.
33
+ function computeInvoiceTotal(items, taxRate) {
34
+ const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
35
+ const shipping = subtotal > 100 ? 0 : 9.99;
36
+ return (subtotal + shipping) * (1 + taxRate);
37
+ }
38
+
39
+ function applyDiscountCode(code, amount) {
40
+ const table = { WELCOME10: 0.1, LOYAL20: 0.2 };
41
+ return amount * (1 - (table[code] ?? 0));
42
+ }
43
+ module.exports = { computeInvoiceTotal, applyDiscountCode };
44
+ `.trim();
45
+
46
+ /** Filler that is plausible code about something else entirely. */
47
+ function haystackFile(n) {
48
+ const lines = [`// Service module ${n}: request handling for the platform's internal API.`];
49
+ for (let i = 1; i <= 40; i++) {
50
+ lines.push(
51
+ `// helper ${i}: validates the incoming payload, maps upstream errors and records timing`,
52
+ `function svc${n}Handler${i}(request, context) {`,
53
+ ` const parsed = validateSchema(request.body, schemas.v${i});`,
54
+ ` if (!parsed.ok) return respond(400, { error: parsed.reason });`,
55
+ ` return withRetries(() => upstream.call(parsed.value, { timeout: 5000 }), 3);`,
56
+ `}`,
57
+ ""
58
+ );
59
+ }
60
+ return lines.join("\n");
61
+ }
62
+
63
+ /**
64
+ * Write the fixture into `dir`. Returns what was written, so a caller can report the size it
65
+ * actually produced rather than a number someone wrote in a comment once.
66
+ */
67
+ export async function writeRagFixture(dir, { files = 12 } = {}) {
68
+ const { mkdir, writeFile } = await import("node:fs/promises");
69
+ const { join } = await import("node:path");
70
+ await mkdir(dir, { recursive: true });
71
+
72
+ const written = [];
73
+ for (let n = 1; n <= files; n++) {
74
+ const name = `service${n}.js`;
75
+ const body = haystackFile(n);
76
+ await writeFile(join(dir, name), body);
77
+ written.push({ name, bytes: Buffer.byteLength(body) });
78
+ }
79
+ await writeFile(join(dir, NEEDLE_FILE), NEEDLE);
80
+ written.push({ name: NEEDLE_FILE, bytes: Buffer.byteLength(NEEDLE) });
81
+
82
+ return { dir, files: written, totalBytes: written.reduce((s, f) => s + f.bytes, 0) };
83
+ }
84
+
85
+ /**
86
+ * Split text the way the indexer does — roughly, and that is fine. This fixture exists to ask
87
+ * "can the embedder tell these documents apart", not to re-implement the chunker.
88
+ */
89
+ export function chunk(text, size = 1200) {
90
+ const out = [];
91
+ for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
92
+ return out;
93
+ }
94
+
95
+ /** Cosine similarity. The whole of retrieval, once the vectors exist. */
96
+ export function cosine(a, b) {
97
+ let dot = 0;
98
+ let na = 0;
99
+ let nb = 0;
100
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
101
+ dot += a[i] * b[i];
102
+ na += a[i] * a[i];
103
+ nb += b[i] * b[i];
104
+ }
105
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
106
+ }
107
+
108
+ /** Read the API base + worker key exactly as the CLI does. */
109
+ async function workerEnv() {
110
+ const txt = await readFile(join(homedir(), ".gonext", "worker.env"), "utf8").catch(() => "");
111
+ const get = (k) => (new RegExp(`^${k}=(.*)$`, "m").exec(txt)?.[1] ?? "").trim();
112
+ return { apiBase: get("GONEXT_API_BASE").replace(/\/+$/, ""), key: get("GONEXT_WORKER_KEY") };
113
+ }
114
+
115
+ /**
116
+ * Run the checks, reporting each through `onStep`.
117
+ * Returns { ok, checks } — the caller decides how to print and what exit code to use.
118
+ */
119
+ export async function runRagSelfTest({ onStep = () => {} } = {}) {
120
+ const checks = [];
121
+ const step = async (title, fn) => {
122
+ try {
123
+ const detail = await fn();
124
+ checks.push({ title, ok: true, detail: detail ?? "" });
125
+ onStep({ title, ok: true, detail: detail ?? "" });
126
+ } catch (e) {
127
+ const detail = String(e?.message ?? e).split("\n")[0];
128
+ checks.push({ title, ok: false, detail });
129
+ onStep({ title, ok: false, detail });
130
+ }
131
+ };
132
+
133
+ let embedUrl = "";
134
+ let embedModel = "";
135
+
136
+ await step("read the embedder from this machine's settings", async () => {
137
+ const { apiBase, key } = await workerEnv();
138
+ if (!apiBase || !key) throw new Error("no ~/.gonext/worker.env — run `gu` first");
139
+ const res = await fetch(`${apiBase}/api/worker/settings`, {
140
+ headers: { "X-Worker-Key": key },
141
+ signal: AbortSignal.timeout(10_000),
142
+ });
143
+ if (!res.ok) throw new Error(`settings → HTTP ${res.status} (is the API running?)`);
144
+ const s = await res.json();
145
+ // The stored URL may or may not carry /v1 — the worker normalises it, so this must too.
146
+ // Without that the request goes to <host>/embeddings and 404s, which reads as "the
147
+ // embedder is down" when it is simply a different path.
148
+ const raw = String(s.ragEmbedUrl || "").replace(/\/+$/, "");
149
+ embedUrl = raw && !/\/v1$/i.test(raw) ? `${raw}/v1` : raw;
150
+ embedModel = String(s.ragEmbedModel || "");
151
+ if (!embedUrl) throw new Error("no RAG embed URL configured for this account");
152
+ return `${embedModel || "(no model name)"} @ ${embedUrl}`;
153
+ });
154
+
155
+ const embed = async (input) => {
156
+ const res = await fetch(`${embedUrl}/embeddings`, {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json" },
159
+ body: JSON.stringify({ model: embedModel, input }),
160
+ signal: AbortSignal.timeout(120_000),
161
+ });
162
+ if (!res.ok) throw new Error(`embeddings → HTTP ${res.status} ${(await res.text()).slice(0, 100)}`);
163
+ const body = await res.json();
164
+ const vectors = (body.data ?? []).map((d) => d.embedding);
165
+ if (!vectors.length || !vectors[0]?.length) throw new Error("no vectors came back");
166
+ return vectors;
167
+ };
168
+
169
+ if (embedUrl) {
170
+ await step("the embedder answers", async () => {
171
+ const t0 = Date.now();
172
+ const [v] = await embed(["a short piece of text"]);
173
+ return `${v.length}-dim in ${Date.now() - t0}ms`;
174
+ });
175
+ }
176
+
177
+ let dir = "";
178
+ let fixture = null;
179
+ await step("build a project big enough to be worth indexing", async () => {
180
+ dir = await mkdtemp(join(tmpdir(), "gu-rag-"));
181
+ fixture = await writeRagFixture(dir);
182
+ return `${fixture.files.length} files, ${Math.round(fixture.totalBytes / 1024)} KB`;
183
+ });
184
+
185
+ if (embedUrl && fixture) {
186
+ await step("the needle ranks first for a question that does not name it", async () => {
187
+ const docs = [];
188
+ for (const f of fixture.files) {
189
+ const text = await readFile(join(dir, f.name), "utf8");
190
+ for (const c of chunk(text)) docs.push({ file: f.name, text: c });
191
+ }
192
+ const vectors = [];
193
+ for (let i = 0; i < docs.length; i += 40) {
194
+ vectors.push(...(await embed(docs.slice(i, i + 40).map((d) => d.text))));
195
+ }
196
+ const [q] = await embed([NEEDLE_QUERY]);
197
+ const ranked = docs
198
+ .map((d, i) => ({ file: d.file, score: cosine(q, vectors[i]) }))
199
+ .sort((a, b) => b.score - a.score);
200
+ const top = ranked[0];
201
+ const runnerUp = ranked.find((r) => r.file !== top.file);
202
+ if (top.file !== NEEDLE_FILE) {
203
+ throw new Error(
204
+ `top hit was ${top.file} (${top.score.toFixed(3)}), expected ${NEEDLE_FILE} — ` +
205
+ "the embedder is reachable but is not separating this corpus"
206
+ );
207
+ }
208
+ const margin = (top.score - (runnerUp?.score ?? 0)).toFixed(3);
209
+ return `${docs.length} chunks · ${NEEDLE_FILE} first (${top.score.toFixed(3)}, +${margin} over the rest)`;
210
+ });
211
+ }
212
+
213
+ if (dir) await rm(dir, { recursive: true, force: true }).catch(() => {});
214
+ return { ok: checks.every((c) => c.ok), checks };
215
+ }
package/s3-setup.mjs ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Pointing a machine at its own RAG S3 bucket from the TERMINAL (`/s3`).
3
+ *
4
+ * The web app has had this since RAG existed — Settings → RAG, with a "Test access" button. A
5
+ * Client Expert machine cannot reach any of it: PATCH /api/settings is behind a Firebase login
6
+ * that box deliberately does not have, so the one place these values could be set was the one
7
+ * place that machine cannot open. `/s3` is the same three fields and the same test, asked for
8
+ * over a worker key.
9
+ *
10
+ * PURE ON PURPOSE, like ollama-setup.mjs beside it: every decision here is a function from
11
+ * values to strings, so "what does this config look like / what did the test say" is a test
12
+ * rather than something you find out by pointing a REPL at a real bucket.
13
+ *
14
+ * WHAT IS NOT HERE, deliberately: parsing the S3 location into bucket + prefix. The API already
15
+ * does that (parseRagLocation) and it is the thing that decides whether a location is valid at
16
+ * all. A second copy here would be a second opinion — and the one the user sees would be the
17
+ * one that is not used to actually reach the bucket.
18
+ */
19
+
20
+ /** An access key id, shown without showing it: "AKIA…MPLE". "" stays "". */
21
+ export function maskKeyId(id) {
22
+ const s = String(id ?? "").trim();
23
+ if (!s) return "";
24
+ // Short enough that a mask would reveal most of it anyway → say nothing about the middle.
25
+ if (s.length <= 8) return "…";
26
+ return `${s.slice(0, 4)}…${s.slice(-4)}`;
27
+ }
28
+
29
+ /**
30
+ * The current config, as lines for the user to read before deciding to change it.
31
+ *
32
+ * The SECRET is reported as stored-or-not and never shown; `secretConfigured` is the boolean the
33
+ * API returns in place of it. An unset field says "not set" rather than printing an empty value,
34
+ * because a blank after a label reads as a rendering bug rather than as an answer.
35
+ */
36
+ export function s3SummaryRows({
37
+ location = "",
38
+ region = "",
39
+ accessKeyId = "",
40
+ secretConfigured = false,
41
+ } = {}) {
42
+ const val = (v) => (String(v ?? "").trim() ? String(v).trim() : "not set");
43
+ return [
44
+ `bucket ${val(location)}`,
45
+ `region ${val(region)}`,
46
+ `key id ${accessKeyId ? maskKeyId(accessKeyId) : "not set"}`,
47
+ `secret ${secretConfigured ? "stored" : "not set"}`,
48
+ ];
49
+ }
50
+
51
+ /** Is there enough here to be worth testing? → "" when yes, else what is missing. */
52
+ export function whatIsMissing({ location = "", accessKeyId = "", secretConfigured = false } = {}) {
53
+ const missing = [];
54
+ if (!String(location ?? "").trim()) missing.push("a bucket");
55
+ if (!String(accessKeyId ?? "").trim()) missing.push("an access key id");
56
+ if (!secretConfigured) missing.push("a secret");
57
+ return missing.length ? missing.join(", ") : "";
58
+ }
59
+
60
+ /**
61
+ * The test result, as lines. → { rows, ok }
62
+ *
63
+ * READ AND WRITE ARE REPORTED SEPARATELY because they fail separately and for different
64
+ * reasons: a bucket policy that allows ListObjects and denies PutObject is a normal, common
65
+ * misconfiguration, and "S3 access failed" would send someone to check their keys when the keys
66
+ * are fine. The API probes both by actually doing them, so these are observations, not guesses.
67
+ *
68
+ * The error is printed VERBATIM. It is AWS's own sentence ("Access Denied", "The specified
69
+ * bucket does not exist"), and paraphrasing it would cost the one detail that identifies which
70
+ * of a dozen S3 misconfigurations this is.
71
+ */
72
+ export function describeS3Test(result) {
73
+ if (!result) {
74
+ return { ok: false, rows: ["could not reach the API to run the test."] };
75
+ }
76
+ const rows = [];
77
+ const where = result.bucket
78
+ ? `${result.bucket}${result.prefix ? `/${result.prefix}` : ""}`
79
+ : "";
80
+ if (where) rows.push(`bucket ${where}`);
81
+ rows.push(`read ${result.canRead ? "ok" : "no"}`);
82
+ rows.push(`write ${result.canWrite ? "ok" : "no"}`);
83
+ if (result.error) rows.push(`error ${result.error}`);
84
+ return { ok: Boolean(result.ok), rows };
85
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Copying text out of the terminal (task #158).
3
+ *
4
+ * The problem this solves: gu renders its own view, so what is ON SCREEN is not the text
5
+ * anyone wants. An answer is hard-wrapped to the window width, indented into the gutter, and
6
+ * coloured — so a native drag-select yields hard newlines mid-sentence, three leading spaces
7
+ * per line, and escape codes. Pasting a code block that way does not run.
8
+ *
9
+ * The answer is to copy the SOURCE, not the screen: printed blocks register the text they came
10
+ * from, and a double-click resolves a screen row back to that. Cleaning rendered lines is the
11
+ * fallback for output that never registered anything.
12
+ *
13
+ * Everything here is pure. The IO — the clipboard write, the overlay paint — lives in the REPL
14
+ * where the terminal is.
15
+ */
16
+
17
+ const CSI = /\x1b\[[0-9;?]*[A-Za-z]/g;
18
+ const OSC = /\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g;
19
+
20
+ /** Strip every escape sequence, leaving the text a person sees. */
21
+ export function stripAnsi(s) {
22
+ return String(s ?? "").replace(OSC, "").replace(CSI, "");
23
+ }
24
+
25
+ /**
26
+ * The gutter and margin markers are CHROME, not content.
27
+ *
28
+ * Every printed row is indented to one shared text column, and bullets hang their glyph in
29
+ * that margin (`markLine`). Copying those means every pasted line starts with three spaces and
30
+ * some start with "● " — which breaks indentation-sensitive languages outright.
31
+ */
32
+ export function stripGutter(line, gutter = 3) {
33
+ const s = stripAnsi(line);
34
+ if (!s.trim()) return "";
35
+ // A hanging marker: glyph right-aligned in the gutter, then one space. Take the text column.
36
+ const marked = new RegExp(`^\\s{0,${Math.max(0, gutter - 2)}}[●⏺✓✗↻·>»]{1,2}\\s`).exec(s);
37
+ if (marked) return s.slice(marked[0].length);
38
+ // The ">>" prompt echo hangs the same way.
39
+ const prompt = /^\s*>>\s?/.exec(s);
40
+ if (prompt) return s.slice(prompt[0].length);
41
+ return s.startsWith(" ".repeat(gutter)) ? s.slice(gutter) : s.replace(/^\s+/, "");
42
+ }
43
+
44
+ /**
45
+ * Rendered rows → text worth pasting.
46
+ *
47
+ * A LAST RESORT. It cannot undo the hard wrap — `renderAnswer` split the paragraph into
48
+ * separate lines before they were ever recorded, and nothing in the rendered text says which
49
+ * newlines were the author's. That is exactly why printed blocks register their source; this
50
+ * runs only for output that did not.
51
+ */
52
+ export function cleanRenderedLines(lines) {
53
+ return lines
54
+ .map((l) => stripGutter(l))
55
+ .join("\n")
56
+ .replace(/[ \t]+$/gm, "")
57
+ .replace(/\n{3,}/g, "\n\n")
58
+ .trim();
59
+ }
60
+
61
+ /**
62
+ * Was this the second click of a double-click?
63
+ *
64
+ * Terminals send no double-click event, so it is two presses close in time AND place. The cell
65
+ * must match: two clicks on different paragraphs are two single clicks, however fast.
66
+ */
67
+ export function isDoubleClick(prev, next, maxMs = 400) {
68
+ if (!prev || !next) return false;
69
+ if (prev.row !== next.row) return false;
70
+ // A little horizontal slack — a hand moves a column between presses on a trackpad.
71
+ if (Math.abs((prev.col ?? 0) - (next.col ?? 0)) > 2) return false;
72
+ return next.at - prev.at <= maxMs && next.at >= prev.at;
73
+ }
74
+
75
+ /**
76
+ * How many clicks in a row landed on the same spot? → 1, 2 or 3.
77
+ *
78
+ * `prev` is the RUN so far ({ row, col, at, count }), not just the last click, because a triple
79
+ * is three clicks in sequence rather than "a double, then another double" — the second click is
80
+ * within 400ms of the first and the third within 400ms of the second, and comparing only pairs
81
+ * would either miss the third or count it as the start of a new double.
82
+ *
83
+ * Caps at 3: macOS has no fourth gesture here, and a fourth click restarting the run means a
84
+ * fast repeated click cycles word → line → word rather than doing nothing.
85
+ */
86
+ export function clickRun(prev, next, maxMs = 400) {
87
+ if (!isDoubleClick(prev, next, maxMs)) return 1;
88
+ const count = (prev?.count ?? 1) + 1;
89
+ return count > 3 ? 1 : count;
90
+ }
91
+
92
+ /**
93
+ * The whole typed line, as a selection range over `text`. → { from, to } or null.
94
+ *
95
+ * WHAT "THE LINE" MEANS HERE. The input line WRAPS (#179), so the text a user sees can occupy
96
+ * several physical rows — and the gesture has to mean the whole LOGICAL line, all of `rl.line`.
97
+ * Selecting the clicked row would hand back a fragment of a long question, which is precisely
98
+ * the case someone reaches for this gesture to avoid.
99
+ *
100
+ * Trailing whitespace is left out: it is invisible, so including it makes the highlight run
101
+ * past the last character for no reason a user can see, and a copy then carries padding.
102
+ * Leading whitespace is KEPT — it is inside the text they typed, and deleting the selection
103
+ * should leave an empty line rather than a stub of spaces.
104
+ */
105
+ export function lineRangeAt(text) {
106
+ const s = String(text ?? "");
107
+ const to = s.replace(/\s+$/, "").length;
108
+ return to > 0 ? { from: 0, to } : null;
109
+ }
110
+
111
+ /**
112
+ * What a keypress does to a live selection on the input line: "delete" | "replace" | "dismiss".
113
+ *
114
+ * Every key ends the selection — the only question is whether it takes the text with it. This
115
+ * is the decision most likely to be wrong by one case, which is why it is out here rather than
116
+ * inline in the readline hook: Enter, Tab, Escape and every arrow arrive as strings too, and
117
+ * treating any of them as typed text would silently eat the line.
118
+ *
119
+ * The printable test is on the STRING, not the key name: a control key's payload always lands
120
+ * in \x00-\x1f or \x7f (Enter is \r, Tab is \t, an arrow is a whole ESC [ D), while anything a
121
+ * person meant to type does not — including accented letters and emoji, which no name-based
122
+ * test gets right.
123
+ */
124
+ export function selectionKeyAction(str, key) {
125
+ if (key?.name === "backspace" || key?.name === "delete") return "delete";
126
+ if (key?.ctrl || key?.meta) return "dismiss"; // Ctrl+U and friends do their own thing
127
+ return typeof str === "string" && /^[^\x00-\x1f\x7f]+$/.test(str) ? "replace" : "dismiss";
128
+ }
129
+
130
+ /** How the copy is reported. Lines matter as much as characters for a block of code. */
131
+ export function copyBadge({ chars, lines, ok = true, why = "" }) {
132
+ if (!ok) return why ? `copy failed — ${why}` : "copy failed";
133
+ const n = Number(chars) || 0;
134
+ const l = Number(lines) || 0;
135
+ return l > 1 ? `${n} chars · ${l} lines copied` : `${n} chars copied`;
136
+ }
137
+
138
+ /**
139
+ * Where the badge goes on its row, or null when it does not fit.
140
+ *
141
+ * Right-aligned, and it must NEVER overwrite the row's own text — the feedback would then
142
+ * destroy what it is confirming. When the line reaches too far right the caller falls back to
143
+ * the bottom bar. Clipped, never wrapped: a wrapped overlay row breaks the cursor arithmetic
144
+ * that positions it (ESC[nA counts PHYSICAL rows — the #109 lesson).
145
+ */
146
+ export function badgePlacement({ rowText, badge, width, gap = 2 }) {
147
+ const w = Math.max(1, Number(width) || 80);
148
+ const text = stripAnsi(rowText ?? "");
149
+ const b = String(badge ?? "");
150
+ if (!b) return null;
151
+ // 1-based column where the badge would start.
152
+ const col = w - b.length + 1;
153
+ if (col < 1) return null; // wider than the window
154
+ if (text.length + gap >= col) return null; // would land on the row's own text
155
+ return { col, text: b };
156
+ }
157
+
158
+ /**
159
+ * How to put text on the clipboard on this platform.
160
+ *
161
+ * `wl-copy` before `xclip`: Wayland is the default on current Ubuntu, and xclip on a Wayland
162
+ * session either fails or writes a clipboard nothing reads.
163
+ */
164
+ export function clipboardCommands(platform = process.platform) {
165
+ if (platform === "darwin") return [{ cmd: "pbcopy", args: [] }];
166
+ if (platform === "win32") return [{ cmd: "clip.exe", args: [] }];
167
+ return [
168
+ { cmd: "wl-copy", args: [] },
169
+ { cmd: "xclip", args: ["-selection", "clipboard"] },
170
+ { cmd: "xsel", args: ["--clipboard", "--input"] },
171
+ ];
172
+ }
173
+
174
+ /**
175
+ * The OSC 52 sequence that asks the TERMINAL to set its clipboard.
176
+ *
177
+ * This is the one that works over SSH. `pbcopy` on a remote box writes the clipboard of the
178
+ * machine nobody is sitting at — which looks exactly like success. Not every terminal permits
179
+ * it (and there is no reply to check), so it is sent ALONGSIDE the local tool rather than
180
+ * instead of it.
181
+ */
182
+ export function osc52(text) {
183
+ const b64 = Buffer.from(String(text ?? ""), "utf8").toString("base64");
184
+ return `\x1b]52;c;${b64}\x07`;
185
+ }
186
+
187
+ /**
188
+ * Is this text small enough for OSC 52?
189
+ *
190
+ * Terminals cap the sequence (commonly ~100KB, xterm's default is far lower). An oversized
191
+ * write is silently dropped by some and prints garbage into the session on others, so it is
192
+ * better not to send one at all.
193
+ */
194
+ export function osc52Fits(text, limit = 74994) {
195
+ return Buffer.byteLength(String(text ?? ""), "utf8") <= limit;
196
+ }
197
+
198
+ /**
199
+ * Find the registered block containing an absolute transcript line.
200
+ *
201
+ * Newest first: a range can be re-registered when a block is expanded in place, and the most
202
+ * recent record is the true one.
203
+ */
204
+ export function blockAt(blocks, abs) {
205
+ for (let i = blocks.length - 1; i >= 0; i--) {
206
+ const b = blocks[i];
207
+ if (abs >= b.from && abs <= b.to) return b;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * The text a copy should place on the clipboard, given the block (if any) and the rendered
214
+ * lines that were double-clicked.
215
+ *
216
+ * Prefers the block's SOURCE — that is the whole point — and falls back to cleaning what is on
217
+ * screen for output that registered nothing.
218
+ */
219
+ export function textToCopy({ block, renderedLines }) {
220
+ if (block && typeof block.text === "string" && block.text.trim()) {
221
+ return block.text.replace(/\s+$/, "");
222
+ }
223
+ return cleanRenderedLines(renderedLines ?? []);
224
+ }
225
+
226
+ /** What the badge reports about a piece of text. */
227
+ export function measure(text) {
228
+ const s = String(text ?? "");
229
+ return { chars: s.length, lines: s ? s.split("\n").length : 0 };
230
+ }
231
+
232
+ /**
233
+ * Where a block just written landed, in ABSOLUTE transcript indices.
234
+ *
235
+ * Absolute because the buffer trims from the front, so a stored position has to survive that.
236
+ * Measured AFTER the write: the final transcript entry is the still-open line, so the last
237
+ * COMPLETED line is `length - 2`.
238
+ *
239
+ * `trailingBlanks` is the trap. `console.log(x + "\n")` emits TWO newlines — the string's and
240
+ * console.log's own — so a block printed that way is followed by a blank line, and the naive
241
+ * `length - 2` points at the blank rather than the block's last row. The range then covers the
242
+ * blank plus all but the final row: a double-click near the end of an answer silently copies
243
+ * the wrong lines, and nothing about the output looks wrong.
244
+ */
245
+ export function registeredRange({ dropped, length, rowCount, trailingBlanks = 0 }) {
246
+ const endAbs = dropped + length - 2 - Math.max(0, trailingBlanks);
247
+ return { from: endAbs - rowCount + 1, to: endAbs };
248
+ }