dsh-code 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +17 -4
  2. package/README.zh.md +17 -4
  3. package/cordis.patch.yml +22 -5
  4. package/lib/index.mjs +1888 -100
  5. package/lib/invariant.mjs +1 -1
  6. package/lib/startup.mjs +70 -0
  7. package/lib/types/app.d.ts +64 -9
  8. package/lib/types/approval.d.ts +57 -0
  9. package/lib/types/commands.d.ts +37 -0
  10. package/lib/types/index.d.ts +19 -7
  11. package/lib/types/invariant.d.ts +2 -2
  12. package/lib/types/mentions.d.ts +70 -0
  13. package/lib/types/models.d.ts +37 -0
  14. package/lib/types/questions.d.ts +48 -0
  15. package/lib/types/render/animations.d.ts +15 -0
  16. package/lib/types/render/markdown.d.ts +27 -0
  17. package/lib/types/render/projection.d.ts +37 -3
  18. package/lib/types/render/status.d.ts +4 -0
  19. package/lib/types/render/text.d.ts +18 -0
  20. package/lib/types/render/tool-preview.d.ts +15 -0
  21. package/lib/types/skills.d.ts +45 -0
  22. package/lib/types/startup.d.ts +44 -0
  23. package/lib/types/store.d.ts +8 -2
  24. package/lib/types/theme.d.ts +4 -0
  25. package/package.json +36 -3
  26. package/src/app.ts +971 -57
  27. package/src/approval.ts +126 -0
  28. package/src/commands.ts +71 -0
  29. package/src/index.ts +353 -40
  30. package/src/invariant.ts +3 -3
  31. package/src/mentions.ts +193 -0
  32. package/src/models.ts +66 -0
  33. package/src/questions.ts +143 -0
  34. package/src/render/animations.ts +22 -0
  35. package/src/render/markdown.ts +235 -0
  36. package/src/render/projection.ts +117 -10
  37. package/src/render/status.ts +14 -2
  38. package/src/render/text.ts +24 -0
  39. package/src/render/tool-preview.ts +34 -0
  40. package/src/skills.ts +104 -0
  41. package/src/startup.ts +91 -0
  42. package/src/store.ts +10 -4
  43. package/src/theme.ts +4 -0
package/lib/index.mjs CHANGED
@@ -1,12 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { basename, join } from "node:path";
4
- import { createElement, useState, useSyncExternalStore } from "react";
4
+ import { createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
+ import z from "@deepseek-ai/schemastery";
5
6
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
6
7
  import { assertNever, boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
7
8
  import { SessionId } from "@deepseek-ai/dsh-session";
8
- import { Box, Text, render, useInput } from "ink";
9
+ import { Box, Text, render, useInput, useStdout } from "ink";
9
10
  import chalk from "chalk";
11
+ import { readdir } from "node:fs/promises";
12
+ import { formatSessionReferenceMention, parseSessionReferenceText } from "@deepseek-ai/dsh-session-reference";
13
+ import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
14
+ import { isUserInvocable } from "@deepseek-ai/dsh-skill";
10
15
  //#region src/theme.ts
11
16
  /**
12
17
  * Terminal color tokens for the dsh TUI, mapped from the product design
@@ -62,6 +67,18 @@ const TUI_RGB = {
62
67
  245,
63
68
  158,
64
69
  11
70
+ ],
71
+ /** Default foreground text — `--dsw-static-neutral-50`. */
72
+ text: [
73
+ 236,
74
+ 240,
75
+ 246
76
+ ],
77
+ /** Inline/fenced code — soft sky blue, distinct from brand accents. */
78
+ code: [
79
+ 125,
80
+ 211,
81
+ 252
65
82
  ]
66
83
  };
67
84
  /** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
@@ -76,6 +93,10 @@ function dim(text) {
76
93
  function error(text) {
77
94
  return chalk.rgb(...TUI_RGB.error)(text);
78
95
  }
96
+ /** Paint warnings. */
97
+ function warn(text) {
98
+ return chalk.rgb(...TUI_RGB.warn)(text);
99
+ }
79
100
  //#endregion
80
101
  //#region src/whale-glyph.ts
81
102
  /** Half-block whale glyph rows; render with the brand color. */
@@ -90,6 +111,258 @@ const WHALE_GLYPH = [
90
111
  " ▀▀███████▀▀ ▀▀▀ "
91
112
  ];
92
113
  //#endregion
114
+ //#region src/render/markdown.ts
115
+ /** Plain segment helper. */
116
+ function seg(text, style = "plain") {
117
+ return {
118
+ text,
119
+ style
120
+ };
121
+ }
122
+ /** Visible width of a run in columns (CJK counts double). */
123
+ function visibleColumns(text) {
124
+ let columns = 0;
125
+ for (const char of text) {
126
+ const code = char.codePointAt(0) ?? 0;
127
+ columns += code > 11903 ? 2 : 1;
128
+ }
129
+ return columns;
130
+ }
131
+ /** Walk a segment list, breaking it into lines that fit `width` columns. */
132
+ function wrapSegments(segments, width) {
133
+ const lines = [];
134
+ let current = [];
135
+ let used = 0;
136
+ for (const segment of segments) {
137
+ const words = segment.text.split(/( )/u);
138
+ for (const word of words) {
139
+ if (word === "") continue;
140
+ const columns = visibleColumns(word);
141
+ if (used + columns > width && used > 0) {
142
+ lines.push(current);
143
+ current = [];
144
+ used = 0;
145
+ }
146
+ current.push({
147
+ text: word,
148
+ style: segment.style
149
+ });
150
+ used += columns;
151
+ }
152
+ }
153
+ if (current.length > 0) lines.push(current);
154
+ return lines.map((line) => {
155
+ const last = line[line.length - 1];
156
+ if (last !== void 0 && last.text === " " && line.length > 1) return line.slice(0, -1);
157
+ return line;
158
+ });
159
+ }
160
+ /** Join adjacent same-style runs so the app renders fewer elements. */
161
+ function merge(segments) {
162
+ const merged = [];
163
+ for (const segment of segments) {
164
+ const last = merged[merged.length - 1];
165
+ if (last !== void 0 && last.style === segment.style) merged[merged.length - 1] = {
166
+ text: last.text + segment.text,
167
+ style: last.style
168
+ };
169
+ else merged.push({ ...segment });
170
+ }
171
+ return merged;
172
+ }
173
+ /**
174
+ * Parse inline markdown in one line of text. Link destinations render as a
175
+ * dim `(url)` suffix — the visible text keeps the accent.
176
+ */
177
+ function parseInline(text) {
178
+ const runs = [];
179
+ let rest = text;
180
+ while (rest !== "") {
181
+ const code = /^`([^`]+)`/u.exec(rest);
182
+ if (code !== null) {
183
+ runs.push({
184
+ text: code[1] ?? "",
185
+ style: "code"
186
+ });
187
+ rest = rest.slice(code[0].length);
188
+ continue;
189
+ }
190
+ const boldItalic = /^\*\*\*([^*]+)\*\*\*/u.exec(rest);
191
+ if (boldItalic !== null) {
192
+ runs.push({
193
+ text: boldItalic[1] ?? "",
194
+ style: "boldItalic"
195
+ });
196
+ rest = rest.slice(boldItalic[0].length);
197
+ continue;
198
+ }
199
+ const bold = /^\*\*([^*]+)\*\*/u.exec(rest);
200
+ if (bold !== null) {
201
+ runs.push({
202
+ text: bold[1] ?? "",
203
+ style: "bold"
204
+ });
205
+ rest = rest.slice(bold[0].length);
206
+ continue;
207
+ }
208
+ const italic = /^\*([^*]+)\*/u.exec(rest) ?? /^_([^_]+)_/u.exec(rest);
209
+ if (italic !== null) {
210
+ runs.push({
211
+ text: italic[1] ?? "",
212
+ style: "italic"
213
+ });
214
+ rest = rest.slice(italic[0].length);
215
+ continue;
216
+ }
217
+ const strike = /^~~([^~]+)~~/u.exec(rest);
218
+ if (strike !== null) {
219
+ runs.push({
220
+ text: strike[1] ?? "",
221
+ style: "strike"
222
+ });
223
+ rest = rest.slice(strike[0].length);
224
+ continue;
225
+ }
226
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)/u.exec(rest);
227
+ if (link !== null) {
228
+ const label = link[1] ?? "";
229
+ const url = link[2] ?? "";
230
+ runs.push({
231
+ text: label,
232
+ style: "accent"
233
+ });
234
+ runs.push({
235
+ text: ` (${url})`,
236
+ style: "dim"
237
+ });
238
+ rest = rest.slice(link[0].length);
239
+ continue;
240
+ }
241
+ const next = rest.search(/[*_`~[]/u);
242
+ if (next === -1) {
243
+ runs.push({
244
+ text: rest,
245
+ style: "plain"
246
+ });
247
+ break;
248
+ }
249
+ if (next > 0) {
250
+ runs.push({
251
+ text: rest.slice(0, next),
252
+ style: "plain"
253
+ });
254
+ rest = rest.slice(next);
255
+ continue;
256
+ }
257
+ runs.push({
258
+ text: rest.slice(0, 1),
259
+ style: "plain"
260
+ });
261
+ rest = rest.slice(1);
262
+ }
263
+ return runs;
264
+ }
265
+ const HEADING = /^(#{1,6})\s+(.*)$/u;
266
+ const FENCE = /^```([^\s`]*)\s*$/u;
267
+ const RULE = /^(?:---|\*\*\*|___)\s*$/u;
268
+ const QUOTE = /^>\s?(.*)$/u;
269
+ const UNORDERED = /^\s*[-*+]\s+(.*)$/u;
270
+ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u;
271
+ /** Render markdown text into styled lines of at most `width` columns. */
272
+ function renderMarkdown(text, width) {
273
+ const lines = [];
274
+ const push = (segments) => {
275
+ for (const wrapped of wrapSegments(segments, Math.max(10, width))) lines.push({ segments: merge(wrapped) });
276
+ };
277
+ const source = text.replaceAll("\r", "").split("\n");
278
+ let index = 0;
279
+ while (index < source.length) {
280
+ const line = source[index] ?? "";
281
+ index += 1;
282
+ const fence = FENCE.exec(line);
283
+ if (fence !== null) {
284
+ const language = fence[1] ?? "";
285
+ if (language !== "") push([seg(` ${language}`, "dim")]);
286
+ while (index < source.length && !FENCE.test(source[index] ?? "")) {
287
+ push([seg(` ${source[index] ?? ""}`, "code")]);
288
+ index += 1;
289
+ }
290
+ index += 1;
291
+ continue;
292
+ }
293
+ if (line.trim() === "") continue;
294
+ if (RULE.test(line.trim())) {
295
+ push([seg(` ${"─".repeat(Math.max(1, Math.floor(width / 4)))}`, "dim")]);
296
+ continue;
297
+ }
298
+ const heading = HEADING.exec(line);
299
+ if (heading !== null) {
300
+ push([seg(heading[2] ?? "", "accent")]);
301
+ continue;
302
+ }
303
+ const quote = QUOTE.exec(line);
304
+ if (quote !== null) {
305
+ push([seg(" │ ", "accent"), ...parseInline(quote[1] ?? "").map((run) => seg(run.text, run.style === "plain" ? "dim" : run.style))]);
306
+ continue;
307
+ }
308
+ const ordered = ORDERED.exec(line);
309
+ if (ordered !== null) {
310
+ push([seg(` ${ordered[1] ?? ""}. `, "accent"), ...parseInline(ordered[2] ?? "").map((run) => seg(run.text, run.style))]);
311
+ continue;
312
+ }
313
+ const unordered = UNORDERED.exec(line);
314
+ if (unordered !== null) {
315
+ push([seg(" • ", "accent"), ...parseInline(unordered[1] ?? "").map((run) => seg(run.text, run.style))]);
316
+ continue;
317
+ }
318
+ const paragraph = [line];
319
+ while (index < source.length && (source[index] ?? "").trim() !== "") {
320
+ paragraph.push(source[index] ?? "");
321
+ index += 1;
322
+ }
323
+ const runs = [];
324
+ for (let at = 0; at < paragraph.length; at += 1) {
325
+ if (at > 0) runs.push({
326
+ text: " ",
327
+ style: "plain"
328
+ });
329
+ runs.push(...parseInline(paragraph[at] ?? ""));
330
+ }
331
+ push(runs.map((run) => seg(run.text, run.style)));
332
+ }
333
+ return lines;
334
+ }
335
+ //#endregion
336
+ //#region src/render/animations.ts
337
+ /**
338
+ * Terminal animation frame tables derived from the web design language:
339
+ * the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
340
+ * steps, 1s cycle) becomes the single-cell stepped pulse below, and the
341
+ * streaming caret blink is the Claude-Code convention. Pure functions only —
342
+ * the Ink layer owns timers and colors.
343
+ *
344
+ * @module @deepseek-ai/dsh-code/render/animations
345
+ */
346
+ /** Single-cell stepped pulse: flat holds mirroring the web's 125ms keyframes. */
347
+ const PULSE_FRAMES = [
348
+ "█",
349
+ "█",
350
+ "▆",
351
+ "▃",
352
+ "▁",
353
+ "▃",
354
+ "▆",
355
+ "█"
356
+ ];
357
+ /** Pulse frame for a monotonic tick. */
358
+ function pulseFrame(tick) {
359
+ return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0];
360
+ }
361
+ /** Caret visibility: half the ticks on, half off (530ms blink). */
362
+ function caretVisible(tick) {
363
+ return tick % 2 === 0;
364
+ }
365
+ //#endregion
93
366
  //#region src/render/status.ts
