dsh-code 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -1,13 +1,16 @@
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, useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { createElement, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { installModelSelection } from "@deepseek-ai/dsh-agent";
7
7
  import { assertNever, boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
8
8
  import { SessionId } from "@deepseek-ai/dsh-session";
9
- import { Box, Text, render, useInput } from "ink";
9
+ import { Box, Text, render, useInput, useStdout } from "ink";
10
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";
11
14
  import { isUserInvocable } from "@deepseek-ai/dsh-skill";
12
15
  //#region src/theme.ts
13
16
  /**
@@ -64,6 +67,18 @@ const TUI_RGB = {
64
67
  245,
65
68
  158,
66
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
67
82
  ]
68
83
  };
69
84
  /** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
@@ -96,6 +111,258 @@ const WHALE_GLYPH = [
96
111
  " ▀▀███████▀▀ ▀▀▀ "
97
112
  ];
98
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
99
366
  //#region src/render/status.ts
100
367
  /**
101
368
  * Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
@@ -139,7 +406,8 @@ function buildStatusGroups(facts, stats) {
139
406
  const identity = [
140
407
  facts.model,
141
408
  facts.cwd,
142
- facts.branch === "" ? void 0 : `⑂ ${facts.branch}`
409
+ facts.branch === "" ? void 0 : `⑂ ${facts.branch}`,
410
+ facts.plan ? "⧉ plan" : void 0
143
411
  ].filter((part) => part !== void 0 && part !== "");
144
412
  if (identity.length > 0) groups.push(identity.join(" · "));
145
413
  if (stats.turns > 0 || stats.steps > 0) {
@@ -155,6 +423,7 @@ function buildStatusGroups(facts, stats) {
155
423
  groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`);
156
424
  }
157
425
  if (facts.sessionId !== "") groups.push(facts.sessionId);
426
+ if (facts.permission !== void 0 && facts.permission !== "") groups.push(facts.permission);
158
427
  return groups;
159
428
  }
160
429
  //#endregion
@@ -202,25 +471,151 @@ function displayText(text) {
202
471
  function inkColor(triple) {
203
472
  return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`;
204
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
+ }
205
578
  /** One settled transcript row. */
206
- function EntryLine({ entry }) {
579
+ function EntryLine({ entry, showReasoning }) {
207
580
  switch (entry.kind) {
208
- case "user": return createElement(Text, null, brand("❯ "), displayText(entry.text));
209
- case "assistant": return createElement(Text, null, displayText(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 }));
210
586
  case "tool": {
211
- 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) }, "⏺");
212
- return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(displayText(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)}`));
213
589
  }
214
590
  case "command": {
215
- 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) }, "⏺");
216
- return createElement(Text, null, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`, entry.summary === "" ? "" : ` ${dim(displayText(entry.summary))}`);
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)}`));
217
593
  }
218
594
  case "error": return createElement(Text, null, error(displayText(entry.text)));
219
595
  default: return assertNever(entry, "transcript entry kind");
220
596
  }
221
597
  }
222
- /** The whale wordmark header in DeepSeek blue, hugging its content width. */
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
+ */
223
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));
224
619
  return createElement(Box, {
225
620
  flexDirection: "row",
226
621
  gap: 1,
@@ -240,7 +635,7 @@ function Header({ resumed }) {
240
635
  }, createElement(Text, {
241
636
  color: inkColor(TUI_RGB.brand),
242
637
  bold: true
243
- }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit")));
638
+ }, "DeepSeek Harness"), createElement(Text, { dimColor: true }, hint)));
244
639
  }
245
640
  /** Todo status glyph: web TodoPanel's three-state marker. */
246
641
  function todoMark(status) {
@@ -258,7 +653,8 @@ function TodoPanel({ todos }) {
258
653
  borderStyle: "round",
259
654
  borderColor: inkColor(TUI_RGB.brandDeep),
260
655
  alignSelf: "flex-start",
261
- marginLeft: 1
656
+ marginLeft: 1,
657
+ marginTop: 1
262
658
  }, createElement(Text, {
263
659
  color: inkColor(TUI_RGB.brand),
264
660
  bold: true
@@ -275,16 +671,27 @@ function TodoPanel({ todos }) {
275
671
  */
276
672
  function StatusLine({ facts, stats, busy }) {
277
673
  const groups = buildStatusGroups(facts, stats);
278
- 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, " ")];
279
675
  groups.forEach((group, index) => {
280
676
  if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(" | ")));
281
677
  children.push(createElement(Text, { dimColor: true }, group));
282
678
  });
283
- return createElement(Box, { paddingX: 1 }, ...children);
679
+ return createElement(Box, {
680
+ paddingX: 1,
681
+ marginTop: 1
682
+ }, ...children);
284
683
  }
285
684
  /** The y/n approval bar rendered while an approval ask is pending. */
286
- function ApprovalBar({ approval }) {
685
+ function ApprovalBar({ approval, locked }) {
287
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
+ });
288
695
  if (snapshot.pending === void 0) return void 0;
289
696
  const { pending, answered } = snapshot;
290
697
  return createElement(Box, {
@@ -293,12 +700,148 @@ function ApprovalBar({ approval }) {
293
700
  borderStyle: "round",
294
701
  borderColor: inkColor(TUI_RGB.warn),
295
702
  alignSelf: "flex-start",
296
- marginLeft: 1
703
+ marginLeft: 1,
704
+ marginTop: 1
297
705
  }, createElement(Text, {
298
706
  color: inkColor(TUI_RGB.warn),
299
707
  bold: true
300
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")));
301
709
  }
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
+ }
302
845
  /** The /model panel: a scrolling list over the advisory model directory. */
303
846
  function ModelPanel({ directory, error, onSelect, onClose }) {
304
847
  const [cursor, setCursor] = useState(0);
@@ -328,7 +871,8 @@ function ModelPanel({ directory, error, onSelect, onClose }) {
328
871
  borderStyle: "round",
329
872
  borderColor: inkColor(TUI_RGB.brand),
330
873
  alignSelf: "flex-start",
331
- marginLeft: 1
874
+ marginLeft: 1,
875
+ marginTop: 1
332
876
  }, createElement(Text, {
333
877
  color: inkColor(TUI_RGB.brand),
334
878
  bold: true
@@ -372,7 +916,8 @@ function completionCandidates(value, descriptors, skills) {
372
916
  origin: "command"
373
917
  }
374
918
  ];
375
- const registry = descriptors.map((descriptor) => ({
919
+ const localNames = new Set(local.map((candidate) => candidate.label.slice(1)));
920
+ const registry = descriptors.filter((descriptor) => !localNames.has(descriptor.name)).map((descriptor) => ({
376
921
  label: `/${descriptor.name}`,
377
922
  description: descriptor.description,
378
923
  origin: "command"
@@ -392,10 +937,36 @@ function completionCandidates(value, descriptors, skills) {
392
937
  return all.filter((candidate) => candidate.label.slice(1).startsWith(prefix)).slice(0, 10);
393
938
  }
394
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
+ /**
395
964
  * The prompt box: TUI-local slash commands handled locally, other lines
396
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.
397
968
  */
398
- function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }) {
969
+ function Input({ active, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify, toggleReasoning, loadMentions, cyclePermission, onMenuState }) {
399
970
  const [value, setValue] = useState("");
400
971
  const [cursor, setCursor] = useState(0);
401
972
  const history = useRef([]);
@@ -403,8 +974,68 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
403
974
  const draft = useRef("");
404
975
  const [completionIndex, setCompletionIndex] = useState(0);
405
976
  const candidates = completionCandidates(value, descriptors, skills);
406
- const completionActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
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
+ ]);
407
1028
  useInput((input, key) => {
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
+ }
408
1039
  if (key.ctrl && input === "c") {
409
1040
  if (busy) interrupt();
410
1041
  else if (value !== "") {
@@ -441,7 +1072,7 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
441
1072
  return;
442
1073
  }
443
1074
  if (text === "/help") {
444
- notify("/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn");
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");
445
1076
  return;
446
1077
  }
447
1078
  if (text === "/clear") {
@@ -459,12 +1090,12 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
459
1090
  dispatch(text);
460
1091
  return;
461
1092
  }
462
- if (completionActive && key.upArrow) {
463
- setCompletionIndex((index) => (index + candidates.length - 1) % candidates.length);
1093
+ if (menuActive && key.upArrow) {
1094
+ setCompletionIndex((index) => (index + menuRows.length - 1) % menuRows.length);
464
1095
  return;
465
1096
  }
466
- if (completionActive && key.downArrow) {
467
- setCompletionIndex((index) => (index + 1) % candidates.length);
1097
+ if (menuActive && key.downArrow) {
1098
+ setCompletionIndex((index) => (index + 1) % menuRows.length);
468
1099
  return;
469
1100
  }
470
1101
  if (key.upArrow) {
@@ -492,13 +1123,22 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
492
1123
  setCursor((entries[next] ?? "").length);
493
1124
  return;
494
1125
  }
495
- if (key.tab && completionActive) {
496
- const candidate = candidates[completionIndex % candidates.length];
497
- if (candidate !== void 0) {
498
- setValue(`${candidate.label} `);
499
- setCursor(candidate.label.length + 1);
500
- setCompletionIndex(0);
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
+ }
501
1140
  }
1141
+ setCompletionIndex(0);
502
1142
  return;
503
1143
  }
504
1144
  if (key.backspace || key.delete) {
@@ -536,13 +1176,14 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
536
1176
  setCompletionIndex(0);
537
1177
  }
538
1178
  });
539
- return createElement(Box, { flexDirection: "column" }, completionActive && !busy ? createElement(Box, {
1179
+ return createElement(Box, {
540
1180
  flexDirection: "column",
541
- marginLeft: 1
542
- }, ...candidates.map((candidate, index) => createElement(Text, {
543
- key: candidate.label,
544
- color: index === completionIndex % candidates.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
545
- }, `${index === completionIndex % candidates.length ? "❯ " : " "}${candidate.label} ${dim(displayText(candidate.description))}`)), createElement(Text, { dimColor: true }, dim(" ↑↓ choose · tab complete"))) : void 0, busy && value === "" ? createElement(Text, { dimColor: true }, dim(" enter steers the running turn · esc or ctrl+c cancels")) : void 0, createElement(Box, null, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), createElement(Text, null, value.slice(0, cursor)), createElement(Text, { inverse: true }, value.slice(cursor, cursor + 1) === "" ? " " : value.slice(cursor, cursor + 1)), createElement(Text, null, value.slice(cursor + 1))));
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))));
546
1187
  }
547
1188
  /** The whole terminal app; state arrives via the store, output via Ink. */
548
1189
  function App(props) {
@@ -574,13 +1215,39 @@ function App(props) {
574
1215
  };
575
1216
  }, [modelOpen]);
576
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
+ });
577
1238
  return createElement(Box, { flexDirection: "column" }, createElement(Header, { resumed: props.resumed }), createElement(Box, {
578
1239
  flexDirection: "column",
579
1240
  paddingX: 1
580
- }, ...view.entries.map((entry, index) => createElement(EntryLine, {
581
- key: index,
582
- entry
583
- })), view.streaming !== "" ? createElement(Text, null, displayText(view.streaming)) : void 0, busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "thinking…") : void 0), createElement(TodoPanel, { todos: view.todos }), createElement(ApprovalBar, { approval: props.approval }), modelOpen ? createElement(ModelPanel, {
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, {
584
1251
  directory,
585
1252
  error: modelError,
586
1253
  onSelect: (row) => {
@@ -595,6 +1262,7 @@ function App(props) {
595
1262
  key: index,
596
1263
  dimColor: true
597
1264
  }, notice))), createElement(Input, {
1265
+ active: inputActive,
598
1266
  busy,
599
1267
  descriptors,
600
1268
  skills,
@@ -605,17 +1273,25 @@ function App(props) {
605
1273
  openModel: () => {
606
1274
  setModelOpen(true);
607
1275
  },
608
- notify
1276
+ notify,
1277
+ toggleReasoning: () => {
1278
+ setShowReasoning((current) => !current);
1279
+ },
1280
+ loadMentions: props.loadMentions,
1281
+ cyclePermission: props.cyclePermission,
1282
+ onMenuState: setMenuState
609
1283
  }), createElement(StatusLine, {
610
1284
  facts: {
611
1285
  model: modelLabel,
612
1286
  cwd: props.cwd,
613
1287
  branch: props.branch,
614
- sessionId: props.sessionId
1288
+ sessionId: props.sessionId,
1289
+ plan: view.plan,
1290
+ permission: view.permission
615
1291
  },
616
1292
  stats: view.stats,
617
1293
  busy
618
- }));
1294
+ }), createElement(CompletionMenu, { state: menuState }));
619
1295
  }
620
1296
  //#endregion
621
1297
  //#region src/approval.ts
@@ -807,6 +1483,264 @@ async function loadModelDirectory(ctx) {
807
1483
  };
808
1484
  }
809
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
810
1744
  //#region src/render/projection.ts
811
1745
  /**
812
1746
  * Pure session-event-to-view projection for the TUI transcript: one reducer
@@ -820,14 +1754,21 @@ async function loadModelDirectory(ctx) {
820
1754
  function textOf(content) {
821
1755
  return content.filter((block) => block.type === "text").map((block) => block.text).join("");
822
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
+ }
823
1761
  /** A fresh, empty transcript view. */
824
1762
  function createTranscriptView() {
825
1763
  return {
826
1764
  entries: [],
827
1765
  streaming: "",
1766
+ streamingReasoning: "",
828
1767
  todos: [],
829
1768
  busy: false,
830
1769
  model: "",
1770
+ plan: false,
1771
+ permission: "",
831
1772
  stats: {
832
1773
  turns: 0,
833
1774
  steps: 0,
@@ -859,7 +1800,8 @@ function projectEvent(view, event) {
859
1800
  ...view,
860
1801
  entries: [...view.entries, {
861
1802
  kind: "user",
862
- text: textOf(message.content)
1803
+ text: textOf(message.content),
1804
+ notice: false
863
1805
  }]
864
1806
  };
865
1807
  const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
@@ -867,17 +1809,22 @@ function projectEvent(view, event) {
867
1809
  ...view,
868
1810
  entries: [...view.entries, {
869
1811
  kind: "user",
870
- text: boundContextSummary(notice)
1812
+ text: boundContextSummary(notice),
1813
+ notice: true
871
1814
  }]
872
1815
  };
873
1816
  }
874
1817
  case "assistant/chunk": {
875
1818
  const chunk = event.data.chunk;
876
- if (chunk.type !== "text-delta") return view;
877
- return {
1819
+ if (chunk.type === "text-delta") return {
878
1820
  ...view,
879
1821
  streaming: view.streaming + chunk.text
880
1822
  };
1823
+ if (chunk.type === "reasoning-delta") return {
1824
+ ...view,
1825
+ streamingReasoning: view.streamingReasoning + chunk.text
1826
+ };
1827
+ return view;
881
1828
  }
882
1829
  case "assistant/message": {
883
1830
  const key = `${event.data.turn}:${event.data.step}`;
@@ -888,9 +1835,11 @@ function projectEvent(view, event) {
888
1835
  return {
889
1836
  ...view,
890
1837
  streaming: "",
1838
+ streamingReasoning: "",
891
1839
  entries: [...view.entries, {
892
1840
  kind: "assistant",
893
- text: textOf(event.data.message.content)
1841
+ text: textOf(event.data.message.content),
1842
+ reasoning: reasoningOf(event.data.message.content)
894
1843
  }],
895
1844
  stats: {
896
1845
  ...view.stats,
@@ -913,6 +1862,7 @@ function projectEvent(view, event) {
913
1862
  callId: data.callId,
914
1863
  name: data.name,
915
1864
  arguments: data.arguments,
1865
+ preview: toolArgumentsPreview(data.arguments, data.name),
916
1866
  state: "running",
917
1867
  summary: ""
918
1868
  }]
@@ -984,6 +1934,14 @@ function projectEvent(view, event) {
984
1934
  model: `${config.provider}/${config.model}`
985
1935
  };
986
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
+ };
987
1945
  case "command/run": {
988
1946
  const data = event.data;
989
1947
  return {
@@ -1199,25 +2157,7 @@ function approvalCommandPreview(events, callId, toolName) {
1199
2157
  if (callId === void 0) return "";
1200
2158
  const entry = events.find((candidate) => candidate.kind === "tool" && candidate.callId === callId);
1201
2159
  if (entry === void 0) return "";
1202
- const args = entry.arguments ?? "";
1203
- try {
1204
- const parsed = JSON.parse(args);
1205
- if (parsed !== null && typeof parsed === "object") {
1206
- const record = parsed;
1207
- for (const key of [
1208
- "command",
1209
- "cmd",
1210
- "description",
1211
- "path",
1212
- "pattern",
1213
- "query"
1214
- ]) {
1215
- const value = record[key];
1216
- if (typeof value === "string" && value !== "") return value;
1217
- }
1218
- }
1219
- } catch {}
1220
- return args.length > 80 ? `${args.slice(0, 77)}...` : args === "" ? toolName : args;
2160
+ return toolArgumentsPreview(entry.arguments ?? "", toolName);
1221
2161
  }
1222
2162
  /**
1223
2163
  * Run the interactive terminal session: resolve the target session, create or
@@ -1298,6 +2238,8 @@ async function run(ctx, startup, io) {
1298
2238
  const skills = watchSkills(ctx);
1299
2239
  skills.setAgent(agent);
1300
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);
1301
2243
  const bridge = { notify: () => {} };
1302
2244
  const mountRef = {};
1303
2245
  let quitting = false;
@@ -1312,37 +2254,70 @@ async function run(ctx, startup, io) {
1312
2254
  io.exit(0);
1313
2255
  });
1314
2256
  };
1315
- /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
1316
- const dispatch = (text) => {
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({
2267
+ content: [{
2268
+ type: "text",
2269
+ text: line
2270
+ }],
2271
+ source: { kind: "user" }
2272
+ }));
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) => {
1317
2279
  const line = text.trim();
1318
2280
  if (line === "") return;
1319
- if (isSlashLine(line)) {
1320
- const registry = ctx.get("commands");
1321
- if (registry === void 0) {
1322
- bridge.notify("no command registry is mounted in this composition");
1323
- return;
1324
- }
1325
- const controller = new AbortController();
1326
- registry.execute(agent, line, controller.signal).then((execution) => {
1327
- if (execution === void 0) agent.followup(createUserMessage({
1328
- content: [{
1329
- type: "text",
1330
- text: line
1331
- }],
1332
- source: { kind: "user" }
1333
- }));
1334
- }, (error) => {
1335
- bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
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" }
1336
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);
1337
2308
  return;
1338
2309
  }
1339
- agent.followup(createUserMessage({
1340
- content: [{
1341
- type: "text",
1342
- text: line
1343
- }],
1344
- source: { kind: "user" }
1345
- }));
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");
1346
2321
  };
1347
2322
  /**
1348
2323
  * Submit steering: a running driver consumes the text at its next step
@@ -1350,16 +2325,7 @@ async function run(ctx, startup, io) {
1350
2325
  * a turn, so this doubles as the busy-state submit path.
1351
2326
  */
1352
2327
  const steer = (text) => {
1353
- const line = text.trim();
1354
- if (line === "") return;
1355
- agent.steer(createUserMessage({
1356
- content: [{
1357
- type: "text",
1358
- text: line
1359
- }],
1360
- source: { kind: "user" }
1361
- }));
1362
- bridge.notify("steering queued — the next step sees it");
2328
+ send(text, "steer");
1363
2329
  };
1364
2330
  /** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
1365
2331
  const interrupt = () => {
@@ -1368,6 +2334,23 @@ async function run(ctx, startup, io) {
1368
2334
  bridge.notify("turn cancelled — Ctrl+C or /quit to exit");
1369
2335
  return true;
1370
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
+ };
1371
2354
  /** Apply one /model selection: takes effect from the next assembled step. */
1372
2355
  const selectModel = (row) => {
1373
2356
  picked = {
@@ -1380,6 +2363,7 @@ async function run(ctx, startup, io) {
1380
2363
  mountRef.current = io.mount(createElement(App, {
1381
2364
  store,
1382
2365
  approval,
2366
+ questions,
1383
2367
  commands,
1384
2368
  skills,
1385
2369
  model: initialModel,
@@ -1392,6 +2376,8 @@ async function run(ctx, startup, io) {
1392
2376
  interrupt,
1393
2377
  quit,
1394
2378
  loadModels: () => loadModelDirectory(ctx),
2379
+ loadMentions: mentions.candidates,
2380
+ cyclePermission,
1395
2381
  selectModel,
1396
2382
  onBridgeReady: (instance) => {
1397
2383
  bridge.notify = instance.notify;