dsh-code 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/README.zh.md +48 -0
- package/cordis.patch.yml +28 -0
- package/lib/index.mjs +623 -0
- package/lib/invariant.mjs +21 -0
- package/lib/types/app.d.ts +34 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/internals.d.ts +24 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/render/projection.d.ts +102 -0
- package/lib/types/render/status.d.ts +47 -0
- package/lib/types/store.d.ts +24 -0
- package/lib/types/theme.d.ts +42 -0
- package/lib/types/whale-glyph.d.ts +6 -0
- package/package.json +69 -0
- package/src/app.ts +201 -0
- package/src/index.ts +146 -0
- package/src/internals.ts +37 -0
- package/src/invariant.ts +30 -0
- package/src/render/projection.ts +222 -0
- package/src/render/status.ts +87 -0
- package/src/store.ts +47 -0
- package/src/theme.ts +66 -0
- package/src/whale-glyph.ts +23 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { createElement, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
6
|
+
import { assertNever, boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
8
|
+
import { Box, Text, render, useInput } from "ink";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
//#region src/theme.ts
|
|
11
|
+
/**
|
|
12
|
+
* Terminal color tokens for the dsh TUI, mapped from the product design
|
|
13
|
+
* platform's DeepSeek palette
|
|
14
|
+
* (`packages/client/ui-theme/src/styles/design-platform.css`). Truecolor RGB
|
|
15
|
+
* rides chalk, which degrades automatically on terminals without truecolor.
|
|
16
|
+
*
|
|
17
|
+
* @module @deepseek-ai/dsh-tui/theme
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* RGB triples for the TUI, one entry per design-platform token in use.
|
|
21
|
+
* Keep names and values in sync with the CSS custom properties cited inline.
|
|
22
|
+
*/
|
|
23
|
+
const TUI_RGB = {
|
|
24
|
+
/** Primary brand blue — `--dsw-static-deepseek-500`. */
|
|
25
|
+
brand: [
|
|
26
|
+
65,
|
|
27
|
+
118,
|
|
28
|
+
230
|
|
29
|
+
],
|
|
30
|
+
/** Brighter brand blue for live/streaming emphasis — `--dsw-static-deepseek-400`. */
|
|
31
|
+
brandBright: [
|
|
32
|
+
103,
|
|
33
|
+
158,
|
|
34
|
+
254
|
|
35
|
+
],
|
|
36
|
+
/** Deep brand blue for secondary chrome — `--dsw-static-deepseek-600`. */
|
|
37
|
+
brandDeep: [
|
|
38
|
+
72,
|
|
39
|
+
104,
|
|
40
|
+
178
|
|
41
|
+
],
|
|
42
|
+
/** Muted caption gray — `--dsw-static-neutral-bluish-600`. */
|
|
43
|
+
dim: [
|
|
44
|
+
129,
|
|
45
|
+
133,
|
|
46
|
+
140
|
|
47
|
+
],
|
|
48
|
+
/** Success green — `--dsw-static-green-500`. */
|
|
49
|
+
success: [
|
|
50
|
+
34,
|
|
51
|
+
197,
|
|
52
|
+
94
|
|
53
|
+
],
|
|
54
|
+
/** Error red — `--dsw-static-red-500`. */
|
|
55
|
+
error: [
|
|
56
|
+
239,
|
|
57
|
+
68,
|
|
58
|
+
68
|
|
59
|
+
],
|
|
60
|
+
/** Warning amber — `--dsw-static-amber-500`. */
|
|
61
|
+
warn: [
|
|
62
|
+
245,
|
|
63
|
+
158,
|
|
64
|
+
11
|
|
65
|
+
]
|
|
66
|
+
};
|
|
67
|
+
/** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
|
|
68
|
+
function brand(text) {
|
|
69
|
+
return chalk.rgb(...TUI_RGB.brand)(text);
|
|
70
|
+
}
|
|
71
|
+
/** Paint muted captions, hints, and meta lines. */
|
|
72
|
+
function dim(text) {
|
|
73
|
+
return chalk.rgb(...TUI_RGB.dim)(text);
|
|
74
|
+
}
|
|
75
|
+
/** Paint failures and error entries. */
|
|
76
|
+
function error(text) {
|
|
77
|
+
return chalk.rgb(...TUI_RGB.error)(text);
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/whale-glyph.ts
|
|
81
|
+
/** Half-block whale glyph rows; render with the brand color. */
|
|
82
|
+
const WHALE_GLYPH = [
|
|
83
|
+
" ▄▄▄▄▄▄▄▄█ ▄█▄ ▄",
|
|
84
|
+
" ▄▄██████████▄▄ ▀███▄████",
|
|
85
|
+
"▄███████████████▄ ███▀▀▀ ",
|
|
86
|
+
"██ ▀▀█████▄▀██████ ",
|
|
87
|
+
"██▄ ▀████▄▄████ ",
|
|
88
|
+
" ██▄ ▀██████▀ ",
|
|
89
|
+
" ▀██▄▄ ██▄ ▀███▄▄ ",
|
|
90
|
+
" ▀▀███████▀▀ ▀▀▀ "
|
|
91
|
+
];
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/render/status.ts
|
|
94
|
+
/**
|
|
95
|
+
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three
|
|
96
|
+
* digits), mirroring the web composer's StatsLine format.
|
|
97
|
+
* @param n - token count.
|
|
98
|
+
* @returns display string.
|
|
99
|
+
*/
|
|
100
|
+
function formatTokens(n) {
|
|
101
|
+
const scaled = (v) => v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10);
|
|
102
|
+
if (n < 1e3) return String(n);
|
|
103
|
+
if (n < 1e6) return `${scaled(n / 1e3)}K`;
|
|
104
|
+
return `${scaled(n / 1e6)}M`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
|
108
|
+
* @param ms - duration in milliseconds.
|
|
109
|
+
* @returns display string.
|
|
110
|
+
*/
|
|
111
|
+
function formatDuration(ms) {
|
|
112
|
+
const s = ms / 1e3;
|
|
113
|
+
if (s < 60) return `${Math.round(s * 10) / 10}s`;
|
|
114
|
+
const whole = Math.round(s);
|
|
115
|
+
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Cache-hit share of billed prompt-side input.
|
|
119
|
+
* @param usage - cumulative token totals.
|
|
120
|
+
* @returns rounded integer percent, or null when no input was billed.
|
|
121
|
+
*/
|
|
122
|
+
function cacheHitPercent(usage) {
|
|
123
|
+
return usage.inputTokens === 0 ? null : Math.round(usage.cacheReadTokens / usage.inputTokens * 100);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the footer's display groups; a group with no data drops out whole.
|
|
127
|
+
* @param facts - identity facts resolved by the runner.
|
|
128
|
+
* @param stats - session figures folded from the durable log.
|
|
129
|
+
* @returns one string per pipe-separated group, in display order.
|
|
130
|
+
*/
|
|
131
|
+
function buildStatusGroups(facts, stats) {
|
|
132
|
+
const groups = [];
|
|
133
|
+
const identity = [
|
|
134
|
+
facts.model,
|
|
135
|
+
facts.cwd,
|
|
136
|
+
facts.branch === "" ? void 0 : `⑂ ${facts.branch}`
|
|
137
|
+
].filter((part) => part !== void 0 && part !== "");
|
|
138
|
+
if (identity.length > 0) groups.push(identity.join(" · "));
|
|
139
|
+
if (stats.turns > 0 || stats.steps > 0) {
|
|
140
|
+
groups.push(`T${stats.turns} · S${stats.steps}`);
|
|
141
|
+
const durations = [];
|
|
142
|
+
if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`);
|
|
143
|
+
if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`);
|
|
144
|
+
if (durations.length > 0) groups.push(durations.join(" · "));
|
|
145
|
+
}
|
|
146
|
+
const cacheHit = cacheHitPercent(stats.usage);
|
|
147
|
+
if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
|
|
148
|
+
if (cacheHit !== null) groups.push(`cache ${cacheHit}%`);
|
|
149
|
+
groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`);
|
|
150
|
+
}
|
|
151
|
+
if (facts.sessionId !== "") groups.push(facts.sessionId);
|
|
152
|
+
return groups;
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/app.ts
|
|
156
|
+
/**
|
|
157
|
+
* The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
|
|
158
|
+
* transcript, the streaming line, local notices, and the input box. All state
|
|
159
|
+
* arrives through the transcript store (derived from the durable session log)
|
|
160
|
+
* plus local input state; the app owns no session mutation of its own.
|
|
161
|
+
*
|
|
162
|
+
* Element construction uses `createElement` (not JSX): the `dsh` source launch
|
|
163
|
+
* compiles this file through tsx's ESM-only hook, which does not adopt this
|
|
164
|
+
* package's `jsx: react-jsx` compiler option, and the classic JSX runtime
|
|
165
|
+
* would demand a React global.
|
|
166
|
+
*
|
|
167
|
+
* @module @deepseek-ai/dsh-tui/app
|
|
168
|
+
*/
|
|
169
|
+
/** Ink `color` string for one palette triple. */
|
|
170
|
+
function inkColor(triple) {
|
|
171
|
+
return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`;
|
|
172
|
+
}
|
|
173
|
+
/** One settled transcript row. */
|
|
174
|
+
function EntryLine({ entry }) {
|
|
175
|
+
switch (entry.kind) {
|
|
176
|
+
case "user": return createElement(Text, null, brand("❯ "), entry.text);
|
|
177
|
+
case "assistant": return createElement(Text, null, entry.text);
|
|
178
|
+
case "tool": {
|
|
179
|
+
const mark = entry.state === "running" ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "◐") : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
|
|
180
|
+
return createElement(Text, null, mark, " ", brand(entry.name), entry.summary === "" ? "" : ` ${dim(entry.summary)}`);
|
|
181
|
+
}
|
|
182
|
+
case "error": return createElement(Text, null, error(entry.text));
|
|
183
|
+
default: return assertNever(entry, "transcript entry kind");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** The whale wordmark header in DeepSeek blue, hugging its content width. */
|
|
187
|
+
function Header() {
|
|
188
|
+
return createElement(Box, {
|
|
189
|
+
flexDirection: "row",
|
|
190
|
+
gap: 1,
|
|
191
|
+
borderStyle: "round",
|
|
192
|
+
borderColor: inkColor(TUI_RGB.brand),
|
|
193
|
+
paddingX: 1,
|
|
194
|
+
alignSelf: "flex-start"
|
|
195
|
+
}, createElement(Box, {
|
|
196
|
+
flexDirection: "column",
|
|
197
|
+
width: 26
|
|
198
|
+
}, ...WHALE_GLYPH.map((row, index) => createElement(Text, {
|
|
199
|
+
key: index,
|
|
200
|
+
color: inkColor(TUI_RGB.brand)
|
|
201
|
+
}, row))), createElement(Box, {
|
|
202
|
+
flexDirection: "column",
|
|
203
|
+
justifyContent: "center"
|
|
204
|
+
}, createElement(Text, {
|
|
205
|
+
color: inkColor(TUI_RGB.brand),
|
|
206
|
+
bold: true
|
|
207
|
+
}, "DeepSeek Harness"), createElement(Text, { dimColor: true }, "/help commands · Ctrl+C quit")));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The footer status line: Claude-Code-style identity facts (model, working
|
|
211
|
+
* directory, git branch, session) beside the web composer's session figures
|
|
212
|
+
* (turns/steps, model and tool wall time, cache hit, token totals), joined
|
|
213
|
+
* by brand-colored pipes.
|
|
214
|
+
*/
|
|
215
|
+
function StatusLine({ facts, stats, busy }) {
|
|
216
|
+
const groups = buildStatusGroups(facts, stats);
|
|
217
|
+
const children = [busy ? createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, "● ") : createElement(Text, { color: inkColor(TUI_RGB.brand) }, "○ ")];
|
|
218
|
+
groups.forEach((group, index) => {
|
|
219
|
+
if (index > 0) children.push(createElement(Text, { dimColor: true }, dim(" | ")));
|
|
220
|
+
children.push(createElement(Text, { dimColor: true }, group));
|
|
221
|
+
});
|
|
222
|
+
return createElement(Box, { paddingX: 1 }, ...children);
|
|
223
|
+
}
|
|
224
|
+
/** The prompt box: slash commands handled locally, other text submitted. */
|
|
225
|
+
function Input({ busy, onSubmit, onQuit }) {
|
|
226
|
+
const [value, setValue] = useState("");
|
|
227
|
+
const [notices, setNotices] = useState([]);
|
|
228
|
+
useInput((input, key) => {
|
|
229
|
+
if (key.ctrl && (input === "c" || input === "d")) {
|
|
230
|
+
onQuit();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (key.return) {
|
|
234
|
+
const text = value.trim();
|
|
235
|
+
setValue("");
|
|
236
|
+
if (text === "") return;
|
|
237
|
+
if (text === "/quit") {
|
|
238
|
+
onQuit();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (text === "/help") {
|
|
242
|
+
setNotices([...notices, "/help show commands · /clear clear the screen · /quit exit"]);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (text === "/clear") {
|
|
246
|
+
setNotices([]);
|
|
247
|
+
console.clear();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (busy) {
|
|
251
|
+
setNotices([...notices, "the agent is working — wait for the turn to finish"]);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
onSubmit(text);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (key.backspace || key.delete) {
|
|
258
|
+
setValue(value.slice(0, -1));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (input !== "") setValue(value + input);
|
|
262
|
+
});
|
|
263
|
+
return createElement(Box, { flexDirection: "column" }, ...notices.map((notice, index) => createElement(Text, {
|
|
264
|
+
key: index,
|
|
265
|
+
dimColor: true
|
|
266
|
+
}, notice)), createElement(Box, null, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), createElement(Text, null, value)));
|
|
267
|
+
}
|
|
268
|
+
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
269
|
+
function App({ store, model, cwd, branch, sessionId, onSubmit, onQuit }) {
|
|
270
|
+
const view = useSyncExternalStore(store.subscribe, store.getView);
|
|
271
|
+
return createElement(Box, { flexDirection: "column" }, createElement(Header), createElement(Box, {
|
|
272
|
+
flexDirection: "column",
|
|
273
|
+
paddingX: 1
|
|
274
|
+
}, ...view.entries.map((entry, index) => createElement(EntryLine, {
|
|
275
|
+
key: index,
|
|
276
|
+
entry
|
|
277
|
+
})), view.streaming !== "" ? createElement(Text, null, view.streaming) : void 0, view.busy && view.streaming === "" ? createElement(Text, { dimColor: true }, "thinking…") : void 0), createElement(Input, {
|
|
278
|
+
busy: view.busy,
|
|
279
|
+
onSubmit,
|
|
280
|
+
onQuit
|
|
281
|
+
}), createElement(StatusLine, {
|
|
282
|
+
facts: {
|
|
283
|
+
model,
|
|
284
|
+
cwd,
|
|
285
|
+
branch,
|
|
286
|
+
sessionId
|
|
287
|
+
},
|
|
288
|
+
stats: view.stats,
|
|
289
|
+
busy: view.busy
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
//#endregion
|
|
293
|
+
//#region src/internals.ts
|
|
294
|
+
/**
|
|
295
|
+
* Injectable process-facing effects for the TUI runner. Tests substitute the
|
|
296
|
+
* Ink mount with a capturing fake and the streams with string sinks, keeping
|
|
297
|
+
* the runner's lifecycle testable without a terminal.
|
|
298
|
+
*
|
|
299
|
+
* @module @deepseek-ai/dsh-tui/internals
|
|
300
|
+
*/
|
|
301
|
+
/** Substitutable runner effects; production values write to the real terminal. */
|
|
302
|
+
const internals = {
|
|
303
|
+
mount: (element) => {
|
|
304
|
+
const instance = render(element);
|
|
305
|
+
return { unmount() {
|
|
306
|
+
instance.unmount();
|
|
307
|
+
} };
|
|
308
|
+
},
|
|
309
|
+
stderr: process.stderr
|
|
310
|
+
};
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/render/projection.ts
|
|
313
|
+
/**
|
|
314
|
+
* Pure session-event-to-view projection for the TUI transcript: one reducer
|
|
315
|
+
* over {@link SessionEvent}s producing the ordered entries the renderer draws.
|
|
316
|
+
* Rendering never reads the session directly — this module owns the view
|
|
317
|
+
* model, so tests drive it with plain event arrays.
|
|
318
|
+
*
|
|
319
|
+
* @module @deepseek-ai/dsh-tui/render/projection
|
|
320
|
+
*/
|
|
321
|
+
/** Join the text blocks of a content list; non-text blocks contribute nothing. */
|
|
322
|
+
function textOf(content) {
|
|
323
|
+
return content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
324
|
+
}
|
|
325
|
+
/** A fresh, empty transcript view. */
|
|
326
|
+
function createTranscriptView() {
|
|
327
|
+
return {
|
|
328
|
+
entries: [],
|
|
329
|
+
streaming: "",
|
|
330
|
+
todos: [],
|
|
331
|
+
busy: false,
|
|
332
|
+
stats: {
|
|
333
|
+
turns: 0,
|
|
334
|
+
steps: 0,
|
|
335
|
+
llmMs: 0,
|
|
336
|
+
toolMs: 0,
|
|
337
|
+
usage: {
|
|
338
|
+
inputTokens: 0,
|
|
339
|
+
outputTokens: 0,
|
|
340
|
+
cacheReadTokens: 0
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
anchors: {
|
|
344
|
+
stepStart: /* @__PURE__ */ new Map(),
|
|
345
|
+
toolStart: /* @__PURE__ */ new Map()
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Fold one session event into an updated view (copy-on-write).
|
|
351
|
+
* @param view - the view before the event.
|
|
352
|
+
* @param event - one durable session event from `session/event` or the log.
|
|
353
|
+
* @returns the view after the event; the input view is never mutated.
|
|
354
|
+
*/
|
|
355
|
+
function projectEvent(view, event) {
|
|
356
|
+
switch (event.type) {
|
|
357
|
+
case "user/message": {
|
|
358
|
+
const message = event.data;
|
|
359
|
+
if (message.source.kind === "user") return {
|
|
360
|
+
...view,
|
|
361
|
+
entries: [...view.entries, {
|
|
362
|
+
kind: "user",
|
|
363
|
+
text: textOf(message.content)
|
|
364
|
+
}]
|
|
365
|
+
};
|
|
366
|
+
const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
|
|
367
|
+
return {
|
|
368
|
+
...view,
|
|
369
|
+
entries: [...view.entries, {
|
|
370
|
+
kind: "user",
|
|
371
|
+
text: boundContextSummary(notice)
|
|
372
|
+
}]
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
case "assistant/chunk": {
|
|
376
|
+
const chunk = event.data.chunk;
|
|
377
|
+
if (chunk.type !== "text-delta") return view;
|
|
378
|
+
return {
|
|
379
|
+
...view,
|
|
380
|
+
streaming: view.streaming + chunk.text
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
case "assistant/message": {
|
|
384
|
+
const key = `${event.data.turn}:${event.data.step}`;
|
|
385
|
+
const started = view.anchors.stepStart.get(key);
|
|
386
|
+
view.anchors.stepStart.delete(key);
|
|
387
|
+
const usage = event.data.usage;
|
|
388
|
+
const totals = view.stats.usage;
|
|
389
|
+
return {
|
|
390
|
+
...view,
|
|
391
|
+
streaming: "",
|
|
392
|
+
entries: [...view.entries, {
|
|
393
|
+
kind: "assistant",
|
|
394
|
+
text: textOf(event.data.message.content)
|
|
395
|
+
}],
|
|
396
|
+
stats: {
|
|
397
|
+
...view.stats,
|
|
398
|
+
llmMs: view.stats.llmMs + (started === void 0 ? 0 : Math.max(0, event.time - started)),
|
|
399
|
+
usage: usage === void 0 ? totals : {
|
|
400
|
+
inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
401
|
+
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
402
|
+
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
case "tool/call": {
|
|
408
|
+
const data = event.data;
|
|
409
|
+
view.anchors.toolStart.set(data.callId, event.time);
|
|
410
|
+
return {
|
|
411
|
+
...view,
|
|
412
|
+
entries: [...view.entries, {
|
|
413
|
+
kind: "tool",
|
|
414
|
+
callId: data.callId,
|
|
415
|
+
name: data.name,
|
|
416
|
+
arguments: data.arguments,
|
|
417
|
+
state: "running",
|
|
418
|
+
summary: ""
|
|
419
|
+
}]
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
case "tool/result": {
|
|
423
|
+
const block = event.data.message.content[0];
|
|
424
|
+
const started = view.anchors.toolStart.get(block.toolCallId);
|
|
425
|
+
view.anchors.toolStart.delete(block.toolCallId);
|
|
426
|
+
const summary = boundContextSummary(textOf(block.content));
|
|
427
|
+
const entries = view.entries.map((entry) => {
|
|
428
|
+
if (entry.kind !== "tool" || entry.callId !== block.toolCallId) return entry;
|
|
429
|
+
return {
|
|
430
|
+
...entry,
|
|
431
|
+
state: block.isError === true ? "error" : "done",
|
|
432
|
+
summary
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
return {
|
|
436
|
+
...view,
|
|
437
|
+
entries,
|
|
438
|
+
stats: {
|
|
439
|
+
...view.stats,
|
|
440
|
+
toolMs: view.stats.toolMs + (started === void 0 ? 0 : Math.max(0, event.time - started))
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
case "todo/write": return {
|
|
445
|
+
...view,
|
|
446
|
+
todos: event.data.todos
|
|
447
|
+
};
|
|
448
|
+
case "turn/start": return {
|
|
449
|
+
...view,
|
|
450
|
+
busy: true,
|
|
451
|
+
stats: {
|
|
452
|
+
...view.stats,
|
|
453
|
+
turns: view.stats.turns + 1
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
case "step/start":
|
|
457
|
+
view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time);
|
|
458
|
+
return {
|
|
459
|
+
...view,
|
|
460
|
+
stats: {
|
|
461
|
+
...view.stats,
|
|
462
|
+
steps: view.stats.steps + 1
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
case "turn/end": {
|
|
466
|
+
const reason = event.data.reason;
|
|
467
|
+
if (reason.kind !== "error") return {
|
|
468
|
+
...view,
|
|
469
|
+
busy: false
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
...view,
|
|
473
|
+
busy: false,
|
|
474
|
+
entries: [...view.entries, {
|
|
475
|
+
kind: "error",
|
|
476
|
+
text: `${reason.error.code}: ${reason.error.message}`
|
|
477
|
+
}]
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
default: return view;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/store.ts
|
|
485
|
+
/**
|
|
486
|
+
* Create one transcript store.
|
|
487
|
+
* @returns the store the runner feeds and the renderer subscribes to.
|
|
488
|
+
*/
|
|
489
|
+
function createTranscriptStore() {
|
|
490
|
+
let view = createTranscriptView();
|
|
491
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
492
|
+
return {
|
|
493
|
+
getView: () => view,
|
|
494
|
+
subscribe(listener) {
|
|
495
|
+
listeners.add(listener);
|
|
496
|
+
return () => {
|
|
497
|
+
listeners.delete(listener);
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
apply(event) {
|
|
501
|
+
const next = projectEvent(view, event);
|
|
502
|
+
if (next === view) return;
|
|
503
|
+
view = next;
|
|
504
|
+
for (const listener of listeners) listener();
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
//#endregion
|
|
509
|
+
//#region src/index.ts
|
|
510
|
+
/**
|
|
511
|
+
* @deepseek-ai/dsh-tui — the interactive terminal driver. The bundle patch
|
|
512
|
+
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
513
|
+
* creates one Agent through the core registry, mounts the Ink app (DeepSeek
|
|
514
|
+
* blue, whale wordmark), folds submitted prompts into the same durable
|
|
515
|
+
* session, streams `session/event` into the transcript, and on quit flushes
|
|
516
|
+
* and requests process exit.
|
|
517
|
+
*
|
|
518
|
+
* @module @deepseek-ai/dsh-tui
|
|
519
|
+
*/
|
|
520
|
+
/** Stable Cordis plugin name. */
|
|
521
|
+
const name = "tui-runner";
|
|
522
|
+
/** Core services required before the interactive session can start. */
|
|
523
|
+
const inject = [
|
|
524
|
+
"agentDefaultModel",
|
|
525
|
+
"agents",
|
|
526
|
+
"sessions"
|
|
527
|
+
];
|
|
528
|
+
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
529
|
+
function fail(io, error) {
|
|
530
|
+
internals.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
531
|
+
io.exit(1);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Resolve the working directory's git branch for the status line.
|
|
535
|
+
* @param cwd - the session's working directory.
|
|
536
|
+
* @returns the branch name, or '' outside a repository or on a detached HEAD.
|
|
537
|
+
*/
|
|
538
|
+
function gitBranch(cwd) {
|
|
539
|
+
try {
|
|
540
|
+
return readFileSync(join(cwd, ".git", "HEAD"), "utf8").trim().match(/^ref: refs\/heads\/(.+)$/)?.[1] ?? "";
|
|
541
|
+
} catch {
|
|
542
|
+
return "";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Run the interactive terminal session: create one Agent, mount the app, and
|
|
547
|
+
* keep the process alive until the user quits.
|
|
548
|
+
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
|
549
|
+
* @param io - process-facing effects.
|
|
550
|
+
*/
|
|
551
|
+
async function run(ctx, io) {
|
|
552
|
+
await ctx.get("loader")?.await();
|
|
553
|
+
const agents = ctx.get("agents");
|
|
554
|
+
const defaultModel = ctx.get("agentDefaultModel");
|
|
555
|
+
const sessions = ctx.get("sessions");
|
|
556
|
+
if (agents === void 0 || defaultModel === void 0 || sessions === void 0) return;
|
|
557
|
+
const selection = defaultModel.currentSelection();
|
|
558
|
+
const { agent } = await agents.create({
|
|
559
|
+
sessionId: SessionId(`session-${randomUUID()}`),
|
|
560
|
+
meta: { cwd: process.cwd() },
|
|
561
|
+
agentOptions: {
|
|
562
|
+
provider: selection.provider,
|
|
563
|
+
model: selection.model
|
|
564
|
+
},
|
|
565
|
+
setup: (agentCtx) => {
|
|
566
|
+
installModelSelection(agentCtx, {
|
|
567
|
+
current: selection,
|
|
568
|
+
assembled: void 0
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
const store = createTranscriptStore();
|
|
573
|
+
const off = ctx.on("session/event", (session, event) => {
|
|
574
|
+
if (session.id === agent.session.id) store.apply(event);
|
|
575
|
+
});
|
|
576
|
+
const mountRef = {};
|
|
577
|
+
let quitting = false;
|
|
578
|
+
const quit = () => {
|
|
579
|
+
if (quitting) return;
|
|
580
|
+
quitting = true;
|
|
581
|
+
off();
|
|
582
|
+
mountRef.current?.unmount();
|
|
583
|
+
sessions.flush(agent.session).catch((flushError) => {
|
|
584
|
+
internals.stderr.write(`dsh: session flush failed: ${flushError instanceof Error ? flushError.message : String(flushError)}\n`);
|
|
585
|
+
}).then(() => {
|
|
586
|
+
io.exit(0);
|
|
587
|
+
});
|
|
588
|
+
};
|
|
589
|
+
mountRef.current = io.mount(createElement(App, {
|
|
590
|
+
store,
|
|
591
|
+
model: `${selection.provider}/${selection.model}`,
|
|
592
|
+
cwd: basename(process.cwd()),
|
|
593
|
+
branch: gitBranch(process.cwd()),
|
|
594
|
+
sessionId: agent.session.id.slice(-8),
|
|
595
|
+
onSubmit: (text) => {
|
|
596
|
+
agent.followup(createUserMessage({
|
|
597
|
+
content: [{
|
|
598
|
+
type: "text",
|
|
599
|
+
text
|
|
600
|
+
}],
|
|
601
|
+
source: { kind: "user" }
|
|
602
|
+
}));
|
|
603
|
+
},
|
|
604
|
+
onQuit: quit
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Mount the interactive terminal driver.
|
|
609
|
+
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
610
|
+
*/
|
|
611
|
+
function apply(ctx) {
|
|
612
|
+
const exit = ctx.get("appExit");
|
|
613
|
+
if (exit === void 0) throw new Error("tui-runner: the launcher must provide ctx.appExit before the tree mounts");
|
|
614
|
+
const io = {
|
|
615
|
+
mount: internals.mount,
|
|
616
|
+
exit
|
|
617
|
+
};
|
|
618
|
+
run(ctx, io).catch((error) => {
|
|
619
|
+
fail(io, error);
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
//#endregion
|
|
623
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/invariant.ts
|
|
2
|
+
const PACKAGE_NAME = "@deepseek-ai/dsh-tui";
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
const name = "tui-invariant";
|
|
5
|
+
/** Service required before the companion can register. */
|
|
6
|
+
const inject = ["invariants"];
|
|
7
|
+
/**
|
|
8
|
+
* No runtime invariant beyond the projection's own contract: the TUI renders
|
|
9
|
+
* only from `session/event` (model-visible means logged), so the display
|
|
10
|
+
* relation the renderer could desync from is already asserted by the session
|
|
11
|
+
* log's projection invariants; this companion registers nothing.
|
|
12
|
+
*/
|
|
13
|
+
const install = () => {};
|
|
14
|
+
/**
|
|
15
|
+
* Register this package's invariant companion.
|
|
16
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
17
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
18
|
+
*/
|
|
19
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
20
|
+
//#endregion
|
|
21
|
+
export { apply, inject, name };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ink terminal app: whale-and-wordmark header in DeepSeek blue, the live
|
|
3
|
+
* transcript, the streaming line, local notices, and the input box. All state
|
|
4
|
+
* arrives through the transcript store (derived from the durable session log)
|
|
5
|
+
* plus local input state; the app owns no session mutation of its own.
|
|
6
|
+
*
|
|
7
|
+
* Element construction uses `createElement` (not JSX): the `dsh` source launch
|
|
8
|
+
* compiles this file through tsx's ESM-only hook, which does not adopt this
|
|
9
|
+
* package's `jsx: react-jsx` compiler option, and the classic JSX runtime
|
|
10
|
+
* would demand a React global.
|
|
11
|
+
*
|
|
12
|
+
* @module @deepseek-ai/dsh-tui/app
|
|
13
|
+
*/
|
|
14
|
+
import { type ReactElement } from 'react';
|
|
15
|
+
import type { TranscriptStore } from './store.ts';
|
|
16
|
+
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
17
|
+
export interface AppProps {
|
|
18
|
+
/** Event-fed transcript store for the live session. */
|
|
19
|
+
store: TranscriptStore;
|
|
20
|
+
/** `provider/model` selection serving this session. */
|
|
21
|
+
model: string;
|
|
22
|
+
/** Working-directory basename the session serves. */
|
|
23
|
+
cwd: string;
|
|
24
|
+
/** Git branch name, empty outside a repository. */
|
|
25
|
+
branch: string;
|
|
26
|
+
/** Short session identifier. */
|
|
27
|
+
sessionId: string;
|
|
28
|
+
/** Submit one human prompt; the runner folds it into the session. */
|
|
29
|
+
onSubmit(text: string): void;
|
|
30
|
+
/** Quit: unmount, flush, and request process exit. */
|
|
31
|
+
onQuit(): void;
|
|
32
|
+
}
|
|
33
|
+
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
34
|
+
export declare function App({ store, model, cwd, branch, sessionId, onSubmit, onQuit }: AppProps): ReactElement;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @deepseek-ai/dsh-tui — the interactive terminal driver. The bundle patch
|
|
3
|
+
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
4
|
+
* creates one Agent through the core registry, mounts the Ink app (DeepSeek
|
|
5
|
+
* blue, whale wordmark), folds submitted prompts into the same durable
|
|
6
|
+
* session, streams `session/event` into the transcript, and on quit flushes
|
|
7
|
+
* and requests process exit.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-tui
|
|
10
|
+
*/
|
|
11
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
12
|
+
/** Stable Cordis plugin name. */
|
|
13
|
+
export declare const name = "tui-runner";
|
|
14
|
+
/** Core services required before the interactive session can start. */
|
|
15
|
+
export declare const inject: string[];
|
|
16
|
+
/**
|
|
17
|
+
* Mount the interactive terminal driver.
|
|
18
|
+
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
19
|
+
*/
|
|
20
|
+
export declare function apply(ctx: Context): void;
|