94
367
  /**
95
368
  * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
@@ -133,7 +406,8 @@ function buildStatusGroups(facts, stats) {
133
406
  const identity = [
134
407
  facts.model,
135
408
  facts.cwd,
136
- facts.branch === "" ? void 0 : `⑂ ${facts.branch}`
409
+ facts.branch === "" ? void 0 : `⑂ ${facts.branch}`,
410
+ facts.plan ? "⧉ plan" : void 0
137
411
  ].filter((part) => part !== void 0 && part !== "");
138
412
  if (identity.length > 0) groups.push(identity.join(" · "));
139
413
  if (stats.turns > 0 || stats.steps > 0) {
@@ -149,42 +423,199 @@ function buildStatusGroups(facts, stats) {
149
423
  groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`);
150
424
  }
151
425
  if (facts.sessionId !== "") groups.push(facts.sessionId);
426
+ if (facts.permission !== void 0 && facts.permission !== "") groups.push(facts.permission);
152
427
  return groups;
153
428
  }
154
429
  //#endregion
430
+ //#region src/render/text.ts
431
+ /**
432
+ * Display-boundary sanitization for externally sourced text (model output,
433
+ * tool payloads, skill descriptions). Control characters — including ANSI
434
+ * CSI/OSC escape sequences — would otherwise pass through Ink into the
435
+ * terminal, letting output rewrite the screen or inject prompts. Newlines
436
+ * and tabs survive; everything else in C0/C1 plus DEL becomes a visible
437
+ * `\xNN` escape.
438
+ *
439
+ * @module @deepseek-ai/dsh-code/render/text
440
+ */
441
+ /** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
442
+ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
443
+ /**
444
+ * Escape control characters so externally sourced text cannot drive the
445
+ * terminal.
446
+ * @param text - raw text from a session event, tool payload, or catalog.
447
+ * @returns text with every control character (except `\n`, `\t`) rendered
448
+ * as a literal `\xNN` escape.
449
+ */
450
+ function displayText(text) {
451
+ return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
452
+ }
453
+ //#endregion
155
454
  //#region src/app.ts
156
455
  /**
157
456
  * The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
158
- * transcript, the streaming line, local notices, and the input box. All state
159
- * arrives through the transcript store (derived from the durable session log)
160
- * plus local input state; the app owns no session mutation of its own.
457
+ * transcript, the todo panel, the streaming line, the approval bar, the model
458
+ * panel, local notices, and the input box with history and slash-command
459
+ * completion. All state arrives through the transcript store (derived from
460
+ * the durable session log) plus local input state; the app owns no session
461
+ * mutation of its own.
161
462
  *
162
463
  * Element construction uses `createElement` (not JSX): the `dsh` source launch
163
464
  * compiles this file through tsx's ESM-only hook, which does not adopt this
164
465
  * package's `jsx: react-jsx` compiler option, and the classic JSX runtime
165
466
  * would demand a React global.
166
467
  *
167
- * @module @deepseek-ai/dsh-tui/app
468
+ * @module @deepseek-ai/dsh-code/app
168
469
  */
169
470
  /** Ink `color` string for one palette triple. */
170
471
  function inkColor(triple) {
171
472
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`;
172
473
  }
474
+ /** Truncate text to a visible-column budget, appending … when cut. */
475
+ function truncateColumns(text, max) {
476
+ let columns = 0;
477
+ let out = "";
478
+ for (const char of text) {
479
+ const width = (char.codePointAt(0) ?? 0) > 11903 ? 2 : 1;
480
+ if (columns + width > max) return `${out}…`;
481
+ out += char;
482
+ columns += width;
483
+ }
484
+ return out;
485
+ }
486
+ /** Pad text with spaces to a visible-column target (menu name column). */
487
+ function padColumns(text, width) {
488
+ return text + " ".repeat(Math.max(0, width - visibleColumns(text)));
489
+ }
490
+ /** Interval-driven frame counter for one self-contained animated leaf. */
491
+ function useFrames(intervalMs) {
492
+ const [tick, setTick] = useState(0);
493
+ useEffect(() => {
494
+ const id = setInterval(() => setTick((current) => current + 1), intervalMs);
495
+ return () => {
496
+ clearInterval(id);
497
+ };
498
+ }, [intervalMs]);
499
+ return tick;
500
+ }
501
+ /** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
502
+ function Pulse() {
503
+ const tick = useFrames(125);
504
+ return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick));
505
+ }
506
+ /** Blinking block caret appended to streaming text. */
507
+ function Caret() {
508
+ const tick = useFrames(530);
509
+ return createElement(Text, null, caretVisible(tick) ? "▍" : " ");
510
+ }
511
+ /** Blinking input cursor: inverse block while the caret phase is on. */
512
+ function CursorBlock({ char }) {
513
+ const tick = useFrames(530);
514
+ return createElement(Text, { inverse: caretVisible(tick) || void 0 }, char);
515
+ }
516
+ /** Ink props for one markdown style class. */
517
+ function segmentProps(style) {
518
+ switch (style) {
519
+ case "accent": return {
520
+ color: inkColor(TUI_RGB.brandBright),
521
+ bold: void 0,
522
+ italic: void 0,
523
+ strikethrough: void 0
524
+ };
525
+ case "code": return {
526
+ color: inkColor(TUI_RGB.code),
527
+ bold: void 0,
528
+ italic: void 0,
529
+ strikethrough: void 0
530
+ };
531
+ case "dim": return {
532
+ color: inkColor(TUI_RGB.dim),
533
+ bold: void 0,
534
+ italic: void 0,
535
+ strikethrough: void 0
536
+ };
537
+ case "bold": return {
538
+ color: void 0,
539
+ bold: true,
540
+ italic: void 0,
541
+ strikethrough: void 0
542
+ };
543
+ case "italic": return {
544
+ color: void 0,
545
+ bold: void 0,
546
+ italic: true,
547
+ strikethrough: void 0
548
+ };
549
+ case "boldItalic": return {
550
+ color: void 0,
551
+ bold: true,
552
+ italic: true,
553
+ strikethrough: void 0
554
+ };
555
+ case "strike": return {
556
+ color: inkColor(TUI_RGB.dim),
557
+ bold: void 0,
558
+ italic: void 0,
559
+ strikethrough: true
560
+ };
561
+ default: return {
562
+ color: void 0,
563
+ bold: void 0,
564
+ italic: void 0,
565
+ strikethrough: void 0
566
+ };
567
+ }
568
+ }
569
+ /** One settled markdown document rendered as styled lines at the terminal width. */
570
+ function MarkdownBody({ text }) {
571
+ const columns = useStdout().stdout?.columns ?? 80;
572
+ const lines = useMemo(() => renderMarkdown(displayText(text), Math.max(20, columns - 2)), [text, columns]);
573
+ return createElement(Box, { flexDirection: "column" }, ...lines.map((line, index) => createElement(Text, { key: index }, ...line.segments.map((segment, at) => createElement(Text, {
574
+ key: at,
575
+ ...segmentProps(segment.style)
576
+ }, segment.text)))));
577
+ }
173
578
  /** One settled transcript row. */
174
- function EntryLine({ entry }) {
579
+ function EntryLine({ entry, showReasoning }) {
175
580
  switch (entry.kind) {
176
- case "user": return createElement(Text, null, brand("❯ "), entry.text);
177
- case "assistant": return createElement(Text, null, entry.text);
581
+ case "user": return entry.notice ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`) : createElement(Text, null, brand("❯ "), displayText(entry.text));
582
+ case "assistant": return createElement(Box, { flexDirection: "column" }, entry.reasoning === "" ? void 0 : showReasoning ? createElement(Text, {
583
+ dimColor: true,
584
+ italic: true
585
+ }, ` ✻ ${displayText(entry.reasoning)}`) : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`), createElement(MarkdownBody, { text: entry.text }));
178
586
  case "tool": {
179
- const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
180
- return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(entry.summary)}`);
587
+ const mark = entry.state === "running" ? createElement(Pulse) : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
588
+ return createElement(Box, { flexDirection: "column" }, createElement(Text, null, mark, " ", brand(entry.name), entry.preview === "" ? "" : ` ${dim(displayText(entry.preview))}`), entry.summary === "" ? void 0 : createElement(Text, { color: entry.state === "error" ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim) }, ` ⎿ ${displayText(entry.summary)}`));
589
+ }
590
+ case "command": {
591
+ const mark = entry.state === "running" ? createElement(Pulse) : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
592
+ return createElement(Box, { flexDirection: "column" }, createElement(Text, null, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`), entry.summary === "" ? void 0 : createElement(Text, { color: inkColor(TUI_RGB.dim) }, ` ⎿ ${displayText(entry.summary)}`));
181
593
  }
182
- case "error": return createElement(Text, null, error(entry.text));
594
+ case "error": return createElement(Text, null, error(displayText(entry.text)));
183
595
  default: return assertNever(entry, "transcript entry kind");
184
596
  }
185
597
  }
186
- /** The whale wordmark header in DeepSeek blue, hugging its content width. */
187
- function Header() {
598
+ /**
599
+ * The whale wordmark header in DeepSeek blue, hugging its content width.
600
+ * The 8-row half-block glyph pairs adjacent lines, so on a terminal too
601
+ * short to show it whole (or mid-resize) the clipped pairs garble the
602
+ * screen — below the height floor the header collapses to a single-line
603
+ * wordmark that stays correct at any size.
604
+ */
605
+ function Header({ resumed }) {
606
+ const rows = useStdout().stdout?.rows ?? 40;
607
+ const hint = resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit";
608
+ if (rows < 20) return createElement(Box, {
609
+ flexDirection: "row",
610
+ gap: 1,
611
+ borderStyle: "round",
612
+ borderColor: inkColor(TUI_RGB.brand),
613
+ paddingX: 1,
614
+ alignSelf: "flex-start"
615
+ }, createElement(Text, {
616
+ color: inkColor(TUI_RGB.brand),
617
+ bold: true
618
+ }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, hint));
188
619
  return createElement(Box, {
189
620
  flexDirection: "row",
190
621
  gap: 1,
@@ -204,7 +635,33 @@ function Header() {
204
635
  }, createElement(Text, {
205
636
  color: inkColor(TUI_RGB.brand),
206
637
  bold: true
207
- }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, "/help commands · Ctrl+C quit")));
638
+ }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, hint)));
639
+ }
640
+ /** Todo status glyph: web TodoPanel's three-state marker. */
641
+ function todoMark(status) {
642
+ return status === "completed" ? "✓" : status === "in_progress" ? "●" : "○";
643
+ }
644
+ /** Inline todo list (web TodoPanel's compact terminal form). */
645
+ function TodoPanel({ todos }) {
646
+ if (todos.length === 0) return void 0;
647
+ const completed = todos.filter((todo) => todo.status === "completed").length;
648
+ const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
649
+ const pending = todos.length - completed - inProgress;
650
+ return createElement(Box, {
651
+ flexDirection: "column",
652
+ paddingX: 1,
653
+ borderStyle: "round",
654
+ borderColor: inkColor(TUI_RGB.brandDeep),
655
+ alignSelf: "flex-start",
656
+ marginLeft: 1,
657
+ marginTop: 1
658
+ }, createElement(Text, {
659
+ color: inkColor(TUI_RGB.brand),
660
+ bold: true
661
+ }, `todos ${completed}/${todos.length}`, createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`)), ...todos.map((todo, index) => createElement(Text, {
662
+ key: index,
663
+ color: todo.status === "completed" ? inkColor(TUI_RGB.success) : todo.status === "in_progress" ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
664
+ }, `${todoMark(todo.status)} ${displayText(todo.content)}`)));
208
665
  }
