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/README.md +7 -2
- package/README.zh.md +7 -2
- package/cordis.patch.yml +6 -0
- package/lib/index.mjs +1091 -105
- package/lib/types/app.d.ts +29 -0
- package/lib/types/mentions.d.ts +70 -0
- package/lib/types/questions.d.ts +48 -0
- package/lib/types/render/animations.d.ts +15 -0
- package/lib/types/render/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +13 -0
- package/lib/types/render/status.d.ts +4 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/theme.d.ts +4 -0
- package/package.json +10 -2
- package/src/app.ts +582 -78
- package/src/index.ts +110 -46
- package/src/mentions.ts +193 -0
- package/src/questions.ts +143 -0
- package/src/render/animations.ts +22 -0
- package/src/render/markdown.ts +235 -0
- package/src/render/projection.ts +48 -7
- package/src/render/status.ts +14 -2
- package/src/render/tool-preview.ts +34 -0
- package/src/theme.ts +4 -0
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(
|
|
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(
|
|
212
|
-
return createElement(Text, null, mark, " ", brand(entry.name), entry.
|
|
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(
|
|
216
|
-
return createElement(Text, null, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}
|
|
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
|
-
/**
|
|
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 },
|
|
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.
|
|
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, {
|
|
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
|
|
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
|
|
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 (
|
|
463
|
-
setCompletionIndex((index) => (index +
|
|
1093
|
+
if (menuActive && key.upArrow) {
|
|
1094
|
+
setCompletionIndex((index) => (index + menuRows.length - 1) % menuRows.length);
|
|
464
1095
|
return;
|
|
465
1096
|
}
|
|
466
|
-
if (
|
|
467
|
-
setCompletionIndex((index) => (index + 1) %
|
|
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 &&
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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, {
|
|
1179
|
+
return createElement(Box, {
|
|
540
1180
|
flexDirection: "column",
|
|
541
|
-
|
|
542
|
-
},
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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.
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
})), view.streaming !== "" ? createElement(Text, null, displayText(view.streaming)) : void 0, busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
1316
|
-
const
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
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;
|