acryldev 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/bin.js +4303 -0
  4. package/package.json +207 -0
package/bin.js ADDED
@@ -0,0 +1,4303 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
4
+ import { join, relative, resolve } from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
8
+ import { SessionId } from "@deepseek-ai/dsh-session";
9
+ import { writeFileSync } from "node:fs";
10
+ import { DEFAULT_PROFILE_BUNDLES, boot, composeEntries, healProfilesModuleFallback, initProfile, loadProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
11
+ import { diffLines } from "diff";
12
+ import { Container, Editor, Key, KeybindingsManager, ProcessTerminal, ScrollView, TUI_KEYBINDINGS, Text, TuiAltScreen, VStack, matchesKey, setKeybindings, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
13
+ import { readdir } from "node:fs/promises";
14
+ //#region src/cli/node-launcher.ts
15
+ /** Return the child Node invocation required to expose Cordis HMR internals. */
16
+ function exposedInternalsInvocation(input) {
17
+ if (input.execArgv.includes("--expose-internals")) return void 0;
18
+ return [
19
+ "--expose-internals",
20
+ ...input.execArgv,
21
+ input.script,
22
+ ...input.args
23
+ ];
24
+ }
25
+ /** Re-execute this CLI under Node with the Cordis HMR prerequisite enabled. */
26
+ async function relaunchWithExposedInternals(input) {
27
+ const invocation = exposedInternalsInvocation(input);
28
+ if (invocation === void 0) return false;
29
+ const exitCode = await new Promise((resolve, reject) => {
30
+ const child = spawn(process.execPath, invocation, { stdio: "inherit" });
31
+ child.once("error", reject);
32
+ child.once("exit", (code) => resolve(code ?? 1));
33
+ });
34
+ process.exitCode = exitCode;
35
+ return true;
36
+ }
37
+ //#endregion
38
+ //#region ../acryl-harness-runtime/lib/index.mjs
39
+ function contentText(content) {
40
+ return content.filter((block) => {
41
+ return block.type === "text" && typeof block.text === "string";
42
+ }).map((block) => block.text).join("");
43
+ }
44
+ function transcript(events) {
45
+ const items = [];
46
+ for (const event of events) {
47
+ if (event.type === "user/message" && event.data.source.kind === "user") {
48
+ const text = contentText(event.data.content);
49
+ if (text !== "") items.push(Object.freeze({
50
+ id: `event-${event.seq}`,
51
+ author: "user",
52
+ text
53
+ }));
54
+ }
55
+ if (event.type === "assistant/message") {
56
+ const text = contentText(event.data.message.content);
57
+ if (text !== "") items.push(Object.freeze({
58
+ id: `event-${event.seq}`,
59
+ author: "assistant",
60
+ text
61
+ }));
62
+ }
63
+ }
64
+ return Object.freeze(items);
65
+ }
66
+ function tools(events) {
67
+ const current = /* @__PURE__ */ new Map();
68
+ for (const event of events) {
69
+ if (event.type === "tool/call") current.set(event.data.callId, Object.freeze({
70
+ callId: event.data.callId,
71
+ name: event.data.name,
72
+ status: "running"
73
+ }));
74
+ if (event.type === "tool/result") {
75
+ const callId = event.data.message.source.callId;
76
+ const existing = current.get(callId);
77
+ if (existing !== void 0) current.set(callId, Object.freeze({
78
+ ...existing,
79
+ status: "succeeded"
80
+ }));
81
+ }
82
+ }
83
+ return Object.freeze([...current.values()]);
84
+ }
85
+ function status(agent) {
86
+ return agent.status === "running" ? "running" : "idle";
87
+ }
88
+ /**
89
+ * Runtime-owned adapter over one native DSH agent/session. It creates or resumes
90
+ * the native agent and derives every presentation value from its durable log.
91
+ */
92
+ function createAcrylSessionBridge(ctx, options) {
93
+ const handles = /* @__PURE__ */ new Map();
94
+ const subscribers = /* @__PURE__ */ new Map();
95
+ const eventListeners = /* @__PURE__ */ new Map();
96
+ let disposed = false;
97
+ const notify = (sessionId) => {
98
+ const listeners = subscribers.get(sessionId);
99
+ if (listeners === void 0) return;
100
+ snapshot(sessionId).then((next) => {
101
+ for (const listener of listeners) try {
102
+ listener(next);
103
+ } catch {}
104
+ });
105
+ };
106
+ const offSessionEvent = ctx.on("session/event", (session, event) => {
107
+ if (!handles.has(session.id)) return;
108
+ notify(session.id);
109
+ const listeners = eventListeners.get(session.id);
110
+ if (listeners === void 0) return;
111
+ for (const listener of listeners) try {
112
+ listener(event);
113
+ } catch {}
114
+ });
115
+ const snapshot = async (sessionId) => {
116
+ const agent = agentFor(sessionId);
117
+ return Object.freeze({
118
+ profile: options.profile,
119
+ generationId: options.generationId,
120
+ attachment: options.attachment,
121
+ sessionId: agent.id,
122
+ agentStatus: status(agent),
123
+ transcript: transcript(agent.session.events),
124
+ tools: tools(agent.session.events)
125
+ });
126
+ };
127
+ const agentFor = (sessionId) => {
128
+ if (disposed) throw new Error("ACRYL session bridge is disposed");
129
+ const handle = handles.get(sessionId);
130
+ if (handle === void 0) throw new Error(`ACRYL session ${sessionId} is not active`);
131
+ return handle.agent;
132
+ };
133
+ return Object.freeze({
134
+ async open(resumeSessionId) {
135
+ if (disposed) throw new Error("ACRYL session bridge is disposed");
136
+ if (handles.size !== 0) throw new Error("ACRYL session bridge already has an active session");
137
+ const defaultModel = ctx.get("agentDefaultModel");
138
+ if (defaultModel === void 0) throw new Error("ACRYL profile has no default agent model");
139
+ const selection = defaultModel.currentSelection();
140
+ const handle = resumeSessionId === void 0 ? await ctx.agents.create({
141
+ sessionId: SessionId(`acryl-session-${crypto.randomUUID()}`),
142
+ meta: { cwd: options.cwd },
143
+ agentOptions: {
144
+ provider: selection.provider,
145
+ model: selection.model
146
+ }
147
+ }) : await ctx.agents.resume({
148
+ resumeSessionId: SessionId(resumeSessionId),
149
+ agentOptions: {
150
+ provider: selection.provider,
151
+ model: selection.model
152
+ }
153
+ });
154
+ handles.set(handle.agent.id, handle);
155
+ return handle.agent.id;
156
+ },
157
+ snapshot,
158
+ events(sessionId) {
159
+ return agentFor(sessionId).session.events;
160
+ },
161
+ async subscribe(sessionId, listener, _onError) {
162
+ agentFor(sessionId);
163
+ const listeners = subscribers.get(sessionId) ?? /* @__PURE__ */ new Set();
164
+ subscribers.set(sessionId, listeners);
165
+ listeners.add(listener);
166
+ try {
167
+ listener(await snapshot(sessionId));
168
+ } catch {}
169
+ let active = true;
170
+ return Object.freeze({
171
+ whenError() {
172
+ return new Promise(() => {});
173
+ },
174
+ async dispose() {
175
+ if (!active) return;
176
+ active = false;
177
+ listeners.delete(listener);
178
+ if (listeners.size === 0) subscribers.delete(sessionId);
179
+ }
180
+ });
181
+ },
182
+ async subscribeEvents(sessionId, listener) {
183
+ agentFor(sessionId);
184
+ const listeners = eventListeners.get(sessionId) ?? /* @__PURE__ */ new Set();
185
+ eventListeners.set(sessionId, listeners);
186
+ listeners.add(listener);
187
+ let active = true;
188
+ return Object.freeze({ async dispose() {
189
+ if (!active) return;
190
+ active = false;
191
+ listeners.delete(listener);
192
+ if (listeners.size === 0) eventListeners.delete(sessionId);
193
+ } });
194
+ },
195
+ async submitPrompt(input) {
196
+ const agent = agentFor(input.sessionId);
197
+ if (input.text.trim() === "") throw new Error("ACRYL prompt must not be empty");
198
+ const accepted = new Promise((resolve) => {
199
+ const off = ctx.on("session/event", (session, event) => {
200
+ if (session !== agent.session || event.type !== "user/message") return;
201
+ off();
202
+ resolve();
203
+ });
204
+ });
205
+ agent.followup(createUserMessage({
206
+ content: [{
207
+ type: "text",
208
+ text: input.text
209
+ }],
210
+ source: { kind: "user" }
211
+ }));
212
+ await accepted;
213
+ },
214
+ async cancel(sessionId) {
215
+ agentFor(sessionId).cancel({ kind: "user" });
216
+ },
217
+ async dispose() {
218
+ if (disposed) return;
219
+ disposed = true;
220
+ offSessionEvent();
221
+ subscribers.clear();
222
+ eventListeners.clear();
223
+ const activeHandles = [...handles.values()];
224
+ handles.clear();
225
+ const sessions = ctx.get("sessions");
226
+ for (const handle of activeHandles) {
227
+ try {
228
+ await handle.agent.whenIdle();
229
+ } catch {}
230
+ try {
231
+ await sessions?.flush(handle.agent.session);
232
+ } catch {}
233
+ }
234
+ await Promise.all(activeHandles.map((handle) => handle.dispose()));
235
+ }
236
+ });
237
+ }
238
+ const dshInstallAnchor = createRequire(import.meta.url).resolve("@deepseek-ai/dsh/package.json");
239
+ const profileRoot = "[]\n";
240
+ const ACRYL_RUNTIME_ROWS = [
241
+ {
242
+ id: "system-prompt",
243
+ name: "@deepseek-ai/dsh-system-prompt",
244
+ config: { persona: "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}." }
245
+ },
246
+ {
247
+ id: "agent-presets",
248
+ name: "@deepseek-ai/dsh-agent-presets",
249
+ config: { default: "standard" }
250
+ },
251
+ {
252
+ id: "session-stats",
253
+ name: "@deepseek-ai/dsh-session-stats"
254
+ }
255
+ ];
256
+ /** Boot one normal pinned-Harness ACRYL profile in a single Cordis root. */
257
+ async function bootAcrylHarnessProfile(options) {
258
+ if (options.profile.trim() === "") throw new Error("ACRYL Harness profile must not be empty");
259
+ initProfile(resolveProfileDir(options.profile), DEFAULT_PROFILE_BUNDLES);
260
+ healProfilesModuleFallback(dshInstallAnchor);
261
+ const profile = loadProfile("acryl", options.profile, dshInstallAnchor);
262
+ const rootConfig = join(profile.dir, "cordis.yml");
263
+ writeFileSync(rootConfig, profileRoot);
264
+ const patches = structuredClone([
265
+ ...profile.layers.flatMap((layer) => layer.patches),
266
+ ...ACRYL_RUNTIME_ROWS,
267
+ ...profile.patches
268
+ ]);
269
+ if (composeEntries([patches]).find((entry) => entry.id === "hmr")?.disabled !== true && !process.execArgv.includes("--expose-internals")) throw new Error("ACRYL profile enables Cordis HMR and must be launched with Node --expose-internals");
270
+ const ctx = await boot("acryl", rootConfig, patches, options.prepare);
271
+ let disposed = false;
272
+ return Object.freeze({
273
+ ctx,
274
+ profileDirectory: profile.dir,
275
+ async dispose() {
276
+ if (disposed) return;
277
+ disposed = true;
278
+ await ctx.fiber.dispose();
279
+ }
280
+ });
281
+ }
282
+ //#endregion
283
+ //#region src/host/direct.ts
284
+ /** Start one normal local Harness runtime for this terminal surface. */
285
+ /**
286
+ * A local surface owns its normal DSH/Cordis root. Durable DSH sessions, not
287
+ * `.acryl/control` experiments, provide continuity across later launches.
288
+ */
289
+ async function startDirectHost(options) {
290
+ if (options.profile.trim() === "") throw new Error("ACRYL direct host profile must not be empty");
291
+ const runtime = await bootAcrylHarnessProfile({ profile: options.profile });
292
+ const ctx = runtime.ctx;
293
+ let disposed = false;
294
+ return Object.freeze({
295
+ ctx,
296
+ profile: options.profile,
297
+ generationId: options.generationId ?? randomUUID(),
298
+ runtimeState: ctx.get("sessions") !== void 0 && ctx.get("agents") !== void 0 ? "ready" : "unavailable",
299
+ async dispose() {
300
+ if (disposed) return;
301
+ disposed = true;
302
+ await runtime.dispose();
303
+ }
304
+ });
305
+ }
306
+ //#endregion
307
+ //#region src/tui/theme.ts
308
+ /**
309
+ * DeepSeek brand palette for the TUI. Keep color decisions semantic so every
310
+ * Ink surface and the raw-ANSI `render.ts`/`bannerText.ts` helpers share the
311
+ * same visual language — see `DESIGN.md` for the full rationale and the
312
+ * component-mapping guide this table is drawn from.
313
+ *
314
+ * The terminal still owns its background and default foreground; these are
315
+ * foreground tokens for interactive, stateful, and brand elements only —
316
+ * this TUI renders to native scrollback (no painted panel backgrounds), so
317
+ * DeepSeek's `surface`/`bg-dark`/`border-dim` tokens are deliberately not
318
+ * represented here.
319
+ * @module @tomowang/dsh-tui/tui/theme
320
+ */
321
+ const theme = {
322
+ /** DeepSeek Blue — brand banner/ASCII, active input border. */
323
+ primary: "#4F6BFE",
324
+ /** Electric Cyan — section headers, streaming/progress indicators. */
325
+ secondary: "#38BDF8",
326
+ /** Slate Indigo — badges (active provider/model). */
327
+ accent: "#818CF8",
328
+ /** Thought Violet — reasoning/thinking content, set apart from assistant text; see `formatReasoningSummary`/`formatStreamingText` in `src/render.ts`. */
329
+ reasoning: "#A855F7",
330
+ /** Mint Emerald. */
331
+ success: "#34D399",
332
+ /** Amber Sun. */
333
+ warning: "#FBBF24",
334
+ /** Coral Red. */
335
+ error: "#F87171",
336
+ /** DeepSeek uses its primary blue for informational UI. */
337
+ info: "#4F6BFE",
338
+ /** Slate Gray — dim/secondary text (labels, hints, timestamps). */
339
+ muted: "#94A3B8"
340
+ };
341
+ /** 24-bit-color ANSI wrapper, shared by every raw-ANSI formatter (`render.ts`, `markdown.ts`, `bannerText.ts`) and every pi-tui component theme adapter. */
342
+ function fg(hex) {
343
+ const n = Number.parseInt(hex.slice(1), 16);
344
+ const r = n >> 16 & 255;
345
+ const g = n >> 8 & 255;
346
+ const b = n & 255;
347
+ return (s) => `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
348
+ }
349
+ //#endregion
350
+ //#region src/markdown.ts
351
+ /**
352
+ * Terminal Markdown rendering for assistant text. `render.ts` prints
353
+ * assistant/tool output straight to native scrollback via raw ANSI, so this
354
+ * module detects whether a text blob is (at least partly) Markdown before
355
+ * paying the cost of styling it — plain prose keeps rendering exactly as it
356
+ * always has, only text carrying real Markdown syntax gets headers, bold,
357
+ * lists, code spans, tables, etc. converted to ANSI.
358
+ * @module @tomowang/dsh-tui/markdown
359
+ */
360
+ const ESC$1 = "\x1B[";
361
+ const dim$3 = fg(theme.muted);
362
+ const cyan$1 = fg(theme.secondary);
363
+ const primary$1 = fg(theme.primary);
364
+ const bold$11 = (s) => `${ESC$1}1m${s}${ESC$1}0m`;
365
+ const italic = (s) => `${ESC$1}3m${s}${ESC$1}0m`;
366
+ const strike = (s) => `${ESC$1}9m${s}${ESC$1}0m`;
367
+ const underline = (s) => `${ESC$1}4m${s}${ESC$1}0m`;
368
+ /** Wrap `label` as an OSC 8 terminal hyperlink to `url`; terminals without OSC 8 support just print `label` and ignore the surrounding escapes. */
369
+ function hyperlink(url, label) {
370
+ return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
371
+ }
372
+ const FENCE_RE = /^(\s*)(`{3,}|~{3,})\s*(\S*)\s*$/;
373
+ const ATX_HEADER_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
374
+ const HR_RE = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/;
375
+ const BLOCKQUOTE_RE = /^(\s*)((?:>\s?)+)(.*)$/;
376
+ const UNORDERED_RE = /^(\s*)([-*+])\s+(.*)$/;
377
+ const ORDERED_RE = /^(\s*)(\d+)([.)])\s+(.*)$/;
378
+ const TABLE_ROW_RE = /^\s*\|.*\|\s*$/;
379
+ const TABLE_SEPARATOR_CELL_RE = /^:?-+:?$/;
380
+ const LINK_RE = /\[([^\]\n]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/;
381
+ const BOLD_RE = /\*\*([^*\n]+)\*\*|__([^_\n]+)__/;
382
+ const INLINE_CODE_RE = /`([^`\n]+)`/;
383
+ const ITALIC_RE = /(?<!\*)\*(?!\*)([^*\n]+)\*(?!\*)|(?<!_)_(?!_)([^_\n]+)_(?!_)/;
384
+ const STRIKE_RE = /~~([^~\n]+)~~/;
385
+ const LINK_RE_G = new RegExp(LINK_RE.source, "g");
386
+ const BOLD_RE_G = new RegExp(BOLD_RE.source, "g");
387
+ const ITALIC_RE_G = new RegExp(ITALIC_RE.source, "g");
388
+ const STRIKE_RE_G = new RegExp(STRIKE_RE.source, "g");
389
+ const INLINE_CODE_RE_G = new RegExp(INLINE_CODE_RE.source, "g");
390
+ /**
391
+ * Heuristically decides whether `text` carries Markdown markup worth
392
+ * rendering, as opposed to plain prose that happens to contain a stray `*`
393
+ * or `_`. Block-level syntax (fenced code, headers, rules, quotes, lists,
394
+ * table rows) and unambiguous inline syntax (links, bold, strikethrough,
395
+ * inline code) each single-handedly qualify. Lone single-`*`/`_` emphasis is
396
+ * deliberately excluded: it is the highest false-positive-risk cue (globs,
397
+ * multiplication, snake_case, `*args`) and easy to get wrong on its own, so
398
+ * it only ever renders as emphasis when some other signal already confirmed
399
+ * the text is Markdown.
400
+ */
401
+ function looksLikeMarkdown(text) {
402
+ for (const line of text.split("\n")) if (FENCE_RE.test(line) || ATX_HEADER_RE.test(line) || HR_RE.test(line) || BLOCKQUOTE_RE.test(line) || UNORDERED_RE.test(line) || ORDERED_RE.test(line) || TABLE_ROW_RE.test(line)) return true;
403
+ return LINK_RE.test(text) || BOLD_RE.test(text) || STRIKE_RE.test(text) || INLINE_CODE_RE.test(text);
404
+ }
405
+ /** Style links, bold, strikethrough, and emphasis in a span already known to contain no inline code. */
406
+ function applyNonCodeInline(text) {
407
+ let working = text.replaceAll(LINK_RE_G, (_match, label, url) => hyperlink(url, underline(primary$1(label))));
408
+ working = working.replaceAll(BOLD_RE_G, (_match, a, b) => bold$11(a ?? b ?? ""));
409
+ working = working.replaceAll(STRIKE_RE_G, (_match, t) => strike(t));
410
+ return working.replaceAll(ITALIC_RE_G, (_match, a, b) => italic(a ?? b ?? ""));
411
+ }
412
+ /**
413
+ * Style one line's inline Markdown (links, bold, strikethrough, inline
414
+ * code, emphasis). Splits on inline code spans first — `String.split` with
415
+ * a single-capture-group regex interleaves the code contents (odd indices)
416
+ * between the surrounding plain-text spans (even indices) — so a code
417
+ * span's contents can never be mistaken for bold/italic/link syntax.
418
+ */
419
+ function applyInline(text) {
420
+ return text.split(INLINE_CODE_RE_G).map((part, i) => i % 2 === 1 ? cyan$1(part) : applyNonCodeInline(part)).join("");
421
+ }
422
+ /** Split a line already confirmed by `TABLE_ROW_RE` into trimmed cells, dropping the framing `|`. */
423
+ function splitTableRow(line) {
424
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((cell) => cell.trim());
425
+ }
426
+ /**
427
+ * A GFM delimiter row: every cell is dashes with optional leading/trailing
428
+ * colons for alignment. Returns each column's alignment, or `null` when
429
+ * `line` isn't a valid delimiter row — the caller then treats the preceding
430
+ * line as ordinary text rather than a table header.
431
+ */
432
+ function parseTableSeparator(line) {
433
+ const cells = splitTableRow(line);
434
+ const aligns = [];
435
+ for (const cell of cells) {
436
+ if (!TABLE_SEPARATOR_CELL_RE.test(cell)) return null;
437
+ const left = cell.startsWith(":");
438
+ const right = cell.endsWith(":");
439
+ aligns.push(left && right ? "center" : right ? "right" : "left");
440
+ }
441
+ return aligns;
442
+ }
443
+ /** Every escape this module's own `fg`/`bold`/`italic`/`strike`/`underline`/`hyperlink` helpers emit: SGR sequences and OSC 8 hyperlinks (BEL- or ST-terminated). */
444
+ const ANSI_RE$1 = /\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b\[[0-9;]*m/g;
445
+ /**
446
+ * Visible length of an already-styled cell: ANSI escapes have string length
447
+ * but no on-screen width, so measuring `styled.length` directly would
448
+ * overcount by however many escape bytes the cell's markup produced.
449
+ * Stripping them first — rather than measuring the pre-styling raw source
450
+ * — also fixes the companion bug that source length would otherwise cause:
451
+ * `**bold**`'s raw text is 4 characters longer than what it renders as, so
452
+ * a column sized from raw source overcounts a bold cell's true width, and
453
+ * that same bold cell then looks "wide enough" and gets under-padded
454
+ * relative to its plain siblings. Like the rest of this module, "visible"
455
+ * means UTF-16 code units after stripping escapes, not a true display-width
456
+ * count, so a wide/astral cell (CJK, emoji) can still under-pad — a known
457
+ * limitation shared with the fixed-width horizontal rule below.
458
+ */
459
+ function visibleLength(styled) {
460
+ return styled.replace(ANSI_RE$1, "").length;
461
+ }
462
+ /** Pad an already-styled cell out to `width`, measuring by `visibleLength` rather than `styled.length`. */
463
+ function padCell(styled, width, align) {
464
+ const gap = Math.max(0, width - visibleLength(styled));
465
+ if (align === "right") return " ".repeat(gap) + styled;
466
+ if (align === "center") {
467
+ const left = Math.floor(gap / 2);
468
+ return " ".repeat(left) + styled + " ".repeat(gap - left);
469
+ }
470
+ return styled + " ".repeat(gap);
471
+ }
472
+ /** Pad one row of already-styled cells (header or body) to a single column-aligned line. */
473
+ function formatTableRow(styledCells, widths, aligns, isHeader) {
474
+ return widths.map((width, i) => padCell(isHeader ? bold$11(styledCells[i] ?? "") : styledCells[i] ?? "", width, aligns[i] ?? "left")).join(dim$3(" │ "));
475
+ }
476
+ /** A dim horizontal rule under the header row, with a cross at each column boundary. */
477
+ function formatTableRule(widths) {
478
+ return dim$3(widths.map((width) => "─".repeat(width)).join("─┼─"));
479
+ }
480
+ /**
481
+ * Render Markdown source to ANSI-styled terminal text: headers, fenced/
482
+ * inline code, block quotes, ordered/unordered lists, rules, tables, links,
483
+ * bold, strikethrough, and emphasis. Text that `looksLikeMarkdown` rejects
484
+ * passes through byte-for-byte unchanged.
485
+ */
486
+ function renderMarkdown(text) {
487
+ if (!looksLikeMarkdown(text)) return text;
488
+ const out = [];
489
+ let inCode = false;
490
+ let fenceChar = "";
491
+ let fenceLen = 0;
492
+ const lines = text.split("\n");
493
+ for (let i = 0; i < lines.length; i++) {
494
+ const line = lines[i];
495
+ if (!inCode && TABLE_ROW_RE.test(line) && i + 1 < lines.length) {
496
+ const aligns = TABLE_ROW_RE.test(lines[i + 1]) ? parseTableSeparator(lines[i + 1]) : null;
497
+ if (aligns !== null) {
498
+ const styledHeader = splitTableRow(line).map((cell) => applyInline(cell));
499
+ const styledRows = [];
500
+ let j = i + 2;
501
+ for (; j < lines.length && TABLE_ROW_RE.test(lines[j]); j++) styledRows.push(splitTableRow(lines[j]).map((cell) => applyInline(cell)));
502
+ const columns = Math.max(styledHeader.length, ...styledRows.map((row) => row.length), aligns.length);
503
+ const widths = Array.from({ length: columns }, (_, col) => Math.max(visibleLength(styledHeader[col] ?? ""), ...styledRows.map((row) => visibleLength(row[col] ?? ""))));
504
+ const columnAligns = Array.from({ length: columns }, (_, col) => aligns[col] ?? "left");
505
+ out.push(formatTableRow(styledHeader, widths, columnAligns, true));
506
+ out.push(formatTableRule(widths));
507
+ for (const styledRow of styledRows) out.push(formatTableRow(styledRow, widths, columnAligns, false));
508
+ i = j - 1;
509
+ continue;
510
+ }
511
+ }
512
+ const fence = FENCE_RE.exec(line);
513
+ if (fence !== null && (!inCode || fence[2][0] === fenceChar && fence[2].length >= fenceLen)) {
514
+ if (inCode) inCode = false;
515
+ else {
516
+ inCode = true;
517
+ fenceChar = fence[2][0];
518
+ fenceLen = fence[2].length;
519
+ if (fence[3] !== "") out.push(dim$3(fence[3]));
520
+ }
521
+ continue;
522
+ }
523
+ if (inCode) {
524
+ out.push(dim$3(line));
525
+ continue;
526
+ }
527
+ const header = ATX_HEADER_RE.exec(line);
528
+ if (header !== null) {
529
+ const level = header[1].length;
530
+ const content = applyInline(header[2]);
531
+ out.push(level === 1 ? bold$11(primary$1(content)) : level === 2 ? bold$11(cyan$1(content)) : bold$11(content));
532
+ continue;
533
+ }
534
+ if (HR_RE.test(line)) {
535
+ out.push(dim$3("─".repeat(40)));
536
+ continue;
537
+ }
538
+ const quote = BLOCKQUOTE_RE.exec(line);
539
+ if (quote !== null) {
540
+ const depth = (quote[2].match(/>/g) ?? []).length;
541
+ out.push(`${dim$3("▏".repeat(depth))} ${applyInline(quote[3])}`);
542
+ continue;
543
+ }
544
+ const unordered = UNORDERED_RE.exec(line);
545
+ if (unordered !== null) {
546
+ out.push(`${unordered[1]}${cyan$1("•")} ${applyInline(unordered[3])}`);
547
+ continue;
548
+ }
549
+ const ordered = ORDERED_RE.exec(line);
550
+ if (ordered !== null) {
551
+ out.push(`${ordered[1]}${cyan$1(`${ordered[2]}${ordered[3]}`)} ${applyInline(ordered[4])}`);
552
+ continue;
553
+ }
554
+ out.push(applyInline(line));
555
+ }
556
+ return out.join("\n");
557
+ }
558
+ //#endregion
559
+ //#region src/render.ts
560
+ /**
561
+ * Terminal projection of durable session events. The TUI renders only from the
562
+ * append-only session log, so a resumed session replays through the exact same
563
+ * code path as live events.
564
+ * @module @tomowang/dsh-tui/render
565
+ */
566
+ const dim$2 = fg(theme.muted);
567
+ const cyan = fg(theme.secondary);
568
+ const red = fg(theme.error);
569
+ const green = fg(theme.success);
570
+ const yellow = fg(theme.warning);
571
+ const violet$1 = fg(theme.reasoning);
572
+ /** Line cap for a settled shell-escape (`!`) run's body in the permanent transcript; `<Static>` prints can't be redrawn, so a long run is summarized there — tool calls/results don't use this cap, since the transcript only ever shows their one-line collapsed summary (see `formatToolCardSummary`), with full detail available via `formatToolCardDetail` in the Tool Cards overlay. */
573
+ const MAX_CARD_LINES = 20;
574
+ /** `diff` package's `maxEditLength`: bounds worst-case diff cost on a huge file, mirroring the removed first-party TUI's default. */
575
+ const MAX_DIFF_EDIT_LENGTH = 1e3;
576
+ /** Clamp one-line summaries so tool arguments cannot flood the transcript. */
577
+ function truncate(text, max) {
578
+ const oneLine = text.replaceAll("\n", " ");
579
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
580
+ }
581
+ /** Join the text blocks of a message content array. */
582
+ function textOf(content) {
583
+ return content.filter((block) => block.type === "text").map((block) => block.text).join("");
584
+ }
585
+ /** Join the reasoning/thinking blocks of a message content array, distinct from its visible `textOf`. */
586
+ function reasoningOf(content) {
587
+ return content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
588
+ }
589
+ /** Preview length for a settled step's reasoning summary line — long enough to be useful, short enough that a long thinking block never floods the transcript; the full text is always in `/trajectory`. */
590
+ const REASONING_SUMMARY_LENGTH = 80;
591
+ /** Format one settled message's reasoning/thinking content as a single collapsed line — the label plus a short preview — never the full body, which stays available via `/trajectory`. */
592
+ function formatReasoningSummary(text) {
593
+ return violet$1(`✦ think · ${truncate(text, REASONING_SUMMARY_LENGTH)}`);
594
+ }
595
+ /**
596
+ * Format one settled step's text (and, ahead of it, a one-line reasoning
597
+ * summary when the step had any), for the permanent transcript.
598
+ */
599
+ function formatSettledMessage(text, reasoningText) {
600
+ const parts = [];
601
+ if (reasoningText !== "") parts.push(formatReasoningSummary(reasoningText));
602
+ if (text !== "") parts.push(renderMarkdown(text));
603
+ return parts.length === 0 ? void 0 : `\n${parts.join("\n")}\n`;
604
+ }
605
+ /**
606
+ * Format the in-progress step's live region: while reasoning has started but
607
+ * no visible text has arrived yet, an animated `spinnerChar thinking` line
608
+ * stands in for the raw, fast-scrolling reasoning body (its settled one-line
609
+ * `✦ think · …` summary appears in the transcript once the step lands —
610
+ * see `formatSettledMessage`); once text starts streaming, that text is
611
+ * shown directly.
612
+ */
613
+ function formatStreamingText(text, reasoningText = "", spinnerChar = "✦") {
614
+ if (text === "" && reasoningText === "") return void 0;
615
+ if (text === "") return `\n${violet$1(`${spinnerChar} thinking`)}\n`;
616
+ return `\n${renderMarkdown(text)}\n`;
617
+ }
618
+ /** One local shell-escape run's header + output lines, shared by the settled and in-flight renderers below. `exitCode` is `null` while still running. */
619
+ function formatShellLines(command, output, exitCode) {
620
+ const lines = [`${yellow("!")} ${command}`];
621
+ if (output !== "") lines.push(...splitLines(output).map(dim$2));
622
+ if (exitCode !== null) lines.push(exitCode === 0 ? dim$2(`[exit ${exitCode}]`) : red(`[exit ${exitCode}]`));
623
+ return lines;
624
+ }
625
+ /** Format one settled local shell-escape run (`!` prompt-mode) for the permanent transcript, mirroring a `terminal` tool card. */
626
+ function formatShellRun(command, output, exitCode) {
627
+ return `\n${capLines(formatShellLines(command, output, exitCode), MAX_CARD_LINES).join("\n")}\n`;
628
+ }
629
+ /** Format the in-progress shell-escape run's accumulated output for the live region, mirroring `formatStreamingText`'s settle-without-jump framing. */
630
+ function formatShellRunLive(command, output) {
631
+ return `\n${capLines(formatShellLines(command, output, null), MAX_CARD_LINES).join("\n")}\n`;
632
+ }
633
+ /** Parse a tool call's JSON-encoded arguments; malformed JSON can't be handed to a presenter. */
634
+ function parseJson(text) {
635
+ try {
636
+ return {
637
+ valid: true,
638
+ value: JSON.parse(text)
639
+ };
640
+ } catch {
641
+ return { valid: false };
642
+ }
643
+ }
644
+ /** Render a value for display: a string as-is, anything else as pretty JSON. */
645
+ function pretty(value) {
646
+ if (typeof value === "string") return value;
647
+ return JSON.stringify(value, null, 2) ?? String(value);
648
+ }
649
+ /** A string's content lines: empty text is zero lines, a trailing newline terminates the last line. */
650
+ function splitLines(text) {
651
+ if (text === "") return [];
652
+ return (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n");
653
+ }
654
+ /** Cap a card body to `max` lines, appending a dim summary of what was omitted. */
655
+ function capLines(lines, max) {
656
+ if (lines.length <= max) return [...lines];
657
+ const omitted = lines.length - max;
658
+ return [...lines.slice(0, max), dim$2(`… +${omitted} line${omitted === 1 ? "" : "s"} omitted`)];
659
+ }
660
+ /**
661
+ * One file's change as +/- diff lines under a dim path header. A `null` prior
662
+ * text (new file, or a call-time overwrite with no before-image) renders the
663
+ * whole new text as additions; a comparison beyond `MAX_DIFF_EDIT_LENGTH` falls
664
+ * back to whole-side add/remove so a huge file can't stall formatting.
665
+ */
666
+ function renderFileDiff(diff) {
667
+ const lines = [dim$2(diff.path)];
668
+ if (diff.oldText === null) {
669
+ for (const line of splitLines(diff.newText)) lines.push(green(`+ ${line}`));
670
+ return lines;
671
+ }
672
+ const changes = diffLines(diff.oldText, diff.newText, { maxEditLength: MAX_DIFF_EDIT_LENGTH });
673
+ if (changes === void 0) {
674
+ lines.push(dim$2(`[diff omitted: over ${MAX_DIFF_EDIT_LENGTH} changed lines]`));
675
+ for (const line of splitLines(diff.oldText)) lines.push(red(`- ${line}`));
676
+ for (const line of splitLines(diff.newText)) lines.push(green(`+ ${line}`));
677
+ return lines;
678
+ }
679
+ for (const change of changes) {
680
+ const prefix = change.added ? "+" : change.removed ? "-" : " ";
681
+ const color = change.added ? green : change.removed ? red : dim$2;
682
+ for (const line of splitLines(change.value)) lines.push(color(`${prefix} ${line}`));
683
+ }
684
+ return lines;
685
+ }
686
+ /** One or more `FileDiff`s, blank-line separated when there's more than one. */
687
+ function renderFileDiffs(diffs) {
688
+ return diffs.flatMap((fileDiff, index) => (index > 0 ? [""] : []).concat(renderFileDiff(fileDiff)));
689
+ }
690
+ /** Today's flat one-line fallback for a pending call, unchanged: no tool, no presenter, bad JSON, or a throwing/`undefined` presenter. */
691
+ function fallbackCallLine(name, rawArgs) {
692
+ return `${cyan("⚙")} ${name} ${dim$2(truncate(rawArgs, 100))}`;
693
+ }
694
+ /** Resolve a `tool/call`'s presented view, or `undefined` for any condition that keeps the flat fallback. */
695
+ function presentCallSafely(name, rawArgs, getTool) {
696
+ const tool = getTool?.(name);
697
+ if (tool?.presentCall === void 0) return void 0;
698
+ const parsed = parseJson(rawArgs);
699
+ if (!parsed.valid) return void 0;
700
+ try {
701
+ return tool.presentCall(parsed.value);
702
+ } catch {
703
+ return;
704
+ }
705
+ }
706
+ /** A presented pending call's lines: a cyan header (the presenter's title) plus card-specific body. */
707
+ function formatCallLines(view) {
708
+ const header = `${cyan("⚙")} ${view.title}`;
709
+ if (view.card === "terminal") {
710
+ const lines = [];
711
+ if (view.description !== void 0 && view.description !== "") lines.push(dim$2(view.description));
712
+ lines.push(header);
713
+ if (view.cwd !== void 0) lines.push(dim$2(view.cwd));
714
+ return lines;
715
+ }
716
+ if (view.card === "diff") return [header, ...renderFileDiffs(view.diffs)];
717
+ return [header, ...view.rawInput === void 0 ? [] : splitLines(pretty(view.rawInput)).map(dim$2)];
718
+ }
719
+ /**
720
+ * A presented call's one-line identity. A `TerminalCallView`'s `title` is
721
+ * deliberately just the bare command with no verb (unlike a `generic`/`diff`
722
+ * title, which a presenter writes to already read as one, e.g. "Read foo.ts")
723
+ * — so on its own it reads as arbitrary text with no hint it was a shell
724
+ * call. Label it with the tool's own name so the one-line summary still
725
+ * names both what ran and how — the command itself is the detail a reader
726
+ * wants here; its optional `description` stays a detail-view-only addition
727
+ * (`formatCallLines` already shows it above the command there).
728
+ */
729
+ function callSummaryTitle(name, view) {
730
+ if (view.card !== "terminal") return view.title;
731
+ return `${name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1)}: ${view.title}`;
732
+ }
733
+ /** A pending call's one-line title, presenting through the tool's `presentCall` when available — shared by the live region's spinner row. */
734
+ function pendingCallTitle(name, rawArgs, getTool) {
735
+ const view = presentCallSafely(name, rawArgs, getTool);
736
+ return view === void 0 ? `${name} ${truncate(rawArgs, 100)}` : callSummaryTitle(name, view);
737
+ }
738
+ /**
739
+ * Format every tool call that's been sent but has no `tool/result` yet, for
740
+ * the live region: one line per call, the shared spinner frame standing in
741
+ * for the settled ✓/✖ icon it'll collapse to once its result lands and it
742
+ * becomes a single transcript line (see `formatToolCardSummary`).
743
+ */
744
+ function formatPendingToolCalls(calls, spinnerChar, getTool) {
745
+ if (calls.length === 0) return "";
746
+ return `\n${calls.map((call) => `${cyan(spinnerChar)} ${pendingCallTitle(call.name, call.arguments, getTool)}`).join("\n")}\n`;
747
+ }
748
+ /** Resolve a `tool/result`'s presented view, or `undefined` for any condition that keeps the flat fallback. */
749
+ function presentResultSafely(callId, result, options) {
750
+ const call = options.getToolCall?.(callId);
751
+ if (call === void 0) return void 0;
752
+ const tool = options.getTool?.(call.name);
753
+ if (tool?.presentResult === void 0) return void 0;
754
+ const parsed = parseJson(call.arguments);
755
+ if (!parsed.valid) return void 0;
756
+ try {
757
+ const view = tool.presentResult(parsed.value, result);
758
+ return view === void 0 ? void 0 : {
759
+ name: call.name,
760
+ view
761
+ };
762
+ } catch {
763
+ return;
764
+ }
765
+ }
766
+ /** A `tool/result`'s paired `tool/call`'s presented one-line identity, when both the call and a presenter for it resolve — the "pending-state title" a result view's own optional `title` defers to when omitted (see `ToolResultView` docs in `@deepseek-ai/dsh-tools`). */
767
+ function resolveCallTitle(callId, options) {
768
+ const call = options.getToolCall?.(callId);
769
+ if (call === void 0) return void 0;
770
+ const view = presentCallSafely(call.name, call.arguments, options.getTool);
771
+ return view === void 0 ? void 0 : callSummaryTitle(call.name, view);
772
+ }
773
+ /** Shared by the transcript's compact line and the Tool Cards overlay's summary/detail, so all three read the same icon and presented view instead of re-deriving it. */
774
+ function resolveToolResult(event, options) {
775
+ if (event.data.error !== void 0) return {
776
+ kind: "error",
777
+ line: `${red("✖")} ${event.data.error.code}: ${event.data.error.name}`
778
+ };
779
+ const [block] = event.data.message.content;
780
+ const failed = block.isError === true;
781
+ const icon = failed ? red("✖") : cyan("✓");
782
+ const callId = event.data.message.source.callId;
783
+ const presented = presentResultSafely(callId, {
784
+ content: block.content,
785
+ isError: failed,
786
+ ...event.data.meta !== void 0 ? { meta: event.data.meta } : {}
787
+ }, options);
788
+ return {
789
+ kind: "ok",
790
+ icon,
791
+ content: block.content,
792
+ presented,
793
+ callTitle: resolveCallTitle(callId, options)
794
+ };
795
+ }
796
+ /** A presented completed call's lines: an outcome-colored header plus card-specific body. `callTitle` is the paired call's presented title — a result view's own `title` field defers to it (the "pending-state title") when omitted, so it comes before the flat fallback name. */
797
+ function formatResultLines(fallbackName, callTitle, icon, rawContent, view) {
798
+ const header = `${icon} ${view.title ?? callTitle ?? fallbackName}`;
799
+ switch (view.card) {
800
+ case "generic": return [header, ...splitLines(textOf(view.content ?? rawContent)).map(dim$2)];
801
+ case "terminal": {
802
+ const lines = [header];
803
+ if (view.output !== void 0 && view.output !== "") lines.push(...splitLines(view.output).map(dim$2));
804
+ if (view.exitCode !== void 0) lines.push(dim$2(`[exit ${view.exitCode}]`));
805
+ if (view.signal !== void 0) lines.push(red(`[signal ${view.signal}]`));
806
+ return lines;
807
+ }
808
+ case "diff": return [header, ...renderFileDiffs(view.diffs)];
809
+ case "search": {
810
+ const lines = [header];
811
+ let shown = 0;
812
+ if (view.shape === "matches") for (const file of view.files) {
813
+ lines.push(dim$2(file.path));
814
+ for (const match of file.matches) lines.push(dim$2(` ${match.lineNumber}: ${match.line}`));
815
+ shown += file.matches.length;
816
+ }
817
+ else {
818
+ for (const path of view.paths) lines.push(dim$2(path));
819
+ shown = view.paths.length;
820
+ }
821
+ if (view.truncated) lines.push(dim$2(`… showing ${shown} of ${view.total}`));
822
+ return lines;
823
+ }
824
+ case "read": {
825
+ const lines = [`${icon} ${view.title ?? callTitle ?? view.path}`];
826
+ for (const line of view.lines) lines.push(dim$2(`${line.number}: ${line.text}`));
827
+ if (view.totalLines > 0) {
828
+ const last = view.offset + view.lines.length - 1;
829
+ lines.push(dim$2(`[${view.offset}-${last} of ${view.totalLines}]`));
830
+ }
831
+ return lines;
832
+ }
833
+ case "web": {
834
+ const lines = [header];
835
+ if (view.kind === "search") {
836
+ for (const source of view.sources) lines.push(dim$2(`${source.title ?? source.url} — ${source.url}`));
837
+ if (view.answer !== void 0 && view.answer !== "") lines.push(...splitLines(view.answer).map(dim$2));
838
+ } else lines.push(dim$2(`${view.url} [${view.statusCode}]`));
839
+ if (view.truncated) lines.push(dim$2("… truncated"));
840
+ return lines;
841
+ }
842
+ }
843
+ }
844
+ /**
845
+ * One `goal/change` mutation's transcript line, mirroring the durable
846
+ * ledger's `operation`. An explicit `switch` with no `default` over the
847
+ * post-`clear` operation union (rather than a sequential `if` chain ending
848
+ * in an implicit "must be block") so a future `dsh-goal` operation the TUI
849
+ * doesn't know about fails to compile here instead of silently rendering
850
+ * the wrong line.
851
+ */
852
+ function goalChangeLine(change) {
853
+ if (change.operation === "clear") return `${dim$2("🗑")} goal cleared`;
854
+ const goal = change.goal;
855
+ switch (change.operation) {
856
+ case "create": return `${cyan("🎯")} goal set: ${goal.objective}`;
857
+ case "edit": return `${cyan("🎯")} goal updated: ${goal.objective}`;
858
+ case "pause": return `${yellow("⏸")} goal paused: ${goal.objective}`;
859
+ case "resume": return `${green("▶")} goal resumed: ${goal.objective}`;
860
+ case "complete": return `${green("✓")} goal complete: ${goal.objective}`;
861
+ case "block": return `${red("⛔")} goal blocked${goal.blockedReason === void 0 ? "" : `: ${goal.blockedReason.code}: ${goal.blockedReason.message}`}`;
862
+ }
863
+ }
864
+ /**
865
+ * Format one durable session event as a terminal line, or `undefined` for
866
+ * events this viewer does not present. Unknown event types are silently
867
+ * skipped: the log's vocabulary is merge-extensible and a transcript viewer
868
+ * must tolerate events from plugins it does not know.
869
+ * @param event - the durable session event to project.
870
+ * @param options - replay/live rendering context, plus optional tool-presentation resolvers.
871
+ */
872
+ function formatEvent(event, options) {
873
+ switch (event.type) {
874
+ case "user/message": {
875
+ const source = event.data.source;
876
+ if (source.kind === "user") {
877
+ const text = textOf(event.data.content);
878
+ return text === "" ? void 0 : `${dim$2("you ›")} ${text}`;
879
+ }
880
+ if (source.kind === "plugin") {
881
+ const summary = source.form === "notice" ? source.summary : void 0;
882
+ return `${dim$2("⊕ context ›")} ${source.plugin}${summary === void 0 ? "" : ` · ${summary}`}`;
883
+ }
884
+ if (source.kind === "goal") return `${dim$2("⊕ goal ›")} round ${source.round}`;
885
+ return `${dim$2("⊕ context ›")} ${source.kind}`;
886
+ }
887
+ case "assistant/message": {
888
+ const content = event.data.message.content;
889
+ return formatSettledMessage(textOf(content), reasoningOf(content));
890
+ }
891
+ case "tool/call": return;
892
+ case "tool/result": return formatToolCardSummary(event, options);
893
+ case "turn/end": {
894
+ const reason = event.data.reason;
895
+ if (reason.kind === "error") return `${red("✖")} ${reason.error.code}: ${reason.error.message}`;
896
+ else if (reason.kind === "aborted") return `${yellow("⏹")} ${dim$2("turn canceled")}`;
897
+ return;
898
+ }
899
+ case "compaction/summary": return `${cyan("⊙")} compacted ${event.data.shadowedSeqs.length} items (~${event.data.shadowedTokenCount} tokens)`;
900
+ case "compaction/end": return event.data.error === void 0 ? void 0 : `${red("✖")} compaction: ${event.data.error}`;
901
+ case "goal/change": return goalChangeLine(event.data);
902
+ default: return;
903
+ }
904
+ }
905
+ /**
906
+ * A `tool/call`/`tool/result` event's one-line summary — the Tool Cards
907
+ * overlay's collapsed row. Distinct from `formatEvent`'s own card rendering
908
+ * (which can be multi-line even at its most compact), because the overlay
909
+ * needs a genuine single line to toggle open from.
910
+ */
911
+ function formatToolCardSummary(event, options) {
912
+ if (event.type === "tool/call") {
913
+ const view = presentCallSafely(event.data.name, event.data.arguments, options.getTool);
914
+ return view === void 0 ? fallbackCallLine(event.data.name, event.data.arguments) : `${cyan("⚙")} ${callSummaryTitle(event.data.name, view)}`;
915
+ }
916
+ if (event.type === "tool/result") {
917
+ const resolved = resolveToolResult(event, options);
918
+ if (resolved.kind === "error") return resolved.line;
919
+ const { icon, content, presented, callTitle } = resolved;
920
+ if (presented === void 0) {
921
+ const text = truncate(textOf(content), 100);
922
+ return text === "" ? icon : `${icon} ${dim$2(text)}`;
923
+ }
924
+ return `${icon} ${presented.view.title ?? callTitle ?? presented.name}`;
925
+ }
926
+ return "";
927
+ }
928
+ /**
929
+ * Full, uncapped presentation lines for a `tool/call`/`tool/result` event.
930
+ * Unlike `formatEvent`, this never truncates or omits — the Tool Cards
931
+ * overlay scrolls its own window over the result instead of relying on a
932
+ * fixed line cap, so it needs the complete card body to scroll through.
933
+ */
934
+ function formatToolCardDetail(event, options) {
935
+ if (event.type === "tool/call") {
936
+ const view = presentCallSafely(event.data.name, event.data.arguments, options.getTool);
937
+ return view === void 0 ? [fallbackCallLine(event.data.name, event.data.arguments)] : formatCallLines(view);
938
+ }
939
+ if (event.type === "tool/result") {
940
+ const resolved = resolveToolResult(event, options);
941
+ if (resolved.kind === "error") return [resolved.line];
942
+ const { icon, content, presented, callTitle } = resolved;
943
+ if (presented === void 0) {
944
+ const text = textOf(content);
945
+ return text === "" ? [icon] : [icon, ...splitLines(text)];
946
+ }
947
+ return formatResultLines(presented.name, callTitle, icon, content, presented.view);
948
+ }
949
+ return [];
950
+ }
951
+ //#endregion
952
+ //#region src/tui/store.ts
953
+ const EMPTY_STATS = {
954
+ sessionStats: void 0,
955
+ tokenUsage: void 0,
956
+ contextPressure: void 0,
957
+ contextBreakdown: void 0
958
+ };
959
+ const EMPTY_FILE_INDEX = {
960
+ candidates: void 0,
961
+ loading: false
962
+ };
963
+ const CLOSED_OVERLAY = { kind: "none" };
964
+ /** Mutable projection; `getSnapshot`/`subscribe` satisfy `useSyncExternalStore`. */
965
+ var TuiStore = class {
966
+ state;
967
+ listeners = /* @__PURE__ */ new Set();
968
+ lastSeq;
969
+ streamingAssembler;
970
+ streamingKey;
971
+ toolCalls = /* @__PURE__ */ new Map();
972
+ pendingToolCallsMap = /* @__PURE__ */ new Map();
973
+ constructor(initial) {
974
+ const lastSeq = initial.events.at(-1)?.seq ?? 0;
975
+ this.lastSeq = lastSeq;
976
+ for (const event of initial.events) if (event.type === "tool/call") {
977
+ const call = {
978
+ name: event.data.name,
979
+ arguments: event.data.arguments
980
+ };
981
+ this.toolCalls.set(event.data.callId, call);
982
+ this.pendingToolCallsMap.set(event.data.callId, call);
983
+ } else if (event.type === "tool/result") this.pendingToolCallsMap.delete(event.data.message.source.callId);
984
+ this.state = {
985
+ events: initial.events.filter((event) => event.type !== "assistant/chunk"),
986
+ replayThrough: lastSeq,
987
+ status: "idle",
988
+ queued: [],
989
+ notice: void 0,
990
+ overlay: CLOSED_OVERLAY,
991
+ permission: void 0,
992
+ goal: void 0,
993
+ title: void 0,
994
+ stats: EMPTY_STATS,
995
+ preset: void 0,
996
+ streaming: void 0,
997
+ pendingToolCalls: this.pendingToolCallsSnapshot(),
998
+ shellRun: void 0,
999
+ shellHistory: [],
1000
+ fileIndex: EMPTY_FILE_INDEX,
1001
+ updateHint: void 0
1002
+ };
1003
+ }
1004
+ getSnapshot = () => this.state;
1005
+ /** The `tool/call` a later `tool/result` correlates with, by `callId`; `undefined` when its call was never seen (e.g. log truncation). */
1006
+ getToolCall = (callId) => this.toolCalls.get(callId);
1007
+ subscribe = (listener) => {
1008
+ this.listeners.add(listener);
1009
+ return () => this.listeners.delete(listener);
1010
+ };
1011
+ /** Append one live session event, ignoring anything already seeded/seen. */
1012
+ appendEvent(event) {
1013
+ if (event.seq <= this.lastSeq) return;
1014
+ this.lastSeq = event.seq;
1015
+ if (event.type === "tool/call") {
1016
+ const call = {
1017
+ name: event.data.name,
1018
+ arguments: event.data.arguments
1019
+ };
1020
+ this.toolCalls.set(event.data.callId, call);
1021
+ this.pendingToolCallsMap.set(event.data.callId, call);
1022
+ this.set({
1023
+ events: [...this.state.events, event],
1024
+ pendingToolCalls: this.pendingToolCallsSnapshot()
1025
+ });
1026
+ return;
1027
+ }
1028
+ if (event.type === "tool/result") {
1029
+ this.pendingToolCallsMap.delete(event.data.message.source.callId);
1030
+ this.set({
1031
+ events: [...this.state.events, event],
1032
+ pendingToolCalls: this.pendingToolCallsSnapshot()
1033
+ });
1034
+ return;
1035
+ }
1036
+ if (event.type === "assistant/chunk") {
1037
+ this.foldChunk(event.data);
1038
+ return;
1039
+ }
1040
+ if (event.type === "assistant/message") {
1041
+ this.streamingAssembler = void 0;
1042
+ this.streamingKey = void 0;
1043
+ this.set({
1044
+ events: [...this.state.events, event],
1045
+ streaming: void 0
1046
+ });
1047
+ return;
1048
+ }
1049
+ this.set({ events: [...this.state.events, event] });
1050
+ }
1051
+ /** Snapshot `pendingToolCallsMap` into `TuiState`'s array shape, in call order. */
1052
+ pendingToolCallsSnapshot() {
1053
+ return [...this.pendingToolCallsMap.entries()].map(([callId, call]) => ({
1054
+ callId,
1055
+ ...call
1056
+ }));
1057
+ }
1058
+ /** Fold one raw stream chunk into the in-flight step's live text, keyed by `{turn, step}`. */
1059
+ foldChunk(data) {
1060
+ const { turn, step, chunk } = data;
1061
+ if (this.streamingKey?.turn !== turn || this.streamingKey?.step !== step) {
1062
+ this.streamingAssembler = new BlockAssembler();
1063
+ this.streamingKey = {
1064
+ turn,
1065
+ step
1066
+ };
1067
+ }
1068
+ this.streamingAssembler.push(chunk);
1069
+ const blocks = this.streamingAssembler.blocks();
1070
+ const text = textOf(blocks);
1071
+ const reasoningText = reasoningOf(blocks);
1072
+ this.set({ streaming: text === "" && reasoningText === "" ? void 0 : {
1073
+ turn,
1074
+ step,
1075
+ text,
1076
+ reasoningText
1077
+ } });
1078
+ }
1079
+ setStatus(status) {
1080
+ if (status === this.state.status) return;
1081
+ this.set({ status });
1082
+ }
1083
+ setQueued(queued) {
1084
+ this.set({ queued });
1085
+ }
1086
+ setNotice(notice) {
1087
+ this.set({ notice });
1088
+ }
1089
+ setPermission(permission) {
1090
+ this.set({ permission });
1091
+ }
1092
+ /** Refresh the session's current goal from the 'goal' session projection; `undefined` when the projection unit isn't composed, `null` before the first create or after a clear. */
1093
+ setGoal(goal) {
1094
+ this.set({ goal });
1095
+ }
1096
+ /** Refresh the session's current title from the 'title' session projection; `undefined` when `dsh-session-title` isn't composed, `null` before the first accepted title. */
1097
+ setTitle(title) {
1098
+ this.set({ title });
1099
+ }
1100
+ setStats(stats) {
1101
+ this.set({ stats });
1102
+ }
1103
+ setPreset(preset) {
1104
+ this.set({ preset });
1105
+ }
1106
+ shellRunSeq = 0;
1107
+ /** Begin one local shell-escape run; its output accumulates via `appendShellOutput` until `finishShellRun` settles it into the transcript. */
1108
+ startShellRun(command) {
1109
+ const id = ++this.shellRunSeq;
1110
+ this.set({ shellRun: {
1111
+ id,
1112
+ command,
1113
+ output: ""
1114
+ } });
1115
+ return id;
1116
+ }
1117
+ /** Append one chunk of stdout/stderr to the in-flight run; a no-op once it's settled or superseded by a later run. */
1118
+ appendShellOutput(id, chunk) {
1119
+ if (this.state.shellRun?.id !== id) return;
1120
+ this.set({ shellRun: {
1121
+ ...this.state.shellRun,
1122
+ output: this.state.shellRun.output + chunk
1123
+ } });
1124
+ }
1125
+ /** Settle the in-flight run into the permanent transcript; a no-op once it's already settled or superseded. */
1126
+ finishShellRun(id, exitCode) {
1127
+ if (this.state.shellRun?.id !== id) return;
1128
+ const { command, output } = this.state.shellRun;
1129
+ this.set({
1130
+ shellRun: void 0,
1131
+ shellHistory: [...this.state.shellHistory, {
1132
+ id,
1133
+ command,
1134
+ output,
1135
+ exitCode,
1136
+ afterSeq: this.lastSeq
1137
+ }]
1138
+ });
1139
+ }
1140
+ /** Open the `/model` overlay to a fresh, loading provider list. */
1141
+ openModelProfile() {
1142
+ this.set({ overlay: {
1143
+ kind: "modelProfile",
1144
+ modelProfile: {
1145
+ view: "list",
1146
+ providers: void 0,
1147
+ selected: 0,
1148
+ draft: void 0,
1149
+ formKey: 0,
1150
+ discovered: void 0,
1151
+ busy: true,
1152
+ error: void 0
1153
+ }
1154
+ } });
1155
+ }
1156
+ /** Open the `/trajectory` ledger overlay. */
1157
+ openTrajectory() {
1158
+ this.set({ overlay: { kind: "trajectory" } });
1159
+ }
1160
+ /** Open the expandable Tool Cards inspector. */
1161
+ openToolCards() {
1162
+ this.set({ overlay: { kind: "toolCards" } });
1163
+ }
1164
+ /** Open the `/context` usage overlay. */
1165
+ openContext() {
1166
+ this.set({ overlay: { kind: "context" } });
1167
+ }
1168
+ /** Open the `/plugins` loaded-plugin-tree overlay with a snapshotted row list. */
1169
+ openPlugins(rows) {
1170
+ this.set({ overlay: {
1171
+ kind: "plugins",
1172
+ rows
1173
+ } });
1174
+ }
1175
+ /** Open the `/presets` overlay to a fresh, loading roster. */
1176
+ openAgentPresets(init) {
1177
+ this.set({ overlay: {
1178
+ kind: "agentPresets",
1179
+ agentPresets: {
1180
+ rows: [],
1181
+ selected: 0,
1182
+ current: init.current,
1183
+ blank: init.blank,
1184
+ busy: true,
1185
+ error: void 0
1186
+ }
1187
+ } });
1188
+ }
1189
+ /** Present one pending tool-approval decision, taking over the live region. */
1190
+ openApproval(approval) {
1191
+ this.set({ overlay: {
1192
+ kind: "approval",
1193
+ approval
1194
+ } });
1195
+ }
1196
+ /** Present one pending question, taking over the live region. */
1197
+ openUserQuestion(userQuestion) {
1198
+ this.set({ overlay: {
1199
+ kind: "userQuestion",
1200
+ userQuestion
1201
+ } });
1202
+ }
1203
+ /** Close whichever overlay is open, restoring the normal prompt/status controls. */
1204
+ closeOverlay() {
1205
+ this.set({ overlay: CLOSED_OVERLAY });
1206
+ }
1207
+ /** Patch the open `/model` overlay's sub-state; a no-op once it's closed. */
1208
+ updateModelProfile(patch) {
1209
+ if (this.state.overlay.kind !== "modelProfile") return;
1210
+ this.set({ overlay: {
1211
+ kind: "modelProfile",
1212
+ modelProfile: {
1213
+ ...this.state.overlay.modelProfile,
1214
+ ...patch
1215
+ }
1216
+ } });
1217
+ }
1218
+ /** Patch the open `/presets` overlay's sub-state; a no-op once it's closed. */
1219
+ updateAgentPresets(patch) {
1220
+ if (this.state.overlay.kind !== "agentPresets") return;
1221
+ this.set({ overlay: {
1222
+ kind: "agentPresets",
1223
+ agentPresets: {
1224
+ ...this.state.overlay.agentPresets,
1225
+ ...patch
1226
+ }
1227
+ } });
1228
+ }
1229
+ /** Move the `/presets` overlay's list cursor. */
1230
+ selectAgentPresetRow(index) {
1231
+ this.updateAgentPresets({ selected: index });
1232
+ }
1233
+ /** Mark the `@`-mention file index as loading; a no-op once candidates are already present. */
1234
+ setFileIndexLoading() {
1235
+ if (this.state.fileIndex.candidates !== void 0) return;
1236
+ this.set({ fileIndex: {
1237
+ candidates: void 0,
1238
+ loading: true
1239
+ } });
1240
+ }
1241
+ /** Settle the `@`-mention file index once `loadFileIndex` resolves. */
1242
+ setFileIndex(candidates) {
1243
+ this.set({ fileIndex: {
1244
+ candidates,
1245
+ loading: false
1246
+ } });
1247
+ }
1248
+ /** Record a newer npm-published version found by the startup update check; persists for the session (not cleared by `/clear`'s notice reset) until dismissed by a fresh check finding none. */
1249
+ setUpdateHint(version) {
1250
+ this.set({ updateHint: version });
1251
+ }
1252
+ set(partial) {
1253
+ this.state = {
1254
+ ...this.state,
1255
+ ...partial
1256
+ };
1257
+ for (const listener of this.listeners) listener();
1258
+ }
1259
+ };
1260
+ //#endregion
1261
+ //#region src/tui/logoArt.generated.ts
1262
+ /**
1263
+ * Generated by `node scripts/generate-logo.mjs` from
1264
+ * `assets/deepseek-128px.png` via chafa (half-block, 16x16, rgb color
1265
+ * space). Do not hand-edit — re-run the script after the source image
1266
+ * changes; requires `chafa` installed locally (e.g. `brew install chafa`).
1267
+ * @module @tomowang/dsh-tui/tui/logoArt.generated
1268
+ */
1269
+ /** One entry per output row; each line carries embedded 24-bit ANSI SGR color codes. */
1270
+ const LOGO_HALF_BLOCK = [
1271
+ "\x1B[0m \x1B[0m",
1272
+ " \x1B[38;2;60;84;201m▄\x1B[38;2;76;106;253m▄▄▄▄\x1B[38;2;75;105;251;48;2;49;69;164m▄\x1B[0m \x1B[38;2;75;105;251m▄\x1B[38;2;44;61;147m▄\x1B[0m \x1B[38;2;58;81;193m▄\x1B[0m",
1273
+ "\x1B[38;2;46;64;153m▄\x1B[38;2;76;106;253;48;2;69;96;231m▄\x1B[48;2;76;106;253m▄▄▄▄▄▄\x1B[48;2;69;96;231m▄\x1B[0m\x1B[38;2;71;99;238m▄\x1B[0m \x1B[7m\x1B[38;2;73;103;246m▄\x1B[0m\x1B[38;2;76;106;253;48;2;76;106;253m▄\x1B[38;2;75;105;252;48;2;73;102;245m▄\x1B[38;2;55;77;185;48;2;76;106;253m▄\x1B[0m\x1B[7m\x1B[38;2;52;73;175m▄\x1B[0m",
1274
+ "\x1B[38;2;67;93;224;48;2;76;106;253m▌\x1B[0m \x1B[7m\x1B[38;2;39;55;132m▄\x1B[38;2;69;96;231m▄\x1B[0m\x1B[38;2;48;67;160;48;2;76;106;253m▄\x1B[38;2;76;106;253m▄▄\x1B[38;2;69;96;230;48;2;48;67;161m▌\x1B[0m\x1B[7m\x1B[38;2;68;95;228m▄\x1B[0m\x1B[38;2;67;94;224;48;2;73;102;245m▄\x1B[38;2;76;106;253;48;2;48;67;160m▄\x1B[48;2;76;106;253m▄\x1B[0m\x1B[38;2;53;74;177m▌\x1B[0m \x1B[0m",
1275
+ "\x1B[38;2;40;57;136;48;2;75;105;252m▌\x1B[0m\x1B[38;2;69;96;230m▌\x1B[0m \x1B[7m\x1B[38;2;45;63;151m▄\x1B[0m\x1B[38;2;61;86;206;48;2;76;106;253m▄\x1B[38;2;76;106;253m▄\x1B[0m\x1B[38;2;76;106;253m▄\x1B[48;2;41;58;139m▄\x1B[48;2;76;106;253m▄\x1B[38;2;73;103;246;48;2;44;62;148m▌\x1B[0m \x1B[0m",
1276
+ " \x1B[7m\x1B[38;2;75;104;250m▄\x1B[0m\x1B[38;2;76;106;253;48;2;40;56;135m▄\x1B[0m\x1B[38;2;43;61;146m▄\x1B[0m \x1B[7m\x1B[38;2;50;70;167m▌\x1B[0m\x1B[38;2;64;90;215m▄\x1B[0m \x1B[7m\x1B[38;2;73;102;244m▄\x1B[0m\x1B[38;2;74;103;247;48;2;76;106;253m▄\x1B[38;2;76;106;253m▄\x1B[38;2;68;96;229;48;2;39;55;132m▌\x1B[0m \x1B[0m",
1277
+ " \x1B[7m\x1B[38;2;73;102;245m▄\x1B[38;2;74;103;248m▄\x1B[0m\x1B[38;2;53;74;178;48;2;75;104;250m▄\x1B[38;2;57;79;189;48;2;76;106;253m▄\x1B[38;2;50;71;169;48;2;74;103;247m▄\x1B[0m\x1B[7m\x1B[38;2;50;71;169m▄\x1B[38;2;46;64;154m▄\x1B[38;2;52;72;173m▄\x1B[38;2;58;82;196m▄\x1B[0m \x1B[0m",
1278
+ " \x1B[0m"
1279
+ ];
1280
+ //#endregion
1281
+ //#region src/tui/bannerText.ts
1282
+ /**
1283
+ * Pure text builder for the startup banner: a bordered box with the brand
1284
+ * logo on the left and session info on the right. Kept dependency-free (no
1285
+ * Ink/React) so it's trivial to reason about and test in isolation,
1286
+ * mirroring `render.ts`'s hand-rolled ANSI-aware formatting.
1287
+ * @module @tomowang/dsh-tui/tui/bannerText
1288
+ */
1289
+ const ESC = "\x1B[";
1290
+ const bold$10 = (s) => `${ESC}1m${s}${ESC}0m`;
1291
+ const dim$1 = fg(theme.muted);
1292
+ const primary = fg(theme.primary);
1293
+ const ANSI_RE = /\x1b\[[0-9;?]*[a-zA-Z]/g;
1294
+ const visibleWidth$1 = (s) => s.replace(ANSI_RE, "").length;
1295
+ const padVisible = (s, width) => s + " ".repeat(Math.max(0, width - visibleWidth$1(s)));
1296
+ const LOGO_WIDTH = 16;
1297
+ const LOGO_MARGIN = 2;
1298
+ const LEFT_WIDTH = 20;
1299
+ const MIN_WIDTH = 56;
1300
+ const MAX_WIDTH = 96;
1301
+ function clamp(value, min, max) {
1302
+ return Math.min(max, Math.max(min, value));
1303
+ }
1304
+ /** Pads `rows` with blank lines, split evenly top/bottom, to reach `height`. */
1305
+ function centerRows(rows, height) {
1306
+ const pad = Math.max(0, height - rows.length);
1307
+ const top = Math.floor(pad / 2);
1308
+ const bottom = pad - top;
1309
+ return [
1310
+ ...Array(top).fill(""),
1311
+ ...rows,
1312
+ ...Array(bottom).fill("")
1313
+ ];
1314
+ }
1315
+ function topBorder(title, total) {
1316
+ const inner = total - 2;
1317
+ const label = ` ${title} `;
1318
+ const leftDashes = 3;
1319
+ const rightDashes = Math.max(1, inner - leftDashes - label.length);
1320
+ return `╭${"─".repeat(leftDashes)}${primary(label)}${"─".repeat(rightDashes)}╮`;
1321
+ }
1322
+ function buildLeftColumn() {
1323
+ const margin = " ".repeat(LOGO_MARGIN);
1324
+ return LOGO_HALF_BLOCK.map((line) => margin + padVisible(line, LOGO_WIDTH) + margin);
1325
+ }
1326
+ function buildRightColumn(content, width) {
1327
+ return [
1328
+ bold$10(primary("DeepSeek Harness")),
1329
+ "",
1330
+ dim$1(`${content.provider}/${content.model}`),
1331
+ dim$1(truncate(content.cwd, width))
1332
+ ];
1333
+ }
1334
+ /**
1335
+ * Builds the full multi-line banner text, sized to the given terminal
1336
+ * width: title bar, then a fixed-width logo column beside a session-info
1337
+ * column (vertically centered against each other), then the bottom border.
1338
+ */
1339
+ function buildBannerText(content, columns) {
1340
+ const total = clamp(columns, MIN_WIDTH, MAX_WIDTH);
1341
+ const inner = total - 2;
1342
+ const top = topBorder(`dsh-tui v${content.version}`, total);
1343
+ const bottom = `╰${"─".repeat(inner)}╯`;
1344
+ const rightWidth = inner - LEFT_WIDTH - 1;
1345
+ const left = buildLeftColumn();
1346
+ const right = buildRightColumn(content, rightWidth);
1347
+ const height = Math.max(left.length, right.length);
1348
+ const leftRows = centerRows(left, height);
1349
+ const rightRows = centerRows(right, height);
1350
+ return [
1351
+ top,
1352
+ ...leftRows.map((line, i) => `│${padVisible(line, LEFT_WIDTH)}│${padVisible(rightRows[i] ?? "", rightWidth)}│`),
1353
+ bottom
1354
+ ].join("\n");
1355
+ }
1356
+ //#endregion
1357
+ //#region src/tui/statsFormat.ts
1358
+ /**
1359
+ * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
1360
+ * @param n - token count.
1361
+ * @returns display string.
1362
+ */
1363
+ function formatTokens(n) {
1364
+ const scaled = (v) => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10);
1365
+ if (n < 1e3) return String(n);
1366
+ if (n < 1e6) return `${scaled(n / 1e3)}K`;
1367
+ return `${scaled(n / 1e6)}M`;
1368
+ }
1369
+ /**
1370
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
1371
+ * @param ms - duration in milliseconds.
1372
+ * @returns display string.
1373
+ */
1374
+ function formatDuration(ms) {
1375
+ const s = ms / 1e3;
1376
+ if (s < 60) return `${Math.round(s * 10) / 10}s`;
1377
+ const whole = Math.round(s);
1378
+ return `${Math.floor(whole / 60)}m${whole % 60}s`;
1379
+ }
1380
+ /**
1381
+ * Compact throughput: one decimal under 10 tok/s, whole above.
1382
+ * @param tps - tokens per second.
1383
+ * @returns display string.
1384
+ */
1385
+ function formatTokensPerSecond(tps) {
1386
+ const clamped = Math.max(0, tps);
1387
+ return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
1388
+ }
1389
+ /**
1390
+ * Sum the three disjoint prompt-side billing buckets.
1391
+ * @param usage - the session's token-usage projection value.
1392
+ * @returns billed input tokens.
1393
+ */
1394
+ function billedInputTokens(usage) {
1395
+ return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
1396
+ }
1397
+ /**
1398
+ * Cache-hit share of prompt-side input over the whole durable log.
1399
+ * @param usage - the session's token-usage projection value.
1400
+ * @returns rounded integer percent, or null when no input was billed.
1401
+ */
1402
+ function cacheHitPercent(usage) {
1403
+ const denominator = billedInputTokens(usage);
1404
+ return denominator === 0 ? null : Math.round(usage.cacheReadTokens / denominator * 100);
1405
+ }
1406
+ /**
1407
+ * Build the pipe-separated stats line for the status bar, e.g.
1408
+ * `1 turns · 1 steps| LLM 4.3s| TTFT avg 1.1s · 131 tok/s| Cache hit 80%| Input 9.1K tok · Output 412 tok`.
1409
+ * A group with no data drops out whole; an empty return means nothing to show yet.
1410
+ * @param stats - whole-log turn/step counts and wall times, or `undefined` without the projection unit mounted.
1411
+ * @param usage - whole-log provider token usage, or `undefined` without the projection unit mounted.
1412
+ * @returns the joined line, or `''` when there is nothing to display.
1413
+ */
1414
+ function buildStatsLine(stats, usage) {
1415
+ const groups = [];
1416
+ if (stats !== void 0 && stats.steps > 0) {
1417
+ groups.push(`${stats.turns} turns · ${stats.steps} steps`);
1418
+ const durations = [];
1419
+ if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`);
1420
+ if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`);
1421
+ if (durations.length > 0) groups.push(durations.join(" · "));
1422
+ const speeds = [];
1423
+ if (stats.ttftSteps > 0) speeds.push(`TTFT avg ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
1424
+ if (stats.decodeMs > 0) speeds.push(`${formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`);
1425
+ if (speeds.length > 0) groups.push(speeds.join(" · "));
1426
+ }
1427
+ if (usage !== void 0 && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
1428
+ const cacheHit = cacheHitPercent(usage);
1429
+ if (cacheHit !== null) groups.push(`Cache hit ${cacheHit}%`);
1430
+ groups.push(`Input ${formatTokens(billedInputTokens(usage))} tok · Output ${formatTokens(usage.outputTokens)} tok`);
1431
+ }
1432
+ return groups.join("| ");
1433
+ }
1434
+ /**
1435
+ * Derive occupancy from the newest pressure sample, or `null` while either
1436
+ * side (a usage sample, a known route capacity) hasn't arrived yet.
1437
+ * @param pressure - the session's context-pressure projection value.
1438
+ * @returns occupancy figures, or `null` when there is nothing to show yet.
1439
+ */
1440
+ function contextOccupancy(pressure) {
1441
+ const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens;
1442
+ if (usedTokens === void 0 || pressure?.contextWindow === void 0) return null;
1443
+ return {
1444
+ percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
1445
+ usedTokens,
1446
+ contextWindow: pressure.contextWindow
1447
+ };
1448
+ }
1449
+ /**
1450
+ * Build the always-on compact context-usage line, e.g. `Context 1% · ~8.1K / 1M tok`.
1451
+ * @param pressure - the session's context-pressure projection value.
1452
+ * @returns the display line, or `''` when there is nothing to show yet.
1453
+ */
1454
+ function buildContextLine(pressure) {
1455
+ const occupancy = contextOccupancy(pressure);
1456
+ if (occupancy === null) return "";
1457
+ return `Context ${occupancy.percent}% · ~${formatTokens(occupancy.usedTokens)} / ${formatTokens(occupancy.contextWindow)} tok`;
1458
+ }
1459
+ /**
1460
+ * Proportional breakdown rows for the `/context` overlay's bar. The three
1461
+ * heuristic figures are composition only — they do not sum to
1462
+ * `occupancy.usedTokens` — so segment widths are scaled to `occupancy.percent`
1463
+ * rather than treated as an independent total; see `ContextBreakdownProjection`'s doc comment.
1464
+ * @param occupancy - this session's occupancy figures, or `null` without a usage sample yet.
1465
+ * @param breakdown - the session's context-breakdown projection value.
1466
+ * @returns the three rows in System/Tools/Messages order, or `[]` when there is nothing to show yet.
1467
+ */
1468
+ function contextBreakdownRows(occupancy, breakdown) {
1469
+ if (occupancy === null || breakdown === void 0) return [];
1470
+ const total = breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens;
1471
+ if (total === 0) return [];
1472
+ const scale = (tokens) => occupancy.percent * tokens / total;
1473
+ return [
1474
+ {
1475
+ label: "System prompt",
1476
+ tokens: breakdown.systemTokens,
1477
+ width: scale(breakdown.systemTokens)
1478
+ },
1479
+ {
1480
+ label: "Tools",
1481
+ tokens: breakdown.toolsTokens,
1482
+ width: scale(breakdown.toolsTokens)
1483
+ },
1484
+ {
1485
+ label: "Messages",
1486
+ tokens: breakdown.messageTokens,
1487
+ width: scale(breakdown.messageTokens)
1488
+ }
1489
+ ];
1490
+ }
1491
+ //#endregion
1492
+ //#region src/sessionId.ts
1493
+ /**
1494
+ * Session id prefix helpers shared between id generation/resolution
1495
+ * (`src/index.ts`) and display (`src/tui/StatusBar.tsx`). `session-` is the
1496
+ * de facto convention for top-level interactive sessions across the harness
1497
+ * (web portal, headless bundle), so ids keep the prefix on disk; only
1498
+ * user-facing text strips it.
1499
+ * @module @tomowang/dsh-tui/sessionId
1500
+ */
1501
+ const SESSION_ID_PREFIX = "session-";
1502
+ /** Strip the `session-` prefix for display, if present. */
1503
+ function stripSessionIdPrefix(id) {
1504
+ return id.startsWith(SESSION_ID_PREFIX) ? id.slice(8) : id;
1505
+ }
1506
+ //#endregion
1507
+ //#region src/tui/liveText.ts
1508
+ const dim = fg(theme.muted);
1509
+ const accent = fg(theme.accent);
1510
+ const warning$1 = fg(theme.warning);
1511
+ function buildStatusBarText(params) {
1512
+ const { sessionId, provider, model, status, queuedCount, presetLabel, eventCount, spinnerChar } = params;
1513
+ const queuedSuffix = queuedCount > 0 ? ` · ${queuedCount} queued` : "";
1514
+ const presetSegment = presetLabel === void 0 ? "" : ` · ${presetLabel}`;
1515
+ const spinnerPart = status === "running" ? spinnerChar : "";
1516
+ return dim(`session ${stripSessionIdPrefix(sessionId)} · `) + accent(`${provider}/${model}`) + dim(`${presetSegment} · ${spinnerPart} ${status}${queuedSuffix} · ${eventCount} events`);
1517
+ }
1518
+ function previewOf(message) {
1519
+ return truncate(message.content.filter((block) => block.type === "text").map((block) => block.text).join(""), 80);
1520
+ }
1521
+ function buildQueuedText(queued) {
1522
+ if (queued.length === 0) return "";
1523
+ return queued.map((message) => dim(`↳ queued: ${previewOf(message)}`)).join("\n");
1524
+ }
1525
+ const PERMISSION_LABELS = {
1526
+ "read-only": "Read Only",
1527
+ "workspace-write": "Workspace Write",
1528
+ "danger-full-access": "Full Access",
1529
+ custom: "Custom"
1530
+ };
1531
+ const PERMISSION_ICONS = {
1532
+ "read-only": "⊘",
1533
+ "workspace-write": "✎",
1534
+ "danger-full-access": "‼",
1535
+ custom: "⊛"
1536
+ };
1537
+ const PERMISSION_COLORS = {
1538
+ "read-only": theme.info,
1539
+ "workspace-write": theme.success,
1540
+ "danger-full-access": theme.error,
1541
+ custom: theme.muted
1542
+ };
1543
+ /**
1544
+ * Persistent low-key dock row nudging the reader to upgrade once the
1545
+ * startup registry check (`src/updateCheck.ts`) finds a newer published
1546
+ * version; renders nothing while unchecked or already current. Unlike
1547
+ * `notice`, this isn't cleared on the next input — it's meant to stay
1548
+ * visible for the rest of the session, mirroring how `gh`/`npm` surface an
1549
+ * available-update line.
1550
+ */
1551
+ function buildUpdateHintText(currentVersion, latestVersion) {
1552
+ if (latestVersion === void 0) return "";
1553
+ return warning$1(`⬆ dsh-tui update available: v${currentVersion} → v${latestVersion}`) + dim(" (run `dsh plugin --profile tui add @tomowang/dsh-tui` to upgrade)");
1554
+ }
1555
+ function buildPermissionText(permission) {
1556
+ if (permission === void 0) return "";
1557
+ const icon = PERMISSION_ICONS[permission.current] ?? "•";
1558
+ const label = PERMISSION_LABELS[permission.current] ?? permission.current;
1559
+ return `${fg(PERMISSION_COLORS[permission.current] ?? theme.muted)(`${icon} ${label}`)}${dim(" (shift+tab to cycle)")}`;
1560
+ }
1561
+ /** Human label for one durable goal phase — the single source of truth shared by the `/goal` notice (`index.ts`) and this strip. */
1562
+ function goalPhaseLabel(phase) {
1563
+ switch (phase) {
1564
+ case "active": return "active";
1565
+ case "paused": return "paused";
1566
+ case "blocked": return "blocked";
1567
+ case "complete": return "complete";
1568
+ }
1569
+ }
1570
+ /** Phase color for the goal glyph + label; active reads green, paused amber, blocked coral. */
1571
+ const GOAL_PHASE_COLORS = {
1572
+ active: theme.success,
1573
+ paused: theme.warning,
1574
+ blocked: theme.error
1575
+ };
1576
+ /** Long-objective cap for the goal strip, matching the queued-preview cap. */
1577
+ const GOAL_OBJECTIVE_LIMIT = 80;
1578
+ /**
1579
+ * The goal strip docked above the composer — the terminal GoalBar. Mirrors
1580
+ * the web portal's rendering rule exactly: loading (`undefined` — projection
1581
+ * unit not composed), absent/cleared (`null`), and complete goals render
1582
+ * nothing; a present goal shows a goal glyph, its phase label, and the
1583
+ * truncated objective, with the blocker explanation appended for a blocked
1584
+ * goal (the portal shows it as a hover tooltip, which a terminal cannot).
1585
+ * Mutations live on the `/goal` command, not on the strip.
1586
+ */
1587
+ /**
1588
+ * Terminal window/tab title: `<session title> — dsh-tui` once the optional
1589
+ * `dsh-session-title` service has accepted one for this session, or just
1590
+ * `dsh-tui` before that (loading) or without the service composed —
1591
+ * mirroring the harness's own `<session title> — <configured title>` OSC 0
1592
+ * convention. Plain text, never ANSI-colored: an OSC 0 title string is
1593
+ * displayed verbatim by the terminal chrome, not interpreted as SGR.
1594
+ */
1595
+ function buildTerminalTitle(title) {
1596
+ return title === null || title === void 0 ? "dsh-tui" : `${title} — dsh-tui`;
1597
+ }
1598
+ function buildGoalBarText(goal) {
1599
+ if (goal === void 0 || goal === null || goal.goal.phase === "complete") return "";
1600
+ const snapshot = goal.goal;
1601
+ const color = fg(GOAL_PHASE_COLORS[snapshot.phase] ?? theme.muted);
1602
+ const label = goalPhaseLabel(snapshot.phase);
1603
+ const objective = truncate(snapshot.objective, GOAL_OBJECTIVE_LIMIT);
1604
+ const blocker = snapshot.phase === "blocked" && snapshot.blockedReason !== void 0 ? dim(` · ${snapshot.blockedReason.code}: ${truncate(snapshot.blockedReason.message, GOAL_OBJECTIVE_LIMIT)}`) : "";
1605
+ return `${color(`🎯 ${label}`)} · ${objective}${blocker}`;
1606
+ }
1607
+ //#endregion
1608
+ //#region src/tui/text.ts
1609
+ /**
1610
+ * Thin pi-tui `Component` wrappers around already-ANSI-styled strings.
1611
+ * `render.ts`/`markdown.ts`/`bannerText.ts` produce terminal-ready text
1612
+ * (colors, bold, links baked in via raw SGR/OSC sequences) — these wrappers
1613
+ * exist only to satisfy pi-tui's `Component` interface (`render(width)`,
1614
+ * `invalidate()`) without re-styling that text, mirroring how the old Ink
1615
+ * `<Text>` usage printed these strings unmodified.
1616
+ * @module @tomowang/dsh-tui/tui/text
1617
+ */
1618
+ /** Left/right margin applied to main-panel message content, so it doesn't sit flush against either terminal edge. */
1619
+ const TRANSCRIPT_MARGIN = 2;
1620
+ const TRANSCRIPT_INDENT = " ".repeat(TRANSCRIPT_MARGIN);
1621
+ /** Word-wraps already-ANSI-styled text to fit within `width` minus the transcript's left/right margin, then indents every resulting line. Used by the live-region rows (streaming text, pending tool calls, live shell output), which rebuild their string from the store on every render — see `createTranscriptLine` for the settled, append-only transcript rows, which get the same margin from pi-tui's own `Text` instead so repeated renders can be cached. */
1622
+ function padTranscriptText(text, width) {
1623
+ if (text === "") return [];
1624
+ return wrapTextWithAnsi(text, Math.max(1, width - TRANSCRIPT_MARGIN * 2)).map((line) => `${TRANSCRIPT_INDENT}${line}`);
1625
+ }
1626
+ /** A settled transcript line: pi-tui's `Text` component wraps to width and applies the same left/right margin as `padTranscriptText`, but — unlike our own `Component`s here — caches its wrapped output keyed on `(text, width)`, so appended transcript history isn't re-wrapped on every unrelated store update (e.g. a streaming token delta) the way a hand-rolled render() would. Content is fixed at construction — transcript rows are append-only and never mutated after being added. */
1627
+ function createTranscriptLine(text) {
1628
+ return new Text(text, TRANSCRIPT_MARGIN, 0);
1629
+ }
1630
+ /** A block of pre-styled text rebuilt from the current viewport width on every render — for content (the banner) whose own layout is width-responsive. */
1631
+ var DynamicText = class {
1632
+ build;
1633
+ constructor(build) {
1634
+ this.build = build;
1635
+ }
1636
+ invalidate() {}
1637
+ render(width) {
1638
+ const text = this.build(width);
1639
+ return text === "" ? [] : text.split("\n");
1640
+ }
1641
+ };
1642
+ //#endregion
1643
+ //#region src/tui/commands.ts
1644
+ const SLASH_COMMANDS = [
1645
+ {
1646
+ command: "/help",
1647
+ description: "Show help and available commands"
1648
+ },
1649
+ {
1650
+ command: "/model",
1651
+ description: "Manage LLM provider profiles"
1652
+ },
1653
+ {
1654
+ command: "/trajectory",
1655
+ description: "Browse the turn/step event ledger"
1656
+ },
1657
+ {
1658
+ command: "/tools",
1659
+ description: "Browse and expand tool cards"
1660
+ },
1661
+ {
1662
+ command: "/context",
1663
+ description: "Show context window usage"
1664
+ },
1665
+ {
1666
+ command: "/plugins",
1667
+ description: "Show the loaded plugin tree"
1668
+ },
1669
+ {
1670
+ command: "/presets",
1671
+ description: "Show and switch agent presets (only while the session is blank)"
1672
+ },
1673
+ {
1674
+ command: "/goal",
1675
+ description: "Set or view the long-running goal: /goal <objective> | clear | edit <objective> | pause | resume"
1676
+ },
1677
+ {
1678
+ command: "/plan",
1679
+ description: "Enter plan mode, optionally with a message; /plan off to leave"
1680
+ },
1681
+ {
1682
+ command: "/compact",
1683
+ description: "Summarize and compact session history"
1684
+ },
1685
+ {
1686
+ command: "/clear",
1687
+ description: "Clear the screen and start a new session"
1688
+ },
1689
+ {
1690
+ command: "/exit",
1691
+ description: "Exit dsh-tui"
1692
+ },
1693
+ {
1694
+ command: "/quit",
1695
+ description: "Exit dsh-tui"
1696
+ }
1697
+ ];
1698
+ Math.max(...SLASH_COMMANDS.map((c) => c.command.length));
1699
+ function matchSlashCommands(query) {
1700
+ return SLASH_COMMANDS.filter((c) => c.command.startsWith(query));
1701
+ }
1702
+ function commandQuery(value) {
1703
+ const query = value.trim();
1704
+ const isCommandMode = value.startsWith("/") && !/\s/.test(query);
1705
+ return {
1706
+ isCommandMode,
1707
+ matches: isCommandMode ? matchSlashCommands(query) : []
1708
+ };
1709
+ }
1710
+ /** `/plan` on its own, or followed by whitespace — matches the harness's own `/plan [message]`/`/plan off` syntax. */
1711
+ const PLAN_COMMAND = /^\/plan(?:$|\s)/u;
1712
+ /**
1713
+ * `/plan`'s argument takes free text (a message, or the literal `off`), so unlike every other
1714
+ * command it can't route through {@link matchSlashCommands}'s whitespace-free matching.
1715
+ * @param text - Raw submitted line.
1716
+ * @returns The trimmed argument text, or `undefined` when `text` isn't a `/plan` invocation.
1717
+ */
1718
+ function parsePlanCommand(text) {
1719
+ const trimmed = text.trim();
1720
+ if (!PLAN_COMMAND.test(trimmed)) return void 0;
1721
+ return trimmed.slice(5).trim();
1722
+ }
1723
+ /** `/goal` on its own, or followed by whitespace — its objective is free text, so it shares `/plan`'s parse-ahead shape. */
1724
+ const GOAL_COMMAND = /^\/goal(?:$|\s)/u;
1725
+ /**
1726
+ * Parse a `/goal` invocation exactly the way `@deepseek-ai/dsh-command-goal`'s own
1727
+ * `parseGoalCommand` does — bare `/goal` shows the current goal, the control words
1728
+ * `clear`/`pause`/`resume` (case-insensitive) mutate it, `edit <objective>` replaces
1729
+ * the objective (bare `edit` is an error), and any other text is a create objective.
1730
+ * @param text - Raw submitted line.
1731
+ * @returns The parsed command, or `undefined` when `text` isn't a `/goal` invocation.
1732
+ */
1733
+ function parseGoalCommand(text) {
1734
+ const trimmed = text.trim();
1735
+ if (!GOAL_COMMAND.test(trimmed)) return void 0;
1736
+ const input = trimmed.slice(5).trim();
1737
+ if (input.length === 0) return { kind: "show" };
1738
+ const control = input.toLowerCase();
1739
+ if (control === "clear") return { kind: "clear" };
1740
+ if (control === "pause") return { kind: "pause" };
1741
+ if (control === "resume") return { kind: "resume" };
1742
+ if (control === "edit") return { kind: "invalid-edit" };
1743
+ if (/^edit(?=\s)/iu.test(input)) return {
1744
+ kind: "edit",
1745
+ objective: input.slice(4).trim()
1746
+ };
1747
+ return {
1748
+ kind: "create",
1749
+ objective: input
1750
+ };
1751
+ }
1752
+ function runSlashCommand(command, actions) {
1753
+ switch (command) {
1754
+ case "/help":
1755
+ actions.help();
1756
+ return;
1757
+ case "/exit":
1758
+ case "/quit":
1759
+ actions.shutdown();
1760
+ return;
1761
+ case "/clear":
1762
+ actions.clear();
1763
+ return;
1764
+ case "/model":
1765
+ actions.openModelProfile();
1766
+ return;
1767
+ case "/trajectory":
1768
+ actions.openTrajectory();
1769
+ return;
1770
+ case "/tools":
1771
+ actions.openToolCards();
1772
+ return;
1773
+ case "/context":
1774
+ actions.openContext();
1775
+ return;
1776
+ case "/plugins":
1777
+ actions.openPlugins();
1778
+ return;
1779
+ case "/presets":
1780
+ actions.openAgentPresets();
1781
+ return;
1782
+ case "/compact":
1783
+ actions.compact();
1784
+ return;
1785
+ }
1786
+ }
1787
+ //#endregion
1788
+ //#region src/tui/piTheme.ts
1789
+ const bold$9 = (s) => `\x1b[1m${s}\x1b[0m`;
1790
+ const selectListTheme = {
1791
+ selectedPrefix: fg(theme.primary),
1792
+ selectedText: (s) => bold$9(fg(theme.primary)(s)),
1793
+ description: fg(theme.muted),
1794
+ scrollInfo: fg(theme.muted),
1795
+ noMatch: fg(theme.muted)
1796
+ };
1797
+ const editorTheme = {
1798
+ borderColor: fg(theme.primary),
1799
+ selectList: selectListTheme
1800
+ };
1801
+ const shellModeEditorBorderColor = fg(theme.warning);
1802
+ const NOT_MENTION = {
1803
+ isMentionMode: false,
1804
+ query: "",
1805
+ start: -1
1806
+ };
1807
+ /**
1808
+ * Find the `@`-mention token, if any, ending at `cursor`.
1809
+ * @param value - the full prompt buffer.
1810
+ * @param cursor - the buffer offset the reader is currently editing at.
1811
+ * @returns the open mention's query/span, or `isMentionMode: false` outside one.
1812
+ */
1813
+ function mentionQuery(value, cursor) {
1814
+ let i = cursor;
1815
+ while (i > 0 && !/\s/.test(value[i - 1])) i--;
1816
+ if (i === cursor || value[i] !== "@") return NOT_MENTION;
1817
+ const before = value[i - 1];
1818
+ if (before !== void 0 && !/\s/.test(before)) return NOT_MENTION;
1819
+ return {
1820
+ isMentionMode: true,
1821
+ query: value.slice(i + 1, cursor),
1822
+ start: i
1823
+ };
1824
+ }
1825
+ /**
1826
+ * Filter and rank file candidates for a `@`-mention query.
1827
+ * @param candidates - the full file index, repo-relative paths.
1828
+ * @param query - text typed after `@` (case-insensitive substring match).
1829
+ * @param limit - max rows returned.
1830
+ * @returns matches ranked by path-prefix, then basename-prefix, then path length.
1831
+ */
1832
+ function matchFileCandidates(candidates, query, limit = 10) {
1833
+ const needle = query.toLowerCase();
1834
+ const matches = candidates.filter((path) => path.toLowerCase().includes(needle));
1835
+ matches.sort((a, b) => rank(a, needle) - rank(b, needle) || a.length - b.length);
1836
+ return matches.slice(0, limit);
1837
+ }
1838
+ function rank(path, needle) {
1839
+ const lower = path.toLowerCase();
1840
+ if (lower.startsWith(needle)) return 0;
1841
+ if (lower.slice(lower.lastIndexOf("/") + 1).startsWith(needle)) return 1;
1842
+ return 2;
1843
+ }
1844
+ //#endregion
1845
+ //#region src/tui/promptAutocomplete.ts
1846
+ function offsetToLineCol(lines, offset) {
1847
+ let remaining = offset;
1848
+ for (let line = 0; line < lines.length; line++) {
1849
+ const len = lines[line].length;
1850
+ if (remaining <= len) return {
1851
+ line,
1852
+ col: remaining
1853
+ };
1854
+ remaining -= len + 1;
1855
+ }
1856
+ const lastLine = Math.max(0, lines.length - 1);
1857
+ return {
1858
+ line: lastLine,
1859
+ col: lines[lastLine]?.length ?? 0
1860
+ };
1861
+ }
1862
+ function lineColToOffset(lines, line, col) {
1863
+ let offset = 0;
1864
+ for (let i = 0; i < line; i++) offset += lines[i].length + 1;
1865
+ return offset + col;
1866
+ }
1867
+ function splitWithCursor(text, offset) {
1868
+ const lines = text.split("\n");
1869
+ const { line, col } = offsetToLineCol(lines, offset);
1870
+ return {
1871
+ lines,
1872
+ cursorLine: line,
1873
+ cursorCol: col
1874
+ };
1875
+ }
1876
+ var PromptAutocompleteProvider = class {
1877
+ getFileCandidates;
1878
+ triggerCharacters = ["/", "@"];
1879
+ constructor(getFileCandidates) {
1880
+ this.getFileCandidates = getFileCandidates;
1881
+ }
1882
+ async getSuggestions(lines, cursorLine, cursorCol, { signal }) {
1883
+ const value = lines.join("\n");
1884
+ const offset = lineColToOffset(lines, cursorLine, cursorCol);
1885
+ const { isCommandMode, matches } = commandQuery(value);
1886
+ if (isCommandMode) {
1887
+ if (matches.length === 0) return null;
1888
+ return {
1889
+ items: matches.map((c) => ({
1890
+ value: c.command,
1891
+ label: c.command,
1892
+ description: c.description
1893
+ })),
1894
+ prefix: value.trim()
1895
+ };
1896
+ }
1897
+ const mention = mentionQuery(value, offset);
1898
+ if (!mention.isMentionMode) return null;
1899
+ const candidates = await this.getFileCandidates();
1900
+ if (signal.aborted) return null;
1901
+ const paths = matchFileCandidates(candidates, mention.query);
1902
+ if (paths.length === 0) return null;
1903
+ return {
1904
+ items: paths.map((path) => ({
1905
+ value: path,
1906
+ label: path
1907
+ })),
1908
+ prefix: mention.query
1909
+ };
1910
+ }
1911
+ applyCompletion(lines, cursorLine, cursorCol, item, _prefix) {
1912
+ const value = lines.join("\n");
1913
+ const offset = lineColToOffset(lines, cursorLine, cursorCol);
1914
+ const { isCommandMode } = commandQuery(value);
1915
+ if (isCommandMode) return splitWithCursor(item.value, item.value.length);
1916
+ const mention = mentionQuery(value, offset);
1917
+ if (mention.isMentionMode) {
1918
+ const start = mention.start + 1;
1919
+ const end = start + mention.query.length;
1920
+ const inserted = `${item.value} `;
1921
+ return splitWithCursor(value.slice(0, start) + inserted + value.slice(end), start + inserted.length);
1922
+ }
1923
+ return {
1924
+ lines,
1925
+ cursorLine,
1926
+ cursorCol
1927
+ };
1928
+ }
1929
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
1930
+ return mentionQuery(lines.join("\n"), lineColToOffset(lines, cursorLine, cursorCol)).isMentionMode;
1931
+ }
1932
+ };
1933
+ //#endregion
1934
+ //#region src/tui/CustomEditor.ts
1935
+ const EXIT_ARM_TIMEOUT_MS = 2e3;
1936
+ const armedHint = fg(theme.muted);
1937
+ const shellModeHint = fg(theme.warning);
1938
+ var CustomEditor = class extends Editor {
1939
+ actions;
1940
+ deps;
1941
+ shellMode = false;
1942
+ armedKey;
1943
+ armTimer;
1944
+ constructor(tui, actions, deps) {
1945
+ super(tui, editorTheme, {
1946
+ paddingX: 2,
1947
+ autocompleteMaxVisible: 20
1948
+ });
1949
+ this.actions = actions;
1950
+ this.deps = deps;
1951
+ this.setAutocompleteProvider(new PromptAutocompleteProvider(deps.getFileCandidates));
1952
+ this.onSubmit = (text) => this.handleSubmit(text);
1953
+ for (const line of deps.history.slice(-100)) this.addToHistory(line);
1954
+ }
1955
+ armOrConfirmExit(key) {
1956
+ if (this.armedKey === key) {
1957
+ if (this.armTimer !== void 0) clearTimeout(this.armTimer);
1958
+ this.armedKey = void 0;
1959
+ this.actions.shutdown();
1960
+ return;
1961
+ }
1962
+ if (this.armTimer !== void 0) clearTimeout(this.armTimer);
1963
+ this.armedKey = key;
1964
+ this.tui.requestRender();
1965
+ this.armTimer = setTimeout(() => {
1966
+ this.armTimer = void 0;
1967
+ this.armedKey = void 0;
1968
+ this.tui.requestRender();
1969
+ }, EXIT_ARM_TIMEOUT_MS);
1970
+ }
1971
+ clearArm() {
1972
+ if (this.armTimer !== void 0) clearTimeout(this.armTimer);
1973
+ this.armTimer = void 0;
1974
+ this.armedKey = void 0;
1975
+ }
1976
+ setShellMode(enabled) {
1977
+ this.shellMode = enabled;
1978
+ this.borderColor = enabled ? shellModeEditorBorderColor : editorTheme.borderColor;
1979
+ }
1980
+ handleSubmit(text) {
1981
+ const trimmed = text.trim();
1982
+ const shellMode = this.shellMode;
1983
+ this.setShellMode(false);
1984
+ if (trimmed === "") return;
1985
+ if (this.deps.history.at(-1) !== trimmed) {
1986
+ this.deps.history.push(trimmed);
1987
+ this.actions.recordHistory(trimmed);
1988
+ }
1989
+ this.addToHistory(trimmed);
1990
+ if (shellMode) {
1991
+ this.actions.runShell(trimmed);
1992
+ return;
1993
+ }
1994
+ const planArgs = parsePlanCommand(trimmed);
1995
+ if (planArgs !== void 0) {
1996
+ this.actions.plan(planArgs);
1997
+ return;
1998
+ }
1999
+ const goalCommand = parseGoalCommand(trimmed);
2000
+ if (goalCommand !== void 0) {
2001
+ this.actions.goal(goalCommand);
2002
+ return;
2003
+ }
2004
+ const matches = trimmed.startsWith("/") && !/\s/.test(trimmed) ? matchSlashCommands(trimmed) : [];
2005
+ if (matches.length > 0) {
2006
+ runSlashCommand(matches[0].command, this.actions);
2007
+ return;
2008
+ }
2009
+ this.actions.send(trimmed);
2010
+ }
2011
+ handleInput(data) {
2012
+ if (matchesKey(data, Key.ctrl("o"))) {
2013
+ this.actions.openToolCards();
2014
+ return;
2015
+ }
2016
+ if (!this.shellMode && data === "!" && this.getText() === "") {
2017
+ this.setShellMode(true);
2018
+ this.tui.requestRender();
2019
+ return;
2020
+ }
2021
+ if (this.shellMode && (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) && this.getText() === "")) {
2022
+ this.setShellMode(false);
2023
+ this.tui.requestRender();
2024
+ return;
2025
+ }
2026
+ if (matchesKey(data, "shift+tab")) {
2027
+ this.actions.cyclePermission();
2028
+ return;
2029
+ }
2030
+ if (matchesKey(data, Key.ctrl("c"))) {
2031
+ if (this.deps.getStatus() === "running") {
2032
+ this.actions.cancel();
2033
+ return;
2034
+ }
2035
+ if (this.getText() !== "") {
2036
+ this.setText("");
2037
+ this.setShellMode(false);
2038
+ this.clearArm();
2039
+ this.tui.requestRender();
2040
+ return;
2041
+ }
2042
+ this.armOrConfirmExit("c");
2043
+ return;
2044
+ }
2045
+ if (matchesKey(data, Key.ctrl("d"))) {
2046
+ if (this.deps.getStatus() === "running") return;
2047
+ if (this.getText() !== "") {
2048
+ super.handleInput(data);
2049
+ return;
2050
+ }
2051
+ this.armOrConfirmExit("d");
2052
+ return;
2053
+ }
2054
+ super.handleInput(data);
2055
+ }
2056
+ render(width) {
2057
+ const hints = [];
2058
+ if (this.armedKey !== void 0) hints.push(armedHint(`Press Ctrl+${this.armedKey.toUpperCase()} again to exit`));
2059
+ if (this.shellMode) hints.push(shellModeHint("! shell mode — Enter runs the command, Esc/Backspace exits"));
2060
+ return [...hints, ...this.withPromptPrefix(super.render(width), width)];
2061
+ }
2062
+ /**
2063
+ * Splices a `'› '`/`'! '` prompt marker into the editor box's first
2064
+ * content row (index 1 — index 0 is always the top border), replacing
2065
+ * that row's leading `paddingX` spaces. Skipped below `paddingX: 2`'s
2066
+ * width-clamped floor (mirrors `Editor.render`'s own `maxPadding` clamp)
2067
+ * rather than risk eating actual text in a pathologically narrow terminal.
2068
+ */
2069
+ withPromptPrefix(lines, width) {
2070
+ if (lines.length < 2) return lines;
2071
+ if (Math.max(0, Math.floor((width - 1) / 2)) < 2) return lines;
2072
+ const prefix = this.borderColor(this.shellMode ? "! " : "› ");
2073
+ const next = [...lines];
2074
+ next[1] = prefix + next[1].slice(2);
2075
+ return next;
2076
+ }
2077
+ };
2078
+ //#endregion
2079
+ //#region src/tui/Spinner.ts
2080
+ const FRAMES = [
2081
+ "⠋",
2082
+ "⠙",
2083
+ "⠹",
2084
+ "⠸",
2085
+ "⠼",
2086
+ "⠴",
2087
+ "⠦",
2088
+ "⠧",
2089
+ "⠇",
2090
+ "⠏"
2091
+ ];
2092
+ const INTERVAL_MS = 80;
2093
+ var Spinner = class {
2094
+ tui;
2095
+ frame = 0;
2096
+ timer;
2097
+ constructor(tui) {
2098
+ this.tui = tui;
2099
+ }
2100
+ current() {
2101
+ return FRAMES[this.frame];
2102
+ }
2103
+ start() {
2104
+ if (this.timer !== void 0) return;
2105
+ this.timer = setInterval(() => {
2106
+ this.frame = (this.frame + 1) % FRAMES.length;
2107
+ this.tui.requestRender();
2108
+ }, INTERVAL_MS);
2109
+ }
2110
+ stop() {
2111
+ if (this.timer === void 0) return;
2112
+ clearInterval(this.timer);
2113
+ this.timer = void 0;
2114
+ }
2115
+ };
2116
+ //#endregion
2117
+ //#region src/tui/miniTextField.ts
2118
+ /**
2119
+ * A tiny hand-rolled single-line text buffer (value + cursor, insert/
2120
+ * backspace/delete/left/right/home/end) shared by the handful of overlays
2121
+ * that need one embedded field inside otherwise-custom keyboard handling
2122
+ * (`QuestionOverlay`'s free-text answer, `TrajectoryOverlay`'s filter,
2123
+ * `ProviderForm`'s fields, `ModelListEditor`'s add-id field) — replacing
2124
+ * `ink-text-input` without wrestling with pi-tui's focus model, which only
2125
+ * tracks one focused `Component` at a time and has no built-in way to
2126
+ * delegate keystrokes to a field nested inside a larger custom overlay.
2127
+ * @module @tomowang/dsh-tui/tui/miniTextField
2128
+ */
2129
+ function emptyMiniTextField(value = "") {
2130
+ return {
2131
+ value,
2132
+ cursor: value.length
2133
+ };
2134
+ }
2135
+ /** Apply one keystroke, or return `undefined` if this field doesn't handle it (so the caller can fall through to its own bindings, e.g. Enter/Escape/Tab). */
2136
+ function miniTextFieldInput(state, data) {
2137
+ if (matchesKey(data, Key.left)) return {
2138
+ ...state,
2139
+ cursor: Math.max(0, state.cursor - 1)
2140
+ };
2141
+ if (matchesKey(data, Key.right)) return {
2142
+ ...state,
2143
+ cursor: Math.min(state.value.length, state.cursor + 1)
2144
+ };
2145
+ if (matchesKey(data, Key.home) || matchesKey(data, Key.ctrl("a"))) return {
2146
+ ...state,
2147
+ cursor: 0
2148
+ };
2149
+ if (matchesKey(data, Key.end) || matchesKey(data, Key.ctrl("e"))) return {
2150
+ ...state,
2151
+ cursor: state.value.length
2152
+ };
2153
+ if (matchesKey(data, Key.backspace)) {
2154
+ if (state.cursor === 0) return state;
2155
+ return {
2156
+ value: state.value.slice(0, state.cursor - 1) + state.value.slice(state.cursor),
2157
+ cursor: state.cursor - 1
2158
+ };
2159
+ }
2160
+ if (matchesKey(data, Key.delete)) {
2161
+ if (state.cursor >= state.value.length) return state;
2162
+ return {
2163
+ value: state.value.slice(0, state.cursor) + state.value.slice(state.cursor + 1),
2164
+ cursor: state.cursor
2165
+ };
2166
+ }
2167
+ if (data.length > 0 && !data.startsWith("\x1B") && data !== "\r" && data !== "\n" && data !== " ") return {
2168
+ value: state.value.slice(0, state.cursor) + data + state.value.slice(state.cursor),
2169
+ cursor: state.cursor + data.length
2170
+ };
2171
+ }
2172
+ /** Render the field's text, optionally with an inverse-video cursor block at the cursor position. */
2173
+ function renderMiniTextField(state, cursorVisible, mask) {
2174
+ const display = mask === void 0 ? state.value : mask.repeat(state.value.length);
2175
+ if (!cursorVisible) return display;
2176
+ return `${display.slice(0, state.cursor)}\x1b[7m${display[state.cursor] ?? " "}\x1b[0m${display.slice(state.cursor + 1)}`;
2177
+ }
2178
+ //#endregion
2179
+ //#region src/tui/modelProfile/ModelProfileOverlay.ts
2180
+ const bold$8 = (s) => `\x1b[1m${s}\x1b[0m`;
2181
+ const secondary$8 = fg(theme.secondary);
2182
+ const muted$9 = fg(theme.muted);
2183
+ const errorColor$4 = fg(theme.error);
2184
+ const invert$4 = (s) => `\x1b[7m${s}\x1b[0m`;
2185
+ var ModelProfileOverlay = class {
2186
+ store;
2187
+ actions;
2188
+ confirmDelete;
2189
+ formKeySeen = -1;
2190
+ route = emptyMiniTextField();
2191
+ displayName = emptyMiniTextField();
2192
+ api = emptyMiniTextField();
2193
+ baseURL = emptyMiniTextField();
2194
+ apiKeyDraft = emptyMiniTextField();
2195
+ models = [];
2196
+ showModels = false;
2197
+ focused = 0;
2198
+ modelDraftId = emptyMiniTextField();
2199
+ modelSelected = 0;
2200
+ modelInputFocused = false;
2201
+ constructor(store, actions) {
2202
+ this.store = store;
2203
+ this.actions = actions;
2204
+ }
2205
+ invalidate() {}
2206
+ textFields(draft) {
2207
+ return draft.isNew ? [
2208
+ "route",
2209
+ "displayName",
2210
+ "api",
2211
+ "baseURL",
2212
+ "apiKey"
2213
+ ] : [
2214
+ "displayName",
2215
+ "api",
2216
+ "baseURL",
2217
+ "apiKey"
2218
+ ];
2219
+ }
2220
+ /** Reinitialize form-local state from the store's draft when `formKey` changes — the equivalent of the old `key={formKey}` remount. */
2221
+ syncFormState(mp) {
2222
+ if (mp.view !== "form" || mp.draft === void 0) return void 0;
2223
+ const draft = mp.draft;
2224
+ if (mp.formKey !== this.formKeySeen) {
2225
+ this.formKeySeen = mp.formKey;
2226
+ this.route = emptyMiniTextField(draft.route);
2227
+ this.displayName = emptyMiniTextField(draft.displayName);
2228
+ this.api = emptyMiniTextField(draft.api);
2229
+ this.baseURL = emptyMiniTextField(draft.baseURL);
2230
+ this.apiKeyDraft = emptyMiniTextField("");
2231
+ this.models = [...draft.models];
2232
+ this.showModels = false;
2233
+ this.focused = 0;
2234
+ this.modelDraftId = emptyMiniTextField();
2235
+ this.modelSelected = 0;
2236
+ this.modelInputFocused = false;
2237
+ }
2238
+ return draft;
2239
+ }
2240
+ buildDraft(draft) {
2241
+ return {
2242
+ ...draft,
2243
+ route: draft.isNew ? this.route.value.trim() : draft.route,
2244
+ displayName: this.displayName.value,
2245
+ api: this.api.value,
2246
+ baseURL: this.baseURL.value,
2247
+ apiKeyDraft: this.apiKeyDraft.value,
2248
+ models: this.models
2249
+ };
2250
+ }
2251
+ render(_width) {
2252
+ const overlay = this.store.getSnapshot().overlay;
2253
+ if (overlay.kind !== "modelProfile") return [];
2254
+ const mp = overlay.modelProfile;
2255
+ const draft = this.syncFormState(mp);
2256
+ if (draft !== void 0) return this.showModels ? this.renderModelListEditor(mp) : this.renderForm(draft, mp);
2257
+ return this.renderList(mp);
2258
+ }
2259
+ renderList(mp) {
2260
+ const { providers, selected, busy, error } = mp;
2261
+ const lines = [bold$8(secondary$8("Model providers"))];
2262
+ if (error !== void 0) lines.push(errorColor$4(error));
2263
+ if (busy && providers === void 0) lines.push(muted$9("Loading…"));
2264
+ providers?.forEach((row, index) => {
2265
+ const marker = row.configured ? "● " : "○ ";
2266
+ const active = row.live ? " (active)" : "";
2267
+ const noKey = row.apiKeyConfigured ? "" : " [no api key]";
2268
+ const confirm = this.confirmDelete === index ? " — press d again to delete" : "";
2269
+ const text = `${index === selected ? "› " : " "}${marker}${row.displayName}${active}${noKey}${confirm}`;
2270
+ lines.push(index === selected ? invert$4(text) : text);
2271
+ });
2272
+ if (providers?.length === 0) lines.push(muted$9("No providers configured yet — press a to add one."));
2273
+ lines.push(muted$9("↑↓ select · enter edit · a add · d delete · s set active model · esc close"));
2274
+ return lines;
2275
+ }
2276
+ handleListInput(data, mp) {
2277
+ const { providers, selected } = mp;
2278
+ if (matchesKey(data, Key.escape)) {
2279
+ this.actions.closeModelProfile();
2280
+ return;
2281
+ }
2282
+ if (providers === void 0 || providers.length === 0) {
2283
+ if (data === "a") this.actions.createProvider();
2284
+ return;
2285
+ }
2286
+ if (matchesKey(data, Key.up)) {
2287
+ this.confirmDelete = void 0;
2288
+ this.actions.selectProvider(Math.max(0, selected - 1));
2289
+ return;
2290
+ }
2291
+ if (matchesKey(data, Key.down)) {
2292
+ this.confirmDelete = void 0;
2293
+ this.actions.selectProvider(Math.min(providers.length - 1, selected + 1));
2294
+ return;
2295
+ }
2296
+ if (matchesKey(data, Key.enter)) {
2297
+ this.actions.editProvider(providers[selected].route);
2298
+ return;
2299
+ }
2300
+ if (data === "a") {
2301
+ this.actions.createProvider();
2302
+ return;
2303
+ }
2304
+ if (data === "s") {
2305
+ const row = providers[selected];
2306
+ const model = row.models[0];
2307
+ if (model !== void 0) this.actions.setActiveModel(row.route, model.id);
2308
+ return;
2309
+ }
2310
+ if (data === "d") {
2311
+ if (this.confirmDelete === selected) {
2312
+ this.confirmDelete = void 0;
2313
+ this.actions.deleteProvider(providers[selected]);
2314
+ } else this.confirmDelete = selected;
2315
+ return;
2316
+ }
2317
+ this.confirmDelete = void 0;
2318
+ }
2319
+ renderForm(draft, mp) {
2320
+ const textFields = this.textFields(draft);
2321
+ const modelsRow = textFields.length;
2322
+ const saveRow = textFields.length + 1;
2323
+ const fieldState = {
2324
+ route: this.route,
2325
+ displayName: this.displayName,
2326
+ api: this.api,
2327
+ baseURL: this.baseURL,
2328
+ apiKey: this.apiKeyDraft
2329
+ };
2330
+ const labels = {
2331
+ route: "Route",
2332
+ displayName: "Name",
2333
+ api: "Protocol",
2334
+ baseURL: "Base URL",
2335
+ apiKey: draft.apiKeyConfigured ? "API key (set — leave blank to keep)" : "API key"
2336
+ };
2337
+ const lines = [bold$8(secondary$8(draft.isNew ? "Add provider" : `Edit ${draft.displayName || draft.route}`))];
2338
+ if (mp.error !== void 0) lines.push(errorColor$4(mp.error));
2339
+ textFields.forEach((field, index) => {
2340
+ const isFocused = this.focused === index;
2341
+ const mask = field === "apiKey" ? "*" : void 0;
2342
+ const prefix = `${isFocused ? "› " : " "}${labels[field]}: `;
2343
+ lines.push(`${prefix}${renderMiniTextField(fieldState[field], isFocused, mask)}`);
2344
+ });
2345
+ const modelsText = `${this.focused === modelsRow ? "› " : " "}Models (${this.models.length}) — enter to edit`;
2346
+ lines.push(this.focused === modelsRow ? invert$4(modelsText) : modelsText);
2347
+ const saveText = `${this.focused === saveRow ? "› " : " "}${mp.busy ? "Saving…" : "Save"}`;
2348
+ lines.push(this.focused === saveRow ? invert$4(saveText) : saveText);
2349
+ lines.push(muted$9("tab/shift+tab move · enter confirm field / activate row · esc cancel"));
2350
+ return lines;
2351
+ }
2352
+ handleFormInput(data, draft) {
2353
+ const textFields = this.textFields(draft);
2354
+ const modelsRow = textFields.length;
2355
+ const saveRow = textFields.length + 1;
2356
+ const rowCount = textFields.length + 2;
2357
+ if (matchesKey(data, Key.escape)) {
2358
+ this.actions.backToProviderList();
2359
+ return;
2360
+ }
2361
+ if (matchesKey(data, "shift+tab")) {
2362
+ this.focused = (this.focused - 1 + rowCount) % rowCount;
2363
+ return;
2364
+ }
2365
+ if (matchesKey(data, Key.tab)) {
2366
+ this.focused = (this.focused + 1) % rowCount;
2367
+ return;
2368
+ }
2369
+ if (matchesKey(data, Key.enter) && this.focused === modelsRow) {
2370
+ this.showModels = true;
2371
+ return;
2372
+ }
2373
+ if (matchesKey(data, Key.enter) && this.focused === saveRow) {
2374
+ this.actions.saveProvider(this.buildDraft(draft));
2375
+ return;
2376
+ }
2377
+ if (matchesKey(data, Key.enter) && this.focused < textFields.length) {
2378
+ this.focused = (this.focused + 1) % rowCount;
2379
+ return;
2380
+ }
2381
+ if (this.focused < textFields.length) {
2382
+ const field = textFields[this.focused];
2383
+ const next = miniTextFieldInput({
2384
+ route: this.route,
2385
+ displayName: this.displayName,
2386
+ api: this.api,
2387
+ baseURL: this.baseURL,
2388
+ apiKey: this.apiKeyDraft
2389
+ }[field], data);
2390
+ if (next === void 0) return;
2391
+ if (field === "route") this.route = next;
2392
+ else if (field === "displayName") this.displayName = next;
2393
+ else if (field === "api") this.api = next;
2394
+ else if (field === "baseURL") this.baseURL = next;
2395
+ else this.apiKeyDraft = next;
2396
+ }
2397
+ }
2398
+ renderModelListEditor(mp) {
2399
+ const lines = [bold$8(secondary$8("Models"))];
2400
+ this.models.forEach((model, index) => {
2401
+ const isSelected = !this.modelInputFocused && index === this.modelSelected;
2402
+ const text = `${isSelected ? "› " : " "}${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`;
2403
+ lines.push(isSelected ? invert$4(text) : text);
2404
+ });
2405
+ if (this.models.length === 0) lines.push(muted$9("No models yet."));
2406
+ lines.push(`${this.modelInputFocused ? "› " : " "}Add id: ${renderMiniTextField(this.modelDraftId, this.modelInputFocused)}`);
2407
+ if (mp.busy) lines.push(muted$9("Discovering…"));
2408
+ if (mp.discovered !== void 0) if (mp.discovered.length === 0) lines.push(muted$9("No models discovered."));
2409
+ else {
2410
+ lines.push(muted$9("Discovered — tab to the id field and type one to adopt it:"));
2411
+ for (const model of mp.discovered) lines.push(muted$9(` ${model.id}${model.name === void 0 ? "" : ` — ${model.name}`}`));
2412
+ }
2413
+ lines.push(muted$9("tab toggle list/input · ↑↓ select · x remove · g discover · esc back"));
2414
+ return lines;
2415
+ }
2416
+ addModel(id) {
2417
+ const trimmed = id.trim();
2418
+ if (trimmed === "" || this.models.some((model) => model.id === trimmed)) return;
2419
+ const overlay = this.store.getSnapshot().overlay;
2420
+ const found = (overlay.kind === "modelProfile" ? overlay.modelProfile.discovered : void 0)?.find((model) => model.id === trimmed);
2421
+ this.models = [...this.models, found === void 0 ? { id: trimmed } : { ...found }];
2422
+ this.modelDraftId = emptyMiniTextField();
2423
+ }
2424
+ handleModelListEditorInput(data, draft) {
2425
+ if (matchesKey(data, Key.escape)) {
2426
+ this.showModels = false;
2427
+ return;
2428
+ }
2429
+ if (matchesKey(data, Key.tab)) {
2430
+ this.modelInputFocused = !this.modelInputFocused;
2431
+ return;
2432
+ }
2433
+ if (this.modelInputFocused) {
2434
+ if (matchesKey(data, Key.enter)) {
2435
+ this.addModel(this.modelDraftId.value);
2436
+ return;
2437
+ }
2438
+ const next = miniTextFieldInput(this.modelDraftId, data);
2439
+ if (next !== void 0) this.modelDraftId = next;
2440
+ return;
2441
+ }
2442
+ if (data === "g") {
2443
+ this.actions.discoverModelsForDraft(this.buildDraft(draft));
2444
+ return;
2445
+ }
2446
+ if (this.models.length === 0) return;
2447
+ if (matchesKey(data, Key.up)) {
2448
+ this.modelSelected = Math.max(0, this.modelSelected - 1);
2449
+ return;
2450
+ }
2451
+ if (matchesKey(data, Key.down)) {
2452
+ this.modelSelected = Math.min(this.models.length - 1, this.modelSelected + 1);
2453
+ return;
2454
+ }
2455
+ if (data === "x") {
2456
+ this.models = this.models.filter((_, index) => index !== this.modelSelected);
2457
+ this.modelSelected = Math.max(0, Math.min(this.modelSelected, this.models.length - 1));
2458
+ }
2459
+ }
2460
+ handleInput(data) {
2461
+ const overlay = this.store.getSnapshot().overlay;
2462
+ if (overlay.kind !== "modelProfile") return;
2463
+ const mp = overlay.modelProfile;
2464
+ const draft = this.syncFormState(mp);
2465
+ if (draft !== void 0) {
2466
+ if (this.showModels) this.handleModelListEditorInput(data, draft);
2467
+ else this.handleFormInput(data, draft);
2468
+ return;
2469
+ }
2470
+ this.handleListInput(data, mp);
2471
+ }
2472
+ };
2473
+ //#endregion
2474
+ //#region src/tui/trajectory/layout.ts
2475
+ const LABEL_LIMIT = 100;
2476
+ function prettyJson(raw) {
2477
+ try {
2478
+ return JSON.stringify(JSON.parse(raw), null, 2);
2479
+ } catch {
2480
+ return raw;
2481
+ }
2482
+ }
2483
+ /** The row's kind tag (USER/CONTEXT) already names the source, so the label itself carries no redundant prefix. */
2484
+ function userLabel(data) {
2485
+ const { source } = data;
2486
+ if (source.kind === "user") return truncate(textOf(data.content), LABEL_LIMIT);
2487
+ if (source.kind === "plugin") {
2488
+ const summary = source.form === "notice" ? source.summary : void 0;
2489
+ return `${source.plugin}${summary === void 0 ? "" : ` · ${summary}`}`;
2490
+ }
2491
+ if (source.kind === "goal") return `goal · round ${source.round}`;
2492
+ return source.kind;
2493
+ }
2494
+ /**
2495
+ * Fold the session log into ledger rows, in seq order.
2496
+ * @param events - the session's durable event log (replay + live, already seq-deduped by `TuiStore`).
2497
+ * @param collapsedTurns - turns whose non-first content row should fold into one `'collapsed'` summary row.
2498
+ */
2499
+ function buildTrajectoryRows(events, collapsedTurns) {
2500
+ const rows = [];
2501
+ const stepsByTurn = /* @__PURE__ */ new Map();
2502
+ const pendingCalls = /* @__PURE__ */ new Map();
2503
+ const openTurnRows = /* @__PURE__ */ new Map();
2504
+ let currentTurn = 0;
2505
+ let currentStep = 0;
2506
+ for (const event of events) switch (event.type) {
2507
+ case "turn/start": {
2508
+ currentTurn = event.data.turn;
2509
+ const draft = {
2510
+ kind: "turn",
2511
+ turn: currentTurn,
2512
+ aborted: void 0
2513
+ };
2514
+ openTurnRows.set(currentTurn, draft);
2515
+ rows.push(draft);
2516
+ break;
2517
+ }
2518
+ case "turn/end": {
2519
+ const draft = openTurnRows.get(event.data.turn);
2520
+ const { reason } = event.data;
2521
+ if (draft !== void 0 && reason.kind === "error") draft.aborted = `${reason.error.code}: ${reason.error.message}`;
2522
+ else if (draft !== void 0 && reason.kind === "aborted") draft.aborted = "turn canceled";
2523
+ break;
2524
+ }
2525
+ case "step/start": {
2526
+ currentStep = event.data.step;
2527
+ let steps = stepsByTurn.get(event.data.turn);
2528
+ if (steps === void 0) {
2529
+ steps = /* @__PURE__ */ new Set();
2530
+ stepsByTurn.set(event.data.turn, steps);
2531
+ }
2532
+ steps.add(event.data.step);
2533
+ rows.push({
2534
+ kind: "step",
2535
+ turn: event.data.turn,
2536
+ step: event.data.step
2537
+ });
2538
+ break;
2539
+ }
2540
+ case "user/message": {
2541
+ const label = userLabel(event.data);
2542
+ const text = textOf(event.data.content);
2543
+ const record = {
2544
+ id: `${event.seq}`,
2545
+ kind: event.data.source.kind === "user" ? "user" : "context",
2546
+ turn: currentTurn,
2547
+ step: currentStep,
2548
+ seq: event.seq,
2549
+ startedAt: event.time,
2550
+ completedAt: void 0,
2551
+ label,
2552
+ isError: false,
2553
+ summary: label,
2554
+ payload: text === "" ? void 0 : text,
2555
+ result: void 0,
2556
+ reasoning: void 0,
2557
+ source: event.data.source,
2558
+ toolName: void 0
2559
+ };
2560
+ rows.push({
2561
+ kind: "record",
2562
+ record
2563
+ });
2564
+ break;
2565
+ }
2566
+ case "assistant/message": {
2567
+ const content = event.data.message.content;
2568
+ const text = textOf(content);
2569
+ const reasoningText = reasoningOf(content);
2570
+ const displaySource = text === "" ? reasoningText : text;
2571
+ const label = displaySource === "" ? "(tool calls only)" : truncate(displaySource, LABEL_LIMIT);
2572
+ const record = {
2573
+ id: `${event.seq}`,
2574
+ kind: "assistant",
2575
+ turn: event.data.turn,
2576
+ step: event.data.step,
2577
+ seq: event.seq,
2578
+ startedAt: event.time,
2579
+ completedAt: void 0,
2580
+ label,
2581
+ isError: false,
2582
+ summary: label,
2583
+ payload: text === "" ? void 0 : text,
2584
+ result: void 0,
2585
+ reasoning: reasoningText === "" ? void 0 : reasoningText,
2586
+ source: void 0,
2587
+ toolName: void 0
2588
+ };
2589
+ rows.push({
2590
+ kind: "record",
2591
+ record
2592
+ });
2593
+ break;
2594
+ }
2595
+ case "tool/call": {
2596
+ const label = `${event.data.name} ${truncate(event.data.arguments, LABEL_LIMIT)}`;
2597
+ const record = {
2598
+ id: event.data.callId,
2599
+ kind: "tool",
2600
+ turn: event.data.turn,
2601
+ step: event.data.step,
2602
+ seq: event.seq,
2603
+ startedAt: event.time,
2604
+ completedAt: void 0,
2605
+ label,
2606
+ isError: false,
2607
+ summary: label,
2608
+ payload: prettyJson(event.data.arguments),
2609
+ result: void 0,
2610
+ reasoning: void 0,
2611
+ source: void 0,
2612
+ toolName: event.data.name
2613
+ };
2614
+ pendingCalls.set(event.data.callId, record);
2615
+ rows.push({
2616
+ kind: "record",
2617
+ record
2618
+ });
2619
+ break;
2620
+ }
2621
+ case "tool/result": {
2622
+ const [block] = event.data.message.content;
2623
+ const failed = event.data.error !== void 0 || block.isError === true;
2624
+ const resultText = event.data.error !== void 0 ? `${event.data.error.code}: ${event.data.error.name}` : textOf(block.content);
2625
+ const callId = event.data.message.source.callId;
2626
+ const pending = pendingCalls.get(callId);
2627
+ if (pending !== void 0) {
2628
+ pending.completedAt = event.time;
2629
+ pending.isError = failed;
2630
+ pending.result = resultText;
2631
+ pending.summary = `${pending.label} → ${failed ? "error" : "ok"}`;
2632
+ pendingCalls.delete(callId);
2633
+ } else {
2634
+ const label = `(unmatched result) ${truncate(resultText, LABEL_LIMIT)}`;
2635
+ const record = {
2636
+ id: `${event.seq}`,
2637
+ kind: "tool",
2638
+ turn: event.data.turn,
2639
+ step: event.data.step,
2640
+ seq: event.seq,
2641
+ startedAt: event.time,
2642
+ completedAt: event.time,
2643
+ label,
2644
+ isError: failed,
2645
+ summary: label,
2646
+ payload: void 0,
2647
+ result: resultText,
2648
+ reasoning: void 0,
2649
+ source: void 0,
2650
+ toolName: void 0
2651
+ };
2652
+ rows.push({
2653
+ kind: "record",
2654
+ record
2655
+ });
2656
+ }
2657
+ break;
2658
+ }
2659
+ case "request/header": {
2660
+ if (event.data.reason === "initial") break;
2661
+ const label = `config ${event.data.reason} updated`;
2662
+ const record = {
2663
+ id: `${event.seq}`,
2664
+ kind: "header",
2665
+ turn: currentTurn,
2666
+ step: currentStep,
2667
+ seq: event.seq,
2668
+ startedAt: event.time,
2669
+ completedAt: void 0,
2670
+ label,
2671
+ isError: false,
2672
+ summary: label,
2673
+ payload: JSON.stringify(event.data.header, null, 2),
2674
+ result: void 0,
2675
+ reasoning: void 0,
2676
+ source: void 0,
2677
+ toolName: void 0
2678
+ };
2679
+ rows.push({
2680
+ kind: "record",
2681
+ record
2682
+ });
2683
+ break;
2684
+ }
2685
+ default: break;
2686
+ }
2687
+ return collapseRows(rows.filter((row) => row.kind !== "step" || (stepsByTurn.get(row.turn)?.size ?? 0) > 1), collapsedTurns);
2688
+ }
2689
+ /** Fold every row of a collapsed turn after its first content row into one summary row. */
2690
+ function collapseRows(rows, collapsedTurns) {
2691
+ if (collapsedTurns.size === 0) return rows;
2692
+ const out = [];
2693
+ let seenFirstInTurn = false;
2694
+ let pendingCount = 0;
2695
+ let pendingTurn = -1;
2696
+ const flush = () => {
2697
+ if (pendingCount > 0) {
2698
+ out.push({
2699
+ kind: "collapsed",
2700
+ turn: pendingTurn,
2701
+ count: pendingCount
2702
+ });
2703
+ pendingCount = 0;
2704
+ }
2705
+ };
2706
+ for (const row of rows) {
2707
+ if (row.kind === "turn") {
2708
+ flush();
2709
+ seenFirstInTurn = false;
2710
+ pendingTurn = row.turn;
2711
+ out.push(row);
2712
+ continue;
2713
+ }
2714
+ const turn = row.kind === "step" ? row.turn : row.kind === "record" ? row.record.turn : pendingTurn;
2715
+ if (!collapsedTurns.has(turn)) {
2716
+ out.push(row);
2717
+ continue;
2718
+ }
2719
+ if (!seenFirstInTurn) {
2720
+ seenFirstInTurn = true;
2721
+ out.push(row);
2722
+ continue;
2723
+ }
2724
+ pendingCount += 1;
2725
+ }
2726
+ flush();
2727
+ return out;
2728
+ }
2729
+ //#endregion
2730
+ //#region src/tui/trajectory/TrajectoryLedger.ts
2731
+ const bold$7 = (s) => `\x1b[1m${s}\x1b[0m`;
2732
+ const invert$3 = (s) => `\x1b[7m${s}\x1b[0m`;
2733
+ const secondary$7 = fg(theme.secondary);
2734
+ const muted$8 = fg(theme.muted);
2735
+ const errorColor$3 = fg(theme.error);
2736
+ function recordGlyph(record) {
2737
+ if (record.kind === "tool") return record.isError ? "✖" : "⚙";
2738
+ if (record.kind === "header") return "⊕";
2739
+ return " ";
2740
+ }
2741
+ /** Kind tags, matching the web ledger's USER/CONTEXT/ASSISTANT/TOOL wording exactly (`header` has no web counterpart). */
2742
+ const KIND_TAG = {
2743
+ user: "USER",
2744
+ context: "CONTEXT",
2745
+ assistant: "ASSISTANT",
2746
+ tool: "TOOL",
2747
+ header: "HEADER"
2748
+ };
2749
+ const KIND_TAG_WIDTH = Math.max(...Object.values(KIND_TAG).map((tag) => tag.length));
2750
+ /** Mirrors the web ledger's per-row kind tag coloring (assistant violet, tool amber, user brand blue, context mint, header neutral). */
2751
+ function kindColor(kind) {
2752
+ switch (kind) {
2753
+ case "user": return theme.primary;
2754
+ case "context": return theme.success;
2755
+ case "assistant": return theme.reasoning;
2756
+ case "tool": return theme.warning;
2757
+ case "header": return theme.muted;
2758
+ }
2759
+ }
2760
+ function recordLine(record, selected) {
2761
+ const tag = bold$7(fg(kindColor(record.kind))(KIND_TAG[record.kind].padEnd(KIND_TAG_WIDTH)));
2762
+ const body = `${selected ? "› " : " "}${tag} ${recordGlyph(record)} ${record.label}`;
2763
+ const withColor = record.isError ? errorColor$3(body) : record.kind === "header" ? muted$8(body) : body;
2764
+ return selected ? invert$3(withColor) : withColor;
2765
+ }
2766
+ function buildLedgerLines(rows, selectedId) {
2767
+ return rows.map((row) => {
2768
+ switch (row.kind) {
2769
+ case "turn": return bold$7(secondary$7(`── Turn ${row.turn} ──${row.aborted === void 0 ? "" : ` ⚠ ${row.aborted}`}`));
2770
+ case "step": return muted$8(` Step ${row.step}`);
2771
+ case "collapsed": return muted$8(` … ${row.count} record${row.count === 1 ? "" : "s"} collapsed`);
2772
+ case "record": return recordLine(row.record, row.record.id === selectedId);
2773
+ }
2774
+ });
2775
+ }
2776
+ //#endregion
2777
+ //#region src/tui/trajectory/detail.ts
2778
+ const violet = fg(theme.reasoning);
2779
+ function formatTime(ms) {
2780
+ return new Date(ms).toLocaleTimeString();
2781
+ }
2782
+ function summaryText(record) {
2783
+ const lines = [
2784
+ `kind ${record.kind}`,
2785
+ `turn/step ${record.turn}/${record.step}`,
2786
+ `started ${formatTime(record.startedAt)}`
2787
+ ];
2788
+ if (record.completedAt !== void 0) lines.push(`duration ${record.completedAt - record.startedAt}ms`);
2789
+ if (record.isError) lines.push("status error");
2790
+ return lines.join("\n");
2791
+ }
2792
+ function timingText(record) {
2793
+ if (record.completedAt === void 0) return `started ${formatTime(record.startedAt)}\n(no completion recorded)`;
2794
+ return [
2795
+ `started ${formatTime(record.startedAt)}`,
2796
+ `completed ${formatTime(record.completedAt)}`,
2797
+ `duration ${record.completedAt - record.startedAt}ms`
2798
+ ].join("\n");
2799
+ }
2800
+ /** Rendered Preview tab: reasoning (if any) ahead of the visible text, mirroring the transcript's own reasoning-then-answer framing (`formatStreamingText` in `render.ts`). */
2801
+ function previewText(record) {
2802
+ const parts = [];
2803
+ if (record.reasoning !== void 0) parts.push(violet("✦ thinking"), violet(record.reasoning));
2804
+ if (record.payload !== void 0) parts.push(renderMarkdown(record.payload));
2805
+ return parts.length === 0 ? "(no content)" : parts.join("\n\n");
2806
+ }
2807
+ /** Raw tab: same content as `previewText`, unrendered — the markdown/plain source as the log carries it. */
2808
+ function rawText(record) {
2809
+ const parts = [];
2810
+ if (record.reasoning !== void 0) parts.push(`[thinking]\n${record.reasoning}`);
2811
+ if (record.payload !== void 0) parts.push(record.payload);
2812
+ return parts.length === 0 ? "(no content)" : parts.join("\n\n");
2813
+ }
2814
+ /** Source tab: the raw `user/message` event's `source` descriptor, pretty-printed. */
2815
+ function sourceText(record) {
2816
+ if (record.source === void 0) return "(no source)";
2817
+ try {
2818
+ return JSON.stringify(record.source, null, 2);
2819
+ } catch {
2820
+ return "(unserializable source)";
2821
+ }
2822
+ }
2823
+ /** Schema tab: the tool's own declared `{name, description, parameters}` (`ToolSchema`, via `getTool`) — the live registry's schema, not a per-request snapshot, so it can drift from what an older call actually saw if the tool changed mid-session. */
2824
+ function schemaText(record, getTool) {
2825
+ const tool = record.toolName === void 0 ? void 0 : getTool?.(record.toolName);
2826
+ if (tool === void 0) return "Schema unavailable";
2827
+ return JSON.stringify({
2828
+ name: tool.name,
2829
+ description: tool.description,
2830
+ parameters: tool.parameters
2831
+ }, null, 2);
2832
+ }
2833
+ /** Render the content of one detail-pane tab for the selected record. */
2834
+ function buildDetail(record, tab, getTool) {
2835
+ switch (tab) {
2836
+ case "summary": return summaryText(record);
2837
+ case "payload": return record.payload ?? "(no payload)";
2838
+ case "result": return record.result ?? "—";
2839
+ case "timing": return timingText(record);
2840
+ case "preview": return previewText(record);
2841
+ case "raw": return rawText(record);
2842
+ case "source": return sourceText(record);
2843
+ case "schema": return schemaText(record, getTool);
2844
+ }
2845
+ }
2846
+ //#endregion
2847
+ //#region src/tui/trajectory/types.ts
2848
+ /**
2849
+ * Which detail tabs apply to a record, mirroring the web ledger's per-kind
2850
+ * split: a markdown record (user/context/assistant) gets Summary/Preview/Raw
2851
+ * plus Source only when the underlying event actually carried one (user and
2852
+ * context always do; assistant never does — see `layout.ts`). Everything
2853
+ * else (tool, header) gets Summary plus whichever of Payload/Result the
2854
+ * record actually has, and — tool records only — Schema, always followed by
2855
+ * Timing.
2856
+ */
2857
+ function detailTabsFor(record) {
2858
+ if (record.kind === "user" || record.kind === "context" || record.kind === "assistant") {
2859
+ const tabs = [
2860
+ "summary",
2861
+ "preview",
2862
+ "raw"
2863
+ ];
2864
+ if (record.source !== void 0) tabs.push("source");
2865
+ return tabs;
2866
+ }
2867
+ const tabs = ["summary"];
2868
+ if (record.payload !== void 0) tabs.push("payload");
2869
+ if (record.result !== void 0) tabs.push("result");
2870
+ if (record.kind === "tool") tabs.push("schema");
2871
+ tabs.push("timing");
2872
+ return tabs;
2873
+ }
2874
+ //#endregion
2875
+ //#region src/tui/trajectory/TrajectoryDetail.ts
2876
+ /**
2877
+ * Pure line-builder for the `/trajectory` overlay's tabbed detail pane —
2878
+ * tabs vary by the selected record's kind (see `detailTabsFor`), content
2879
+ * word-wrapped to the pane's width and line-clamped to fit its height
2880
+ * budget. Wrapping matters here specifically because `buildDetail`'s
2881
+ * Preview/Raw/Payload/Schema content can be arbitrarily long prose or JSON
2882
+ * with no line breaks of its own — unlike a browser, the terminal won't wrap
2883
+ * it for us.
2884
+ * @module @tomowang/dsh-tui/tui/trajectory/TrajectoryDetail
2885
+ */
2886
+ const muted$7 = fg(theme.muted);
2887
+ /** Left padding for the panel's body, under its flush-left tab-bar heading — mirrors the ledger's own "Turn" header / indented "Step" row convention (`TrajectoryLedger.ts`). */
2888
+ const DETAIL_INDENT = " ";
2889
+ /** Right padding, matching `DETAIL_INDENT`'s width — reserved purely by wrapping short of the pane's edge, since there's no trailing character to place there. */
2890
+ const DETAIL_RIGHT_PADDING = 2;
2891
+ function buildDetailLines(record, tab, maxLines, getTool, width) {
2892
+ const wrapWidth = Math.max(1, width - 2 - DETAIL_RIGHT_PADDING);
2893
+ const lines = record === void 0 ? [] : wrapTextWithAnsi(buildDetail(record, tab, getTool), wrapWidth);
2894
+ const shown = lines.slice(0, maxLines);
2895
+ const hidden = lines.length - shown.length;
2896
+ const out = [(record === void 0 ? [] : detailTabsFor(record)).map((candidate) => candidate === tab ? `[${candidate}]` : ` ${candidate} `).join(" ")];
2897
+ if (record === void 0) out.push(`${DETAIL_INDENT}${muted$7("(no record selected)")}`);
2898
+ else out.push(...shown.map((line) => `${DETAIL_INDENT}${line}`));
2899
+ if (hidden > 0) out.push(`${DETAIL_INDENT}${muted$7(`… ${hidden} more line${hidden === 1 ? "" : "s"}`)}`);
2900
+ return out;
2901
+ }
2902
+ //#endregion
2903
+ //#region src/tui/trajectory/TrajectoryOverlay.ts
2904
+ const bold$6 = (s) => `\x1b[1m${s}\x1b[0m`;
2905
+ const secondary$6 = fg(theme.secondary);
2906
+ const muted$6 = fg(theme.muted);
2907
+ var TrajectoryOverlay = class {
2908
+ tui;
2909
+ store;
2910
+ actions;
2911
+ getTool;
2912
+ collapsedTurns = /* @__PURE__ */ new Set();
2913
+ filter = emptyMiniTextField();
2914
+ filterFocused = false;
2915
+ detailTab = "summary";
2916
+ selectedId;
2917
+ scrollOffset = 0;
2918
+ lastSelectedTurn;
2919
+ constructor(tui, store, actions, getTool) {
2920
+ this.tui = tui;
2921
+ this.store = store;
2922
+ this.actions = actions;
2923
+ this.getTool = getTool;
2924
+ }
2925
+ invalidate() {}
2926
+ heights() {
2927
+ const availableRows = Math.max(10, this.tui.terminal.rows - 1);
2928
+ const remaining = Math.max(6, availableRows - 4);
2929
+ const detailContentHeight = Math.max(2, Math.floor(remaining / 2));
2930
+ return {
2931
+ ledgerHeight: Math.max(3, remaining - detailContentHeight - 1),
2932
+ detailContentHeight
2933
+ };
2934
+ }
2935
+ computeRows() {
2936
+ const events = this.store.getSnapshot().events;
2937
+ const rows = buildTrajectoryRows(events, this.collapsedTurns);
2938
+ const query = this.filter.value.trim().toLowerCase();
2939
+ const filteredRows = query === "" ? rows : rows.filter((row) => row.kind === "record" && (row.record.label.toLowerCase().includes(query) || row.record.summary.toLowerCase().includes(query)));
2940
+ return {
2941
+ filteredRows,
2942
+ records: filteredRows.filter((row) => row.kind === "record")
2943
+ };
2944
+ }
2945
+ render(width) {
2946
+ const { filteredRows, records } = this.computeRows();
2947
+ const selectedIndex = this.selectedId === void 0 ? -1 : records.findIndex((row) => row.record.id === this.selectedId);
2948
+ const effectiveIndex = selectedIndex === -1 ? records.length - 1 : selectedIndex;
2949
+ const selectedRecord = records[effectiveIndex]?.record;
2950
+ if (selectedRecord !== void 0) this.lastSelectedTurn = selectedRecord.turn;
2951
+ if (selectedRecord !== void 0 && !detailTabsFor(selectedRecord).includes(this.detailTab)) this.detailTab = "summary";
2952
+ const selectedRowIndex = selectedRecord === void 0 ? -1 : filteredRows.findIndex((row) => row.kind === "record" && row.record.id === selectedRecord.id);
2953
+ const { ledgerHeight, detailContentHeight } = this.heights();
2954
+ const maxOffset = Math.max(0, filteredRows.length - ledgerHeight);
2955
+ if (selectedRowIndex < this.scrollOffset) this.scrollOffset = selectedRowIndex;
2956
+ else if (selectedRowIndex >= this.scrollOffset + ledgerHeight) this.scrollOffset = selectedRowIndex - ledgerHeight + 1;
2957
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
2958
+ const windowedRows = filteredRows.slice(this.scrollOffset, this.scrollOffset + ledgerHeight);
2959
+ const lines = [
2960
+ bold$6(secondary$6(`Trajectory${this.filter.value === "" ? "" : ` — filter: ${this.filter.value}`}${records.length === 0 ? "" : ` (${effectiveIndex + 1}/${records.length})`}`)),
2961
+ ...buildLedgerLines(windowedRows, selectedRecord?.id),
2962
+ "",
2963
+ ...buildDetailLines(selectedRecord, this.detailTab, detailContentHeight, this.getTool, width),
2964
+ ""
2965
+ ];
2966
+ if (this.filterFocused) lines.push(`/ ${renderMiniTextField(this.filter, true)}`);
2967
+ else lines.push(muted$6("↑↓ select · tab detail · c collapse · / filter · esc close"));
2968
+ return lines;
2969
+ }
2970
+ moveSelection(delta) {
2971
+ const { records } = this.computeRows();
2972
+ if (records.length === 0) return;
2973
+ const selectedIndex = this.selectedId === void 0 ? -1 : records.findIndex((row) => row.record.id === this.selectedId);
2974
+ const effectiveIndex = selectedIndex === -1 ? records.length - 1 : selectedIndex;
2975
+ const next = Math.min(records.length - 1, Math.max(0, effectiveIndex + delta));
2976
+ this.selectedId = records[next].record.id;
2977
+ }
2978
+ selectedRecord() {
2979
+ const { records } = this.computeRows();
2980
+ const selectedIndex = this.selectedId === void 0 ? -1 : records.findIndex((row) => row.record.id === this.selectedId);
2981
+ return records[selectedIndex === -1 ? records.length - 1 : selectedIndex]?.record;
2982
+ }
2983
+ cycleTab(delta) {
2984
+ const record = this.selectedRecord();
2985
+ if (record === void 0) return;
2986
+ const tabs = detailTabsFor(record);
2987
+ const index = tabs.indexOf(this.detailTab);
2988
+ const next = ((index === -1 ? 0 : index) + delta + tabs.length) % tabs.length;
2989
+ this.detailTab = tabs[next];
2990
+ }
2991
+ toggleCollapse() {
2992
+ const turn = this.selectedRecord()?.turn ?? this.lastSelectedTurn;
2993
+ if (turn === void 0) return;
2994
+ if (this.collapsedTurns.has(turn)) this.collapsedTurns.delete(turn);
2995
+ else this.collapsedTurns.add(turn);
2996
+ }
2997
+ handleInput(data) {
2998
+ if (this.filterFocused) {
2999
+ if (matchesKey(data, Key.escape)) {
3000
+ this.filterFocused = false;
3001
+ return;
3002
+ }
3003
+ if (matchesKey(data, Key.enter)) {
3004
+ this.filterFocused = false;
3005
+ return;
3006
+ }
3007
+ const next = miniTextFieldInput(this.filter, data);
3008
+ if (next !== void 0) this.filter = next;
3009
+ return;
3010
+ }
3011
+ if (matchesKey(data, Key.escape)) {
3012
+ this.actions.closeTrajectory();
3013
+ return;
3014
+ }
3015
+ if (matchesKey(data, Key.up)) {
3016
+ this.moveSelection(-1);
3017
+ return;
3018
+ }
3019
+ if (matchesKey(data, Key.down)) {
3020
+ this.moveSelection(1);
3021
+ return;
3022
+ }
3023
+ if (matchesKey(data, "shift+tab")) {
3024
+ this.cycleTab(-1);
3025
+ return;
3026
+ }
3027
+ if (matchesKey(data, Key.tab)) {
3028
+ this.cycleTab(1);
3029
+ return;
3030
+ }
3031
+ if (data === "c") {
3032
+ this.toggleCollapse();
3033
+ return;
3034
+ }
3035
+ if (data === "/") this.filterFocused = true;
3036
+ }
3037
+ };
3038
+ //#endregion
3039
+ //#region src/tui/toolCards/ToolCardsOverlay.ts
3040
+ const bold$5 = (s) => `\x1b[1m${s}\x1b[0m`;
3041
+ const secondary$5 = fg(theme.secondary);
3042
+ const muted$5 = fg(theme.muted);
3043
+ /** Stable identity for a row across its pending → resolved transition — the call's own `seq` when there is one, so `collapsed`/scroll state survives its result landing. */
3044
+ function rowKey(row) {
3045
+ return (row.call ?? row.result).seq;
3046
+ }
3047
+ function summaryOf(row, options) {
3048
+ if (row.result !== void 0) return formatToolCardSummary(row.result, options);
3049
+ if (row.call !== void 0) return formatToolCardSummary(row.call, options);
3050
+ return "";
3051
+ }
3052
+ /** Full detail for a row: the call's presentation, then the result's, blank-line separated when both are present. */
3053
+ function detailOf(row, options) {
3054
+ const callLines = row.call === void 0 ? [] : formatToolCardDetail(row.call, options);
3055
+ const resultLines = row.result === void 0 ? [] : formatToolCardDetail(row.result, options);
3056
+ if (callLines.length === 0) return resultLines;
3057
+ if (resultLines.length === 0) return callLines;
3058
+ return [
3059
+ ...callLines,
3060
+ "",
3061
+ ...resultLines
3062
+ ];
3063
+ }
3064
+ var ToolCardsOverlay = class {
3065
+ tui;
3066
+ store;
3067
+ actions;
3068
+ getTool;
3069
+ getToolCall;
3070
+ selected;
3071
+ collapsed = /* @__PURE__ */ new Set();
3072
+ scrollOffset = 0;
3073
+ lastRowKey;
3074
+ lastOpen = false;
3075
+ constructor(tui, store, actions, getTool, getToolCall) {
3076
+ this.tui = tui;
3077
+ this.store = store;
3078
+ this.actions = actions;
3079
+ this.getTool = getTool;
3080
+ this.getToolCall = getToolCall;
3081
+ }
3082
+ invalidate() {}
3083
+ /** Pairs `tool/call`/`tool/result` events by `callId`, in call order; an orphaned result (no call in the log) gets its own trailing row. */
3084
+ cards() {
3085
+ const rows = [];
3086
+ const indexByCallId = /* @__PURE__ */ new Map();
3087
+ for (const event of this.store.getSnapshot().events) if (event.type === "tool/call") {
3088
+ indexByCallId.set(event.data.callId, rows.length);
3089
+ rows.push({
3090
+ call: event,
3091
+ result: void 0
3092
+ });
3093
+ } else if (event.type === "tool/result") {
3094
+ const index = indexByCallId.get(event.data.message.source.callId);
3095
+ if (index === void 0) rows.push({
3096
+ call: void 0,
3097
+ result: event
3098
+ });
3099
+ else rows[index] = {
3100
+ ...rows[index],
3101
+ result: event
3102
+ };
3103
+ }
3104
+ return rows;
3105
+ }
3106
+ contentRows() {
3107
+ const availableRows = Math.max(6, this.tui.terminal.rows - 1);
3108
+ return Math.max(1, availableRows - 4);
3109
+ }
3110
+ render(_width) {
3111
+ const cards = this.cards();
3112
+ const index = cards.length === 0 ? 0 : Math.min(this.selected ?? cards.length - 1, cards.length - 1);
3113
+ const row = cards[index];
3114
+ const key = row === void 0 ? void 0 : rowKey(row);
3115
+ const open = key !== void 0 && !this.collapsed.has(key);
3116
+ if (key !== this.lastRowKey || open !== this.lastOpen) {
3117
+ this.scrollOffset = 0;
3118
+ this.lastRowKey = key;
3119
+ this.lastOpen = open;
3120
+ }
3121
+ const options = {
3122
+ replay: false,
3123
+ getTool: this.getTool,
3124
+ getToolCall: this.getToolCall
3125
+ };
3126
+ const contentRows = this.contentRows();
3127
+ const summary = row === void 0 ? void 0 : summaryOf(row, options);
3128
+ const detailLines = row === void 0 || !open ? void 0 : detailOf(row, options);
3129
+ const totalDetailLines = detailLines?.length ?? 0;
3130
+ const maxScrollOffset = Math.max(0, totalDetailLines - contentRows);
3131
+ const clampedOffset = Math.min(this.scrollOffset, maxScrollOffset);
3132
+ const visibleDetailLines = detailLines?.slice(clampedOffset, clampedOffset + contentRows);
3133
+ const scrollHint = totalDetailLines <= contentRows ? "" : ` · lines ${clampedOffset + 1}-${Math.min(totalDetailLines, clampedOffset + contentRows)} of ${totalDetailLines}`;
3134
+ const lines = [bold$5(secondary$5(`Tool Cards${cards.length === 0 ? "" : ` (${index + 1}/${cards.length})`}${open ? scrollHint : ""}`))];
3135
+ if (row === void 0) lines.push(muted$5("No tool cards in this session yet."));
3136
+ else if (open) lines.push(...visibleDetailLines ?? []);
3137
+ else lines.push(`▸ ${summary ?? ""}`);
3138
+ lines.push("");
3139
+ lines.push(muted$5(`↑↓ select · PgUp/PgDn/Home/End scroll · Enter/Space ${open ? "collapse" : "expand"} · Ctrl+O/Esc close`));
3140
+ return lines;
3141
+ }
3142
+ move(delta) {
3143
+ const cards = this.cards();
3144
+ if (cards.length === 0) return;
3145
+ const current = this.selected ?? cards.length - 1;
3146
+ this.selected = Math.max(0, Math.min(cards.length - 1, current + delta));
3147
+ }
3148
+ scroll(delta, maxScrollOffset) {
3149
+ this.scrollOffset = Math.max(0, Math.min(maxScrollOffset, this.scrollOffset + delta));
3150
+ }
3151
+ toggle() {
3152
+ const cards = this.cards();
3153
+ const row = cards[cards.length === 0 ? 0 : Math.min(this.selected ?? cards.length - 1, cards.length - 1)];
3154
+ if (row === void 0) return;
3155
+ const key = rowKey(row);
3156
+ if (this.collapsed.has(key)) this.collapsed.delete(key);
3157
+ else this.collapsed.add(key);
3158
+ }
3159
+ handleInput(data) {
3160
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("o")) || data === "q") {
3161
+ this.actions.closeToolCards();
3162
+ return;
3163
+ }
3164
+ const cards = this.cards();
3165
+ const row = cards[cards.length === 0 ? 0 : Math.min(this.selected ?? cards.length - 1, cards.length - 1)];
3166
+ const key = row === void 0 ? void 0 : rowKey(row);
3167
+ const open = key !== void 0 && !this.collapsed.has(key);
3168
+ const contentRows = this.contentRows();
3169
+ const options = {
3170
+ replay: false,
3171
+ getTool: this.getTool,
3172
+ getToolCall: this.getToolCall
3173
+ };
3174
+ const detailLines = row === void 0 || !open ? void 0 : detailOf(row, options);
3175
+ const maxScrollOffset = Math.max(0, (detailLines?.length ?? 0) - contentRows);
3176
+ if (matchesKey(data, Key.pageUp)) return this.scroll(-contentRows, maxScrollOffset);
3177
+ if (matchesKey(data, Key.pageDown)) return this.scroll(contentRows, maxScrollOffset);
3178
+ if (matchesKey(data, Key.home)) return this.scroll(-maxScrollOffset, maxScrollOffset);
3179
+ if (matchesKey(data, Key.end)) return this.scroll(maxScrollOffset, maxScrollOffset);
3180
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("p"))) {
3181
+ this.move(-1);
3182
+ return;
3183
+ }
3184
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("n"))) {
3185
+ this.move(1);
3186
+ return;
3187
+ }
3188
+ if (matchesKey(data, Key.enter) || data === " ") this.toggle();
3189
+ }
3190
+ };
3191
+ //#endregion
3192
+ //#region src/tui/context/ContextOverlay.ts
3193
+ const bold$4 = (s) => `\x1b[1m${s}\x1b[0m`;
3194
+ const secondary$4 = fg(theme.secondary);
3195
+ const muted$4 = fg(theme.muted);
3196
+ const BAR_WIDTH = 30;
3197
+ function bar(widthPercent) {
3198
+ const filled = Math.round(widthPercent / 100 * BAR_WIDTH);
3199
+ return "█".repeat(Math.max(0, Math.min(BAR_WIDTH, filled))).padEnd(BAR_WIDTH, "░");
3200
+ }
3201
+ var ContextOverlay = class {
3202
+ store;
3203
+ actions;
3204
+ constructor(store, actions) {
3205
+ this.store = store;
3206
+ this.actions = actions;
3207
+ }
3208
+ invalidate() {}
3209
+ render(_width) {
3210
+ const { contextPressure, contextBreakdown } = this.store.getSnapshot().stats;
3211
+ const occupancy = contextOccupancy(contextPressure);
3212
+ const rows = contextBreakdownRows(occupancy, contextBreakdown);
3213
+ const lines = [bold$4(secondary$4("Context usage"))];
3214
+ if (occupancy === null) lines.push(muted$4("No usage reported yet — send a message first."));
3215
+ else {
3216
+ lines.push(`${occupancy.percent}% of context used`);
3217
+ lines.push(muted$4(`~${formatTokens(occupancy.usedTokens)} / ${formatTokens(occupancy.contextWindow)}`));
3218
+ lines.push("");
3219
+ if (rows.length === 0) lines.push(muted$4("No composition breakdown yet."));
3220
+ else for (const row of rows) lines.push(`${row.label.padEnd(14)} ${secondary$4(bar(row.width))} ${formatTokens(row.tokens)}`);
3221
+ }
3222
+ lines.push("");
3223
+ lines.push(muted$4("esc close"));
3224
+ return lines;
3225
+ }
3226
+ handleInput(data) {
3227
+ if (matchesKey(data, Key.escape) || data === "q") {
3228
+ this.actions.closeContext();
3229
+ return;
3230
+ }
3231
+ }
3232
+ };
3233
+ //#endregion
3234
+ //#region src/tui/plugins/PluginsOverlay.ts
3235
+ const bold$3 = (s) => `\x1b[1m${s}\x1b[0m`;
3236
+ const secondary$3 = fg(theme.secondary);
3237
+ const muted$3 = fg(theme.muted);
3238
+ const errorColor$2 = fg(theme.error);
3239
+ const success$1 = fg(theme.success);
3240
+ const STATE_LABEL = {
3241
+ pending: "pending",
3242
+ loading: "loading",
3243
+ active: "active",
3244
+ failed: "failed",
3245
+ disposed: "disposed",
3246
+ unloading: "unloading"
3247
+ };
3248
+ function rowLabel(row) {
3249
+ if (row.state !== void 0) return STATE_LABEL[row.state];
3250
+ return row.disabled ? "off" : "···";
3251
+ }
3252
+ function rowColor(row) {
3253
+ if (row.disabled) return muted$3;
3254
+ if (row.state === "failed") return errorColor$2;
3255
+ if (row.state === "active") return success$1;
3256
+ }
3257
+ var PluginsOverlay = class {
3258
+ tui;
3259
+ rows;
3260
+ actions;
3261
+ scrollOffset = 0;
3262
+ constructor(tui, rows, actions) {
3263
+ this.tui = tui;
3264
+ this.rows = rows;
3265
+ this.actions = actions;
3266
+ }
3267
+ invalidate() {}
3268
+ listHeight() {
3269
+ const availableRows = Math.max(10, this.tui.terminal.rows - 1);
3270
+ return Math.max(3, availableRows - 2);
3271
+ }
3272
+ maxOffset() {
3273
+ return Math.max(0, this.rows.length - this.listHeight());
3274
+ }
3275
+ render(_width) {
3276
+ const listHeight = this.listHeight();
3277
+ const offset = Math.min(this.scrollOffset, this.maxOffset());
3278
+ const windowedRows = this.rows.slice(offset, offset + listHeight);
3279
+ const activeCount = this.rows.filter((row) => row.state === "active").length;
3280
+ const failedCount = this.rows.filter((row) => row.state === "failed").length;
3281
+ const lines = [bold$3(secondary$3(`Plugins (${this.rows.length}) — ${activeCount} active${failedCount === 0 ? "" : `, ${failedCount} failed`}`))];
3282
+ for (const row of windowedRows) {
3283
+ const color = rowColor(row);
3284
+ const label = color === void 0 ? rowLabel(row).padEnd(8) : color(rowLabel(row).padEnd(8));
3285
+ const id = row.disabled ? muted$3(` ${row.id}`) : ` ${row.id}`;
3286
+ lines.push(`${label}${id}${muted$3(` (${row.name})`)}`);
3287
+ }
3288
+ lines.push(muted$3("↑↓ scroll · esc close"));
3289
+ return lines;
3290
+ }
3291
+ handleInput(data) {
3292
+ if (matchesKey(data, Key.escape) || data === "q") {
3293
+ this.actions.closePlugins();
3294
+ return;
3295
+ }
3296
+ const listHeight = this.listHeight();
3297
+ const maxOffset = this.maxOffset();
3298
+ if (matchesKey(data, Key.up)) {
3299
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
3300
+ return;
3301
+ }
3302
+ if (matchesKey(data, Key.down)) {
3303
+ this.scrollOffset = Math.min(maxOffset, this.scrollOffset + 1);
3304
+ return;
3305
+ }
3306
+ if (matchesKey(data, Key.pageUp)) {
3307
+ this.scrollOffset = Math.max(0, this.scrollOffset - listHeight);
3308
+ return;
3309
+ }
3310
+ if (matchesKey(data, Key.pageDown)) this.scrollOffset = Math.min(maxOffset, this.scrollOffset + listHeight);
3311
+ }
3312
+ };
3313
+ //#endregion
3314
+ //#region src/tui/agentPresets/AgentPresetsOverlay.ts
3315
+ const bold$2 = (s) => `\x1b[1m${s}\x1b[0m`;
3316
+ const secondary$2 = fg(theme.secondary);
3317
+ const muted$2 = fg(theme.muted);
3318
+ const errorColor$1 = fg(theme.error);
3319
+ const invert$2 = (s) => `\x1b[7m${s}\x1b[0m`;
3320
+ var AgentPresetsOverlay = class {
3321
+ store;
3322
+ actions;
3323
+ constructor(store, actions) {
3324
+ this.store = store;
3325
+ this.actions = actions;
3326
+ }
3327
+ invalidate() {}
3328
+ render(_width) {
3329
+ const overlay = this.store.getSnapshot().overlay;
3330
+ if (overlay.kind !== "agentPresets") return [];
3331
+ const { rows, selected, current, blank, busy, error } = overlay.agentPresets;
3332
+ const lines = [bold$2(secondary$2("Agent presets"))];
3333
+ if (error !== void 0) lines.push(errorColor$1(error));
3334
+ if (busy && rows.length === 0) lines.push(muted$2("Loading…"));
3335
+ rows.forEach((row, index) => {
3336
+ const marker = row.id === current ? "● " : "○ ";
3337
+ const trust = row.trust === "user" ? " (custom)" : "";
3338
+ const row0 = `${index === selected ? "› " : " "}${marker}${row.label}${trust}`;
3339
+ lines.push(index === selected ? invert$2(row0) : row0);
3340
+ if (row.broken !== void 0) lines.push(errorColor$1(` broken: ${row.broken}`));
3341
+ else if (row.description !== void 0) lines.push(muted$2(` ${row.description}`));
3342
+ });
3343
+ if (rows.length === 0 && !busy) lines.push(muted$2("No agent presets configured in this profile."));
3344
+ lines.push(muted$2(blank ? "↑↓ select · enter apply · esc close" : "session already started — preset is fixed · esc close"));
3345
+ return lines;
3346
+ }
3347
+ handleInput(data) {
3348
+ const overlay = this.store.getSnapshot().overlay;
3349
+ if (overlay.kind !== "agentPresets") return;
3350
+ const { rows, selected, blank } = overlay.agentPresets;
3351
+ if (matchesKey(data, Key.escape) || data === "q") {
3352
+ this.actions.closeAgentPresets();
3353
+ return;
3354
+ }
3355
+ if (rows.length === 0) return;
3356
+ if (matchesKey(data, Key.up)) {
3357
+ this.actions.selectAgentPresetRow(Math.max(0, selected - 1));
3358
+ return;
3359
+ }
3360
+ if (matchesKey(data, Key.down)) {
3361
+ this.actions.selectAgentPresetRow(Math.min(rows.length - 1, selected + 1));
3362
+ return;
3363
+ }
3364
+ if (matchesKey(data, Key.enter) && blank) {
3365
+ const row = rows[selected];
3366
+ if (row.broken === void 0) this.actions.applyAgentPreset(row.id);
3367
+ }
3368
+ }
3369
+ };
3370
+ //#endregion
3371
+ //#region src/tui/interaction/ApprovalOverlay.ts
3372
+ const bold$1 = (s) => `\x1b[1m${s}\x1b[0m`;
3373
+ const warning = fg(theme.warning);
3374
+ const muted$1 = fg(theme.muted);
3375
+ const success = fg(theme.success);
3376
+ const errorColor = fg(theme.error);
3377
+ const invert$1 = (s) => `\x1b[7m${s}\x1b[0m`;
3378
+ const CHOICES = [{
3379
+ outcome: "allowed-once",
3380
+ label: "Allow once"
3381
+ }, {
3382
+ outcome: "rejected",
3383
+ label: "Reject"
3384
+ }];
3385
+ var ApprovalOverlay = class {
3386
+ approval;
3387
+ actions;
3388
+ selected = 0;
3389
+ constructor(approval, actions) {
3390
+ this.approval = approval;
3391
+ this.actions = actions;
3392
+ }
3393
+ invalidate() {}
3394
+ render(_width) {
3395
+ const lines = [bold$1(warning("Approval requested"))];
3396
+ const idSuffix = this.approval.callId === void 0 ? "" : muted$1(` (${this.approval.callId})`);
3397
+ lines.push(`Tool: ${bold$1(this.approval.toolName)}${idSuffix}`);
3398
+ if (this.approval.reason !== void 0) lines.push(muted$1(this.approval.reason));
3399
+ CHOICES.forEach((choice, index) => {
3400
+ const color = choice.outcome === "rejected" ? errorColor : success;
3401
+ const text = `${index === this.selected ? "› " : " "}${choice.label}`;
3402
+ lines.push(color(index === this.selected ? invert$1(text) : text));
3403
+ });
3404
+ lines.push(muted$1("↑↓ select · enter confirm · y allow · n/esc reject"));
3405
+ return lines;
3406
+ }
3407
+ handleInput(data) {
3408
+ if (data === "y") {
3409
+ this.actions.answerApproval("allowed-once");
3410
+ return;
3411
+ }
3412
+ if (data === "n" || matchesKey(data, Key.escape)) {
3413
+ this.actions.answerApproval("rejected");
3414
+ return;
3415
+ }
3416
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) {
3417
+ this.selected = (this.selected - 1 + CHOICES.length) % CHOICES.length;
3418
+ return;
3419
+ }
3420
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.right)) {
3421
+ this.selected = (this.selected + 1) % CHOICES.length;
3422
+ return;
3423
+ }
3424
+ if (matchesKey(data, Key.enter)) this.actions.answerApproval(CHOICES[this.selected].outcome);
3425
+ }
3426
+ };
3427
+ //#endregion
3428
+ //#region src/tui/interaction/QuestionOverlay.ts
3429
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
3430
+ const secondary$1 = fg(theme.secondary);
3431
+ const muted = fg(theme.muted);
3432
+ const invert = (s) => `\x1b[7m${s}\x1b[0m`;
3433
+ /** Detail (e.g. a plan-review's plan markdown) line cap. */
3434
+ const MAX_DETAIL_LINES = 60;
3435
+ function capDetailLines(detail) {
3436
+ const lines = detail.split("\n");
3437
+ if (lines.length <= MAX_DETAIL_LINES) return lines;
3438
+ const omitted = lines.length - MAX_DETAIL_LINES;
3439
+ return [...lines.slice(0, MAX_DETAIL_LINES), `… +${omitted} more line${omitted === 1 ? "" : "s"}`];
3440
+ }
3441
+ var QuestionOverlay = class {
3442
+ question;
3443
+ actions;
3444
+ cursor = 0;
3445
+ toggled = /* @__PURE__ */ new Set();
3446
+ customMode;
3447
+ customField = emptyMiniTextField();
3448
+ constructor(question, actions) {
3449
+ this.question = question;
3450
+ this.actions = actions;
3451
+ this.customMode = question.options.length === 0;
3452
+ }
3453
+ invalidate() {}
3454
+ submit() {
3455
+ const custom = this.customField.value.trim();
3456
+ if (this.question.multiSelect) {
3457
+ const selected = [...this.toggled].sort((a, b) => a - b).map((index) => this.question.options[index].label);
3458
+ this.actions.answerQuestion({
3459
+ selected,
3460
+ custom: custom === "" ? void 0 : custom
3461
+ });
3462
+ return;
3463
+ }
3464
+ if (this.customMode) {
3465
+ this.actions.answerQuestion({
3466
+ selected: [],
3467
+ custom
3468
+ });
3469
+ return;
3470
+ }
3471
+ this.actions.answerQuestion({
3472
+ selected: [this.question.options[this.cursor].label],
3473
+ custom: void 0
3474
+ });
3475
+ }
3476
+ render(_width) {
3477
+ const { header, question: text, detail, options, multiSelect, approveLabel, progress } = this.question;
3478
+ const otherIndex = options.length;
3479
+ const lines = [];
3480
+ lines.push(bold(secondary$1(`${header ?? "Question"}${progress === void 0 ? "" : ` — ${progress}`}`)));
3481
+ lines.push(text);
3482
+ if (detail !== void 0) {
3483
+ lines.push("");
3484
+ for (const line of capDetailLines(detail)) lines.push(muted(line));
3485
+ lines.push("");
3486
+ }
3487
+ options.forEach((option, index) => {
3488
+ const isSelected = !this.customMode && this.cursor === index;
3489
+ const box = multiSelect ? this.toggled.has(index) ? "[x] " : "[ ] " : "";
3490
+ const approve = approveLabel === option.label ? " (approve)" : "";
3491
+ const row = `${isSelected ? "› " : " "}${box}${option.label}${approve}`;
3492
+ lines.push(isSelected ? invert(row) : row);
3493
+ if (option.description !== void 0) lines.push(muted(` ${option.description}`));
3494
+ });
3495
+ if (options.length > 0) {
3496
+ const isSelected = !this.customMode && this.cursor === otherIndex;
3497
+ const row = `${isSelected ? "› " : " "}Other…`;
3498
+ lines.push(isSelected ? invert(row) : row);
3499
+ }
3500
+ if (this.customMode) lines.push(`> ${renderMiniTextField(this.customField, true)}`);
3501
+ const hint = [
3502
+ multiSelect ? "↑↓ move · space toggle · enter submit" : "↑↓ move · enter select",
3503
+ options.length === 0 ? "" : "\"Other…\" for free text",
3504
+ "esc skip"
3505
+ ].filter((s) => s !== "").join(" · ");
3506
+ lines.push(muted(hint));
3507
+ return lines;
3508
+ }
3509
+ handleInput(data) {
3510
+ const { options, multiSelect } = this.question;
3511
+ const otherIndex = options.length;
3512
+ if (this.customMode) {
3513
+ if (matchesKey(data, Key.escape)) {
3514
+ if (options.length > 0) this.customMode = false;
3515
+ else this.actions.answerQuestion({
3516
+ selected: [],
3517
+ custom: void 0
3518
+ });
3519
+ return;
3520
+ }
3521
+ if (matchesKey(data, Key.enter)) {
3522
+ this.submit();
3523
+ return;
3524
+ }
3525
+ const next = miniTextFieldInput(this.customField, data);
3526
+ if (next !== void 0) this.customField = next;
3527
+ return;
3528
+ }
3529
+ if (matchesKey(data, Key.escape)) {
3530
+ this.actions.answerQuestion({
3531
+ selected: [],
3532
+ custom: void 0
3533
+ });
3534
+ return;
3535
+ }
3536
+ if (options.length === 0) return;
3537
+ if (matchesKey(data, Key.up)) {
3538
+ this.cursor = (this.cursor - 1 + options.length + 1) % (options.length + 1);
3539
+ return;
3540
+ }
3541
+ if (matchesKey(data, Key.down)) {
3542
+ this.cursor = (this.cursor + 1) % (options.length + 1);
3543
+ return;
3544
+ }
3545
+ if (data === " " && multiSelect && this.cursor < options.length) {
3546
+ if (this.toggled.has(this.cursor)) this.toggled.delete(this.cursor);
3547
+ else this.toggled.add(this.cursor);
3548
+ return;
3549
+ }
3550
+ if (matchesKey(data, Key.enter)) {
3551
+ if (this.cursor === otherIndex) {
3552
+ this.customMode = true;
3553
+ return;
3554
+ }
3555
+ this.submit();
3556
+ }
3557
+ }
3558
+ };
3559
+ //#endregion
3560
+ //#region src/tui/TuiApp.ts
3561
+ /**
3562
+ * Root orchestrator: builds the pi-tui component tree once, then patches it
3563
+ * imperatively from `TuiStore` change notifications — the pi-tui equivalent
3564
+ * of `App.tsx` (root component) + `mount.tsx` (the `render()` call site)
3565
+ * combined, since pi-tui has no JSX/reconciler to split those across.
3566
+ *
3567
+ * Two different update strategies are used, deliberately:
3568
+ *
3569
+ * - The live region (notice, queued preview, streaming text, status bar,
3570
+ * stats line, permission indicator, update hint) is a `DynamicText`/`Spinner` per row,
3571
+ * each pulling straight from `store.getSnapshot()` at render time. There is
3572
+ * no manual `setText` bookkeeping to keep in sync — every repaint just
3573
+ * reflects whatever the store currently holds. The approve/reject panel
3574
+ * (`ApprovalSlot`) is a live-region row too, but an interactive one: it
3575
+ * delegates render/input to whichever `ApprovalOverlay` is currently
3576
+ * active and takes focus while one is, rather than covering the screen.
3577
+ * - The transcript is append-only: `appendNewTranscriptItems` diffs the
3578
+ * store's `events`/`shellHistory` arrays against how much has already been
3579
+ * turned into a `createTranscriptLine` child of `documentContainer`,
3580
+ * appending only the new tail. Re-formatting the whole transcript on every
3581
+ * store change would be wasteful for a long session — `ScrollView`'s own
3582
+ * viewport culling (confirmed in pi-tui's own test suite: painting a huge
3583
+ * scroll child is O(viewport), not O(content)) is what makes this safe to
3584
+ * grow without bound.
3585
+ *
3586
+ * Overlays (`/model`, `/trajectory`, Ctrl+O tool cards, `/context`,
3587
+ * `/plugins`, `/presets`, question) are `tui.showOverlay(...)` calls keyed
3588
+ * off `store.getSnapshot().overlay.kind` — see `updateOverlay`. Approval is
3589
+ * the one exception: it renders inline in the dock instead (see above).
3590
+ * @module @tomowang/dsh-tui/tui/TuiApp
3591
+ */
3592
+ const secondary = fg(theme.secondary);
3593
+ /** Full-screen panel anchored at the top — every overlay's uniform placement. */
3594
+ const OVERLAY_OPTIONS = {
3595
+ anchor: "top-left",
3596
+ row: 0,
3597
+ col: 0,
3598
+ width: "100%",
3599
+ maxHeight: "100%"
3600
+ };
3601
+ /**
3602
+ * Wraps an overlay `Component` so it always paints every cell of the
3603
+ * terminal, not just however many lines its own content happens to need.
3604
+ * `tui.showOverlay` composites exactly what `render()` returns onto the base
3605
+ * frame at the requested position/size — it does not clear or pad the rest
3606
+ * of that box — so a short overlay (e.g. a "No tool cards yet" one-liner)
3607
+ * otherwise leaves the transcript/dock's last-painted content visible
3608
+ * underneath it, which reads as a rendering bug (old messages "bleeding
3609
+ * through" around a top-anchored panel) rather than an intentional
3610
+ * takeover. Padding to the full terminal height/width here, once, means no
3611
+ * individual overlay has to reimplement this.
3612
+ */
3613
+ var FullScreenOverlay = class {
3614
+ inner;
3615
+ tui;
3616
+ constructor(inner, tui) {
3617
+ this.inner = inner;
3618
+ this.tui = tui;
3619
+ }
3620
+ get wantsKeyRelease() {
3621
+ return this.inner.wantsKeyRelease ?? false;
3622
+ }
3623
+ invalidate() {
3624
+ this.inner.invalidate();
3625
+ }
3626
+ handleInput(data) {
3627
+ this.inner.handleInput?.(data);
3628
+ }
3629
+ render(width) {
3630
+ const lines = this.inner.render(width);
3631
+ const height = Math.max(lines.length, this.tui.terminal.rows);
3632
+ const padded = [];
3633
+ for (let i = 0; i < height; i++) {
3634
+ const line = lines[i] ?? "";
3635
+ const pad = width - visibleWidth(line);
3636
+ padded.push(pad > 0 ? line + " ".repeat(pad) : line);
3637
+ }
3638
+ return padded;
3639
+ }
3640
+ };
3641
+ /**
3642
+ * Dock row that delegates to whichever `ApprovalOverlay` is currently
3643
+ * active, or renders nothing between approvals. Unlike the other dock rows
3644
+ * (`DynamicText`, pulling read-only text from the store each render), this
3645
+ * one also takes focus and forwards keystrokes — it's how the approve/reject
3646
+ * panel gets shown inline, in the live region, instead of as a
3647
+ * full-screen `showOverlay` panel covering the transcript.
3648
+ */
3649
+ var ApprovalSlot = class {
3650
+ current;
3651
+ set(component) {
3652
+ this.current = component;
3653
+ }
3654
+ invalidate() {
3655
+ this.current?.invalidate();
3656
+ }
3657
+ render(width) {
3658
+ return this.current?.render(width) ?? [];
3659
+ }
3660
+ handleInput(data) {
3661
+ this.current?.handleInput(data);
3662
+ }
3663
+ };
3664
+ let activeTui;
3665
+ let keybindingsConfigured = false;
3666
+ /**
3667
+ * Emacs-style Ctrl+P/Ctrl+N aliases, and history recall on up/down —
3668
+ * matching the old hand-rolled `PromptInput` exactly. Also frees `Home`/`End`
3669
+ * from `TuiAltScreen`'s default viewport-jump-to-top/bottom bindings: the
3670
+ * alt-screen's own viewport navigation intercepts input *before* it reaches
3671
+ * the focused component (confirmed empirically — an unmodified `Home`
3672
+ * scrolled the transcript instead of moving the prompt's cursor to line
3673
+ * start), which would otherwise silently break `Editor`'s own
3674
+ * `cursorLineStart`/`cursorLineEnd` (`Home`/`End`/Ctrl+A/Ctrl+E) whenever the
3675
+ * prompt has focus, which is effectively always. `Ctrl+A`/`Ctrl+E` still
3676
+ * give line motion and `PageUp`/`PageDown`/mouse wheel still give transcript
3677
+ * scroll, so unbinding the dedicated top/bottom jump is a reasonable trade.
3678
+ * Configured once, globally (pi-tui's keybinding registry is module-global,
3679
+ * not per-instance).
3680
+ */
3681
+ function ensureKeybindings() {
3682
+ if (keybindingsConfigured) return;
3683
+ keybindingsConfigured = true;
3684
+ setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS, {
3685
+ "tui.editor.cursorUp": ["up", "ctrl+p"],
3686
+ "tui.editor.cursorDown": ["down", "ctrl+n"],
3687
+ "tui.editor.historyPrevious": ["up", "ctrl+p"],
3688
+ "tui.editor.historyNext": ["down", "ctrl+n"],
3689
+ "tui.altScreen.top": [],
3690
+ "tui.altScreen.bottom": []
3691
+ }));
3692
+ }
3693
+ var TuiApp = class {
3694
+ options;
3695
+ tui;
3696
+ documentContainer = new Container();
3697
+ editor;
3698
+ spinner;
3699
+ appendedEventsCount = 0;
3700
+ appendedShellCount = 0;
3701
+ currentOverlayKind = "none";
3702
+ overlayHandle;
3703
+ approvalSlot = new ApprovalSlot();
3704
+ wasRunning = false;
3705
+ stopped = false;
3706
+ /** Last title string sent to the terminal, so an unrelated store change doesn't re-issue the same OSC 0 write every render. */
3707
+ lastTerminalTitle;
3708
+ constructor(options) {
3709
+ this.options = options;
3710
+ const { store, actions } = options;
3711
+ const terminal = new ProcessTerminal();
3712
+ this.tui = new TuiAltScreen(terminal, true, void 0, { mouse: true });
3713
+ this.spinner = new Spinner(this.tui);
3714
+ this.documentContainer.addChild(new DynamicText((width) => buildBannerText({
3715
+ version: options.version,
3716
+ provider: options.provider,
3717
+ model: options.model,
3718
+ cwd: options.cwd
3719
+ }, width)));
3720
+ const transcriptScrollView = new ScrollView(this.documentContainer, {
3721
+ follow: "end",
3722
+ primary: true,
3723
+ overscroll: "chain"
3724
+ });
3725
+ this.editor = new CustomEditor(this.tui, actions, {
3726
+ getStatus: () => store.getSnapshot().status,
3727
+ history: options.promptHistory,
3728
+ getFileCandidates: () => this.waitForFileIndex()
3729
+ });
3730
+ const noticeText = new DynamicText(() => {
3731
+ const notice = store.getSnapshot().notice;
3732
+ return notice === void 0 ? "" : secondary(notice);
3733
+ });
3734
+ const goalText = new DynamicText(() => buildGoalBarText(store.getSnapshot().goal));
3735
+ const queuedText = new DynamicText(() => buildQueuedText(store.getSnapshot().queued));
3736
+ const streamingText = new DynamicText((width) => {
3737
+ const streaming = store.getSnapshot().streaming;
3738
+ if (streaming === void 0) return "";
3739
+ return padTranscriptText(formatStreamingText(streaming.text, streaming.reasoningText, this.spinner.current()) ?? "", width).join("\n");
3740
+ });
3741
+ const pendingToolCallsText = new DynamicText((width) => {
3742
+ const { pendingToolCalls } = store.getSnapshot();
3743
+ return padTranscriptText(formatPendingToolCalls(pendingToolCalls, this.spinner.current(), options.getTool), width).join("\n");
3744
+ });
3745
+ const shellRunLiveText = new DynamicText((width) => {
3746
+ const run = store.getSnapshot().shellRun;
3747
+ if (run === void 0) return "";
3748
+ return padTranscriptText(formatShellRunLive(run.command, run.output), width).join("\n");
3749
+ });
3750
+ const statusBarText = new DynamicText(() => {
3751
+ const state = store.getSnapshot();
3752
+ return buildStatusBarText({
3753
+ sessionId: options.sessionId,
3754
+ provider: options.provider,
3755
+ model: options.model,
3756
+ status: state.status,
3757
+ queuedCount: state.queued.length,
3758
+ presetLabel: state.preset?.current,
3759
+ eventCount: state.events.length,
3760
+ spinnerChar: this.spinner.current()
3761
+ });
3762
+ });
3763
+ const permissionText = new DynamicText(() => buildPermissionText(store.getSnapshot().permission));
3764
+ const updateHintText = new DynamicText(() => buildUpdateHintText(options.version, store.getSnapshot().updateHint));
3765
+ const statsLineText = new DynamicText(() => {
3766
+ const stats = store.getSnapshot().stats;
3767
+ return [buildStatsLine(stats.sessionStats, stats.tokenUsage), buildContextLine(stats.contextPressure)].filter((group) => group !== "").join("| ");
3768
+ });
3769
+ const dock = new VStack([
3770
+ noticeText,
3771
+ goalText,
3772
+ queuedText,
3773
+ streamingText,
3774
+ pendingToolCallsText,
3775
+ shellRunLiveText,
3776
+ statusBarText,
3777
+ this.approvalSlot,
3778
+ this.editor,
3779
+ permissionText,
3780
+ updateHintText,
3781
+ statsLineText
3782
+ ], { gap: 0 });
3783
+ const layoutRoot = new VStack([{
3784
+ component: transcriptScrollView,
3785
+ basis: 0,
3786
+ grow: 1,
3787
+ minSize: 1
3788
+ }, {
3789
+ component: dock,
3790
+ basis: "auto",
3791
+ shrink: 1,
3792
+ minSize: 1
3793
+ }], { gap: 0 });
3794
+ this.tui.setLayoutRoot(layoutRoot);
3795
+ this.tui.setFocus(this.editor);
3796
+ this.appendNewTranscriptItems(store.getSnapshot());
3797
+ this.updateTerminalTitle(store.getSnapshot().title);
3798
+ store.subscribe(() => {
3799
+ const state = store.getSnapshot();
3800
+ this.appendNewTranscriptItems(state);
3801
+ this.updateOverlay(state.overlay);
3802
+ this.updateTerminalTitle(state.title);
3803
+ const running = state.status === "running";
3804
+ if (running !== this.wasRunning) {
3805
+ this.wasRunning = running;
3806
+ if (running) this.spinner.start();
3807
+ else this.spinner.stop();
3808
+ }
3809
+ this.tui.requestRender();
3810
+ });
3811
+ }
3812
+ /** Push the terminal window/tab title (OSC 0) when the session's title projection changes; a no-op once already reflecting the current value. */
3813
+ updateTerminalTitle(title) {
3814
+ const text = buildTerminalTitle(title);
3815
+ if (text === this.lastTerminalTitle) return;
3816
+ this.lastTerminalTitle = text;
3817
+ this.tui.terminal.setTitle(text);
3818
+ }
3819
+ start() {
3820
+ activeTui = this.tui;
3821
+ this.tui.start();
3822
+ }
3823
+ appendNewTranscriptItems(state) {
3824
+ const { getTool, getToolCall } = this.options;
3825
+ if (state.events.length > this.appendedEventsCount) {
3826
+ for (let i = this.appendedEventsCount; i < state.events.length; i++) {
3827
+ const event = state.events[i];
3828
+ const formatted = formatEvent(event, {
3829
+ replay: event.seq <= state.replayThrough,
3830
+ getTool,
3831
+ getToolCall
3832
+ });
3833
+ if (formatted !== void 0 && formatted !== "") this.documentContainer.addChild(createTranscriptLine(formatted));
3834
+ }
3835
+ this.appendedEventsCount = state.events.length;
3836
+ }
3837
+ if (state.shellHistory.length > this.appendedShellCount) {
3838
+ for (let i = this.appendedShellCount; i < state.shellHistory.length; i++) {
3839
+ const run = state.shellHistory[i];
3840
+ this.documentContainer.addChild(createTranscriptLine(formatShellRun(run.command, run.output, run.exitCode)));
3841
+ }
3842
+ this.appendedShellCount = state.shellHistory.length;
3843
+ }
3844
+ }
3845
+ /** Loads (once, cached in the store) and resolves with the `@`-mention file index, for `CustomEditor`'s autocomplete provider. */
3846
+ waitForFileIndex() {
3847
+ const { store, actions } = this.options;
3848
+ actions.ensureFileIndex();
3849
+ const snapshot = store.getSnapshot().fileIndex;
3850
+ if (snapshot.candidates !== void 0) return Promise.resolve(snapshot.candidates);
3851
+ return new Promise((resolve) => {
3852
+ const unsubscribe = store.subscribe(() => {
3853
+ const current = store.getSnapshot().fileIndex;
3854
+ if (current.candidates !== void 0) {
3855
+ unsubscribe();
3856
+ resolve(current.candidates);
3857
+ }
3858
+ });
3859
+ });
3860
+ }
3861
+ buildOverlayComponent(overlay) {
3862
+ const { store, actions, getTool, getToolCall } = this.options;
3863
+ switch (overlay.kind) {
3864
+ case "none": return;
3865
+ case "modelProfile": return new ModelProfileOverlay(store, actions);
3866
+ case "trajectory": return new TrajectoryOverlay(this.tui, store, actions, getTool);
3867
+ case "toolCards": return new ToolCardsOverlay(this.tui, store, actions, getTool, getToolCall);
3868
+ case "context": return new ContextOverlay(store, actions);
3869
+ case "plugins": return new PluginsOverlay(this.tui, overlay.rows, actions);
3870
+ case "agentPresets": return new AgentPresetsOverlay(store, actions);
3871
+ case "approval": return;
3872
+ case "userQuestion": return new QuestionOverlay(overlay.userQuestion, actions);
3873
+ }
3874
+ }
3875
+ updateOverlay(overlay) {
3876
+ if (overlay.kind === this.currentOverlayKind) return;
3877
+ const previousKind = this.currentOverlayKind;
3878
+ this.currentOverlayKind = overlay.kind;
3879
+ if ((overlay.kind === "approval" || overlay.kind === "userQuestion") && previousKind !== "approval" && previousKind !== "userQuestion") {
3880
+ const message = overlay.kind === "approval" ? "dsh-tui is waiting for your approval" : "dsh-tui is waiting for your answer";
3881
+ this.tui.terminal.write(`\x1b]9;${message}\x07`);
3882
+ }
3883
+ if (previousKind === "approval") this.approvalSlot.set(void 0);
3884
+ if (this.overlayHandle !== void 0) {
3885
+ this.overlayHandle.hide();
3886
+ this.overlayHandle = void 0;
3887
+ }
3888
+ if (overlay.kind === "approval") {
3889
+ this.approvalSlot.set(new ApprovalOverlay(overlay.approval, this.options.actions));
3890
+ this.tui.setFocus(this.approvalSlot);
3891
+ return;
3892
+ }
3893
+ this.tui.setFocus(this.editor);
3894
+ const component = this.buildOverlayComponent(overlay);
3895
+ if (component === void 0) return;
3896
+ this.overlayHandle = this.tui.showOverlay(new FullScreenOverlay(component, this.tui), OVERLAY_OPTIONS);
3897
+ }
3898
+ unmount(options) {
3899
+ if (this.stopped) return;
3900
+ this.stopped = true;
3901
+ this.spinner.stop();
3902
+ this.tui.stop(options);
3903
+ if (activeTui === this.tui) activeTui = void 0;
3904
+ }
3905
+ waitUntilExit() {
3906
+ return new Promise((resolve) => setTimeout(resolve, 0));
3907
+ }
3908
+ };
3909
+ /** Mount the interactive front door. */
3910
+ function mountTui(options) {
3911
+ ensureKeybindings();
3912
+ const app = new TuiApp(options);
3913
+ app.start();
3914
+ return app;
3915
+ }
3916
+ /** Directory names the fallback walk never descends into. */
3917
+ const WALK_EXCLUDES = /* @__PURE__ */ new Set([".git", "node_modules"]);
3918
+ /**
3919
+ * List candidate file paths under `cwd`, relative to `cwd`.
3920
+ * @param cwd - root to list from.
3921
+ * @returns tracked and untracked-but-not-gitignored paths via `git ls-files`
3922
+ * when `cwd` is inside a git repo; otherwise a bounded recursive walk.
3923
+ */
3924
+ async function loadFileIndex(cwd) {
3925
+ const fromGit = await listGitFiles(cwd);
3926
+ if (fromGit !== void 0) return fromGit;
3927
+ return walkDirectory(cwd);
3928
+ }
3929
+ function listGitFiles(cwd) {
3930
+ return new Promise((resolve) => {
3931
+ let out = "";
3932
+ let child;
3933
+ try {
3934
+ child = spawn("git", [
3935
+ "ls-files",
3936
+ "--cached",
3937
+ "--others",
3938
+ "--exclude-standard"
3939
+ ], {
3940
+ cwd,
3941
+ stdio: [
3942
+ "ignore",
3943
+ "pipe",
3944
+ "ignore"
3945
+ ]
3946
+ });
3947
+ } catch {
3948
+ resolve(void 0);
3949
+ return;
3950
+ }
3951
+ child.stdout.on("data", (chunk) => {
3952
+ out += chunk.toString();
3953
+ });
3954
+ child.on("error", () => resolve(void 0));
3955
+ child.on("close", (code) => {
3956
+ if (code !== 0) {
3957
+ resolve(void 0);
3958
+ return;
3959
+ }
3960
+ resolve(out.split("\n").filter((line) => line.length > 0));
3961
+ });
3962
+ });
3963
+ }
3964
+ async function walkDirectory(cwd) {
3965
+ const results = [];
3966
+ const queue = [cwd];
3967
+ while (queue.length > 0 && results.length < 5e3) {
3968
+ const dir = queue.shift();
3969
+ let entries;
3970
+ try {
3971
+ entries = await readdir(dir, { withFileTypes: true });
3972
+ } catch {
3973
+ continue;
3974
+ }
3975
+ for (const entry of entries) {
3976
+ if (results.length >= 5e3) break;
3977
+ if (entry.isDirectory()) {
3978
+ if (WALK_EXCLUDES.has(entry.name)) continue;
3979
+ queue.push(join(dir, entry.name));
3980
+ continue;
3981
+ }
3982
+ if (entry.isFile()) results.push(relative(cwd, join(dir, entry.name)));
3983
+ }
3984
+ }
3985
+ return results;
3986
+ }
3987
+ //#endregion
3988
+ //#region src/tui-app/session.ts
3989
+ /**
3990
+ * ACRYL terminal host adapter: brings up one normal local runtime, opens or
3991
+ * resumes one native durable DSH session through the runtime-owned bridge,
3992
+ * projects the durable event log into `TuiStore`, and drives the pi-tui shell
3993
+ * (ported from `tomowang/dsh-tui`) through `TuiActions`. The adapter owns the
3994
+ * mount/dispose order only; it reuses ACRYL runtime ownership and never
3995
+ * constructs a Cordis tree or touches DSH agent internals directly.
3996
+ *
3997
+ * First slice scope: create/resume -> prompt -> stream -> tool state -> cancel
3998
+ * -> clean dispose. Overlay, approval/question, model/preset and shell-mode
3999
+ * surfaces are intentionally stubbed (later increments).
4000
+ */
4001
+ const TUI_VERSION = "0.1.0-dev.0";
4002
+ const PROMPT_HISTORY_LIMIT = 200;
4003
+ const NOT_AVAILABLE = "not available in this build yet";
4004
+ function failUnknown(status) {
4005
+ return status === "running" ? "running" : "idle";
4006
+ }
4007
+ function toolPreview(ctx) {
4008
+ return (name) => ctx.get("tools")?.get(name);
4009
+ }
4010
+ /** Mount one interactive pi-tui session over the bridge. Resolves when the shell exits. */
4011
+ async function runAcrylTui(options) {
4012
+ const host = await startDirectHost({ profile: options.profile });
4013
+ let bridge;
4014
+ let instance;
4015
+ let settled = false;
4016
+ try {
4017
+ const created = createAcrylSessionBridge(host.ctx, {
4018
+ profile: options.profile,
4019
+ generationId: randomUUID(),
4020
+ attachment: "owner",
4021
+ cwd: process.cwd()
4022
+ });
4023
+ bridge = created;
4024
+ const sessionId = await created.open(options.resumeSessionId);
4025
+ const store = new TuiStore({ events: created.events(sessionId) });
4026
+ const initial = await created.snapshot(sessionId);
4027
+ store.setStatus(failUnknown(initial.agentStatus));
4028
+ created.subscribeEvents(sessionId, (event) => {
4029
+ store.appendEvent(event);
4030
+ created.snapshot(sessionId).then((next) => store.setStatus(failUnknown(next.agentStatus)));
4031
+ });
4032
+ const history = [];
4033
+ let exitResolve = () => {};
4034
+ const exited = new Promise((resolve) => {
4035
+ exitResolve = resolve;
4036
+ });
4037
+ const actions = {
4038
+ send(text) {
4039
+ store.setNotice(void 0);
4040
+ created.submitPrompt({
4041
+ sessionId,
4042
+ text
4043
+ }).catch((error) => {
4044
+ store.setNotice(error instanceof Error ? error.message : String(error));
4045
+ });
4046
+ },
4047
+ cancel() {
4048
+ created.cancel(sessionId).catch(() => {});
4049
+ },
4050
+ shutdown() {
4051
+ exitResolve();
4052
+ },
4053
+ help() {
4054
+ store.setNotice("available: any text submits, Ctrl+C cancels, Ctrl+D exits, /help shows commands");
4055
+ },
4056
+ recordHistory(line) {
4057
+ history.push(line);
4058
+ if (history.length > PROMPT_HISTORY_LIMIT) history.shift();
4059
+ },
4060
+ clear() {
4061
+ store.setNotice(`/clear ${NOT_AVAILABLE}`);
4062
+ },
4063
+ cyclePermission() {
4064
+ store.setNotice(`permission cycling ${NOT_AVAILABLE}`);
4065
+ },
4066
+ compact() {
4067
+ store.setNotice(`/compact ${NOT_AVAILABLE}`);
4068
+ },
4069
+ plan() {
4070
+ store.setNotice(`plan mode ${NOT_AVAILABLE}`);
4071
+ },
4072
+ goal() {
4073
+ store.setNotice(`goal mode ${NOT_AVAILABLE}`);
4074
+ },
4075
+ runShell() {
4076
+ store.setNotice(`shell mode ${NOT_AVAILABLE}`);
4077
+ },
4078
+ ensureFileIndex() {
4079
+ if (store.getSnapshot().fileIndex.candidates !== void 0) return;
4080
+ loadFileIndex(process.cwd()).then((candidates) => store.setFileIndex(candidates));
4081
+ },
4082
+ openModelProfile() {
4083
+ store.setNotice(`/model ${NOT_AVAILABLE}`);
4084
+ },
4085
+ openTrajectory() {
4086
+ store.setNotice(`/trajectory ${NOT_AVAILABLE}`);
4087
+ },
4088
+ openToolCards() {
4089
+ store.setNotice(`/tools ${NOT_AVAILABLE}`);
4090
+ },
4091
+ openContext() {
4092
+ store.setNotice(`/context ${NOT_AVAILABLE}`);
4093
+ },
4094
+ openPlugins() {
4095
+ store.setNotice(`/plugins ${NOT_AVAILABLE}`);
4096
+ },
4097
+ openAgentPresets() {
4098
+ store.setNotice(`/presets ${NOT_AVAILABLE}`);
4099
+ },
4100
+ closeModelProfile() {},
4101
+ backToProviderList() {},
4102
+ selectProvider() {},
4103
+ createProvider() {},
4104
+ editProvider() {},
4105
+ saveProvider() {},
4106
+ deleteProvider() {},
4107
+ discoverModelsForDraft() {},
4108
+ setActiveModel() {},
4109
+ closeTrajectory() {},
4110
+ closeToolCards() {},
4111
+ closeContext() {},
4112
+ closePlugins() {},
4113
+ closeAgentPresets() {},
4114
+ selectAgentPresetRow() {},
4115
+ applyAgentPreset() {},
4116
+ answerApproval() {},
4117
+ answerQuestion() {}
4118
+ };
4119
+ const selection = host.ctx.get("agentDefaultModel")?.currentSelection();
4120
+ instance = mountTui({
4121
+ store,
4122
+ actions,
4123
+ sessionId,
4124
+ provider: selection?.provider ?? "",
4125
+ model: selection?.model ?? "",
4126
+ version: TUI_VERSION,
4127
+ cwd: process.cwd(),
4128
+ promptHistory: history,
4129
+ getTool: toolPreview(host.ctx),
4130
+ getToolCall: store.getToolCall
4131
+ });
4132
+ await exited;
4133
+ instance.unmount();
4134
+ await created.dispose();
4135
+ await host.dispose();
4136
+ const resumeHint = stripSessionIdPrefix(sessionId);
4137
+ return Object.freeze({
4138
+ sessionId,
4139
+ resumeHint,
4140
+ async dispose() {
4141
+ if (settled) return;
4142
+ settled = true;
4143
+ instance?.unmount();
4144
+ await bridge?.dispose();
4145
+ await host.dispose();
4146
+ }
4147
+ });
4148
+ } catch (error) {
4149
+ instance?.unmount();
4150
+ await bridge?.dispose().catch(() => {});
4151
+ await host.dispose();
4152
+ throw error;
4153
+ }
4154
+ }
4155
+ //#endregion
4156
+ //#region src/version.ts
4157
+ /** Canonical ACRYL version string used by the CLI and the release smoke checks. */
4158
+ const ACRYL_VERSION = "0.1.0-dev.0";
4159
+ //#endregion
4160
+ //#region src/cli/grammar.ts
4161
+ const HOST_COMMANDS = /* @__PURE__ */ new Set([
4162
+ "tui",
4163
+ "gui",
4164
+ "web"
4165
+ ]);
4166
+ function hostCommand(value) {
4167
+ return HOST_COMMANDS.has(value) ? value : void 0;
4168
+ }
4169
+ function parseAcrylArgs(args) {
4170
+ let command;
4171
+ let profile;
4172
+ let resumeSessionId;
4173
+ let json = false;
4174
+ let version = false;
4175
+ for (let index = 0; index < args.length; index += 1) {
4176
+ const argument = args[index];
4177
+ if (argument === void 0) continue;
4178
+ if (argument === "--version" || argument === "-v") {
4179
+ if (version) throw new Error("--version may be provided only once");
4180
+ version = true;
4181
+ continue;
4182
+ }
4183
+ if (argument === "--profile") {
4184
+ if (profile !== void 0) throw new Error("--profile may be provided only once");
4185
+ const value = args[index + 1];
4186
+ if (value === void 0 || value.startsWith("--") || value.trim() === "") throw new Error("--profile requires a value");
4187
+ profile = value;
4188
+ index += 1;
4189
+ continue;
4190
+ }
4191
+ if (argument === "--resume") {
4192
+ if (resumeSessionId !== void 0) throw new Error("--resume may be provided only once");
4193
+ const value = args[index + 1];
4194
+ if (value === void 0 || value.startsWith("--") || value.trim() === "") throw new Error("--resume requires a session id");
4195
+ resumeSessionId = value;
4196
+ index += 1;
4197
+ continue;
4198
+ }
4199
+ if (argument === "--json") {
4200
+ if (json) throw new Error("--json may be provided only once");
4201
+ json = true;
4202
+ continue;
4203
+ }
4204
+ if (argument.startsWith("-")) throw new Error(`unknown option: ${argument}`);
4205
+ const parsed = hostCommand(argument);
4206
+ if (command === void 0) {
4207
+ if (parsed === void 0) throw new Error(`unknown command: ${argument}`);
4208
+ command = parsed;
4209
+ continue;
4210
+ }
4211
+ throw new Error(`unexpected argument for ${command}: ${argument}`);
4212
+ }
4213
+ const resolvedCommand = command ?? "tui";
4214
+ if (!version && profile === void 0 && resumeSessionId === void 0) return {
4215
+ command: resolvedCommand,
4216
+ json,
4217
+ version
4218
+ };
4219
+ return {
4220
+ command: resolvedCommand,
4221
+ json,
4222
+ version,
4223
+ ...profile === void 0 ? {} : { profile },
4224
+ ...resumeSessionId === void 0 ? {} : { resumeSessionId }
4225
+ };
4226
+ }
4227
+ //#endregion
4228
+ //#region src/cli/run.ts
4229
+ const defaults = {
4230
+ startDirectHost,
4231
+ runTui: runAcrylTui,
4232
+ exit: (code) => {
4233
+ process.exitCode = code;
4234
+ },
4235
+ write: (line) => {
4236
+ process.stdout.write(`${line}\n`);
4237
+ }
4238
+ };
4239
+ function statusLine(host) {
4240
+ return JSON.stringify({
4241
+ mode: "direct",
4242
+ profile: host.profile,
4243
+ generationId: host.generationId
4244
+ });
4245
+ }
4246
+ /**
4247
+ * Run the direct ACRYL terminal host. `--json` is a short-lived, scriptable
4248
+ * headless readiness probe; interactive mode mounts the pi-tui session via the
4249
+ * runtime bridge until a normal exit, then prints a resumable session id.
4250
+ */
4251
+ async function runAcryl(args, supplied = {}) {
4252
+ const dependencies = {
4253
+ ...defaults,
4254
+ ...supplied
4255
+ };
4256
+ const invocation = parseAcrylArgs(args);
4257
+ if (invocation.command !== "tui") throw new Error(`ACRYL ${invocation.command} host is not implemented; use "acryl tui"`);
4258
+ if (invocation.version) {
4259
+ dependencies.write(ACRYL_VERSION);
4260
+ return;
4261
+ }
4262
+ if (invocation.json) {
4263
+ const host = await dependencies.startDirectHost({ profile: invocation.profile ?? "acryl" });
4264
+ try {
4265
+ dependencies.write(statusLine(host));
4266
+ } finally {
4267
+ await host.dispose();
4268
+ }
4269
+ return;
4270
+ }
4271
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
4272
+ dependencies.write("acryl-tui: stdin and stdout must both be TTYs; use `acryl tui --json` for a headless probe");
4273
+ dependencies.exit(1);
4274
+ return;
4275
+ }
4276
+ const result = await dependencies.runTui({
4277
+ profile: invocation.profile ?? "acryl",
4278
+ resumeSessionId: invocation.resumeSessionId
4279
+ });
4280
+ dependencies.write(`resume with: acryl tui --resume ${result.resumeHint}`);
4281
+ }
4282
+ //#endregion
4283
+ //#region src/bin.ts
4284
+ function isEntrypoint() {
4285
+ const entrypoint = process.argv[1];
4286
+ return entrypoint !== void 0 && resolve(entrypoint) === fileURLToPath(import.meta.url);
4287
+ }
4288
+ if (isEntrypoint()) (async () => {
4289
+ const script = process.argv[1];
4290
+ if (script === void 0) throw new Error("ACRYL Node entrypoint is unavailable");
4291
+ if (await relaunchWithExposedInternals({
4292
+ execArgv: process.execArgv,
4293
+ script,
4294
+ args: process.argv.slice(2)
4295
+ })) return;
4296
+ await runAcryl(process.argv.slice(2));
4297
+ })().catch((cause) => {
4298
+ const message = cause instanceof Error ? cause.message : String(cause);
4299
+ process.stderr.write(`acryl: ${message}\n`);
4300
+ process.exitCode = 1;
4301
+ });
4302
+ //#endregion
4303
+ export { parseAcrylArgs, runAcryl };