209
666
  /**
210
667
  * The footer status line: Claude-Code-style identity facts (model, working
@@ -214,80 +671,753 @@ function Header() {
214
671
  */
215
672
  function StatusLine({ facts, stats, busy }) {
216
673
  const groups = buildStatusGroups(facts, stats);
217
- const children = [busy ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "") : createElement(Text, { color: inkColor(TUI_RGB.brand) }, " ")];
674
+ const children = [busy ? createElement(Pulse) : createElement(Text, { color: inkColor(TUI_RGB.brand) }, ""), createElement(Text, null, " ")];
218
675
  groups.forEach((group, index) => {
219
676
  if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(" | ")));
220
677
  children.push(createElement(Text, { dimColor: true }, group));
221
678
  });
222
- return createElement(Box, { paddingX: 1 }, ...children);
679
+ return createElement(Box, {
680
+ paddingX: 1,
681
+ marginTop: 1
682
+ }, ...children);
683
+ }
684
+ /** The y/n approval bar rendered while an approval ask is pending. */
685
+ function ApprovalBar({ approval, locked }) {
686
+ const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot);
687
+ useInput((input) => {
688
+ if (locked || snapshot.pending === void 0 || snapshot.answered) return;
689
+ if (input === "y" || input === "Y") {
690
+ snapshot.pending.answer("allowed-once");
691
+ return;
692
+ }
693
+ if (input === "n" || input === "N") snapshot.pending.answer("rejected");
694
+ });
695
+ if (snapshot.pending === void 0) return void 0;
696
+ const { pending, answered } = snapshot;
697
+ return createElement(Box, {
698
+ flexDirection: "column",
699
+ paddingX: 1,
700
+ borderStyle: "round",
701
+ borderColor: inkColor(TUI_RGB.warn),
702
+ alignSelf: "flex-start",
703
+ marginLeft: 1,
704
+ marginTop: 1
705
+ }, createElement(Text, {
706
+ color: inkColor(TUI_RGB.warn),
707
+ bold: true
708
+ }, "⏸ waiting for approval"), createElement(Text, null, warn(displayText(pending.headline))), pending.command === "" ? void 0 : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)), answered ? createElement(Text, { dimColor: true }, " submitted…") : createElement(Text, { dimColor: true }, dim(" y allow once · n reject")));
223
709
  }
