codsh-cli 0.2.0 → 0.3.0

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/lib/index.js DELETED
@@ -1,4911 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
- import { basename, dirname, join, parse } from "node:path";
5
- import z from "@deepseek-ai/schemastery";
6
- import { installModelSelection } from "@deepseek-ai/dsh-agent";
7
- import { createUserMessage } from "@deepseek-ai/dsh-llm";
8
- import { SessionId } from "@deepseek-ai/dsh-session";
9
- import { homedir } from "node:os";
10
- import stringWidth from "string-width";
11
- import { readdirSync } from "node:fs";
12
- import { createInterface } from "node:readline";
13
- import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
14
- import { fileURLToPath } from "node:url";
15
- import { structuredPatch } from "diff";
16
-
17
- //#region src/approval.ts
18
- /**
19
- * Approval state for one terminal session.
20
- *
21
- * The remembered set is per-process and never written to disk. An
22
- * {@link ApprovalRequest} carries the tool name, reason, and call id but NOT
23
- * the call arguments, so a grant cannot be narrowed to "this command" — it
24
- * covers every later call to that tool. Persisting a grant that broad across
25
- * runs would outlive the intent that produced it.
26
- */
27
- var TerminalApproval = class {
28
- allowed = /* @__PURE__ */ new Set();
29
- constructor(prompt, theme, write) {
30
- this.prompt = prompt;
31
- this.theme = theme;
32
- this.write = write;
33
- }
34
- /** Tool names granted for the rest of this process, in grant order. */
35
- get remembered() {
36
- return [...this.allowed];
37
- }
38
- /** Forget every remembered grant, so the next call of each tool asks again. */
39
- clear() {
40
- this.allowed.clear();
41
- }
42
- /**
43
- * Decide one request, asking the keyboard unless the tool is already granted.
44
- * @param req - the pending decision.
45
- * @returns the outcome for this request.
46
- */
47
- async decide(req) {
48
- if (this.allowed.has(req.toolName)) return "allowed-once";
49
- const answer = await this.prompt.ask(req.toolName, req.reason, req.signal);
50
- if (answer === void 0) return "cancelled";
51
- if (answer === "reject") {
52
- this.write(this.theme.error(` ✗ denied ${req.toolName}`));
53
- return "rejected";
54
- }
55
- if (answer === "always") {
56
- this.allowed.add(req.toolName);
57
- this.write(this.theme.dim(` ✓ allowing every ${req.toolName} call for the rest of this session`));
58
- }
59
- return "allowed-once";
60
- }
61
- };
62
- /**
63
- * Map one keystroke to an approval answer.
64
- * @param key - the character typed at the prompt.
65
- * @returns the answer, or undefined when the key means nothing here.
66
- */
67
- function answerForKey(key) {
68
- const normalized = key.trim().toLowerCase();
69
- if (normalized === "y" || normalized === "") return "once";
70
- if (normalized === "a") return "always";
71
- if (normalized === "n") return "reject";
72
- }
73
-
74
- //#endregion
75
- //#region src/theme.ts
76
- /** SGR codes applied by {@link Theme}, by role. */
77
- const SGR$1 = {
78
- reset: "\x1B[0m",
79
- dim: "\x1B[2m",
80
- bold: "\x1B[1m",
81
- red: "\x1B[31m",
82
- green: "\x1B[32m",
83
- yellow: "\x1B[33m",
84
- blue: "\x1B[34m",
85
- magenta: "\x1B[35m",
86
- cyan: "\x1B[36m"
87
- };
88
- /** A theme that emits no sequences, used off a TTY and under `NO_COLOR`. */
89
- const PLAIN = {
90
- colored: false,
91
- dim: (text) => text,
92
- bold: (text) => text,
93
- error: (text) => text,
94
- success: (text) => text,
95
- pending: (text) => text,
96
- tool: (text) => text,
97
- path: (text) => text,
98
- user: (text) => text,
99
- syntax: {
100
- keyword: (text) => text,
101
- string: (text) => text,
102
- number: (text) => text,
103
- comment: (text) => text
104
- }
105
- };
106
- /**
107
- * Build the theme for one surface.
108
- *
109
- * Color is suppressed off a TTY and whenever `NO_COLOR` is set to any value,
110
- * following the `no-color.org` convention: a redirected transcript stays
111
- * greppable, and a pipe never receives sequences a reader would have to strip.
112
- *
113
- * Secondary text uses a palette gray on a 256-color terminal rather than the
114
- * `dim` attribute: several terminals render `dim` at full brightness, and a
115
- * hierarchy nobody can see is no hierarchy — the placeholder, the menu details,
116
- * and the status row must sit visibly behind what the person typed.
117
- * @param isTty - whether the output stream is a terminal.
118
- * @param env - the environment to read `NO_COLOR` and the color depth from.
119
- * @returns the styling functions for this surface.
120
- */
121
- function createTheme(isTty, env) {
122
- if (!isTty || env.NO_COLOR !== void 0) return PLAIN;
123
- const palette = env.TERM?.includes("256color") === true || env.COLORTERM !== void 0;
124
- const wrap = (code) => (text) => `${code}${text}${SGR$1.reset}`;
125
- return {
126
- colored: true,
127
- dim: wrap(palette ? "\x1B[38;5;245m" : SGR$1.dim),
128
- bold: wrap(SGR$1.bold),
129
- error: wrap(SGR$1.red),
130
- success: wrap(SGR$1.green),
131
- pending: wrap(SGR$1.yellow),
132
- tool: wrap(SGR$1.cyan),
133
- path: wrap(SGR$1.blue),
134
- user: wrap(SGR$1.magenta),
135
- syntax: {
136
- keyword: wrap(SGR$1.magenta),
137
- string: wrap(SGR$1.green),
138
- number: wrap(SGR$1.cyan),
139
- comment: wrap(SGR$1.dim)
140
- }
141
- };
142
- }
143
- /**
144
- * Display columns a string occupies once printed, ignoring styling sequences.
145
- *
146
- * Measured by `string-width` — the width authority cli-table3, ink, and every
147
- * maintained terminal renderer sit on — so East Asian Wide, emoji presentation
148
- * (`⚡` included), combining marks, and ZWJ sequences all match what a
149
- * terminal's cursor actually does. A hand-kept range table here mis-sized `⚡`
150
- * and sheared a real table's columns; widths are exactly the kind of data
151
- * nobody should maintain by hand.
152
- * @param text - the string to measure, possibly carrying SGR sequences.
153
- * @returns the number of display columns.
154
- */
155
- function displayWidth(text) {
156
- if (text === "") return 0;
157
- let ascii = true;
158
- for (let index = 0; index < text.length; index += 1) {
159
- const code = text.charCodeAt(index);
160
- if (code < 32 || code > 126) {
161
- ascii = false;
162
- break;
163
- }
164
- }
165
- if (ascii) return text.length;
166
- return stringWidth(text);
167
- }
168
- /** Matches one SGR sequence at the start of a string. */
169
- const SGR_AT_START = /^\u001B\[[0-9;]*m/u;
170
- /**
171
- * Shorten a string to at most `columns` display columns, marking the cut with
172
- * an ellipsis when anything was dropped.
173
- *
174
- * Styling survives: sequences cost no columns and travel with the text they
175
- * style, and a cut that kept any styling closes it with a reset before the
176
- * ellipsis so nothing leaks onto the next row. A string that already fits is
177
- * returned exactly as it came — a fit is not a licence to restyle it.
178
- * @param text - the string to shorten, possibly carrying SGR sequences.
179
- * @param columns - the display-column budget; a budget under 2 yields the empty string.
180
- * @returns the string, unchanged when it already fits.
181
- */
182
- function truncate(text, columns) {
183
- if (displayWidth(text) <= columns) return text;
184
- if (columns < 2) return "";
185
- let width = 0;
186
- let out = "";
187
- let styled = false;
188
- let at = 0;
189
- while (at < text.length) {
190
- const sequence = SGR_AT_START.exec(text.slice(at));
191
- if (sequence !== null) {
192
- out += sequence[0];
193
- styled = true;
194
- at += sequence[0].length;
195
- continue;
196
- }
197
- const code = text.codePointAt(at) ?? 0;
198
- const cell = String.fromCodePoint(code);
199
- const step = displayWidth(cell);
200
- if (width + step > columns - 1) break;
201
- width += step;
202
- out += cell;
203
- at += cell.length;
204
- }
205
- return `${out}${styled ? "\x1B[0m" : ""}…`;
206
- }
207
-
208
- //#endregion
209
- //#region src/status.ts
210
- /**
211
- * Abbreviate a token count the way a status line wants it: exact while small,
212
- * one decimal at thousands, whole at millions.
213
- * @param tokens - the count to render.
214
- * @returns the abbreviated figure.
215
- */
216
- function formatTokens(tokens) {
217
- if (tokens < 1e3) return String(tokens);
218
- if (tokens < 1e6) {
219
- const thousands = tokens / 1e3;
220
- return `${thousands < 10 ? thousands.toFixed(1) : Math.round(thousands)}k`;
221
- }
222
- return `${(tokens / 1e6).toFixed(1)}M`;
223
- }
224
- /**
225
- * Total tokens a session has spent across every bucket.
226
- *
227
- * The four buckets are disjoint by contract — reasoning already sits inside
228
- * output — so a plain sum is the session total.
229
- * @param usage - cumulative usage, or undefined before any request.
230
- * @returns the total, or undefined when nothing is recorded.
231
- */
232
- function totalTokens(usage) {
233
- if (usage === void 0) return void 0;
234
- return usage.uncachedInputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
235
- }
236
- /**
237
- * Percentage of the context window still free for the next request.
238
- *
239
- * `projectedTokens` is what the NEXT prompt would cost, which is the figure a
240
- * person deciding whether to keep going needs; it also moves the instant a
241
- * compaction shadows a span, where the raw sample cannot.
242
- * @param context - the occupancy projection.
243
- * @returns whole percent remaining, or undefined without both figures.
244
- */
245
- function contextLeftPercent(context) {
246
- const window = context?.contextWindow;
247
- const used = context?.projectedTokens ?? context?.pressureTokens;
248
- if (window === void 0 || used === void 0 || window <= 0) return void 0;
249
- return Math.max(0, Math.min(100, Math.round((1 - used / window) * 100)));
250
- }
251
- /**
252
- * Shorten a path for display, collapsing the home directory to `~`.
253
- * @param path - the absolute path.
254
- * @param home - the home directory to collapse; defaults to the real one.
255
- * @returns the display path.
256
- */
257
- function displayPath(path, home = homedir()) {
258
- return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
259
- }
260
- /**
261
- * Read the checked-out branch by walking up to the repository's `HEAD`.
262
- *
263
- * Read rather than shelled out: `git` may be absent, slow, or blocked by the
264
- * sandbox, and a status line must never be the reason a prompt stalls. A
265
- * detached head reports no branch rather than a bare revision, which would read
266
- * as a branch named after a hash.
267
- * @param cwd - directory to start from.
268
- * @returns the branch name, or undefined outside a repository or when detached.
269
- */
270
- async function gitBranch(cwd) {
271
- const root = parse(cwd).root;
272
- for (let dir = cwd;; dir = dirname(dir)) {
273
- const marker = join(dir, ".git");
274
- const pointer = await readFile(marker, "utf8").catch(() => void 0);
275
- const gitDir = pointer === void 0 ? marker : pointer.startsWith("gitdir:") ? pointer.slice(7).trim() : void 0;
276
- if (gitDir !== void 0) {
277
- const head = await readFile(join(gitDir, "HEAD"), "utf8").catch(() => void 0);
278
- const ref = head?.trim().match(/^ref: refs\/heads\/(.+)$/);
279
- if (ref?.[1] !== void 0) return ref[1];
280
- if (head !== void 0) return void 0;
281
- }
282
- if (dir === root) return void 0;
283
- }
284
- }
285
- /**
286
- * Render the status line.
287
- *
288
- * Segments that have nothing to report are dropped rather than shown empty, so
289
- * a fresh session reads as short rather than as broken.
290
- * @param facts - what to report.
291
- * @param theme - styling for the segments.
292
- * @param columns - display columns available; a longer line is cut, never wrapped.
293
- * @returns the line, unstyled when the theme is plain.
294
- */
295
- function statusLine(facts, theme, columns) {
296
- const left = contextLeftPercent(facts.context);
297
- const total = totalTokens(facts.usage);
298
- const headroom = left === void 0 ? [] : [left <= 10 ? theme.error(`${left}% context left`) : left <= 25 ? theme.pending(`${left}% context left`) : theme.dim(`${left}% context left`)];
299
- return truncate([
300
- theme.tool(facts.model),
301
- ...facts.preset === void 0 ? [] : [theme.dim(facts.preset)],
302
- ...facts.permission === void 0 ? [] : [theme.dim(facts.permission)],
303
- ...facts.planMode ? [theme.pending("plan")] : [],
304
- ...total === void 0 ? [] : [theme.dim(`${formatTokens(total)} tokens`)],
305
- ...headroom,
306
- theme.dim(facts.branch === void 0 ? displayPath(facts.cwd) : `${displayPath(facts.cwd)} (${facts.branch})`)
307
- ].join(theme.dim(" · ")), columns);
308
- }
309
- /**
310
- * Render the fuller readout `/status` answers with.
311
- *
312
- * The status line is a glance; this is the place a person looks when the glance
313
- * raised a question, so it names each usage bucket rather than one total.
314
- * @param facts - what to report.
315
- * @param session - the session identity, which `--resume` takes.
316
- * @returns the report, one `label: value` per line.
317
- */
318
- function statusReport(facts, session) {
319
- const left = contextLeftPercent(facts.context);
320
- const window = facts.context?.contextWindow;
321
- const next = facts.context?.projectedTokens ?? facts.context?.pressureTokens;
322
- const usage = facts.usage;
323
- const rows = [
324
- ["session", session],
325
- ["model", facts.model],
326
- ...facts.preset === void 0 ? [] : [["preset", facts.preset]],
327
- ...facts.permission === void 0 ? [] : [["permissions", facts.permission]],
328
- ["plan mode", facts.planMode ? "on" : "off"],
329
- ["workspace", facts.branch === void 0 ? displayPath(facts.cwd) : `${displayPath(facts.cwd)} (${facts.branch})`],
330
- ...usage === void 0 ? [] : [
331
- ["input", formatTokens(usage.uncachedInputTokens)],
332
- ["output", formatTokens(usage.outputTokens)],
333
- ["cache read", formatTokens(usage.cacheReadTokens)],
334
- ["cache write", formatTokens(usage.cacheWriteTokens)],
335
- ["total", formatTokens(totalTokens(usage) ?? 0)]
336
- ],
337
- ...next === void 0 || window === void 0 ? [] : [["next request", `${formatTokens(next)} of ${formatTokens(window)}${left === void 0 ? "" : ` (${left}% left)`}`]]
338
- ];
339
- const label = Math.max(...rows.map(([name$1]) => name$1.length));
340
- return rows.map(([name$1, value]) => `${name$1.padEnd(label)} ${value}`).join("\n");
341
- }
342
-
343
- //#endregion
344
- //#region src/banner.ts
345
- /** The product name, shown as the framed headline. */
346
- const NAME$1 = "dsh code";
347
- /**
348
- * The lettermark, drawn in half-block glyphs.
349
- *
350
- * Forty columns wide: it fits an eighty-column terminal with room to spare,
351
- * and anything narrower falls back to the plain headline anyway.
352
- */
353
- const LOGO = [
354
- " ██████╗ ██████╗ ██████╗ ███████╗██╗ ██╗",
355
- "██╔════╝██╔═══██╗██╔══██╗██╔════╝██║ ██║",
356
- "██║ ██║ ██║██║ ██║███████╗███████║",
357
- "██║ ██║ ██║██║ ██║╚════██║██╔══██║",
358
- "╚██████╗╚██████╔╝██████╔╝███████║██║ ██║",
359
- " ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝"
360
- ];
361
- /** Display width of the widest logo row. */
362
- const LOGO_WIDTH = 41;
363
- /**
364
- * Frame one headline in a rounded box sized to its content.
365
- *
366
- * Drawn from the measured display width rather than the character count, so a
367
- * headline carrying wide characters still closes its own box.
368
- * @param headline - the text to frame, already styled.
369
- * @param width - display width of the headline's plain text.
370
- * @param theme - styling for the frame itself.
371
- * @returns the three box lines.
372
- */
373
- function framed(headline, width, theme) {
374
- const rule = "─".repeat(width + 2);
375
- return [
376
- theme.dim(`╭${rule}╮`),
377
- `${theme.dim("│")} ${headline} ${theme.dim("│")}`,
378
- theme.dim(`╰${rule}╯`)
379
- ];
380
- }
381
- /**
382
- * Render the opening banner.
383
- * @param facts - what to report.
384
- * @param theme - styling for the frame and the detail lines.
385
- * @param columns - display columns available; a narrow terminal loses the frame.
386
- * @returns the lines to print, ending with a blank separator.
387
- */
388
- function bannerLines(facts, theme, columns) {
389
- const composition = [facts.model, ...facts.preset === void 0 ? [] : [facts.preset]].join(" · ");
390
- const where = facts.branch === void 0 ? displayPath(facts.cwd) : `${displayPath(facts.cwd)} (${facts.branch})`;
391
- const interrupt = facts.readsKeys ? "ESC" : "Ctrl-C";
392
- const plain = `${NAME$1} · ${composition}`;
393
- const headline = `${theme.bold(NAME$1)}${theme.dim(` · ${composition}`)}`;
394
- const details = [
395
- theme.dim(` ${truncate(where, columns - 2)}`),
396
- theme.dim(` session ${facts.session}${facts.resumed ? " (resumed)" : ""}`),
397
- "",
398
- theme.dim(truncate(` /help for commands · Tab completes · ⇧Tab plan mode · ${interrupt} interrupts · /exit leaves`, columns)),
399
- ""
400
- ];
401
- if (facts.readsKeys && !facts.resumed && columns >= LOGO_WIDTH + 2) return [
402
- "",
403
- ...LOGO.map((row) => theme.user(` ${row}`)),
404
- "",
405
- ` ${theme.bold("✻ Welcome to codsh")}${theme.dim(` · ${composition}`)}`,
406
- "",
407
- ...details
408
- ];
409
- return [...displayWidth(plain) + 4 <= columns ? framed(headline, displayWidth(plain), theme) : [truncate(plain, columns)], ...details];
410
- }
411
-
412
- //#endregion
413
- //#region src/completion.ts
414
- /** Directories never worth offering: build output and version-control internals. */
415
- const HIDDEN_DIRS = new Set([
416
- "node_modules",
417
- ".git",
418
- "lib",
419
- "dist",
420
- "build",
421
- "out",
422
- "coverage"
423
- ]);
424
- /** Deepest directory level the file index walks. */
425
- const INDEX_DEPTH = 8;
426
- /** Most entries the file index keeps; a bigger workspace is sampled, not hung on. */
427
- const INDEX_CAP = 5e3;
428
- /** How long one file index answers Tabs before the workspace is re-walked. */
429
- const INDEX_TTL_MS = 5e3;
430
- /** Candidates offered per completion. */
431
- const LIMIT = 8;
432
- /**
433
- * Score `needle` against `hay` as a fuzzy subsequence.
434
- *
435
- * Consecutive hits and hits on a boundary (start, or after a separator) score
436
- * higher, which is what ranks `src/idx` matches the way a person expects. A
437
- * needle that is not a subsequence scores nothing at all.
438
- * @param needle - what was typed, matched case-insensitively.
439
- * @param hay - the candidate.
440
- * @returns the score, or undefined when it does not match.
441
- */
442
- function fuzzyScore(needle, hay) {
443
- const want = needle.toLowerCase();
444
- const have = hay.toLowerCase();
445
- let score = 0;
446
- let at = -1;
447
- let previous = -2;
448
- for (const cell of want) {
449
- at = have.indexOf(cell, at + 1);
450
- if (at < 0) return void 0;
451
- const boundary = at === 0 || "/._- ".includes(have[at - 1] ?? "");
452
- score += at === previous + 1 ? 3 : boundary ? 2 : 1;
453
- previous = at;
454
- }
455
- return score * 100 - have.length;
456
- }
457
- let cached;
458
- /**
459
- * Walk the workspace into a flat list of relative paths.
460
- *
461
- * Synchronous and bounded: Tab is a keystroke-latency path, so the walk is
462
- * capped in depth and count and its result reused for a few seconds. An
463
- * unreadable directory contributes nothing rather than failing the keystroke.
464
- * @param cwd - the workspace root.
465
- * @returns relative paths, directories marked with a trailing slash.
466
- */
467
- function fileIndex(cwd) {
468
- const now = Date.now();
469
- if (cached !== void 0 && cached.cwd === cwd && now - cached.at < INDEX_TTL_MS) return cached.entries;
470
- const entries = [];
471
- const queue = [""];
472
- while (queue.length > 0 && entries.length < INDEX_CAP) {
473
- const dir = queue.shift() ?? "";
474
- if (dir.split("/").length > INDEX_DEPTH) continue;
475
- let found;
476
- try {
477
- found = readdirSync(join(cwd, dir), { withFileTypes: true });
478
- } catch {
479
- continue;
480
- }
481
- for (const entry of found) {
482
- if (entries.length >= INDEX_CAP) break;
483
- if (entry.name.startsWith(".") || HIDDEN_DIRS.has(entry.name)) continue;
484
- const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
485
- if (entry.isDirectory()) {
486
- entries.push(`${path}/`);
487
- queue.push(path);
488
- } else entries.push(path);
489
- }
490
- }
491
- cached = {
492
- cwd,
493
- at: now,
494
- entries
495
- };
496
- return entries;
497
- }
498
- /**
499
- * Complete a workspace path from an `@` mention, fuzzily across the whole tree.
500
- * @param token - the mention as typed, including its leading `@`.
501
- * @param cwd - the workspace the mention resolves against.
502
- * @returns candidate mentions, best match first.
503
- */
504
- function completePath(token, cwd) {
505
- const typed = token.slice(1);
506
- const entries = fileIndex(cwd);
507
- if (typed === "") return entries.filter((entry) => !entry.slice(0, -1).includes("/")).sort().slice(0, LIMIT).map((entry) => `@${entry}`);
508
- const prefixed = entries.filter((entry) => entry.startsWith(typed)).sort();
509
- const scored = entries.map((entry) => ({
510
- entry,
511
- score: fuzzyScore(typed, entry)
512
- })).filter((hit) => hit.score !== void 0).sort((a, b) => b.score - a.score).map((hit) => hit.entry);
513
- const ranked = [];
514
- for (const entry of [...prefixed, ...scored]) {
515
- if (!ranked.includes(entry)) ranked.push(entry);
516
- if (ranked.length >= LIMIT) break;
517
- }
518
- return ranked.map((entry) => `@${entry}`);
519
- }
520
- /**
521
- * Build the completer for one session.
522
- *
523
- * The command list is read on each use rather than captured, because a command
524
- * registry is scoped and changes with the session's mode — plan mode alone adds
525
- * and removes one.
526
- * @param commands - reads the currently registered commands.
527
- * @param cwd - the workspace `@` mentions resolve against.
528
- * @returns a completer over the word under the cursor.
529
- */
530
- function createCompleter(commands, cwd) {
531
- return (line) => {
532
- const token = line.slice(line.lastIndexOf(" ") + 1);
533
- if (token.startsWith("@")) return [completePath(token, cwd), token];
534
- if (!line.startsWith("/") || line.includes(" ")) return [[], token];
535
- const typed = line.slice(1);
536
- const names = commands().map((command) => `/${command.name}`);
537
- const prefixed = names.filter((name$1) => name$1.startsWith(`/${typed}`));
538
- const fuzzy = names.map((name$1) => ({
539
- name: name$1,
540
- score: fuzzyScore(typed, name$1.slice(1))
541
- })).filter((hit) => hit.score !== void 0).sort((a, b) => b.score - a.score).map((hit) => hit.name);
542
- const ranked = [];
543
- for (const name$1 of [...prefixed, ...fuzzy]) {
544
- if (!ranked.includes(name$1)) ranked.push(name$1);
545
- if (ranked.length >= LIMIT) break;
546
- }
547
- return [ranked, line];
548
- };
549
- }
550
-
551
- //#endregion
552
- //#region src/custom-commands.ts
553
- /** The registry's command-name rule; a file that breaks it cannot register. */
554
- const NAME = /^[a-z][a-z0-9_-]*$/;
555
- /**
556
- * Parse one command file: optional `---` frontmatter with a `description`
557
- * line, then the prompt body.
558
- * @param source - the file content.
559
- * @returns the description (empty when absent) and the body.
560
- */
561
- function parseCommandFile(source) {
562
- if (source.startsWith("---\n")) {
563
- const end = source.indexOf("\n---\n", 4);
564
- if (end >= 0) {
565
- const header = source.slice(4, end);
566
- return {
567
- description: /^description:\s*(.+)$/m.exec(header)?.[1]?.trim() ?? "",
568
- body: source.slice(end + 5).trim()
569
- };
570
- }
571
- }
572
- return {
573
- description: "",
574
- body: source.trim()
575
- };
576
- }
577
- /**
578
- * Fill a template with the typed arguments.
579
- * @param template - the prompt body.
580
- * @param typed - what followed the command name, possibly empty.
581
- * @returns the prompt to submit: placeholders replaced, or the arguments
582
- * appended when the template never asked for them.
583
- */
584
- function expandTemplate(template, typed) {
585
- if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", typed);
586
- return typed === "" ? template : `${template}\n\n${typed}`;
587
- }
588
- /**
589
- * Load every command under the given roots, later roots shadowing earlier.
590
- *
591
- * A missing root is normal (most setups define no custom commands); an
592
- * unreadable or misnamed file is a warning, never a failed startup — a broken
593
- * canned prompt should cost that prompt, not the session.
594
- * @param roots - directories to scan, lowest precedence first.
595
- * @param taken - names already registered, which a file cannot shadow.
596
- * @returns the loaded commands and the warnings to show once.
597
- */
598
- async function loadCustomCommands(roots, taken) {
599
- const commands = /* @__PURE__ */ new Map();
600
- const warnings = [];
601
- for (const root of roots) {
602
- let entries;
603
- try {
604
- entries = await readdir(root);
605
- } catch {
606
- continue;
607
- }
608
- for (const entry of entries.filter((name$1) => name$1.endsWith(".md")).sort()) {
609
- const name$1 = entry.slice(0, -3);
610
- if (!NAME.test(name$1)) {
611
- warnings.push(`${join(root, entry)}: "${name$1}" is not a command name (lowercase letters, digits, - and _)`);
612
- continue;
613
- }
614
- if (taken.has(name$1)) {
615
- warnings.push(`${join(root, entry)}: /${name$1} is already a built-in command`);
616
- continue;
617
- }
618
- try {
619
- const { description, body } = parseCommandFile(await readFile(join(root, entry), "utf8"));
620
- if (body === "") {
621
- warnings.push(`${join(root, entry)}: empty prompt body`);
622
- continue;
623
- }
624
- commands.set(name$1, {
625
- name: name$1,
626
- description: description === "" ? `custom command (${entry})` : description,
627
- template: body
628
- });
629
- } catch (error) {
630
- warnings.push(`${join(root, entry)}: ${error instanceof Error ? error.message : String(error)}`);
631
- }
632
- }
633
- }
634
- return {
635
- commands: [...commands.values()],
636
- warnings
637
- };
638
- }
639
-
640
- //#endregion
641
- //#region src/keys.ts
642
- /** Bracketed paste start, which a terminal wraps pasted text in. */
643
- const PASTE_START = "\x1B[200~";
644
- /** Bracketed paste end. */
645
- const PASTE_END = "\x1B[201~";
646
- /**
647
- * Rows one wheel event moves.
648
- *
649
- * One, not three: trackpads and hi-res wheels emit a stream of events per
650
- * gesture, so a multiplier here compounds into overshoot. The smoothness comes
651
- * from coalescing the stream into one repaint, not from bigger steps.
652
- */
653
- const WHEEL_LINES = 1;
654
- /** An SGR mouse report: `ESC [ < button ; column ; row (M|m)`. */
655
- const MOUSE = /^\u001B\[<(\d+);(\d+);(\d+)([Mm])/;
656
- /** Wheel-up button code in the SGR encoding; wheel-down is one higher. */
657
- const WHEEL_UP = 64;
658
- /** Modifier bits in an SGR button code: Shift, Meta, and Control. */
659
- const MOUSE_MODIFIERS = 28;
660
- /** The motion bit, set on reports sent while a button is held. */
661
- const MOUSE_MOTION = 32;
662
- /** Sequences that resolve to one key, longest first so a prefix never wins. */
663
- const SEQUENCES = [
664
- ["\x1B[5~", {
665
- kind: "page",
666
- direction: -1
667
- }],
668
- ["\x1B[6~", {
669
- kind: "page",
670
- direction: 1
671
- }],
672
- ["\x1B[1;2A", {
673
- kind: "scroll",
674
- lines: -1
675
- }],
676
- ["\x1B[1;2B", {
677
- kind: "scroll",
678
- lines: 1
679
- }],
680
- ["\x1B[1;5F", { kind: "scroll-end" }],
681
- ["\x1B[1;5D", { kind: "word-left" }],
682
- ["\x1B[1;5C", { kind: "word-right" }],
683
- ["\x1B[1;3D", { kind: "word-left" }],
684
- ["\x1B[1;3C", { kind: "word-right" }],
685
- ["\x1B[3~", { kind: "delete" }],
686
- ["\x1B[1~", { kind: "home" }],
687
- ["\x1B[4~", { kind: "end" }],
688
- ["\x1B[7~", { kind: "home" }],
689
- ["\x1B[8~", { kind: "end" }],
690
- ["\x1B[A", { kind: "up" }],
691
- ["\x1B[B", { kind: "down" }],
692
- ["\x1B[C", { kind: "right" }],
693
- ["\x1B[D", { kind: "left" }],
694
- ["\x1B[H", { kind: "home" }],
695
- ["\x1B[F", { kind: "end" }],
696
- ["\x1BOA", { kind: "up" }],
697
- ["\x1BOB", { kind: "down" }],
698
- ["\x1BOC", { kind: "right" }],
699
- ["\x1BOD", { kind: "left" }],
700
- ["\x1BOH", { kind: "home" }],
701
- ["\x1BOF", { kind: "end" }],
702
- ["\x1B[Z", { kind: "shift-tab" }],
703
- ["\x1Bb", { kind: "word-left" }],
704
- ["\x1Bf", { kind: "word-right" }],
705
- ["\x1B", { kind: "kill-word" }],
706
- ["\x1B\r", { kind: "newline" }],
707
- ["\x1B\n", { kind: "newline" }]
708
- ];
709
- /**
710
- * Control bytes that map straight to one key.
711
- *
712
- * Both carriage return and line feed submit. A terminal in raw mode sends `\r`
713
- * for Enter, but not every one does, and a key that inserted a line break
714
- * instead of submitting would be the worse failure — Alt-Enter is the binding
715
- * that adds a line.
716
- */
717
- const CONTROLS = {
718
- "\r": { kind: "enter" },
719
- "\n": { kind: "enter" },
720
- " ": { kind: "tab" },
721
- "": { kind: "backspace" },
722
- "\b": { kind: "backspace" },
723
- "": { kind: "interrupt" },
724
- "": { kind: "eof" },
725
- "": { kind: "home" },
726
- "": { kind: "end" },
727
- "\v": { kind: "kill-line" },
728
- "\f": { kind: "clear-screen" },
729
- "": { kind: "expand-output" },
730
- "": { kind: "kill-input" },
731
- "": { kind: "kill-word" }
732
- };
733
- /** Decodes terminal bytes into keys, holding partial sequences between reads. */
734
- var KeyDecoder = class {
735
- held = "";
736
- pasting = false;
737
- pasted = "";
738
- /**
739
- * Feed one read's worth of input.
740
- * @param chunk - the bytes as text.
741
- * @returns the keys this read completed, in order.
742
- */
743
- push(chunk) {
744
- this.held += chunk;
745
- const keys = [];
746
- for (;;) {
747
- const key = this.take();
748
- if (key === void 0) break;
749
- keys.push(...key);
750
- }
751
- return keys;
752
- }
753
- /** Whether bytes are held back awaiting the rest of a sequence. */
754
- get pending() {
755
- return this.held !== "";
756
- }
757
- /**
758
- * Resolve a held Escape that no further byte arrived for.
759
- *
760
- * `ESC` alone and the first byte of `ESC [ A` are the same byte, so the two can
761
- * only be told apart by what follows — or by nothing following. The caller arms
762
- * a short timer after each read and calls this when it expires: an arrow key
763
- * split across reads completes long before that, and a key pressed by itself
764
- * never completes at all.
765
- * @returns the Escape key, or nothing when the held bytes are a real prefix.
766
- */
767
- flush() {
768
- if (this.pasting || this.held !== "\x1B") return [];
769
- this.held = "";
770
- return [{ kind: "escape" }];
771
- }
772
- /**
773
- * Resolve the held bytes into one key, if they are enough.
774
- * @returns the keys produced, or undefined when more bytes are needed.
775
- */
776
- take() {
777
- if (this.held === "") return void 0;
778
- if (this.pasting) return this.takePasted();
779
- if (this.held.startsWith(PASTE_START)) {
780
- this.held = this.held.slice(6);
781
- this.pasting = true;
782
- this.pasted = "";
783
- return [];
784
- }
785
- if (PASTE_START.startsWith(this.held)) return void 0;
786
- const mouse = MOUSE.exec(this.held);
787
- if (mouse !== null) {
788
- this.held = this.held.slice(mouse[0].length);
789
- const button = Number(mouse[1]);
790
- if (button === WHEEL_UP) return [{
791
- kind: "scroll",
792
- lines: -WHEEL_LINES
793
- }];
794
- if (button === WHEEL_UP + 1) return [{
795
- kind: "scroll",
796
- lines: WHEEL_LINES
797
- }];
798
- const column = Number(mouse[2]);
799
- const row = Number(mouse[3]);
800
- if ((button & ~MOUSE_MOTION) === 0 && (button & MOUSE_MODIFIERS) === 0) {
801
- if (mouse[4] === "m") return [{
802
- kind: "mouse-up",
803
- row,
804
- column
805
- }];
806
- if ((button & MOUSE_MOTION) !== 0) return [{
807
- kind: "mouse-drag",
808
- row,
809
- column
810
- }];
811
- return [{
812
- kind: "mouse-down",
813
- row,
814
- column
815
- }];
816
- }
817
- return [];
818
- }
819
- if (/^\u001B(\[(<\d*(;\d*){0,2})?)?$/.test(this.held)) return void 0;
820
- for (const [sequence, key] of SEQUENCES) {
821
- if (this.held.startsWith(sequence)) {
822
- this.held = this.held.slice(sequence.length);
823
- return [key];
824
- }
825
- if (sequence.startsWith(this.held)) return void 0;
826
- }
827
- const first = this.held[0] ?? "";
828
- if (first === "\x1B") {
829
- this.held = this.held.slice(1);
830
- return [];
831
- }
832
- const control = CONTROLS[first];
833
- if (control !== void 0) {
834
- this.held = this.held.slice(1);
835
- return [control];
836
- }
837
- const printable = /^[^\u0000-\u001F\u007F]+/u.exec(this.held);
838
- if (printable === null) {
839
- this.held = this.held.slice(1);
840
- return [];
841
- }
842
- this.held = this.held.slice(printable[0].length);
843
- return [{
844
- kind: "text",
845
- text: printable[0]
846
- }];
847
- }
848
- /**
849
- * Collect bracketed-paste content up to its end marker.
850
- * @returns the paste key once complete, otherwise undefined.
851
- */
852
- takePasted() {
853
- const end = this.held.indexOf(PASTE_END);
854
- if (end < 0) {
855
- const safe = Math.max(0, this.held.length - 6 + 1);
856
- this.pasted += this.held.slice(0, safe);
857
- this.held = this.held.slice(safe);
858
- return;
859
- }
860
- this.pasted += this.held.slice(0, end);
861
- this.held = this.held.slice(end + 6);
862
- this.pasting = false;
863
- const text = this.pasted;
864
- this.pasted = "";
865
- return [{
866
- kind: "paste",
867
- text
868
- }];
869
- }
870
- };
871
- /** Ask the terminal to wrap pasted text in markers. */
872
- const ENABLE_PASTE_MARKERS = "\x1B[?2004h";
873
- /** Stop the terminal wrapping pasted text, restoring what it did before. */
874
- const DISABLE_PASTE_MARKERS = "\x1B[?2004l";
875
-
876
- //#endregion
877
- //#region src/wrap.ts
878
- /** One SGR sequence, which occupies no display columns. */
879
- const SGR = /^\u001B\[[0-9;]*m/;
880
- /** Any other escape sequence, also zero-width. */
881
- const ESCAPE = /^(?:\u001B\[[0-9;?]*[A-Za-z]|\u001B\][^\u0007]*\u0007|\u001B.)/;
882
- /** Closes every style a row opened, so a row never bleeds into the next. */
883
- const RESET = "\x1B[0m";
884
- /**
885
- * Break one styled line into rows no wider than `columns` display columns.
886
- *
887
- * Styles carry across the break: each continuation row re-opens whatever was
888
- * active where the cut fell, and every row that opened a style closes it.
889
- * @param text - the styled line, without a terminator.
890
- * @param columns - display columns available per row.
891
- * @returns the rows, at least one (an empty line yields one empty row).
892
- */
893
- function wrapStyled(text, columns) {
894
- if (columns <= 0) return [text];
895
- const rows = [];
896
- /** SGR sequences active at the cursor, in the order they were applied. */
897
- let active = [];
898
- let row = "";
899
- let width = 0;
900
- let rest = text;
901
- const flush = () => {
902
- rows.push(active.length > 0 ? `${row}${RESET}` : row);
903
- row = active.join("");
904
- width = 0;
905
- };
906
- while (rest !== "") {
907
- const sgr = SGR.exec(rest);
908
- if (sgr !== null) {
909
- const sequence = sgr[0];
910
- if (sequence === "\x1B[0m" || sequence === "\x1B[m") active = [];
911
- else active.push(sequence);
912
- row += sequence;
913
- rest = rest.slice(sequence.length);
914
- continue;
915
- }
916
- const other = ESCAPE.exec(rest);
917
- if (other !== null) {
918
- row += other[0];
919
- rest = rest.slice(other[0].length);
920
- continue;
921
- }
922
- const character = [...rest][0] ?? "";
923
- const cost = displayWidth(character);
924
- if (width + cost > columns && width > 0) flush();
925
- row += character;
926
- width += cost;
927
- rest = rest.slice(character.length);
928
- }
929
- rows.push(active.length > 0 ? `${row}${RESET}` : row);
930
- return rows;
931
- }
932
- /**
933
- * Wrap many lines, keeping their order.
934
- * @param lines - styled lines.
935
- * @param columns - display columns per row.
936
- * @returns the physical rows they occupy.
937
- */
938
- function wrapAll(lines, columns) {
939
- return lines.flatMap((line) => wrapStyled(line, columns));
940
- }
941
-
942
- //#endregion
943
- //#region src/screen.ts
944
- /** Logical transcript lines kept before the oldest are dropped. */
945
- const MAX_SCROLLBACK = 5e3;
946
- /** Enter the alternate screen, saving the cursor and the current buffer. */
947
- const ENTER_ALT = "\x1B[?1049h";
948
- /** Leave it, restoring both. */
949
- const LEAVE_ALT = "\x1B[?1049l";
950
- /**
951
- * Report wheel and button events, in the SGR encoding.
952
- *
953
- * Button tracking (1002) rather than any-motion (1003): the wheel and clicks
954
- * are all this surface reads, and motion reporting floods the input for
955
- * nothing. Most terminals still hand a Shift-drag to their own selection, so
956
- * copying text keeps working.
957
- */
958
- const ENABLE_MOUSE = "\x1B[?1002h\x1B[?1006h";
959
- /** Stop reporting them. */
960
- const DISABLE_MOUSE = "\x1B[?1006l\x1B[?1002l";
961
- /** Ask the terminal to paint a frame atomically, so no half-frame is shown. */
962
- const SYNC_BEGIN = "\x1B[?2026h";
963
- /** End the atomic frame. */
964
- const SYNC_END = "\x1B[?2026l";
965
- /** Erase the row from the cursor rightwards. */
966
- const CLEAR_LINE = "\x1B[K";
967
- /** Hide the cursor while a frame is painted. */
968
- const HIDE_CURSOR = "\x1B[?25l";
969
- /** Show it again. */
970
- const SHOW_CURSOR = "\x1B[?25h";
971
- /** Styling escapes, removed before a row is measured or copied. */
972
- const STYLES = /\u001B\[[0-9;]*m/gu;
973
- /** Start reverse video, which is how the selection shows itself. */
974
- const INVERSE = "\x1B[7m";
975
- /** End reverse video only, leaving any other attributes alone. */
976
- const INVERSE_OFF = "\x1B[27m";
977
- /**
978
- * The string index where a display column begins.
979
- *
980
- * Columns are what the mouse reports and characters are what strings hold;
981
- * this is the bridge. A column landing inside a wide character snaps past it.
982
- * @param text - plain text, no escapes.
983
- * @param column - display column, 0-based.
984
- * @returns the index of the first character at or beyond that column.
985
- */
986
- function columnIndex(text, column) {
987
- let width = 0;
988
- let index = 0;
989
- for (const character of text) {
990
- if (width >= column) return index;
991
- width += displayWidth(character);
992
- index += character.length;
993
- }
994
- return text.length;
995
- }
996
- /** An alternate-screen viewport over a scrollback buffer this surface owns. */
997
- var Screen = class {
998
- /** Logical transcript lines, unwrapped, oldest first. */
999
- logical = [];
1000
- /** The same lines wrapped to the current width — what the viewport slices. */
1001
- physical = [];
1002
- /** The bottom rows: input box, menu, indicator, status. */
1003
- chrome = [];
1004
- chromeCursor = {
1005
- row: 0,
1006
- column: 0
1007
- };
1008
- /** Whether the chrome holds input focus, which is when the cursor shows. */
1009
- chromeFocus = true;
1010
- /** Physical rows hidden below the viewport; zero means following the tail. */
1011
- offset = 0;
1012
- /** What to show while scrolled back, drawn over the viewport's top row. */
1013
- notice = "";
1014
- /** A mouse selection over the transcript, in physical-row coordinates. */
1015
- selection;
1016
- /** Collapsed blocks in the transcript, in order, with both of their forms. */
1017
- folds = [];
1018
- /** Whether the folds currently show their full form. */
1019
- expanded = false;
1020
- /** The last painted frame, so a repaint only touches rows that changed. */
1021
- painted = [];
1022
- /** Width the current frame was painted at, to detect a resize. */
1023
- paintedColumns = 0;
1024
- active = false;
1025
- constructor(host) {
1026
- this.host = host;
1027
- }
1028
- /** Whether the alternate screen is currently held. */
1029
- get entered() {
1030
- return this.active;
1031
- }
1032
- /** Physical rows scrolled up out of view; zero means the tail is showing. */
1033
- get scrolledBy() {
1034
- return this.offset;
1035
- }
1036
- /** Take the alternate screen and start reporting the mouse. */
1037
- enter() {
1038
- if (this.active) return;
1039
- this.active = true;
1040
- this.host.write(`${ENTER_ALT}${ENABLE_MOUSE}${HIDE_CURSOR}`);
1041
- this.painted = [];
1042
- this.render();
1043
- }
1044
- /**
1045
- * Give the terminal back exactly as it was.
1046
- *
1047
- * Idempotent, because every exit path calls it — a normal quit, an
1048
- * interrupt, and a crash handler all have to leave the terminal usable.
1049
- */
1050
- leave() {
1051
- if (!this.active) return;
1052
- this.active = false;
1053
- this.host.write(`${DISABLE_MOUSE}${SHOW_CURSOR}${LEAVE_ALT}`);
1054
- this.painted = [];
1055
- }
1056
- /**
1057
- * Append finished transcript lines.
1058
- *
1059
- * Following the tail is the default; a person who has scrolled up stays
1060
- * where they are, and the new rows accumulate below them.
1061
- * @param lines - the lines to keep, already styled.
1062
- */
1063
- append(lines) {
1064
- if (lines.length === 0) return;
1065
- const columns = this.contentColumns();
1066
- for (const line of lines) {
1067
- this.logical.push(line);
1068
- this.physical.push(...wrapStyled(line, columns));
1069
- }
1070
- if (this.logical.length > MAX_SCROLLBACK) {
1071
- const dropped = this.logical.length - MAX_SCROLLBACK;
1072
- this.logical.splice(0, dropped);
1073
- this.folds = this.folds.flatMap((fold) => {
1074
- const at = fold.at - dropped;
1075
- return at >= 0 ? [{
1076
- ...fold,
1077
- at
1078
- }] : [];
1079
- });
1080
- this.rewrap();
1081
- }
1082
- this.render();
1083
- }
1084
- /**
1085
- * Append one collapsible block: its summary now, its full form on demand.
1086
- *
1087
- * This is what makes every long block — not merely the latest — expandable:
1088
- * the buffer keeps both forms, and toggling rebuilds the transcript in
1089
- * place, exactly like a details/summary element.
1090
- * @param summary - the collapsed lines, already styled.
1091
- * @param full - the expanded lines, already styled.
1092
- */
1093
- appendFold(summary, full) {
1094
- const shown = this.expanded ? full : summary;
1095
- this.folds.push({
1096
- at: this.logical.length,
1097
- shownLength: shown.length,
1098
- summary: [...summary],
1099
- full: [...full],
1100
- expanded: this.expanded
1101
- });
1102
- this.append(shown);
1103
- }
1104
- /**
1105
- * Turn the last `count` appended lines into a collapsible block after the
1106
- * fact.
1107
- *
1108
- * This is how a finished answer becomes foldable without ever having been
1109
- * withheld: it streamed in the open, and only once complete does it grow a
1110
- * summary form. The block starts expanded — the person is reading it — and
1111
- * collapses with the rest when the conversation moves on.
1112
- * @param count - how many trailing lines the block owns.
1113
- * @param summary - the collapsed lines, already styled.
1114
- */
1115
- foldBack(count, summary) {
1116
- const at = this.logical.length - count;
1117
- if (count <= 0 || at < 0) return;
1118
- const last = this.folds.at(-1);
1119
- if (last !== void 0 && at < last.at + last.shownLength) return;
1120
- this.folds.push({
1121
- at,
1122
- shownLength: count,
1123
- summary: [...summary],
1124
- full: this.logical.slice(at),
1125
- expanded: true
1126
- });
1127
- }
1128
- /** Whether any collapsible block exists. */
1129
- get hasFolds() {
1130
- return this.folds.length > 0;
1131
- }
1132
- /** Whether the folds currently show their full form. */
1133
- get foldsExpanded() {
1134
- return this.expanded;
1135
- }
1136
- /**
1137
- * Swap every fold between its summary and its full form.
1138
- * @returns false when there is nothing to toggle.
1139
- */
1140
- toggleFolds() {
1141
- if (this.folds.length === 0) return false;
1142
- this.setFolds(!this.expanded);
1143
- return true;
1144
- }
1145
- /** Return every fold to its summary, the way moving on reads as dismissal. */
1146
- collapseFolds() {
1147
- if (this.folds.some((fold) => fold.expanded)) this.setFolds(false);
1148
- else this.expanded = false;
1149
- }
1150
- /** Put every fold into one form, whatever mix of states they are in now. */
1151
- setFolds(expanded) {
1152
- this.expanded = expanded;
1153
- const deltas = /* @__PURE__ */ new Map();
1154
- for (const fold of [...this.folds].reverse()) {
1155
- if (fold.expanded === expanded) {
1156
- deltas.set(fold, 0);
1157
- continue;
1158
- }
1159
- const shown = expanded ? fold.full : fold.summary;
1160
- this.logical.splice(fold.at, fold.shownLength, ...shown);
1161
- deltas.set(fold, shown.length - fold.shownLength);
1162
- fold.shownLength = shown.length;
1163
- fold.expanded = expanded;
1164
- }
1165
- let shift = 0;
1166
- for (const fold of this.folds) {
1167
- fold.at += shift;
1168
- shift += deltas.get(fold) ?? 0;
1169
- }
1170
- this.rewrap();
1171
- this.offset = 0;
1172
- this.painted = [];
1173
- this.render();
1174
- }
1175
- /**
1176
- * Replace the bottom rows.
1177
- * @param rows - the chrome, top to bottom.
1178
- * @param cursor - where the cursor belongs among them.
1179
- * @param focus - whether to show the cursor there.
1180
- */
1181
- setChrome(rows, cursor, focus) {
1182
- this.chrome = rows.map((row) => truncate(row, this.contentColumns()));
1183
- this.chromeCursor = { ...cursor };
1184
- this.chromeFocus = focus;
1185
- this.render();
1186
- }
1187
- /**
1188
- * Set the line shown while the reader is away from the tail.
1189
- *
1190
- * Drawn OVER the viewport's top row rather than added to the chrome: a notice
1191
- * that changed the chrome's height would move the input box as a side effect
1192
- * of scrolling, and would make a page up and a page down different sizes.
1193
- * @param text - the styled notice, already fitted.
1194
- */
1195
- setScrollNotice(text) {
1196
- if (text === this.notice) return;
1197
- this.notice = text;
1198
- if (this.offset > 0) this.render();
1199
- }
1200
- /**
1201
- * Scroll the transcript.
1202
- * @param delta - rows to move; negative scrolls back into history.
1203
- */
1204
- scrollBy(delta) {
1205
- const limit = Math.max(0, this.physical.length - this.viewportHeight());
1206
- const next = Math.min(limit, Math.max(0, this.offset - delta));
1207
- if (next === this.offset) return;
1208
- this.offset = next;
1209
- this.render();
1210
- }
1211
- /**
1212
- * Scroll by a whole viewport, which is what the page keys mean.
1213
- * @param direction - -1 for back into history, 1 towards the tail.
1214
- */
1215
- scrollPage(direction) {
1216
- this.scrollBy(direction * Math.max(1, this.viewportHeight() - 1));
1217
- }
1218
- /** Jump back to the tail, which is also what a new submission does. */
1219
- scrollToBottom() {
1220
- if (this.offset === 0) return;
1221
- this.offset = 0;
1222
- this.render();
1223
- }
1224
- /**
1225
- * Drop the transcript, keeping the chrome.
1226
- *
1227
- * Ctrl-L on a shared terminal clears a viewport the person may want back; on
1228
- * our own screen the buffer IS the session's history, so this empties it.
1229
- */
1230
- clearTranscript() {
1231
- this.logical = [];
1232
- this.physical = [];
1233
- this.folds = [];
1234
- this.expanded = false;
1235
- this.offset = 0;
1236
- this.painted = [];
1237
- this.render();
1238
- }
1239
- /** Re-wrap and repaint after the terminal changed size. */
1240
- resize() {
1241
- this.rewrap();
1242
- this.painted = [];
1243
- this.render();
1244
- }
1245
- /**
1246
- * Anchor a selection where the left button went down.
1247
- *
1248
- * The terminal cannot select for us while mouse reporting is on, so the
1249
- * viewport does it: press anchors, motion extends, release copies — the
1250
- * shape opencode and Claude give the same gesture.
1251
- * @param row - terminal row, 1-based.
1252
- * @param column - terminal column, 1-based.
1253
- */
1254
- mouseDown(row, column) {
1255
- const had = this.selection !== void 0;
1256
- this.selection = void 0;
1257
- const at = this.locate(row, column, false);
1258
- if (at !== void 0) this.selection = {
1259
- anchor: at,
1260
- focus: at,
1261
- dragged: false
1262
- };
1263
- if (had) this.render();
1264
- }
1265
- /**
1266
- * Extend the selection to where the pointer moved.
1267
- * @param row - terminal row, 1-based.
1268
- * @param column - terminal column, 1-based.
1269
- */
1270
- mouseDrag(row, column) {
1271
- if (this.selection === void 0) return;
1272
- const at = this.locate(row, column, true);
1273
- if (at === void 0) return;
1274
- this.selection.focus = at;
1275
- this.selection.dragged = true;
1276
- this.render();
1277
- }
1278
- /**
1279
- * Finish the gesture.
1280
- *
1281
- * The highlight stays up — the copy already happened, and the marks show
1282
- * what it took — until the next click or reflow dismisses it.
1283
- * @returns the selected text, or undefined for a bare click.
1284
- */
1285
- mouseUp() {
1286
- const selection = this.selection;
1287
- if (selection === void 0) return void 0;
1288
- if (!selection.dragged) {
1289
- this.selection = void 0;
1290
- return;
1291
- }
1292
- const text = this.selectedText();
1293
- if (text === "") {
1294
- this.selection = void 0;
1295
- this.render();
1296
- return;
1297
- }
1298
- return text;
1299
- }
1300
- /** The selection's bounds in order, top-left first. */
1301
- orderedSelection() {
1302
- const selection = this.selection;
1303
- if (selection === void 0 || !selection.dragged) return void 0;
1304
- const { anchor, focus } = selection;
1305
- const [from, to] = focus.row < anchor.row || focus.row === anchor.row && focus.column < anchor.column ? [focus, anchor] : [anchor, focus];
1306
- return {
1307
- from,
1308
- to
1309
- };
1310
- }
1311
- /** The plain text under the selection, visual rows joined by newlines. */
1312
- selectedText() {
1313
- const bounds = this.orderedSelection();
1314
- if (bounds === void 0) return "";
1315
- const rows = [];
1316
- for (let index = bounds.from.row; index <= bounds.to.row; index += 1) {
1317
- const plain = (this.physical[index] ?? "").replaceAll(STYLES, "");
1318
- const start = index === bounds.from.row ? columnIndex(plain, bounds.from.column) : 0;
1319
- const end = index === bounds.to.row ? columnIndex(plain, bounds.to.column + 1) : plain.length;
1320
- rows.push(plain.slice(start, end));
1321
- }
1322
- return rows.join("\n").replace(/^\n+|\n+$/gu, "") === "" ? "" : rows.join("\n");
1323
- }
1324
- /**
1325
- * Map a terminal position to a physical buffer position.
1326
- * @param row - terminal row, 1-based.
1327
- * @param column - terminal column, 1-based.
1328
- * @param clamp - pull an outside position to the nearest content row, the
1329
- * way dragging past an edge keeps selecting, instead of refusing it.
1330
- * @returns the position, or undefined when it misses the content.
1331
- */
1332
- locate(row, column, clamp) {
1333
- if (this.physical.length === 0) return void 0;
1334
- const height = this.viewportHeight();
1335
- const end = this.physical.length - this.offset;
1336
- const start = Math.max(0, end - height);
1337
- let index = start + row - 1;
1338
- if (!clamp && (row - 1 >= height || index >= end || index < start)) return void 0;
1339
- index = Math.min(Math.max(index, start), end - 1);
1340
- return {
1341
- row: index,
1342
- column: Math.max(0, column - 1)
1343
- };
1344
- }
1345
- /** Rows the transcript viewport occupies. */
1346
- viewportHeight() {
1347
- return Math.max(1, this.host.rows() - this.chrome.length);
1348
- }
1349
- /** Columns content is laid out for, one short of the width so no row wraps. */
1350
- contentColumns() {
1351
- return Math.max(1, this.host.columns() - 1);
1352
- }
1353
- /** Re-wrap every kept line at the current width. */
1354
- rewrap() {
1355
- this.selection = void 0;
1356
- this.physical = wrapAll(this.logical, this.contentColumns());
1357
- const limit = Math.max(0, this.physical.length - this.viewportHeight());
1358
- this.offset = Math.min(this.offset, limit);
1359
- }
1360
- /**
1361
- * Compose and paint the frame.
1362
- *
1363
- * The viewport is padded at the top when the transcript is shorter than the
1364
- * screen, which is what puts the chrome at the bottom from the first frame
1365
- * rather than wherever output happened to reach.
1366
- */
1367
- render() {
1368
- if (!this.active) return;
1369
- const columns = this.host.columns();
1370
- if (columns !== this.paintedColumns) {
1371
- this.physical = wrapAll(this.logical, this.contentColumns());
1372
- this.painted = [];
1373
- this.paintedColumns = columns;
1374
- }
1375
- const height = this.viewportHeight();
1376
- const end = this.physical.length - this.offset;
1377
- const visible = this.physical.slice(Math.max(0, end - height), Math.max(0, end));
1378
- const padding = Array.from({ length: Math.max(0, height - visible.length) }, () => "");
1379
- const bounds = this.orderedSelection();
1380
- if (bounds !== void 0) {
1381
- const first = Math.max(0, end - height);
1382
- for (let index = 0; index < visible.length; index += 1) {
1383
- const at = first + index;
1384
- if (at < bounds.from.row || at > bounds.to.row) continue;
1385
- const plain = (visible[index] ?? "").replaceAll(STYLES, "");
1386
- const start = at === bounds.from.row ? columnIndex(plain, bounds.from.column) : 0;
1387
- const stop = at === bounds.to.row ? columnIndex(plain, bounds.to.column + 1) : plain.length;
1388
- let marked = plain.slice(start, stop);
1389
- if (marked === "" && at > bounds.from.row && at < bounds.to.row) marked = " ";
1390
- if (marked === "" && start >= stop) continue;
1391
- visible[index] = `${plain.slice(0, start)}${INVERSE}${marked}${INVERSE_OFF}${plain.slice(stop)}`;
1392
- }
1393
- }
1394
- const viewport = [...visible, ...padding];
1395
- if (this.offset > 0 && this.notice !== "" && viewport.length > 0) viewport[0] = truncate(this.notice, this.contentColumns());
1396
- const frame = [...viewport, ...this.chrome];
1397
- let out = SYNC_BEGIN + HIDE_CURSOR;
1398
- frame.forEach((row, index) => {
1399
- if (this.painted[index] === row) return;
1400
- out += `\u001B[${index + 1};1H${CLEAR_LINE}${row}`;
1401
- });
1402
- for (let index = frame.length; index < this.painted.length; index += 1) out += `\u001B[${index + 1};1H${CLEAR_LINE}`;
1403
- if (this.chromeFocus) {
1404
- const row = frame.length - this.chrome.length + this.chromeCursor.row + 1;
1405
- out += `\u001B[${row};${this.chromeCursor.column + 1}H${SHOW_CURSOR}`;
1406
- }
1407
- out += SYNC_END;
1408
- this.host.write(out);
1409
- this.painted = frame;
1410
- }
1411
- };
1412
-
1413
- //#endregion
1414
- //#region src/console.ts
1415
- /** Columns assumed when the output stream reports none (a pipe). */
1416
- const FALLBACK_COLUMNS = 80;
1417
- /**
1418
- * Narrowest width this surface will lay out for.
1419
- *
1420
- * A terminal can report a width of zero — a PTY opened without a window size
1421
- * does — and honouring it would truncate every card line away to nothing. Below
1422
- * this floor the lines overflow instead, which stays readable.
1423
- */
1424
- const MIN_COLUMNS = 20;
1425
- /** Rows assumed when the output stream reports none. */
1426
- const FALLBACK_ROWS = 24;
1427
- /**
1428
- * How long a held Escape waits for a successor before it counts as the key.
1429
- *
1430
- * `ESC` alone and the first byte of `ESC [ A` are the same byte. A terminal
1431
- * delivers the rest of a real sequence in the same read or the very next one, so
1432
- * a wait this short never splits one while still answering the bare key
1433
- * promptly.
1434
- */
1435
- const ESCAPE_FLUSH_MS = 20;
1436
- /** Line input and output over one pair of process streams. */
1437
- var TerminalConsole = class {
1438
- rl;
1439
- decoder = new KeyDecoder();
1440
- pending = [];
1441
- waiters = [];
1442
- keyHandler;
1443
- /** Keys decoded before any handler registered — type-ahead is never dropped. */
1444
- earlyKeys = [];
1445
- escapeTimer;
1446
- ended = false;
1447
- /** The viewport this surface owns on a terminal; absent off one. */
1448
- screen;
1449
- constructor(input, output) {
1450
- this.input = input;
1451
- this.output = output;
1452
- if (this.readsKeys) {
1453
- input.setRawMode?.(true);
1454
- this.output.write(ENABLE_PASTE_MARKERS);
1455
- this.screen = new Screen({
1456
- write: (data) => void this.output.write(data),
1457
- columns: () => this.columns,
1458
- rows: () => Math.max(2, this.output.rows ?? FALLBACK_ROWS)
1459
- });
1460
- this.output.on("resize", () => {
1461
- this.screen?.resize();
1462
- });
1463
- input.on("data", (chunk) => {
1464
- this.onBytes(chunk);
1465
- });
1466
- input.on("end", () => {
1467
- this.end();
1468
- });
1469
- this.rl = void 0;
1470
- return;
1471
- }
1472
- this.rl = createInterface({
1473
- input,
1474
- output,
1475
- terminal: false
1476
- });
1477
- this.rl.on("line", (line) => {
1478
- this.offer(line);
1479
- });
1480
- this.rl.on("close", () => {
1481
- this.end();
1482
- });
1483
- }
1484
- /** Display columns available for one line, never below {@link MIN_COLUMNS}. */
1485
- get columns() {
1486
- return Math.max(this.output.columns ?? FALLBACK_COLUMNS, MIN_COLUMNS);
1487
- }
1488
- /**
1489
- * Columns content may be laid out for: one less than the width, because the
1490
- * viewport wraps at that boundary. Markdown layout MUST use this figure — a
1491
- * table laid out one column wider is refolded by the viewport, and its rows
1492
- * shear apart.
1493
- */
1494
- get contentColumns() {
1495
- return Math.max(1, this.columns - 1);
1496
- }
1497
- /** Whether the output stream is a terminal. */
1498
- get isTty() {
1499
- return this.output.isTTY === true;
1500
- }
1501
- /**
1502
- * Whether this surface owns the keyboard.
1503
- *
1504
- * That needs both streams on a terminal: raw mode is what delivers a key
1505
- * before its line, and there is no point managing rows on a stream with no
1506
- * cursor.
1507
- */
1508
- get readsKeys() {
1509
- return this.input.isTTY === true && this.isTty;
1510
- }
1511
- /** Whether input has finished. */
1512
- get finished() {
1513
- return this.ended;
1514
- }
1515
- /**
1516
- * Register a handler for terminal window changes.
1517
- * @param handler - called after each resize while registered.
1518
- * @returns a disposer that removes it.
1519
- */
1520
- onResize(handler) {
1521
- if (!this.readsKeys) return () => {};
1522
- this.output.on("resize", handler);
1523
- return () => void this.output.off("resize", handler);
1524
- }
1525
- /**
1526
- * Take the viewport: the transcript and the prompt live on their own screen
1527
- * from here, and the terminal keeps the buffer the person had.
1528
- */
1529
- enterScreen() {
1530
- this.screen?.enter();
1531
- }
1532
- /**
1533
- * Give the terminal back. Idempotent: every exit path calls it.
1534
- */
1535
- leaveScreen() {
1536
- this.screen?.leave();
1537
- }
1538
- /** Whether this surface currently holds its own screen. */
1539
- get owningScreen() {
1540
- return this.screen?.entered === true;
1541
- }
1542
- /**
1543
- * Scroll the transcript inside the viewport.
1544
- * @param delta - rows to move; negative goes back into history.
1545
- */
1546
- scrollBy(delta) {
1547
- this.screen?.scrollBy(delta);
1548
- }
1549
- /**
1550
- * Set the notice shown while the transcript is scrolled back.
1551
- * @param text - the styled line, or the empty string for none.
1552
- */
1553
- setScrollNotice(text) {
1554
- this.screen?.setScrollNotice(text);
1555
- }
1556
- /**
1557
- * Scroll the transcript by a whole viewport.
1558
- * @param direction - -1 for back into history, 1 towards the tail.
1559
- */
1560
- scrollPage(direction) {
1561
- this.screen?.scrollPage(direction);
1562
- }
1563
- /** Return to the tail of the transcript. */
1564
- scrollToBottom() {
1565
- this.screen?.scrollToBottom();
1566
- }
1567
- /** Physical rows currently scrolled out of view; zero means at the tail. */
1568
- get scrolledBy() {
1569
- return this.screen?.scrolledBy ?? 0;
1570
- }
1571
- /**
1572
- * Clear the transcript this session accumulated.
1573
- *
1574
- * On its own screen there is no shell scrollback to preserve, so this empties
1575
- * the buffer the viewport shows rather than wiping a shared terminal.
1576
- */
1577
- clearScreen() {
1578
- this.screen?.clearTranscript();
1579
- }
1580
- /**
1581
- * Route decoded keys to a handler.
1582
- * @param handler - receives every key while registered.
1583
- * @returns a disposer that removes it.
1584
- */
1585
- onKey(handler) {
1586
- this.keyHandler = handler;
1587
- for (const key of this.earlyKeys.splice(0)) handler(key);
1588
- return () => {
1589
- this.keyHandler = void 0;
1590
- };
1591
- }
1592
- /**
1593
- * Decode one read and dispatch its keys.
1594
- * @param chunk - the bytes the terminal delivered.
1595
- */
1596
- onBytes(chunk) {
1597
- const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
1598
- for (const key of this.decoder.push(text)) this.deliver(key);
1599
- this.armEscapeFlush();
1600
- }
1601
- /** Hand one key to the handler, or hold it until one registers. */
1602
- deliver(key) {
1603
- if (this.keyHandler === void 0) {
1604
- this.earlyKeys.push(key);
1605
- return;
1606
- }
1607
- this.keyHandler(key);
1608
- }
1609
- /** Wait briefly for the rest of a held sequence, then resolve it as Escape. */
1610
- armEscapeFlush() {
1611
- if (this.escapeTimer !== void 0) clearTimeout(this.escapeTimer);
1612
- this.escapeTimer = void 0;
1613
- if (!this.decoder.pending) return;
1614
- this.escapeTimer = setTimeout(() => {
1615
- this.escapeTimer = void 0;
1616
- for (const key of this.decoder.flush()) this.deliver(key);
1617
- }, ESCAPE_FLUSH_MS);
1618
- this.escapeTimer.unref();
1619
- }
1620
- /**
1621
- * Hand one input line to the longest-waiting read, or queue it.
1622
- * @param line - the line the reader produced.
1623
- */
1624
- offer(line) {
1625
- const waiter = this.waiters.shift();
1626
- if (waiter === void 0) {
1627
- this.pending.push(line);
1628
- return;
1629
- }
1630
- waiter.dispose();
1631
- waiter.resolve(line);
1632
- }
1633
- /** Mark input finished and release every waiting read. */
1634
- end() {
1635
- if (this.ended) return;
1636
- this.ended = true;
1637
- for (const waiter of this.waiters.splice(0)) {
1638
- waiter.dispose();
1639
- waiter.resolve(void 0);
1640
- }
1641
- }
1642
- /**
1643
- * Keep one finished transcript line.
1644
- *
1645
- * On its own screen the line goes into the viewport's scrollback, which is
1646
- * what lets the transcript scroll under a prompt that does not move. Off one
1647
- * it is written straight out, because a pipe's reader wants exactly that.
1648
- * @param line - the line, without its terminator.
1649
- */
1650
- write(line) {
1651
- if (this.screen !== void 0) {
1652
- this.screen.append([line]);
1653
- return;
1654
- }
1655
- this.output.write(`${line}\n`);
1656
- }
1657
- /**
1658
- * Replace the rows pinned below the transcript.
1659
- *
1660
- * This is the whole live area: an input box, a completion menu, a working
1661
- * indicator, the status row. Off a terminal the call is ignored — a
1662
- * redirected transcript must not collect frames of a box nobody can see.
1663
- * @param rows - the rows to display, top to bottom.
1664
- * @param cursor - where to leave the terminal cursor among them.
1665
- * @param focus - whether the rows hold input focus, which is when the cursor
1666
- * shows. Parked anywhere else it reads as content colliding with it.
1667
- */
1668
- setRegion(rows, cursor, focus = true) {
1669
- this.screen?.setChrome(rows, cursor, focus);
1670
- }
1671
- /**
1672
- * Keep one collapsible block: summary now, full form behind the toggle.
1673
- *
1674
- * Off a terminal only the summary is written — a pipe has no keys to toggle
1675
- * with, and scripts want the digest.
1676
- * @param summary - the collapsed lines.
1677
- * @param full - the expanded lines.
1678
- */
1679
- appendFold(summary, full) {
1680
- if (this.screen !== void 0) {
1681
- this.screen.appendFold(summary, full);
1682
- return;
1683
- }
1684
- for (const line of summary) this.output.write(`${line}\n`);
1685
- }
1686
- /**
1687
- * Swap every collapsible block between summary and full form.
1688
- * @returns false when there is nothing to toggle.
1689
- */
1690
- /**
1691
- * Anchor a mouse selection at a terminal position.
1692
- * @param row - terminal row, 1-based.
1693
- * @param column - terminal column, 1-based.
1694
- */
1695
- mouseDown(row, column) {
1696
- this.screen?.mouseDown(row, column);
1697
- }
1698
- /**
1699
- * Extend the mouse selection to a terminal position.
1700
- * @param row - terminal row, 1-based.
1701
- * @param column - terminal column, 1-based.
1702
- */
1703
- mouseDrag(row, column) {
1704
- this.screen?.mouseDrag(row, column);
1705
- }
1706
- /**
1707
- * Finish the mouse selection.
1708
- * @returns the selected text, or undefined for a bare click.
1709
- */
1710
- mouseUp() {
1711
- return this.screen?.mouseUp();
1712
- }
1713
- /**
1714
- * Put text on the clipboard.
1715
- *
1716
- * Two channels, because neither is universal: OSC 52 reaches through SSH and
1717
- * works wherever the terminal permits it, and the platform helper covers the
1718
- * terminals that refuse the escape. `CODSH_CLIPBOARD` narrows it to `osc52`,
1719
- * `system`, or `off` — tests use `osc52` so a run never touches the real
1720
- * clipboard.
1721
- * @param text - the plain text to copy.
1722
- * @returns whether a copy was attempted at all.
1723
- */
1724
- copyText(text) {
1725
- const mode = process.env["CODSH_CLIPBOARD"] ?? "both";
1726
- if (mode === "off" || text === "") return false;
1727
- if (mode !== "system") this.output.write(`\u001B]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
1728
- if (mode !== "osc52") {
1729
- const command = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : process.env["WAYLAND_DISPLAY"] === void 0 ? [
1730
- "xclip",
1731
- "-selection",
1732
- "clipboard"
1733
- ] : ["wl-copy"];
1734
- try {
1735
- const child = spawn(command[0] ?? "", command.slice(1), { stdio: [
1736
- "pipe",
1737
- "ignore",
1738
- "ignore"
1739
- ] });
1740
- child.on("error", () => {});
1741
- child.stdin.end(text);
1742
- } catch {}
1743
- }
1744
- return true;
1745
- }
1746
- /**
1747
- * Make the last `count` written lines a collapsible block after the fact.
1748
- *
1749
- * Screen-only on purpose: a pipe already carries the full text, and a
1750
- * summary would subtract from it.
1751
- * @param count - how many trailing lines the block owns.
1752
- * @param summary - the collapsed lines, already styled.
1753
- */
1754
- foldRecent(count, summary) {
1755
- this.screen?.foldBack(count, summary);
1756
- }
1757
- toggleFolds() {
1758
- return this.screen?.toggleFolds() ?? false;
1759
- }
1760
- /** Return every block to its summary. */
1761
- collapseFolds() {
1762
- this.screen?.collapseFolds();
1763
- }
1764
- /** Take the pinned rows down, leaving the transcript alone. */
1765
- clearRegion() {
1766
- this.screen?.setChrome([], {
1767
- row: 0,
1768
- column: 0
1769
- }, false);
1770
- }
1771
- /** Ring the terminal bell; a pipe gets nothing to beep with. */
1772
- bell() {
1773
- if (!this.isTty) return;
1774
- this.output.write("\x07");
1775
- }
1776
- /**
1777
- * Set the terminal window title.
1778
- * @param title - the title text; control bytes are the terminal's to reject.
1779
- */
1780
- setTitle(title) {
1781
- if (!this.isTty) return;
1782
- this.output.write(`\u001B]2;${title}\u0007`);
1783
- }
1784
- /**
1785
- * Read one line from a piped stream.
1786
- *
1787
- * Only the non-terminal shape reads this way; with the keyboard owned, input
1788
- * arrives as keys and the caller drives an editor instead.
1789
- * @param signal - aborts the pending read.
1790
- * @returns the line, or undefined when input ended or the read aborted.
1791
- */
1792
- readLine(signal) {
1793
- const queued = this.pending.shift();
1794
- if (queued !== void 0) return Promise.resolve(queued);
1795
- if (this.ended) return Promise.resolve(void 0);
1796
- if (signal?.aborted === true) return Promise.resolve(void 0);
1797
- return new Promise((resolve) => {
1798
- const waiter = {
1799
- resolve,
1800
- dispose: () => {
1801
- signal?.removeEventListener("abort", onAbort);
1802
- }
1803
- };
1804
- const onAbort = () => {
1805
- const index = this.waiters.indexOf(waiter);
1806
- if (index >= 0) this.waiters.splice(index, 1);
1807
- waiter.dispose();
1808
- resolve(void 0);
1809
- };
1810
- signal?.addEventListener("abort", onAbort, { once: true });
1811
- this.waiters.push(waiter);
1812
- });
1813
- }
1814
- /** Restore the terminal and stop reading. */
1815
- close() {
1816
- if (this.escapeTimer !== void 0) clearTimeout(this.escapeTimer);
1817
- if (this.readsKeys) {
1818
- this.screen?.leave();
1819
- this.output.write(DISABLE_PASTE_MARKERS);
1820
- this.input.setRawMode?.(false);
1821
- this.input.pause();
1822
- }
1823
- this.rl?.close();
1824
- this.end();
1825
- }
1826
- /**
1827
- * Write one line to the terminal the person keeps, not to our viewport.
1828
- *
1829
- * The exit summary is what needs this: the session's own screen disappears
1830
- * with it, so the few facts worth keeping — the session id, what it cost —
1831
- * have to land in the buffer that survives.
1832
- * @param line - the line, without its terminator.
1833
- */
1834
- writeAfterScreen(line) {
1835
- this.output.write(`${line}\n`);
1836
- }
1837
- };
1838
-
1839
- //#endregion
1840
- //#region src/editor.ts
1841
- /** Longest run of history the editor keeps for one session. */
1842
- const HISTORY_LIMIT = 200;
1843
- /**
1844
- * Split text into the units the cursor counts.
1845
- *
1846
- * Code points, not grapheme clusters: a column here is one cursor step, and the
1847
- * terminal moves the cursor by code point too. A combining mark or a ZWJ emoji
1848
- * therefore takes more than one step, which is the same limit the width
1849
- * measurement carries.
1850
- * @param text - the text to split.
1851
- * @returns its code points.
1852
- */
1853
- const points = (text) => Array.from(text);
1854
- /** A multi-line prompt editor. */
1855
- var Editor = class {
1856
- lines = [""];
1857
- row = 0;
1858
- column = 0;
1859
- candidates = [];
1860
- selected = 0;
1861
- history = [];
1862
- /** Where the caller is in history; equals `history.length` when not browsing. */
1863
- browsing = 0;
1864
- /** The buffer set aside while history is being browsed. */
1865
- stashed;
1866
- constructor(sources) {
1867
- this.sources = sources;
1868
- }
1869
- /** What to render. */
1870
- get view() {
1871
- return {
1872
- lines: this.lines,
1873
- row: this.row,
1874
- column: this.column,
1875
- candidates: this.candidates,
1876
- selected: this.selected,
1877
- token: this.token()
1878
- };
1879
- }
1880
- /** The buffer as one string. */
1881
- get text() {
1882
- return this.lines.join("\n");
1883
- }
1884
- /** Whether nothing has been typed. */
1885
- get empty() {
1886
- return this.text === "";
1887
- }
1888
- /**
1889
- * Replace the buffer with earlier text, cursor at its end.
1890
- *
1891
- * This is recall-for-editing: the second Escape puts the previous submission
1892
- * back so it can be corrected and resent.
1893
- * @param text - the text to edit, possibly multi-line.
1894
- */
1895
- prefill(text) {
1896
- this.lines = text.split("\n");
1897
- this.row = this.lines.length - 1;
1898
- this.column = points(this.line()).length;
1899
- this.candidates = [];
1900
- }
1901
- /** Submissions this session recorded, oldest first, for persistence. */
1902
- get pastSubmissions() {
1903
- return this.history;
1904
- }
1905
- /**
1906
- * Preload history from an earlier session.
1907
- *
1908
- * Applied before any live submission, so recall starts where the last
1909
- * session ended rather than empty.
1910
- * @param entries - past submissions, oldest first.
1911
- */
1912
- seedHistory(entries) {
1913
- this.history.splice(0, this.history.length, ...entries.slice(-HISTORY_LIMIT));
1914
- this.browsing = this.history.length;
1915
- }
1916
- /**
1917
- * Apply one key.
1918
- * @param key - the decoded keystroke.
1919
- * @returns what the caller must do about it.
1920
- */
1921
- handle(key) {
1922
- switch (key.kind) {
1923
- case "text": return this.insert(key.text);
1924
- case "paste": return this.insert(key.text);
1925
- case "enter": return this.accept();
1926
- case "newline": return this.insert("\n");
1927
- case "tab": return this.complete();
1928
- case "backspace": return this.backspace();
1929
- case "delete": return this.forwardDelete();
1930
- case "up": return this.moveUp();
1931
- case "down": return this.moveDown();
1932
- case "left": return this.moveLeft();
1933
- case "right": return this.moveRight();
1934
- case "home": return this.jump(0);
1935
- case "end": return this.jump(points(this.line()).length);
1936
- case "kill-line": return this.killLine();
1937
- case "kill-input": return this.killInput();
1938
- case "kill-word": return this.killWord();
1939
- case "word-left": return this.wordLeft();
1940
- case "word-right": return this.wordRight();
1941
- case "escape": return this.cancel();
1942
- case "interrupt": return { kind: "interrupt" };
1943
- case "eof": return this.text === "" ? { kind: "eof" } : { kind: "none" };
1944
- default: return { kind: "none" };
1945
- }
1946
- }
1947
- /** The line the cursor is on. */
1948
- line() {
1949
- return this.lines[this.row] ?? "";
1950
- }
1951
- /** Replace the cursor's line. */
1952
- setLine(text) {
1953
- this.lines[this.row] = text;
1954
- }
1955
- /**
1956
- * Insert text at the cursor, splitting lines on newlines.
1957
- * @param text - the text to insert.
1958
- * @returns always `none`; insertion never completes a read.
1959
- */
1960
- insert(text) {
1961
- const line = this.line();
1962
- const before = points(line).slice(0, this.column).join("");
1963
- const after = points(line).slice(this.column).join("");
1964
- const parts = (before + text + after).split("\n");
1965
- const inserted = (before + text).split("\n");
1966
- this.lines.splice(this.row, 1, ...parts);
1967
- this.row += inserted.length - 1;
1968
- this.column = points(inserted.at(-1) ?? "").length;
1969
- this.refresh();
1970
- return { kind: "none" };
1971
- }
1972
- /**
1973
- * Submit, or accept the highlighted candidate when the menu is open.
1974
- * @returns the submission, or `none` when a candidate was taken instead.
1975
- */
1976
- accept() {
1977
- if (this.candidates.length > 0) return this.take();
1978
- const text = this.text;
1979
- if (text.trim() === "") return { kind: "none" };
1980
- this.remember(text);
1981
- this.lines = [""];
1982
- this.row = 0;
1983
- this.column = 0;
1984
- this.candidates = [];
1985
- return {
1986
- kind: "submit",
1987
- text
1988
- };
1989
- }
1990
- /** Record a submission for history, collapsing an immediate repeat. */
1991
- remember(text) {
1992
- if (this.history.at(-1) !== text) this.history.push(text);
1993
- if (this.history.length > HISTORY_LIMIT) this.history.shift();
1994
- this.browsing = this.history.length;
1995
- this.stashed = void 0;
1996
- }
1997
- /**
1998
- * Open the menu, or move through it when it is already open.
1999
- * @returns always `none`.
2000
- */
2001
- complete() {
2002
- if (this.candidates.length === 0) this.refresh();
2003
- if (this.candidates.length === 1) return this.take();
2004
- if (this.candidates.length > 1) this.selected = (this.selected + 1) % this.candidates.length;
2005
- return { kind: "none" };
2006
- }
2007
- /**
2008
- * Replace the token under the cursor with the selected candidate.
2009
- * @returns always `none`.
2010
- */
2011
- take() {
2012
- const candidate = this.candidates[this.selected];
2013
- this.candidates = [];
2014
- if (candidate === void 0) return { kind: "none" };
2015
- const cells = points(this.line());
2016
- const start = this.tokenStart();
2017
- const replaced = [
2018
- ...cells.slice(0, start),
2019
- candidate.value,
2020
- ...cells.slice(this.column)
2021
- ];
2022
- this.setLine(replaced.join(""));
2023
- this.column = start + points(candidate.value).length;
2024
- return { kind: "none" };
2025
- }
2026
- /** Where the token under the cursor begins, in code points. */
2027
- tokenStart() {
2028
- return points(this.line()).slice(0, this.column).lastIndexOf(" ") + 1;
2029
- }
2030
- /** The token under the cursor. */
2031
- token() {
2032
- return points(this.line()).slice(this.tokenStart(), this.column).join("");
2033
- }
2034
- /**
2035
- * Recompute the candidate list for the token under the cursor.
2036
- *
2037
- * Recomputed on every edit rather than only on Tab, which is what makes the
2038
- * menu appear as a command is typed instead of after a key that asks for it.
2039
- */
2040
- refresh() {
2041
- const token = this.token();
2042
- const wholeLine = this.row === 0 && this.tokenStart() === 0;
2043
- const line = this.lines[0] ?? "";
2044
- const command = /^\/([a-z][a-z0-9_-]*) /.exec(line)?.[1];
2045
- const inArgument = this.row === 0 && command !== void 0 && this.tokenStart() === command.length + 2;
2046
- if (token.startsWith("@")) {
2047
- const [values] = this.sources.paths(token);
2048
- this.candidates = values.map((value) => ({
2049
- value,
2050
- detail: ""
2051
- }));
2052
- } else if (wholeLine && token.startsWith("/")) this.candidates = this.sources.commands().filter((entry) => `/${entry.name}`.startsWith(token)).map((entry) => ({
2053
- value: `/${entry.name}`,
2054
- detail: entry.description
2055
- }));
2056
- else if (inArgument) this.candidates = [...this.sources.commandArguments?.(command, token) ?? []];
2057
- else this.candidates = [];
2058
- if (this.candidates.length === 1 && this.candidates[0]?.value === token) this.candidates = [];
2059
- this.selected = 0;
2060
- }
2061
- /** Remove the character before the cursor, joining lines at a boundary. */
2062
- backspace() {
2063
- if (this.column > 0) {
2064
- const cells = points(this.line());
2065
- cells.splice(this.column - 1, 1);
2066
- this.setLine(cells.join(""));
2067
- this.column -= 1;
2068
- } else if (this.row > 0) {
2069
- const previous = this.lines[this.row - 1] ?? "";
2070
- const current = this.line();
2071
- this.lines.splice(this.row - 1, 2, previous + current);
2072
- this.row -= 1;
2073
- this.column = points(previous).length;
2074
- }
2075
- this.refresh();
2076
- return { kind: "none" };
2077
- }
2078
- /** Remove the character after the cursor, joining lines at a boundary. */
2079
- forwardDelete() {
2080
- const cells = points(this.line());
2081
- if (this.column < cells.length) {
2082
- cells.splice(this.column, 1);
2083
- this.setLine(cells.join(""));
2084
- } else if (this.row < this.lines.length - 1) {
2085
- const next = this.lines[this.row + 1] ?? "";
2086
- this.lines.splice(this.row, 2, this.line() + next);
2087
- }
2088
- this.refresh();
2089
- return { kind: "none" };
2090
- }
2091
- /** Move up a line, or back through history from the first line. */
2092
- moveUp() {
2093
- if (this.candidates.length > 0) {
2094
- this.selected = (this.selected - 1 + this.candidates.length) % this.candidates.length;
2095
- return { kind: "none" };
2096
- }
2097
- if (this.row > 0) {
2098
- this.row -= 1;
2099
- this.column = Math.min(this.column, points(this.line()).length);
2100
- return { kind: "none" };
2101
- }
2102
- return this.recall(-1);
2103
- }
2104
- /** Move down a line, or forward through history from the last line. */
2105
- moveDown() {
2106
- if (this.candidates.length > 0) {
2107
- this.selected = (this.selected + 1) % this.candidates.length;
2108
- return { kind: "none" };
2109
- }
2110
- if (this.row < this.lines.length - 1) {
2111
- this.row += 1;
2112
- this.column = Math.min(this.column, points(this.line()).length);
2113
- return { kind: "none" };
2114
- }
2115
- return this.recall(1);
2116
- }
2117
- /**
2118
- * Step through history.
2119
- * @param delta - -1 for older, 1 for newer.
2120
- * @returns always `none`.
2121
- */
2122
- recall(delta) {
2123
- const next = this.browsing + delta;
2124
- if (next < 0 || next > this.history.length) return { kind: "none" };
2125
- if (this.browsing === this.history.length) this.stashed = this.lines;
2126
- this.browsing = next;
2127
- this.lines = [...next === this.history.length ? this.stashed ?? [""] : (this.history[next] ?? "").split("\n")];
2128
- this.row = this.lines.length - 1;
2129
- this.column = points(this.line()).length;
2130
- this.candidates = [];
2131
- return { kind: "none" };
2132
- }
2133
- /** Move the cursor one position left, wrapping to the previous line. */
2134
- moveLeft() {
2135
- if (this.column > 0) this.column -= 1;
2136
- else if (this.row > 0) {
2137
- this.row -= 1;
2138
- this.column = points(this.line()).length;
2139
- }
2140
- return { kind: "none" };
2141
- }
2142
- /** Move the cursor one position right, wrapping to the next line. */
2143
- moveRight() {
2144
- if (this.column < points(this.line()).length) this.column += 1;
2145
- else if (this.row < this.lines.length - 1) {
2146
- this.row += 1;
2147
- this.column = 0;
2148
- }
2149
- return { kind: "none" };
2150
- }
2151
- /**
2152
- * Put the cursor at a column on the current line.
2153
- * @param column - the target column.
2154
- * @returns always `none`.
2155
- */
2156
- jump(column) {
2157
- this.column = column;
2158
- return { kind: "none" };
2159
- }
2160
- /** Drop everything after the cursor on this line. */
2161
- killLine() {
2162
- this.setLine(points(this.line()).slice(0, this.column).join(""));
2163
- this.refresh();
2164
- return { kind: "none" };
2165
- }
2166
- /** Drop everything before the cursor on this line. */
2167
- killInput() {
2168
- this.setLine(points(this.line()).slice(this.column).join(""));
2169
- this.column = 0;
2170
- this.refresh();
2171
- return { kind: "none" };
2172
- }
2173
- /** Move the cursor to the start of the word before it. */
2174
- wordLeft() {
2175
- const cells = points(this.line());
2176
- let at = this.column;
2177
- while (at > 0 && cells[at - 1] === " ") at -= 1;
2178
- while (at > 0 && cells[at - 1] !== " ") at -= 1;
2179
- this.column = at;
2180
- return { kind: "none" };
2181
- }
2182
- /** Move the cursor past the end of the word after it. */
2183
- wordRight() {
2184
- const cells = points(this.line());
2185
- let at = this.column;
2186
- while (at < cells.length && cells[at] === " ") at += 1;
2187
- while (at < cells.length && cells[at] !== " ") at += 1;
2188
- this.column = at;
2189
- return { kind: "none" };
2190
- }
2191
- /** Drop the word before the cursor. */
2192
- killWord() {
2193
- const cells = points(this.line());
2194
- let at = this.column;
2195
- while (at > 0 && cells[at - 1] === " ") at -= 1;
2196
- while (at > 0 && cells[at - 1] !== " ") at -= 1;
2197
- this.setLine([...cells.slice(0, at), ...cells.slice(this.column)].join(""));
2198
- this.column = at;
2199
- this.refresh();
2200
- return { kind: "none" };
2201
- }
2202
- /**
2203
- * Close the menu, or report Escape when there is none to close.
2204
- * @returns `none` when a menu was dismissed, otherwise `escape`.
2205
- */
2206
- cancel() {
2207
- if (this.candidates.length > 0) {
2208
- this.candidates = [];
2209
- return { kind: "none" };
2210
- }
2211
- return { kind: "escape" };
2212
- }
2213
- };
2214
-
2215
- //#endregion
2216
- //#region src/inputbox.ts
2217
- /** How many candidates the menu shows before it says how many it hid. */
2218
- const MENU_LIMIT = 8;
2219
- /** Columns the frame itself occupies: two borders and two pads. */
2220
- const FRAME_WIDTH = 4;
2221
- /** Columns the gutter occupies inside the frame: the marker and its space. */
2222
- const GUTTER_WIDTH = 2;
2223
- /** Content rows shown before the box windows around the cursor. */
2224
- const MAX_CONTENT_ROWS = 6;
2225
- /**
2226
- * Wrap one logical line into segments no wider than the budget.
2227
- * @param line - the logical line.
2228
- * @param logical - its index.
2229
- * @param budget - display columns per segment.
2230
- * @returns at least one segment, empty lines included.
2231
- */
2232
- function wrapLine(line, logical, budget) {
2233
- const cells = Array.from(line);
2234
- const rows = [];
2235
- let start = 0;
2236
- let width = 0;
2237
- let length = 0;
2238
- for (const cell of cells) {
2239
- const cost = displayWidth(cell);
2240
- if (width + cost > budget && length > 0) {
2241
- rows.push({
2242
- text: cells.slice(start, start + length).join(""),
2243
- logical,
2244
- start,
2245
- length,
2246
- last: false
2247
- });
2248
- start += length;
2249
- width = 0;
2250
- length = 0;
2251
- }
2252
- width += cost;
2253
- length += 1;
2254
- }
2255
- rows.push({
2256
- text: cells.slice(start, start + length).join(""),
2257
- logical,
2258
- start,
2259
- length,
2260
- last: true
2261
- });
2262
- return rows;
2263
- }
2264
- /**
2265
- * Lay out the input box.
2266
- * @param view - what the editor is showing.
2267
- * @param theme - styling for the frame, the marker, and the menu.
2268
- * @param columns - display columns available.
2269
- * @param options - placeholder, hint, and frame accent.
2270
- * @returns the rows and cursor position.
2271
- */
2272
- function inputBox(view, theme, columns, options = {}) {
2273
- const accent = options.accent ?? ((text) => theme.dim(text));
2274
- const inner = Math.max(8, columns - FRAME_WIDTH);
2275
- const budget = inner - GUTTER_WIDTH;
2276
- const rule = "─".repeat(inner + 2);
2277
- const visual = view.lines.flatMap((line, index) => wrapLine(line, index, budget));
2278
- const cursorVisual = visual.findIndex((row) => row.logical === view.row && view.column >= row.start && (view.column < row.start + row.length || row.last && view.column === row.start + row.length));
2279
- const cursorAt = Math.max(0, cursorVisual);
2280
- const start = Math.max(0, Math.min(visual.length - MAX_CONTENT_ROWS, cursorAt - (MAX_CONTENT_ROWS - 1)));
2281
- const end = Math.min(visual.length, start + MAX_CONTENT_ROWS);
2282
- const shown = visual.slice(start, end);
2283
- const empty = view.lines.length === 1 && view.lines[0] === "";
2284
- const rows = [accent(`╭${rule}╮`)];
2285
- if (empty && options.placeholder !== void 0) {
2286
- const text = truncate(options.placeholder, budget);
2287
- const pad = " ".repeat(Math.max(0, budget - displayWidth(text)));
2288
- rows.push(`${accent("│")} ${theme.user("›")} ${theme.dim(text)}${pad} ${accent("│")}`);
2289
- } else shown.forEach((row, index) => {
2290
- const first = start + index === 0;
2291
- const clippedAbove = index === 0 && start > 0;
2292
- const clippedBelow = index === shown.length - 1 && end < visual.length;
2293
- const gutter = clippedAbove || clippedBelow ? theme.dim("…") : first ? theme.user("›") : " ";
2294
- const pad = " ".repeat(Math.max(0, budget - displayWidth(row.text)));
2295
- rows.push(`${accent("│")} ${gutter} ${row.text}${pad} ${accent("│")}`);
2296
- });
2297
- rows.push(accent(`╰${rule}╯`));
2298
- if (view.candidates.length > 0) {
2299
- const first = Math.min(Math.max(0, view.selected - MENU_LIMIT + 1), Math.max(0, view.candidates.length - MENU_LIMIT));
2300
- const menu = view.candidates.slice(first, first + MENU_LIMIT);
2301
- const width = Math.max(...menu.map((candidate) => displayWidth(candidate.value)));
2302
- if (first > 0) rows.push(theme.dim(` ↑ ${first} more`));
2303
- menu.forEach((candidate, index) => {
2304
- const chosen = first + index === view.selected;
2305
- const matched = view.token !== "" && candidate.value.startsWith(view.token) ? view.token.length : 0;
2306
- const head = candidate.value.slice(0, matched);
2307
- const tail = candidate.value.slice(matched);
2308
- const label = chosen ? theme.bold(theme.tool(candidate.value)) : `${theme.tool(head)}${tail}`;
2309
- const pad = " ".repeat(Math.max(0, width - displayWidth(candidate.value)));
2310
- const detail = candidate.detail === "" ? "" : ` ${candidate.detail}`;
2311
- rows.push(truncate(`${chosen ? theme.user("❯") : " "} ${label}${pad}${theme.dim(detail)}`, columns));
2312
- });
2313
- const below = view.candidates.length - first - menu.length;
2314
- if (below > 0) rows.push(theme.dim(` ↓ ${below} more`));
2315
- } else if (options.hint !== void 0) rows.push(theme.dim(truncate(options.hint, columns)));
2316
- const inCursorRow = visual[cursorAt];
2317
- const before = inCursorRow === void 0 ? "" : Array.from(view.lines[view.row] ?? "").slice(inCursorRow.start, view.column).join("");
2318
- return {
2319
- rows,
2320
- cursorRow: 1 + (cursorAt - start),
2321
- cursorColumn: FRAME_WIDTH + Math.min(displayWidth(before), budget)
2322
- };
2323
- }
2324
-
2325
- //#endregion
2326
- //#region src/selector.ts
2327
- /** An in-progress selection. */
2328
- /** Option rows shown at once; the window follows the marked row. */
2329
- const VISIBLE_ROWS = 10;
2330
- var Selector = class {
2331
- selected = 0;
2332
- checked = /* @__PURE__ */ new Set();
2333
- constructor(spec) {
2334
- this.spec = spec;
2335
- }
2336
- /** How many rows the widget offers, the custom row included. */
2337
- get count() {
2338
- return this.spec.options.length + (this.spec.custom === void 0 ? 0 : 1);
2339
- }
2340
- /** Whether a row index is the custom "type your own" row. */
2341
- isCustom(index) {
2342
- return this.spec.custom !== void 0 && index === this.spec.options.length;
2343
- }
2344
- /**
2345
- * Apply one key.
2346
- * @param key - the decoded keystroke.
2347
- * @returns whether the selection settled, and how.
2348
- */
2349
- handle(key) {
2350
- switch (key.kind) {
2351
- case "up":
2352
- this.selected = (this.selected - 1 + this.count) % this.count;
2353
- return { kind: "pending" };
2354
- case "down":
2355
- case "tab":
2356
- this.selected = (this.selected + 1) % this.count;
2357
- return { kind: "pending" };
2358
- case "enter": return this.accept(this.selected);
2359
- case "escape": return {
2360
- kind: "done",
2361
- outcome: { kind: "cancelled" }
2362
- };
2363
- case "text": return this.typed(key.text);
2364
- default: return { kind: "pending" };
2365
- }
2366
- }
2367
- /**
2368
- * Resolve a typed character: a digit jumps, a shortcut picks, Space toggles.
2369
- * @param text - what was typed.
2370
- * @returns whether the selection settled.
2371
- */
2372
- typed(text) {
2373
- if (this.spec.multi === true && text === " ") {
2374
- if (!this.isCustom(this.selected)) if (this.checked.has(this.selected)) this.checked.delete(this.selected);
2375
- else this.checked.add(this.selected);
2376
- return { kind: "pending" };
2377
- }
2378
- const digit = Number(text);
2379
- if (Number.isInteger(digit) && digit >= 1 && digit <= this.count) {
2380
- if (this.spec.multi === true && !this.isCustom(digit - 1)) {
2381
- this.selected = digit - 1;
2382
- if (this.checked.has(digit - 1)) this.checked.delete(digit - 1);
2383
- else this.checked.add(digit - 1);
2384
- return { kind: "pending" };
2385
- }
2386
- return this.accept(digit - 1);
2387
- }
2388
- const shortcut = this.spec.options.findIndex((option) => option.shortcut === text.toLowerCase());
2389
- if (shortcut >= 0) return this.accept(shortcut);
2390
- return { kind: "pending" };
2391
- }
2392
- /**
2393
- * Settle on a row.
2394
- * @param index - the row accepted.
2395
- * @returns the settled step.
2396
- */
2397
- accept(index) {
2398
- if (this.isCustom(index)) return {
2399
- kind: "done",
2400
- outcome: { kind: "custom" }
2401
- };
2402
- if (this.spec.multi === true) return {
2403
- kind: "done",
2404
- outcome: {
2405
- kind: "chosen",
2406
- indices: this.checked.size > 0 ? [...this.checked].sort((a, b) => a - b) : [index]
2407
- }
2408
- };
2409
- return {
2410
- kind: "done",
2411
- outcome: {
2412
- kind: "chosen",
2413
- indices: [index]
2414
- }
2415
- };
2416
- }
2417
- /**
2418
- * Render the widget.
2419
- * @param theme - styling for the marker, shortcuts, and details.
2420
- * @param columns - display columns available per row.
2421
- * @returns the rows, title first.
2422
- */
2423
- view(theme, columns) {
2424
- const rows = [theme.bold(truncate(this.spec.title, columns))];
2425
- const total = this.count;
2426
- const first = Math.min(Math.max(0, this.selected - VISIBLE_ROWS + 1), Math.max(0, total - VISIBLE_ROWS));
2427
- if (first > 0) rows.push(theme.dim(` ↑ ${first} more`));
2428
- for (let index = first; index < Math.min(total, first + VISIBLE_ROWS); index += 1) {
2429
- const option = this.spec.options[index];
2430
- if (option !== void 0) rows.push(this.row(index, this.label(option, theme), option.detail, theme, columns));
2431
- else if (this.spec.custom !== void 0) rows.push(this.row(index, theme.dim(this.spec.custom), void 0, theme, columns));
2432
- }
2433
- const below = total - first - VISIBLE_ROWS;
2434
- if (below > 0) rows.push(theme.dim(` ↓ ${below} more`));
2435
- const how = this.spec.multi === true ? "Space toggles · Enter confirms · Esc cancels" : "↑↓ move · Enter accepts · Esc cancels";
2436
- rows.push(theme.dim(truncate(` ${how}`, columns)));
2437
- return rows;
2438
- }
2439
- /**
2440
- * One option's label with its number and shortcut.
2441
- * @param option - the option to label.
2442
- * @param theme - styling for the shortcut.
2443
- * @returns the label text.
2444
- */
2445
- label(option, theme) {
2446
- const shortcut = option.shortcut === void 0 ? "" : theme.dim(` (${option.shortcut})`);
2447
- return `${option.label}${shortcut}`;
2448
- }
2449
- /**
2450
- * One rendered row.
2451
- * @param index - the row's index.
2452
- * @param label - the row's label, already styled.
2453
- * @param detail - dim context beside it.
2454
- * @param theme - styling for the marker and detail.
2455
- * @param columns - display columns available.
2456
- * @returns the row text.
2457
- */
2458
- row(index, label, detail, theme, columns) {
2459
- const marked = index === this.selected;
2460
- const marker = marked ? theme.user("❯") : " ";
2461
- const box = this.spec.multi === true && !this.isCustom(index) ? this.checked.has(index) ? theme.success("◉ ") : theme.dim("○ ") : "";
2462
- const number = theme.dim(`${index + 1}.`);
2463
- const trail = detail === void 0 || detail === "" ? "" : theme.dim(` ${detail}`);
2464
- return truncate(`${marker} ${number} ${box}${marked ? theme.bold(theme.tool(label)) : label}${trail}`, columns);
2465
- }
2466
- };
2467
-
2468
- //#endregion
2469
- //#region src/prompt.ts
2470
- /** How long a flash notice holds the hint row. */
2471
- const FLASH_MS = 1500;
2472
- /** Drives the input box and answers reads and selections. */
2473
- var Prompt = class {
2474
- editor;
2475
- pending;
2476
- select_;
2477
- /**
2478
- * Submissions made before anything asked for them.
2479
- *
2480
- * Typing while the agent works — or in the instant before a read begins — must
2481
- * not be lost; the queue is what a line reader provides for free.
2482
- */
2483
- queued = [];
2484
- /** The working indicator shown under the box. */
2485
- hint;
2486
- /** A short-lived notice that borrows the hint row, e.g. the copy toast. */
2487
- flash;
2488
- flashTimer;
2489
- /** The always-current session facts shown as the region's last row. */
2490
- status;
2491
- /** The assistant line still arriving, shown above the box. */
2492
- streaming;
2493
- /** Frame styling for the current mode, e.g. plan mode's accent. */
2494
- accent;
2495
- /** Whether a read is outstanding, which decides where a submission goes. */
2496
- reading = false;
2497
- /** Wheel rows accumulated this tick, painted once — scrolling per event janks. */
2498
- pendingScroll = 0;
2499
- scrollFlushQueued = false;
2500
- /**
2501
- * Whether the interactive session is running, which is when the box is worth
2502
- * drawing. The box stays up while the agent works — typing ahead must be
2503
- * visible, and a prompt that vanishes for every turn reads as losing focus —
2504
- * so this is session-scoped, not read-scoped.
2505
- */
2506
- engaged = false;
2507
- constructor(console, theme, sources, handlers, placeholder) {
2508
- this.console = console;
2509
- this.theme = theme;
2510
- this.handlers = handlers;
2511
- this.placeholder = placeholder;
2512
- this.editor = new Editor(sources);
2513
- if (this.console.readsKeys) {
2514
- this.console.onKey((key) => {
2515
- this.onKey(key);
2516
- });
2517
- this.console.onResize(() => {
2518
- this.render();
2519
- });
2520
- }
2521
- }
2522
- /** The editor's submission history, for persistence. */
2523
- get history() {
2524
- return this.editor.pastSubmissions;
2525
- }
2526
- /**
2527
- * Preload history from an earlier session.
2528
- * @param entries - past submissions, oldest first.
2529
- */
2530
- seedHistory(entries) {
2531
- this.editor.seedHistory(entries);
2532
- }
2533
- /** Whether the box holds no typed text. */
2534
- get empty() {
2535
- return this.editor.empty;
2536
- }
2537
- /**
2538
- * Show the input box from now on, independent of an outstanding read.
2539
- * @param engaged - whether the interactive session is running.
2540
- */
2541
- setEngaged(engaged) {
2542
- this.engaged = engaged;
2543
- this.render();
2544
- }
2545
- /**
2546
- * Put earlier text back into the box for editing.
2547
- * @param text - the text to edit.
2548
- */
2549
- prefill(text) {
2550
- this.editor.prefill(text);
2551
- this.render();
2552
- }
2553
- /**
2554
- * Set the working indicator under the box.
2555
- * @param text - the text, or undefined to drop the row.
2556
- */
2557
- setHint(text) {
2558
- if (text === this.hint) return;
2559
- this.hint = text;
2560
- this.render();
2561
- }
2562
- /**
2563
- * Show a notice on the hint row briefly, then give the row back.
2564
- *
2565
- * The hint row belongs to the working indicator, which repaints itself
2566
- * continuously — a notice written through setHint would last one tick. The
2567
- * flash outranks the hint until its moment passes.
2568
- * @param text - the styled notice.
2569
- */
2570
- setFlash(text) {
2571
- this.flash = text;
2572
- if (this.flashTimer !== void 0) clearTimeout(this.flashTimer);
2573
- this.flashTimer = setTimeout(() => {
2574
- this.flash = void 0;
2575
- this.flashTimer = void 0;
2576
- this.render();
2577
- }, FLASH_MS);
2578
- this.flashTimer.unref();
2579
- this.render();
2580
- }
2581
- /**
2582
- * Set the status row, the region's always-current last line.
2583
- * @param text - the styled row, or undefined to drop it.
2584
- */
2585
- setStatus(text) {
2586
- if (text === this.status) return;
2587
- this.status = text;
2588
- this.render();
2589
- }
2590
- /**
2591
- * Set the frame accent, which is how a mode shows on the box itself.
2592
- * @param accent - the styling, or undefined for the default frame.
2593
- */
2594
- setAccent(accent) {
2595
- this.accent = accent;
2596
- this.render();
2597
- }
2598
- /**
2599
- * Set the assistant line currently arriving, shown above the box.
2600
- * @param text - the partial line, or undefined when none is open.
2601
- */
2602
- setStreaming(text) {
2603
- this.streaming = text;
2604
- this.render();
2605
- }
2606
- /**
2607
- * Write one finished transcript line above the region.
2608
- * @param line - the line to keep.
2609
- */
2610
- write(line) {
2611
- this.console.write(line);
2612
- }
2613
- /**
2614
- * Wait for the next submitted text.
2615
- * @param signal - abandons the read, which an aborted tool call does.
2616
- * @returns the text, or undefined when input ended or the read was abandoned.
2617
- */
2618
- read(signal) {
2619
- if (!this.console.readsKeys) return this.console.readLine(signal);
2620
- const typedAhead = this.queued.shift();
2621
- if (typedAhead !== void 0) return Promise.resolve(typedAhead);
2622
- if (this.console.finished) return Promise.resolve(void 0);
2623
- this.reading = true;
2624
- this.render();
2625
- return new Promise((resolve) => {
2626
- const settle = (text) => {
2627
- this.pending = void 0;
2628
- this.reading = false;
2629
- resolve(text);
2630
- };
2631
- const onAbort = () => {
2632
- settle(void 0);
2633
- };
2634
- this.pending = {
2635
- resolve: settle,
2636
- dispose: () => {
2637
- signal?.removeEventListener("abort", onAbort);
2638
- }
2639
- };
2640
- signal?.addEventListener("abort", onAbort, { once: true });
2641
- });
2642
- }
2643
- /**
2644
- * Put one decision to the keyboard as an arrow-key selection.
2645
- *
2646
- * Only the terminal shape can offer this; the caller keeps a line-based
2647
- * fallback for pipes, where the selection keys cannot arrive.
2648
- * @param spec - the question and its options.
2649
- * @param signal - cancels the selection, which an aborted tool call does.
2650
- * @returns how the person decided.
2651
- */
2652
- select(spec, signal) {
2653
- if (this.console.finished || signal?.aborted === true) return Promise.resolve({ kind: "cancelled" });
2654
- return new Promise((resolve) => {
2655
- const settle = (outcome) => {
2656
- this.select_ = void 0;
2657
- resolve(outcome);
2658
- this.render();
2659
- };
2660
- const onAbort = () => {
2661
- settle({ kind: "cancelled" });
2662
- };
2663
- this.select_ = {
2664
- selector: new Selector(spec),
2665
- resolve: settle,
2666
- dispose: () => {
2667
- signal?.removeEventListener("abort", onAbort);
2668
- }
2669
- };
2670
- signal?.addEventListener("abort", onAbort, { once: true });
2671
- this.render();
2672
- });
2673
- }
2674
- /** Take the region down, so what follows lands at the bottom of the screen. */
2675
- clear() {
2676
- this.reading = false;
2677
- this.console.clearRegion();
2678
- }
2679
- /**
2680
- * Apply one key: control keys to the owner, a selection's keys to the
2681
- * selector, everything else to the editor.
2682
- * @param key - the decoded keystroke.
2683
- */
2684
- onKey(key) {
2685
- if (key.kind === "interrupt") {
2686
- this.handlers.interrupt();
2687
- return;
2688
- }
2689
- if (key.kind === "clear-screen") {
2690
- this.console.clearScreen();
2691
- this.render();
2692
- return;
2693
- }
2694
- if (key.kind === "shift-tab") {
2695
- this.handlers.shiftTab?.();
2696
- return;
2697
- }
2698
- if (key.kind === "expand-output") {
2699
- this.handlers.expandOutput?.();
2700
- return;
2701
- }
2702
- if (key.kind === "page") {
2703
- this.console.scrollPage(key.direction);
2704
- this.render();
2705
- return;
2706
- }
2707
- if (key.kind === "scroll") {
2708
- this.pendingScroll += key.lines;
2709
- if (!this.scrollFlushQueued) {
2710
- this.scrollFlushQueued = true;
2711
- queueMicrotask(() => {
2712
- this.scrollFlushQueued = false;
2713
- const delta = this.pendingScroll;
2714
- this.pendingScroll = 0;
2715
- if (delta !== 0) {
2716
- this.console.scrollBy(delta);
2717
- this.render();
2718
- }
2719
- });
2720
- }
2721
- return;
2722
- }
2723
- if (key.kind === "scroll-end") {
2724
- this.console.scrollToBottom();
2725
- this.render();
2726
- return;
2727
- }
2728
- if (key.kind === "mouse-down") {
2729
- this.console.mouseDown(key.row, key.column);
2730
- return;
2731
- }
2732
- if (key.kind === "mouse-drag") {
2733
- this.console.mouseDrag(key.row, key.column);
2734
- return;
2735
- }
2736
- if (key.kind === "mouse-up") {
2737
- const text = this.console.mouseUp();
2738
- if (text !== void 0 && this.console.copyText(text)) {
2739
- const rows = text.split("\n").length;
2740
- this.setFlash(this.theme.dim(rows > 1 ? ` ✓ copied ${rows} lines` : " ✓ copied"));
2741
- }
2742
- return;
2743
- }
2744
- const selecting = this.select_;
2745
- if (selecting !== void 0) {
2746
- const step = selecting.selector.handle(key);
2747
- if (step.kind === "done") {
2748
- selecting.dispose();
2749
- selecting.resolve(step.outcome);
2750
- return;
2751
- }
2752
- this.render();
2753
- return;
2754
- }
2755
- const action = this.editor.handle(key);
2756
- switch (action.kind) {
2757
- case "submit": {
2758
- const waiting = this.pending;
2759
- if (waiting === void 0) {
2760
- this.queued.push(action.text);
2761
- break;
2762
- }
2763
- waiting.dispose();
2764
- waiting.resolve(action.text);
2765
- this.console.scrollToBottom();
2766
- break;
2767
- }
2768
- case "escape":
2769
- this.handlers.escape();
2770
- break;
2771
- case "eof": {
2772
- const waiting = this.pending;
2773
- if (waiting !== void 0) {
2774
- waiting.dispose();
2775
- waiting.resolve(void 0);
2776
- }
2777
- this.handlers.eof();
2778
- break;
2779
- }
2780
- default: break;
2781
- }
2782
- this.render();
2783
- }
2784
- /** Recompose and redraw the bottom region. */
2785
- render() {
2786
- if (!this.console.readsKeys) return;
2787
- const columns = this.console.columns - 1;
2788
- const rows = [];
2789
- let cursor = {
2790
- row: 0,
2791
- column: 0
2792
- };
2793
- if (this.streaming !== void 0) rows.push(this.streaming);
2794
- if (this.select_ !== void 0) rows.push(...this.select_.selector.view(this.theme, columns));
2795
- else if (this.engaged || this.reading) {
2796
- const box = inputBox(this.editor.view, this.theme, columns, {
2797
- placeholder: this.placeholder,
2798
- accent: this.accent
2799
- });
2800
- cursor = {
2801
- row: rows.length + box.cursorRow,
2802
- column: box.cursorColumn
2803
- };
2804
- rows.push(...box.rows);
2805
- }
2806
- if (this.queued.length > 0) {
2807
- const preview = this.queued[0] ?? "";
2808
- const more = this.queued.length > 1 ? ` (+${this.queued.length - 1} more)` : "";
2809
- rows.push(this.theme.dim(truncate(` ↳ queued: ${preview.split("\n")[0] ?? ""}${more}`, columns)));
2810
- }
2811
- this.console.setScrollNotice(this.console.scrolledBy > 0 ? this.theme.dim(truncate(` ↑ ${this.console.scrolledBy} rows above · PgDn returns to the latest`, columns)) : "");
2812
- const notice = this.flash ?? this.hint;
2813
- if (notice !== void 0) rows.push(notice);
2814
- if (this.status !== void 0) rows.push(this.status);
2815
- if (rows.length === 0) {
2816
- this.console.clearRegion();
2817
- return;
2818
- }
2819
- const focus = this.select_ === void 0 && (this.engaged || this.reading);
2820
- if (!focus) cursor = {
2821
- row: rows.length - 1,
2822
- column: 0
2823
- };
2824
- this.console.setRegion(rows, cursor, focus);
2825
- }
2826
- };
2827
-
2828
- //#endregion
2829
- //#region src/preset-install.ts
2830
- /** The preset this bundle's patch names as the roster default. */
2831
- const PACKAGED_PRESET = "code-cli";
2832
- /**
2833
- * The writable roster root, relative to the Harness home.
2834
- *
2835
- * Repeated rather than imported: `dsh-agent-presets` keeps this as its own
2836
- * internal `USER_PRESET_DIR` and does not export it, and this package installs
2837
- * from npm against a PUBLISHED dependency — an import added to the workspace
2838
- * source would resolve here and break for everyone else. `tests/` asserts the
2839
- * two stay equal, so a rename upstream fails loudly instead of silently
2840
- * installing into a directory nothing reads.
2841
- */
2842
- const USER_PRESET_DIR = ".agent-presets";
2843
- /**
2844
- * The packaged preset directory.
2845
- *
2846
- * Resolved from this module rather than the process cwd so it is correct in the
2847
- * built `lib/` layout and in the source tree alike; `agent-presets/` sits beside
2848
- * both.
2849
- */
2850
- const PACKAGED_ROOT = fileURLToPath(new URL("../agent-presets/", import.meta.url));
2851
- /**
2852
- * Copy the packaged preset into the user root unless it is already there.
2853
- * @param home - the user preset root; defaults to the Harness home's.
2854
- * @returns where the preset lives and whether this call wrote it.
2855
- */
2856
- async function installPackagedPreset(home = dshHomePath(USER_PRESET_DIR)) {
2857
- const target = join(home, PACKAGED_PRESET);
2858
- if (await readdir(target).catch(() => void 0) !== void 0) return {
2859
- path: target,
2860
- installed: false
2861
- };
2862
- const source = join(PACKAGED_ROOT, PACKAGED_PRESET);
2863
- const entries = await readdir(source, { withFileTypes: true });
2864
- await mkdir(target, { recursive: true });
2865
- for (const entry of entries) {
2866
- if (!entry.isFile()) continue;
2867
- await copyFile(join(source, entry.name), join(target, entry.name));
2868
- }
2869
- return {
2870
- path: target,
2871
- installed: true
2872
- };
2873
- }
2874
-
2875
- //#endregion
2876
- //#region src/markdown.ts
2877
- /** Opens or closes a fenced block, capturing its language. */
2878
- const FENCE = /^\s*(?:```|~~~)\s*([\w+-]*)\s*$/;
2879
- /** An ATX heading and its text. */
2880
- const HEADING = /^(#{1,6})\s+(.*)$/;
2881
- /** A bullet item: the indent, the marker, and the text. */
2882
- const BULLET = /^(\s*)[-*+]\s+(.*)$/;
2883
- /** A numbered item: the indent, the number, and the text. */
2884
- const NUMBERED = /^(\s*)(\d+)[.)]\s+(.*)$/;
2885
- /** A blockquote line. */
2886
- const QUOTE = /^\s*>\s?(.*)$/;
2887
- /** A thematic break. */
2888
- const RULE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
2889
- /** A table row: pipe-delimited cells with a leading and trailing pipe. */
2890
- const TABLE_ROW = /^\s*\|.*\|\s*$/;
2891
- /** A table's delimiter cell: dashes with optional alignment colons. */
2892
- const TABLE_DELIMITER = /^:?-+:?$/;
2893
- /**
2894
- * Inline constructs, in one alternation so each is consumed once.
2895
- *
2896
- * Ordered so the longer delimiter wins: `**bold**` must not be read as two
2897
- * empty emphases. Code spans come first because their content is literal.
2898
- *
2899
- * The underscore forms are guarded against intraword matches, which is what
2900
- * Markdown itself requires: without the guard `some_helper_name` reads as an
2901
- * emphasis and the identifier comes out mangled.
2902
- */
2903
- const INLINE = /(`[^`]+`)|(\*\*[^*]+\*\*)|((?<!\w)__[^_]+__(?!\w))|(\[[^\]]*\]\([^)]*\))|(\*[^*\s][^*]*\*)|((?<!\w)_[^_\s][^_]*_(?!\w))/g;
2904
- /**
2905
- * Keywords shared across the languages this surface commonly shows.
2906
- *
2907
- * Deliberately conservative: a word that reads as a keyword in one language and
2908
- * as an ordinary name in another is left out, because colouring `go(...)` or
2909
- * `use(...)` as a keyword is a visible error while missing one is not.
2910
- */
2911
- const KEYWORDS = new Set([
2912
- "as",
2913
- "async",
2914
- "await",
2915
- "break",
2916
- "case",
2917
- "catch",
2918
- "class",
2919
- "const",
2920
- "continue",
2921
- "def",
2922
- "default",
2923
- "defer",
2924
- "elif",
2925
- "else",
2926
- "enum",
2927
- "export",
2928
- "extends",
2929
- "false",
2930
- "finally",
2931
- "fn",
2932
- "for",
2933
- "from",
2934
- "func",
2935
- "function",
2936
- "if",
2937
- "impl",
2938
- "import",
2939
- "in",
2940
- "instanceof",
2941
- "interface",
2942
- "lambda",
2943
- "let",
2944
- "match",
2945
- "new",
2946
- "nil",
2947
- "none",
2948
- "null",
2949
- "package",
2950
- "private",
2951
- "protected",
2952
- "public",
2953
- "raise",
2954
- "return",
2955
- "self",
2956
- "static",
2957
- "struct",
2958
- "super",
2959
- "switch",
2960
- "this",
2961
- "throw",
2962
- "trait",
2963
- "true",
2964
- "try",
2965
- "type",
2966
- "typeof",
2967
- "undefined",
2968
- "var",
2969
- "void",
2970
- "while",
2971
- "with",
2972
- "yield"
2973
- ]);
2974
- /** Code tokens, in one alternation: strings, comments, numbers, then words. */
2975
- const CODE_TOKEN = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*|#[^\n]*)|(\b\d[\d_.]*\b)|(\b[A-Za-z_]\w*\b)/g;
2976
- /**
2977
- * Colour one line of code by token class.
2978
- *
2979
- * A heuristic, not a parser: it has no state between lines, so a string or
2980
- * comment spanning several lines is coloured only on the line where it opens.
2981
- * Getting that wrong costs a colour, never the text — every branch reproduces
2982
- * its input exactly.
2983
- * @param line - the code line.
2984
- * @param syntax - styling per token class.
2985
- * @returns the coloured line.
2986
- */
2987
- function highlightCode(line, syntax) {
2988
- return line.replace(CODE_TOKEN, (match, text, comment, number, word) => {
2989
- if (text !== void 0) return syntax.string(text);
2990
- if (comment !== void 0) return syntax.comment(comment);
2991
- if (number !== void 0) return syntax.number(number);
2992
- if (word !== void 0 && KEYWORDS.has(word)) return syntax.keyword(word);
2993
- return match;
2994
- });
2995
- }
2996
- /**
2997
- * Style the inline constructs of one line of prose.
2998
- * @param text - the line, with block syntax already stripped.
2999
- * @param theme - styling for emphasis, code spans, and link targets.
3000
- * @returns the styled line.
3001
- */
3002
- function renderInline(text, theme) {
3003
- const emphasized = (inner) => inner.split(/(`[^`]+`)/u).map((segment) => segment.startsWith("`") && segment.endsWith("`") && segment.length > 1 ? theme.bold(theme.tool(segment.slice(1, -1))) : segment === "" ? "" : theme.bold(segment)).join("");
3004
- return text.replace(INLINE, (match, code, starBold, underBold, link, starEm, underEm) => {
3005
- if (code !== void 0) return theme.tool(code.slice(1, -1));
3006
- if (starBold !== void 0) return emphasized(starBold.slice(2, -2));
3007
- if (underBold !== void 0) return emphasized(underBold.slice(2, -2));
3008
- if (link !== void 0) {
3009
- const parts = /^\[([^\]]*)\]\(([^)]*)\)$/.exec(link);
3010
- if (parts === null) return match;
3011
- const [, label, target] = parts;
3012
- return `${theme.bold(label ?? "")} ${theme.dim(`(${target ?? ""})`)}`;
3013
- }
3014
- if (starEm !== void 0) return emphasized(starEm.slice(1, -1));
3015
- if (underEm !== void 0) return emphasized(underEm.slice(1, -1));
3016
- return match;
3017
- });
3018
- }
3019
- /**
3020
- * Build a line-at-a-time Markdown renderer.
3021
- * @param theme - styling for every construct.
3022
- * @param columns - display columns available, read per table; absent means
3023
- * unconstrained. A table wider than this prints as its source lines.
3024
- * @returns the renderer, carrying its own fence and table state.
3025
- */
3026
- function createMarkdownStream(theme, columns) {
3027
- let fenceLanguage;
3028
- const table = [];
3029
- const fence = {
3030
- get: () => fenceLanguage,
3031
- set: (value) => {
3032
- fenceLanguage = value;
3033
- }
3034
- };
3035
- const drainTable = () => {
3036
- if (table.length === 0) return [];
3037
- return layoutTable(table.splice(0), theme, columns?.() ?? Number.POSITIVE_INFINITY);
3038
- };
3039
- return {
3040
- get inCode() {
3041
- return fenceLanguage !== void 0;
3042
- },
3043
- line: (line) => {
3044
- if (fenceLanguage === void 0 && TABLE_ROW.test(line)) {
3045
- table.push(line);
3046
- return [];
3047
- }
3048
- return [...drainTable(), ...renderLine(line, theme, fence)];
3049
- },
3050
- flush: drainTable
3051
- };
3052
- }
3053
- /**
3054
- * Lay out one buffered table as a bordered grid.
3055
- *
3056
- * Outer borders, one space of padding inside every cell, and a rule between
3057
- * every pair of rows: the padding keeps text off the rules, and the row rules
3058
- * are unconditional because a wrapped cell's continuation must never blur into
3059
- * the record below it. Cells render their inline constructs; every width is of
3060
- * the VISIBLE text. A table wider than the terminal wraps inside its cells,
3061
- * shrinking the widest columns toward an even share; only a terminal too
3062
- * narrow to hold the columns at all falls back to the source lines.
3063
- * @param rows - the raw `|`-delimited lines, in order.
3064
- * @param theme - styling for the borders and the header.
3065
- * @param budget - display columns available.
3066
- * @returns the rendered table, or the source lines styled as prose.
3067
- */
3068
- function layoutTable(rows, theme, budget) {
3069
- const asSource = () => rows.map((row) => renderInline(row, theme));
3070
- const raw = rows.map((row) => row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim()));
3071
- const delimiter = raw[1];
3072
- const marks = (delimiter ?? []).filter((cell) => cell !== "");
3073
- if (raw.length < 2 || delimiter === void 0 || marks.length === 0 || !marks.every((cell) => TABLE_DELIMITER.test(cell))) return asSource();
3074
- let count = Math.max(1, (raw[0] ?? []).length);
3075
- const content = [raw[0] ?? [], ...raw.slice(2)];
3076
- while (count > 1 && content.every((row) => (row[count - 1] ?? "") === "")) count -= 1;
3077
- const styled = content.map((row) => row.slice(0, count).map((cell) => renderInline(cell, theme)));
3078
- const visible = (cell) => displayWidth(cell.replaceAll(/\u001B\[[0-9;]*m/gu, ""));
3079
- const natural = Array.from({ length: count }, (_, column) => Math.max(1, ...styled.map((row) => visible(row[column] ?? ""))));
3080
- const framing = 3 * count + 1;
3081
- let widths = [...natural];
3082
- if (natural.reduce((sum, width) => sum + width, 0) + framing > budget) {
3083
- const available = budget - framing;
3084
- if (available < count * 2) return asSource();
3085
- const fair = Math.floor(available / count);
3086
- const kept = natural.map((width) => Math.min(width, fair));
3087
- let spare = available - kept.reduce((sum, width) => sum + width, 0);
3088
- widths = natural.map((width, column) => {
3089
- const base = kept[column] ?? 0;
3090
- if (width <= base) return width;
3091
- const extra = Math.min(width - base, spare);
3092
- spare -= extra;
3093
- return base + extra;
3094
- });
3095
- }
3096
- const edge = (left, junction, right) => theme.dim(`${left}${widths.map((width) => "─".repeat(width + 2)).join(junction)}${right}`);
3097
- const bar = theme.dim("│");
3098
- const line = (row, style) => {
3099
- const wrapped = Array.from({ length: count }, (_, column) => wrapStyled(row[column] ?? "", widths[column] ?? 1));
3100
- const height = Math.max(...wrapped.map((cell) => cell.length));
3101
- return Array.from({ length: height }, (_, index) => {
3102
- return `${bar}${wrapped.map((cell, column) => {
3103
- const piece = cell[index] ?? "";
3104
- const alignRight = /^:?-+:$/.test(delimiter[column] ?? "") && !/^:-+:$/.test(delimiter[column] ?? "");
3105
- const pad = " ".repeat(Math.max(0, (widths[column] ?? 0) - visible(piece)));
3106
- return alignRight ? ` ${pad}${style(piece)} ` : ` ${style(piece)}${pad} `;
3107
- }).join(bar)}${bar}`;
3108
- });
3109
- };
3110
- const separator = edge("├", "┼", "┤");
3111
- const body = styled.slice(1).flatMap((row, index) => index > 0 ? [separator, ...line(row, (text) => text)] : line(row, (text) => text));
3112
- return [
3113
- edge("╭", "┬", "╮"),
3114
- ...line(styled[0] ?? [], (text) => theme.bold(text)),
3115
- separator,
3116
- ...body,
3117
- edge("╰", "┴", "╯")
3118
- ];
3119
- }
3120
- /**
3121
- * Render one Markdown line against the carried fence state.
3122
- * @param line - the input line.
3123
- * @param theme - styling for every construct.
3124
- * @param fence - the fence state, read and updated in place.
3125
- * @returns the output lines for this input line.
3126
- */
3127
- function renderLine(line, theme, fence) {
3128
- const out = [];
3129
- {
3130
- const opened = FENCE.exec(line);
3131
- let fenceLanguage = fence.get();
3132
- if (opened !== null) {
3133
- if (fenceLanguage === void 0) {
3134
- fenceLanguage = opened[1] ?? "";
3135
- if (fenceLanguage !== "") out.push(theme.dim(` ${fenceLanguage}`));
3136
- } else fenceLanguage = void 0;
3137
- fence.set(fenceLanguage);
3138
- return out;
3139
- }
3140
- if (fenceLanguage !== void 0) {
3141
- out.push(` ${highlightCode(line, theme.syntax)}`);
3142
- return out;
3143
- }
3144
- const heading = HEADING.exec(line);
3145
- if (heading !== null) {
3146
- out.push(theme.bold(renderInline(heading[2] ?? "", theme)));
3147
- return out;
3148
- }
3149
- if (RULE.test(line)) {
3150
- out.push(theme.dim("───"));
3151
- return out;
3152
- }
3153
- const quote = QUOTE.exec(line);
3154
- if (quote !== null) {
3155
- out.push(`${theme.dim("│")} ${theme.dim(renderInline(quote[1] ?? "", theme))}`);
3156
- return out;
3157
- }
3158
- const bullet = BULLET.exec(line);
3159
- if (bullet !== null) {
3160
- out.push(`${bullet[1] ?? ""}${theme.dim("•")} ${renderInline(bullet[2] ?? "", theme)}`);
3161
- return out;
3162
- }
3163
- const numbered = NUMBERED.exec(line);
3164
- if (numbered !== null) {
3165
- out.push(`${numbered[1] ?? ""}${theme.dim(`${numbered[2] ?? ""}.`)} ${renderInline(numbered[3] ?? "", theme)}`);
3166
- return out;
3167
- }
3168
- out.push(renderInline(line, theme));
3169
- }
3170
- return out;
3171
- }
3172
- /**
3173
- * Render a whole Markdown answer as terminal lines.
3174
- * @param text - the answer, as the model produced it.
3175
- * @param theme - styling for every construct.
3176
- * @returns the output lines, blocks included.
3177
- */
3178
- function renderMarkdown(text, theme) {
3179
- const stream = createMarkdownStream(theme);
3180
- return [...text.split("\n").flatMap((line) => stream.line(line)), ...stream.flush()];
3181
- }
3182
-
3183
- //#endregion
3184
- //#region src/questions.ts
3185
- /**
3186
- * Parse a selection line against one question's options.
3187
- *
3188
- * A comma-separated list of numbers selects those options; a multi-select
3189
- * question accepts several, a single-select takes the first. Anything that is
3190
- * not a valid index becomes the free-text answer.
3191
- * @param line - the line the person typed.
3192
- * @param question - the question being answered.
3193
- * @returns the encoded answer for this question.
3194
- */
3195
- function encodeAnswer(line, question) {
3196
- const options = question.options ?? [];
3197
- const trimmed = line.trim();
3198
- if (trimmed === "") return {
3199
- id: question.id,
3200
- selected: []
3201
- };
3202
- const indices = trimmed.split(",").map((part) => Number(part.trim()));
3203
- if (!indices.every((index) => Number.isInteger(index) && index >= 1 && index <= options.length) || options.length === 0) return {
3204
- id: question.id,
3205
- selected: [],
3206
- custom: trimmed
3207
- };
3208
- const chosen = question.multiSelect === true ? indices : indices.slice(0, 1);
3209
- return {
3210
- id: question.id,
3211
- selected: chosen.map((index) => options[index - 1]?.label ?? "")
3212
- };
3213
- }
3214
- /**
3215
- * Render one question as the lines shown above its prompt.
3216
- * @param question - the question to render.
3217
- * @param theme - styling for the heading and option list.
3218
- * @returns the lines to print.
3219
- */
3220
- function questionLines(question, theme) {
3221
- const lines = [""];
3222
- const plan = question.intent?.kind === "plan-review" ? question.intent : void 0;
3223
- if (plan !== void 0) {
3224
- lines.push(theme.pending("▲ plan for review"), "");
3225
- if (question.detail !== void 0) lines.push(...question.detail.split("\n"));
3226
- lines.push("", theme.bold(question.question));
3227
- (question.options ?? []).forEach((option, index) => {
3228
- const mark = option.label === plan.approve ? theme.success(String(index + 1)) : theme.error(String(index + 1));
3229
- const description = option.description === void 0 ? "" : theme.dim(` — ${option.description}`);
3230
- lines.push(` ${mark}. ${option.label}${description}`);
3231
- });
3232
- lines.push(theme.dim(" (a number, or type your own answer)"));
3233
- return lines;
3234
- }
3235
- if (question.header !== void 0) lines.push(theme.dim(`[${question.header}]`));
3236
- lines.push(theme.bold(question.question));
3237
- if (question.detail !== void 0) lines.push(...question.detail.split("\n").map((line) => theme.dim(line)));
3238
- const options = question.options ?? [];
3239
- options.forEach((option, index) => {
3240
- const description = option.description === void 0 ? "" : theme.dim(` — ${option.description}`);
3241
- lines.push(` ${theme.pending(String(index + 1))}. ${option.label}${description}`);
3242
- });
3243
- if (options.length > 0) {
3244
- const how = question.multiSelect === true ? "numbers separated by commas, or type your own answer" : "a number, or type your own answer";
3245
- lines.push(theme.dim(` (${how})`));
3246
- }
3247
- return lines;
3248
- }
3249
- /** Answers `ask_user_question` from the terminal. */
3250
- var TerminalQuestions = class {
3251
- constructor(reader, theme, write, select) {
3252
- this.reader = reader;
3253
- this.theme = theme;
3254
- this.write = write;
3255
- this.select = select;
3256
- }
3257
- /**
3258
- * Put every question in one request to the person, in order.
3259
- * @param request - the questions, owner agent, and abort signal.
3260
- * @returns one answer per question, in request order.
3261
- */
3262
- async ask(request) {
3263
- const answers = [];
3264
- for (const question of request.questions) answers.push(await this.one(question, request.signal));
3265
- return { answers };
3266
- }
3267
- /**
3268
- * Put one question to the person.
3269
- * @param question - the question.
3270
- * @param signal - aborts with the owning tool call.
3271
- * @returns the encoded answer.
3272
- */
3273
- async one(question, signal) {
3274
- const options = question.options ?? [];
3275
- if (this.select === void 0 || options.length === 0) {
3276
- for (const line of questionLines(question, this.theme)) this.write(line);
3277
- return encodeAnswer(await this.reader.read(signal) ?? "", question);
3278
- }
3279
- this.write("");
3280
- if (question.header !== void 0) this.write(this.theme.dim(`[${question.header}]`));
3281
- if (question.intent?.kind === "plan-review") {
3282
- this.write(this.theme.pending("▲ plan for review"));
3283
- this.write("");
3284
- if (question.detail !== void 0) for (const line of renderMarkdown(question.detail, this.theme)) this.write(line);
3285
- this.write("");
3286
- } else if (question.detail !== void 0) for (const line of question.detail.split("\n")) this.write(this.theme.dim(line));
3287
- const outcome = await this.select({
3288
- title: question.question,
3289
- options: options.map((option) => ({
3290
- label: option.label,
3291
- ...option.description === void 0 ? {} : { detail: option.description }
3292
- })),
3293
- ...question.multiSelect === true ? { multi: true } : {},
3294
- custom: "✎ Type your own answer"
3295
- }, signal);
3296
- if (outcome.kind === "chosen") {
3297
- const selected = outcome.indices.map((index) => options[index]?.label).filter((label) => label !== void 0);
3298
- this.write(this.theme.dim(` ✓ ${selected.join(", ")}`));
3299
- return {
3300
- id: question.id,
3301
- selected
3302
- };
3303
- }
3304
- if (outcome.kind === "custom") {
3305
- const custom = (await this.reader.read(signal) ?? "").trim();
3306
- return custom === "" ? {
3307
- id: question.id,
3308
- selected: []
3309
- } : {
3310
- id: question.id,
3311
- selected: [],
3312
- custom
3313
- };
3314
- }
3315
- return {
3316
- id: question.id,
3317
- selected: []
3318
- };
3319
- }
3320
- };
3321
-
3322
- //#endregion
3323
- //#region src/spinner.ts
3324
- /** Braille frames, one cell wide each, so the line never changes width. */
3325
- const FRAMES = [
3326
- "⠋",
3327
- "⠙",
3328
- "⠹",
3329
- "⠸",
3330
- "⠼",
3331
- "⠴",
3332
- "⠦",
3333
- "⠧",
3334
- "⠇",
3335
- "⠏"
3336
- ];
3337
- /** How often the frame advances. */
3338
- const TICK_MS = 90;
3339
- /**
3340
- * Render one tick of the indicator.
3341
- * @param frame - the frame index, taken modulo the frame count.
3342
- * @param elapsedMs - milliseconds since the work started.
3343
- * @param label - the verb and interrupt key to name.
3344
- * @param theme - styling for the frame and the hint.
3345
- * @returns the line to display.
3346
- */
3347
- function spinnerText(frame, elapsedMs, label, theme) {
3348
- const seconds = (elapsedMs / 1e3).toFixed(elapsedMs < 1e4 ? 1 : 0);
3349
- const mark = FRAMES[frame % FRAMES.length] ?? FRAMES[0];
3350
- const extra = label.detail?.();
3351
- const detail = extra === void 0 || extra === "" ? "" : `${extra} · `;
3352
- return `${theme.pending(mark)} ${label.verb} ${theme.dim(`${seconds}s · ${detail}${label.interrupt} to interrupt`)}`;
3353
- }
3354
- /** Drives the working indicator for as long as the agent is busy. */
3355
- var Spinner = class {
3356
- timer;
3357
- frame = 0;
3358
- startedAt = 0;
3359
- constructor(surface, theme, label, now = () => performance.now()) {
3360
- this.surface = surface;
3361
- this.theme = theme;
3362
- this.label = label;
3363
- this.now = now;
3364
- }
3365
- /** Whether the indicator is running. */
3366
- get running() {
3367
- return this.timer !== void 0;
3368
- }
3369
- /**
3370
- * Start the indicator, or do nothing when it is already running or the
3371
- * surface has no cursor to rewrite.
3372
- *
3373
- * The clock keeps running across a pause: the elapsed figure is the whole
3374
- * turn's, and an indicator that restarted from zero at every tool call would
3375
- * report each step instead.
3376
- */
3377
- start() {
3378
- if (this.timer !== void 0 || !this.surface.isTty) return;
3379
- if (this.startedAt === 0) this.startedAt = this.now();
3380
- this.draw();
3381
- this.timer = setInterval(() => {
3382
- this.frame += 1;
3383
- this.draw();
3384
- }, TICK_MS);
3385
- this.timer.unref();
3386
- }
3387
- /** Hide the indicator without forgetting when the turn began. */
3388
- pause() {
3389
- if (this.timer === void 0) return;
3390
- clearInterval(this.timer);
3391
- this.timer = void 0;
3392
- this.surface.setLive(void 0);
3393
- }
3394
- /** Stop the indicator: the turn is over, and the next one starts at zero. */
3395
- stop() {
3396
- this.pause();
3397
- this.startedAt = 0;
3398
- this.frame = 0;
3399
- }
3400
- /** Paint the current frame. */
3401
- draw() {
3402
- this.surface.setLive(spinnerText(this.frame, this.now() - this.startedAt, this.label, this.theme));
3403
- }
3404
- };
3405
-
3406
- //#endregion
3407
- //#region src/streaming.ts
3408
- /** Accumulates assistant text deltas into rendered lines. */
3409
- var TextStream = class {
3410
- markdown;
3411
- partial = "";
3412
- seen = false;
3413
- constructor(theme, columns, plain = false) {
3414
- this.theme = theme;
3415
- this.columns = columns;
3416
- this.plain = plain;
3417
- this.markdown = createMarkdownStream(theme, columns);
3418
- }
3419
- /** Whether this message has produced any text yet. */
3420
- get streamed() {
3421
- return this.seen;
3422
- }
3423
- /**
3424
- * Take one text delta.
3425
- * @param delta - the text fragment, which may contain any number of newlines.
3426
- * @returns the lines to append and the line still open.
3427
- */
3428
- push(delta) {
3429
- if (delta === "") return {
3430
- lines: [],
3431
- live: this.liveText()
3432
- };
3433
- this.seen = true;
3434
- const lines = [];
3435
- const parts = (this.partial + delta).split("\n");
3436
- this.partial = parts.pop() ?? "";
3437
- for (const complete of parts) lines.push(...this.renderLine(complete));
3438
- return {
3439
- lines,
3440
- live: this.liveText()
3441
- };
3442
- }
3443
- /**
3444
- * Close the message, rendering whatever line was still open.
3445
- *
3446
- * Called when the model finishes and when a turn is cancelled mid-line: the
3447
- * text already shown has to land in the transcript either way, or the live
3448
- * region would take it away again.
3449
- * @returns the remaining lines to append.
3450
- */
3451
- flush() {
3452
- const lines = this.partial === "" ? [] : this.renderLine(this.partial);
3453
- lines.push(...this.plain ? [] : this.markdown.flush());
3454
- this.partial = "";
3455
- this.seen = false;
3456
- this.markdown = createMarkdownStream(this.theme, this.columns);
3457
- return lines;
3458
- }
3459
- /** Render one complete line in this stream's mode. */
3460
- renderLine(line) {
3461
- return this.plain ? [this.theme.dim(` ${line}`)] : this.markdown.line(line);
3462
- }
3463
- /**
3464
- * The in-progress line as the live region should show it.
3465
- *
3466
- * Raw rather than rendered: it is not a line yet, and inside a fenced block it
3467
- * is code that Markdown must not touch. Truncated because the live region is
3468
- * one row — a wrapped live line cannot be erased by a single carriage return.
3469
- * @returns the text, or undefined when no line is open.
3470
- */
3471
- liveText() {
3472
- if (this.partial === "") return void 0;
3473
- const prefix = this.markdown.inCode ? " " : "";
3474
- return this.theme.dim(truncate(`${prefix}${this.partial}`, this.columns() - 1));
3475
- }
3476
- };
3477
-
3478
- //#endregion
3479
- //#region src/transcript.ts
3480
- /** Context lines kept on each side of a rendered hunk. */
3481
- const DIFF_CONTEXT = 3;
3482
- /** Diff body lines printed for one file before the card collapses the rest. */
3483
- const MAX_DIFF_LINES = 24;
3484
- /**
3485
- * Result body lines printed for one completed call before the card collapses.
3486
- *
3487
- * Small on purpose: a long output in the transcript is skimmed, not read, and
3488
- * the collapsed remainder is one Ctrl+O away in full.
3489
- */
3490
- const MAX_RESULT_LINES = 5;
3491
- /**
3492
- * Concatenate a message's text blocks, dropping reasoning and non-text content.
3493
- * @param content - the message content blocks.
3494
- * @returns the joined visible text.
3495
- */
3496
- function visibleText(content) {
3497
- return content.filter((block) => block.type === "text").map((block) => block.text).join("");
3498
- }
3499
- /**
3500
- * Render one file's change as unified-diff body lines.
3501
- *
3502
- * A {@link FileDiff} carries one hunk's old and new blocks including their
3503
- * context lines, so re-diffing the two blocks recovers which lines actually
3504
- * changed. A `null` `oldText` is a create: every line is an addition.
3505
- * @param diff - the file change to render.
3506
- * @param theme - styling for added and removed lines.
3507
- * @returns the body lines, marker-prefixed.
3508
- */
3509
- function diffBody(diff, theme) {
3510
- if (diff.oldText === null) {
3511
- const lines$1 = diff.newText.split("\n");
3512
- if (lines$1.at(-1) === "") lines$1.pop();
3513
- return lines$1.map((line) => theme.success(`+ ${line}`));
3514
- }
3515
- const patch = structuredPatch("", "", diff.oldText, diff.newText, void 0, void 0, { context: DIFF_CONTEXT });
3516
- const lines = [];
3517
- for (const hunk of patch.hunks) for (const line of hunk.lines) {
3518
- if (line.startsWith("\\")) continue;
3519
- const text = line.slice(1);
3520
- if (line.startsWith("+")) lines.push(theme.success(`+ ${text}`));
3521
- else if (line.startsWith("-")) lines.push(theme.error(`- ${text}`));
3522
- else lines.push(theme.dim(` ${text}`));
3523
- }
3524
- return lines;
3525
- }
3526
- /**
3527
- * Cap a body at `limit` lines, replacing the remainder with a count.
3528
- * @param lines - the rendered body.
3529
- * @param limit - how many lines to keep.
3530
- * @param theme - styling for the summary line.
3531
- * @returns the capped body.
3532
- */
3533
- function cap(lines, limit, theme) {
3534
- if (lines.length <= limit) return lines;
3535
- return [...lines.slice(0, limit), theme.dim(` … +${lines.length - limit} lines (Ctrl+O expands)`)];
3536
- }
3537
- /** Renders one session's appended events as terminal lines. */
3538
- var Transcript = class {
3539
- calls = /* @__PURE__ */ new Map();
3540
- /** The full form of the event just rendered, when its body was collapsed. */
3541
- fold;
3542
- constructor(options, presenters) {
3543
- this.options = options;
3544
- this.presenters = presenters;
3545
- }
3546
- /**
3547
- * Shorten an absolute path inside the workspace to a workspace-relative one.
3548
- * @param path - the model-facing path a card carries.
3549
- * @returns the display path.
3550
- */
3551
- relative(path) {
3552
- const root = this.options.cwd.endsWith("/") ? this.options.cwd : `${this.options.cwd}/`;
3553
- return path.startsWith(root) ? path.slice(root.length) : path;
3554
- }
3555
- /**
3556
- * Shorten every workspace path a presenter embedded in free text.
3557
- *
3558
- * A title is prose the tool composed (`Write /abs/path`), so the path inside
3559
- * it needs the same shortening as a structured `locations` entry.
3560
- * @param text - the presenter-supplied line.
3561
- * @returns the line with workspace-rooted paths made relative.
3562
- */
3563
- relativizeIn(text) {
3564
- const root = this.options.cwd.endsWith("/") ? this.options.cwd : `${this.options.cwd}/`;
3565
- return text.split(root).join("");
3566
- }
3567
- /**
3568
- * Paths worth appending to a title that may already name them.
3569
- * @param title - the presenter's title, already relativized.
3570
- * @param paths - the relativized paths the card covers.
3571
- * @returns the paths the title does not mention, joined for display.
3572
- */
3573
- extraPaths(title, paths) {
3574
- const missing = paths.filter((path) => !title.includes(path));
3575
- return missing.length === 0 ? "" : ` ${missing.join(", ")}`;
3576
- }
3577
- /**
3578
- * Render one appended event.
3579
- * @param event - the event exactly as recorded.
3580
- * @returns the lines to append to the transcript, empty when the event shows nothing.
3581
- */
3582
- render(event) {
3583
- const { theme } = this.options;
3584
- switch (event.type) {
3585
- case "user/message": {
3586
- if (event.data.source.kind !== "user") return [];
3587
- const [first = "", ...rest] = visibleText(event.data.content).split("\n");
3588
- return [
3589
- `${theme.user("›")} ${first}`,
3590
- ...rest.map((line) => ` ${line}`),
3591
- ""
3592
- ];
3593
- }
3594
- case "assistant/message": {
3595
- const text = visibleText(event.data.message.content);
3596
- return text === "" ? [] : [...renderMarkdown(text, theme), ""];
3597
- }
3598
- case "tool/call": return this.renderCall(event.data.callId, event.data.name, event.data.arguments);
3599
- case "tool/result": return this.renderResult(event.data);
3600
- case "todo/write": {
3601
- const { todos } = event.data;
3602
- if (todos.length === 0) return [];
3603
- const done = todos.filter((todo) => todo.status === "completed").length;
3604
- return [
3605
- `${theme.tool("todos")} ${theme.dim(`${done}/${todos.length}`)}`,
3606
- ...todos.map((todo) => {
3607
- if (todo.status === "completed") return theme.dim(` ✔ ${todo.content}`);
3608
- if (todo.status === "in_progress") return ` ${theme.pending("▶")} ${todo.content}`;
3609
- return theme.dim(` ○ ${todo.content}`);
3610
- }),
3611
- ""
3612
- ];
3613
- }
3614
- case "plan/mode": return event.data.active ? [theme.pending("▲ plan mode — exploring only; no files will change until you approve a plan"), ""] : [theme.dim("▼ plan mode off"), ""];
3615
- case "turn/end": return event.data.reason.kind === "error" ? [theme.error(`✗ ${event.data.reason.error.code}: ${event.data.reason.error.message}`), ""] : [];
3616
- default: return [];
3617
- }
3618
- }
3619
- /**
3620
- * Render a pending call as its declared card.
3621
- * @param callId - correlation id, remembered until the result pairs with it.
3622
- * @param name - the tool the model called.
3623
- * @param rawArguments - the unparsed arguments JSON the model produced.
3624
- * @returns the pending card's lines.
3625
- */
3626
- renderCall(callId, name$1, rawArguments) {
3627
- const { theme, columns } = this.options;
3628
- let args;
3629
- try {
3630
- args = JSON.parse(rawArguments);
3631
- } catch {
3632
- args = void 0;
3633
- }
3634
- const view = this.safeCall(name$1, args);
3635
- const record = (title$1, lines) => {
3636
- this.calls.set(callId, {
3637
- name: name$1,
3638
- args,
3639
- title: title$1
3640
- });
3641
- return lines;
3642
- };
3643
- if (view === void 0) return record(name$1, [`${theme.pending("●")} ${theme.tool(name$1)}`]);
3644
- if (view.card === "terminal") {
3645
- const header = view.cwd === void 0 ? "" : theme.dim(` (${this.relative(view.cwd)})`);
3646
- const description = view.description === void 0 ? [] : [theme.dim(` ${view.description}`)];
3647
- const command = this.relativizeIn(view.title);
3648
- return record(command, [
3649
- `${theme.pending("●")} ${theme.tool(name$1)}${header}`,
3650
- ` $ ${truncate(command, columns - 4)}`,
3651
- ...description
3652
- ]);
3653
- }
3654
- if (view.card === "diff") {
3655
- const title$1 = this.relativizeIn(view.title);
3656
- const paths = view.diffs.map((diff) => this.relative(diff.path));
3657
- return record(`${title$1}${this.extraPaths(title$1, paths)}`, [`${theme.pending("●")} ${theme.tool(title$1)}${theme.path(this.extraPaths(title$1, paths))}`]);
3658
- }
3659
- const title = this.relativizeIn(view.title);
3660
- const locations = (view.locations ?? []).map((location) => this.relative(location.path));
3661
- const extra = this.extraPaths(title, locations);
3662
- return record(`${title}${extra}`, [`${theme.pending("●")} ${truncate(title, columns - 4)}${theme.path(extra)}`]);
3663
- }
3664
- /**
3665
- * Render a completed call, pairing it with the call this transcript recorded.
3666
- * @param data - the `tool/result` payload.
3667
- * @returns the completed card's lines.
3668
- */
3669
- renderResult(data) {
3670
- const { theme } = this.options;
3671
- const { message, meta, error } = data;
3672
- const [block] = message.content;
3673
- const callId = message.source.callId;
3674
- const pending = this.calls.get(callId);
3675
- this.calls.delete(callId);
3676
- const failed = error !== void 0 || block.isError === true;
3677
- const marker = failed ? theme.error("✗") : theme.success("●");
3678
- if (pending === void 0) {
3679
- const raw = this.resultText(block.content).split("\n");
3680
- const head$1 = `${marker} ${theme.dim("(result)")}`;
3681
- if (raw.length > MAX_RESULT_LINES) this.fold = [
3682
- head$1,
3683
- ...raw,
3684
- ""
3685
- ];
3686
- return [
3687
- head$1,
3688
- ...cap(raw, MAX_RESULT_LINES, theme),
3689
- ""
3690
- ];
3691
- }
3692
- const view = this.safeResult(pending, block.content, failed, meta);
3693
- const title = view?.title === void 0 ? pending.title : this.relativizeIn(view.title);
3694
- const { suffix, body, full } = this.outcome(view, block);
3695
- const head = failed || title !== pending.title ? [`${marker} ${theme.tool(title)}${suffix === "" ? "" : ` ${suffix}`}`] : suffix !== "" ? [` ${suffix}`] : body.length === 0 ? [` ${theme.success("✓")}`] : [];
3696
- if (full !== void 0) this.fold = [
3697
- ...head,
3698
- ...full,
3699
- ""
3700
- ];
3701
- return [
3702
- ...head,
3703
- ...body,
3704
- ""
3705
- ];
3706
- }
3707
- /**
3708
- * The expanded form of the lines {@link render} just returned, when that
3709
- * event's body was collapsed — the whole event re-rendered without its cap,
3710
- * because a fold swaps entire blocks, not just the clipped tail. The full
3711
- * body is kept from the render itself: what a tool truncated before
3712
- * returning is upstream of the log and unrecoverable everywhere.
3713
- * @returns the full lines, or undefined when nothing was collapsed.
3714
- */
3715
- takeFold() {
3716
- const fold = this.fold;
3717
- this.fold = void 0;
3718
- return fold;
3719
- }
3720
- /**
3721
- * Render one completed call's status suffix and body from its declared view.
3722
- * @param view - the result view, absent when no presenter answered.
3723
- * @param block - the model-facing result block, used by the generic fallback.
3724
- * @returns the suffix, the (possibly capped) body, and — when the cap dropped
3725
- * lines, or a bodiless card withheld content — the full body for Ctrl-O.
3726
- */
3727
- outcome(view, block) {
3728
- const { theme } = this.options;
3729
- const capped = (lines, limit) => {
3730
- const body = cap(lines, limit, theme);
3731
- return lines.length > limit ? {
3732
- body,
3733
- full: lines
3734
- } : { body };
3735
- };
3736
- if (view?.card === "diff") return {
3737
- suffix: "",
3738
- ...capped(view.diffs.flatMap((diff) => diffBody(diff, theme)), MAX_DIFF_LINES)
3739
- };
3740
- if (view?.card === "terminal") {
3741
- const suffix = view.signal !== void 0 ? theme.error(`(killed by ${view.signal})`) : view.exitCode !== void 0 && view.exitCode !== 0 ? theme.error(`(exit ${view.exitCode})`) : "";
3742
- const output = (view.output ?? "").trimEnd();
3743
- return {
3744
- suffix,
3745
- ...capped(output === "" ? [] : output.split("\n").map((line) => theme.dim(` ${line}`)), MAX_RESULT_LINES)
3746
- };
3747
- }
3748
- if (view?.card === "search") {
3749
- const total = view.truncated ? `${view.total}+ (capped)` : String(view.total);
3750
- const body = view.shape === "paths" ? view.paths.map((path) => theme.dim(` ${this.relative(path)}`)) : view.files.flatMap((file) => [theme.path(` ${this.relative(file.path)}`), ...file.matches.map((match) => theme.dim(` ${match.lineNumber}: ${match.line}`))]);
3751
- return {
3752
- suffix: theme.dim(`${total} results`),
3753
- ...capped(body, MAX_RESULT_LINES)
3754
- };
3755
- }
3756
- if (view?.card === "read") {
3757
- const body = view.lines.map((line) => theme.dim(` ${line.number}: ${line.text}`));
3758
- return {
3759
- suffix: theme.dim(`${view.lines.length} of ${view.totalLines} lines`),
3760
- body: [],
3761
- ...body.length === 0 ? {} : { full: body }
3762
- };
3763
- }
3764
- const text = this.resultText(view?.card === "generic" && view.content !== void 0 ? view.content : block.content);
3765
- if (text === "") return {
3766
- suffix: "",
3767
- body: []
3768
- };
3769
- return {
3770
- suffix: "",
3771
- ...capped(text.split("\n").map((line) => theme.dim(` ${line}`)), MAX_RESULT_LINES)
3772
- };
3773
- }
3774
- /**
3775
- * Flatten a result's content blocks to displayable text.
3776
- * @param content - the result content blocks.
3777
- * @returns the joined text.
3778
- */
3779
- resultText(content) {
3780
- return visibleText(content).trimEnd();
3781
- }
3782
- /**
3783
- * Ask a call presenter for its view, absorbing a throwing presenter.
3784
- * @param name - the tool the model called.
3785
- * @param args - the parsed call arguments.
3786
- * @returns the view, or undefined to fall back to the generic line.
3787
- */
3788
- safeCall(name$1, args) {
3789
- try {
3790
- return this.presenters.call(name$1, args);
3791
- } catch {
3792
- return;
3793
- }
3794
- }
3795
- /**
3796
- * Ask a result presenter for its view, absorbing a throwing presenter.
3797
- * @param pending - the recorded call this result pairs with.
3798
- * @param content - the model-facing result content.
3799
- * @param isError - whether the executor reported a failure.
3800
- * @param meta - the tool's private presentation payload, when it attached one.
3801
- * @returns the view, or undefined to fall back to the generic card.
3802
- */
3803
- safeResult(pending, content, isError, meta) {
3804
- try {
3805
- return this.presenters.result(pending.name, pending.args, {
3806
- content: [...content],
3807
- isError,
3808
- ...meta === void 0 ? {} : { meta }
3809
- });
3810
- } catch {
3811
- return;
3812
- }
3813
- }
3814
- };
3815
-
3816
- //#endregion
3817
- //#region src/index.ts
3818
- /** Stable Cordis plugin name. */
3819
- const name = "coding-cli-runner";
3820
- /** Core services required before the surface can compose an agent. */
3821
- const inject = [
3822
- "agentDefaultModel",
3823
- "agents",
3824
- "sessions",
3825
- "tools"
3826
- ];
3827
- const Config = z.object({
3828
- task: z.string().default(""),
3829
- resume: z.string().default(""),
3830
- preset: z.string().default(""),
3831
- print: z.boolean().default(false),
3832
- bell: z.boolean().default(true),
3833
- bangTimeoutMs: z.number().default(12e4),
3834
- bangOutputLines: z.number().default(200)
3835
- });
3836
- /** The process streams the surface binds to; tests substitute captures. */
3837
- const internals = {
3838
- input: process.stdin,
3839
- output: process.stdout
3840
- };
3841
- /** How long a second interrupt keeps ending the process rather than the turn. */
3842
- const INTERRUPT_EXIT_WINDOW_MS = 2e3;
3843
- /**
3844
- * Resolve the session `--continue` reopens: the newest one recorded in this
3845
- * working directory.
3846
- * @param ctx - plugin context carrying the optional session query engine.
3847
- * @param cwd - the workspace to match.
3848
- * @returns the session id, or undefined when nothing was recorded here.
3849
- */
3850
- async function latestSessionIn(ctx, cwd) {
3851
- const query = ctx.get("sessionQuery");
3852
- if (query === void 0) return void 0;
3853
- return (await query.listSessions()).find((record) => record.header.cwd === cwd)?.header.id;
3854
- }
3855
- /**
3856
- * Build the presenter lookups for one live agent.
3857
- *
3858
- * Presenters live with the tool definitions, and definitions live in the scope
3859
- * chain a preset registers into. The live agent IS that scope key.
3860
- * @param ctx - plugin context carrying the tool registry.
3861
- * @param agent - the live agent whose catalog to resolve against.
3862
- * @returns the call and result presenter lookups.
3863
- */
3864
- function presentersFor(ctx, agent) {
3865
- return {
3866
- call: (toolName, args) => ctx.tools.get(toolName, agent)?.presentCall?.(args),
3867
- result: (toolName, args, result) => ctx.tools.get(toolName, agent)?.presentResult?.(args, result)
3868
- };
3869
- }
3870
- /**
3871
- * Whether plan mode is holding, from the log's last `plan/mode` record.
3872
- *
3873
- * Folded here rather than through the plugin's own helper because importing a
3874
- * runtime value from a bundled dependency inlines that package into this one;
3875
- * the flag is one last-wins boolean, and the surface already reads the log.
3876
- * @param events - the session log, oldest first.
3877
- * @returns whether plan mode is on.
3878
- */
3879
- function planModeFrom(events) {
3880
- let active = false;
3881
- for (const event of events) if (event.type === "plan/mode") active = event.data.active;
3882
- return active;
3883
- }
3884
- /**
3885
- * Gather what the status line reports for the session as it stands now.
3886
- *
3887
- * Read fresh on every call: usage, occupancy, permission, and plan state are
3888
- * all folds over the log, so a stale copy would report the turn before last.
3889
- * @param ctx - plugin context carrying the projection and permission services.
3890
- * @param agent - the live agent.
3891
- * @param cwd - the session workspace.
3892
- * @param model - the model route answering this session.
3893
- * @param presetId - the composed preset, when a roster resolved one.
3894
- * @param branch - the checked-out branch, read once per prompt.
3895
- * @returns the facts to render.
3896
- */
3897
- function statusFacts(ctx, agent, cwd, selection, presetId, branch) {
3898
- const projections = ctx.get("sessionProjections")?.snapshot(agent.session).values;
3899
- return {
3900
- model: selection.current?.model ?? "",
3901
- preset: presetId,
3902
- permission: ctx.get("permissionPresets")?.current(agent.session.events),
3903
- planMode: planModeFrom(agent.session.events),
3904
- cwd,
3905
- branch,
3906
- usage: projections?.tokenUsage,
3907
- context: projections?.contextPressure
3908
- };
3909
- }
3910
- /**
3911
- * Render every event a resumed session already holds, so the person sees the
3912
- * conversation they are continuing.
3913
- * @param session - the reconstructed session.
3914
- * @param transcript - the renderer, which also learns the pending call table.
3915
- * @param io - the terminal to write to.
3916
- */
3917
- function replay(session, transcript, io) {
3918
- for (const event of session.events) for (const line of transcript.render(event)) io.console.write(line);
3919
- }
3920
- /**
3921
- * Run one conversation turn and wait for the agent to go idle.
3922
- * @param agent - the live agent.
3923
- * @param text - the person's message.
3924
- * @param working - the indicator to run while the turn does.
3925
- * @param source - the message source; a canned prompt is plugin-sourced so the
3926
- * transcript echoes the command that ran it, not its whole body.
3927
- */
3928
- async function turn(agent, text, working, source = { kind: "user" }) {
3929
- agent.followup(createUserMessage({
3930
- content: [{
3931
- type: "text",
3932
- text
3933
- }],
3934
- source
3935
- }));
3936
- working?.start();
3937
- try {
3938
- await agent.whenIdle();
3939
- } finally {
3940
- working?.stop();
3941
- }
3942
- }
3943
- /**
3944
- * Execute one slash command through the command registry.
3945
- * @param ctx - plugin context carrying the optional command registry.
3946
- * @param agent - the live agent the command applies to.
3947
- * @param line - the typed line, including its leading slash.
3948
- * @param io - the terminal to write to.
3949
- * @param theme - styling for the command's report.
3950
- * @param signal - cancels the command when the person interrupts.
3951
- */
3952
- async function runCommand(ctx, agent, line, io, theme, signal) {
3953
- const commands = ctx.get("commands");
3954
- if (commands === void 0) {
3955
- io.console.write(theme.error(" commands are unavailable in this composition"));
3956
- return;
3957
- }
3958
- if (line === "/help" || line === "/") {
3959
- const width = Math.max(...commands.list(agent).map((command) => command.name.length), 4);
3960
- for (const command of commands.list(agent)) io.console.write(` ${theme.tool(`/${command.name}`.padEnd(width + 1))} ${theme.dim(command.description)}`);
3961
- io.console.write(` ${theme.tool("/exit".padEnd(width + 1))} ${theme.dim("leave the session")}`);
3962
- io.console.write("");
3963
- return;
3964
- }
3965
- const execution = await commands.execute(agent, line, signal);
3966
- if (execution === void 0) {
3967
- io.console.write(theme.error(` unknown command: ${line}`));
3968
- return;
3969
- }
3970
- const { result } = execution;
3971
- const report = result.kind === "error" ? theme.error(result.text) : result.text;
3972
- if (report !== void 0 && report !== "") for (const reported of report.split("\n")) io.console.write(` ${reported}`);
3973
- io.console.write("");
3974
- }
3975
- /** How long the second Escape has to arrive to recall the previous message. */
3976
- const RECALL_WINDOW_MS = 1500;
3977
- /** Turns longer than this ring the bell on completion, when the bell is on. */
3978
- const BELL_TURN_MS = 1e4;
3979
- /** A finished answer longer than this many rendered lines becomes a fold. */
3980
- const ANSWER_FOLD_LINES = 24;
3981
- /** How many of its head lines a collapsed answer keeps visible. */
3982
- const ANSWER_HEAD_LINES = 8;
3983
- /**
3984
- * Run one subprocess and capture everything it printed.
3985
- * @param file - the executable, or a shell when `shell` is given.
3986
- * @param args - its arguments.
3987
- * @param options - working directory, abort wiring, and an optional kill timer.
3988
- * @returns the merged output and exit status; spawn failures come back as a
3989
- * nonzero code with the error message as output.
3990
- */
3991
- function capture(file, args, options) {
3992
- return new Promise((resolve) => {
3993
- const child = spawn(file, args, {
3994
- cwd: options.cwd,
3995
- stdio: [
3996
- "ignore",
3997
- "pipe",
3998
- "pipe"
3999
- ]
4000
- });
4001
- let output = "";
4002
- const take = (chunk) => {
4003
- output += chunk.toString();
4004
- };
4005
- child.stdout.on("data", take);
4006
- child.stderr.on("data", take);
4007
- const timer = options.timeoutMs === void 0 ? void 0 : setTimeout(() => {
4008
- child.kill("SIGTERM");
4009
- }, options.timeoutMs);
4010
- const onAbort = () => {
4011
- child.kill("SIGTERM");
4012
- };
4013
- options.signal?.addEventListener("abort", onAbort, { once: true });
4014
- child.on("error", (error) => {
4015
- resolve({
4016
- output: error.message,
4017
- code: 127,
4018
- signal: null
4019
- });
4020
- });
4021
- child.on("close", (code, signal) => {
4022
- if (timer !== void 0) clearTimeout(timer);
4023
- options.signal?.removeEventListener("abort", onAbort);
4024
- resolve({
4025
- output,
4026
- code,
4027
- signal
4028
- });
4029
- });
4030
- });
4031
- }
4032
- /**
4033
- * Style one unified-diff line for the transcript.
4034
- * @param line - the raw diff line.
4035
- * @param theme - styling for additions, removals, and headers.
4036
- * @returns the styled line.
4037
- */
4038
- function diffLine(line, theme) {
4039
- if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff ") || line.startsWith("index ")) return theme.dim(line);
4040
- if (line.startsWith("@@")) return theme.tool(line);
4041
- if (line.startsWith("+")) return theme.success(line);
4042
- if (line.startsWith("-")) return theme.error(line);
4043
- return theme.dim(line);
4044
- }
4045
- /**
4046
- * A moment's age as a person reads it.
4047
- * @param epochMs - when it happened.
4048
- * @returns e.g. `just now`, `5m ago`, `3h ago`, `2d ago`.
4049
- */
4050
- function age(epochMs) {
4051
- const minutes = Math.floor((Date.now() - epochMs) / 6e4);
4052
- if (minutes < 1) return "just now";
4053
- if (minutes < 60) return `${minutes}m ago`;
4054
- const hours = Math.floor(minutes / 60);
4055
- if (hours < 24) return `${hours}h ago`;
4056
- return `${Math.floor(hours / 24)}d ago`;
4057
- }
4058
- /** The `/init` prompt: a canned task submitted through the ordinary turn path. */
4059
- const INIT_PROMPT = `Analyze this repository and write an AGENTS.md file at its root for future coding agents.
4060
- Cover: what the project is, the repository layout, how to build/test/lint (exact commands), code conventions worth enforcing, and any non-obvious constraints you find in configs or docs.
4061
- If AGENTS.md (or CLAUDE.md) already exists, read it first and improve it rather than starting over. Keep it concise and factual.`;
4062
- /**
4063
- * Compose the agent this invocation asked for: a fresh session, or the
4064
- * persisted one `--resume`/`--continue` names.
4065
- * @param ctx - plugin context carrying the agent, preset, and query services.
4066
- * @param config - the resolved invocation.
4067
- * @param cwd - the session workspace.
4068
- * @returns the live agent and the preset it composed, or undefined when the
4069
- * requested session could not be resolved.
4070
- */
4071
- async function compose(ctx, config, cwd) {
4072
- const agents = ctx.get("agents");
4073
- const defaultModel = ctx.get("agentDefaultModel");
4074
- if (agents === void 0 || defaultModel === void 0) return void 0;
4075
- const selection = defaultModel.currentSelection();
4076
- const selected = {
4077
- current: selection,
4078
- assembled: void 0
4079
- };
4080
- const presets = ctx.get("agentPresets");
4081
- const preset = presets === void 0 ? void 0 : await presets.resolve(config.preset === "" ? void 0 : config.preset);
4082
- const setup = async (agentCtx) => {
4083
- installModelSelection(agentCtx, selected);
4084
- if (presets !== void 0 && preset !== void 0) await presets.mount(agentCtx, preset.id);
4085
- };
4086
- const agentOptions = {
4087
- provider: selection.provider,
4088
- model: selection.model
4089
- };
4090
- const createAnother = () => agents.create({
4091
- sessionId: SessionId(`session-${randomUUID()}`),
4092
- meta: {
4093
- cwd,
4094
- ...preset === void 0 ? {} : { agentPreset: preset.id }
4095
- },
4096
- agentOptions,
4097
- setup
4098
- });
4099
- const resumeAnother = (id) => agents.resume({
4100
- resumeSessionId: id,
4101
- agentOptions,
4102
- setup
4103
- });
4104
- const composed = {
4105
- model: selection.model,
4106
- selection: selected,
4107
- createAnother,
4108
- resumeAnother,
4109
- ...preset === void 0 ? {} : { presetId: preset.id }
4110
- };
4111
- if (config.resume === "") return {
4112
- handle: await createAnother(),
4113
- ...composed
4114
- };
4115
- const resumeSessionId = config.resume === "latest" ? await latestSessionIn(ctx, cwd) : SessionId(config.resume);
4116
- if (resumeSessionId === void 0) return void 0;
4117
- return {
4118
- handle: await resumeAnother(resumeSessionId),
4119
- ...composed
4120
- };
4121
- }
4122
- /**
4123
- * Drive one terminal session from composition to exit.
4124
- * @param ctx - plugin context carrying the core services and launcher IO.
4125
- * @param config - the resolved invocation.
4126
- * @param io - process-facing effects.
4127
- */
4128
- async function run(ctx, config, io) {
4129
- await ctx.get("loader")?.await();
4130
- const sessions = ctx.get("sessions");
4131
- if (sessions === void 0) return;
4132
- const cwd = process.cwd();
4133
- const theme = createTheme(io.console.isTty, process.env);
4134
- const preset = await installPackagedPreset();
4135
- if (preset.installed) io.console.write(theme.dim(`installed preset into ${preset.path}`));
4136
- const composed = await compose(ctx, config, cwd);
4137
- if (composed === void 0) {
4138
- io.console.write(theme.error(`dsh: no session to resume${config.resume === "latest" ? ` in ${cwd}` : ""}`));
4139
- io.exit(1);
4140
- return;
4141
- }
4142
- const { model, presetId, selection } = composed;
4143
- const live = {
4144
- handle: composed.handle,
4145
- agent: composed.handle.agent,
4146
- transcript: new Transcript({
4147
- theme,
4148
- columns: io.console.columns,
4149
- cwd
4150
- }, presentersFor(ctx, composed.handle.agent))
4151
- };
4152
- const facts = (branch$1) => statusFacts(ctx, live.agent, cwd, selection, presetId, branch$1);
4153
- io.console.setTitle(`dsh code — ${basename(cwd)}`);
4154
- let branch = await gitBranch(cwd);
4155
- if (config.resume !== "") replay(live.agent.session, live.transcript, io);
4156
- for (const line of bannerLines({
4157
- model,
4158
- preset: presetId,
4159
- cwd,
4160
- branch,
4161
- session: live.agent.session.id,
4162
- readsKeys: io.console.readsKeys,
4163
- resumed: config.resume !== ""
4164
- }, theme, io.console.columns)) io.console.write(line);
4165
- const disposers = [];
4166
- const commands = ctx.get("commands");
4167
- /** The advertised model catalog, fetched once and refreshed per /model call. */
4168
- let modelCatalog = [];
4169
- const refreshModelCatalog = async () => {
4170
- const llm = ctx.get("llm");
4171
- if (llm === void 0) return;
4172
- const providers = llm.listProviders();
4173
- modelCatalog = (await Promise.all(providers.map(async (provider) => {
4174
- try {
4175
- return await llm.listModels(provider.id);
4176
- } catch {
4177
- return [];
4178
- }
4179
- }))).flat().map((entry) => ({
4180
- provider: entry.provider,
4181
- id: entry.id,
4182
- name: entry.name
4183
- }));
4184
- };
4185
- refreshModelCatalog();
4186
- /**
4187
- * Resolve a /model argument to a selection.
4188
- * @param typed - a bare model id, or an explicit `provider/model`.
4189
- * @returns the selection, or an error message naming what is available.
4190
- */
4191
- const resolveModelArgument = (typed) => {
4192
- const slash = typed.indexOf("/");
4193
- if (slash > 0) return {
4194
- provider: typed.slice(0, slash),
4195
- model: typed.slice(slash + 1)
4196
- };
4197
- const hits = modelCatalog.filter((entry) => entry.id === typed);
4198
- if (hits.length === 1 && hits[0] !== void 0) return {
4199
- provider: hits[0].provider,
4200
- model: hits[0].id
4201
- };
4202
- if (hits.length > 1) return `"${typed}" is served by several providers; pick one: ${hits.map((hit) => `${hit.provider}/${hit.id}`).join(", ")}`;
4203
- const known = modelCatalog.map((entry) => entry.id).join(", ");
4204
- return known === "" ? `no catalog to match "${typed}" against; use the explicit provider/model form` : `unknown model "${typed}" (available: ${known}); provider/model also works`;
4205
- };
4206
- const custom = await loadCustomCommands([dshHomePath("commands"), join(cwd, ".dsh", "commands")], new Set([
4207
- ...(commands?.list(live.agent) ?? []).map((entry) => entry.name),
4208
- "exit",
4209
- "quit",
4210
- "help",
4211
- "init",
4212
- "status",
4213
- "model",
4214
- "clear",
4215
- "resume",
4216
- "diff"
4217
- ]));
4218
- for (const warning of custom.warnings) io.console.write(theme.dim(` skipped ${warning}`));
4219
- const customByName = new Map(custom.commands.map((command) => [command.name, command]));
4220
- const completable = () => [
4221
- ...commands?.list(live.agent) ?? [],
4222
- {
4223
- name: "init",
4224
- description: "analyze the repo and draft AGENTS.md"
4225
- },
4226
- ...custom.commands.map((command) => ({
4227
- name: command.name,
4228
- description: command.description
4229
- })),
4230
- {
4231
- name: "exit",
4232
- description: "leave the session"
4233
- }
4234
- ];
4235
- const completePath$1 = createCompleter(completable, cwd);
4236
- /**
4237
- * The first-argument candidates per command, read live: plan's argument
4238
- * depends on its state, permission's on the preset table, model's on the
4239
- * advisory catalog.
4240
- */
4241
- const argumentsFor = (command, typed) => {
4242
- const offer = (values) => values.filter((entry) => entry.value.startsWith(typed));
4243
- if (command === "plan") return planModeFrom(live.agent.session.events) ? offer([{
4244
- value: "off",
4245
- detail: "leave plan mode"
4246
- }]) : [];
4247
- if (command === "permission") {
4248
- const presets = ctx.get("permissionPresets");
4249
- if (presets === void 0) return [];
4250
- const current = presets.current(live.agent.session.events);
4251
- return offer(presets.names.map((name$1) => ({
4252
- value: name$1,
4253
- detail: name$1 === current ? "current" : ""
4254
- })));
4255
- }
4256
- if (command === "model") {
4257
- const current = selection.current;
4258
- const exact = offer(modelCatalog.map((entry) => ({
4259
- value: entry.id,
4260
- detail: entry.provider === current?.provider && entry.id === current.model ? "current" : entry.name
4261
- })));
4262
- if (exact.length > 0 || typed === "") return exact;
4263
- return modelCatalog.map((entry) => ({
4264
- entry,
4265
- score: fuzzyScore(typed, entry.id)
4266
- })).filter((hit) => hit.score !== void 0).sort((a, b) => b.score - a.score).map((hit) => ({
4267
- value: hit.entry.id,
4268
- detail: hit.entry.name
4269
- }));
4270
- }
4271
- return [];
4272
- };
4273
- let onEscapeKey = () => {};
4274
- let onInterruptKey = () => {};
4275
- const prompt = new Prompt(io.console, theme, {
4276
- commands: completable,
4277
- paths: completePath$1,
4278
- commandArguments: argumentsFor
4279
- }, {
4280
- interrupt: () => {
4281
- onInterruptKey();
4282
- },
4283
- escape: () => {
4284
- onEscapeKey();
4285
- },
4286
- eof: () => {
4287
- io.console.close();
4288
- },
4289
- shiftTab: () => {
4290
- const line = planModeFrom(live.agent.session.events) ? "/plan off" : "/plan";
4291
- commands?.execute(live.agent, line, new AbortController().signal);
4292
- },
4293
- expandOutput: () => {
4294
- if (!io.console.toggleFolds()) prompt.write(theme.dim(" nothing to expand"));
4295
- }
4296
- }, "Ask anything · / for commands · @ for files · ⇧Tab plan mode");
4297
- let turnBaseTokens = 0;
4298
- const spinner = new Spinner({
4299
- setLive: (text) => {
4300
- prompt.setHint(text);
4301
- },
4302
- isTty: io.console.readsKeys
4303
- }, theme, {
4304
- verb: "working",
4305
- interrupt: io.console.readsKeys ? "ESC" : "Ctrl-C",
4306
- detail: () => {
4307
- const spent = (totalTokens(facts(branch).usage) ?? 0) - turnBaseTokens;
4308
- return spent > 0 ? `${formatTokens(spent)} tokens` : void 0;
4309
- }
4310
- });
4311
- const historyPath = dshHomePath("code-cli-history.json");
4312
- try {
4313
- const seeded = JSON.parse(await readFile(historyPath, "utf8"));
4314
- if (Array.isArray(seeded)) prompt.seedHistory(seeded.filter((entry) => typeof entry === "string"));
4315
- } catch {}
4316
- let adopt = () => {};
4317
- /**
4318
- * Swap the surface onto another session: new handle in, old one disposed.
4319
- * @param next - the replacement, already composed.
4320
- * @param replayLog - whether to render the session's existing events.
4321
- */
4322
- const switchTo = async (next, replayLog) => {
4323
- await sessions.flush(live.agent.session);
4324
- const old = live.handle;
4325
- adopt(next, replayLog);
4326
- await old.dispose();
4327
- };
4328
- if (commands !== void 0) {
4329
- disposers.push(commands.register({
4330
- name: "status",
4331
- description: "show the model, composition, permissions, and token usage",
4332
- handler: () => ({
4333
- kind: "success",
4334
- text: statusReport(facts(branch), live.agent.session.id)
4335
- })
4336
- }));
4337
- disposers.push(commands.register({
4338
- name: "clear",
4339
- description: "start a fresh session in place",
4340
- handler: async () => {
4341
- await switchTo(await composed.createAnother(), false);
4342
- for (const line of bannerLines({
4343
- model,
4344
- preset: presetId,
4345
- cwd,
4346
- branch,
4347
- session: live.agent.session.id,
4348
- readsKeys: io.console.readsKeys,
4349
- resumed: false
4350
- }, theme, io.console.columns)) prompt.write(line);
4351
- return {
4352
- kind: "success",
4353
- text: `new session ${live.agent.session.id}`
4354
- };
4355
- }
4356
- }));
4357
- disposers.push(commands.register({
4358
- name: "resume",
4359
- description: "switch to an earlier session",
4360
- input: { hint: "[session-id]" },
4361
- handler: async ({ rawInput, signal }) => {
4362
- const typed = rawInput.trim();
4363
- const resume = async (id) => {
4364
- if (id === live.agent.session.id) return {
4365
- kind: "success",
4366
- text: "already on that session"
4367
- };
4368
- await switchTo(await composed.resumeAnother(id), true);
4369
- return {
4370
- kind: "success",
4371
- text: `resumed ${id}`
4372
- };
4373
- };
4374
- if (typed !== "") return resume(SessionId(typed));
4375
- const query = ctx.get("sessionQuery");
4376
- if (query === void 0) return {
4377
- kind: "error",
4378
- text: "session listing is unavailable in this composition"
4379
- };
4380
- const records = (await query.listSessions()).filter((record) => record.header.id !== live.agent.session.id);
4381
- const here = records.filter((record) => record.header.cwd === cwd);
4382
- const elsewhere = records.filter((record) => record.header.cwd !== cwd);
4383
- const listed = [...here, ...elsewhere].slice(0, 20);
4384
- if (listed.length === 0) return {
4385
- kind: "success",
4386
- text: "no other sessions recorded"
4387
- };
4388
- const titles = await Promise.all(listed.map(async (record) => {
4389
- try {
4390
- return (await query.readTitle(record.header.id, signal))?.title;
4391
- } catch {
4392
- return;
4393
- }
4394
- }));
4395
- const rows = listed.map((record, index) => ({
4396
- id: record.header.id,
4397
- label: titles[index] ?? String(record.header.id),
4398
- detail: `${age(record.header.createdAt)}${record.header.cwd === cwd ? "" : ` · ${record.header.cwd ?? ""}`}`
4399
- }));
4400
- if (!io.console.readsKeys) return {
4401
- kind: "success",
4402
- text: rows.map((row) => `${row.id} ${row.label} ${row.detail}`).join("\n")
4403
- };
4404
- const outcome = await prompt.select({
4405
- title: "Resume session",
4406
- options: rows.map((row) => ({
4407
- label: row.label,
4408
- detail: row.detail
4409
- }))
4410
- }, signal);
4411
- if (outcome.kind !== "chosen") return {
4412
- kind: "success",
4413
- text: "nothing resumed"
4414
- };
4415
- const picked = rows[outcome.indices[0] ?? -1];
4416
- if (picked === void 0) return {
4417
- kind: "success",
4418
- text: "nothing resumed"
4419
- };
4420
- return resume(picked.id);
4421
- }
4422
- }));
4423
- disposers.push(commands.register({
4424
- name: "diff",
4425
- description: "show uncommitted workspace changes",
4426
- handler: async ({ signal }) => {
4427
- const against = await capture("git", ["diff", "HEAD"], {
4428
- cwd,
4429
- signal
4430
- });
4431
- const diff = against.code === 0 ? against : await capture("git", ["diff"], {
4432
- cwd,
4433
- signal
4434
- });
4435
- if (diff.code !== 0) return {
4436
- kind: "error",
4437
- text: diff.output.trim() === "" ? "not a git repository" : diff.output.trim()
4438
- };
4439
- if (diff.output.trim() === "") return {
4440
- kind: "success",
4441
- text: "no uncommitted changes"
4442
- };
4443
- for (const line of diff.output.trimEnd().split("\n")) prompt.write(diffLine(line, theme));
4444
- return { kind: "success" };
4445
- }
4446
- }));
4447
- /**
4448
- * Make one route the session's model.
4449
- *
4450
- * Assigning the live ref is the switch; the next request reads it. The
4451
- * default is saved too, like the web surface — a failure there costs the
4452
- * default, never the session.
4453
- */
4454
- const applyModel = async (provider, model$1) => {
4455
- selection.current = {
4456
- provider,
4457
- model: model$1
4458
- };
4459
- try {
4460
- await ctx.get("agentDefaultModel")?.saveSelection(selection.current);
4461
- } catch {}
4462
- refreshStatus();
4463
- };
4464
- disposers.push(commands.register({
4465
- name: "model",
4466
- description: "switch the model answering this session",
4467
- input: { hint: "[model|provider/model]" },
4468
- handler: async ({ rawInput }) => {
4469
- const typed = rawInput.trim();
4470
- if (typed === "") {
4471
- await refreshModelCatalog();
4472
- const current = selection.current;
4473
- const header = `current ${current?.provider ?? "?"}/${current?.model ?? "?"}`;
4474
- if (modelCatalog.length === 0) return {
4475
- kind: "success",
4476
- text: header
4477
- };
4478
- if (!io.console.readsKeys) return {
4479
- kind: "success",
4480
- text: `${header}\n${modelCatalog.map((entry) => {
4481
- return `${entry.provider === current?.provider && entry.id === current.model ? "❯" : " "} ${entry.provider}/${entry.id} ${entry.name}`;
4482
- }).join("\n")}`
4483
- };
4484
- const outcome = await prompt.select({
4485
- title: "Switch model",
4486
- options: modelCatalog.map((entry) => {
4487
- const active = entry.provider === current?.provider && entry.id === current.model;
4488
- return {
4489
- label: `${entry.provider}/${entry.id}`,
4490
- detail: active ? `${entry.name} · current` : entry.name
4491
- };
4492
- })
4493
- });
4494
- if (outcome.kind !== "chosen") return {
4495
- kind: "success",
4496
- text: "model unchanged"
4497
- };
4498
- const picked = modelCatalog[outcome.indices[0] ?? -1];
4499
- if (picked === void 0) return {
4500
- kind: "success",
4501
- text: "model unchanged"
4502
- };
4503
- await applyModel(picked.provider, picked.id);
4504
- return {
4505
- kind: "success",
4506
- text: `model ${picked.provider}/${picked.id}`
4507
- };
4508
- }
4509
- if (modelCatalog.length === 0) await refreshModelCatalog();
4510
- const resolved = resolveModelArgument(typed);
4511
- if (typeof resolved === "string") return {
4512
- kind: "error",
4513
- text: resolved
4514
- };
4515
- await applyModel(resolved.provider, resolved.model);
4516
- return {
4517
- kind: "success",
4518
- text: `model ${resolved.provider}/${resolved.model}`
4519
- };
4520
- }
4521
- }));
4522
- }
4523
- const stream = new TextStream(theme, () => io.console.contentColumns);
4524
- let answerLines = [];
4525
- const finishAnswer = () => {
4526
- if (!stream.streamed) return;
4527
- const tail = stream.flush();
4528
- emit([...tail, ""]);
4529
- answerLines.push(...tail);
4530
- if (answerLines.length > ANSWER_FOLD_LINES) io.console.foldRecent(answerLines.length + 1, [
4531
- ...answerLines.slice(0, ANSWER_HEAD_LINES),
4532
- theme.dim(` … +${answerLines.length - ANSWER_HEAD_LINES} lines (Ctrl+O expands)`),
4533
- ""
4534
- ]);
4535
- answerLines = [];
4536
- };
4537
- const thinking = new TextStream(theme, () => io.console.contentColumns, true);
4538
- let thinkingLines = [];
4539
- let thinkingStartedAt = 0;
4540
- const flushThinking = () => {
4541
- thinkingLines.push(...thinking.flush());
4542
- if (thinkingLines.length === 0) return;
4543
- const seconds = ((performance.now() - thinkingStartedAt) / 1e3).toFixed(1);
4544
- prompt.setStreaming(void 0);
4545
- io.console.appendFold([theme.dim(`✻ thought for ${seconds}s · +${thinkingLines.length} lines (Ctrl+O expands)`), ""], [
4546
- theme.dim(`✻ thought for ${seconds}s`),
4547
- ...thinkingLines,
4548
- ""
4549
- ]);
4550
- thinkingLines = [];
4551
- thinkingStartedAt = 0;
4552
- };
4553
- /**
4554
- * Append the lines an event produced, and show the line still being typed.
4555
- * @param lines - finished lines for the transcript.
4556
- * @param live - the in-progress line, or undefined to release the region.
4557
- */
4558
- const emit = (lines, live$1) => {
4559
- if (lines.length > 0) prompt.setStreaming(void 0);
4560
- for (const line of lines) prompt.write(line);
4561
- prompt.setStreaming(live$1);
4562
- };
4563
- /** Push the always-current status row; the pipe shape prints it instead. */
4564
- const refreshStatus = () => {
4565
- if (!io.console.readsKeys) return;
4566
- prompt.setStatus(statusLine(facts(branch), theme, io.console.columns - 1));
4567
- };
4568
- if (planModeFrom(live.agent.session.events)) prompt.setAccent((text) => theme.pending(text));
4569
- refreshStatus();
4570
- ctx.on("session/event", (session, event) => {
4571
- if (session !== live.agent.session) return;
4572
- if (event.type === "plan/mode") prompt.setAccent(event.data.active ? (text) => theme.pending(text) : void 0);
4573
- refreshStatus();
4574
- if (config.print && event.type === "user/message") return;
4575
- if (event.type === "assistant/chunk") {
4576
- const { chunk } = event.data;
4577
- if (chunk.type === "reasoning-delta") {
4578
- if (chunk.text === "") return;
4579
- if (thinkingStartedAt === 0) thinkingStartedAt = performance.now();
4580
- const step$1 = thinking.push(chunk.text);
4581
- thinkingLines.push(...step$1.lines);
4582
- prompt.setStreaming(step$1.live ?? thinkingLines.at(-1) ?? theme.dim("✻ thinking"));
4583
- return;
4584
- }
4585
- if (chunk.type !== "text-delta") return;
4586
- flushThinking();
4587
- const step = stream.push(chunk.text);
4588
- emit(step.lines, step.live);
4589
- answerLines.push(...step.lines);
4590
- return;
4591
- }
4592
- if (event.type === "assistant/message") {
4593
- flushThinking();
4594
- if (stream.streamed) {
4595
- finishAnswer();
4596
- return;
4597
- }
4598
- }
4599
- const lines = live.transcript.render(event);
4600
- const full = live.transcript.takeFold();
4601
- if (full === void 0) {
4602
- emit(lines);
4603
- return;
4604
- }
4605
- prompt.setStreaming(void 0);
4606
- io.console.appendFold(lines, full);
4607
- });
4608
- /** Pause the indicator around a decision, and resume it if work continues. */
4609
- const whileDeciding = async (decide) => {
4610
- spinner.pause();
4611
- if (config.bell) io.console.bell();
4612
- try {
4613
- return await decide();
4614
- } finally {
4615
- if (live.agent.status === "running") spinner.start();
4616
- }
4617
- };
4618
- const approval = new TerminalApproval({ ask: (toolName, reason, signal) => whileDeciding(async () => {
4619
- if (!io.console.readsKeys) {
4620
- const detail = reason === void 0 ? "" : ` ${theme.dim(reason)}`;
4621
- prompt.write(`${theme.pending("?")} allow ${theme.tool(toolName)}${detail}`);
4622
- const line = await prompt.read(signal);
4623
- return line === void 0 ? void 0 : answerForKey(line) ?? "reject";
4624
- }
4625
- if (reason !== void 0) prompt.write(theme.dim(` ${reason}`));
4626
- const outcome = await prompt.select({
4627
- title: `Allow ${toolName}?`,
4628
- options: [
4629
- {
4630
- label: "Yes, this time",
4631
- shortcut: "y"
4632
- },
4633
- {
4634
- label: `Yes, every ${toolName} call this session`,
4635
- shortcut: "a"
4636
- },
4637
- {
4638
- label: "No",
4639
- shortcut: "n"
4640
- }
4641
- ]
4642
- }, signal);
4643
- if (outcome.kind !== "chosen") return void 0;
4644
- const [chosen] = outcome.indices;
4645
- return chosen === 0 ? "once" : chosen === 1 ? "always" : "reject";
4646
- }) }, theme, (line) => {
4647
- prompt.write(line);
4648
- });
4649
- ctx.on("approval/request", (req, next) => req.agent === live.agent ? approval.decide(req) : next());
4650
- adopt = (next, replayLog) => {
4651
- live.handle = next;
4652
- live.agent = next.agent;
4653
- live.transcript = new Transcript({
4654
- theme,
4655
- columns: io.console.columns,
4656
- cwd
4657
- }, presentersFor(ctx, next.agent));
4658
- io.console.clearScreen();
4659
- approval.clear();
4660
- turnBaseTokens = 0;
4661
- prompt.setAccent(planModeFrom(next.agent.session.events) ? (text) => theme.pending(text) : void 0);
4662
- if (replayLog) replay(next.agent.session, live.transcript, io);
4663
- refreshStatus();
4664
- };
4665
- const questions = ctx.get("userQuestions");
4666
- if (questions !== void 0) {
4667
- const terminalQuestions = new TerminalQuestions(prompt, theme, (line) => {
4668
- prompt.write(line);
4669
- }, io.console.readsKeys ? async (spec, signal) => prompt.select(spec, signal) : void 0);
4670
- questions.registerProvider({ ask: async (request) => whileDeciding(() => terminalQuestions.ask(request)) });
4671
- }
4672
- let running;
4673
- /**
4674
- * Stop whatever the agent is doing. Cancelling an idle agent is a no-op, so
4675
- * the report is withheld unless there was work to stop — an Escape pressed at
4676
- * an empty prompt should look like nothing happened.
4677
- * @returns whether anything was running.
4678
- */
4679
- const interrupt = () => {
4680
- const busy = live.agent.status === "running" || running !== void 0;
4681
- spinner.stop();
4682
- running?.abort();
4683
- live.agent.cancel({ kind: "user" });
4684
- flushThinking();
4685
- finishAnswer();
4686
- if (busy) prompt.write(theme.dim(" interrupted"));
4687
- return busy;
4688
- };
4689
- /**
4690
- * Hand the terminal back and leave.
4691
- *
4692
- * The session's own screen disappears with it, so the few facts a person
4693
- * still needs — which session this was, what it cost, how to reopen it — are
4694
- * written to the buffer that survives instead.
4695
- * @param code - the exit status to request.
4696
- */
4697
- const leave = (code) => {
4698
- prompt.clear();
4699
- io.console.close();
4700
- const usage = totalTokens(facts(branch).usage);
4701
- const spent = usage === void 0 || usage === 0 ? "" : ` · ${formatTokens(usage)} tokens`;
4702
- io.console.writeAfterScreen(theme.dim(`codsh session ${live.agent.session.id}${spent}`));
4703
- io.console.writeAfterScreen(theme.dim(` reopen with: codsh --resume ${live.agent.session.id}`));
4704
- io.exit(code);
4705
- };
4706
- let lastInterrupt = 0;
4707
- let recallArmed;
4708
- onEscapeKey = () => {
4709
- if (interrupt()) return;
4710
- if (!prompt.empty) return;
4711
- const last = prompt.history.findLast((entry) => !entry.startsWith("/") && !entry.startsWith("!"));
4712
- if (last === void 0) return;
4713
- if (recallArmed !== void 0) {
4714
- clearTimeout(recallArmed);
4715
- recallArmed = void 0;
4716
- prompt.setHint(void 0);
4717
- prompt.prefill(last);
4718
- return;
4719
- }
4720
- prompt.setHint(theme.dim(" ESC again to edit your previous message"));
4721
- recallArmed = setTimeout(() => {
4722
- recallArmed = void 0;
4723
- prompt.setHint(void 0);
4724
- }, RECALL_WINDOW_MS);
4725
- recallArmed.unref();
4726
- };
4727
- onInterruptKey = () => {
4728
- const now = performance.now();
4729
- const repeated = now - lastInterrupt < INTERRUPT_EXIT_WINDOW_MS;
4730
- lastInterrupt = now;
4731
- if (!repeated && interrupt()) {
4732
- prompt.write(theme.dim(" Ctrl-C again to exit"));
4733
- return;
4734
- }
4735
- leave(130);
4736
- };
4737
- /**
4738
- * Run one turn and report what it cost.
4739
- *
4740
- * The summary is the answer to "was that expensive?" at the moment a person
4741
- * decides whether to keep going, which is why it lands with the turn rather
4742
- * than only in the status line.
4743
- * @param text - the person's message.
4744
- */
4745
- const answer = async (text, source) => {
4746
- const before = totalTokens(facts(branch).usage) ?? 0;
4747
- turnBaseTokens = before;
4748
- const started = performance.now();
4749
- io.console.setTitle(`⚡ dsh code — ${basename(cwd)}`);
4750
- try {
4751
- await turn(live.agent, text, spinner, source);
4752
- } finally {
4753
- io.console.setTitle(`dsh code — ${basename(cwd)}`);
4754
- }
4755
- const spent = (totalTokens(facts(branch).usage) ?? 0) - before;
4756
- const elapsed = (performance.now() - started) / 1e3;
4757
- if (config.bell && elapsed * 1e3 > BELL_TURN_MS) io.console.bell();
4758
- const cost = spent > 0 ? ` · ${formatTokens(spent)} tokens` : "";
4759
- prompt.write(theme.dim(` ${elapsed.toFixed(1)}s${cost}`));
4760
- prompt.write("");
4761
- };
4762
- if (config.print) {
4763
- await turn(live.agent, config.task, spinner);
4764
- flushThinking();
4765
- finishAnswer();
4766
- await sessions.flush(live.agent.session);
4767
- prompt.clear();
4768
- io.console.close();
4769
- io.exit(0);
4770
- return;
4771
- }
4772
- /**
4773
- * Run a `!` line locally and hand the outcome to the model as context.
4774
- *
4775
- * The command runs in the person's shell in the workspace; its output prints
4776
- * like a terminal card and is injected as a plugin-sourced message, so the
4777
- * next request sees what just happened without a turn being spent on it.
4778
- * @param command - the line after the `!`.
4779
- */
4780
- const passthrough = async (command) => {
4781
- prompt.write(`${theme.user("›")} ${theme.tool(`!${command}`)}`);
4782
- running = new AbortController();
4783
- try {
4784
- const result = await capture(process.env["SHELL"] ?? "/bin/sh", ["-c", command], {
4785
- cwd,
4786
- signal: running.signal,
4787
- timeoutMs: config.bangTimeoutMs
4788
- });
4789
- const lines = result.output.trimEnd() === "" ? [] : result.output.trimEnd().split("\n");
4790
- const kept = lines.slice(0, config.bangOutputLines);
4791
- const dropped = lines.length - kept.length;
4792
- for (const line of kept) prompt.write(theme.dim(` ${line}`));
4793
- if (dropped > 0) prompt.write(theme.dim(` … ${dropped} more lines`));
4794
- const status = result.signal !== null ? theme.error(` ✗ killed by ${result.signal}`) : result.code !== 0 ? theme.error(` ✗ exit ${result.code ?? "?"}`) : void 0;
4795
- if (status !== void 0) prompt.write(status);
4796
- prompt.write("");
4797
- const report = [...kept, ...dropped > 0 ? [`… ${dropped} more lines`] : []].join("\n");
4798
- const exit = result.signal !== null ? `killed by ${result.signal}` : String(result.code ?? 0);
4799
- live.agent.inject(createUserMessage({
4800
- content: [{
4801
- type: "text",
4802
- text: `<bash-input>${command}</bash-input>\n<bash-output>\n${report}\n</bash-output>\n<bash-exit>${exit}</bash-exit>`
4803
- }],
4804
- source: {
4805
- kind: "plugin",
4806
- plugin: "coding-cli"
4807
- }
4808
- }));
4809
- } finally {
4810
- running = void 0;
4811
- }
4812
- };
4813
- io.console.enterScreen();
4814
- prompt.setEngaged(true);
4815
- if (config.task !== "") await answer(config.task);
4816
- let shownStatus;
4817
- for (;;) {
4818
- branch = await gitBranch(cwd);
4819
- if (io.console.readsKeys) refreshStatus();
4820
- else {
4821
- const status = statusLine(facts(branch), theme, io.console.columns);
4822
- if (status !== shownStatus) {
4823
- prompt.write(status);
4824
- shownStatus = status;
4825
- }
4826
- }
4827
- const line = await prompt.read();
4828
- if (line === void 0) break;
4829
- io.console.collapseFolds();
4830
- const trimmed = line.trim();
4831
- if (trimmed === "") continue;
4832
- if (trimmed === "/exit" || trimmed === "/quit") break;
4833
- if (trimmed.startsWith("!")) {
4834
- const command = trimmed.slice(1).trim();
4835
- if (command !== "") await passthrough(command);
4836
- continue;
4837
- }
4838
- if (trimmed.startsWith("/")) {
4839
- prompt.write(`${theme.user("›")} ${trimmed}`);
4840
- const [, name$1 = "", rest = ""] = /^\/(\S+)\s*([\s\S]*)$/.exec(trimmed) ?? [];
4841
- if (name$1 === "init") {
4842
- await answer(INIT_PROMPT, {
4843
- kind: "plugin",
4844
- plugin: "coding-cli"
4845
- });
4846
- continue;
4847
- }
4848
- const canned = customByName.get(name$1);
4849
- if (canned !== void 0) {
4850
- await answer(expandTemplate(canned.template, rest.trim()), {
4851
- kind: "plugin",
4852
- plugin: "coding-cli"
4853
- });
4854
- continue;
4855
- }
4856
- running = new AbortController();
4857
- try {
4858
- await runCommand(ctx, live.agent, trimmed, io, theme, running.signal);
4859
- } finally {
4860
- running = void 0;
4861
- }
4862
- continue;
4863
- }
4864
- await answer(trimmed);
4865
- }
4866
- await sessions.flush(live.agent.session);
4867
- try {
4868
- const worthRecalling = prompt.history.filter((entry) => entry !== "/exit" && entry !== "/quit");
4869
- await writeFile(historyPath, `${JSON.stringify(worthRecalling)}\n`);
4870
- } catch {}
4871
- for (const dispose of disposers.splice(0)) dispose();
4872
- prompt.setEngaged(false);
4873
- leave(0);
4874
- }
4875
- /**
4876
- * Report an unexpected surface failure and request a failing exit.
4877
- * @param io - process-facing effects.
4878
- * @param error - the failure.
4879
- */
4880
- function fail(io, error) {
4881
- io.console.close();
4882
- io.console.writeAfterScreen(`codsh: ${error instanceof Error ? error.message : String(error)}`);
4883
- io.exit(1);
4884
- }
4885
- /**
4886
- * Mount the interactive terminal surface.
4887
- * @param ctx - plugin context carrying core services and the launcher-provided exit request.
4888
- * @param config - validated invocation config.
4889
- */
4890
- function apply(ctx, config) {
4891
- const exit = ctx.get("appExit");
4892
- if (exit === void 0) throw new Error("coding-cli-runner: the launcher must provide ctx.appExit before the tree mounts");
4893
- const io = {
4894
- console: new TerminalConsole(internals.input, internals.output),
4895
- exit
4896
- };
4897
- const restore = () => {
4898
- io.console.leaveScreen();
4899
- };
4900
- process.once("exit", restore);
4901
- for (const signal of ["SIGTERM", "SIGHUP"]) process.once(signal, () => {
4902
- restore();
4903
- process.exit(signal === "SIGTERM" ? 143 : 129);
4904
- });
4905
- run(ctx, config, io).catch((error) => {
4906
- fail(io, error);
4907
- });
4908
- }
4909
-
4910
- //#endregion
4911
- export { Config, apply, inject, internals, name };