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