224
- /** The prompt box: slash commands handled locally, other text submitted. */
225
- function Input({ busy, onSubmit, onQuit }) {
710
+ /**
711
+ * The ask_user_question bar: walks one request question by question,
712
+ * renders the option menu (Claude-Code style: arrows move, space toggles a
713
+ * multi-select, enter submits, `c` opens the custom-answer box, Esc
714
+ * interrupts the question as aborted). Plan reviews arrive through the same
715
+ * service with a `plan-review` intent — the approve option gets a ✓ mark,
716
+ * the answer encoding stays identical.
717
+ */
718
+ function QuestionBar({ store, locked }) {
719
+ const pending = useSyncExternalStore(store.subscribe, store.getSnapshot).pending;
720
+ const [index, setIndex] = useState(0);
721
+ const [cursor, setCursor] = useState(0);
722
+ const [selected, setSelected] = useState([]);
723
+ const [mode, setMode] = useState("options");
724
+ const [custom, setCustom] = useState("");
725
+ const [answers, setAnswers] = useState([]);
726
+ const [submitted, setSubmitted] = useState(false);
727
+ useEffect(() => {
728
+ const question = pending?.request.questions[0];
729
+ setIndex(0);
730
+ setCursor(0);
731
+ setSelected([]);
732
+ setMode(question?.options === void 0 || question.options.length === 0 ? "custom" : "options");
733
+ setCustom("");
734
+ setAnswers([]);
735
+ setSubmitted(false);
736
+ }, [pending]);
737
+ const question = pending?.request.questions[index];
738
+ const options = question?.options ?? [];
739
+ const isPlan = question?.intent?.kind === "plan-review";
740
+ const isMulti = question?.multiSelect === true;
741
+ const commit = (answer) => {
742
+ if (pending === void 0) return;
743
+ const next = [...answers, answer];
744
+ const total = pending.request.questions.length;
745
+ if (index + 1 >= total) {
746
+ setSubmitted(true);
747
+ store.submit(pending, { answers: next });
748
+ return;
749
+ }
750
+ setAnswers(next);
751
+ setIndex(index + 1);
752
+ setCursor(0);
753
+ setSelected([]);
754
+ setMode("options");
755
+ setCustom("");
756
+ };
757
+ const commitOption = () => {
758
+ if (pending === void 0 || question === void 0) return;
759
+ if (isMulti) {
760
+ const labels = selected.map((at) => options[at]?.label).filter((label) => label !== void 0);
761
+ const customText = custom.trim();
762
+ commit({
763
+ id: question.id,
764
+ selected: labels,
765
+ ...customText === "" ? {} : { custom: customText }
766
+ });
767
+ return;
768
+ }
769
+ const option = options[cursor];
770
+ if (option === void 0) return;
771
+ commit({
772
+ id: question.id,
773
+ selected: [option.label]
774
+ });
775
+ };
776
+ useInput((input, key) => {
777
+ if (locked || pending === void 0 || question === void 0 || submitted) return;
778
+ if (key.escape) {
779
+ store.cancel(pending);
780
+ return;
781
+ }
782
+ if (mode === "custom" || options.length === 0) {
783
+ if (key.return) {
784
+ if (custom.trim() === "" && options.length > 0) {
785
+ commitOption();
786
+ return;
787
+ }
788
+ commit({
789
+ id: question.id,
790
+ selected: isMulti ? selected.map((at) => options[at]?.label).filter((label) => label !== void 0) : [],
791
+ ...custom.trim() === "" ? {} : { custom: custom.trim() }
792
+ });
793
+ return;
794
+ }
795
+ if (key.backspace) {
796
+ setCustom((current) => current.slice(0, -1));
797
+ return;
798
+ }
799
+ if (input !== "" && !key.ctrl && !key.meta) setCustom((current) => current + input);
800
+ return;
801
+ }
802
+ if (key.upArrow) {
803
+ setCursor((current) => (current + options.length - 1) % options.length);
804
+ return;
805
+ }
806
+ if (key.downArrow) {
807
+ setCursor((current) => (current + 1) % options.length);
808
+ return;
809
+ }
810
+ if (key.return) {
811
+ commitOption();
812
+ return;
813
+ }
814
+ if (key.tab || input === "c" || input === "C") {
815
+ setMode("custom");
816
+ return;
817
+ }
818
+ if (input === " " && isMulti) setSelected((current) => current.includes(cursor) ? current.filter((at) => at !== cursor) : [...current, cursor]);
819
+ });
820
+ if (pending === void 0 || question === void 0) return void 0;
821
+ return createElement(Box, {
822
+ flexDirection: "column",
823
+ paddingX: 1,
824
+ borderStyle: "round",
825
+ borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep),
826
+ alignSelf: "flex-start",
827
+ marginLeft: 1,
828
+ marginTop: 1
829
+ }, createElement(Text, {
830
+ color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep),
831
+ bold: true
832
+ }, isPlan ? `📋 plan review (${index + 1}/${pending.request.questions.length})` : `❓ question ${index + 1}/${pending.request.questions.length}`), question.header === void 0 ? void 0 : createElement(Text, { bold: true }, displayText(question.header)), createElement(Text, null, displayText(question.question)), question.detail === void 0 ? void 0 : isPlan ? createElement(MarkdownBody, { text: question.detail }) : createElement(Text, { dimColor: true }, displayText(question.detail)), submitted ? createElement(Text, { dimColor: true }, " submitted…") : createElement(Box, {
833
+ flexDirection: "column",
834
+ marginLeft: 1
835
+ }, ...mode === "custom" || options.length === 0 ? [createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` custom: ${custom}${submitted ? "" : "▌"}`), createElement(Text, { dimColor: true }, dim(" type your answer · enter submit · esc interrupt"))] : options.map((option, at) => {
836
+ const chosen = isMulti && selected.includes(at);
837
+ const approve = isPlan && question.intent?.approve === option.label;
838
+ const mark = approve ? "✓ " : chosen ? "◉ " : at === cursor ? "❯ " : " ";
839
+ return createElement(Text, {
840
+ key: at,
841
+ color: at === cursor ? inkColor(TUI_RGB.brandBright) : chosen || approve ? inkColor(TUI_RGB.success) : inkColor(TUI_RGB.text)
842
+ }, `${mark}${displayText(option.label)}${option.description === void 0 ? "" : dim(` — ${displayText(option.description)}`)}`);
843
+ }), createElement(Text, { dimColor: true }, dim(isMulti ? " ↑↓ move · space toggle · enter submit · c custom · esc interrupt" : " ↑↓ move · enter submit · c custom · esc interrupt"))));
844
+ }
845
+ /** The /model panel: a scrolling list over the advisory model directory. */
846
+ function ModelPanel({ directory, error, onSelect, onClose }) {
847
+ const [cursor, setCursor] = useState(0);
848
+ useInput((input, key) => {
849
+ if (key.escape || input === "q") {
850
+ onClose();
851
+ return;
852
+ }
853
+ const rows = directory?.rows ?? [];
854
+ if (key.upArrow) {
855
+ setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
856
+ return;
857
+ }
858
+ if (key.downArrow) {
859
+ setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
860
+ return;
861
+ }
862
+ if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
863
+ });
864
+ const rows = directory?.rows ?? [];
865
+ const window = 8;
866
+ const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window));
867
+ const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window);
868
+ return createElement(Box, {
869
+ flexDirection: "column",
870
+ paddingX: 1,
871
+ borderStyle: "round",
872
+ borderColor: inkColor(TUI_RGB.brand),
873
+ alignSelf: "flex-start",
874
+ marginLeft: 1,
875
+ marginTop: 1
876
+ }, createElement(Text, {
877
+ color: inkColor(TUI_RGB.brand),
878
+ bold: true
879
+ }, "/model — select the model for the next step"), directory === void 0 && error === void 0 ? createElement(Text, { dimColor: true }, " loading models…") : void 0, error !== void 0 ? createElement(Text, { color: inkColor(TUI_RGB.error) }, ` ${error}`) : void 0, ...visible.map((row) => {
880
+ const index = rows.indexOf(row);
881
+ const label = displayText(`${row.providerName} · ${row.modelName}`);
882
+ return createElement(Text, {
883
+ key: `${row.provider}/${row.model}`,
884
+ color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
885
+ }, `${index === cursor ? "❯ " : " "}${label}`);
886
+ }), createElement(Text, { dimColor: true }, dim(" ↑↓ move · enter select · esc close")));
887
+ }
888
+ /**
889
+ * Resolve completion candidates for the current input: TUI-local commands,
890
+ * the live registry descriptors, and user-invocable skills, filtered by the
891
+ * typed prefix. Command names win collisions (the dispatch tries the
892
+ * registry first and only then falls through to the skill gesture).
893
+ */
894
+ function completionCandidates(value, descriptors, skills) {
895
+ if (!value.startsWith("/")) return [];
896
+ const prefix = value.slice(1).split(" ")[0] ?? "";
897
+ const local = [
898
+ {
899
+ label: "/help",
900
+ description: "show commands",
901
+ origin: "command"
902
+ },
903
+ {
904
+ label: "/model",
905
+ description: "switch the model",
906
+ origin: "command"
907
+ },
908
+ {
909
+ label: "/clear",
910
+ description: "clear the screen",
911
+ origin: "command"
912
+ },
913
+ {
914
+ label: "/quit",
915
+ description: "exit",
916
+ origin: "command"
917
+ }
918
+ ];
919
+ const localNames = new Set(local.map((candidate) => candidate.label.slice(1)));
920
+ const registry = descriptors.filter((descriptor) => !localNames.has(descriptor.name)).map((descriptor) => ({
921
+ label: `/${descriptor.name}`,
922
+ description: descriptor.description,
923
+ origin: "command"
924
+ }));
925
+ const taken = new Set([...local, ...registry].map((candidate) => candidate.label.slice(1)));
926
+ const skillRows = skills.filter((skill) => !taken.has(skill.name)).map((skill) => ({
927
+ label: `/${skill.name}`,
928
+ description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
929
+ origin: "skill"
930
+ }));
931
+ const all = [
932
+ ...local,
933
+ ...registry,
934
+ ...skillRows
935
+ ];
936
+ if (prefix === "") return all.slice(0, 10);
937
+ return all.filter((candidate) => candidate.label.slice(1).startsWith(prefix)).slice(0, 10);
938
+ }
939
+ /**
940
+ * The completion menu, rendered after the status line — the very last
941
+ * element in the tree. Being last in the layout flow, opening or closing it
942
+ * moves nothing above it: the transcript, input box, and status line all
943
+ * stay put (the Claude-Code dropdown treatment adapted to Ink, whose
944
+ * absolute positioning cannot place children above their parent).
945
+ */
946
+ function CompletionMenu({ state }) {
947
+ if (!state.active) return void 0;
948
+ const columns = useStdout().stdout?.columns ?? 80;
949
+ const nameWidth = Math.min(18, Math.max(0, ...state.rows.map((row) => visibleColumns(row.label))) + 2);
950
+ const descBudget = Math.max(24, columns - nameWidth - 8);
951
+ return createElement(Box, {
952
+ flexDirection: "column",
953
+ marginTop: 1,
954
+ marginLeft: 2
955
+ }, ...state.rows.length === 0 ? [createElement(Text, {
956
+ key: "loading",
957
+ dimColor: true
958
+ }, "searching…")] : state.rows.map((candidate, index) => createElement(Text, {
959
+ key: candidate.label,
960
+ color: index === state.index % state.rows.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
961
+ }, `${index === state.index % state.rows.length ? "❯ " : " "}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`)), createElement(Text, { dimColor: true }, dim(state.mention ? "↑↓ choose · tab insert" : "↑↓ choose · tab complete")));
962
+ }
963
+ /**
964
+ * The prompt box: TUI-local slash commands handled locally, other lines
965
+ * dispatched; input editing keeps a cursor with history and completion.
966
+ * While a modal (approval / question / model panel) owns the keys, the
967
+ * box passes every key through untouched.
968
+ */
969
+ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }) {
226
970
  const [value, setValue] = useState("");
227
- const [notices, setNotices] = useState([]);
971
+ const [cursor, setCursor] = useState(0);
972
+ const history = useRef([]);
973
+ const historyIndex = useRef(null);
974
+ const draft = useRef("");
975
+ const [completionIndex, setCompletionIndex] = useState(0);
976
+ const candidates = completionCandidates(value, descriptors, skills);
977
+ const slashActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
978
+ const beforeCursor = value.slice(0, cursor);
979
+ const lastLine = beforeCursor.split("\n").at(-1) ?? "";
980
+ const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine);
981
+ const mentionToken = tokenMatch === null ? void 0 : {
982
+ start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0),
983
+ query: tokenMatch[2] ?? ""
984
+ };
985
+ const mentionActive = mentionToken !== void 0;
986
+ const [mentionRows, setMentionRows] = useState([]);
987
+ useEffect(() => {
988
+ if (!mentionActive) {
989
+ setMentionRows([]);
990
+ return;
991
+ }
992
+ const controller = new AbortController();
993
+ setMentionRows([]);
994
+ loadMentions(mentionToken.query, controller.signal).then((rows) => setMentionRows(rows), () => {});
995
+ return () => {
996
+ controller.abort();
997
+ };
998
+ }, [mentionActive, mentionToken?.query]);
999
+ const menuActive = (slashActive || mentionActive) && !busy;
1000
+ const menuRows = mentionActive ? mentionRows.map((row) => ({
1001
+ label: row.label.startsWith("@") ? row.label : `@${row.label}${row.kind === "directory" ? "/" : ""}`,
1002
+ description: row.description,
1003
+ origin: "mention"
1004
+ })) : candidates;
1005
+ const menuStateKey = useRef("");
1006
+ useEffect(() => {
1007
+ const key = JSON.stringify([
1008
+ menuActive,
1009
+ mentionActive,
1010
+ completionIndex,
1011
+ menuRows.map((row) => row.label)
1012
+ ]);
1013
+ if (key === menuStateKey.current) return;
1014
+ menuStateKey.current = key;
1015
+ onMenuState({
1016
+ active: menuActive,
1017
+ mention: mentionActive,
1018
+ index: completionIndex,
1019
+ rows: menuRows
1020
+ });
1021
+ }, [
1022
+ menuActive,
1023
+ mentionActive,
1024
+ completionIndex,
1025
+ menuRows,
1026
+ onMenuState
1027
+ ]);
228
1028
  useInput((input, key) => {
229
- if (key.ctrl && (input === "c" || input === "d")) {
230
- onQuit();
1029
+ if (!active) return;
1030
+ if (key.tab && key.shift) {
1031
+ const next = cyclePermission();
1032
+ if (next !== "") notify(`permission → ${next}`);
1033
+ return;
1034
+ }
1035
+ if (key.ctrl && input === "r") {
1036
+ toggleReasoning();
1037
+ return;
1038
+ }
1039
+ if (key.ctrl && input === "c") {
1040
+ if (busy) interrupt();
1041
+ else if (value !== "") {
1042
+ setValue("");
1043
+ setCursor(0);
1044
+ setCompletionIndex(0);
1045
+ } else quit();
1046
+ return;
1047
+ }
1048
+ if (key.ctrl && input === "d") {
1049
+ if (busy) notify("cancel the running turn before exiting (Esc or Ctrl+C)");
1050
+ else quit();
1051
+ return;
1052
+ }
1053
+ if (key.escape) {
1054
+ if (busy) interrupt();
231
1055
  return;
232
1056
  }
233
1057
  if (key.return) {
1058
+ if (key.meta || key.ctrl && input === "j") {
1059
+ setValue(value.slice(0, cursor) + "\n" + value.slice(cursor));
1060
+ setCursor(cursor + 1);
1061
+ return;
1062
+ }
234
1063
  const text = value.trim();
235
1064
  setValue("");
1065
+ setCursor(0);
1066
+ setCompletionIndex(0);
236
1067
  if (text === "") return;
1068
+ history.current = [...history.current, text];
1069
+ historyIndex.current = null;
237
1070
  if (text === "/quit") {
238
- onQuit();
1071
+ quit();
239
1072
  return;
240
1073
  }
241
1074
  if (text === "/help") {
242
- setNotices([...notices, "/help show commands · /clear clear the screen · /quit exit"]);
1075
+ notify("/model switch · /clear clear the screen · /quit exit · Ctrl+R toggle thinking · Shift+Tab cycle permission · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn");
243
1076
  return;
244
1077
  }
245
1078
  if (text === "/clear") {
246
- setNotices([]);
247
1079
  console.clear();
248
1080
  return;
249
1081
  }
250
- if (busy) {
251
- setNotices([...notices, "the agent is working — wait for the turn to finish"]);
1082
+ if (text === "/model" || text.startsWith("/model ")) {
1083
+ openModel();
1084
+ return;
1085
+ }
1086
+ if (busy && !text.startsWith("/")) {
1087
+ steer(text);
252
1088
  return;
253
1089
  }
254
- onSubmit(text);
1090
+ dispatch(text);
1091
+ return;
1092
+ }
1093
+ if (menuActive && key.upArrow) {
1094
+ setCompletionIndex((index) => (index + menuRows.length - 1) % menuRows.length);
1095
+ return;
1096
+ }
1097
+ if (menuActive && key.downArrow) {
1098
+ setCompletionIndex((index) => (index + 1) % menuRows.length);
1099
+ return;
1100
+ }
1101
+ if (key.upArrow) {
1102
+ const entries = history.current;
1103
+ if (entries.length === 0) return;
1104
+ const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1);
1105
+ if (historyIndex.current === null) draft.current = value;
1106
+ historyIndex.current = next;
1107
+ setValue(entries[next] ?? "");
1108
+ setCursor((entries[next] ?? "").length);
1109
+ return;
1110
+ }
1111
+ if (key.downArrow) {
1112
+ const entries = history.current;
1113
+ if (historyIndex.current === null) return;
1114
+ const next = historyIndex.current + 1;
1115
+ if (next >= entries.length) {
1116
+ historyIndex.current = null;
1117
+ setValue(draft.current);
1118
+ setCursor(draft.current.length);
1119
+ return;
1120
+ }
1121
+ historyIndex.current = next;
1122
+ setValue(entries[next] ?? "");
1123
+ setCursor((entries[next] ?? "").length);
1124
+ return;
1125
+ }
1126
+ if (key.tab && menuActive) {
1127
+ if (mentionActive && mentionToken !== void 0) {
1128
+ const row = mentionRows[completionIndex % mentionRows.length];
1129
+ if (row !== void 0) {
1130
+ const insertion = row.label.startsWith("@") ? row.label : `@${row.label}${row.kind === "directory" ? "/" : ""}`;
1131
+ setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor));
1132
+ setCursor(mentionToken.start + insertion.length);
1133
+ }
1134
+ } else {
1135
+ const candidate = candidates[completionIndex % candidates.length];
1136
+ if (candidate !== void 0) {
1137
+ setValue(`${candidate.label} `);
1138
+ setCursor(candidate.label.length + 1);
1139
+ }
1140
+ }
1141
+ setCompletionIndex(0);
255
1142
  return;
256
1143
  }
257
1144
  if (key.backspace || key.delete) {
258
- setValue(value.slice(0, -1));
1145
+ if (cursor > 0) {
1146
+ setValue(value.slice(0, cursor - 1) + value.slice(cursor));
1147
+ setCursor(cursor - 1);
1148
+ setCompletionIndex(0);
1149
+ }
1150
+ return;
1151
+ }
1152
+ if (key.leftArrow) {
1153
+ setCursor(Math.max(0, cursor - 1));
1154
+ return;
1155
+ }
1156
+ if (key.rightArrow) {
1157
+ setCursor(Math.min(value.length, cursor + 1));
1158
+ return;
1159
+ }
1160
+ if (key.ctrl && input === "u") {
1161
+ setValue("");
1162
+ setCursor(0);
1163
+ return;
1164
+ }
1165
+ if (key.ctrl && input === "a") {
1166
+ setCursor(0);
259
1167
  return;
260
1168
  }
261
- if (input !== "") setValue(value + input);
1169
+ if (key.ctrl && input === "e") {
1170
+ setCursor(value.length);
1171
+ return;
1172
+ }
1173
+ if (input !== "" && !key.ctrl && !key.meta) {
1174
+ setValue(value.slice(0, cursor) + input + value.slice(cursor));
1175
+ setCursor(cursor + input.length);
1176
+ setCompletionIndex(0);
1177
+ }
262
1178
  });
