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