dsh-code 0.1.0 → 0.2.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 +11 -3
- package/README.zh.md +11 -3
- package/cordis.patch.yml +16 -5
- package/lib/index.mjs +890 -88
- package/lib/invariant.mjs +1 -1
- package/lib/startup.mjs +70 -0
- package/lib/types/app.d.ts +35 -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/models.d.ts +37 -0
- package/lib/types/render/projection.d.ts +24 -3
- package/lib/types/render/text.d.ts +18 -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/package.json +28 -3
- package/src/app.ts +452 -42
- package/src/approval.ts +126 -0
- package/src/commands.ts +71 -0
- package/src/index.ts +289 -40
- package/src/invariant.ts +3 -3
- package/src/models.ts +66 -0
- package/src/render/projection.ts +70 -4
- package/src/render/text.ts +24 -0
- package/src/skills.ts +104 -0
- package/src/startup.ts +91 -0
- package/src/store.ts +10 -4
package/lib/index.mjs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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, 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
9
|
import { Box, Text, render, useInput } from "ink";
|
|
9
10
|
import chalk from "chalk";
|
|
11
|
+
import { isUserInvocable } from "@deepseek-ai/dsh-skill";
|
|
10
12
|
//#region src/theme.ts
|
|
11
13
|
/**
|
|
12
14
|
* Terminal color tokens for the dsh TUI, mapped from the product design
|
|
@@ -76,6 +78,10 @@ function dim(text) {
|
|
|
76
78
|
function error(text) {
|
|
77
79
|
return chalk.rgb(...TUI_RGB.error)(text);
|
|
78
80
|
}
|
|
81
|
+
/** Paint warnings. */
|
|
82
|
+
function warn(text) {
|
|
83
|
+
return chalk.rgb(...TUI_RGB.warn)(text);
|
|
84
|
+
}
|
|
79
85
|
//#endregion
|
|
80
86
|
//#region src/whale-glyph.ts
|
|
81
87
|
/** Half-block whale glyph rows; render with the brand color. */
|
|
@@ -152,19 +158,45 @@ function buildStatusGroups(facts, stats) {
|
|
|
152
158
|
return groups;
|
|
153
159
|
}
|
|
154
160
|
//#endregion
|
|
161
|
+
//#region src/render/text.ts
|
|
162
|
+
/**
|
|
163
|
+
* Display-boundary sanitization for externally sourced text (model output,
|
|
164
|
+
* tool payloads, skill descriptions). Control characters — including ANSI
|
|
165
|
+
* CSI/OSC escape sequences — would otherwise pass through Ink into the
|
|
166
|
+
* terminal, letting output rewrite the screen or inject prompts. Newlines
|
|
167
|
+
* and tabs survive; everything else in C0/C1 plus DEL becomes a visible
|
|
168
|
+
* `\xNN` escape.
|
|
169
|
+
*
|
|
170
|
+
* @module @deepseek-ai/dsh-code/render/text
|
|
171
|
+
*/
|
|
172
|
+
/** C0 controls except tab (0x09) and newline (0x0a), plus DEL and C1. */
|
|
173
|
+
const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
|
|
174
|
+
/**
|
|
175
|
+
* Escape control characters so externally sourced text cannot drive the
|
|
176
|
+
* terminal.
|
|
177
|
+
* @param text - raw text from a session event, tool payload, or catalog.
|
|
178
|
+
* @returns text with every control character (except `\n`, `\t`) rendered
|
|
179
|
+
* as a literal `\xNN` escape.
|
|
180
|
+
*/
|
|
181
|
+
function displayText(text) {
|
|
182
|
+
return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
155
185
|
//#region src/app.ts
|
|
156
186
|
/**
|
|
157
187
|
* The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
|
|
158
|
-
* transcript, the
|
|
159
|
-
*
|
|
160
|
-
*
|
|
188
|
+
* transcript, the todo panel, the streaming line, the approval bar, the model
|
|
189
|
+
* panel, local notices, and the input box with history and slash-command
|
|
190
|
+
* completion. All state arrives through the transcript store (derived from
|
|
191
|
+
* the durable session log) plus local input state; the app owns no session
|
|
192
|
+
* mutation of its own.
|
|
161
193
|
*
|
|
162
194
|
* Element construction uses `createElement` (not JSX): the `dsh` source launch
|
|
163
195
|
* compiles this file through tsx's ESM-only hook, which does not adopt this
|
|
164
196
|
* package's `jsx: react-jsx` compiler option, and the classic JSX runtime
|
|
165
197
|
* would demand a React global.
|
|
166
198
|
*
|
|
167
|
-
* @module @deepseek-ai/dsh-
|
|
199
|
+
* @module @deepseek-ai/dsh-code/app
|
|
168
200
|
*/
|
|
169
201
|
/** Ink `color` string for one palette triple. */
|
|
170
202
|
function inkColor(triple) {
|
|
@@ -173,18 +205,22 @@ function inkColor(triple) {
|
|
|
173
205
|
/** One settled transcript row. */
|
|
174
206
|
function EntryLine({ entry }) {
|
|
175
207
|
switch (entry.kind) {
|
|
176
|
-
case "user": return createElement(Text, null, brand("❯ "), entry.text);
|
|
177
|
-
case "assistant": return createElement(Text, null, entry.text);
|
|
208
|
+
case "user": return createElement(Text, null, brand("❯ "), displayText(entry.text));
|
|
209
|
+
case "assistant": return createElement(Text, null, displayText(entry.text));
|
|
178
210
|
case "tool": {
|
|
179
211
|
const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
|
|
180
|
-
return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(entry.summary)}`);
|
|
212
|
+
return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(displayText(entry.summary))}`);
|
|
181
213
|
}
|
|
182
|
-
case "
|
|
214
|
+
case "command": {
|
|
215
|
+
const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
|
|
216
|
+
return createElement(Text, null, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`, entry.summary === "" ? "" : ` ${dim(displayText(entry.summary))}`);
|
|
217
|
+
}
|
|
218
|
+
case "error": return createElement(Text, null, error(displayText(entry.text)));
|
|
183
219
|
default: return assertNever(entry, "transcript entry kind");
|
|
184
220
|
}
|
|
185
221
|
}
|
|
186
222
|
/** The whale wordmark header in DeepSeek blue, hugging its content width. */
|
|
187
|
-
function Header() {
|
|
223
|
+
function Header({ resumed }) {
|
|
188
224
|
return createElement(Box, {
|
|
189
225
|
flexDirection: "row",
|
|
190
226
|
gap: 1,
|
|
@@ -204,7 +240,32 @@ function Header() {
|
|
|
204
240
|
}, createElement(Text, {
|
|
205
241
|
color: inkColor(TUI_RGB.brand),
|
|
206
242
|
bold: true
|
|
207
|
-
}, "DeepSeek Harness"), createElement(Text, { dimColor: true }, "/help commands · Ctrl+C quit")));
|
|
243
|
+
}, "DeepSeek Harness"), createElement(Text, { dimColor: true }, resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit")));
|
|
244
|
+
}
|
|
245
|
+
/** Todo status glyph: web TodoPanel's three-state marker. */
|
|
246
|
+
function todoMark(status) {
|
|
247
|
+
return status === "completed" ? "✓" : status === "in_progress" ? "●" : "○";
|
|
248
|
+
}
|
|
249
|
+
/** Inline todo list (web TodoPanel's compact terminal form). */
|
|
250
|
+
function TodoPanel({ todos }) {
|
|
251
|
+
if (todos.length === 0) return void 0;
|
|
252
|
+
const completed = todos.filter((todo) => todo.status === "completed").length;
|
|
253
|
+
const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
|
|
254
|
+
const pending = todos.length - completed - inProgress;
|
|
255
|
+
return createElement(Box, {
|
|
256
|
+
flexDirection: "column",
|
|
257
|
+
paddingX: 1,
|
|
258
|
+
borderStyle: "round",
|
|
259
|
+
borderColor: inkColor(TUI_RGB.brandDeep),
|
|
260
|
+
alignSelf: "flex-start",
|
|
261
|
+
marginLeft: 1
|
|
262
|
+
}, createElement(Text, {
|
|
263
|
+
color: inkColor(TUI_RGB.brand),
|
|
264
|
+
bold: true
|
|
265
|
+
}, `todos ${completed}/${todos.length}`, createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`)), ...todos.map((todo, index) => createElement(Text, {
|
|
266
|
+
key: index,
|
|
267
|
+
color: todo.status === "completed" ? inkColor(TUI_RGB.success) : todo.status === "in_progress" ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
|
|
268
|
+
}, `${todoMark(todo.status)} ${displayText(todo.content)}`)));
|
|
208
269
|
}
|
|
209
270
|
/**
|
|
210
271
|
* The footer status line: Claude-Code-style identity facts (model, working
|
|
@@ -221,75 +282,468 @@ function StatusLine({ facts, stats, busy }) {
|
|
|
221
282
|
});
|
|
222
283
|
return createElement(Box, { paddingX: 1 }, ...children);
|
|
223
284
|
}
|
|
224
|
-
/** The
|
|
225
|
-
function
|
|
285
|
+
/** The y/n approval bar rendered while an approval ask is pending. */
|
|
286
|
+
function ApprovalBar({ approval }) {
|
|
287
|
+
const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot);
|
|
288
|
+
if (snapshot.pending === void 0) return void 0;
|
|
289
|
+
const { pending, answered } = snapshot;
|
|
290
|
+
return createElement(Box, {
|
|
291
|
+
flexDirection: "column",
|
|
292
|
+
paddingX: 1,
|
|
293
|
+
borderStyle: "round",
|
|
294
|
+
borderColor: inkColor(TUI_RGB.warn),
|
|
295
|
+
alignSelf: "flex-start",
|
|
296
|
+
marginLeft: 1
|
|
297
|
+
}, createElement(Text, {
|
|
298
|
+
color: inkColor(TUI_RGB.warn),
|
|
299
|
+
bold: true
|
|
300
|
+
}, "⏸ waiting for approval"), createElement(Text, null, warn(displayText(pending.headline))), pending.command === "" ? void 0 : createElement(Text, { dimColor: true }, dim(` ${displayText(pending.command)}`)), answered ? createElement(Text, { dimColor: true }, " submitted…") : createElement(Text, { dimColor: true }, dim(" y allow once · n reject")));
|
|
301
|
+
}
|
|
302
|
+
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
303
|
+
function ModelPanel({ directory, error, onSelect, onClose }) {
|
|
304
|
+
const [cursor, setCursor] = useState(0);
|
|
305
|
+
useInput((input, key) => {
|
|
306
|
+
if (key.escape || input === "q") {
|
|
307
|
+
onClose();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const rows = directory?.rows ?? [];
|
|
311
|
+
if (key.upArrow) {
|
|
312
|
+
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (key.downArrow) {
|
|
316
|
+
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
|
|
320
|
+
});
|
|
321
|
+
const rows = directory?.rows ?? [];
|
|
322
|
+
const window = 8;
|
|
323
|
+
const first = Math.max(0, Math.min(cursor - Math.floor(window / 2), rows.length - window));
|
|
324
|
+
const visible = rows.slice(Math.max(0, first), Math.max(0, first) + window);
|
|
325
|
+
return createElement(Box, {
|
|
326
|
+
flexDirection: "column",
|
|
327
|
+
paddingX: 1,
|
|
328
|
+
borderStyle: "round",
|
|
329
|
+
borderColor: inkColor(TUI_RGB.brand),
|
|
330
|
+
alignSelf: "flex-start",
|
|
331
|
+
marginLeft: 1
|
|
332
|
+
}, createElement(Text, {
|
|
333
|
+
color: inkColor(TUI_RGB.brand),
|
|
334
|
+
bold: true
|
|
335
|
+
}, "/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) => {
|
|
336
|
+
const index = rows.indexOf(row);
|
|
337
|
+
const label = displayText(`${row.providerName} · ${row.modelName}`);
|
|
338
|
+
return createElement(Text, {
|
|
339
|
+
key: `${row.provider}/${row.model}`,
|
|
340
|
+
color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
|
|
341
|
+
}, `${index === cursor ? "❯ " : " "}${label}`);
|
|
342
|
+
}), createElement(Text, { dimColor: true }, dim(" ↑↓ move · enter select · esc close")));
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Resolve completion candidates for the current input: TUI-local commands,
|
|
346
|
+
* the live registry descriptors, and user-invocable skills, filtered by the
|
|
347
|
+
* typed prefix. Command names win collisions (the dispatch tries the
|
|
348
|
+
* registry first and only then falls through to the skill gesture).
|
|
349
|
+
*/
|
|
350
|
+
function completionCandidates(value, descriptors, skills) {
|
|
351
|
+
if (!value.startsWith("/")) return [];
|
|
352
|
+
const prefix = value.slice(1).split(" ")[0] ?? "";
|
|
353
|
+
const local = [
|
|
354
|
+
{
|
|
355
|
+
label: "/help",
|
|
356
|
+
description: "show commands",
|
|
357
|
+
origin: "command"
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
label: "/model",
|
|
361
|
+
description: "switch the model",
|
|
362
|
+
origin: "command"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
label: "/clear",
|
|
366
|
+
description: "clear the screen",
|
|
367
|
+
origin: "command"
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
label: "/quit",
|
|
371
|
+
description: "exit",
|
|
372
|
+
origin: "command"
|
|
373
|
+
}
|
|
374
|
+
];
|
|
375
|
+
const registry = descriptors.map((descriptor) => ({
|
|
376
|
+
label: `/${descriptor.name}`,
|
|
377
|
+
description: descriptor.description,
|
|
378
|
+
origin: "command"
|
|
379
|
+
}));
|
|
380
|
+
const taken = new Set([...local, ...registry].map((candidate) => candidate.label.slice(1)));
|
|
381
|
+
const skillRows = skills.filter((skill) => !taken.has(skill.name)).map((skill) => ({
|
|
382
|
+
label: `/${skill.name}`,
|
|
383
|
+
description: skill.modelInvocable ? `skill · ${skill.description}` : `skill (user only) · ${skill.description}`,
|
|
384
|
+
origin: "skill"
|
|
385
|
+
}));
|
|
386
|
+
const all = [
|
|
387
|
+
...local,
|
|
388
|
+
...registry,
|
|
389
|
+
...skillRows
|
|
390
|
+
];
|
|
391
|
+
if (prefix === "") return all.slice(0, 10);
|
|
392
|
+
return all.filter((candidate) => candidate.label.slice(1).startsWith(prefix)).slice(0, 10);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
396
|
+
* dispatched; input editing keeps a cursor with history and completion.
|
|
397
|
+
*/
|
|
398
|
+
function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }) {
|
|
226
399
|
const [value, setValue] = useState("");
|
|
227
|
-
const [
|
|
400
|
+
const [cursor, setCursor] = useState(0);
|
|
401
|
+
const history = useRef([]);
|
|
402
|
+
const historyIndex = useRef(null);
|
|
403
|
+
const draft = useRef("");
|
|
404
|
+
const [completionIndex, setCompletionIndex] = useState(0);
|
|
405
|
+
const candidates = completionCandidates(value, descriptors, skills);
|
|
406
|
+
const completionActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
|
|
228
407
|
useInput((input, key) => {
|
|
229
|
-
if (key.ctrl &&
|
|
230
|
-
|
|
408
|
+
if (key.ctrl && input === "c") {
|
|
409
|
+
if (busy) interrupt();
|
|
410
|
+
else if (value !== "") {
|
|
411
|
+
setValue("");
|
|
412
|
+
setCursor(0);
|
|
413
|
+
setCompletionIndex(0);
|
|
414
|
+
} else quit();
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (key.ctrl && input === "d") {
|
|
418
|
+
if (busy) notify("cancel the running turn before exiting (Esc or Ctrl+C)");
|
|
419
|
+
else quit();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (key.escape) {
|
|
423
|
+
if (busy) interrupt();
|
|
231
424
|
return;
|
|
232
425
|
}
|
|
233
426
|
if (key.return) {
|
|
427
|
+
if (key.meta || key.ctrl && input === "j") {
|
|
428
|
+
setValue(value.slice(0, cursor) + "\n" + value.slice(cursor));
|
|
429
|
+
setCursor(cursor + 1);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
234
432
|
const text = value.trim();
|
|
235
433
|
setValue("");
|
|
434
|
+
setCursor(0);
|
|
435
|
+
setCompletionIndex(0);
|
|
236
436
|
if (text === "") return;
|
|
437
|
+
history.current = [...history.current, text];
|
|
438
|
+
historyIndex.current = null;
|
|
237
439
|
if (text === "/quit") {
|
|
238
|
-
|
|
440
|
+
quit();
|
|
239
441
|
return;
|
|
240
442
|
}
|
|
241
443
|
if (text === "/help") {
|
|
242
|
-
|
|
444
|
+
notify("/model switch · /clear clear the screen · /quit exit · other /commands reach the registry · Esc or Ctrl+C interrupts the running turn");
|
|
243
445
|
return;
|
|
244
446
|
}
|
|
245
447
|
if (text === "/clear") {
|
|
246
|
-
setNotices([]);
|
|
247
448
|
console.clear();
|
|
248
449
|
return;
|
|
249
450
|
}
|
|
250
|
-
if (
|
|
251
|
-
|
|
451
|
+
if (text === "/model" || text.startsWith("/model ")) {
|
|
452
|
+
openModel();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (busy && !text.startsWith("/")) {
|
|
456
|
+
steer(text);
|
|
252
457
|
return;
|
|
253
458
|
}
|
|
254
|
-
|
|
459
|
+
dispatch(text);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (completionActive && key.upArrow) {
|
|
463
|
+
setCompletionIndex((index) => (index + candidates.length - 1) % candidates.length);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (completionActive && key.downArrow) {
|
|
467
|
+
setCompletionIndex((index) => (index + 1) % candidates.length);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (key.upArrow) {
|
|
471
|
+
const entries = history.current;
|
|
472
|
+
if (entries.length === 0) return;
|
|
473
|
+
const next = historyIndex.current === null ? entries.length - 1 : Math.max(0, historyIndex.current - 1);
|
|
474
|
+
if (historyIndex.current === null) draft.current = value;
|
|
475
|
+
historyIndex.current = next;
|
|
476
|
+
setValue(entries[next] ?? "");
|
|
477
|
+
setCursor((entries[next] ?? "").length);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (key.downArrow) {
|
|
481
|
+
const entries = history.current;
|
|
482
|
+
if (historyIndex.current === null) return;
|
|
483
|
+
const next = historyIndex.current + 1;
|
|
484
|
+
if (next >= entries.length) {
|
|
485
|
+
historyIndex.current = null;
|
|
486
|
+
setValue(draft.current);
|
|
487
|
+
setCursor(draft.current.length);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
historyIndex.current = next;
|
|
491
|
+
setValue(entries[next] ?? "");
|
|
492
|
+
setCursor((entries[next] ?? "").length);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (key.tab && completionActive) {
|
|
496
|
+
const candidate = candidates[completionIndex % candidates.length];
|
|
497
|
+
if (candidate !== void 0) {
|
|
498
|
+
setValue(`${candidate.label} `);
|
|
499
|
+
setCursor(candidate.label.length + 1);
|
|
500
|
+
setCompletionIndex(0);
|
|
501
|
+
}
|
|
255
502
|
return;
|
|
256
503
|
}
|
|
257
504
|
if (key.backspace || key.delete) {
|
|
258
|
-
|
|
505
|
+
if (cursor > 0) {
|
|
506
|
+
setValue(value.slice(0, cursor - 1) + value.slice(cursor));
|
|
507
|
+
setCursor(cursor - 1);
|
|
508
|
+
setCompletionIndex(0);
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (key.leftArrow) {
|
|
513
|
+
setCursor(Math.max(0, cursor - 1));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (key.rightArrow) {
|
|
517
|
+
setCursor(Math.min(value.length, cursor + 1));
|
|
259
518
|
return;
|
|
260
519
|
}
|
|
261
|
-
if (input
|
|
520
|
+
if (key.ctrl && input === "u") {
|
|
521
|
+
setValue("");
|
|
522
|
+
setCursor(0);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (key.ctrl && input === "a") {
|
|
526
|
+
setCursor(0);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (key.ctrl && input === "e") {
|
|
530
|
+
setCursor(value.length);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (input !== "" && !key.ctrl && !key.meta) {
|
|
534
|
+
setValue(value.slice(0, cursor) + input + value.slice(cursor));
|
|
535
|
+
setCursor(cursor + input.length);
|
|
536
|
+
setCompletionIndex(0);
|
|
537
|
+
}
|
|
262
538
|
});
|
|
263
|
-
return createElement(Box, { flexDirection: "column" },
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
},
|
|
539
|
+
return createElement(Box, { flexDirection: "column" }, completionActive && !busy ? createElement(Box, {
|
|
540
|
+
flexDirection: "column",
|
|
541
|
+
marginLeft: 1
|
|
542
|
+
}, ...candidates.map((candidate, index) => createElement(Text, {
|
|
543
|
+
key: candidate.label,
|
|
544
|
+
color: index === completionIndex % candidates.length ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
|
|
545
|
+
}, `${index === completionIndex % candidates.length ? "❯ " : " "}${candidate.label} ${dim(displayText(candidate.description))}`)), createElement(Text, { dimColor: true }, dim(" ↑↓ choose · tab complete"))) : void 0, busy && value === "" ? createElement(Text, { dimColor: true }, dim(" enter steers the running turn · esc or ctrl+c cancels")) : void 0, createElement(Box, null, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), createElement(Text, null, value.slice(0, cursor)), createElement(Text, { inverse: true }, value.slice(cursor, cursor + 1) === "" ? " " : value.slice(cursor, cursor + 1)), createElement(Text, null, value.slice(cursor + 1))));
|
|
267
546
|
}
|
|
268
547
|
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
269
|
-
function App(
|
|
270
|
-
const view = useSyncExternalStore(store.subscribe, store.getView);
|
|
271
|
-
|
|
548
|
+
function App(props) {
|
|
549
|
+
const view = useSyncExternalStore(props.store.subscribe, props.store.getView);
|
|
550
|
+
const descriptors = useSyncExternalStore(props.commands.subscribe, () => props.commands.descriptors);
|
|
551
|
+
const skills = useSyncExternalStore(props.skills.subscribe, () => props.skills.rows);
|
|
552
|
+
const [modelLabel, setModelLabel] = useState(props.model);
|
|
553
|
+
const [modelOpen, setModelOpen] = useState(false);
|
|
554
|
+
const [directory, setDirectory] = useState(void 0);
|
|
555
|
+
const [modelError, setModelError] = useState(void 0);
|
|
556
|
+
const [notices, setNotices] = useState([]);
|
|
557
|
+
const notify = (text) => {
|
|
558
|
+
setNotices((current) => [...current, text]);
|
|
559
|
+
};
|
|
560
|
+
useEffect(() => {
|
|
561
|
+
props.onBridgeReady({ notify });
|
|
562
|
+
}, []);
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
if (!modelOpen || directory !== void 0) return;
|
|
565
|
+
let cancelled = false;
|
|
566
|
+
setModelError(void 0);
|
|
567
|
+
props.loadModels().then((loaded) => {
|
|
568
|
+
if (!cancelled) setDirectory(loaded);
|
|
569
|
+
}, (error) => {
|
|
570
|
+
if (!cancelled) setModelError(error instanceof Error ? error.message : String(error));
|
|
571
|
+
});
|
|
572
|
+
return () => {
|
|
573
|
+
cancelled = true;
|
|
574
|
+
};
|
|
575
|
+
}, [modelOpen]);
|
|
576
|
+
const busy = view.busy;
|
|
577
|
+
return createElement(Box, { flexDirection: "column" }, createElement(Header, { resumed: props.resumed }), createElement(Box, {
|
|
272
578
|
flexDirection: "column",
|
|
273
579
|
paddingX: 1
|
|
274
580
|
}, ...view.entries.map((entry, index) => createElement(EntryLine, {
|
|
275
581
|
key: index,
|
|
276
582
|
entry
|
|
277
|
-
})), view.streaming !== "" ? createElement(Text, null, view.streaming) : void 0,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
583
|
+
})), view.streaming !== "" ? createElement(Text, null, displayText(view.streaming)) : void 0, busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "thinking…") : void 0), createElement(TodoPanel, { todos: view.todos }), createElement(ApprovalBar, { approval: props.approval }), modelOpen ? createElement(ModelPanel, {
|
|
584
|
+
directory,
|
|
585
|
+
error: modelError,
|
|
586
|
+
onSelect: (row) => {
|
|
587
|
+
setModelLabel(props.selectModel(row));
|
|
588
|
+
notify(`model → next step uses ${row.provider}/${row.model}`);
|
|
589
|
+
setModelOpen(false);
|
|
590
|
+
},
|
|
591
|
+
onClose: () => {
|
|
592
|
+
setModelOpen(false);
|
|
593
|
+
}
|
|
594
|
+
}) : void 0, createElement(Box, { flexDirection: "column" }, ...notices.slice(-3).map((notice, index) => createElement(Text, {
|
|
595
|
+
key: index,
|
|
596
|
+
dimColor: true
|
|
597
|
+
}, notice))), createElement(Input, {
|
|
598
|
+
busy,
|
|
599
|
+
descriptors,
|
|
600
|
+
skills,
|
|
601
|
+
dispatch: props.dispatch,
|
|
602
|
+
steer: props.steer,
|
|
603
|
+
interrupt: props.interrupt,
|
|
604
|
+
quit: props.quit,
|
|
605
|
+
openModel: () => {
|
|
606
|
+
setModelOpen(true);
|
|
607
|
+
},
|
|
608
|
+
notify
|
|
281
609
|
}), createElement(StatusLine, {
|
|
282
610
|
facts: {
|
|
283
|
-
model,
|
|
284
|
-
cwd,
|
|
285
|
-
branch,
|
|
286
|
-
sessionId
|
|
611
|
+
model: modelLabel,
|
|
612
|
+
cwd: props.cwd,
|
|
613
|
+
branch: props.branch,
|
|
614
|
+
sessionId: props.sessionId
|
|
287
615
|
},
|
|
288
616
|
stats: view.stats,
|
|
289
|
-
busy
|
|
617
|
+
busy
|
|
290
618
|
}));
|
|
291
619
|
}
|
|
292
620
|
//#endregion
|
|
621
|
+
//#region src/approval.ts
|
|
622
|
+
/**
|
|
623
|
+
* Create the approval store and mount the answerer listener on the context.
|
|
624
|
+
* The listener claims only requests for `owns`-owned agents and defers every
|
|
625
|
+
* other request back into the waterfall (`next()`), so sibling answerers stay
|
|
626
|
+
* usable. An aborted ask never reaches the human. Plugin teardown removes the
|
|
627
|
+
* listener; the service then fails its own question closed.
|
|
628
|
+
* @param ctx - plugin context whose event bus carries `approval/request`.
|
|
629
|
+
* @param owns - agents this terminal answers for.
|
|
630
|
+
* @param preview - resolves a tool-call preview for a pending request (the
|
|
631
|
+
* request contract carries no arguments; the UI self-serves from the
|
|
632
|
+
* transcript projection via `callId`).
|
|
633
|
+
* @returns the store the renderer subscribes to.
|
|
634
|
+
*/
|
|
635
|
+
function mountApprovalAnswerer(ctx, owns, preview) {
|
|
636
|
+
let snapshot = {
|
|
637
|
+
pending: void 0,
|
|
638
|
+
answered: false
|
|
639
|
+
};
|
|
640
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
641
|
+
const set = (next) => {
|
|
642
|
+
snapshot = next;
|
|
643
|
+
for (const listener of listeners) listener();
|
|
644
|
+
};
|
|
645
|
+
ctx.on("approval/request", (request, next) => {
|
|
646
|
+
if (!owns(request.agent)) return next();
|
|
647
|
+
if (request.signal?.aborted === true) return Promise.resolve("cancelled");
|
|
648
|
+
let resolved = false;
|
|
649
|
+
let settle;
|
|
650
|
+
const withdraw = () => {
|
|
651
|
+
if (resolved) return;
|
|
652
|
+
resolved = true;
|
|
653
|
+
set({
|
|
654
|
+
pending: void 0,
|
|
655
|
+
answered: false
|
|
656
|
+
});
|
|
657
|
+
settle("cancelled");
|
|
658
|
+
};
|
|
659
|
+
if (request.signal !== void 0) request.signal.addEventListener("abort", withdraw, { once: true });
|
|
660
|
+
const pending = {
|
|
661
|
+
headline: request.reason ?? `tool ${request.toolName} asks for your approval`,
|
|
662
|
+
toolName: request.toolName,
|
|
663
|
+
command: preview(request),
|
|
664
|
+
answer: (outcome) => {
|
|
665
|
+
if (resolved) return;
|
|
666
|
+
resolved = true;
|
|
667
|
+
set({
|
|
668
|
+
pending,
|
|
669
|
+
answered: true
|
|
670
|
+
});
|
|
671
|
+
settle(outcome);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
set({
|
|
675
|
+
pending,
|
|
676
|
+
answered: false
|
|
677
|
+
});
|
|
678
|
+
return new Promise((resolve) => {
|
|
679
|
+
settle = resolve;
|
|
680
|
+
}).then((outcome) => {
|
|
681
|
+
if (outcome !== "cancelled") set({
|
|
682
|
+
pending: void 0,
|
|
683
|
+
answered: false
|
|
684
|
+
});
|
|
685
|
+
return outcome;
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
return {
|
|
689
|
+
subscribe(listener) {
|
|
690
|
+
listeners.add(listener);
|
|
691
|
+
return () => {
|
|
692
|
+
listeners.delete(listener);
|
|
693
|
+
};
|
|
694
|
+
},
|
|
695
|
+
getSnapshot() {
|
|
696
|
+
return snapshot;
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/commands.ts
|
|
702
|
+
/**
|
|
703
|
+
* Watch the live command registry. Reads the current list immediately and
|
|
704
|
+
* re-reads on every registry mutation or agent retarget; notification
|
|
705
|
+
* failures are contained by the registry itself, so this watcher only ever
|
|
706
|
+
* re-reads. Without a `commands` service the view stays empty and all lines
|
|
707
|
+
* fall through to normal prompts.
|
|
708
|
+
* @param ctx - context carrying the `commands` service (optional).
|
|
709
|
+
* @returns the view the completion menu subscribes to.
|
|
710
|
+
*/
|
|
711
|
+
function watchCommands(ctx) {
|
|
712
|
+
const commands = ctx.get("commands");
|
|
713
|
+
let agent;
|
|
714
|
+
let descriptors = [];
|
|
715
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
716
|
+
const refresh = () => {
|
|
717
|
+
if (commands === void 0 || agent === void 0) return;
|
|
718
|
+
descriptors = commands.list(agent);
|
|
719
|
+
for (const listener of listeners) listener();
|
|
720
|
+
};
|
|
721
|
+
if (commands !== void 0) ctx.on("commands/change", () => refresh());
|
|
722
|
+
return {
|
|
723
|
+
get descriptors() {
|
|
724
|
+
return descriptors;
|
|
725
|
+
},
|
|
726
|
+
subscribe(listener) {
|
|
727
|
+
listeners.add(listener);
|
|
728
|
+
return () => {
|
|
729
|
+
listeners.delete(listener);
|
|
730
|
+
};
|
|
731
|
+
},
|
|
732
|
+
setAgent(next) {
|
|
733
|
+
agent = next;
|
|
734
|
+
refresh();
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Whether one command line is a syntactically valid slash command.
|
|
740
|
+
* @param line - the complete candidate line.
|
|
741
|
+
* @returns true when the line parses as `/name` or `/name input`.
|
|
742
|
+
*/
|
|
743
|
+
function isSlashLine(line) {
|
|
744
|
+
return /^\/[a-z][a-z0-9_-]*(?=$|[\t ])/u.test(line);
|
|
745
|
+
}
|
|
746
|
+
//#endregion
|
|
293
747
|
//#region src/internals.ts
|
|
294
748
|
/**
|
|
295
749
|
* Injectable process-facing effects for the TUI runner. Tests substitute the
|
|
@@ -309,6 +763,50 @@ const internals = {
|
|
|
309
763
|
stderr: process.stderr
|
|
310
764
|
};
|
|
311
765
|
//#endregion
|
|
766
|
+
//#region src/models.ts
|
|
767
|
+
/**
|
|
768
|
+
* Load the selectable model directory from the live `ctx.llm` registry.
|
|
769
|
+
* Providers are listed synchronously; each provider's models are discovered
|
|
770
|
+
* with a bounded parallel fan-out whose failures degrade to that provider
|
|
771
|
+
* contributing no rows (mirrors the web catalog's per-provider failures).
|
|
772
|
+
* @param ctx - context carrying the `llm` service.
|
|
773
|
+
* @returns the resolved directory; empty rows when `llm` is unavailable.
|
|
774
|
+
*/
|
|
775
|
+
async function loadModelDirectory(ctx) {
|
|
776
|
+
const llm = ctx.get("llm");
|
|
777
|
+
if (llm === void 0) return {
|
|
778
|
+
rows: [],
|
|
779
|
+
failures: []
|
|
780
|
+
};
|
|
781
|
+
const providers = llm.listProviders();
|
|
782
|
+
const listed = await Promise.all(providers.map(async (provider) => {
|
|
783
|
+
try {
|
|
784
|
+
const models = await llm.listModels(provider.id);
|
|
785
|
+
return {
|
|
786
|
+
provider: provider.id,
|
|
787
|
+
providerName: provider.name,
|
|
788
|
+
models: models.map((model) => ({
|
|
789
|
+
provider: provider.id,
|
|
790
|
+
providerName: provider.name,
|
|
791
|
+
model: model.id,
|
|
792
|
+
modelName: model.name
|
|
793
|
+
}))
|
|
794
|
+
};
|
|
795
|
+
} catch {
|
|
796
|
+
return {
|
|
797
|
+
provider: provider.id,
|
|
798
|
+
providerName: provider.name,
|
|
799
|
+
models: [],
|
|
800
|
+
failed: true
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
}));
|
|
804
|
+
return {
|
|
805
|
+
rows: listed.flatMap((entry) => entry.models),
|
|
806
|
+
failures: listed.filter((entry) => "failed" in entry && entry.failed === true).map((entry) => entry.provider)
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
//#endregion
|
|
312
810
|
//#region src/render/projection.ts
|
|
313
811
|
/**
|
|
314
812
|
* Pure session-event-to-view projection for the TUI transcript: one reducer
|
|
@@ -329,6 +827,7 @@ function createTranscriptView() {
|
|
|
329
827
|
streaming: "",
|
|
330
828
|
todos: [],
|
|
331
829
|
busy: false,
|
|
830
|
+
model: "",
|
|
332
831
|
stats: {
|
|
333
832
|
turns: 0,
|
|
334
833
|
steps: 0,
|
|
@@ -448,6 +947,7 @@ function projectEvent(view, event) {
|
|
|
448
947
|
case "turn/start": return {
|
|
449
948
|
...view,
|
|
450
949
|
busy: true,
|
|
950
|
+
todos: [],
|
|
451
951
|
stats: {
|
|
452
952
|
...view.stats,
|
|
453
953
|
turns: view.stats.turns + 1
|
|
@@ -477,17 +977,67 @@ function projectEvent(view, event) {
|
|
|
477
977
|
}]
|
|
478
978
|
};
|
|
479
979
|
}
|
|
980
|
+
case "request/header": {
|
|
981
|
+
const config = event.data.header.config;
|
|
982
|
+
return {
|
|
983
|
+
...view,
|
|
984
|
+
model: `${config.provider}/${config.model}`
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
case "command/run": {
|
|
988
|
+
const data = event.data;
|
|
989
|
+
return {
|
|
990
|
+
...view,
|
|
991
|
+
entries: [...view.entries, {
|
|
992
|
+
kind: "command",
|
|
993
|
+
commandId: data.commandId,
|
|
994
|
+
name: data.name,
|
|
995
|
+
args: data.args ?? "",
|
|
996
|
+
state: "running",
|
|
997
|
+
summary: ""
|
|
998
|
+
}]
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
case "command/done": {
|
|
1002
|
+
const data = event.data;
|
|
1003
|
+
const entries = view.entries.map((entry) => {
|
|
1004
|
+
if (entry.kind !== "command" || entry.commandId !== data.commandId) return entry;
|
|
1005
|
+
return {
|
|
1006
|
+
...entry,
|
|
1007
|
+
state: data.kind === "success" ? "done" : "error",
|
|
1008
|
+
summary: boundContextSummary(data.text ?? "")
|
|
1009
|
+
};
|
|
1010
|
+
});
|
|
1011
|
+
return {
|
|
1012
|
+
...view,
|
|
1013
|
+
entries
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
480
1016
|
default: return view;
|
|
481
1017
|
}
|
|
482
1018
|
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Fold a replayed event history into one view.
|
|
1021
|
+
* @param events - events in `seq` order.
|
|
1022
|
+
* @returns the folded view.
|
|
1023
|
+
*/
|
|
1024
|
+
function projectEvents(events) {
|
|
1025
|
+
return events.reduce(projectEvent, createTranscriptView());
|
|
1026
|
+
}
|
|
483
1027
|
//#endregion
|
|
484
1028
|
//#region src/store.ts
|
|
485
1029
|
/**
|
|
486
|
-
* Create one transcript store.
|
|
1030
|
+
* Create one transcript store, optionally seeded with replayed history. The
|
|
1031
|
+
* seed folds synchronously BEFORE the first render, so a resumed session
|
|
1032
|
+
* paints its full transcript on mount (no live `session/event` fires for
|
|
1033
|
+
* constructor seeds — the store's `session/event` feed only carries new
|
|
1034
|
+
* appends).
|
|
1035
|
+
* @param replay - persisted events in `seq` order (e.g. a resumed session's
|
|
1036
|
+
* constructor seed); folded once and never re-notified.
|
|
487
1037
|
* @returns the store the runner feeds and the renderer subscribes to.
|
|
488
1038
|
*/
|
|
489
|
-
function createTranscriptStore() {
|
|
490
|
-
let view = createTranscriptView();
|
|
1039
|
+
function createTranscriptStore(replay) {
|
|
1040
|
+
let view = replay === void 0 ? createTranscriptView() : projectEvents(replay);
|
|
491
1041
|
const listeners = /* @__PURE__ */ new Set();
|
|
492
1042
|
return {
|
|
493
1043
|
getView: () => view,
|
|
@@ -506,16 +1056,69 @@ function createTranscriptStore() {
|
|
|
506
1056
|
};
|
|
507
1057
|
}
|
|
508
1058
|
//#endregion
|
|
1059
|
+
//#region src/skills.ts
|
|
1060
|
+
function toRows(skills) {
|
|
1061
|
+
return skills.filter((skill) => isUserInvocable(skill)).map((skill) => ({
|
|
1062
|
+
name: skill.name,
|
|
1063
|
+
description: skill.description,
|
|
1064
|
+
modelInvocable: skill.invocation.modelInvocable === true
|
|
1065
|
+
})).sort((left, right) => left.name < right.name ? -1 : 1);
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Watch the user-invocable skill catalog for one agent's workspace. The first
|
|
1069
|
+
* load starts when the owning agent is known (`setAgent`); `skills/change`
|
|
1070
|
+
* and agent retargets re-read. Read failures keep the last good rows (the
|
|
1071
|
+
* next change notification is the retry surface) — a missing `skills`
|
|
1072
|
+
* service leaves the view permanently empty.
|
|
1073
|
+
* @param ctx - context carrying the `skills` service (optional).
|
|
1074
|
+
* @returns the view the completion menu subscribes to.
|
|
1075
|
+
*/
|
|
1076
|
+
function watchSkills(ctx) {
|
|
1077
|
+
const skills = ctx.get("skills");
|
|
1078
|
+
let agent;
|
|
1079
|
+
let rows = [];
|
|
1080
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1081
|
+
const reload = () => {
|
|
1082
|
+
if (skills === void 0 || agent === void 0) return;
|
|
1083
|
+
skills.list({
|
|
1084
|
+
cwd: agent.session.header.cwd,
|
|
1085
|
+
scope: agent
|
|
1086
|
+
}).then((summaries) => {
|
|
1087
|
+
const next = toRows(summaries);
|
|
1088
|
+
if (next.length === rows.length && next.every((row, index) => row.name === rows[index]?.name)) return;
|
|
1089
|
+
rows = next;
|
|
1090
|
+
for (const listener of listeners) listener();
|
|
1091
|
+
}, () => {});
|
|
1092
|
+
};
|
|
1093
|
+
if (skills !== void 0) ctx.on("skills/change", reload);
|
|
1094
|
+
return {
|
|
1095
|
+
get rows() {
|
|
1096
|
+
return rows;
|
|
1097
|
+
},
|
|
1098
|
+
subscribe(listener) {
|
|
1099
|
+
listeners.add(listener);
|
|
1100
|
+
return () => {
|
|
1101
|
+
listeners.delete(listener);
|
|
1102
|
+
};
|
|
1103
|
+
},
|
|
1104
|
+
setAgent(next) {
|
|
1105
|
+
agent = next;
|
|
1106
|
+
reload();
|
|
1107
|
+
}
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
//#endregion
|
|
509
1111
|
//#region src/index.ts
|
|
510
1112
|
/**
|
|
511
|
-
* @deepseek-ai/dsh-
|
|
1113
|
+
* @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
|
|
512
1114
|
* 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
|
|
1115
|
+
* creates (or resumes) one Agent through the core registry, mounts the Ink
|
|
1116
|
+
* app (DeepSeek blue, whale wordmark), folds submitted prompts into the same
|
|
1117
|
+
* durable session, answers approval asks with a y/n bar, dispatches slash
|
|
1118
|
+
* commands through the shared registry, and on quit flushes and requests
|
|
1119
|
+
* process exit.
|
|
517
1120
|
*
|
|
518
|
-
* @module @deepseek-ai/dsh-
|
|
1121
|
+
* @module @deepseek-ai/dsh-code
|
|
519
1122
|
*/
|
|
520
1123
|
/** Stable Cordis plugin name. */
|
|
521
1124
|
const name = "tui-runner";
|
|
@@ -525,6 +1128,10 @@ const inject = [
|
|
|
525
1128
|
"agents",
|
|
526
1129
|
"sessions"
|
|
527
1130
|
];
|
|
1131
|
+
const Config = z.object({ startup: z.object({
|
|
1132
|
+
kind: z.string().required(),
|
|
1133
|
+
sessionId: z.string()
|
|
1134
|
+
}) });
|
|
528
1135
|
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
529
1136
|
function fail(io, error) {
|
|
530
1137
|
internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -543,36 +1150,155 @@ function gitBranch(cwd) {
|
|
|
543
1150
|
}
|
|
544
1151
|
}
|
|
545
1152
|
/**
|
|
546
|
-
*
|
|
547
|
-
*
|
|
1153
|
+
* Resolve the invocation's target session against the persisted headers.
|
|
1154
|
+
* @param startup - the parsed startup flags.
|
|
1155
|
+
* @param persistence - the persistence service; required for resume/latest.
|
|
1156
|
+
* @param cwd - the working directory `--continue` filters by.
|
|
1157
|
+
* @returns the target identity.
|
|
1158
|
+
* @throws with a user-facing message when the flags name nothing resolvable.
|
|
1159
|
+
*/
|
|
1160
|
+
async function resolveTarget(startup, persistence, cwd) {
|
|
1161
|
+
if (startup.kind === "fresh") return {
|
|
1162
|
+
sessionId: `session-${randomUUID()}`,
|
|
1163
|
+
resume: false
|
|
1164
|
+
};
|
|
1165
|
+
if (startup.kind === "named") return {
|
|
1166
|
+
sessionId: startup.sessionId,
|
|
1167
|
+
resume: false
|
|
1168
|
+
};
|
|
1169
|
+
if (persistence === void 0) throw new Error("cannot resolve the requested session: session persistence is not configured");
|
|
1170
|
+
const headers = await persistence.list();
|
|
1171
|
+
if (startup.kind === "resume") {
|
|
1172
|
+
const wanted = startup.sessionId;
|
|
1173
|
+
const exact = headers.filter((header) => header.id === wanted);
|
|
1174
|
+
const matches = exact.length > 0 ? exact : headers.filter((header) => header.id.startsWith(wanted));
|
|
1175
|
+
if (matches.length === 0) throw new Error(`no persisted session matches "${wanted}"`);
|
|
1176
|
+
if (matches.length > 1) throw new Error(`session prefix "${wanted}" is ambiguous (${matches.length} matches): use more of the id`);
|
|
1177
|
+
return {
|
|
1178
|
+
sessionId: matches[0].id,
|
|
1179
|
+
resume: true
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
const local = headers.filter((header) => header.cwd === cwd).sort((left, right) => right.createdAt - left.createdAt);
|
|
1183
|
+
if (local.length === 0) throw new Error(`no persisted session for this directory (${cwd}); start one without --continue`);
|
|
1184
|
+
return {
|
|
1185
|
+
sessionId: local[0].id,
|
|
1186
|
+
resume: true
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Resolve a bounded command preview for one pending approval: the request
|
|
1191
|
+
* contract carries no arguments, so the bar self-serves from the transcript
|
|
1192
|
+
* projection via `callId` (mirrors the web ApprovalPanel's argsRaw lookup).
|
|
1193
|
+
* @param events - the transcript entries to search.
|
|
1194
|
+
* @param callId - the tool call the question is about, when the asker had one.
|
|
1195
|
+
* @param toolName - the tool the question is about.
|
|
1196
|
+
* @returns a bounded preview line, '' when nothing useful resolves.
|
|
1197
|
+
*/
|
|
1198
|
+
function approvalCommandPreview(events, callId, toolName) {
|
|
1199
|
+
if (callId === void 0) return "";
|
|
1200
|
+
const entry = events.find((candidate) => candidate.kind === "tool" && candidate.callId === callId);
|
|
1201
|
+
if (entry === void 0) return "";
|
|
1202
|
+
const args = entry.arguments ?? "";
|
|
1203
|
+
try {
|
|
1204
|
+
const parsed = JSON.parse(args);
|
|
1205
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
1206
|
+
const record = parsed;
|
|
1207
|
+
for (const key of [
|
|
1208
|
+
"command",
|
|
1209
|
+
"cmd",
|
|
1210
|
+
"description",
|
|
1211
|
+
"path",
|
|
1212
|
+
"pattern",
|
|
1213
|
+
"query"
|
|
1214
|
+
]) {
|
|
1215
|
+
const value = record[key];
|
|
1216
|
+
if (typeof value === "string" && value !== "") return value;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
} catch {}
|
|
1220
|
+
return args.length > 80 ? `${args.slice(0, 77)}...` : args === "" ? toolName : args;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Run the interactive terminal session: resolve the target session, create or
|
|
1224
|
+
* resume one Agent, mount the app, and keep the process alive until the user
|
|
1225
|
+
* quits.
|
|
548
1226
|
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
|
1227
|
+
* @param startup - the parsed invocation flags.
|
|
549
1228
|
* @param io - process-facing effects.
|
|
550
1229
|
*/
|
|
551
|
-
async function run(ctx, io) {
|
|
1230
|
+
async function run(ctx, startup, io) {
|
|
552
1231
|
await ctx.get("loader")?.await();
|
|
553
1232
|
const agents = ctx.get("agents");
|
|
554
1233
|
const defaultModel = ctx.get("agentDefaultModel");
|
|
555
1234
|
const sessions = ctx.get("sessions");
|
|
1235
|
+
const persistence = ctx.get("sessionPersistence");
|
|
556
1236
|
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
|
-
|
|
1237
|
+
const cwd = process.cwd();
|
|
1238
|
+
const target = await resolveTarget(startup, persistence, cwd);
|
|
1239
|
+
const defaults = defaultModel.currentSelection();
|
|
1240
|
+
let picked;
|
|
1241
|
+
let session;
|
|
1242
|
+
let agent;
|
|
1243
|
+
if (target.resume) {
|
|
1244
|
+
agent = (await agents.resume({
|
|
1245
|
+
resumeSessionId: SessionId(target.sessionId),
|
|
1246
|
+
agentOptions: {
|
|
1247
|
+
provider: defaults.provider,
|
|
1248
|
+
model: defaults.model
|
|
1249
|
+
},
|
|
1250
|
+
setup: (agentCtx) => {
|
|
1251
|
+
installModelSelection(agentCtx, {
|
|
1252
|
+
get current() {
|
|
1253
|
+
if (picked !== void 0) return picked;
|
|
1254
|
+
const logged = agentCtx.agent?.session.requestHeader()?.config;
|
|
1255
|
+
if (logged !== void 0) return {
|
|
1256
|
+
provider: logged.provider,
|
|
1257
|
+
model: logged.model,
|
|
1258
|
+
...logged.reasoningEffort === void 0 ? {} : { reasoningEffort: logged.reasoningEffort }
|
|
1259
|
+
};
|
|
1260
|
+
return defaults;
|
|
1261
|
+
},
|
|
1262
|
+
set current(next) {
|
|
1263
|
+
picked = next;
|
|
1264
|
+
},
|
|
1265
|
+
assembled: void 0
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
})).agent;
|
|
1269
|
+
session = agent.session;
|
|
1270
|
+
} else {
|
|
1271
|
+
agent = (await agents.create({
|
|
1272
|
+
sessionId: SessionId(target.sessionId),
|
|
1273
|
+
meta: { cwd },
|
|
1274
|
+
agentOptions: {
|
|
1275
|
+
provider: defaults.provider,
|
|
1276
|
+
model: defaults.model
|
|
1277
|
+
},
|
|
1278
|
+
setup: (agentCtx) => {
|
|
1279
|
+
installModelSelection(agentCtx, {
|
|
1280
|
+
get current() {
|
|
1281
|
+
return picked ?? defaults;
|
|
1282
|
+
},
|
|
1283
|
+
set current(next) {
|
|
1284
|
+
picked = next;
|
|
1285
|
+
},
|
|
1286
|
+
assembled: void 0
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
})).agent;
|
|
1290
|
+
session = agent.session;
|
|
1291
|
+
}
|
|
1292
|
+
const store = createTranscriptStore(session.events);
|
|
1293
|
+
const off = ctx.on("session/event", (subject, event) => {
|
|
1294
|
+
if (subject.id === session.id) store.apply(event);
|
|
575
1295
|
});
|
|
1296
|
+
const commands = watchCommands(ctx);
|
|
1297
|
+
commands.setAgent(agent);
|
|
1298
|
+
const skills = watchSkills(ctx);
|
|
1299
|
+
skills.setAgent(agent);
|
|
1300
|
+
const approval = mountApprovalAnswerer(ctx, (candidate) => candidate.id === agent.id, (request) => approvalCommandPreview(store.getView().entries, request.callId, request.toolName));
|
|
1301
|
+
const bridge = { notify: () => {} };
|
|
576
1302
|
const mountRef = {};
|
|
577
1303
|
let quitting = false;
|
|
578
1304
|
const quit = () => {
|
|
@@ -580,44 +1306,120 @@ async function run(ctx, io) {
|
|
|
580
1306
|
quitting = true;
|
|
581
1307
|
off();
|
|
582
1308
|
mountRef.current?.unmount();
|
|
583
|
-
sessions.flush(
|
|
1309
|
+
sessions.flush(session).catch((flushError) => {
|
|
584
1310
|
internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`);
|
|
585
1311
|
}).then(() => {
|
|
586
1312
|
io.exit(0);
|
|
587
1313
|
});
|
|
588
1314
|
};
|
|
1315
|
+
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
1316
|
+
const dispatch = (text) => {
|
|
1317
|
+
const line = text.trim();
|
|
1318
|
+
if (line === "") return;
|
|
1319
|
+
if (isSlashLine(line)) {
|
|
1320
|
+
const registry = ctx.get("commands");
|
|
1321
|
+
if (registry === void 0) {
|
|
1322
|
+
bridge.notify("no command registry is mounted in this composition");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const controller = new AbortController();
|
|
1326
|
+
registry.execute(agent, line, controller.signal).then((execution) => {
|
|
1327
|
+
if (execution === void 0) agent.followup(createUserMessage({
|
|
1328
|
+
content: [{
|
|
1329
|
+
type: "text",
|
|
1330
|
+
text: line
|
|
1331
|
+
}],
|
|
1332
|
+
source: { kind: "user" }
|
|
1333
|
+
}));
|
|
1334
|
+
}, (error) => {
|
|
1335
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1336
|
+
});
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
agent.followup(createUserMessage({
|
|
1340
|
+
content: [{
|
|
1341
|
+
type: "text",
|
|
1342
|
+
text: line
|
|
1343
|
+
}],
|
|
1344
|
+
source: { kind: "user" }
|
|
1345
|
+
}));
|
|
1346
|
+
};
|
|
1347
|
+
/**
|
|
1348
|
+
* Submit steering: a running driver consumes the text at its next step
|
|
1349
|
+
* boundary (the inbox delivers between steps); an idle driver just starts
|
|
1350
|
+
* a turn, so this doubles as the busy-state submit path.
|
|
1351
|
+
*/
|
|
1352
|
+
const steer = (text) => {
|
|
1353
|
+
const line = text.trim();
|
|
1354
|
+
if (line === "") return;
|
|
1355
|
+
agent.steer(createUserMessage({
|
|
1356
|
+
content: [{
|
|
1357
|
+
type: "text",
|
|
1358
|
+
text: line
|
|
1359
|
+
}],
|
|
1360
|
+
source: { kind: "user" }
|
|
1361
|
+
}));
|
|
1362
|
+
bridge.notify("steering queued — the next step sees it");
|
|
1363
|
+
};
|
|
1364
|
+
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
1365
|
+
const interrupt = () => {
|
|
1366
|
+
if (agent.status !== "running") return false;
|
|
1367
|
+
agent.cancel({ kind: "user" });
|
|
1368
|
+
bridge.notify("turn cancelled — Ctrl+C or /quit to exit");
|
|
1369
|
+
return true;
|
|
1370
|
+
};
|
|
1371
|
+
/** Apply one /model selection: takes effect from the next assembled step. */
|
|
1372
|
+
const selectModel = (row) => {
|
|
1373
|
+
picked = {
|
|
1374
|
+
provider: row.provider,
|
|
1375
|
+
model: row.model
|
|
1376
|
+
};
|
|
1377
|
+
return `${row.provider}/${row.model}`;
|
|
1378
|
+
};
|
|
1379
|
+
const initialModel = store.getView().model !== "" ? store.getView().model : `${defaults.provider}/${defaults.model}`;
|
|
589
1380
|
mountRef.current = io.mount(createElement(App, {
|
|
590
1381
|
store,
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1382
|
+
approval,
|
|
1383
|
+
commands,
|
|
1384
|
+
skills,
|
|
1385
|
+
model: initialModel,
|
|
1386
|
+
cwd: basename(cwd),
|
|
1387
|
+
branch: gitBranch(cwd),
|
|
1388
|
+
sessionId: session.id.slice(-8),
|
|
1389
|
+
resumed: target.resume,
|
|
1390
|
+
dispatch,
|
|
1391
|
+
steer,
|
|
1392
|
+
interrupt,
|
|
1393
|
+
quit,
|
|
1394
|
+
loadModels: () => loadModelDirectory(ctx),
|
|
1395
|
+
selectModel,
|
|
1396
|
+
onBridgeReady: (instance) => {
|
|
1397
|
+
bridge.notify = instance.notify;
|
|
1398
|
+
}
|
|
605
1399
|
}));
|
|
606
1400
|
}
|
|
607
1401
|
/**
|
|
608
1402
|
* Mount the interactive terminal driver.
|
|
609
1403
|
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
1404
|
+
* @param config - validated startup config resolved from the tuiStartup provider.
|
|
610
1405
|
*/
|
|
611
|
-
function apply(ctx) {
|
|
1406
|
+
function apply(ctx, config) {
|
|
1407
|
+
const startup = config.startup.kind === "resume" && config.startup.sessionId !== void 0 ? {
|
|
1408
|
+
kind: "resume",
|
|
1409
|
+
sessionId: config.startup.sessionId
|
|
1410
|
+
} : config.startup.kind === "latest" ? { kind: "latest" } : config.startup.kind === "named" && config.startup.sessionId !== void 0 ? {
|
|
1411
|
+
kind: "named",
|
|
1412
|
+
sessionId: config.startup.sessionId
|
|
1413
|
+
} : { kind: "fresh" };
|
|
612
1414
|
const exit = ctx.get("appExit");
|
|
613
1415
|
if (exit === void 0) throw new Error("tui-runner: the launcher must provide ctx.appExit before the tree mounts");
|
|
614
1416
|
const io = {
|
|
615
1417
|
mount: internals.mount,
|
|
616
1418
|
exit
|
|
617
1419
|
};
|
|
618
|
-
run(ctx, io).catch((error) => {
|
|
1420
|
+
run(ctx, startup, io).catch((error) => {
|
|
619
1421
|
fail(io, error);
|
|
620
1422
|
});
|
|
621
1423
|
}
|
|
622
1424
|
//#endregion
|
|
623
|
-
export { apply, inject, name };
|
|
1425
|
+
export { Config, apply, inject, name };
|