263
- return createElement(Box, { flexDirection: "column" }, ...notices.map((notice, index) => createElement(Text, {
264
- key: index,
265
- dimColor: true
266
- }, notice)), createElement(Box, null, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), createElement(Text, null, value)));
1179
+ return createElement(Box, {
1180
+ flexDirection: "column",
1181
+ marginTop: 1
1182
+ }, busy && value === "" ? createElement(Text, { dimColor: true }, dim(" enter steers the running turn · esc or ctrl+c cancels")) : void 0, createElement(Box, {
1183
+ borderStyle: "round",
1184
+ borderColor: inkColor(TUI_RGB.dim),
1185
+ paddingX: 1
1186
+ }, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), value === "" ? void 0 : createElement(Text, null, value.slice(0, cursor)), createElement(CursorBlock, { char: value.slice(cursor, cursor + 1) === "" ? " " : value.slice(cursor, cursor + 1) }), value === "" && !busy ? createElement(Text, { dimColor: true }, "type a message · / commands · @ mentions") : createElement(Text, null, value.slice(cursor + 1))));
267
1187
  }
268
1188
  /** The whole terminal app; state arrives via the store, output via Ink. */
269
- function App({ store, model, cwd, branch, sessionId, onSubmit, onQuit }) {
270
- const view = useSyncExternalStore(store.subscribe, store.getView);
271
- return createElement(Box, { flexDirection: "column" }, createElement(Header), createElement(Box, {
1189
+ function App(props) {
1190
+ const view = useSyncExternalStore(props.store.subscribe, props.store.getView);
1191
+ const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors);
1192
+ const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows);
1193
+ const [modelLabel, setModelLabel] = useState(props.model);
1194
+ const [modelOpen, setModelOpen] = useState(false);
1195
+ const [directory, setDirectory] = useState(void 0);
1196
+ const [modelError, setModelError] = useState(void 0);
1197
+ const [notices, setNotices] = useState([]);
1198
+ const notify = (text) => {
1199
+ setNotices((current) => [...current, text]);
1200
+ };
1201
+ useEffect(() => {
1202
+ props.onBridgeReady({ notify });
1203
+ }, []);
1204
+ useEffect(() => {
1205
+ if (!modelOpen || directory !== void 0) return;
1206
+ let cancelled = false;
1207
+ setModelError(void 0);
1208
+ props.loadModels().then((loaded) => {
1209
+ if (!cancelled) setDirectory(loaded);
1210
+ }, (error) => {
1211
+ if (!cancelled) setModelError(error instanceof Error ? error.message : String(error));
1212
+ });
1213
+ return () => {
1214
+ cancelled = true;
1215
+ };
1216
+ }, [modelOpen]);
1217
+ const busy = view.busy;
1218
+ const [showReasoning, setShowReasoning] = useState(false);
1219
+ const [menuState, setMenuState] = useState({
1220
+ active: false,
1221
+ mention: false,
1222
+ index: 0,
1223
+ rows: []
1224
+ });
1225
+ const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot);
1226
+ const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot);
1227
+ const inputActive = !modelOpen && approvalSnapshot.pending === void 0 && questionSnapshot.pending === void 0;
1228
+ const questionPending = questionSnapshot.pending !== void 0;
1229
+ const transcriptRows = [];
1230
+ view.entries.forEach((entry, index) => {
1231
+ if (entry.kind === "user" && index > 0) transcriptRows.push(createElement(Text, { key: `gap-${index}` }, " "));
1232
+ transcriptRows.push(createElement(EntryLine, {
1233
+ key: index,
1234
+ entry,
1235
+ showReasoning
1236
+ }));
1237
+ });
1238
+ return createElement(Box, { flexDirection: "column" }, createElement(Header, { resumed: props.resumed }), createElement(Box, {
272
1239
  flexDirection: "column",
273
1240
  paddingX: 1
274
- }, ...view.entries.map((entry, index) => createElement(EntryLine, {
1241
+ }, ...transcriptRows, view.streamingReasoning !== "" ? createElement(Text, {
1242
+ dimColor: true,
1243
+ italic: true
1244
+ }, showReasoning ? ` ✻ ${displayText(view.streamingReasoning)}` : " ✻ Thinking…") : void 0, view.streaming !== "" ? createElement(Text, null, displayText(view.streaming), busy ? createElement(Caret) : void 0) : void 0, busy && view.streaming === "" && view.streamingReasoning === "" ? createElement(Text, { dimColor: true }, "Deep diving...") : void 0), createElement(TodoPanel, { todos: view.todos }), createElement(QuestionBar, {
1245
+ store: props.questions,
1246
+ locked: modelOpen
1247
+ }), createElement(ApprovalBar, {
1248
+ approval: props.approval,
1249
+ locked: modelOpen || questionPending
1250
+ }), modelOpen ? createElement(ModelPanel, {
1251
+ directory,
1252
+ error: modelError,
1253
+ onSelect: (row) => {
1254
+ setModelLabel(props.selectModel(row));
1255
+ notify(`model → next step uses ${row.provider}/${row.model}`);
1256
+ setModelOpen(false);
1257
+ },
1258
+ onClose: () => {
1259
+ setModelOpen(false);
1260
+ }
1261
+ }) : void 0, createElement(Box, { flexDirection: "column" }, ...notices.slice(-3).map((notice, index) => createElement(Text, {
275
1262
  key: index,
276
- entry
277
- })), view.streaming !== "" ? createElement(Text, null, view.streaming) : void 0, view.busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "thinking…") : void 0), createElement(Input, {
278
- busy: view.busy,
279
- onSubmit,
280
- onQuit
1263
+ dimColor: true
1264
+ }, notice))), createElement(Input, {
1265
+ active: inputActive,
1266
+ busy,
1267
+ descriptors,
1268
+ skills,
1269
+ dispatch: props.dispatch,
1270
+ steer: props.steer,
1271
+ interrupt: props.interrupt,
1272
+ quit: props.quit,
1273
+ openModel: () => {
1274
+ setModelOpen(true);
1275
+ },
1276
+ notify,
1277
+ toggleReasoning: () => {
1278
+ setShowReasoning((current) => !current);
1279
+ },
1280
+ loadMentions: props.loadMentions,
1281
+ cyclePermission: props.cyclePermission,
1282
+ onMenuState: setMenuState
281
1283
  }), createElement(StatusLine, {
282
1284
  facts: {
283
- model,
284
- cwd,
285
- branch,
286
- sessionId
1285
+ model: modelLabel,
1286
+ cwd: props.cwd,
1287
+ branch: props.branch,
1288
+ sessionId: props.sessionId,
1289
+ plan: view.plan,
1290
+ permission: view.permission
287
1291
  },
288
1292
  stats: view.stats,
289
- busy: view.busy
290
- }));
1293
+ busy
1294
+ }), createElement(CompletionMenu, { state: menuState }));
1295
+ }
1296
+ //#endregion
1297
+ //#region src/approval.ts
1298
+ /**
1299
+ * Create the approval store and mount the answerer listener on the context.
1300
+ * The listener claims only requests for `owns`-owned agents and defers every
1301
+ * other request back into the waterfall (`next()`), so sibling answerers stay
1302
+ * usable. An aborted ask never reaches the human. Plugin teardown removes the
1303
+ * listener; the service then fails its own question closed.
1304
+ * @param ctx - plugin context whose event bus carries `approval/request`.
1305
+ * @param owns - agents this terminal answers for.
1306
+ * @param preview - resolves a tool-call preview for a pending request (the
1307
+ * request contract carries no arguments; the UI self-serves from the
1308
+ * transcript projection via `callId`).
1309
+ * @returns the store the renderer subscribes to.
1310
+ */
1311
+ function mountApprovalAnswerer(ctx, owns, preview) {
1312
+ let snapshot = {
1313
+ pending: void 0,
1314
+ answered: false
1315
+ };
1316
+ const listeners = /* @__PURE__ */ new Set();
1317
+ const set = (next) => {
1318
+ snapshot = next;
1319
+ for (const listener of listeners) listener();
1320
+ };
1321
+ ctx.on("approval/request", (request, next) => {
1322
+ if (!owns(request.agent)) return next();
1323
+ if (request.signal?.aborted === true) return Promise.resolve("cancelled");
1324
+ let resolved = false;
1325
+ let settle;
1326
+ const withdraw = () => {
1327
+ if (resolved) return;
1328
+ resolved = true;
1329
+ set({
1330
+ pending: void 0,
1331
+ answered: false
1332
+ });
1333
+ settle("cancelled");
1334
+ };
1335
+ if (request.signal !== void 0) request.signal.addEventListener("abort", withdraw, { once: true });
1336
+ const pending = {
1337
+ headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
1338
+ toolName: request.toolName,
1339
+ command: preview(request),
1340
+ answer: (outcome) => {
1341
+ if (resolved) return;
1342
+ resolved = true;
1343
+ set({
1344
+ pending,
1345
+ answered: true
1346
+ });
1347
+ settle(outcome);
1348
+ }
1349
+ };
1350
+ set({
1351
+ pending,
1352
+ answered: false
1353
+ });
1354
+ return new Promise((resolve) => {
1355
+ settle = resolve;
1356
+ }).then((outcome) => {
1357
+ if (outcome !== "cancelled") set({
1358
+ pending: void 0,
1359
+ answered: false
1360
+ });
1361
+ return outcome;
1362
+ });
1363
+ });
1364
+ return {
1365
+ subscribe(listener) {
1366
+ listeners.add(listener);
1367
+ return () => {
1368
+ listeners.delete(listener);
1369
+ };
1370
+ },
1371
+ getSnapshot() {
1372
+ return snapshot;
1373
+ }
1374
+ };
1375
+ }
1376
+ //#endregion
1377
+ //#region src/commands.ts
1378
+ /**
1379
+ * Watch the live command registry. Reads the current list immediately and
1380
+ * re-reads on every registry mutation or agent retarget; notification
1381
+ * failures are contained by the registry itself, so this watcher only ever
1382
+ * re-reads. Without a `commands` service the view stays empty and all lines
1383
+ * fall through to normal prompts.
1384
+ * @param ctx - context carrying the `commands` service (optional).
1385
+ * @returns the view the completion menu subscribes to.
1386
+ */
1387
+ function watchCommands(ctx) {
1388
+ const commands = ctx.get("commands");
1389
+ let agent;
1390
+ let descriptors = [];
1391
+ const listeners = /* @__PURE__ */ new Set();
1392
+ const refresh = () => {
1393
+ if (commands === void 0 || agent === void 0) return;
1394
+ descriptors = commands.list(agent);
1395
+ for (const listener of listeners) listener();
1396
+ };
1397
+ if (commands !== void 0) ctx.on("commands/change", () => refresh());
1398
+ return {
1399
+ get descriptors() {
1400
+ return descriptors;
1401
+ },
1402
+ subscribe(listener) {
1403
+ listeners.add(listener);
1404
+ return () => {
1405
+ listeners.delete(listener);
1406
+ };
1407
+ },
1408
+ setAgent(next) {
1409
+ agent = next;
1410
+ refresh();
1411
+ }
1412
+ };
1413
+ }
1414
+ /**
1415
+ * Whether one command line is a syntactically valid slash command.
1416
+ * @param line - the complete candidate line.
1417
+ * @returns true when the line parses as `/name` or `/name input`.
1418
+ */
1419
+ function isSlashLine(line) {
1420
+ return /^\/[a-z][a-z0-9_-]*(?=$|[\t ])/u.test(line);
291
1421
  }
292
1422
  //#endregion
293
1423
  //#region src/internals.ts
@@ -309,6 +1439,308 @@ const internals = {
309
1439
  stderr: process.stderr
310
1440
  };
311
1441
  //#endregion
