@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
package/rag-selftest.mjs
ADDED
|
@@ -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
|
+
}
|