1442
+ //#region src/models.ts
1443
+ /**
1444
+ * Load the selectable model directory from the live `ctx.llm` registry.
1445
+ * Providers are listed synchronously; each provider's models are discovered
1446
+ * with a bounded parallel fan-out whose failures degrade to that provider
1447
+ * contributing no rows (mirrors the web catalog's per-provider failures).
1448
+ * @param ctx - context carrying the `llm` service.
1449
+ * @returns the resolved directory; empty rows when `llm` is unavailable.
1450
+ */
1451
+ async function loadModelDirectory(ctx) {
1452
+ const llm = ctx.get("llm");
1453
+ if (llm === void 0) return {
1454
+ rows: [],
1455
+ failures: []
1456
+ };
1457
+ const providers = llm.listProviders();
1458
+ const listed = await Promise.all(providers.map(async (provider) => {
1459
+ try {
1460
+ const models = await llm.listModels(provider.id);
1461
+ return {
1462
+ provider: provider.id,
1463
+ providerName: provider.name,
1464
+ models: models.map((model) => ({
1465
+ provider: provider.id,
1466
+ providerName: provider.name,
1467
+ model: model.id,
1468
+ modelName: model.name
1469
+ }))
1470
+ };
1471
+ } catch {
1472
+ return {
1473
+ provider: provider.id,
1474
+ providerName: provider.name,
1475
+ models: [],
1476
+ failed: true
1477
+ };
1478
+ }
1479
+ }));
1480
+ return {
1481
+ rows: listed.flatMap((entry) => entry.models),
1482
+ failures: listed.filter((entry) => "failed" in entry && entry.failed === true).map((entry) => entry.provider)
1483
+ };
1484
+ }
1485
+ //#endregion
1486
+ //#region src/mentions.ts
1487
+ /**
1488
+ * Workspace @mention support: file candidates from a bounded async scan of
1489
+ * the session cwd, session candidates from the opt-in `sessionReferenceResolver`
1490
+ * service, and submission preparation through its `prepare()` API. Picked
1491
+ * session mentions land as canonical `@[label](dsh-session:…)` tokens; on
1492
+ * submit the text is parsed back into readable `@label` text plus structured
1493
+ * references, snapshots are injected via `agent.inject()` before the readable
1494
+ * message wakes the driver (`followup` idle, `steer` running) — exactly the
1495
+ * upstream README's wiring.
1496
+ *
1497
+ * @module @deepseek-ai/dsh-code/mentions
1498
+ */
1499
+ /** Directories never entered and files never listed during the scan. */
1500
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
1501
+ ".git",
1502
+ "node_modules",
1503
+ "lib",
1504
+ "dist",
1505
+ "out",
1506
+ ".omc",
1507
+ "coverage"
1508
+ ]);
1509
+ const MAX_FILES = 4e3;
1510
+ const MAX_DEPTH = 12;
1511
+ /** Bounded async BFS scan of a workspace; unreadable entries are skipped. */
1512
+ async function scanWorkspaceFiles(root, signal) {
1513
+ const found = [];
1514
+ const pending = [{
1515
+ absolute: root,
1516
+ relative: "",
1517
+ depth: 0
1518
+ }];
1519
+ const aborted = () => signal?.aborted === true;
1520
+ while (pending.length > 0 && found.length < MAX_FILES && !aborted()) {
1521
+ const current = pending.shift();
1522
+ if (current === void 0) break;
1523
+ let entries;
1524
+ try {
1525
+ entries = await readdir(current.absolute, { withFileTypes: true });
1526
+ } catch {
1527
+ continue;
1528
+ }
1529
+ for (const entry of entries) {
1530
+ if (found.length >= MAX_FILES || aborted()) return found;
1531
+ if (entry.name.startsWith(".")) continue;
1532
+ const relative = current.relative === "" ? entry.name : `${current.relative}/${entry.name}`;
1533
+ if (entry.isDirectory()) {
1534
+ if (SKIP_DIRS.has(entry.name) || current.depth + 1 > MAX_DEPTH) continue;
1535
+ pending.push({
1536
+ absolute: join(current.absolute, entry.name),
1537
+ relative,
1538
+ depth: current.depth + 1
1539
+ });
1540
+ } else if (entry.isFile()) found.push({
1541
+ path: relative,
1542
+ kind: "file"
1543
+ });
1544
+ }
1545
+ }
1546
+ return found.sort((left, right) => left.path < right.path ? -1 : 1);
1547
+ }
1548
+ /** True when every query character appears in order in the haystack. */
1549
+ function isSubsequence(query, haystack) {
1550
+ let at = 0;
1551
+ for (const char of haystack) {
1552
+ if (char === query[at]) at += 1;
1553
+ if (at >= query.length) return true;
1554
+ }
1555
+ return at >= query.length;
1556
+ }
1557
+ /** Rank one file path against the typed query (community-TUI scoring shape). */
1558
+ function scoreFile(path, query) {
1559
+ const name = path.slice(path.lastIndexOf("/") + 1);
1560
+ if (query === "") return 0;
1561
+ if (name === query) return 1e3;
1562
+ if (name.startsWith(query)) return 900;
1563
+ if (name.includes(query)) return 700;
1564
+ if (path.includes(query)) return 500;
1565
+ if (isSubsequence(query, name)) return 300;
1566
+ return 0;
1567
+ }
1568
+ /**
1569
+ * Create the mention API for one agent's workspace. A missing
1570
+ * session-reference service degrades to file mentions only (the scan still
1571
+ * works); `prepare` then passes text through untouched.
1572
+ * @param ctx - context carrying the optional `sessionReferenceResolver`.
1573
+ * @param agent - the session owner; excluded from its own candidates.
1574
+ * @param cwd - workspace root to scan.
1575
+ */
1576
+ function createMentions(ctx, agent, cwd) {
1577
+ const resolver = ctx.get("sessionReferenceResolver");
1578
+ let filesPromise;
1579
+ return {
1580
+ files() {
1581
+ filesPromise ??= scanWorkspaceFiles(cwd);
1582
+ return filesPromise;
1583
+ },
1584
+ async candidates(query, signal) {
1585
+ const needle = query.trim();
1586
+ const [files, sessions] = await Promise.all([this.files(), resolver === void 0 ? Promise.resolve([]) : resolver.listCandidates(agent, needle, 10, signal).catch(() => [])]);
1587
+ const fileRows = files.filter((candidate) => scoreFile(candidate.path, needle) > 0).sort((left, right) => scoreFile(right.path, needle) - scoreFile(left.path, needle)).slice(0, 20).map((candidate) => ({
1588
+ label: candidate.path,
1589
+ description: candidate.kind === "directory" ? "Folder" : "File",
1590
+ kind: candidate.kind
1591
+ }));
1592
+ return [...sessions.map((candidate) => ({
1593
+ label: formatSessionReferenceMention(candidate),
1594
+ description: `Session · ${candidate.cwd ?? "(no cwd)"}`,
1595
+ kind: "session"
1596
+ })), ...fileRows];
1597
+ },
1598
+ parse(text) {
1599
+ return parseSessionReferenceText(text);
1600
+ },
1601
+ async prepare(parsed, signal) {
1602
+ if (parsed.references.length === 0 || resolver === void 0) return {
1603
+ text: parsed.text,
1604
+ references: parsed.references
1605
+ };
1606
+ const prepared = await resolver.prepare(agent, [{
1607
+ type: "text",
1608
+ text: parsed.text
1609
+ }], parsed.references, signal);
1610
+ return {
1611
+ text: prepared.content.filter((block) => block.type === "text").map((block) => block.text).join(""),
1612
+ references: parsed.references,
1613
+ additionalContext: prepared.additionalContext
1614
+ };
1615
+ },
1616
+ sessionMention(candidate) {
1617
+ return formatSessionReferenceMention({
1618
+ sessionId: candidate.sessionId,
1619
+ label: candidate.label
1620
+ });
1621
+ }
1622
+ };
1623
+ }
1624
+ //#endregion
1625
+ //#region src/questions.ts
1626
+ const ABORT_ERROR = new UserQuestionError("ask_user_question was interrupted before the user answered", "ASK_ABORTED");
1627
+ /**
1628
+ * Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
1629
+ * @param ctx - context carrying the `userQuestions` service (dsh-base).
1630
+ * @returns the store the renderer subscribes to; a context without the
1631
+ * service yields a permanently empty store.
1632
+ */
1633
+ function mountQuestionProvider(ctx) {
1634
+ const service = ctx.get("userQuestions");
1635
+ let snapshot = { pending: void 0 };
1636
+ let active;
1637
+ const queue = [];
1638
+ const listeners = /* @__PURE__ */ new Set();
1639
+ const set = (next) => {
1640
+ snapshot = next;
1641
+ for (const listener of listeners) listener();
1642
+ };
1643
+ /** Settle the active request and show the next queued one, if any. */
1644
+ const advance = () => {
1645
+ const next = queue.shift();
1646
+ active = next;
1647
+ set({ pending: next });
1648
+ };
1649
+ if (service !== void 0) service.registerProvider({ ask(request) {
1650
+ return new Promise((resolve, reject) => {
1651
+ const pending = {
1652
+ request,
1653
+ resolve,
1654
+ reject
1655
+ };
1656
+ const onAbort = () => {
1657
+ if (active === pending) {
1658
+ active = void 0;
1659
+ set({ pending: void 0 });
1660
+ advance();
1661
+ } else {
1662
+ const at = queue.indexOf(pending);
1663
+ if (at >= 0) queue.splice(at, 1);
1664
+ }
1665
+ reject(ABORT_ERROR);
1666
+ };
1667
+ if (request.signal?.aborted === true) {
1668
+ reject(ABORT_ERROR);
1669
+ return;
1670
+ }
1671
+ request.signal?.addEventListener("abort", onAbort, { once: true });
1672
+ if (active === void 0) {
1673
+ active = pending;
1674
+ set({ pending });
1675
+ } else queue.push(pending);
1676
+ });
1677
+ } });
1678
+ return {
1679
+ subscribe(listener) {
1680
+ listeners.add(listener);
1681
+ return () => {
1682
+ listeners.delete(listener);
1683
+ };
1684
+ },
1685
+ getSnapshot() {
1686
+ return snapshot;
1687
+ },
1688
+ submit(pending, answers) {
1689
+ if (active !== pending) return;
1690
+ active = void 0;
1691
+ set({ pending: void 0 });
1692
+ pending.resolve(answers);
1693
+ advance();
1694
+ },
1695
+ cancel(pending) {
1696
+ if (active !== pending) return;
1697
+ active = void 0;
1698
+ set({ pending: void 0 });
1699
+ pending.reject(ABORT_ERROR);
1700
+ advance();
1701
+ }
1702
+ };
1703
+ }
1704
+ //#endregion
1705
+ //#region src/render/tool-preview.ts
1706
+ /**
1707
+ * Bounded preview line for a tool invocation's raw JSON arguments: the first
1708
+ * human-meaningful string among the well-known keys (command, path, query, …)
1709
+ * with a fallback to the bounded raw JSON. Shared by the tool card in the
1710
+ * transcript and the approval bar's command preview.
1711
+ *
1712
+ * @module @deepseek-ai/dsh-code/render/tool-preview
1713
+ */
1714
+ /** Keys searched in declaration order when building a preview. */
1715
+ const PREVIEW_KEYS = [
1716
+ "command",
1717
+ "cmd",
1718
+ "description",
1719
+ "path",
1720
+ "pattern",
1721
+ "query"
1722
+ ];
1723
+ /**
1724
+ * Resolve one bounded preview for raw tool arguments.
1725
+ * @param args - raw JSON arguments string as the model produced it.
1726
+ * @param toolName - the tool the arguments belong to (fallback label).
1727
+ * @returns the preview line; empty when nothing useful resolves.
1728
+ */
1729
+ function toolArgumentsPreview(args, toolName) {
1730
+ if (args === "") return toolName;
1731
+ try {
1732
+ const parsed = JSON.parse(args);
1733
+ if (parsed !== null && typeof parsed === "object") {
1734
+ const record = parsed;
1735
+ for (const key of PREVIEW_KEYS) {
1736
+ const value = record[key];
1737
+ if (typeof value === "string" && value !== "") return value;
1738
+ }
1739
+ }
1740
+ } catch {}
1741
+ return args.length > 80 ? `${args.slice(0, 77)}...` : args;
1742
+ }
1743
+ //#endregion
312
1744
  //#region src/render/projection.ts
313
1745
  /**
314
1746
  * Pure session-event-to-view projection for the TUI transcript: one reducer
@@ -322,13 +1754,21 @@ const internals = {
322
1754
  function textOf(content) {
323
1755
  return content.filter((block) => block.type === "text").map((block) => block.text).join("");
324
1756
  }
1757
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
1758
+ function reasoningOf(content) {
1759
+ return content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
1760
+ }
325
1761
  /** A fresh, empty transcript view. */
326
1762
  function createTranscriptView() {
327
1763
  return {
328
1764
  entries: [],
329
1765
  streaming: "",
1766
+ streamingReasoning: "",
330
1767
  todos: [],
331
1768
  busy: false,
1769
+ model: "",
1770
+ plan: false,
1771
+ permission: "",
332
1772
  stats: {
333
1773
  turns: 0,
334
1774
  steps: 0,
@@ -360,7 +1800,8 @@ function projectEvent(view, event) {
360
1800
  ...view,
361
1801
  entries: [...view.entries, {
362
1802
  kind: "user",
363
- text: textOf(message.content)
1803
+ text: textOf(message.content),
1804
+ notice: false
364
1805
  }]
365
1806
  };
366
1807
  const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
@@ -368,17 +1809,22 @@ function projectEvent(view, event) {
368
1809
  ...view,
369
1810
  entries: [...view.entries, {
370
1811
  kind: "user",
371
- text: boundContextSummary(notice)
1812
+ text: boundContextSummary(notice),
1813
+ notice: true
372
1814
  }]
373
1815
  };
374
1816
  }
375
1817
  case "assistant/chunk": {
376
1818
  const chunk = event.data.chunk;
377
- if (chunk.type !== "text-delta") return view;
378
- return {
1819
+ if (chunk.type === "text-delta") return {
379
1820
  ...view,
380
1821
  streaming: view.streaming + chunk.text
381
1822
  };
1823
+ if (chunk.type === "reasoning-delta") return {
1824
+ ...view,
1825
+ streamingReasoning: view.streamingReasoning + chunk.text
1826
+ };
1827
+ return view;
382
1828
  }
383
1829
  case "assistant/message": {
384
1830
  const key = `${event.data.turn}:${event.data.step}`;
@@ -389,9 +1835,11 @@ function projectEvent(view, event) {
389
1835
  return {
390
1836
  ...view,
391
1837
  streaming: "",
1838
+ streamingReasoning: "",
392
1839
  entries: [...view.entries, {
393
1840
  kind: "assistant",
394
- text: textOf(event.data.message.content)
1841
+ text: textOf(event.data.message.content),
1842
+ reasoning: reasoningOf(event.data.message.content)
395
1843
  }],
396
1844
  stats: {
397
1845
  ...view.stats,
@@ -414,6 +1862,7 @@ function projectEvent(view, event) {
414
1862
  callId: data.callId,
415
1863
  name: data.name,
416
1864
  arguments: data.arguments,
1865
+ preview: toolArgumentsPreview(data.arguments, data.name),
417
1866
  state: "running",
418
1867
  summary: ""
419
1868
  }]
@@ -448,6 +1897,7 @@ function projectEvent(view, event) {
448
1897
  case "turn/start": return {
449
1898
  ...view,
450
1899
  busy: true,
1900
+ todos: [],
451
1901
  stats: {
452
1902
  ...view.stats,
453
1903
  turns: view.stats.turns + 1
@@ -477,17 +1927,75 @@ function projectEvent(view, event) {
477
1927
  }]
478
1928
  };
479
1929
  }
1930
+ case "request/header": {
1931
+ const config = event.data.header.config;
1932
+ return {
1933
+ ...view,
1934
+ model: `${config.provider}/${config.model}`
1935
+ };
1936
+ }
1937
+ case "plan/mode": return {
1938
+ ...view,
1939
+ plan: event.data.active
1940
+ };
1941
+ case "permission/preset": return {
1942
+ ...view,
1943
+ permission: event.data.preset
1944
+ };
1945
+ case "command/run": {
1946
+ const data = event.data;
1947
+ return {
1948
+ ...view,
1949
+ entries: [...view.entries, {
1950
+ kind: "command",
1951
+ commandId: data.commandId,
1952
+ name: data.name,
1953
+ args: data.args ?? "",
1954
+ state: "running",
1955
+ summary: ""
1956
+ }]
1957
+ };
1958
+ }
1959
+ case "command/done": {
1960
+ const data = event.data;
1961
+ const entries = view.entries.map((entry) => {
1962
+ if (entry.kind !== "command" || entry.commandId !== data.commandId) return entry;
1963
+ return {
1964
+ ...entry,
1965
+ state: data.kind === "success" ? "done" : "error",
1966
+ summary: boundContextSummary(data.text ?? "")
1967
+ };
1968
+ });
1969
+ return {
1970
+ ...view,
1971
+ entries
1972
+ };
1973
+ }
480
1974
  default: return view;
481
1975
  }
482
1976
  }
1977
+ /**
1978
+ * Fold a replayed event history into one view.
1979
+ * @param events - events in `seq` order.
1980
+ * @returns the folded view.
1981
+ */
1982
+ function projectEvents(events) {
1983
+ return events.reduce(projectEvent, createTranscriptView());
1984
+ }
483
1985
  //#endregion
484
1986
  //#region src/store.ts
485
1987
  /**
486
- * Create one transcript store.
1988
+ * Create one transcript store, optionally seeded with replayed history. The
1989
+ * seed folds synchronously BEFORE the first render, so a resumed session
1990
+ * paints its full transcript on mount (no live `session/event` fires for
1991
+ * constructor seeds — the store's `session/event` feed only carries new
1992
+ * appends).
1993
+ * @param replay - persisted events in `seq` order (e.g. a resumed session's
1994
+ * constructor seed); folded once and never re-notified.
487
1995
  * @returns the store the runner feeds and the renderer subscribes to.
488
1996
  */
489
- function createTranscriptStore() {
490
- let view = createTranscriptView();
1997
+ function createTranscriptStore(replay) {
1998
+ let view = replay === void 0 ? createTranscriptView() : projectEvents(replay);
491
1999
  const listeners = /* @__PURE__ */ new Set();
492
2000
  return {
493
2001
  getView: () => view,
@@ -506,16 +2014,69 @@ function createTranscriptStore() {
506
2014
  };
507
2015
  }
508
2016
  //#endregion
2017
+ //#region src/skills.ts
2018
+ function toRows(skills) {
2019
+ return skills.filter((skill) => isUserInvocable(skill)).map((skill) => ({
2020
+ name: skill.name,
2021
+ description: skill.description,
2022
+ modelInvocable: skill.invocation.modelInvocable === true
2023
+ })).sort((left, right) => left.name < right.name ? -1 : 1);
2024
+ }
2025
+ /**
2026
+ * Watch the user-invocable skill catalog for one agent's workspace. The first
2027
+ * load starts when the owning agent is known (`setAgent`); `skills/change`
2028
+ * and agent retargets re-read. Read failures keep the last good rows (the
2029
+ * next change notification is the retry surface) — a missing `skills`
2030
+ * service leaves the view permanently empty.
2031
+ * @param ctx - context carrying the `skills` service (optional).
2032
+ * @returns the view the completion menu subscribes to.
2033
+ */
2034
+ function watchSkills(ctx) {
2035
+ const skills = ctx.get("skills");
2036
+ let agent;
2037
+ let rows = [];
2038
+ const listeners = /* @__PURE__ */ new Set();
2039
+ const reload = () => {
2040
+ if (skills === void 0 || agent === void 0) return;
2041
+ skills.list({
2042
+ cwd: agent.session.header.cwd,
2043
+ scope: agent
2044
+ }).then((summaries) => {
2045
+ const next = toRows(summaries);
2046
+ if (next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)) return;
2047
+ rows = next;
2048
+ for (const listener of listeners) listener();
2049
+ }, () => {});
2050
+ };
2051
+ if (skills !== void 0) ctx.on("skills/change", reload);
2052
+ return {
2053
+ get rows() {
2054
+ return rows;
2055
+ },
2056
+ subscribe(listener) {
2057
+ listeners.add(listener);
2058
+ return () => {
2059
+ listeners.delete(listener);
2060
+ };
2061
+ },
2062
+ setAgent(next) {
2063
+ agent = next;
2064
+ reload();
2065
+ }
2066
+ };
2067
+ }
2068
+ //#endregion
509
2069
  //#region src/index.ts
510
2070
  /**
511
- * @deepseek-ai/dsh-tui — the interactive terminal driver. The bundle patch
2071
+ * @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
512
2072
  * rides over dsh-base without Host, HTTP, or browser plugins; this runner
513
- * creates one Agent through the core registry, mounts the Ink app (DeepSeek
514
- * blue, whale wordmark), folds submitted prompts into the same durable
515
- * session, streams `session/event` into the transcript, and on quit flushes
516
- * and requests process exit.
2073
+ * creates (or resumes) one Agent through the core registry, mounts the Ink
2074
+ * app (DeepSeek blue, whale wordmark), folds submitted prompts into the same
2075
+ * durable session, answers approval asks with a y/n bar, dispatches slash
2076
+ * commands through the shared registry, and on quit flushes and requests
2077
+ * process exit.
517
2078
  *
518
- * @module @deepseek-ai/dsh-tui
2079
+ * @module @deepseek-ai/dsh-code
519
2080
  */
520
2081
  /** Stable Cordis plugin name. */
521
2082
  const name = "tui-runner";
@@ -525,6 +2086,10 @@ const inject = [
525
2086
  "agents",
526
2087
  "sessions"
527
2088
  ];
2089
+ const Config = z.object({ startup: z.object({
2090
+ kind: z.string().required(),
2091
+ sessionId: z.string()
2092
+ }) });
528
2093
  /** Report an unexpected direct-driver failure and request a failing exit. */
529
2094
  function fail(io, error) {
530
2095
  internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -543,36 +2108,139 @@ function gitBranch(cwd) {
543
2108
  }
544
2109
  }
545
2110
  /**
546
- * Run the interactive terminal session: create one Agent, mount the app, and
547
- * keep the process alive until the user quits.
2111
+ * Resolve the invocation's target session against the persisted headers.
2112
+ * @param startup - the parsed startup flags.
2113
+ * @param persistence - the persistence service; required for resume/latest.
2114
+ * @param cwd - the working directory `--continue` filters by.
2115
+ * @returns the target identity.
2116
+ * @throws with a user-facing message when the flags name nothing resolvable.
2117
+ */
2118
+ async function resolveTarget(startup, persistence, cwd) {
2119
+ if (startup.kind === "fresh") return {
2120
+ sessionId: `session-${randomUUID()}`,
2121
+ resume: false
2122
+ };
2123
+ if (startup.kind === "named") return {
2124
+ sessionId: startup.sessionId,
2125
+ resume: false
2126
+ };
2127
+ if (persistence === void 0) throw new Error("cannot resolve the requested session: session persistence is not configured");
2128
+ const headers = await persistence.list();
2129
+ if (startup.kind === "resume") {
2130
+ const wanted = startup.sessionId;
2131
+ const exact = headers.filter((header) => header.id === wanted);
2132
+ const matches = exact.length > 0 ? exact : headers.filter((header) => header.id.startsWith(wanted));
2133
+ if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`);
2134
+ if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`);
2135
+ return {
2136
+ sessionId: matches[0].id,
2137
+ resume: true
2138
+ };
2139
+ }
2140
+ const local = headers.filter((header) => header.cwd === cwd).sort((left, right) => right.createdAt - left.createdAt);
2141
+ if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`);
2142
+ return {
2143
+ sessionId: local[0].id,
2144
+ resume: true
2145
+ };
2146
+ }
2147
+ /**
2148
+ * Resolve a bounded command preview for one pending approval: the request
2149
+ * contract carries no arguments, so the bar self-serves from the transcript
2150
+ * projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
2151
+ * @param events - the transcript entries to search.
2152
+ * @param callId - the tool call the question is about, when the asker had one.
2153
+ * @param toolName - the tool the question is about.
2154
+ * @returns a bounded preview line, '' when nothing useful resolves.
2155
+ */
2156
+ function approvalCommandPreview(events, callId, toolName) {
2157
+ if (callId === void 0) return "";
2158
+ const entry = events.find((candidate) => candidate.kind === "tool" && candidate.callId === callId);
2159
+ if (entry === void 0) return "";
2160
+ return toolArgumentsPreview(entry.arguments ?? "", toolName);
2161
+ }
2162
+ /**
2163
+ * Run the interactive terminal session: resolve the target session, create or
2164
+ * resume one Agent, mount the app, and keep the process alive until the user
2165
+ * quits.
548
2166
  * @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
2167
+ * @param startup - the parsed invocation flags.
549
2168
  * @param io - process-facing effects.
550
2169
  */
551
- async function run(ctx, io) {
2170
+ async function run(ctx, startup, io) {
552
2171
  await ctx.get("loader")?.await();
553
2172
  const agents = ctx.get("agents");
554
2173
  const defaultModel = ctx.get("agentDefaultModel");
555
2174
  const sessions = ctx.get("sessions");
2175
+ const persistence = ctx.get("sessionPersistence");
556
2176
  if (agents === void 0 || defaultModel === void 0 || sessions === void 0) return;
557
- const selection = defaultModel.currentSelection();
558
- const { agent } = await agents.create({
559
- sessionId: SessionId(`session-${randomUUID()}`),
560
- meta: { cwd: process.cwd() },
561
- agentOptions: {
562
- provider: selection.provider,
563
- model: selection.model
564
- },
565
- setup: (agentCtx) => {
566
- installModelSelection(agentCtx, {
567
- current: selection,
568
- assembled: void 0
569
- });
570
- }
571
- });
572
- const store = createTranscriptStore();
573
- const off = ctx.on("session/event", (session, event) => {
574
- if (session.id === agent.session.id) store.apply(event);
2177
+ const cwd = process.cwd();
2178
+ const target = await resolveTarget(startup, persistence, cwd);
2179
+ const defaults = defaultModel.currentSelection();
2180
+ let picked;
2181
+ let session;
2182
+ let agent;
2183
+ if (target.resume) {
2184
+ agent = (await agents.resume({
2185
+ resumeSessionId: SessionId(target.sessionId),
2186
+ agentOptions: {
2187
+ provider: defaults.provider,
2188
+ model: defaults.model
2189
+ },
2190
+ setup: (agentCtx) => {
2191
+ installModelSelection(agentCtx, {
2192
+ get current() {
2193
+ if (picked !== void 0) return picked;
2194
+ const logged = agentCtx.agent?.session.requestHeader()?.config;
2195
+ if (logged !== void 0) return {
2196
+ provider: logged.provider,
2197
+ model: logged.model,
2198
+ ...logged.reasoningEffort === void 0 ? {} : { reasoningEffort: logged.reasoningEffort }
2199
+ };
2200
+ return defaults;
2201
+ },
2202
+ set current(next) {
2203
+ picked = next;
2204
+ },
2205
+ assembled: void 0
2206
+ });
2207
+ }
2208
+ })).agent;
2209
+ session = agent.session;
2210
+ } else {
2211
+ agent = (await agents.create({
2212
+ sessionId: SessionId(target.sessionId),
2213
+ meta: { cwd },
2214
+ agentOptions: {
2215
+ provider: defaults.provider,
2216
+ model: defaults.model
2217
+ },
2218
+ setup: (agentCtx) => {
2219
+ installModelSelection(agentCtx, {
2220
+ get current() {
2221
+ return picked ?? defaults;
2222
+ },
2223
+ set current(next) {
2224
+ picked = next;
2225
+ },
2226
+ assembled: void 0
2227
+ });
2228
+ }
2229
+ })).agent;
2230
+ session = agent.session;
2231
+ }
2232
+ const store = createTranscriptStore(session.events);
2233
+ const off = ctx.on("session/event", (subject, event) => {
2234
+ if (subject.id === session.id) store.apply(event);
575
2235
  });
2236
+ const commands = watchCommands(ctx);
2237
+ commands.setAgent(agent);
2238
+ const skills = watchSkills(ctx);
2239
+ skills.setAgent(agent);
2240
+ const approval = mountApprovalAnswerer(ctx, (candidate) => candidate.id === agent.id, (request) => approvalCommandPreview(store.getView().entries, request.callId, request.toolName));
2241
+ const questions = mountQuestionProvider(ctx);
2242
+ const mentions = createMentions(ctx, agent, session.header.cwd ?? cwd);
2243
+ const bridge = { notify: () => {} };
576
2244
  const mountRef = {};
577
2245
  let quitting = false;
578
2246
  const quit = () => {
@@ -580,44 +2248,164 @@ async function run(ctx, io) {
580
2248
  quitting = true;
581
2249
  off();
582
2250
  mountRef.current?.unmount();
583
- sessions.flush(agent.session).catch((flushError) => {
2251
+ sessions.flush(session).catch((flushError) => {
584
2252
  internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`);
585
2253
  }).then(() => {
586
2254
  io.exit(0);
587
2255
  });
588
2256
  };
589
- mountRef.current = io.mount(createElement(App, {
590
- store,
591
- model: `${selection.provider}/${selection.model}`,
592
- cwd: basename(process.cwd()),
593
- branch: gitBranch(process.cwd()),
594
- sessionId: agent.session.id.slice(-8),
595
- onSubmit: (text) => {
596
- agent.followup(createUserMessage({
2257
+ /** Run one slash line through the command registry (closed namespace). */
2258
+ const runSlash = (line) => {
2259
+ const registry = ctx.get("commands");
2260
+ if (registry === void 0) {
2261
+ bridge.notify("no command registry is mounted in this composition");
2262
+ return;
2263
+ }
2264
+ const controller = new AbortController();
2265
+ registry.execute(agent, line, controller.signal).then((execution) => {
2266
+ if (execution === void 0) agent.followup(createUserMessage({
597
2267
  content: [{
598
2268
  type: "text",
599
- text
2269
+ text: line
600
2270
  }],
601
2271
  source: { kind: "user" }
602
2272
  }));
603
- },
604
- onQuit: quit
2273
+ }, (error) => {
2274
+ bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
2275
+ });
2276
+ };
2277
+ /** Deliver one readable line to the agent, expanding session mentions first. */
2278
+ const send = (text, mode) => {
2279
+ const line = text.trim();
2280
+ if (line === "") return;
2281
+ if (isSlashLine(line) && mode === "followup") {
2282
+ runSlash(line);
2283
+ return;
2284
+ }
2285
+ let parsed;
2286
+ try {
2287
+ parsed = mentions.parse(line);
2288
+ } catch (error) {
2289
+ bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`);
2290
+ return;
2291
+ }
2292
+ const deliver = (readable, context) => {
2293
+ if (context !== void 0) agent.inject(context);
2294
+ const message = createUserMessage({
2295
+ content: [{
2296
+ type: "text",
2297
+ text: readable
2298
+ }],
2299
+ source: { kind: "user" }
2300
+ });
2301
+ if (mode === "steer") {
2302
+ agent.steer(message);
2303
+ bridge.notify("steering queued — the next step sees it");
2304
+ } else agent.followup(message);
2305
+ };
2306
+ if (parsed.references.length === 0) {
2307
+ deliver(parsed.text);
2308
+ return;
2309
+ }
2310
+ const controller = new AbortController();
2311
+ mentions.prepare(parsed, controller.signal).then((prepared) => {
2312
+ deliver(prepared.text, prepared.additionalContext);
2313
+ }, (error) => {
2314
+ if (controller.signal.aborted) return;
2315
+ bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`);
2316
+ });
2317
+ };
2318
+ /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
2319
+ const dispatch = (text) => {
2320
+ send(text, "followup");
2321
+ };
2322
+ /**
2323
+ * Submit steering: a running driver consumes the text at its next step
2324
+ * boundary (the inbox delivers between steps); an idle driver just starts
2325
+ * a turn, so this doubles as the busy-state submit path.
2326
+ */
2327
+ const steer = (text) => {
2328
+ send(text, "steer");
2329
+ };
2330
+ /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
2331
+ const interrupt = () => {
2332
+ if (agent.status !== "running") return false;
2333
+ agent.cancel({ kind: "user" });
2334
+ bridge.notify("turn cancelled — Ctrl+C or /quit to exit");
2335
+ return true;
2336
+ };
2337
+ /**
2338
+ * Cycle to the next permission preset (Shift+Tab, the Claude-Code
2339
+ * permission-mode convention mapped onto dsh presets). A session in a
2340
+ * custom knob state wraps to the first declared preset.
2341
+ */
2342
+ const cyclePermission = () => {
2343
+ const service = ctx.get("permissionPresets");
2344
+ if (service === void 0 || service.names.length === 0) {
2345
+ bridge.notify("permission presets are not mounted in this composition");
2346
+ return "";
2347
+ }
2348
+ const at = service.names.indexOf(service.current(session.events));
2349
+ const next = service.names[(at + 1) % service.names.length] ?? "";
2350
+ if (next === "") return "";
2351
+ service.set(session, next);
2352
+ return next;
2353
+ };
2354
+ /** Apply one /model selection: takes effect from the next assembled step. */
2355
+ const selectModel = (row) => {
2356
+ picked = {
2357
+ provider: row.provider,
2358
+ model: row.model
2359
+ };
2360
+ return `${row.provider}/${row.model}`;
2361
+ };
2362
+ const initialModel = store.getView().model !== "" ? store.getView().model : `${defaults.provider}/${defaults.model}`;
2363
+ mountRef.current = io.mount(createElement(App, {
2364
+ store,
2365
+ approval,
2366
+ questions,
2367
+ commands,
2368
+ skills,
2369
+ model: initialModel,
2370
+ cwd: basename(cwd),
2371
+ branch: gitBranch(cwd),
2372
+ sessionId: session.id.slice(-8),
2373
+ resumed: target.resume,
2374
+ dispatch,
2375
+ steer,
2376
+ interrupt,
2377
+ quit,
2378
+ loadModels: () => loadModelDirectory(ctx),
2379
+ loadMentions: mentions.candidates,
2380
+ cyclePermission,
2381
+ selectModel,
2382
+ onBridgeReady: (instance) => {
2383
+ bridge.notify = instance.notify;
2384
+ }
605
2385
  }));
606
2386
  }
607
2387
  /**
608
2388
  * Mount the interactive terminal driver.
609
2389
  * @param ctx - plugin context carrying core services and the launcher-provided exit request.
2390
+ * @param config - validated startup config resolved from the tuiStartup provider.
610
2391
  */
611
- function apply(ctx) {
2392
+ function apply(ctx, config) {
2393
+ const startup = config.startup.kind === "resume" && config.startup.sessionId !== void 0 ? {
2394
+ kind: "resume",
2395
+ sessionId: config.startup.sessionId
2396
+ } : config.startup.kind === "latest" ? { kind: "latest" } : config.startup.kind === "named" && config.startup.sessionId !== void 0 ? {
2397
+ kind: "named",
2398
+ sessionId: config.startup.sessionId
2399
+ } : { kind: "fresh" };
612
2400
  const exit = ctx.get("appExit");
613
2401
  if (exit === void 0) throw new Error("tui-runner: the launcher must provide ctx.appExit before the tree mounts");
614
2402
  const io = {
615
2403
  mount: internals.mount,
616
2404
  exit
617
2405
  };
618
- run(ctx, io).catch((error) => {
2406
+ run(ctx, startup, io).catch((error) => {
619
2407
  fail(io, error);
620
2408
  });
621
2409
  }
622
2410
  //#endregion
623
- export { apply, inject, name };
2411
+ export { Config, apply, inject, name };