dsh-code 0.2.0 → 0.4.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 +13 -2
- package/README.zh.md +11 -2
- package/cordis.patch.yml +6 -0
- package/lib/index.mjs +2793 -394
- package/lib/types/app.d.ts +12 -0
- package/lib/types/mentions.d.ts +70 -0
- package/lib/types/questions.d.ts +48 -0
- package/lib/types/render/animations.d.ts +15 -0
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +30 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +107 -2
- package/lib/types/render/status.d.ts +21 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/store.d.ts +2 -0
- package/lib/types/theme.d.ts +4 -0
- package/package.json +20 -2
- package/src/app.ts +1507 -149
- package/src/index.ts +156 -46
- package/src/mentions.ts +193 -0
- package/src/pictures/1.png +0 -0
- package/src/questions.ts +143 -0
- package/src/render/animations.ts +22 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +79 -0
- package/src/render/lines.ts +207 -0
- package/src/render/markdown.ts +235 -0
- package/src/render/projection.ts +322 -18
- package/src/render/status.ts +62 -3
- package/src/render/text.ts +79 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/render/tool-preview.ts +34 -0
- package/src/store.ts +8 -0
- package/src/theme.ts +4 -0
package/lib/index.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { readdir, writeFile } from "node:fs/promises";
|
|
3
4
|
import { basename, join } from "node:path";
|
|
4
|
-
import { createElement, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
|
+
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
5
6
|
import z from "@deepseek-ai/schemastery";
|
|
6
7
|
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
7
8
|
import { assertNever, boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
9
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
9
|
-
import { Box, Text, render, useInput } from "ink";
|
|
10
|
+
import { Box, Static, Text, render, useInput, useStdout } from "ink";
|
|
10
11
|
import chalk from "chalk";
|
|
12
|
+
import { formatSessionReferenceMention, parseSessionReferenceText } from "@deepseek-ai/dsh-session-reference";
|
|
13
|
+
import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
|
|
11
14
|
import { isUserInvocable } from "@deepseek-ai/dsh-skill";
|
|
12
15
|
//#region src/theme.ts
|
|
13
16
|
/**
|
|
@@ -64,37 +67,909 @@ const TUI_RGB = {
|
|
|
64
67
|
245,
|
|
65
68
|
158,
|
|
66
69
|
11
|
|
70
|
+
],
|
|
71
|
+
/** Default foreground text — `--dsw-static-neutral-50`. */
|
|
72
|
+
text: [
|
|
73
|
+
236,
|
|
74
|
+
240,
|
|
75
|
+
246
|
|
76
|
+
],
|
|
77
|
+
/** Inline/fenced code — soft sky blue, distinct from brand accents. */
|
|
78
|
+
code: [
|
|
79
|
+
125,
|
|
80
|
+
211,
|
|
81
|
+
252
|
|
67
82
|
]
|
|
68
83
|
};
|
|
69
84
|
/** Paint with the primary brand blue: whale, wordmark, tool names, accents. */
|
|
70
85
|
function brand(text) {
|
|
71
86
|
return chalk.rgb(...TUI_RGB.brand)(text);
|
|
72
87
|
}
|
|
73
|
-
/** Paint muted captions, hints, and meta lines. */
|
|
74
|
-
function dim(text) {
|
|
75
|
-
return chalk.rgb(...TUI_RGB.dim)(text);
|
|
88
|
+
/** Paint muted captions, hints, and meta lines. */
|
|
89
|
+
function dim(text) {
|
|
90
|
+
return chalk.rgb(...TUI_RGB.dim)(text);
|
|
91
|
+
}
|
|
92
|
+
/** Paint failures and error entries. */
|
|
93
|
+
function error(text) {
|
|
94
|
+
return chalk.rgb(...TUI_RGB.error)(text);
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/whale-glyph.ts
|
|
98
|
+
/** Half-block whale glyph rows; render with the brand color. */
|
|
99
|
+
const WHALE_GLYPH = [
|
|
100
|
+
" ▄▄▄▄▄▄▄▄█ ▄█▄ ▄",
|
|
101
|
+
" ▄▄██████████▄▄ ▀███▄████",
|
|
102
|
+
"▄███████████████▄ ███▀▀▀ ",
|
|
103
|
+
"██ ▀▀█████▄▀██████ ",
|
|
104
|
+
"██▄ ▀████▄▄████ ",
|
|
105
|
+
" ██▄ ▀██████▀ ",
|
|
106
|
+
" ▀██▄▄ ██▄ ▀███▄▄ ",
|
|
107
|
+
" ▀▀███████▀▀ ▀▀▀ "
|
|
108
|
+
];
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/render/tool-preview.ts
|
|
111
|
+
/**
|
|
112
|
+
* Bounded preview line for a tool invocation's raw JSON arguments: the first
|
|
113
|
+
* human-meaningful string among the well-known keys (command, path, query, …)
|
|
114
|
+
* with a fallback to the bounded raw JSON. Shared by the tool card in the
|
|
115
|
+
* transcript and the approval bar's command preview.
|
|
116
|
+
*
|
|
117
|
+
* @module @deepseek-ai/dsh-code/render/tool-preview
|
|
118
|
+
*/
|
|
119
|
+
/** Keys searched in declaration order when building a preview. */
|
|
120
|
+
const PREVIEW_KEYS = [
|
|
121
|
+
"command",
|
|
122
|
+
"cmd",
|
|
123
|
+
"description",
|
|
124
|
+
"path",
|
|
125
|
+
"pattern",
|
|
126
|
+
"query"
|
|
127
|
+
];
|
|
128
|
+
/**
|
|
129
|
+
* Resolve one bounded preview for raw tool arguments.
|
|
130
|
+
* @param args - raw JSON arguments string as the model produced it.
|
|
131
|
+
* @param toolName - the tool the arguments belong to (fallback label).
|
|
132
|
+
* @returns the preview line; empty when nothing useful resolves.
|
|
133
|
+
*/
|
|
134
|
+
function toolArgumentsPreview(args, toolName) {
|
|
135
|
+
if (args === "") return toolName;
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(args);
|
|
138
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
139
|
+
const record = parsed;
|
|
140
|
+
for (const key of PREVIEW_KEYS) {
|
|
141
|
+
const value = record[key];
|
|
142
|
+
if (typeof value === "string" && value !== "") return value;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch {}
|
|
146
|
+
return args.length > 80 ? `${args.slice(0, 77)}...` : args;
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/render/tool-detail.ts
|
|
150
|
+
/**
|
|
151
|
+
* Expansion payloads for tool cards (the Ctrl+O verbose transcript): the
|
|
152
|
+
* TUI-side consumption of the harness presentation contract. Mutation and
|
|
153
|
+
* read tools persist a structured `tool/result.meta` (`diffs`, read
|
|
154
|
+
* windows, web sources) exactly so a capable UI can replay richer cards than
|
|
155
|
+
* the model-facing text; this module narrows that opaque JSON defensively —
|
|
156
|
+
* mirroring the upstream validators — and pre-formats bounded, render-ready
|
|
157
|
+
* rows. Malformed or absent metadata always degrades to the bounded raw
|
|
158
|
+
* result text, never throws during replay.
|
|
159
|
+
*
|
|
160
|
+
* @module @deepseek-ai/dsh-code/render/tool-detail
|
|
161
|
+
*/
|
|
162
|
+
/** Budgets keeping one expanded card bounded on a terminal. */
|
|
163
|
+
const MAX_DIFF_LINES = 200;
|
|
164
|
+
const MAX_READ_LINES = 120;
|
|
165
|
+
const MAX_SOURCES = 10;
|
|
166
|
+
const MAX_RAW_CHARS = 6e3;
|
|
167
|
+
const MAX_LINE_COLUMNS = 240;
|
|
168
|
+
/** Truncate one line to the visible-column budget with an ellipsis marker. */
|
|
169
|
+
function clipLine(text) {
|
|
170
|
+
return text.length > MAX_LINE_COLUMNS ? `${text.slice(0, 239)}…` : text;
|
|
171
|
+
}
|
|
172
|
+
/** Split text into lines, dropping the trailing empty element of a final newline. */
|
|
173
|
+
function toLines(text) {
|
|
174
|
+
const split = text.split("\n");
|
|
175
|
+
return split.length > 0 && split[split.length - 1] === "" ? split.slice(0, -1) : split;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Render one change as removed-then-added rows, hunked by common prefix and
|
|
179
|
+
* suffix. A null before-image (file create) renders as pure additions. The
|
|
180
|
+
* budget caps emitted rows and reports the cut, so a whole-file overwrite
|
|
181
|
+
* never floods the transcript.
|
|
182
|
+
* @param oldText - prior content, or null for a create.
|
|
183
|
+
* @param newText - content after the change.
|
|
184
|
+
* @param budget - maximum rows to emit.
|
|
185
|
+
* @returns the bounded rows and whether they were cut.
|
|
186
|
+
*/
|
|
187
|
+
function diffRows(oldText, newText, budget) {
|
|
188
|
+
const oldLines = oldText === null ? [] : toLines(oldText);
|
|
189
|
+
const newLines = toLines(newText);
|
|
190
|
+
let prefix = 0;
|
|
191
|
+
while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1;
|
|
192
|
+
let suffix = 0;
|
|
193
|
+
while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix += 1;
|
|
194
|
+
const removed = oldLines.slice(prefix, oldLines.length - suffix);
|
|
195
|
+
const added = newLines.slice(prefix, newLines.length - suffix);
|
|
196
|
+
const rows = [...removed.map((text) => ({
|
|
197
|
+
mark: "-",
|
|
198
|
+
text: clipLine(text)
|
|
199
|
+
})), ...added.map((text) => ({
|
|
200
|
+
mark: "+",
|
|
201
|
+
text: clipLine(text)
|
|
202
|
+
}))];
|
|
203
|
+
if (rows.length <= budget) return {
|
|
204
|
+
lines: rows,
|
|
205
|
+
truncated: false
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
lines: rows.slice(0, budget),
|
|
209
|
+
truncated: true
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Whether `value` is a valid upstream FileDiff (defensive narrowing). */
|
|
213
|
+
function isFileDiff(value) {
|
|
214
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
215
|
+
const { path, oldText, newText } = value;
|
|
216
|
+
return typeof path === "string" && (oldText === null || typeof oldText === "string") && typeof newText === "string";
|
|
217
|
+
}
|
|
218
|
+
/** Whether `value` is a valid read-window line (defensive narrowing). */
|
|
219
|
+
function isReadLine(value) {
|
|
220
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
221
|
+
const { number, text } = value;
|
|
222
|
+
return typeof number === "number" && Number.isInteger(number) && number >= 1 && typeof text === "string";
|
|
223
|
+
}
|
|
224
|
+
/** Whether `value` is a valid web source (defensive narrowing). */
|
|
225
|
+
function isWebSource(value) {
|
|
226
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
227
|
+
const { url, title, snippet } = value;
|
|
228
|
+
return typeof url === "string" && (title === void 0 || typeof title === "string") && (snippet === void 0 || typeof snippet === "string");
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Narrow the opaque `tool/result.meta` into one bounded expansion payload,
|
|
232
|
+
* mirroring the upstream presenters' degradation ladder: diffs (write/edit),
|
|
233
|
+
* read windows (read), sources (web_search), fetch summaries (web_fetch), and
|
|
234
|
+
* the bounded raw result text as the universal fallback.
|
|
235
|
+
* @param meta - the persisted presentation metadata, when the tool attached one.
|
|
236
|
+
* @param rawText - the joined text blocks of the result message.
|
|
237
|
+
* @returns the expansion payload, or undefined when nothing renderable exists.
|
|
238
|
+
*/
|
|
239
|
+
function toolResultDetail(meta, rawText) {
|
|
240
|
+
if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
|
|
241
|
+
const record = meta;
|
|
242
|
+
const diffs = record["diffs"];
|
|
243
|
+
if (Array.isArray(diffs) && diffs.length > 0 && diffs.every(isFileDiff)) {
|
|
244
|
+
const budget = Math.max(8, Math.floor(MAX_DIFF_LINES / diffs.length));
|
|
245
|
+
return {
|
|
246
|
+
kind: "diff",
|
|
247
|
+
diffs: diffs.map((diff) => ({
|
|
248
|
+
path: diff.path,
|
|
249
|
+
...diffRows(diff.oldText, diff.newText, budget)
|
|
250
|
+
}))
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const { path, offset, lines, totalLines } = record;
|
|
254
|
+
if (typeof path === "string" && Number.isInteger(offset) && offset >= 1 && Number.isInteger(totalLines) && totalLines >= 0 && Array.isArray(lines) && lines.every(isReadLine)) {
|
|
255
|
+
const window = lines;
|
|
256
|
+
const truncated = window.length > MAX_READ_LINES;
|
|
257
|
+
return {
|
|
258
|
+
kind: "read",
|
|
259
|
+
path,
|
|
260
|
+
offset,
|
|
261
|
+
lines: (truncated ? window.slice(0, MAX_READ_LINES) : window).map((line) => ({
|
|
262
|
+
number: line.number,
|
|
263
|
+
text: clipLine(line.text)
|
|
264
|
+
})),
|
|
265
|
+
totalLines,
|
|
266
|
+
truncated
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const sources = record["sources"];
|
|
270
|
+
if (Array.isArray(sources) && sources.every(isWebSource)) {
|
|
271
|
+
const truncated = sources.length > MAX_SOURCES;
|
|
272
|
+
return {
|
|
273
|
+
kind: "web-search",
|
|
274
|
+
sources: (truncated ? sources.slice(0, MAX_SOURCES) : sources).map((source) => ({
|
|
275
|
+
url: source.url,
|
|
276
|
+
title: typeof source.title === "string" ? source.title : void 0,
|
|
277
|
+
snippet: typeof source.snippet === "string" ? clipLine(source.snippet) : ""
|
|
278
|
+
})),
|
|
279
|
+
truncated
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const { url, statusCode } = record;
|
|
283
|
+
if (typeof url === "string" && typeof statusCode === "number") return {
|
|
284
|
+
kind: "web-fetch",
|
|
285
|
+
url,
|
|
286
|
+
statusCode: Math.trunc(statusCode)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (rawText === "") return void 0;
|
|
290
|
+
const truncated = rawText.length > MAX_RAW_CHARS;
|
|
291
|
+
return {
|
|
292
|
+
kind: "raw",
|
|
293
|
+
text: truncated ? rawText.slice(0, MAX_RAW_CHARS) : rawText,
|
|
294
|
+
truncated
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/render/projection.ts
|
|
299
|
+
/**
|
|
300
|
+
* Pure session-event-to-view projection for the TUI transcript: one reducer
|
|
301
|
+
* over {@link SessionEvent}s producing the ordered entries the renderer draws.
|
|
302
|
+
* Rendering never reads the session directly — this module owns the view
|
|
303
|
+
* model, so tests drive it with plain event arrays.
|
|
304
|
+
*
|
|
305
|
+
* @module @deepseek-ai/dsh-tui/render/projection
|
|
306
|
+
*/
|
|
307
|
+
/** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
|
|
308
|
+
const MAX_STREAMING_CHARS = 65536;
|
|
309
|
+
/** Append one delta without retaining an unbounded duplicate of the live reply. */
|
|
310
|
+
function appendStreamingTail(current, delta) {
|
|
311
|
+
const next = current + delta;
|
|
312
|
+
return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-65536);
|
|
313
|
+
}
|
|
314
|
+
/** Join the text blocks of a content list; non-text blocks contribute nothing. */
|
|
315
|
+
function textOf(content) {
|
|
316
|
+
return content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
317
|
+
}
|
|
318
|
+
/** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
|
|
319
|
+
function reasoningOf(content) {
|
|
320
|
+
return content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
|
|
321
|
+
}
|
|
322
|
+
/** A fresh, empty transcript view. */
|
|
323
|
+
function createTranscriptView() {
|
|
324
|
+
return {
|
|
325
|
+
entries: [],
|
|
326
|
+
streaming: "",
|
|
327
|
+
streamingReasoning: "",
|
|
328
|
+
todos: [],
|
|
329
|
+
busy: false,
|
|
330
|
+
busySince: 0,
|
|
331
|
+
model: "",
|
|
332
|
+
plan: false,
|
|
333
|
+
permission: "",
|
|
334
|
+
title: "",
|
|
335
|
+
sandbox: "",
|
|
336
|
+
goal: void 0,
|
|
337
|
+
stats: {
|
|
338
|
+
turns: 0,
|
|
339
|
+
steps: 0,
|
|
340
|
+
llmMs: 0,
|
|
341
|
+
toolMs: 0,
|
|
342
|
+
usage: {
|
|
343
|
+
inputTokens: 0,
|
|
344
|
+
outputTokens: 0,
|
|
345
|
+
cacheReadTokens: 0
|
|
346
|
+
},
|
|
347
|
+
lastPromptTokens: 0,
|
|
348
|
+
contextWindow: 0,
|
|
349
|
+
ttftMs: 0,
|
|
350
|
+
ttftSteps: 0,
|
|
351
|
+
decodeMs: 0,
|
|
352
|
+
decodeTokens: 0
|
|
353
|
+
},
|
|
354
|
+
anchors: {
|
|
355
|
+
stepStart: /* @__PURE__ */ new Map(),
|
|
356
|
+
toolStart: /* @__PURE__ */ new Map(),
|
|
357
|
+
firstChunkAt: /* @__PURE__ */ new Map(),
|
|
358
|
+
compactionTokens: /* @__PURE__ */ new Map(),
|
|
359
|
+
lastPruneTokens: 0,
|
|
360
|
+
turnFiles: /* @__PURE__ */ new Map()
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Fold one session event into an updated view (copy-on-write).
|
|
366
|
+
* @param view - the view before the event.
|
|
367
|
+
* @param event - one durable session event from `session/event` or the log.
|
|
368
|
+
* @returns the view after the event; the input view is never mutated.
|
|
369
|
+
*/
|
|
370
|
+
function projectEvent(view, event) {
|
|
371
|
+
switch (event.type) {
|
|
372
|
+
case "user/message": {
|
|
373
|
+
const message = event.data;
|
|
374
|
+
if (message.source.kind === "user") return {
|
|
375
|
+
...view,
|
|
376
|
+
entries: [...view.entries, {
|
|
377
|
+
kind: "user",
|
|
378
|
+
text: textOf(message.content),
|
|
379
|
+
notice: false
|
|
380
|
+
}]
|
|
381
|
+
};
|
|
382
|
+
const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
|
|
383
|
+
return {
|
|
384
|
+
...view,
|
|
385
|
+
entries: [...view.entries, {
|
|
386
|
+
kind: "user",
|
|
387
|
+
text: boundContextSummary(notice),
|
|
388
|
+
notice: true
|
|
389
|
+
}]
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
case "assistant/chunk": {
|
|
393
|
+
const chunk = event.data.chunk;
|
|
394
|
+
const key = `${event.data.turn}:${event.data.step}`;
|
|
395
|
+
const delta = chunk.type === "text-delta" || chunk.type === "reasoning-delta" ? chunk.text : "";
|
|
396
|
+
let stats = view.stats;
|
|
397
|
+
if (delta !== "" && !view.anchors.firstChunkAt.has(key)) {
|
|
398
|
+
view.anchors.firstChunkAt.set(key, event.time);
|
|
399
|
+
const started = view.anchors.stepStart.get(key);
|
|
400
|
+
if (started !== void 0) stats = {
|
|
401
|
+
...stats,
|
|
402
|
+
ttftMs: stats.ttftMs + Math.max(0, event.time - started),
|
|
403
|
+
ttftSteps: stats.ttftSteps + 1
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (chunk.type === "text-delta") return {
|
|
407
|
+
...view,
|
|
408
|
+
streaming: appendStreamingTail(view.streaming, chunk.text),
|
|
409
|
+
stats
|
|
410
|
+
};
|
|
411
|
+
if (chunk.type === "reasoning-delta") return {
|
|
412
|
+
...view,
|
|
413
|
+
streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text),
|
|
414
|
+
stats
|
|
415
|
+
};
|
|
416
|
+
return view;
|
|
417
|
+
}
|
|
418
|
+
case "assistant/message": {
|
|
419
|
+
const key = `${event.data.turn}:${event.data.step}`;
|
|
420
|
+
const started = view.anchors.stepStart.get(key);
|
|
421
|
+
view.anchors.stepStart.delete(key);
|
|
422
|
+
const firstChunk = view.anchors.firstChunkAt.get(key);
|
|
423
|
+
view.anchors.firstChunkAt.delete(key);
|
|
424
|
+
const usage = event.data.usage;
|
|
425
|
+
const totals = view.stats.usage;
|
|
426
|
+
return {
|
|
427
|
+
...view,
|
|
428
|
+
streaming: "",
|
|
429
|
+
streamingReasoning: "",
|
|
430
|
+
entries: [...view.entries, {
|
|
431
|
+
kind: "assistant",
|
|
432
|
+
text: textOf(event.data.message.content),
|
|
433
|
+
reasoning: reasoningOf(event.data.message.content)
|
|
434
|
+
}],
|
|
435
|
+
stats: {
|
|
436
|
+
...view.stats,
|
|
437
|
+
llmMs: view.stats.llmMs + (started === void 0 ? 0 : Math.max(0, event.time - started)),
|
|
438
|
+
usage: usage === void 0 ? totals : {
|
|
439
|
+
inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
440
|
+
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
441
|
+
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0)
|
|
442
|
+
},
|
|
443
|
+
lastPromptTokens: usage === void 0 ? view.stats.lastPromptTokens : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
444
|
+
decodeMs: view.stats.decodeMs + (firstChunk === void 0 ? 0 : Math.max(0, event.time - firstChunk)),
|
|
445
|
+
decodeTokens: view.stats.decodeTokens + (firstChunk === void 0 || usage === void 0 ? 0 : usage.outputTokens)
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
case "tool/call": {
|
|
450
|
+
const data = event.data;
|
|
451
|
+
view.anchors.toolStart.set(data.callId, event.time);
|
|
452
|
+
return {
|
|
453
|
+
...view,
|
|
454
|
+
entries: [...view.entries, {
|
|
455
|
+
kind: "tool",
|
|
456
|
+
callId: data.callId,
|
|
457
|
+
name: data.name,
|
|
458
|
+
arguments: data.arguments,
|
|
459
|
+
preview: toolArgumentsPreview(data.arguments, data.name),
|
|
460
|
+
state: "running",
|
|
461
|
+
summary: "",
|
|
462
|
+
detail: void 0
|
|
463
|
+
}]
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
case "tool/result": {
|
|
467
|
+
const block = event.data.message.content[0];
|
|
468
|
+
const started = view.anchors.toolStart.get(block.toolCallId);
|
|
469
|
+
view.anchors.toolStart.delete(block.toolCallId);
|
|
470
|
+
const rawText = textOf(block.content);
|
|
471
|
+
const summary = boundContextSummary(rawText);
|
|
472
|
+
const detail = toolResultDetail(event.data.meta, rawText);
|
|
473
|
+
if (detail?.kind === "diff") {
|
|
474
|
+
const set = view.anchors.turnFiles.get(event.data.turn) ?? /* @__PURE__ */ new Set();
|
|
475
|
+
for (const diff of detail.diffs) set.add(diff.path);
|
|
476
|
+
view.anchors.turnFiles.set(event.data.turn, set);
|
|
477
|
+
}
|
|
478
|
+
const entries = view.entries.map((entry) => {
|
|
479
|
+
if (entry.kind !== "tool" || entry.callId !== block.toolCallId) return entry;
|
|
480
|
+
return {
|
|
481
|
+
...entry,
|
|
482
|
+
state: block.isError === true ? "error" : "done",
|
|
483
|
+
summary,
|
|
484
|
+
detail
|
|
485
|
+
};
|
|
486
|
+
});
|
|
487
|
+
return {
|
|
488
|
+
...view,
|
|
489
|
+
entries,
|
|
490
|
+
stats: {
|
|
491
|
+
...view.stats,
|
|
492
|
+
toolMs: view.stats.toolMs + (started === void 0 ? 0 : Math.max(0, event.time - started))
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
case "todo/write": return {
|
|
497
|
+
...view,
|
|
498
|
+
todos: event.data.todos
|
|
499
|
+
};
|
|
500
|
+
case "turn/start": return {
|
|
501
|
+
...view,
|
|
502
|
+
busy: true,
|
|
503
|
+
busySince: view.busy ? view.busySince : event.time,
|
|
504
|
+
todos: [],
|
|
505
|
+
stats: {
|
|
506
|
+
...view.stats,
|
|
507
|
+
turns: view.stats.turns + 1
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
case "step/start":
|
|
511
|
+
view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time);
|
|
512
|
+
return {
|
|
513
|
+
...view,
|
|
514
|
+
stats: {
|
|
515
|
+
...view.stats,
|
|
516
|
+
steps: view.stats.steps + 1
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
case "turn/end": {
|
|
520
|
+
const reason = event.data.reason;
|
|
521
|
+
const appended = [];
|
|
522
|
+
if (reason.kind === "error") appended.push({
|
|
523
|
+
kind: "error",
|
|
524
|
+
text: `${reason.error.code}: ${reason.error.message}`
|
|
525
|
+
});
|
|
526
|
+
else {
|
|
527
|
+
const marker = reason.kind === "aborted" ? reason.reason.kind === "user" ? "turn cancelled by the user" : `turn cancelled (${reason.reason.kind})` : reason.kind === "max-tokens" ? "turn hit the output-token ceiling (max-tokens)" : reason.kind === "blocked" ? "turn ended blocked" : reason.kind === "interrupted" ? "turn was interrupted by a restart" : void 0;
|
|
528
|
+
if (marker !== void 0) appended.push({
|
|
529
|
+
kind: "turn-marker",
|
|
530
|
+
text: marker
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
const files = view.anchors.turnFiles.get(event.data.turn);
|
|
534
|
+
view.anchors.turnFiles.delete(event.data.turn);
|
|
535
|
+
if (files !== void 0 && files.size > 0) appended.push({
|
|
536
|
+
kind: "files",
|
|
537
|
+
paths: [...files].slice(0, 12)
|
|
538
|
+
});
|
|
539
|
+
if (appended.length === 0) return {
|
|
540
|
+
...view,
|
|
541
|
+
busy: false,
|
|
542
|
+
busySince: 0
|
|
543
|
+
};
|
|
544
|
+
return {
|
|
545
|
+
...view,
|
|
546
|
+
busy: false,
|
|
547
|
+
busySince: 0,
|
|
548
|
+
entries: [...view.entries, ...appended]
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
case "llm/retry": {
|
|
552
|
+
const data = event.data;
|
|
553
|
+
return {
|
|
554
|
+
...view,
|
|
555
|
+
entries: [...view.entries, {
|
|
556
|
+
kind: "retry",
|
|
557
|
+
retryId: data.retryId,
|
|
558
|
+
attempt: data.retry,
|
|
559
|
+
max: "maxRetries" in data ? data.maxRetries : data.retry,
|
|
560
|
+
code: data.failure.code,
|
|
561
|
+
delayMs: data.delayMs,
|
|
562
|
+
state: "running"
|
|
563
|
+
}]
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
case "llm/retry-started": {
|
|
567
|
+
const data = event.data;
|
|
568
|
+
const entries = view.entries.map((entry) => {
|
|
569
|
+
if (entry.kind !== "retry" || entry.retryId !== data.retryId) return entry;
|
|
570
|
+
return {
|
|
571
|
+
...entry,
|
|
572
|
+
state: "done"
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
return {
|
|
576
|
+
...view,
|
|
577
|
+
entries
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
case "sandbox/mode": return {
|
|
581
|
+
...view,
|
|
582
|
+
sandbox: event.data.mode
|
|
583
|
+
};
|
|
584
|
+
case "goal/change": {
|
|
585
|
+
const data = event.data;
|
|
586
|
+
const clip = (text) => text.length > 60 ? `${text.slice(0, 59)}…` : text;
|
|
587
|
+
if (data.operation === "clear") return {
|
|
588
|
+
...view,
|
|
589
|
+
goal: void 0,
|
|
590
|
+
entries: [...view.entries, {
|
|
591
|
+
kind: "turn-marker",
|
|
592
|
+
text: "◎ goal cleared"
|
|
593
|
+
}]
|
|
594
|
+
};
|
|
595
|
+
const goal = {
|
|
596
|
+
objective: data.goal.objective,
|
|
597
|
+
phase: data.goal.phase,
|
|
598
|
+
rounds: data.roundsStarted,
|
|
599
|
+
max: data.goal.maxGoalRounds,
|
|
600
|
+
blocked: data.goal.blockedReason?.message ?? ""
|
|
601
|
+
};
|
|
602
|
+
const line = data.operation === "create" ? `◎ goal: ${clip(data.goal.objective)}` : data.operation === "complete" ? "◎ goal complete" : data.operation === "pause" ? "◎ goal paused" : data.operation === "resume" ? "◎ goal resumed" : data.operation === "block" ? `◎ goal blocked: ${clip(goal.blocked)}` : void 0;
|
|
603
|
+
return {
|
|
604
|
+
...view,
|
|
605
|
+
goal,
|
|
606
|
+
entries: line === void 0 ? view.entries : [...view.entries, {
|
|
607
|
+
kind: "turn-marker",
|
|
608
|
+
text: line
|
|
609
|
+
}]
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
case "session/title": return {
|
|
613
|
+
...view,
|
|
614
|
+
title: event.data.title
|
|
615
|
+
};
|
|
616
|
+
case "compaction/summary":
|
|
617
|
+
view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount);
|
|
618
|
+
return view;
|
|
619
|
+
case "compaction/prune": return {
|
|
620
|
+
...view,
|
|
621
|
+
anchors: {
|
|
622
|
+
...view.anchors,
|
|
623
|
+
lastPruneTokens: event.data.shadowedTokenCount
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
case "compaction/end": {
|
|
627
|
+
const ok = event.data.error === void 0;
|
|
628
|
+
const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens;
|
|
629
|
+
view.anchors.compactionTokens.delete(event.data.compactionId);
|
|
630
|
+
return {
|
|
631
|
+
...view,
|
|
632
|
+
entries: [...view.entries, {
|
|
633
|
+
kind: "compaction",
|
|
634
|
+
ok,
|
|
635
|
+
tokens,
|
|
636
|
+
error: event.data.error ?? ""
|
|
637
|
+
}]
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
case "request/context": return {
|
|
641
|
+
...view,
|
|
642
|
+
stats: {
|
|
643
|
+
...view.stats,
|
|
644
|
+
contextWindow: event.data.contextWindow ?? view.stats.contextWindow
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
case "request/header": {
|
|
648
|
+
const config = event.data.header.config;
|
|
649
|
+
return {
|
|
650
|
+
...view,
|
|
651
|
+
model: `${config.provider}/${config.model}`
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
case "plan/mode": return {
|
|
655
|
+
...view,
|
|
656
|
+
plan: event.data.active
|
|
657
|
+
};
|
|
658
|
+
case "permission/preset": return {
|
|
659
|
+
...view,
|
|
660
|
+
permission: event.data.preset
|
|
661
|
+
};
|
|
662
|
+
case "command/run": {
|
|
663
|
+
const data = event.data;
|
|
664
|
+
return {
|
|
665
|
+
...view,
|
|
666
|
+
entries: [...view.entries, {
|
|
667
|
+
kind: "command",
|
|
668
|
+
commandId: data.commandId,
|
|
669
|
+
name: data.name,
|
|
670
|
+
args: data.args ?? "",
|
|
671
|
+
state: "running",
|
|
672
|
+
summary: ""
|
|
673
|
+
}]
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
case "command/done": {
|
|
677
|
+
const data = event.data;
|
|
678
|
+
const entries = view.entries.map((entry) => {
|
|
679
|
+
if (entry.kind !== "command" || entry.commandId !== data.commandId) return entry;
|
|
680
|
+
return {
|
|
681
|
+
...entry,
|
|
682
|
+
state: data.kind === "success" ? "done" : "error",
|
|
683
|
+
summary: boundContextSummary(data.text ?? "")
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
return {
|
|
687
|
+
...view,
|
|
688
|
+
entries
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
default: return view;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Fold a replayed event history into one view.
|
|
696
|
+
* @param events - events in `seq` order.
|
|
697
|
+
* @returns the folded view.
|
|
698
|
+
*/
|
|
699
|
+
function projectEvents(events) {
|
|
700
|
+
return events.reduce(projectEvent, createTranscriptView());
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* How many leading transcript entries can never change again: only a
|
|
704
|
+
* `running` tool or retry can still mutate in place — everything before the
|
|
705
|
+
* first one (including a completed tail: later events only APPEND new rows)
|
|
706
|
+
* is final. The renderer currently draws the whole transcript dynamically
|
|
707
|
+
* (a `<Static>` flush proved unstable with CJK wrapping on real terminals);
|
|
708
|
+
* this boundary stays as the append-only contract for when flushing is
|
|
709
|
+
* reintroduced.
|
|
710
|
+
* @param entries - the view's transcript entries in order.
|
|
711
|
+
* @returns the count of entries safe to flush (0 for an empty transcript).
|
|
712
|
+
*/
|
|
713
|
+
function settledEntryCount(entries) {
|
|
714
|
+
for (let index = 0; index < entries.length; index++) {
|
|
715
|
+
const entry = entries[index];
|
|
716
|
+
if (entry.kind === "tool" && entry.state === "running") return index;
|
|
717
|
+
if (entry.kind === "retry" && entry.state === "running") return index;
|
|
718
|
+
}
|
|
719
|
+
return entries.length;
|
|
76
720
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/render/markdown.ts
|
|
723
|
+
/** Plain segment helper. */
|
|
724
|
+
function seg(text, style = "plain") {
|
|
725
|
+
return {
|
|
726
|
+
text,
|
|
727
|
+
style
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
/** Visible width of a run in columns (CJK counts double). */
|
|
731
|
+
function visibleColumns(text) {
|
|
732
|
+
let columns = 0;
|
|
733
|
+
for (const char of text) {
|
|
734
|
+
const code = char.codePointAt(0) ?? 0;
|
|
735
|
+
columns += code > 11903 ? 2 : 1;
|
|
736
|
+
}
|
|
737
|
+
return columns;
|
|
738
|
+
}
|
|
739
|
+
/** Walk a segment list, breaking it into lines that fit `width` columns. */
|
|
740
|
+
function wrapSegments(segments, width) {
|
|
741
|
+
const lines = [];
|
|
742
|
+
let current = [];
|
|
743
|
+
let used = 0;
|
|
744
|
+
for (const segment of segments) {
|
|
745
|
+
const words = segment.text.split(/( )/u);
|
|
746
|
+
for (const word of words) {
|
|
747
|
+
if (word === "") continue;
|
|
748
|
+
const columns = visibleColumns(word);
|
|
749
|
+
if (used + columns > width && used > 0) {
|
|
750
|
+
lines.push(current);
|
|
751
|
+
current = [];
|
|
752
|
+
used = 0;
|
|
753
|
+
}
|
|
754
|
+
current.push({
|
|
755
|
+
text: word,
|
|
756
|
+
style: segment.style
|
|
757
|
+
});
|
|
758
|
+
used += columns;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (current.length > 0) lines.push(current);
|
|
762
|
+
return lines.map((line) => {
|
|
763
|
+
const last = line[line.length - 1];
|
|
764
|
+
if (last !== void 0 && last.text === " " && line.length > 1) return line.slice(0, -1);
|
|
765
|
+
return line;
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/** Join adjacent same-style runs so the app renders fewer elements. */
|
|
769
|
+
function merge(segments) {
|
|
770
|
+
const merged = [];
|
|
771
|
+
for (const segment of segments) {
|
|
772
|
+
const last = merged[merged.length - 1];
|
|
773
|
+
if (last !== void 0 && last.style === segment.style) merged[merged.length - 1] = {
|
|
774
|
+
text: last.text + segment.text,
|
|
775
|
+
style: last.style
|
|
776
|
+
};
|
|
777
|
+
else merged.push({ ...segment });
|
|
778
|
+
}
|
|
779
|
+
return merged;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Parse inline markdown in one line of text. Link destinations render as a
|
|
783
|
+
* dim `(url)` suffix — the visible text keeps the accent.
|
|
784
|
+
*/
|
|
785
|
+
function parseInline(text) {
|
|
786
|
+
const runs = [];
|
|
787
|
+
let rest = text;
|
|
788
|
+
while (rest !== "") {
|
|
789
|
+
const code = /^`([^`]+)`/u.exec(rest);
|
|
790
|
+
if (code !== null) {
|
|
791
|
+
runs.push({
|
|
792
|
+
text: code[1] ?? "",
|
|
793
|
+
style: "code"
|
|
794
|
+
});
|
|
795
|
+
rest = rest.slice(code[0].length);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const boldItalic = /^\*\*\*([^*]+)\*\*\*/u.exec(rest);
|
|
799
|
+
if (boldItalic !== null) {
|
|
800
|
+
runs.push({
|
|
801
|
+
text: boldItalic[1] ?? "",
|
|
802
|
+
style: "boldItalic"
|
|
803
|
+
});
|
|
804
|
+
rest = rest.slice(boldItalic[0].length);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const bold = /^\*\*([^*]+)\*\*/u.exec(rest);
|
|
808
|
+
if (bold !== null) {
|
|
809
|
+
runs.push({
|
|
810
|
+
text: bold[1] ?? "",
|
|
811
|
+
style: "bold"
|
|
812
|
+
});
|
|
813
|
+
rest = rest.slice(bold[0].length);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const italic = /^\*([^*]+)\*/u.exec(rest) ?? /^_([^_]+)_/u.exec(rest);
|
|
817
|
+
if (italic !== null) {
|
|
818
|
+
runs.push({
|
|
819
|
+
text: italic[1] ?? "",
|
|
820
|
+
style: "italic"
|
|
821
|
+
});
|
|
822
|
+
rest = rest.slice(italic[0].length);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
const strike = /^~~([^~]+)~~/u.exec(rest);
|
|
826
|
+
if (strike !== null) {
|
|
827
|
+
runs.push({
|
|
828
|
+
text: strike[1] ?? "",
|
|
829
|
+
style: "strike"
|
|
830
|
+
});
|
|
831
|
+
rest = rest.slice(strike[0].length);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const link = /^\[([^\]]+)\]\(([^)\s]+)\)/u.exec(rest);
|
|
835
|
+
if (link !== null) {
|
|
836
|
+
const label = link[1] ?? "";
|
|
837
|
+
const url = link[2] ?? "";
|
|
838
|
+
runs.push({
|
|
839
|
+
text: label,
|
|
840
|
+
style: "accent"
|
|
841
|
+
});
|
|
842
|
+
runs.push({
|
|
843
|
+
text: ` (${url})`,
|
|
844
|
+
style: "dim"
|
|
845
|
+
});
|
|
846
|
+
rest = rest.slice(link[0].length);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const next = rest.search(/[*_`~[]/u);
|
|
850
|
+
if (next === -1) {
|
|
851
|
+
runs.push({
|
|
852
|
+
text: rest,
|
|
853
|
+
style: "plain"
|
|
854
|
+
});
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
if (next > 0) {
|
|
858
|
+
runs.push({
|
|
859
|
+
text: rest.slice(0, next),
|
|
860
|
+
style: "plain"
|
|
861
|
+
});
|
|
862
|
+
rest = rest.slice(next);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
runs.push({
|
|
866
|
+
text: rest.slice(0, 1),
|
|
867
|
+
style: "plain"
|
|
868
|
+
});
|
|
869
|
+
rest = rest.slice(1);
|
|
870
|
+
}
|
|
871
|
+
return runs;
|
|
80
872
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
873
|
+
const HEADING = /^(#{1,6})\s+(.*)$/u;
|
|
874
|
+
const FENCE = /^```([^\s`]*)\s*$/u;
|
|
875
|
+
const RULE = /^(?:---|\*\*\*|___)\s*$/u;
|
|
876
|
+
const QUOTE = /^>\s?(.*)$/u;
|
|
877
|
+
const UNORDERED = /^\s*[-*+]\s+(.*)$/u;
|
|
878
|
+
const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u;
|
|
879
|
+
/** Render markdown text into styled lines of at most `width` columns. */
|
|
880
|
+
function renderMarkdown(text, width) {
|
|
881
|
+
const lines = [];
|
|
882
|
+
const push = (segments) => {
|
|
883
|
+
for (const wrapped of wrapSegments(segments, Math.max(10, width))) lines.push({ segments: merge(wrapped) });
|
|
884
|
+
};
|
|
885
|
+
const source = text.replaceAll("\r", "").split("\n");
|
|
886
|
+
let index = 0;
|
|
887
|
+
while (index < source.length) {
|
|
888
|
+
const line = source[index] ?? "";
|
|
889
|
+
index += 1;
|
|
890
|
+
const fence = FENCE.exec(line);
|
|
891
|
+
if (fence !== null) {
|
|
892
|
+
const language = fence[1] ?? "";
|
|
893
|
+
if (language !== "") push([seg(` ${language}`, "dim")]);
|
|
894
|
+
while (index < source.length && !FENCE.test(source[index] ?? "")) {
|
|
895
|
+
push([seg(` ${source[index] ?? ""}`, "code")]);
|
|
896
|
+
index += 1;
|
|
897
|
+
}
|
|
898
|
+
index += 1;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (line.trim() === "") continue;
|
|
902
|
+
if (RULE.test(line.trim())) {
|
|
903
|
+
push([seg(` ${"─".repeat(Math.max(1, Math.floor(width / 4)))}`, "dim")]);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const heading = HEADING.exec(line);
|
|
907
|
+
if (heading !== null) {
|
|
908
|
+
push([seg(heading[2] ?? "", "accent")]);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const quote = QUOTE.exec(line);
|
|
912
|
+
if (quote !== null) {
|
|
913
|
+
push([seg(" │ ", "accent"), ...parseInline(quote[1] ?? "").map((run) => seg(run.text, run.style === "plain" ? "dim" : run.style))]);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const ordered = ORDERED.exec(line);
|
|
917
|
+
if (ordered !== null) {
|
|
918
|
+
push([seg(` ${ordered[1] ?? ""}. `, "accent"), ...parseInline(ordered[2] ?? "").map((run) => seg(run.text, run.style))]);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const unordered = UNORDERED.exec(line);
|
|
922
|
+
if (unordered !== null) {
|
|
923
|
+
push([seg(" • ", "accent"), ...parseInline(unordered[1] ?? "").map((run) => seg(run.text, run.style))]);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
const paragraph = [line];
|
|
927
|
+
while (index < source.length && (source[index] ?? "").trim() !== "") {
|
|
928
|
+
paragraph.push(source[index] ?? "");
|
|
929
|
+
index += 1;
|
|
930
|
+
}
|
|
931
|
+
const runs = [];
|
|
932
|
+
for (let at = 0; at < paragraph.length; at += 1) {
|
|
933
|
+
if (at > 0) runs.push({
|
|
934
|
+
text: " ",
|
|
935
|
+
style: "plain"
|
|
936
|
+
});
|
|
937
|
+
runs.push(...parseInline(paragraph[at] ?? ""));
|
|
938
|
+
}
|
|
939
|
+
push(runs.map((run) => seg(run.text, run.style)));
|
|
940
|
+
}
|
|
941
|
+
return lines;
|
|
84
942
|
}
|
|
85
943
|
//#endregion
|
|
86
|
-
//#region src/
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
944
|
+
//#region src/render/animations.ts
|
|
945
|
+
/**
|
|
946
|
+
* Terminal animation frame tables derived from the web design language:
|
|
947
|
+
* the StateDot "ongoing" pixel chase (3×3 ring, 125ms flat-hold brightness
|
|
948
|
+
* steps, 1s cycle) becomes the single-cell stepped pulse below, and the
|
|
949
|
+
* streaming caret blink is the Claude-Code convention. Pure functions only —
|
|
950
|
+
* the Ink layer owns timers and colors.
|
|
951
|
+
*
|
|
952
|
+
* @module @deepseek-ai/dsh-code/render/animations
|
|
953
|
+
*/
|
|
954
|
+
/** Single-cell stepped pulse: flat holds mirroring the web's 125ms keyframes. */
|
|
955
|
+
const PULSE_FRAMES = [
|
|
956
|
+
"█",
|
|
957
|
+
"█",
|
|
958
|
+
"▆",
|
|
959
|
+
"▃",
|
|
960
|
+
"▁",
|
|
961
|
+
"▃",
|
|
962
|
+
"▆",
|
|
963
|
+
"█"
|
|
97
964
|
];
|
|
965
|
+
/** Pulse frame for a monotonic tick. */
|
|
966
|
+
function pulseFrame(tick) {
|
|
967
|
+
return PULSE_FRAMES[tick % PULSE_FRAMES.length] ?? PULSE_FRAMES[0];
|
|
968
|
+
}
|
|
969
|
+
/** Caret visibility: half the ticks on, half off (530ms blink). */
|
|
970
|
+
function caretVisible(tick) {
|
|
971
|
+
return tick % 2 === 0;
|
|
972
|
+
}
|
|
98
973
|
//#endregion
|
|
99
974
|
//#region src/render/status.ts
|
|
100
975
|
/**
|
|
@@ -121,6 +996,17 @@ function formatDuration(ms) {
|
|
|
121
996
|
return `${Math.floor(whole / 60)}m${whole % 60}s`;
|
|
122
997
|
}
|
|
123
998
|
/**
|
|
999
|
+
* Compact decode rate: one decimal under a hundred, whole below a thousand,
|
|
1000
|
+
* then thousands (15.3 / 124 / 1.2K).
|
|
1001
|
+
* @param n - tokens per second.
|
|
1002
|
+
* @returns display string.
|
|
1003
|
+
*/
|
|
1004
|
+
function formatRate(n) {
|
|
1005
|
+
if (n < 100) return String(Math.round(n * 10) / 10);
|
|
1006
|
+
if (n < 1e3) return String(Math.round(n));
|
|
1007
|
+
return `${Math.round(n / 100) / 10}K`;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
124
1010
|
* Cache-hit share of billed prompt-side input.
|
|
125
1011
|
* @param usage - cumulative token totals.
|
|
126
1012
|
* @returns rounded integer percent, or null when no input was billed.
|
|
@@ -139,22 +1025,31 @@ function buildStatusGroups(facts, stats) {
|
|
|
139
1025
|
const identity = [
|
|
140
1026
|
facts.model,
|
|
141
1027
|
facts.cwd,
|
|
142
|
-
facts.branch === "" ? void 0 : `⑂ ${facts.branch}
|
|
1028
|
+
facts.branch === "" ? void 0 : `⑂ ${facts.branch}`,
|
|
1029
|
+
facts.plan ? "⧉ plan" : void 0
|
|
143
1030
|
].filter((part) => part !== void 0 && part !== "");
|
|
144
1031
|
if (identity.length > 0) groups.push(identity.join(" · "));
|
|
145
1032
|
if (stats.turns > 0 || stats.steps > 0) {
|
|
146
1033
|
groups.push(`T${stats.turns} · S${stats.steps}`);
|
|
147
1034
|
const durations = [];
|
|
148
1035
|
if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`);
|
|
1036
|
+
if (stats.ttftSteps > 0) durations.push(`ttft ${formatDuration(stats.ttftMs / stats.ttftSteps)}`);
|
|
1037
|
+
if (stats.decodeMs > 0 && stats.decodeTokens > 0) durations.push(`${formatRate(stats.decodeTokens / (stats.decodeMs / 1e3))} tok/s`);
|
|
149
1038
|
if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`);
|
|
150
1039
|
if (durations.length > 0) groups.push(durations.join(" · "));
|
|
151
1040
|
}
|
|
152
1041
|
const cacheHit = cacheHitPercent(stats.usage);
|
|
153
1042
|
if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
|
|
154
1043
|
if (cacheHit !== null) groups.push(`cache ${cacheHit}%`);
|
|
1044
|
+
if (stats.contextWindow > 0 && stats.lastPromptTokens > 0) groups.push(`ctx ${Math.min(999, Math.round(stats.lastPromptTokens / stats.contextWindow * 100))}%`);
|
|
155
1045
|
groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`);
|
|
156
1046
|
}
|
|
157
|
-
|
|
1047
|
+
const label = facts.title !== void 0 && facts.title !== "" ? facts.title.length > 48 ? `${facts.title.slice(0, 47)}…` : facts.title : facts.sessionId;
|
|
1048
|
+
if (label !== "") groups.push(label);
|
|
1049
|
+
if (facts.permission !== void 0 && facts.permission !== "") groups.push(facts.permission);
|
|
1050
|
+
const sandbox = facts.sandbox ?? "";
|
|
1051
|
+
if (sandbox !== "" && sandbox.toLowerCase() !== facts.permission.toLowerCase()) groups.push(`sandbox ${sandbox}`);
|
|
1052
|
+
if (facts.goal !== void 0) groups.push(facts.goal.phase === "active" ? `◎ r${facts.goal.rounds}/${facts.goal.max}` : `◎ ${facts.goal.phase}`);
|
|
158
1053
|
return groups;
|
|
159
1054
|
}
|
|
160
1055
|
//#endregion
|
|
@@ -181,6 +1076,244 @@ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
|
|
|
181
1076
|
function displayText(text) {
|
|
182
1077
|
return text.replace(CONTROL_ESCAPE, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
183
1078
|
}
|
|
1079
|
+
/** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
|
|
1080
|
+
function cellWidth(text) {
|
|
1081
|
+
let columns = 0;
|
|
1082
|
+
for (const char of text) columns += (char.codePointAt(0) ?? 0) > 11903 ? 2 : 1;
|
|
1083
|
+
return columns;
|
|
1084
|
+
}
|
|
1085
|
+
/** Read one Unicode character immediately before `end`. */
|
|
1086
|
+
function previousCharacter(text, end) {
|
|
1087
|
+
const last = text.charCodeAt(end - 1);
|
|
1088
|
+
if (last >= 56320 && last <= 57343 && end >= 2) {
|
|
1089
|
+
const first = text.charCodeAt(end - 2);
|
|
1090
|
+
if (first >= 55296 && first <= 56319) return {
|
|
1091
|
+
char: text.slice(end - 2, end),
|
|
1092
|
+
start: end - 2
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return {
|
|
1096
|
+
char: text.slice(end - 1, end),
|
|
1097
|
+
start: end - 1
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Keep only the newest display-safe text that fits a terminal rectangle.
|
|
1102
|
+
* The scan walks backward and stops as soon as the suffix is full, so a long
|
|
1103
|
+
* reasoning stream does not rescan its entire accumulated prefix per chunk.
|
|
1104
|
+
* Explicit newlines and terminal wrapping both consume rows.
|
|
1105
|
+
* @param text - raw externally sourced text.
|
|
1106
|
+
* @param columns - available terminal columns.
|
|
1107
|
+
* @param rows - available terminal rows.
|
|
1108
|
+
* @returns a sanitized bounded suffix and whether an earlier prefix was cut.
|
|
1109
|
+
*/
|
|
1110
|
+
function displayTail(text, columns, rows) {
|
|
1111
|
+
const columnLimit = Math.max(1, Math.floor(columns));
|
|
1112
|
+
const rowLimit = Math.max(1, Math.floor(rows));
|
|
1113
|
+
const reversed = [];
|
|
1114
|
+
let row = 1;
|
|
1115
|
+
let used = 0;
|
|
1116
|
+
let end = text.length;
|
|
1117
|
+
while (end > 0) {
|
|
1118
|
+
const previous = previousCharacter(text, end);
|
|
1119
|
+
if (previous.char === "\n") {
|
|
1120
|
+
if (row >= rowLimit) break;
|
|
1121
|
+
reversed.push("\n");
|
|
1122
|
+
row += 1;
|
|
1123
|
+
used = 0;
|
|
1124
|
+
end = previous.start;
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const safe = displayText(previous.char);
|
|
1128
|
+
const width = cellWidth(safe);
|
|
1129
|
+
if (used > 0 && used + width > columnLimit) {
|
|
1130
|
+
if (row >= rowLimit) break;
|
|
1131
|
+
reversed.push("\n");
|
|
1132
|
+
row += 1;
|
|
1133
|
+
used = 0;
|
|
1134
|
+
}
|
|
1135
|
+
const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit);
|
|
1136
|
+
if (row + extraRows > rowLimit) break;
|
|
1137
|
+
row += extraRows;
|
|
1138
|
+
reversed.push(safe);
|
|
1139
|
+
used = extraRows === 0 ? used + width : width - extraRows * columnLimit;
|
|
1140
|
+
end = previous.start;
|
|
1141
|
+
}
|
|
1142
|
+
return {
|
|
1143
|
+
text: reversed.reverse().join(""),
|
|
1144
|
+
truncated: end > 0
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region src/render/inspector.ts
|
|
1149
|
+
/** The three-row read-only composer frame plus its one-row status footer. */
|
|
1150
|
+
const INSPECTOR_CHROME_ROWS = 4;
|
|
1151
|
+
/**
|
|
1152
|
+
* Keep the inspector plus its persistent status/composer chrome below
|
|
1153
|
+
* `stdout.rows`: at equality Ink clears the terminal and rewrites all
|
|
1154
|
+
* accumulated `<Static>` output on every frame.
|
|
1155
|
+
*/
|
|
1156
|
+
function panelViewport(columns, rows) {
|
|
1157
|
+
const safeColumns = Math.max(1, Math.floor(columns));
|
|
1158
|
+
const safeRows = Math.max(1, Math.floor(rows));
|
|
1159
|
+
const maxHeight = Math.max(0, Math.min(safeRows - 2 - INSPECTOR_CHROME_ROWS, Math.floor(safeRows / 2)));
|
|
1160
|
+
const compact = maxHeight < 5 || safeColumns < 8;
|
|
1161
|
+
return {
|
|
1162
|
+
maxHeight,
|
|
1163
|
+
bodyRows: compact ? 0 : maxHeight - 4,
|
|
1164
|
+
contentColumns: compact ? Math.max(1, safeColumns - 1) : Math.max(1, safeColumns - 4),
|
|
1165
|
+
compact
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
/** Backward-compatible name for the Ctrl+O-specific caller and tests. */
|
|
1169
|
+
function inspectorViewport(columns, rows) {
|
|
1170
|
+
return panelViewport(columns, rows);
|
|
1171
|
+
}
|
|
1172
|
+
/** Clamp a first-visible row to the range representable by one viewport. */
|
|
1173
|
+
function clampScroll(offset, totalRows, visibleRows) {
|
|
1174
|
+
const last = Math.max(0, Math.max(0, Math.floor(totalRows)) - Math.max(0, Math.floor(visibleRows)));
|
|
1175
|
+
return Math.max(0, Math.min(Math.floor(offset), last));
|
|
1176
|
+
}
|
|
1177
|
+
/** Move a viewport by a signed row delta without escaping its content. */
|
|
1178
|
+
function moveScroll(offset, delta, totalRows, visibleRows) {
|
|
1179
|
+
return clampScroll(offset + delta, totalRows, visibleRows);
|
|
1180
|
+
}
|
|
1181
|
+
/** Keep one focused row visible while preserving the current window when possible. */
|
|
1182
|
+
function revealRow(offset, row, totalRows, visibleRows) {
|
|
1183
|
+
const size = Math.max(1, Math.floor(visibleRows));
|
|
1184
|
+
const target = Math.max(0, Math.min(Math.floor(row), Math.max(0, totalRows - 1)));
|
|
1185
|
+
if (target < offset) return clampScroll(target, totalRows, size);
|
|
1186
|
+
if (target >= offset + size) return clampScroll(target - size + 1, totalRows, size);
|
|
1187
|
+
return clampScroll(offset, totalRows, size);
|
|
1188
|
+
}
|
|
1189
|
+
/** Center a selected list row where possible, clamped at both ends. */
|
|
1190
|
+
function selectionWindow(cursor, totalRows, visibleRows) {
|
|
1191
|
+
return clampScroll(cursor - Math.floor(Math.max(1, visibleRows) / 2), totalRows, visibleRows);
|
|
1192
|
+
}
|
|
1193
|
+
/** Follow appended history only while the inspector cursor was at the tail. */
|
|
1194
|
+
function followInspectorCursor(cursor, previousLength, nextLength) {
|
|
1195
|
+
const nextLast = Math.max(0, nextLength - 1);
|
|
1196
|
+
if (cursor >= Math.max(0, previousLength - 1)) return nextLast;
|
|
1197
|
+
return Math.min(cursor, nextLast);
|
|
1198
|
+
}
|
|
1199
|
+
//#endregion
|
|
1200
|
+
//#region src/render/lines.ts
|
|
1201
|
+
/** Construct one segment without leaking mutable objects into cached rows. */
|
|
1202
|
+
function lineSegment(text, style = "plain") {
|
|
1203
|
+
return {
|
|
1204
|
+
text,
|
|
1205
|
+
style
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
/** Append a character while merging adjacent runs with the same style. */
|
|
1209
|
+
function appendSegment(target, text, style) {
|
|
1210
|
+
const previous = target[target.length - 1];
|
|
1211
|
+
if (previous?.style === style) {
|
|
1212
|
+
target[target.length - 1] = {
|
|
1213
|
+
text: previous.text + text,
|
|
1214
|
+
style
|
|
1215
|
+
};
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
target.push({
|
|
1219
|
+
text,
|
|
1220
|
+
style
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Sanitize and hard-wrap styled content into exact physical rows.
|
|
1225
|
+
* Tabs become two visible spaces because terminal tab stops are contextual
|
|
1226
|
+
* and therefore cannot participate in a deterministic row budget.
|
|
1227
|
+
*/
|
|
1228
|
+
function styledLines(segments, columns) {
|
|
1229
|
+
const width = Math.max(1, Math.floor(columns));
|
|
1230
|
+
const lines = [];
|
|
1231
|
+
let current = [];
|
|
1232
|
+
let used = 0;
|
|
1233
|
+
const flush = () => {
|
|
1234
|
+
lines.push({ segments: current });
|
|
1235
|
+
current = [];
|
|
1236
|
+
used = 0;
|
|
1237
|
+
};
|
|
1238
|
+
for (const segment of segments) {
|
|
1239
|
+
const safe = displayText(segment.text).replaceAll(" ", " ").replaceAll("\r", "");
|
|
1240
|
+
for (const char of safe) {
|
|
1241
|
+
if (char === "\n") {
|
|
1242
|
+
flush();
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
const cells = visibleColumns(char);
|
|
1246
|
+
if (used > 0 && used + cells > width) flush();
|
|
1247
|
+
appendSegment(current, char, segment.style);
|
|
1248
|
+
used += cells;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (current.length > 0 || lines.length === 0) flush();
|
|
1252
|
+
return lines;
|
|
1253
|
+
}
|
|
1254
|
+
/** Plain/dim text convenience over {@link styledLines}. */
|
|
1255
|
+
function textLines(text, columns, style = "plain") {
|
|
1256
|
+
return styledLines([lineSegment(text, style)], columns);
|
|
1257
|
+
}
|
|
1258
|
+
/** Markdown rows re-hardened so a single long word cannot escape the budget. */
|
|
1259
|
+
function markdownLines(text, columns) {
|
|
1260
|
+
const width = Math.max(1, Math.floor(columns));
|
|
1261
|
+
return renderMarkdown(displayText(text), Math.max(10, width)).flatMap((line) => styledLines(line.segments.map((segment) => lineSegment(segment.text, segment.style)), width));
|
|
1262
|
+
}
|
|
1263
|
+
/** Expanded structured tool detail as scrollable, width-safe rows. */
|
|
1264
|
+
function toolDetailLines(detail, columns) {
|
|
1265
|
+
switch (detail.kind) {
|
|
1266
|
+
case "diff": return detail.diffs.flatMap((diff) => [...styledLines([
|
|
1267
|
+
lineSegment(" ── ", "dim"),
|
|
1268
|
+
lineSegment(diff.path, "dim"),
|
|
1269
|
+
lineSegment(diff.truncated ? " (diff truncated)" : "", "dim")
|
|
1270
|
+
], columns), ...diff.lines.flatMap((line) => styledLines([lineSegment(` ${line.mark}${line.text}`, line.mark === "+" ? "success" : line.mark === "-" ? "error" : "dim")], columns))]);
|
|
1271
|
+
case "read": return [...textLines(` ── ${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1].number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? " (window truncated)" : ""}`, columns, "dim"), ...detail.lines.flatMap((line) => textLines(` ${String(line.number).padStart(5, " ")} | ${line.text}`, columns, "dim"))];
|
|
1272
|
+
case "web-search": return [...detail.sources.flatMap((source) => [...styledLines([lineSegment(` ? ${source.title ?? source.url}`, "brand"), lineSegment(` - ${source.url}`, "dim")], columns), ...source.snippet === "" ? [] : textLines(` ${source.snippet}`, columns, "dim")]), ...textLines(` ${detail.sources.length} sources${detail.truncated ? " (capped)" : ""}`, columns, "dim")];
|
|
1273
|
+
case "web-fetch": return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, "dim");
|
|
1274
|
+
case "raw": return [...textLines(` ${detail.text}`, columns, "dim"), ...textLines(detail.truncated ? " … (output truncated)" : " (end of output)", columns, "dim")];
|
|
1275
|
+
default: return detail;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Convert one durable transcript entry to its complete scrollable row model.
|
|
1280
|
+
* The source entry stays intact; only the caller's visible slice is rendered.
|
|
1281
|
+
*/
|
|
1282
|
+
function transcriptEntryLines(entry, columns) {
|
|
1283
|
+
const width = Math.max(1, Math.floor(columns));
|
|
1284
|
+
switch (entry.kind) {
|
|
1285
|
+
case "user": return styledLines([lineSegment(entry.notice ? "⤷ " : "❯ ", entry.notice ? "dim" : "brand"), lineSegment(entry.text, entry.notice ? "dim" : "plain")], width);
|
|
1286
|
+
case "assistant": return [...entry.reasoning === "" ? [] : styledLines([lineSegment(" ✻ ", "dimItalic"), lineSegment(entry.reasoning, "dimItalic")], width), ...markdownLines(entry.text, width)];
|
|
1287
|
+
case "tool": {
|
|
1288
|
+
const mark = entry.state === "running" ? "●" : entry.state === "error" ? "⨯" : "⏺";
|
|
1289
|
+
const markStyle = entry.state === "running" ? "brand" : entry.state === "error" ? "error" : "success";
|
|
1290
|
+
return [
|
|
1291
|
+
...styledLines([
|
|
1292
|
+
lineSegment(`${mark} `, markStyle),
|
|
1293
|
+
lineSegment(entry.name, "brand"),
|
|
1294
|
+
lineSegment(entry.preview === "" ? "" : ` ${entry.preview}`, "dim")
|
|
1295
|
+
], width),
|
|
1296
|
+
...entry.summary === "" ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === "error" ? "error" : "dim"),
|
|
1297
|
+
...entry.detail === void 0 ? [] : toolDetailLines(entry.detail, width)
|
|
1298
|
+
];
|
|
1299
|
+
}
|
|
1300
|
+
case "command": {
|
|
1301
|
+
const mark = entry.state === "running" ? "●" : entry.state === "error" ? "⨯" : "⏺";
|
|
1302
|
+
const markStyle = entry.state === "running" ? "brand" : entry.state === "error" ? "error" : "success";
|
|
1303
|
+
return [...styledLines([
|
|
1304
|
+
lineSegment(`${mark} `, markStyle),
|
|
1305
|
+
lineSegment(`/${entry.name}`, "brand"),
|
|
1306
|
+
lineSegment(entry.args === "" ? "" : ` ${entry.args}`, "dim")
|
|
1307
|
+
], width), ...entry.summary === "" ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === "error" ? "error" : "dim")];
|
|
1308
|
+
}
|
|
1309
|
+
case "turn-marker": return textLines(` ⏹ ${entry.text}`, width, "dim");
|
|
1310
|
+
case "compaction": return textLines(entry.ok ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens` : ` ⧉ compaction failed: ${entry.error}`, width, "dim");
|
|
1311
|
+
case "retry": return textLines(` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`, width, entry.state === "running" ? "warn" : "dim");
|
|
1312
|
+
case "files": return entry.paths.length === 0 ? textLines(" ⎄ no changed files", width, "dim") : [...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? "" : "s"}`, width, "dim"), ...entry.paths.flatMap((path) => textLines(` ${path}`, width, "dim"))];
|
|
1313
|
+
case "error": return textLines(entry.text, width, "error");
|
|
1314
|
+
default: return entry;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
184
1317
|
//#endregion
|
|
185
1318
|
//#region src/app.ts
|
|
186
1319
|
/**
|
|
@@ -198,29 +1331,315 @@ function displayText(text) {
|
|
|
198
1331
|
*
|
|
199
1332
|
* @module @deepseek-ai/dsh-code/app
|
|
200
1333
|
*/
|
|
1334
|
+
/** Match Codex's settled-resize window before rebuilding terminal scrollback. */
|
|
1335
|
+
const RESIZE_REFLOW_DELAY_MS = 75;
|
|
1336
|
+
/** Reset region/style, clear the visible screen and scrollback, then home. */
|
|
1337
|
+
const RESIZE_REFLOW_CLEAR = "\x1B[r\x1B[0m\x1B[H\x1B[2J\x1B[3J\x1B[H";
|
|
201
1338
|
/** Ink `color` string for one palette triple. */
|
|
202
1339
|
function inkColor(triple) {
|
|
203
1340
|
return `rgb(${triple[0]}, ${triple[1]}, ${triple[2]})`;
|
|
204
1341
|
}
|
|
1342
|
+
/** Truncate text to a visible-column budget, appending … when cut. */
|
|
1343
|
+
function truncateColumns(text, max) {
|
|
1344
|
+
let columns = 0;
|
|
1345
|
+
let out = "";
|
|
1346
|
+
for (const char of text) {
|
|
1347
|
+
const width = (char.codePointAt(0) ?? 0) > 11903 ? 2 : 1;
|
|
1348
|
+
if (columns + width > max) return `${out}…`;
|
|
1349
|
+
out += char;
|
|
1350
|
+
columns += width;
|
|
1351
|
+
}
|
|
1352
|
+
return out;
|
|
1353
|
+
}
|
|
1354
|
+
/** Pad text with spaces to a visible-column target (menu name column). */
|
|
1355
|
+
function padColumns(text, width) {
|
|
1356
|
+
return text + " ".repeat(Math.max(0, width - visibleColumns(text)));
|
|
1357
|
+
}
|
|
1358
|
+
/** Interval-driven frame counter for one self-contained animated leaf. */
|
|
1359
|
+
function useFrames(intervalMs) {
|
|
1360
|
+
const [tick, setTick] = useState(0);
|
|
1361
|
+
useEffect(() => {
|
|
1362
|
+
const id = setInterval(() => setTick((current) => current + 1), intervalMs);
|
|
1363
|
+
return () => {
|
|
1364
|
+
clearInterval(id);
|
|
1365
|
+
};
|
|
1366
|
+
}, [intervalMs]);
|
|
1367
|
+
return tick;
|
|
1368
|
+
}
|
|
1369
|
+
/** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
|
|
1370
|
+
function Pulse() {
|
|
1371
|
+
const tick = useFrames(125);
|
|
1372
|
+
return createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, pulseFrame(tick));
|
|
1373
|
+
}
|
|
1374
|
+
/** Blinking block caret appended to streaming text. */
|
|
1375
|
+
function Caret() {
|
|
1376
|
+
const tick = useFrames(530);
|
|
1377
|
+
return createElement(Text, null, caretVisible(tick) ? "▍" : " ");
|
|
1378
|
+
}
|
|
1379
|
+
/** Blinking input cursor: inverse block while the caret phase is on. */
|
|
1380
|
+
function CursorBlock({ char }) {
|
|
1381
|
+
const tick = useFrames(530);
|
|
1382
|
+
return createElement(Text, { inverse: caretVisible(tick) || void 0 }, char);
|
|
1383
|
+
}
|
|
1384
|
+
/** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
|
|
1385
|
+
function runClock(ms) {
|
|
1386
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
1387
|
+
const minutes = Math.floor(total / 60);
|
|
1388
|
+
const seconds = total % 60;
|
|
1389
|
+
return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* The busy line, web TurnStatus contract: the plain `Deep diving...` label,
|
|
1393
|
+
* with the elapsed clock appended only once the turn has clearly been running
|
|
1394
|
+
* (15s) — anchored to `turn/start` so a resumed mid-turn keeps the real time.
|
|
1395
|
+
*/
|
|
1396
|
+
function DeepDivingLine({ since }) {
|
|
1397
|
+
useFrames(1e3);
|
|
1398
|
+
const elapsed = since === 0 ? 0 : Date.now() - since;
|
|
1399
|
+
return createElement(Text, { dimColor: true }, elapsed >= 15e3 ? `Deep diving... ${runClock(elapsed)}` : "Deep diving...");
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* The streaming buffer rendered with a hard size cap: the live region must
|
|
1403
|
+
* ALWAYS fit the terminal, or Ink's erase/rewrite of a dynamic tree taller
|
|
1404
|
+
* than the screen freezes (cursor-up past the top, garbage, no scroll). The
|
|
1405
|
+
* cap counts explicit newlines and terminal wrapping, slicing from the END so
|
|
1406
|
+
* the freshest tokens stay visible while a long reply streams; the complete
|
|
1407
|
+
* text lands in the flushed scrollback once the turn assembles it.
|
|
1408
|
+
*/
|
|
1409
|
+
function StreamTail({ text, dim, maxRows, prefix, children }) {
|
|
1410
|
+
const columns = useStdout().stdout?.columns ?? 80;
|
|
1411
|
+
const safeRows = Math.max(1, maxRows);
|
|
1412
|
+
const contentColumns = Math.max(10, columns - 3 - visibleColumns(prefix ?? ""));
|
|
1413
|
+
const initial = displayTail(text, contentColumns, safeRows);
|
|
1414
|
+
const tail = initial.truncated && safeRows > 1 ? displayTail(text, contentColumns, safeRows - 1) : initial;
|
|
1415
|
+
return createElement(Box, { flexDirection: "column" }, tail.truncated && safeRows > 1 ? createElement(Text, { dimColor: true }, " …") : void 0, createElement(Text, { dimColor: dim || void 0 }, prefix, tail.text, children));
|
|
1416
|
+
}
|
|
1417
|
+
/** Ink props for one markdown style class. */
|
|
1418
|
+
function segmentProps(style) {
|
|
1419
|
+
switch (style) {
|
|
1420
|
+
case "accent": return {
|
|
1421
|
+
color: inkColor(TUI_RGB.brandBright),
|
|
1422
|
+
bold: void 0,
|
|
1423
|
+
italic: void 0,
|
|
1424
|
+
strikethrough: void 0
|
|
1425
|
+
};
|
|
1426
|
+
case "code": return {
|
|
1427
|
+
color: inkColor(TUI_RGB.code),
|
|
1428
|
+
bold: void 0,
|
|
1429
|
+
italic: void 0,
|
|
1430
|
+
strikethrough: void 0
|
|
1431
|
+
};
|
|
1432
|
+
case "dim": return {
|
|
1433
|
+
color: inkColor(TUI_RGB.dim),
|
|
1434
|
+
bold: void 0,
|
|
1435
|
+
italic: void 0,
|
|
1436
|
+
strikethrough: void 0
|
|
1437
|
+
};
|
|
1438
|
+
case "bold": return {
|
|
1439
|
+
color: void 0,
|
|
1440
|
+
bold: true,
|
|
1441
|
+
italic: void 0,
|
|
1442
|
+
strikethrough: void 0
|
|
1443
|
+
};
|
|
1444
|
+
case "italic": return {
|
|
1445
|
+
color: void 0,
|
|
1446
|
+
bold: void 0,
|
|
1447
|
+
italic: true,
|
|
1448
|
+
strikethrough: void 0
|
|
1449
|
+
};
|
|
1450
|
+
case "boldItalic": return {
|
|
1451
|
+
color: void 0,
|
|
1452
|
+
bold: true,
|
|
1453
|
+
italic: true,
|
|
1454
|
+
strikethrough: void 0
|
|
1455
|
+
};
|
|
1456
|
+
case "strike": return {
|
|
1457
|
+
color: inkColor(TUI_RGB.dim),
|
|
1458
|
+
bold: void 0,
|
|
1459
|
+
italic: void 0,
|
|
1460
|
+
strikethrough: true
|
|
1461
|
+
};
|
|
1462
|
+
default: return {
|
|
1463
|
+
color: void 0,
|
|
1464
|
+
bold: void 0,
|
|
1465
|
+
italic: void 0,
|
|
1466
|
+
strikethrough: void 0
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
/** Ink props for the richer line model used by bounded scrolling panels. */
|
|
1471
|
+
function lineStyleProps(style) {
|
|
1472
|
+
switch (style) {
|
|
1473
|
+
case "brand": return {
|
|
1474
|
+
color: inkColor(TUI_RGB.brandBright),
|
|
1475
|
+
bold: void 0,
|
|
1476
|
+
italic: void 0,
|
|
1477
|
+
strikethrough: void 0,
|
|
1478
|
+
dimColor: void 0
|
|
1479
|
+
};
|
|
1480
|
+
case "success": return {
|
|
1481
|
+
color: inkColor(TUI_RGB.success),
|
|
1482
|
+
bold: void 0,
|
|
1483
|
+
italic: void 0,
|
|
1484
|
+
strikethrough: void 0,
|
|
1485
|
+
dimColor: void 0
|
|
1486
|
+
};
|
|
1487
|
+
case "error": return {
|
|
1488
|
+
color: inkColor(TUI_RGB.error),
|
|
1489
|
+
bold: void 0,
|
|
1490
|
+
italic: void 0,
|
|
1491
|
+
strikethrough: void 0,
|
|
1492
|
+
dimColor: void 0
|
|
1493
|
+
};
|
|
1494
|
+
case "warn": return {
|
|
1495
|
+
color: inkColor(TUI_RGB.warn),
|
|
1496
|
+
bold: void 0,
|
|
1497
|
+
italic: void 0,
|
|
1498
|
+
strikethrough: void 0,
|
|
1499
|
+
dimColor: void 0
|
|
1500
|
+
};
|
|
1501
|
+
case "dimItalic": return {
|
|
1502
|
+
color: void 0,
|
|
1503
|
+
bold: void 0,
|
|
1504
|
+
italic: true,
|
|
1505
|
+
strikethrough: void 0,
|
|
1506
|
+
dimColor: true
|
|
1507
|
+
};
|
|
1508
|
+
default: return {
|
|
1509
|
+
...segmentProps(style),
|
|
1510
|
+
dimColor: void 0
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
/** Render width-safe rows; every child is exactly one terminal row. */
|
|
1515
|
+
function StyledRows({ lines }) {
|
|
1516
|
+
return createElement(Box, { flexDirection: "column" }, ...lines.map((line, index) => createElement(Text, {
|
|
1517
|
+
key: index,
|
|
1518
|
+
wrap: "truncate-end"
|
|
1519
|
+
}, line.segments.length === 0 ? " " : line.segments.map((segment, at) => createElement(Text, {
|
|
1520
|
+
key: at,
|
|
1521
|
+
...lineStyleProps(segment.style)
|
|
1522
|
+
}, segment.text)))));
|
|
1523
|
+
}
|
|
1524
|
+
/** One settled markdown document rendered as styled lines at the terminal width. */
|
|
1525
|
+
function MarkdownBody({ text }) {
|
|
1526
|
+
const columns = useStdout().stdout?.columns ?? 80;
|
|
1527
|
+
const lines = useMemo(() => renderMarkdown(displayText(text), Math.max(20, columns - 2)), [text, columns]);
|
|
1528
|
+
return createElement(Box, { flexDirection: "column" }, ...lines.map((line, index) => createElement(Text, { key: index }, ...line.segments.map((segment, at) => createElement(Text, {
|
|
1529
|
+
key: at,
|
|
1530
|
+
...segmentProps(segment.style)
|
|
1531
|
+
}, segment.text)))));
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* One expanded tool-card body for the verbose transcript (Ctrl+O): the
|
|
1535
|
+
* presentation contract's structured cards — inline diffs, read windows,
|
|
1536
|
+
* web sources — rendered as plain terminal rows, degradation-safe against
|
|
1537
|
+
* replayed metadata.
|
|
1538
|
+
*/
|
|
1539
|
+
function ToolDetailBody({ detail }) {
|
|
1540
|
+
switch (detail.kind) {
|
|
1541
|
+
case "diff": return createElement(Box, { flexDirection: "column" }, ...detail.diffs.map((diff, index) => createElement(Box, {
|
|
1542
|
+
key: index,
|
|
1543
|
+
flexDirection: "column"
|
|
1544
|
+
}, createElement(Text, {
|
|
1545
|
+
dimColor: true,
|
|
1546
|
+
wrap: "truncate-end"
|
|
1547
|
+
}, ` ── ${displayText(diff.path)}${diff.truncated ? " (diff truncated)" : ""}`), ...diff.lines.map((line, at) => createElement(Text, {
|
|
1548
|
+
key: at,
|
|
1549
|
+
color: line.mark === "+" ? inkColor(TUI_RGB.success) : line.mark === "-" ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
|
|
1550
|
+
wrap: "truncate-end"
|
|
1551
|
+
}, ` ${line.mark}${displayText(line.text)}`)))));
|
|
1552
|
+
case "read": return createElement(Box, { flexDirection: "column" }, createElement(Text, {
|
|
1553
|
+
dimColor: true,
|
|
1554
|
+
wrap: "truncate-end"
|
|
1555
|
+
}, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1].number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? " (window truncated)" : ""}`), ...detail.lines.map((line, at) => createElement(Text, {
|
|
1556
|
+
key: at,
|
|
1557
|
+
dimColor: true,
|
|
1558
|
+
wrap: "truncate-end"
|
|
1559
|
+
}, ` ${String(line.number).padStart(5, " ")} | ${displayText(line.text)}`)));
|
|
1560
|
+
case "web-search": return createElement(Box, { flexDirection: "column" }, ...detail.sources.map((source, at) => createElement(Text, {
|
|
1561
|
+
key: at,
|
|
1562
|
+
wrap: "truncate-end"
|
|
1563
|
+
}, brand(` ? ${displayText(source.title === void 0 ? source.url : source.title)}`), createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)))), createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? " (capped)" : ""}`)));
|
|
1564
|
+
case "web-fetch": return createElement(Text, {
|
|
1565
|
+
dimColor: true,
|
|
1566
|
+
wrap: "truncate-end"
|
|
1567
|
+
}, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`));
|
|
1568
|
+
case "raw": return createElement(Box, { flexDirection: "column" }, ...displayText(detail.text).split("\n").slice(0, 40).map((line, at) => createElement(Text, {
|
|
1569
|
+
key: at,
|
|
1570
|
+
dimColor: true,
|
|
1571
|
+
wrap: "truncate-end"
|
|
1572
|
+
}, ` ${line}`)), createElement(Text, { dimColor: true }, detail.truncated ? " … (output truncated)" : " (end of output)"));
|
|
1573
|
+
default: return assertNever(detail, "tool detail kind");
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
205
1576
|
/** One settled transcript row. */
|
|
206
|
-
function EntryLine({ entry }) {
|
|
1577
|
+
function EntryLine({ entry, showReasoning, verbose }) {
|
|
207
1578
|
switch (entry.kind) {
|
|
208
|
-
case "user": return createElement(Text, null, brand("❯ "), displayText(entry.text));
|
|
209
|
-
case "assistant": return createElement(
|
|
1579
|
+
case "user": return entry.notice ? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`) : createElement(Text, null, brand("❯ "), displayText(entry.text));
|
|
1580
|
+
case "assistant": return createElement(Box, { flexDirection: "column" }, entry.reasoning === "" ? void 0 : showReasoning ? createElement(Text, {
|
|
1581
|
+
dimColor: true,
|
|
1582
|
+
italic: true
|
|
1583
|
+
}, ` ✻ ${displayText(entry.reasoning)}`) : createElement(Text, { dimColor: true }, ` ✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`), createElement(MarkdownBody, { text: entry.text }));
|
|
210
1584
|
case "tool": {
|
|
211
|
-
const mark = entry.state === "running" ? createElement(
|
|
212
|
-
return createElement(Text,
|
|
1585
|
+
const mark = entry.state === "running" ? createElement(Pulse) : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
|
|
1586
|
+
return createElement(Box, { flexDirection: "column" }, createElement(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(entry.name), entry.preview === "" ? "" : ` ${dim(displayText(entry.preview))}`), entry.summary === "" ? void 0 : createElement(Text, {
|
|
1587
|
+
color: entry.state === "error" ? inkColor(TUI_RGB.error) : inkColor(TUI_RGB.dim),
|
|
1588
|
+
wrap: verbose ? "truncate-end" : void 0
|
|
1589
|
+
}, ` ⎿ ${displayText(entry.summary)}`), verbose && entry.detail !== void 0 ? createElement(ToolDetailBody, { detail: entry.detail }) : void 0);
|
|
213
1590
|
}
|
|
214
1591
|
case "command": {
|
|
215
|
-
const mark = entry.state === "running" ? createElement(
|
|
216
|
-
return createElement(Text,
|
|
1592
|
+
const mark = entry.state === "running" ? createElement(Pulse) : entry.state === "error" ? createElement(Text, { color: inkColor(TUI_RGB.error) }, "⨯") : createElement(Text, { color: inkColor(TUI_RGB.success) }, "⏺");
|
|
1593
|
+
return createElement(Box, { flexDirection: "column" }, createElement(Text, { wrap: verbose ? "truncate-end" : void 0 }, mark, " ", brand(`/${entry.name}`), entry.args === "" ? "" : ` ${dim(displayText(entry.args))}`), entry.summary === "" ? void 0 : createElement(Text, {
|
|
1594
|
+
color: inkColor(TUI_RGB.dim),
|
|
1595
|
+
wrap: verbose ? "truncate-end" : void 0
|
|
1596
|
+
}, ` ⎿ ${displayText(entry.summary)}`));
|
|
1597
|
+
}
|
|
1598
|
+
case "turn-marker": return createElement(Text, {
|
|
1599
|
+
dimColor: true,
|
|
1600
|
+
wrap: verbose ? "truncate-end" : void 0
|
|
1601
|
+
}, ` ⏹ ${displayText(entry.text)}`);
|
|
1602
|
+
case "compaction": return createElement(Text, {
|
|
1603
|
+
dimColor: true,
|
|
1604
|
+
wrap: verbose ? "truncate-end" : void 0
|
|
1605
|
+
}, entry.ok ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens` : ` ⧉ compaction failed: ${displayText(entry.error)}`);
|
|
1606
|
+
case "retry": return createElement(Text, {
|
|
1607
|
+
color: entry.state === "running" ? inkColor(TUI_RGB.warn) : inkColor(TUI_RGB.dim),
|
|
1608
|
+
wrap: verbose ? "truncate-end" : void 0
|
|
1609
|
+
}, ` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`);
|
|
1610
|
+
case "files": {
|
|
1611
|
+
const shown = entry.paths.slice(0, 3).map((path) => displayText(path)).join(" · ");
|
|
1612
|
+
const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : "";
|
|
1613
|
+
return createElement(Text, {
|
|
1614
|
+
dimColor: true,
|
|
1615
|
+
wrap: verbose ? "truncate-end" : void 0
|
|
1616
|
+
}, ` ⎄ ${shown}${more}`);
|
|
217
1617
|
}
|
|
218
|
-
case "error": return createElement(Text,
|
|
1618
|
+
case "error": return createElement(Text, { wrap: verbose ? "truncate-end" : void 0 }, error(displayText(entry.text)));
|
|
219
1619
|
default: return assertNever(entry, "transcript entry kind");
|
|
220
1620
|
}
|
|
221
1621
|
}
|
|
222
|
-
/**
|
|
1622
|
+
/**
|
|
1623
|
+
* The whale wordmark header in DeepSeek blue, hugging its content width.
|
|
1624
|
+
* The 8-row half-block glyph pairs adjacent lines, so on a terminal too
|
|
1625
|
+
* short to show it whole (or mid-resize) the clipped pairs garble the
|
|
1626
|
+
* screen — below the height floor the header collapses to a single-line
|
|
1627
|
+
* wordmark that stays correct at any size.
|
|
1628
|
+
*/
|
|
223
1629
|
function Header({ resumed }) {
|
|
1630
|
+
const rows = useStdout().stdout?.rows ?? 40;
|
|
1631
|
+
const hint = resumed ? "resumed session · /help commands · Esc interrupt" : "/help commands · Esc interrupt · Ctrl+C quit";
|
|
1632
|
+
if (rows < 20) return createElement(Box, {
|
|
1633
|
+
flexDirection: "row",
|
|
1634
|
+
gap: 1,
|
|
1635
|
+
borderStyle: "round",
|
|
1636
|
+
borderColor: inkColor(TUI_RGB.brand),
|
|
1637
|
+
paddingX: 1,
|
|
1638
|
+
alignSelf: "flex-start"
|
|
1639
|
+
}, createElement(Text, {
|
|
1640
|
+
color: inkColor(TUI_RGB.brand),
|
|
1641
|
+
bold: true
|
|
1642
|
+
}, "DeepSeek Harness"), createElement(Text, { dimColor: true }, hint));
|
|
224
1643
|
return createElement(Box, {
|
|
225
1644
|
flexDirection: "row",
|
|
226
1645
|
gap: 1,
|
|
@@ -240,32 +1659,24 @@ function Header({ resumed }) {
|
|
|
240
1659
|
}, createElement(Text, {
|
|
241
1660
|
color: inkColor(TUI_RGB.brand),
|
|
242
1661
|
bold: true
|
|
243
|
-
}, "DeepSeek Harness"), createElement(Text, { dimColor: true },
|
|
1662
|
+
}, "DeepSeek Harness"), createElement(Text, { dimColor: true }, hint)));
|
|
244
1663
|
}
|
|
245
1664
|
/** Todo status glyph: web TodoPanel's three-state marker. */
|
|
246
1665
|
function todoMark(status) {
|
|
247
1666
|
return status === "completed" ? "✓" : status === "in_progress" ? "●" : "○";
|
|
248
1667
|
}
|
|
249
|
-
/**
|
|
1668
|
+
/** One-row todo summary: task count cannot grow the live Ink tree. */
|
|
250
1669
|
function TodoPanel({ todos }) {
|
|
251
1670
|
if (todos.length === 0) return void 0;
|
|
252
1671
|
const completed = todos.filter((todo) => todo.status === "completed").length;
|
|
253
1672
|
const inProgress = todos.filter((todo) => todo.status === "in_progress").length;
|
|
254
1673
|
const pending = todos.length - completed - inProgress;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
paddingX: 1,
|
|
258
|
-
borderStyle: "round",
|
|
259
|
-
borderColor: inkColor(TUI_RGB.brandDeep),
|
|
260
|
-
alignSelf: "flex-start",
|
|
261
|
-
marginLeft: 1
|
|
262
|
-
}, createElement(Text, {
|
|
1674
|
+
const current = todos.find((todo) => todo.status === "in_progress");
|
|
1675
|
+
return createElement(Box, { paddingX: 1 }, createElement(Text, {
|
|
263
1676
|
color: inkColor(TUI_RGB.brand),
|
|
264
|
-
bold: true
|
|
265
|
-
|
|
266
|
-
|
|
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)}`)));
|
|
1677
|
+
bold: true,
|
|
1678
|
+
wrap: "truncate-end"
|
|
1679
|
+
}, `todos ${completed}/${todos.length}`, createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`), current === void 0 ? "" : createElement(Text, { color: inkColor(TUI_RGB.brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`)));
|
|
269
1680
|
}
|
|
270
1681
|
/**
|
|
271
1682
|
* The footer status line: Claude-Code-style identity facts (model, working
|
|
@@ -275,39 +1686,304 @@ function TodoPanel({ todos }) {
|
|
|
275
1686
|
*/
|
|
276
1687
|
function StatusLine({ facts, stats, busy }) {
|
|
277
1688
|
const groups = buildStatusGroups(facts, stats);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
});
|
|
283
|
-
return createElement(Box, { paddingX: 1 }, ...children);
|
|
1689
|
+
return createElement(Box, { paddingLeft: 2 }, createElement(Text, {
|
|
1690
|
+
dimColor: true,
|
|
1691
|
+
wrap: "truncate-end"
|
|
1692
|
+
}, busy ? "● " : "○ ", groups.join(" | ")));
|
|
284
1693
|
}
|
|
285
1694
|
/** The y/n approval bar rendered while an approval ask is pending. */
|
|
286
|
-
function ApprovalBar({ approval }) {
|
|
1695
|
+
function ApprovalBar({ approval, locked }) {
|
|
287
1696
|
const snapshot = useSyncExternalStore(approval.subscribe, approval.getSnapshot);
|
|
1697
|
+
const stdout = useStdout().stdout;
|
|
1698
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
1699
|
+
const [scroll, setScroll] = useState(0);
|
|
1700
|
+
const pending = snapshot.pending;
|
|
1701
|
+
const active = !locked && snapshot.pending !== void 0 && !snapshot.answered;
|
|
1702
|
+
const content = useMemo(() => pending === void 0 ? [] : [...styledLines([lineSegment(pending.headline, "warn")], viewport.contentColumns), ...pending.command === "" ? [] : textLines(` ${pending.command}`, viewport.contentColumns, "dim")], [pending, viewport.contentColumns]);
|
|
1703
|
+
const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows);
|
|
1704
|
+
useEffect(() => {
|
|
1705
|
+
setScroll(0);
|
|
1706
|
+
}, [pending]);
|
|
1707
|
+
useEffect(() => {
|
|
1708
|
+
if (visibleScroll !== scroll) setScroll(visibleScroll);
|
|
1709
|
+
}, [visibleScroll, scroll]);
|
|
1710
|
+
useInput((input, key) => {
|
|
1711
|
+
if (snapshot.pending === void 0) return;
|
|
1712
|
+
if (key.upArrow) {
|
|
1713
|
+
setScroll((current) => moveScroll(current, -1, content.length, viewport.bodyRows));
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (key.downArrow) {
|
|
1717
|
+
setScroll((current) => moveScroll(current, 1, content.length, viewport.bodyRows));
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
if (key.pageUp) {
|
|
1721
|
+
setScroll((current) => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows));
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
if (key.pageDown) {
|
|
1725
|
+
setScroll((current) => moveScroll(current, Math.max(1, viewport.bodyRows - 1), content.length, viewport.bodyRows));
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (snapshot.answered) return;
|
|
1729
|
+
if (input === "y" || input === "Y") {
|
|
1730
|
+
snapshot.pending.answer("allowed-once");
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (input === "n" || input === "N") snapshot.pending.answer("rejected");
|
|
1734
|
+
}, { isActive: active });
|
|
288
1735
|
if (snapshot.pending === void 0) return void 0;
|
|
289
|
-
|
|
1736
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
|
|
1737
|
+
if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("approval · y allow · n reject", viewport.contentColumns));
|
|
1738
|
+
const { answered } = snapshot;
|
|
1739
|
+
return createElement(Box, {
|
|
1740
|
+
flexDirection: "column",
|
|
1741
|
+
paddingX: 1,
|
|
1742
|
+
borderStyle: "round",
|
|
1743
|
+
borderColor: inkColor(TUI_RGB.warn)
|
|
1744
|
+
}, createElement(Text, {
|
|
1745
|
+
color: inkColor(TUI_RGB.warn),
|
|
1746
|
+
bold: true,
|
|
1747
|
+
wrap: "truncate-end"
|
|
1748
|
+
}, truncateColumns(`⏸ waiting for approval · lines ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), createElement(StyledRows, { lines: content.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(Text, {
|
|
1749
|
+
dimColor: true,
|
|
1750
|
+
wrap: "truncate-end"
|
|
1751
|
+
}, dim(truncateColumns(answered ? "submitted…" : "↑↓/pgup/pgdn scroll · y allow once · n reject", viewport.contentColumns))));
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* The ask_user_question bar: walks one request question by question,
|
|
1755
|
+
* renders the option menu (Claude-Code style: arrows move, space toggles a
|
|
1756
|
+
* multi-select, enter submits, `c` opens the custom-answer box, Esc
|
|
1757
|
+
* interrupts the question as aborted). Plan reviews arrive through the same
|
|
1758
|
+
* service with a `plan-review` intent — the approve option gets a ✓ mark,
|
|
1759
|
+
* the answer encoding stays identical.
|
|
1760
|
+
*/
|
|
1761
|
+
function QuestionBar({ store, locked }) {
|
|
1762
|
+
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
1763
|
+
const stdout = useStdout().stdout;
|
|
1764
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
1765
|
+
const pending = snapshot.pending;
|
|
1766
|
+
const [index, setIndex] = useState(0);
|
|
1767
|
+
const [cursor, setCursor] = useState(0);
|
|
1768
|
+
const [selected, setSelected] = useState([]);
|
|
1769
|
+
const [mode, setMode] = useState("options");
|
|
1770
|
+
const [custom, setCustom] = useState("");
|
|
1771
|
+
const [answers, setAnswers] = useState([]);
|
|
1772
|
+
const [submitted, setSubmitted] = useState(false);
|
|
1773
|
+
const [scroll, setScroll] = useState(0);
|
|
1774
|
+
useEffect(() => {
|
|
1775
|
+
const question = pending?.request.questions[0];
|
|
1776
|
+
setIndex(0);
|
|
1777
|
+
setCursor(0);
|
|
1778
|
+
setSelected([]);
|
|
1779
|
+
setMode(question?.options === void 0 || question.options.length === 0 ? "custom" : "options");
|
|
1780
|
+
setCustom("");
|
|
1781
|
+
setAnswers([]);
|
|
1782
|
+
setSubmitted(false);
|
|
1783
|
+
setScroll(0);
|
|
1784
|
+
}, [pending]);
|
|
1785
|
+
const question = pending?.request.questions[index];
|
|
1786
|
+
const options = question?.options ?? [];
|
|
1787
|
+
const isPlan = question?.intent?.kind === "plan-review";
|
|
1788
|
+
const isMulti = question?.multiSelect === true;
|
|
1789
|
+
const active = !locked && pending !== void 0 && question !== void 0 && !submitted;
|
|
1790
|
+
const rendered = useMemo(() => {
|
|
1791
|
+
if (question === void 0) return {
|
|
1792
|
+
lines: [],
|
|
1793
|
+
optionRows: []
|
|
1794
|
+
};
|
|
1795
|
+
const lines = [];
|
|
1796
|
+
const optionRows = [];
|
|
1797
|
+
if (question.header !== void 0) lines.push(...styledLines([lineSegment(question.header, "bold")], viewport.contentColumns));
|
|
1798
|
+
lines.push(...textLines(question.question, viewport.contentColumns));
|
|
1799
|
+
if (question.detail !== void 0) lines.push(...isPlan ? markdownLines(question.detail, viewport.contentColumns) : textLines(question.detail, viewport.contentColumns, "dim"));
|
|
1800
|
+
if (submitted) lines.push(...textLines(" submitted…", viewport.contentColumns, "dim"));
|
|
1801
|
+
else if (mode === "custom" || options.length === 0) lines.push(...styledLines([
|
|
1802
|
+
lineSegment(" custom: ", "brand"),
|
|
1803
|
+
lineSegment(custom, "plain"),
|
|
1804
|
+
lineSegment("▌", "brand")
|
|
1805
|
+
], viewport.contentColumns));
|
|
1806
|
+
else options.forEach((option, at) => {
|
|
1807
|
+
optionRows.push(lines.length);
|
|
1808
|
+
const chosen = isMulti && selected.includes(at);
|
|
1809
|
+
const approve = isPlan && question.intent?.approve === option.label;
|
|
1810
|
+
const mark = approve ? "✓ " : chosen ? "◉ " : at === cursor ? "❯ " : " ";
|
|
1811
|
+
const style = at === cursor ? "brand" : chosen || approve ? "success" : "plain";
|
|
1812
|
+
lines.push(...styledLines([
|
|
1813
|
+
lineSegment(mark, style),
|
|
1814
|
+
lineSegment(option.label, style),
|
|
1815
|
+
lineSegment(option.description === void 0 ? "" : ` — ${option.description}`, "dim")
|
|
1816
|
+
], viewport.contentColumns));
|
|
1817
|
+
});
|
|
1818
|
+
return {
|
|
1819
|
+
lines,
|
|
1820
|
+
optionRows
|
|
1821
|
+
};
|
|
1822
|
+
}, [
|
|
1823
|
+
question,
|
|
1824
|
+
isPlan,
|
|
1825
|
+
submitted,
|
|
1826
|
+
mode,
|
|
1827
|
+
options,
|
|
1828
|
+
custom,
|
|
1829
|
+
isMulti,
|
|
1830
|
+
selected,
|
|
1831
|
+
cursor,
|
|
1832
|
+
viewport.contentColumns
|
|
1833
|
+
]);
|
|
1834
|
+
const visibleScroll = clampScroll(scroll, rendered.lines.length, viewport.bodyRows);
|
|
1835
|
+
useEffect(() => {
|
|
1836
|
+
if (visibleScroll !== scroll) setScroll(visibleScroll);
|
|
1837
|
+
}, [visibleScroll, scroll]);
|
|
1838
|
+
useEffect(() => {
|
|
1839
|
+
if (mode === "options" && options.length > 0) {
|
|
1840
|
+
const focused = rendered.optionRows[cursor] ?? 0;
|
|
1841
|
+
setScroll((current) => revealRow(current, focused, rendered.lines.length, viewport.bodyRows));
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
setScroll(Math.max(0, rendered.lines.length - viewport.bodyRows));
|
|
1845
|
+
}, [
|
|
1846
|
+
cursor,
|
|
1847
|
+
mode,
|
|
1848
|
+
custom.length,
|
|
1849
|
+
rendered.lines.length,
|
|
1850
|
+
viewport.bodyRows
|
|
1851
|
+
]);
|
|
1852
|
+
const commit = (answer) => {
|
|
1853
|
+
if (pending === void 0) return;
|
|
1854
|
+
const next = [...answers, answer];
|
|
1855
|
+
const total = pending.request.questions.length;
|
|
1856
|
+
if (index + 1 >= total) {
|
|
1857
|
+
setSubmitted(true);
|
|
1858
|
+
store.submit(pending, { answers: next });
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
setAnswers(next);
|
|
1862
|
+
const nextIndex = index + 1;
|
|
1863
|
+
const nextQuestion = pending.request.questions[nextIndex];
|
|
1864
|
+
setIndex(nextIndex);
|
|
1865
|
+
setCursor(0);
|
|
1866
|
+
setSelected([]);
|
|
1867
|
+
setMode(nextQuestion?.options === void 0 || nextQuestion.options.length === 0 ? "custom" : "options");
|
|
1868
|
+
setCustom("");
|
|
1869
|
+
setScroll(0);
|
|
1870
|
+
};
|
|
1871
|
+
const commitOption = () => {
|
|
1872
|
+
if (pending === void 0 || question === void 0) return;
|
|
1873
|
+
if (isMulti) {
|
|
1874
|
+
const labels = selected.map((at) => options[at]?.label).filter((label) => label !== void 0);
|
|
1875
|
+
const customText = custom.trim();
|
|
1876
|
+
commit({
|
|
1877
|
+
id: question.id,
|
|
1878
|
+
selected: labels,
|
|
1879
|
+
...customText === "" ? {} : { custom: customText }
|
|
1880
|
+
});
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
const option = options[cursor];
|
|
1884
|
+
if (option === void 0) return;
|
|
1885
|
+
commit({
|
|
1886
|
+
id: question.id,
|
|
1887
|
+
selected: [option.label]
|
|
1888
|
+
});
|
|
1889
|
+
};
|
|
1890
|
+
useInput((input, key) => {
|
|
1891
|
+
if (pending === void 0 || question === void 0 || submitted) return;
|
|
1892
|
+
if (key.escape) {
|
|
1893
|
+
store.cancel(pending);
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
if (key.pageUp) {
|
|
1897
|
+
setScroll((current) => moveScroll(current, -Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows));
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (key.pageDown) {
|
|
1901
|
+
setScroll((current) => moveScroll(current, Math.max(1, viewport.bodyRows - 1), rendered.lines.length, viewport.bodyRows));
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
if (mode === "custom" || options.length === 0) {
|
|
1905
|
+
if (key.upArrow) {
|
|
1906
|
+
setScroll((current) => moveScroll(current, -1, rendered.lines.length, viewport.bodyRows));
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
if (key.downArrow) {
|
|
1910
|
+
setScroll((current) => moveScroll(current, 1, rendered.lines.length, viewport.bodyRows));
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
if (key.return) {
|
|
1914
|
+
if (custom.trim() === "" && options.length > 0) {
|
|
1915
|
+
commitOption();
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
commit({
|
|
1919
|
+
id: question.id,
|
|
1920
|
+
selected: isMulti ? selected.map((at) => options[at]?.label).filter((label) => label !== void 0) : [],
|
|
1921
|
+
...custom.trim() === "" ? {} : { custom: custom.trim() }
|
|
1922
|
+
});
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
if (key.backspace) {
|
|
1926
|
+
setCustom((current) => current.slice(0, -1));
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (input !== "" && !key.ctrl && !key.meta) setCustom((current) => current + input);
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (key.upArrow) {
|
|
1933
|
+
setCursor((current) => (current + options.length - 1) % options.length);
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
if (key.downArrow) {
|
|
1937
|
+
setCursor((current) => (current + 1) % options.length);
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
if (key.return) {
|
|
1941
|
+
commitOption();
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
if (key.tab || input === "c" || input === "C") {
|
|
1945
|
+
setMode("custom");
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
if (input === " " && isMulti) setSelected((current) => current.includes(cursor) ? current.filter((at) => at !== cursor) : [...current, cursor]);
|
|
1949
|
+
}, { isActive: active });
|
|
1950
|
+
if (pending === void 0 || question === void 0) return void 0;
|
|
1951
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
|
|
1952
|
+
if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns(isPlan ? "plan review · esc cancel" : "question · esc cancel", viewport.contentColumns));
|
|
1953
|
+
const footer = submitted ? "submitted…" : mode === "custom" || options.length === 0 ? "↑↓/pgup/pgdn scroll · type answer · enter submit · esc interrupt" : isMulti ? "↑↓ choose · pgup/pgdn scroll · space toggle · enter submit · c custom · esc interrupt" : "↑↓ choose · pgup/pgdn scroll · enter submit · c custom · esc interrupt";
|
|
290
1954
|
return createElement(Box, {
|
|
291
1955
|
flexDirection: "column",
|
|
292
1956
|
paddingX: 1,
|
|
293
1957
|
borderStyle: "round",
|
|
294
|
-
borderColor: inkColor(TUI_RGB.
|
|
295
|
-
alignSelf: "flex-start",
|
|
296
|
-
marginLeft: 1
|
|
1958
|
+
borderColor: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep)
|
|
297
1959
|
}, createElement(Text, {
|
|
298
|
-
color: inkColor(TUI_RGB.
|
|
299
|
-
bold: true
|
|
300
|
-
|
|
1960
|
+
color: inkColor(isPlan ? TUI_RGB.brand : TUI_RGB.brandDeep),
|
|
1961
|
+
bold: true,
|
|
1962
|
+
wrap: "truncate-end"
|
|
1963
|
+
}, truncateColumns(`${isPlan ? "📋 plan review" : "❓ question"} ${index + 1}/${pending.request.questions.length} · lines ${rendered.lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rendered.lines.length, visibleScroll + viewport.bodyRows)}/${rendered.lines.length}`, viewport.contentColumns)), createElement(StyledRows, { lines: rendered.lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }), createElement(Text, {
|
|
1964
|
+
dimColor: true,
|
|
1965
|
+
wrap: "truncate-end"
|
|
1966
|
+
}, dim(truncateColumns(footer, viewport.contentColumns))));
|
|
301
1967
|
}
|
|
302
1968
|
/** The /model panel: a scrolling list over the advisory model directory. */
|
|
303
1969
|
function ModelPanel({ directory, error, onSelect, onClose }) {
|
|
304
1970
|
const [cursor, setCursor] = useState(0);
|
|
1971
|
+
const stdout = useStdout().stdout;
|
|
1972
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
1973
|
+
const rows = directory?.rows ?? [];
|
|
1974
|
+
useEffect(() => {
|
|
1975
|
+
if (rows.length === 0) {
|
|
1976
|
+
if (cursor !== 0) setCursor(0);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
if (cursor >= rows.length) setCursor(rows.length - 1);
|
|
1980
|
+
}, [rows.length, cursor]);
|
|
305
1981
|
useInput((input, key) => {
|
|
306
1982
|
if (key.escape || input === "q") {
|
|
307
1983
|
onClose();
|
|
308
1984
|
return;
|
|
309
1985
|
}
|
|
310
|
-
|
|
1986
|
+
if (rows.length === 0) return;
|
|
311
1987
|
if (key.upArrow) {
|
|
312
1988
|
setCursor(cursor > 0 ? cursor - 1 : rows.length - 1);
|
|
313
1989
|
return;
|
|
@@ -316,31 +1992,301 @@ function ModelPanel({ directory, error, onSelect, onClose }) {
|
|
|
316
1992
|
setCursor(cursor < rows.length - 1 ? cursor + 1 : 0);
|
|
317
1993
|
return;
|
|
318
1994
|
}
|
|
1995
|
+
if (key.pageUp) {
|
|
1996
|
+
setCursor((current) => Math.max(0, current - Math.max(1, viewport.bodyRows - 1)));
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
if (key.pageDown) {
|
|
2000
|
+
setCursor((current) => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)));
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
if (input === "g") {
|
|
2004
|
+
setCursor(0);
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
if (input === "G") {
|
|
2008
|
+
setCursor(rows.length - 1);
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
319
2011
|
if (key.return && rows[cursor] !== void 0) onSelect(rows[cursor]);
|
|
320
2012
|
});
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const first =
|
|
324
|
-
const visible = rows.slice(
|
|
2013
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
|
|
2014
|
+
if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("/model · esc/q close", viewport.contentColumns));
|
|
2015
|
+
const first = selectionWindow(cursor, rows.length, viewport.bodyRows);
|
|
2016
|
+
const visible = rows.slice(first, first + viewport.bodyRows);
|
|
325
2017
|
return createElement(Box, {
|
|
326
2018
|
flexDirection: "column",
|
|
327
2019
|
paddingX: 1,
|
|
328
2020
|
borderStyle: "round",
|
|
329
|
-
borderColor: inkColor(TUI_RGB.brand)
|
|
330
|
-
alignSelf: "flex-start",
|
|
331
|
-
marginLeft: 1
|
|
2021
|
+
borderColor: inkColor(TUI_RGB.brand)
|
|
332
2022
|
}, createElement(Text, {
|
|
333
2023
|
color: inkColor(TUI_RGB.brand),
|
|
334
|
-
bold: true
|
|
335
|
-
|
|
2024
|
+
bold: true,
|
|
2025
|
+
wrap: "truncate-end"
|
|
2026
|
+
}, truncateColumns(`/model — select model${rows.length === 0 ? "" : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)), directory === void 0 && error === void 0 ? createElement(Text, {
|
|
2027
|
+
dimColor: true,
|
|
2028
|
+
wrap: "truncate-end"
|
|
2029
|
+
}, " loading models…") : void 0, error !== void 0 ? createElement(Text, {
|
|
2030
|
+
color: inkColor(TUI_RGB.error),
|
|
2031
|
+
wrap: "truncate-end"
|
|
2032
|
+
}, truncateColumns(` ${displayText(error)}`, viewport.contentColumns)) : void 0, ...visible.map((row) => {
|
|
336
2033
|
const index = rows.indexOf(row);
|
|
337
2034
|
const label = displayText(`${row.providerName} · ${row.modelName}`);
|
|
338
2035
|
return createElement(Text, {
|
|
339
2036
|
key: `${row.provider}/${row.model}`,
|
|
340
|
-
color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim)
|
|
341
|
-
|
|
342
|
-
|
|
2037
|
+
color: index === cursor ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
2038
|
+
wrap: "truncate-end"
|
|
2039
|
+
}, truncateColumns(`${index === cursor ? "❯ " : " "}${label}`, viewport.contentColumns));
|
|
2040
|
+
}), createElement(Text, {
|
|
2041
|
+
dimColor: true,
|
|
2042
|
+
wrap: "truncate-end"
|
|
2043
|
+
}, dim(truncateColumns("↑↓ move · pgup/pgdn page · g/G ends · enter select · esc/q close", viewport.contentColumns))));
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* The /help overlay: one scrolling card with the keyboard map, the TUI-local
|
|
2047
|
+
* commands, the live registry commands, and the user-invocable skills — the
|
|
2048
|
+
* real command surface, replacing the one-line notice.
|
|
2049
|
+
*/
|
|
2050
|
+
function HelpPanel({ descriptors, skills, onClose }) {
|
|
2051
|
+
const stdout = useStdout().stdout;
|
|
2052
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
2053
|
+
const [scroll, setScroll] = useState(0);
|
|
2054
|
+
const nameWidth = 18;
|
|
2055
|
+
const descBudget = Math.max(1, viewport.contentColumns - nameWidth - 2);
|
|
2056
|
+
const row = (label, description) => createElement(Text, {
|
|
2057
|
+
dimColor: true,
|
|
2058
|
+
wrap: "truncate-end"
|
|
2059
|
+
}, ` ${padColumns(label, nameWidth)}${dim(truncateColumns(displayText(description), descBudget))}`);
|
|
2060
|
+
const content = [
|
|
2061
|
+
createElement(Text, {
|
|
2062
|
+
key: "keys-title",
|
|
2063
|
+
bold: true,
|
|
2064
|
+
wrap: "truncate-end"
|
|
2065
|
+
}, " keys"),
|
|
2066
|
+
createElement(Text, {
|
|
2067
|
+
key: "key-submit",
|
|
2068
|
+
dimColor: true,
|
|
2069
|
+
wrap: "truncate-end"
|
|
2070
|
+
}, " enter submit · alt+enter / ctrl+j newline · up/down history · tab complete"),
|
|
2071
|
+
createElement(Text, {
|
|
2072
|
+
key: "key-mentions",
|
|
2073
|
+
dimColor: true,
|
|
2074
|
+
wrap: "truncate-end"
|
|
2075
|
+
}, " tab also completes bare workspace paths · @ mentions files and sessions"),
|
|
2076
|
+
createElement(Text, {
|
|
2077
|
+
key: "key-inspector",
|
|
2078
|
+
dimColor: true,
|
|
2079
|
+
wrap: "truncate-end"
|
|
2080
|
+
}, " ctrl+o history details · ctrl+r thinking · shift+tab permission preset"),
|
|
2081
|
+
createElement(Text, {
|
|
2082
|
+
key: "key-cancel",
|
|
2083
|
+
dimColor: true,
|
|
2084
|
+
wrap: "truncate-end"
|
|
2085
|
+
}, " esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit"),
|
|
2086
|
+
createElement(Text, {
|
|
2087
|
+
key: "key-edit",
|
|
2088
|
+
dimColor: true,
|
|
2089
|
+
wrap: "truncate-end"
|
|
2090
|
+
}, " ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends"),
|
|
2091
|
+
createElement(Text, {
|
|
2092
|
+
key: "commands-title",
|
|
2093
|
+
bold: true,
|
|
2094
|
+
wrap: "truncate-end"
|
|
2095
|
+
}, " commands"),
|
|
2096
|
+
createElement(Box, { key: "local-help" }, row("/help", "show this overlay")),
|
|
2097
|
+
createElement(Box, { key: "local-model" }, row("/model", "switch the model")),
|
|
2098
|
+
createElement(Box, { key: "local-clear" }, row("/clear", "clear the screen")),
|
|
2099
|
+
createElement(Box, { key: "local-export" }, row("/export", "export the transcript to markdown (/export [path])")),
|
|
2100
|
+
createElement(Box, { key: "local-title" }, row("/title", "rename this session (/title <text>)")),
|
|
2101
|
+
createElement(Box, { key: "local-quit" }, row("/quit", "exit")),
|
|
2102
|
+
...descriptors.map((descriptor) => createElement(Text, {
|
|
2103
|
+
key: `command-${descriptor.name}`,
|
|
2104
|
+
dimColor: true,
|
|
2105
|
+
wrap: "truncate-end"
|
|
2106
|
+
}, ` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`)),
|
|
2107
|
+
...skills.length === 0 ? [] : [createElement(Text, {
|
|
2108
|
+
key: "skills-title",
|
|
2109
|
+
bold: true,
|
|
2110
|
+
wrap: "truncate-end"
|
|
2111
|
+
}, " skills")],
|
|
2112
|
+
...skills.map((skill) => createElement(Text, {
|
|
2113
|
+
key: `skill-${skill.name}`,
|
|
2114
|
+
dimColor: true,
|
|
2115
|
+
wrap: "truncate-end"
|
|
2116
|
+
}, ` ${padColumns(`/${skill.name}`, nameWidth)}${dim(truncateColumns(displayText(skill.description), descBudget))}`))
|
|
2117
|
+
];
|
|
2118
|
+
const visibleScroll = clampScroll(scroll, content.length, viewport.bodyRows);
|
|
2119
|
+
const scrollBy = (delta) => {
|
|
2120
|
+
setScroll((current) => moveScroll(current, delta, content.length, viewport.bodyRows));
|
|
2121
|
+
};
|
|
2122
|
+
useEffect(() => {
|
|
2123
|
+
if (visibleScroll !== scroll) setScroll(visibleScroll);
|
|
2124
|
+
}, [visibleScroll, scroll]);
|
|
2125
|
+
useInput((input, key) => {
|
|
2126
|
+
if (key.escape || input === "q") {
|
|
2127
|
+
onClose();
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
if (key.upArrow) scrollBy(-1);
|
|
2131
|
+
else if (key.downArrow) scrollBy(1);
|
|
2132
|
+
else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1));
|
|
2133
|
+
else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1));
|
|
2134
|
+
else if (input === "g") setScroll(0);
|
|
2135
|
+
else if (input === "G") setScroll(Math.max(0, content.length - viewport.bodyRows));
|
|
2136
|
+
});
|
|
2137
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
|
|
2138
|
+
if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("/help · esc/q close", viewport.contentColumns));
|
|
2139
|
+
return createElement(Box, {
|
|
2140
|
+
flexDirection: "column",
|
|
2141
|
+
paddingX: 1,
|
|
2142
|
+
borderStyle: "round",
|
|
2143
|
+
borderColor: inkColor(TUI_RGB.brand)
|
|
2144
|
+
}, createElement(Text, {
|
|
2145
|
+
color: inkColor(TUI_RGB.brand),
|
|
2146
|
+
bold: true,
|
|
2147
|
+
wrap: "truncate-end"
|
|
2148
|
+
}, truncateColumns(`/help — keys and commands · rows ${content.length === 0 ? 0 : visibleScroll + 1}-${Math.min(content.length, visibleScroll + viewport.bodyRows)}/${content.length}`, viewport.contentColumns)), ...content.slice(visibleScroll, visibleScroll + viewport.bodyRows), createElement(Text, {
|
|
2149
|
+
dimColor: true,
|
|
2150
|
+
wrap: "truncate-end"
|
|
2151
|
+
}, dim(truncateColumns("↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close", viewport.contentColumns))));
|
|
2152
|
+
}
|
|
2153
|
+
/** Collapse arbitrary metadata to one terminal row before verbose rendering. */
|
|
2154
|
+
function verboseLine(text, columns) {
|
|
2155
|
+
return truncateColumns(displayText(text).replace(/\n/gu, " ↵ ").replace(/\t/gu, " "), Math.max(1, columns));
|
|
2156
|
+
}
|
|
2157
|
+
/** One-row editor window keeping the logical cursor visible in long drafts. */
|
|
2158
|
+
function editorWindow(value, cursor, columns) {
|
|
2159
|
+
const width = Math.max(1, columns);
|
|
2160
|
+
const normalize = (text) => displayText(text).replace(/\n/gu, "↵").replace(/\t/gu, " ");
|
|
2161
|
+
const caretSource = value.slice(cursor, cursor + 1);
|
|
2162
|
+
const caret = caretSource === "" ? " " : normalize(caretSource);
|
|
2163
|
+
const remaining = Math.max(0, width - visibleColumns(caret));
|
|
2164
|
+
const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(value.slice(cursor + 1))));
|
|
2165
|
+
const beforeBudget = Math.max(0, remaining - afterBudget);
|
|
2166
|
+
return {
|
|
2167
|
+
before: beforeBudget === 0 ? "" : displayTail(normalize(value.slice(0, cursor)), beforeBudget, 1).text,
|
|
2168
|
+
caret,
|
|
2169
|
+
after: afterBudget === 0 ? "" : truncateColumns(normalize(value.slice(cursor + 1)), afterBudget)
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* The Ctrl+O transcript inspector: one selected durable entry at a time,
|
|
2174
|
+
* with independent history selection and content scrolling. The complete
|
|
2175
|
+
* retained entry is converted to physical rows, but only one viewport slice
|
|
2176
|
+
* reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
|
|
2177
|
+
*/
|
|
2178
|
+
function VerbosePanel({ entries, onClose }) {
|
|
2179
|
+
const stdout = useStdout().stdout;
|
|
2180
|
+
const viewport = inspectorViewport(stdout?.columns ?? 80, stdout?.rows ?? 30);
|
|
2181
|
+
const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1));
|
|
2182
|
+
const [scroll, setScroll] = useState(0);
|
|
2183
|
+
const savedScroll = useRef(/* @__PURE__ */ new Map());
|
|
2184
|
+
const cursorRef = useRef(cursor);
|
|
2185
|
+
const previousLength = useRef(entries.length);
|
|
2186
|
+
const entry = entries[cursor];
|
|
2187
|
+
const allLines = useMemo(() => entry === void 0 ? [] : transcriptEntryLines(entry, viewport.contentColumns), [entry, viewport.contentColumns]);
|
|
2188
|
+
const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows);
|
|
2189
|
+
useEffect(() => {
|
|
2190
|
+
cursorRef.current = cursor;
|
|
2191
|
+
}, [cursor]);
|
|
2192
|
+
useEffect(() => {
|
|
2193
|
+
const current = cursorRef.current;
|
|
2194
|
+
const next = followInspectorCursor(current, previousLength.current, entries.length);
|
|
2195
|
+
if (next !== current) {
|
|
2196
|
+
savedScroll.current.set(current, visibleScroll);
|
|
2197
|
+
setCursor(next);
|
|
2198
|
+
setScroll(savedScroll.current.get(next) ?? 0);
|
|
2199
|
+
}
|
|
2200
|
+
previousLength.current = entries.length;
|
|
2201
|
+
}, [entries.length]);
|
|
2202
|
+
useEffect(() => {
|
|
2203
|
+
const clamped = clampScroll(scroll, allLines.length, viewport.bodyRows);
|
|
2204
|
+
if (clamped !== scroll) setScroll(clamped);
|
|
2205
|
+
savedScroll.current.set(cursor, clamped);
|
|
2206
|
+
}, [
|
|
2207
|
+
cursor,
|
|
2208
|
+
scroll,
|
|
2209
|
+
allLines.length,
|
|
2210
|
+
viewport.bodyRows
|
|
2211
|
+
]);
|
|
2212
|
+
const selectEntry = (next) => {
|
|
2213
|
+
if (entries.length === 0) return;
|
|
2214
|
+
const selected = Math.max(0, Math.min(entries.length - 1, next));
|
|
2215
|
+
if (selected === cursor) return;
|
|
2216
|
+
savedScroll.current.set(cursor, visibleScroll);
|
|
2217
|
+
setCursor(selected);
|
|
2218
|
+
setScroll(savedScroll.current.get(selected) ?? 0);
|
|
2219
|
+
};
|
|
2220
|
+
const scrollBy = (delta) => {
|
|
2221
|
+
setScroll((current) => moveScroll(current, delta, allLines.length, viewport.bodyRows));
|
|
2222
|
+
};
|
|
2223
|
+
useInput((input, key) => {
|
|
2224
|
+
if (key.escape || input === "q" || key.ctrl && input === "o") {
|
|
2225
|
+
onClose();
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
if (entries.length === 0) return;
|
|
2229
|
+
if (key.leftArrow) {
|
|
2230
|
+
selectEntry(cursor - 1);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (key.rightArrow) {
|
|
2234
|
+
selectEntry(cursor + 1);
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (key.upArrow) {
|
|
2238
|
+
scrollBy(-1);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (key.downArrow) {
|
|
2242
|
+
scrollBy(1);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (key.pageUp) {
|
|
2246
|
+
scrollBy(-Math.max(1, viewport.bodyRows - 1));
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
if (key.pageDown) {
|
|
2250
|
+
scrollBy(Math.max(1, viewport.bodyRows - 1));
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
if (input === "g") {
|
|
2254
|
+
setScroll(0);
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (input === "G") setScroll(Math.max(0, allLines.length - viewport.bodyRows));
|
|
2258
|
+
});
|
|
2259
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: "none" });
|
|
2260
|
+
if (viewport.compact) return createElement(Text, { wrap: "truncate-end" }, truncateColumns("history details · ctrl+o / esc / q close", viewport.contentColumns));
|
|
2261
|
+
const title = entries.length === 0 ? "history details · empty" : `history details · entry ${cursor + 1}/${entries.length} · lines ${allLines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(allLines.length, visibleScroll + viewport.bodyRows)}/${allLines.length}`;
|
|
2262
|
+
const visible = allLines.slice(visibleScroll, visibleScroll + viewport.bodyRows);
|
|
2263
|
+
return createElement(Box, {
|
|
2264
|
+
flexDirection: "column",
|
|
2265
|
+
paddingX: 1,
|
|
2266
|
+
borderStyle: "round",
|
|
2267
|
+
borderColor: inkColor(TUI_RGB.brand)
|
|
2268
|
+
}, createElement(Text, {
|
|
2269
|
+
color: inkColor(TUI_RGB.brand),
|
|
2270
|
+
bold: true,
|
|
2271
|
+
wrap: "truncate-end"
|
|
2272
|
+
}, truncateColumns(title, viewport.contentColumns)), createElement(Box, { flexDirection: "column" }, entry === void 0 ? createElement(Text, { dimColor: true }, " no durable entries yet") : createElement(StyledRows, { lines: visible })), createElement(Text, {
|
|
2273
|
+
dimColor: true,
|
|
2274
|
+
wrap: "truncate-end"
|
|
2275
|
+
}, dim(truncateColumns("←→ entry · ↑↓ scroll · pgup/pgdn page · g/G ends · ctrl+o/esc/q close", viewport.contentColumns))));
|
|
343
2276
|
}
|
|
2277
|
+
/** Streaming chunks preserve `entries` identity, so the open inspector stays inert. */
|
|
2278
|
+
const MemoVerbosePanel = memo(VerbosePanel);
|
|
2279
|
+
/** Stable append-only boundary: modal updates must never revisit Static rows. */
|
|
2280
|
+
function staticRow(item) {
|
|
2281
|
+
return item;
|
|
2282
|
+
}
|
|
2283
|
+
function StaticTranscript({ items }) {
|
|
2284
|
+
return createElement(Static, {
|
|
2285
|
+
items,
|
|
2286
|
+
children: staticRow
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
const MemoStaticTranscript = memo(StaticTranscript);
|
|
344
2290
|
/**
|
|
345
2291
|
* Resolve completion candidates for the current input: TUI-local commands,
|
|
346
2292
|
* the live registry descriptors, and user-invocable skills, filtered by the
|
|
@@ -366,13 +2312,24 @@ function completionCandidates(value, descriptors, skills) {
|
|
|
366
2312
|
description: "clear the screen",
|
|
367
2313
|
origin: "command"
|
|
368
2314
|
},
|
|
2315
|
+
{
|
|
2316
|
+
label: "/export",
|
|
2317
|
+
description: "export the transcript to markdown",
|
|
2318
|
+
origin: "command"
|
|
2319
|
+
},
|
|
2320
|
+
{
|
|
2321
|
+
label: "/title",
|
|
2322
|
+
description: "rename this session",
|
|
2323
|
+
origin: "command"
|
|
2324
|
+
},
|
|
369
2325
|
{
|
|
370
2326
|
label: "/quit",
|
|
371
2327
|
description: "exit",
|
|
372
2328
|
origin: "command"
|
|
373
2329
|
}
|
|
374
2330
|
];
|
|
375
|
-
const
|
|
2331
|
+
const localNames = new Set(local.map((candidate) => candidate.label.slice(1)));
|
|
2332
|
+
const registry = descriptors.filter((descriptor) => !localNames.has(descriptor.name)).map((descriptor) => ({
|
|
376
2333
|
label: `/${descriptor.name}`,
|
|
377
2334
|
description: descriptor.description,
|
|
378
2335
|
origin: "command"
|
|
@@ -392,10 +2349,52 @@ function completionCandidates(value, descriptors, skills) {
|
|
|
392
2349
|
return all.filter((candidate) => candidate.label.slice(1).startsWith(prefix)).slice(0, 10);
|
|
393
2350
|
}
|
|
394
2351
|
/**
|
|
2352
|
+
* The completion menu, rendered inside the composer's subtree directly above
|
|
2353
|
+
* the framed box — attached the way Claude-Code anchors its dropdown. Opening
|
|
2354
|
+
* it grows the stack downward: the composer stays the last element on screen
|
|
2355
|
+
* and everything above (the flushed static transcript, the status line) never
|
|
2356
|
+
* moves. Props-only (no lifted state): the menu is a pure view of the input
|
|
2357
|
+
* editor's live completion state, so no cross-component effect ever resyncs
|
|
2358
|
+
* it (a state lift here previously deadlocked the menu after a resize).
|
|
2359
|
+
*/
|
|
2360
|
+
function CompletionMenu({ active, mention, index, rows }) {
|
|
2361
|
+
const stdout = useStdout().stdout;
|
|
2362
|
+
const columns = stdout?.columns ?? 80;
|
|
2363
|
+
const terminalRows = stdout?.rows ?? 30;
|
|
2364
|
+
if (!active) return void 0;
|
|
2365
|
+
const nameWidth = Math.min(18, Math.max(0, ...rows.map((row) => visibleColumns(row.label))) + 2);
|
|
2366
|
+
const descBudget = Math.max(24, columns - nameWidth - 8);
|
|
2367
|
+
const showFooter = terminalRows >= 12;
|
|
2368
|
+
const limit = Math.max(1, Math.min(6, terminalRows - (showFooter ? 11 : 10)));
|
|
2369
|
+
const selected = rows.length === 0 ? 0 : index % rows.length;
|
|
2370
|
+
const first = selectionWindow(selected, rows.length, limit);
|
|
2371
|
+
const visible = rows.slice(first, first + limit);
|
|
2372
|
+
return createElement(Box, {
|
|
2373
|
+
flexDirection: "column",
|
|
2374
|
+
marginLeft: 2
|
|
2375
|
+
}, ...rows.length === 0 ? [createElement(Text, {
|
|
2376
|
+
key: "loading",
|
|
2377
|
+
dimColor: true
|
|
2378
|
+
}, "searching…")] : visible.map((candidate, at) => {
|
|
2379
|
+
const absolute = first + at;
|
|
2380
|
+
return createElement(Text, {
|
|
2381
|
+
key: candidate.label,
|
|
2382
|
+
color: absolute === selected ? inkColor(TUI_RGB.brandBright) : inkColor(TUI_RGB.dim),
|
|
2383
|
+
wrap: "truncate-end"
|
|
2384
|
+
}, `${absolute === selected ? "❯ " : " "}${padColumns(candidate.label, nameWidth)}${dim(truncateColumns(displayText(candidate.description), descBudget))}`);
|
|
2385
|
+
}), showFooter ? createElement(Text, {
|
|
2386
|
+
dimColor: true,
|
|
2387
|
+
wrap: "truncate-end"
|
|
2388
|
+
}, dim(mention ? "↑↓ choose · tab insert" : "↑↓ choose · tab complete")) : void 0);
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
395
2391
|
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
396
2392
|
* dispatched; input editing keeps a cursor with history and completion.
|
|
2393
|
+
* While a modal (approval / question / model panel) owns the keys, the
|
|
2394
|
+
* box passes every key through untouched.
|
|
397
2395
|
*/
|
|
398
|
-
function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, notify }) {
|
|
2396
|
+
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openHelp, notify, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle }) {
|
|
2397
|
+
const columns = useStdout().stdout?.columns ?? 80;
|
|
399
2398
|
const [value, setValue] = useState("");
|
|
400
2399
|
const [cursor, setCursor] = useState(0);
|
|
401
2400
|
const history = useRef([]);
|
|
@@ -403,8 +2402,78 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
403
2402
|
const draft = useRef("");
|
|
404
2403
|
const [completionIndex, setCompletionIndex] = useState(0);
|
|
405
2404
|
const candidates = completionCandidates(value, descriptors, skills);
|
|
406
|
-
const
|
|
2405
|
+
const slashActive = candidates.length > 0 && value.startsWith("/") && !value.includes(" ") && !value.includes("\n");
|
|
2406
|
+
const beforeCursor = value.slice(0, cursor);
|
|
2407
|
+
const lastLine = beforeCursor.split("\n").at(-1) ?? "";
|
|
2408
|
+
const tokenMatch = /(^|\s)@([^\s]*)$/u.exec(lastLine);
|
|
2409
|
+
const mentionToken = tokenMatch === null ? void 0 : {
|
|
2410
|
+
start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0),
|
|
2411
|
+
query: tokenMatch[2] ?? ""
|
|
2412
|
+
};
|
|
2413
|
+
const mentionActive = mentionToken !== void 0;
|
|
2414
|
+
const [mentionRows, setMentionRows] = useState([]);
|
|
2415
|
+
const bareTokenMatch = /([^\s]+)$/u.exec(lastLine);
|
|
2416
|
+
const bareToken = bareTokenMatch === null ? "" : bareTokenMatch[1] ?? "";
|
|
2417
|
+
const pathActive = !mentionActive && !bareToken.startsWith("/") && (bareToken.includes("/") || bareToken === "." || bareToken === "..");
|
|
2418
|
+
const pathTokenStart = beforeCursor.length - bareToken.length;
|
|
2419
|
+
const [pathRows, setPathRows] = useState([]);
|
|
2420
|
+
useEffect(() => {
|
|
2421
|
+
if (!active || !pathActive) {
|
|
2422
|
+
setPathRows([]);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const controller = new AbortController();
|
|
2426
|
+
setPathRows([]);
|
|
2427
|
+
loadMentions(bareToken, controller.signal).then((rows) => setPathRows(rows.filter((row) => row.kind !== "session")), () => {});
|
|
2428
|
+
return () => {
|
|
2429
|
+
controller.abort();
|
|
2430
|
+
};
|
|
2431
|
+
}, [
|
|
2432
|
+
active,
|
|
2433
|
+
pathActive,
|
|
2434
|
+
bareToken
|
|
2435
|
+
]);
|
|
2436
|
+
useEffect(() => {
|
|
2437
|
+
if (!active || !mentionActive) {
|
|
2438
|
+
setMentionRows([]);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const controller = new AbortController();
|
|
2442
|
+
setMentionRows([]);
|
|
2443
|
+
loadMentions(mentionToken.query, controller.signal).then((rows) => setMentionRows(rows), () => {});
|
|
2444
|
+
return () => {
|
|
2445
|
+
controller.abort();
|
|
2446
|
+
};
|
|
2447
|
+
}, [
|
|
2448
|
+
active,
|
|
2449
|
+
mentionActive,
|
|
2450
|
+
mentionToken?.query
|
|
2451
|
+
]);
|
|
2452
|
+
const menuActive = (slashActive || mentionActive || pathActive) && !busy;
|
|
2453
|
+
const menuRows = mentionActive ? mentionRows.map((row) => ({
|
|
2454
|
+
label: row.label.startsWith("@") ? row.label : `@${row.label}${row.kind === "directory" ? "/" : ""}`,
|
|
2455
|
+
description: row.description,
|
|
2456
|
+
origin: "mention"
|
|
2457
|
+
})) : pathActive ? pathRows.map((row) => ({
|
|
2458
|
+
label: row.label,
|
|
2459
|
+
description: row.description,
|
|
2460
|
+
origin: "path"
|
|
2461
|
+
})) : candidates;
|
|
407
2462
|
useInput((input, key) => {
|
|
2463
|
+
if (!active) return;
|
|
2464
|
+
if (key.tab && key.shift) {
|
|
2465
|
+
const next = cyclePermission();
|
|
2466
|
+
if (next !== "") notify(`permission → ${next}`);
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
if (key.ctrl && input === "r") {
|
|
2470
|
+
toggleReasoning();
|
|
2471
|
+
return;
|
|
2472
|
+
}
|
|
2473
|
+
if (key.ctrl && input === "o") {
|
|
2474
|
+
openVerbose();
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
408
2477
|
if (key.ctrl && input === "c") {
|
|
409
2478
|
if (busy) interrupt();
|
|
410
2479
|
else if (value !== "") {
|
|
@@ -441,11 +2510,20 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
441
2510
|
return;
|
|
442
2511
|
}
|
|
443
2512
|
if (text === "/help") {
|
|
444
|
-
|
|
2513
|
+
openHelp();
|
|
445
2514
|
return;
|
|
446
2515
|
}
|
|
447
2516
|
if (text === "/clear") {
|
|
448
|
-
|
|
2517
|
+
refresh();
|
|
2518
|
+
clearView();
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
if (text === "/export" || text.startsWith("/export ")) {
|
|
2522
|
+
exportTranscript(text.slice(8));
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
if (text === "/title" || text.startsWith("/title ")) {
|
|
2526
|
+
notify(renameTitle(text.slice(7)));
|
|
449
2527
|
return;
|
|
450
2528
|
}
|
|
451
2529
|
if (text === "/model" || text.startsWith("/model ")) {
|
|
@@ -459,12 +2537,12 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
459
2537
|
dispatch(text);
|
|
460
2538
|
return;
|
|
461
2539
|
}
|
|
462
|
-
if (
|
|
463
|
-
setCompletionIndex((index) => (index +
|
|
2540
|
+
if (menuActive && key.upArrow) {
|
|
2541
|
+
setCompletionIndex((index) => (index + menuRows.length - 1) % menuRows.length);
|
|
464
2542
|
return;
|
|
465
2543
|
}
|
|
466
|
-
if (
|
|
467
|
-
setCompletionIndex((index) => (index + 1) %
|
|
2544
|
+
if (menuActive && key.downArrow) {
|
|
2545
|
+
setCompletionIndex((index) => (index + 1) % menuRows.length);
|
|
468
2546
|
return;
|
|
469
2547
|
}
|
|
470
2548
|
if (key.upArrow) {
|
|
@@ -492,13 +2570,29 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
492
2570
|
setCursor((entries[next] ?? "").length);
|
|
493
2571
|
return;
|
|
494
2572
|
}
|
|
495
|
-
if (key.tab &&
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
2573
|
+
if (key.tab && menuActive) {
|
|
2574
|
+
if (mentionActive && mentionToken !== void 0) {
|
|
2575
|
+
const row = mentionRows[completionIndex % mentionRows.length];
|
|
2576
|
+
if (row !== void 0) {
|
|
2577
|
+
const insertion = row.label.startsWith("@") ? row.label : `@${row.label}${row.kind === "directory" ? "/" : ""}`;
|
|
2578
|
+
setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor));
|
|
2579
|
+
setCursor(mentionToken.start + insertion.length);
|
|
2580
|
+
}
|
|
2581
|
+
} else if (pathActive) {
|
|
2582
|
+
const row = pathRows[completionIndex % Math.max(1, pathRows.length)];
|
|
2583
|
+
if (row !== void 0) {
|
|
2584
|
+
const insertion = row.kind === "directory" ? `${row.label}/` : row.label;
|
|
2585
|
+
setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor));
|
|
2586
|
+
setCursor(pathTokenStart + insertion.length);
|
|
2587
|
+
}
|
|
2588
|
+
} else {
|
|
2589
|
+
const candidate = candidates[completionIndex % candidates.length];
|
|
2590
|
+
if (candidate !== void 0) {
|
|
2591
|
+
setValue(`${candidate.label} `);
|
|
2592
|
+
setCursor(candidate.label.length + 1);
|
|
2593
|
+
}
|
|
501
2594
|
}
|
|
2595
|
+
setCompletionIndex(0);
|
|
502
2596
|
return;
|
|
503
2597
|
}
|
|
504
2598
|
if (key.backspace || key.delete) {
|
|
@@ -522,6 +2616,14 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
522
2616
|
setCursor(0);
|
|
523
2617
|
return;
|
|
524
2618
|
}
|
|
2619
|
+
if (key.ctrl && input === "k") {
|
|
2620
|
+
setValue(value.slice(0, cursor));
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
if (key.ctrl && input === "l") {
|
|
2624
|
+
refresh();
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
525
2627
|
if (key.ctrl && input === "a") {
|
|
526
2628
|
setCursor(0);
|
|
527
2629
|
return;
|
|
@@ -536,13 +2638,25 @@ function Input({ busy, descriptors, skills, dispatch, steer, interrupt, quit, op
|
|
|
536
2638
|
setCompletionIndex(0);
|
|
537
2639
|
}
|
|
538
2640
|
});
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
2641
|
+
if (frozen) {
|
|
2642
|
+
const frozen = value === "" ? "type a message" : verboseLine(value, Math.max(1, columns - 6));
|
|
2643
|
+
return createElement(Box, {
|
|
2644
|
+
borderStyle: "round",
|
|
2645
|
+
borderColor: inkColor(TUI_RGB.dim),
|
|
2646
|
+
paddingX: 1
|
|
2647
|
+
}, createElement(Text, { wrap: "truncate-end" }, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), frozen));
|
|
2648
|
+
}
|
|
2649
|
+
const editor = editorWindow(value, cursor, Math.max(1, columns - 6));
|
|
2650
|
+
return createElement(Box, { flexDirection: "column" }, createElement(CompletionMenu, {
|
|
2651
|
+
active: menuActive,
|
|
2652
|
+
mention: mentionActive,
|
|
2653
|
+
index: completionIndex,
|
|
2654
|
+
rows: menuRows
|
|
2655
|
+
}), createElement(Box, {
|
|
2656
|
+
borderStyle: "round",
|
|
2657
|
+
borderColor: inkColor(TUI_RGB.dim),
|
|
2658
|
+
paddingX: 1
|
|
2659
|
+
}, createElement(Text, { wrap: "truncate-end" }, createElement(Text, { color: inkColor(TUI_RGB.brand) }, busy ? "… " : "❯ "), value === "" ? void 0 : editor.before, createElement(CursorBlock, { char: editor.caret }), value === "" && !busy ? createElement(Text, { dimColor: true }, "type a message · / commands · @ mentions") : editor.after)));
|
|
546
2660
|
}
|
|
547
2661
|
/** The whole terminal app; state arrives via the store, output via Ink. */
|
|
548
2662
|
function App(props) {
|
|
@@ -574,13 +2688,124 @@ function App(props) {
|
|
|
574
2688
|
};
|
|
575
2689
|
}, [modelOpen]);
|
|
576
2690
|
const busy = view.busy;
|
|
577
|
-
|
|
2691
|
+
const [showReasoning, setShowReasoning] = useState(false);
|
|
2692
|
+
const [verboseOpen, setVerboseOpen] = useState(false);
|
|
2693
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
2694
|
+
const [refreshEpoch, setRefreshEpoch] = useState(0);
|
|
2695
|
+
const approvalSnapshot = useSyncExternalStore(props.approval.subscribe, props.approval.getSnapshot);
|
|
2696
|
+
const questionSnapshot = useSyncExternalStore(props.questions.subscribe, props.questions.getSnapshot);
|
|
2697
|
+
const approvalPending = approvalSnapshot.pending !== void 0;
|
|
2698
|
+
const questionPending = questionSnapshot.pending !== void 0;
|
|
2699
|
+
const inputActive = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending;
|
|
2700
|
+
useEffect(() => {
|
|
2701
|
+
if (!approvalPending && !questionPending) return;
|
|
2702
|
+
setModelOpen(false);
|
|
2703
|
+
setHelpOpen(false);
|
|
2704
|
+
setVerboseOpen(false);
|
|
2705
|
+
}, [approvalPending, questionPending]);
|
|
2706
|
+
const settled = useMemo(() => settledEntryCount(view.entries), [view.entries]);
|
|
2707
|
+
const settledRows = useMemo(() => {
|
|
2708
|
+
const rows = [createElement(Header, {
|
|
2709
|
+
key: "header",
|
|
2710
|
+
resumed: props.resumed
|
|
2711
|
+
})];
|
|
2712
|
+
view.entries.slice(0, settled).forEach((entry, index) => {
|
|
2713
|
+
const row = createElement(EntryLine, {
|
|
2714
|
+
entry,
|
|
2715
|
+
showReasoning,
|
|
2716
|
+
verbose: false
|
|
2717
|
+
});
|
|
2718
|
+
if (entry.kind === "user" && index > 0) rows.push(createElement(Box, {
|
|
2719
|
+
key: `gap-${index}`,
|
|
2720
|
+
paddingX: 1
|
|
2721
|
+
}, createElement(Text, null, " ")));
|
|
2722
|
+
rows.push(createElement(Box, {
|
|
2723
|
+
key: index,
|
|
2724
|
+
paddingX: 1
|
|
2725
|
+
}, row));
|
|
2726
|
+
});
|
|
2727
|
+
return rows;
|
|
2728
|
+
}, [
|
|
2729
|
+
view.entries,
|
|
2730
|
+
settled,
|
|
2731
|
+
showReasoning,
|
|
2732
|
+
props.resumed
|
|
2733
|
+
]);
|
|
2734
|
+
const appStdout = useStdout().stdout;
|
|
2735
|
+
const [terminalSize, setTerminalSize] = useState(() => ({
|
|
2736
|
+
columns: appStdout?.columns ?? 80,
|
|
2737
|
+
rows: appStdout?.rows ?? 30
|
|
2738
|
+
}));
|
|
2739
|
+
const terminalSizeRef = useRef(terminalSize);
|
|
2740
|
+
useEffect(() => {
|
|
2741
|
+
if (appStdout === void 0) return;
|
|
2742
|
+
let replayTimer;
|
|
2743
|
+
const handleResize = () => {
|
|
2744
|
+
const next = {
|
|
2745
|
+
columns: appStdout.columns ?? 80,
|
|
2746
|
+
rows: appStdout.rows ?? 30
|
|
2747
|
+
};
|
|
2748
|
+
if (next.columns === terminalSizeRef.current.columns && next.rows === terminalSizeRef.current.rows) return;
|
|
2749
|
+
terminalSizeRef.current = next;
|
|
2750
|
+
setTerminalSize(next);
|
|
2751
|
+
if (replayTimer !== void 0) clearTimeout(replayTimer);
|
|
2752
|
+
replayTimer = setTimeout(() => {
|
|
2753
|
+
appStdout.write(RESIZE_REFLOW_CLEAR);
|
|
2754
|
+
setRefreshEpoch((epoch) => epoch + 1);
|
|
2755
|
+
}, RESIZE_REFLOW_DELAY_MS);
|
|
2756
|
+
};
|
|
2757
|
+
appStdout.on("resize", handleResize);
|
|
2758
|
+
return () => {
|
|
2759
|
+
appStdout.off("resize", handleResize);
|
|
2760
|
+
if (replayTimer !== void 0) clearTimeout(replayTimer);
|
|
2761
|
+
};
|
|
2762
|
+
}, [appStdout]);
|
|
2763
|
+
const terminalRows = terminalSize.rows;
|
|
2764
|
+
const terminalColumns = terminalSize.columns;
|
|
2765
|
+
const dynamicRows = Math.max(1, terminalRows - 12);
|
|
2766
|
+
const streamingActive = view.streaming !== "" || view.streamingReasoning !== "";
|
|
2767
|
+
const deepDivingVisible = busy && !streamingActive;
|
|
2768
|
+
const allLiveLines = useMemo(() => view.entries.slice(settled).flatMap((entry) => transcriptEntryLines(entry, Math.max(1, terminalColumns - 2))), [
|
|
2769
|
+
view.entries,
|
|
2770
|
+
settled,
|
|
2771
|
+
terminalColumns
|
|
2772
|
+
]);
|
|
2773
|
+
const liveBudget = streamingActive ? Math.max(1, Math.floor(dynamicRows / 3)) : Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0));
|
|
2774
|
+
const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget);
|
|
2775
|
+
const streamRows = Math.max(1, dynamicRows - visibleLiveLines.length);
|
|
2776
|
+
const reasoningRows = view.streamingReasoning === "" ? 0 : view.streaming === "" ? streamRows : streamRows <= 1 ? 0 : showReasoning ? Math.max(1, Math.floor(streamRows / 3)) : 1;
|
|
2777
|
+
const answerRows = view.streaming === "" ? 0 : Math.max(1, streamRows - reasoningRows);
|
|
2778
|
+
const transcriptVisible = !modelOpen && !helpOpen && !verboseOpen && !approvalPending && !questionPending;
|
|
2779
|
+
const modalVisible = modelOpen || helpOpen || verboseOpen && !approvalPending && !questionPending || approvalPending || questionPending;
|
|
2780
|
+
const closeInspector = useCallback(() => {
|
|
2781
|
+
setVerboseOpen(false);
|
|
2782
|
+
}, []);
|
|
2783
|
+
const refreshScreen = () => {
|
|
2784
|
+
if (appStdout !== void 0) appStdout.write("\x1B[2J\x1B[3J\x1B[H");
|
|
2785
|
+
setRefreshEpoch((epoch) => epoch + 1);
|
|
2786
|
+
};
|
|
2787
|
+
return createElement(Box, { flexDirection: "column" }, createElement(MemoStaticTranscript, {
|
|
2788
|
+
key: refreshEpoch,
|
|
2789
|
+
items: settledRows
|
|
2790
|
+
}), transcriptVisible ? createElement(Box, {
|
|
578
2791
|
flexDirection: "column",
|
|
579
2792
|
paddingX: 1
|
|
580
|
-
},
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
2793
|
+
}, visibleLiveLines.length === 0 ? void 0 : createElement(StyledRows, { lines: visibleLiveLines }), view.streamingReasoning !== "" && reasoningRows > 0 ? createElement(StreamTail, {
|
|
2794
|
+
text: showReasoning ? view.streamingReasoning : "Thinking…",
|
|
2795
|
+
prefix: " ✻ ",
|
|
2796
|
+
dim: true,
|
|
2797
|
+
maxRows: reasoningRows
|
|
2798
|
+
}) : void 0, view.streaming !== "" && answerRows > 0 ? createElement(StreamTail, {
|
|
2799
|
+
text: view.streaming,
|
|
2800
|
+
dim: false,
|
|
2801
|
+
maxRows: answerRows
|
|
2802
|
+
}, busy ? createElement(Caret) : void 0) : void 0, deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : void 0) : void 0, transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : void 0, createElement(QuestionBar, {
|
|
2803
|
+
store: props.questions,
|
|
2804
|
+
locked: false
|
|
2805
|
+
}), createElement(ApprovalBar, {
|
|
2806
|
+
approval: props.approval,
|
|
2807
|
+
locked: questionPending
|
|
2808
|
+
}), modelOpen && !approvalPending && !questionPending ? createElement(ModelPanel, {
|
|
584
2809
|
directory,
|
|
585
2810
|
error: modelError,
|
|
586
2811
|
onSelect: (row) => {
|
|
@@ -591,10 +2816,22 @@ function App(props) {
|
|
|
591
2816
|
onClose: () => {
|
|
592
2817
|
setModelOpen(false);
|
|
593
2818
|
}
|
|
594
|
-
}) : void 0,
|
|
2819
|
+
}) : void 0, helpOpen && !approvalPending && !questionPending ? createElement(HelpPanel, {
|
|
2820
|
+
descriptors,
|
|
2821
|
+
skills,
|
|
2822
|
+
onClose: () => {
|
|
2823
|
+
setHelpOpen(false);
|
|
2824
|
+
}
|
|
2825
|
+
}) : void 0, verboseOpen && !approvalPending && !questionPending ? createElement(MemoVerbosePanel, {
|
|
2826
|
+
entries: view.entries,
|
|
2827
|
+
onClose: closeInspector
|
|
2828
|
+
}) : void 0, transcriptVisible ? createElement(Box, { flexDirection: "column" }, ...notices.slice(-1).map((notice, index) => createElement(Text, {
|
|
595
2829
|
key: index,
|
|
596
|
-
dimColor: true
|
|
597
|
-
|
|
2830
|
+
dimColor: true,
|
|
2831
|
+
wrap: "truncate-end"
|
|
2832
|
+
}, displayText(notice)))) : void 0, createElement(Box, { flexDirection: "column" }, createElement(Input, {
|
|
2833
|
+
active: inputActive,
|
|
2834
|
+
frozen: modalVisible,
|
|
598
2835
|
busy,
|
|
599
2836
|
descriptors,
|
|
600
2837
|
skills,
|
|
@@ -605,17 +2842,43 @@ function App(props) {
|
|
|
605
2842
|
openModel: () => {
|
|
606
2843
|
setModelOpen(true);
|
|
607
2844
|
},
|
|
608
|
-
|
|
2845
|
+
openHelp: () => {
|
|
2846
|
+
setHelpOpen(true);
|
|
2847
|
+
},
|
|
2848
|
+
notify,
|
|
2849
|
+
openVerbose: () => {
|
|
2850
|
+
setVerboseOpen(true);
|
|
2851
|
+
},
|
|
2852
|
+
clearView: () => {
|
|
2853
|
+
props.store.reset();
|
|
2854
|
+
},
|
|
2855
|
+
refresh: refreshScreen,
|
|
2856
|
+
toggleReasoning: () => {
|
|
2857
|
+
setShowReasoning((current) => !current);
|
|
2858
|
+
},
|
|
2859
|
+
loadMentions: props.loadMentions,
|
|
2860
|
+
cyclePermission: props.cyclePermission,
|
|
2861
|
+
exportTranscript: props.exportTranscript,
|
|
2862
|
+
renameTitle: props.renameTitle
|
|
609
2863
|
}), createElement(StatusLine, {
|
|
610
2864
|
facts: {
|
|
611
2865
|
model: modelLabel,
|
|
612
2866
|
cwd: props.cwd,
|
|
613
2867
|
branch: props.branch,
|
|
614
|
-
sessionId: props.sessionId
|
|
2868
|
+
sessionId: props.sessionId,
|
|
2869
|
+
title: view.title,
|
|
2870
|
+
plan: view.plan,
|
|
2871
|
+
permission: view.permission,
|
|
2872
|
+
sandbox: view.sandbox,
|
|
2873
|
+
goal: view.goal === void 0 ? void 0 : {
|
|
2874
|
+
phase: view.goal.phase,
|
|
2875
|
+
rounds: view.goal.rounds,
|
|
2876
|
+
max: view.goal.max
|
|
2877
|
+
}
|
|
615
2878
|
},
|
|
616
2879
|
stats: view.stats,
|
|
617
2880
|
busy
|
|
618
|
-
}));
|
|
2881
|
+
})));
|
|
619
2882
|
}
|
|
620
2883
|
//#endregion
|
|
621
2884
|
//#region src/approval.ts
|
|
@@ -776,253 +3039,254 @@ async function loadModelDirectory(ctx) {
|
|
|
776
3039
|
const llm = ctx.get("llm");
|
|
777
3040
|
if (llm === void 0) return {
|
|
778
3041
|
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
|
|
810
|
-
//#region src/render/projection.ts
|
|
811
|
-
/**
|
|
812
|
-
* Pure session-event-to-view projection for the TUI transcript: one reducer
|
|
813
|
-
* over {@link SessionEvent}s producing the ordered entries the renderer draws.
|
|
814
|
-
* Rendering never reads the session directly — this module owns the view
|
|
815
|
-
* model, so tests drive it with plain event arrays.
|
|
816
|
-
*
|
|
817
|
-
* @module @deepseek-ai/dsh-tui/render/projection
|
|
818
|
-
*/
|
|
819
|
-
/** Join the text blocks of a content list; non-text blocks contribute nothing. */
|
|
820
|
-
function textOf(content) {
|
|
821
|
-
return content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
822
|
-
}
|
|
823
|
-
/** A fresh, empty transcript view. */
|
|
824
|
-
function createTranscriptView() {
|
|
825
|
-
return {
|
|
826
|
-
entries: [],
|
|
827
|
-
streaming: "",
|
|
828
|
-
todos: [],
|
|
829
|
-
busy: false,
|
|
830
|
-
model: "",
|
|
831
|
-
stats: {
|
|
832
|
-
turns: 0,
|
|
833
|
-
steps: 0,
|
|
834
|
-
llmMs: 0,
|
|
835
|
-
toolMs: 0,
|
|
836
|
-
usage: {
|
|
837
|
-
inputTokens: 0,
|
|
838
|
-
outputTokens: 0,
|
|
839
|
-
cacheReadTokens: 0
|
|
840
|
-
}
|
|
841
|
-
},
|
|
842
|
-
anchors: {
|
|
843
|
-
stepStart: /* @__PURE__ */ new Map(),
|
|
844
|
-
toolStart: /* @__PURE__ */ new Map()
|
|
845
|
-
}
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
/**
|
|
849
|
-
* Fold one session event into an updated view (copy-on-write).
|
|
850
|
-
* @param view - the view before the event.
|
|
851
|
-
* @param event - one durable session event from `session/event` or the log.
|
|
852
|
-
* @returns the view after the event; the input view is never mutated.
|
|
853
|
-
*/
|
|
854
|
-
function projectEvent(view, event) {
|
|
855
|
-
switch (event.type) {
|
|
856
|
-
case "user/message": {
|
|
857
|
-
const message = event.data;
|
|
858
|
-
if (message.source.kind === "user") return {
|
|
859
|
-
...view,
|
|
860
|
-
entries: [...view.entries, {
|
|
861
|
-
kind: "user",
|
|
862
|
-
text: textOf(message.content)
|
|
863
|
-
}]
|
|
864
|
-
};
|
|
865
|
-
const notice = message.source.kind === "plugin" && message.source.form === "notice" ? message.source.summary : message.source.kind;
|
|
866
|
-
return {
|
|
867
|
-
...view,
|
|
868
|
-
entries: [...view.entries, {
|
|
869
|
-
kind: "user",
|
|
870
|
-
text: boundContextSummary(notice)
|
|
871
|
-
}]
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
case "assistant/chunk": {
|
|
875
|
-
const chunk = event.data.chunk;
|
|
876
|
-
if (chunk.type !== "text-delta") return view;
|
|
3042
|
+
failures: []
|
|
3043
|
+
};
|
|
3044
|
+
const providers = llm.listProviders();
|
|
3045
|
+
const listed = await Promise.all(providers.map(async (provider) => {
|
|
3046
|
+
try {
|
|
3047
|
+
const models = await llm.listModels(provider.id);
|
|
877
3048
|
return {
|
|
878
|
-
|
|
879
|
-
|
|
3049
|
+
provider: provider.id,
|
|
3050
|
+
providerName: provider.name,
|
|
3051
|
+
models: models.map((model) => ({
|
|
3052
|
+
provider: provider.id,
|
|
3053
|
+
providerName: provider.name,
|
|
3054
|
+
model: model.id,
|
|
3055
|
+
modelName: model.name
|
|
3056
|
+
}))
|
|
880
3057
|
};
|
|
881
|
-
}
|
|
882
|
-
case "assistant/message": {
|
|
883
|
-
const key = `${event.data.turn}:${event.data.step}`;
|
|
884
|
-
const started = view.anchors.stepStart.get(key);
|
|
885
|
-
view.anchors.stepStart.delete(key);
|
|
886
|
-
const usage = event.data.usage;
|
|
887
|
-
const totals = view.stats.usage;
|
|
3058
|
+
} catch {
|
|
888
3059
|
return {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
text: textOf(event.data.message.content)
|
|
894
|
-
}],
|
|
895
|
-
stats: {
|
|
896
|
-
...view.stats,
|
|
897
|
-
llmMs: view.stats.llmMs + (started === void 0 ? 0 : Math.max(0, event.time - started)),
|
|
898
|
-
usage: usage === void 0 ? totals : {
|
|
899
|
-
inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
|
|
900
|
-
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
901
|
-
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0)
|
|
902
|
-
}
|
|
903
|
-
}
|
|
3060
|
+
provider: provider.id,
|
|
3061
|
+
providerName: provider.name,
|
|
3062
|
+
models: [],
|
|
3063
|
+
failed: true
|
|
904
3064
|
};
|
|
905
3065
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
3066
|
+
}));
|
|
3067
|
+
return {
|
|
3068
|
+
rows: listed.flatMap((entry) => entry.models),
|
|
3069
|
+
failures: listed.filter((entry) => "failed" in entry && entry.failed === true).map((entry) => entry.provider)
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
//#endregion
|
|
3073
|
+
//#region src/mentions.ts
|
|
3074
|
+
/**
|
|
3075
|
+
* Workspace @mention support: file candidates from a bounded async scan of
|
|
3076
|
+
* the session cwd, session candidates from the opt-in `sessionReferenceResolver`
|
|
3077
|
+
* service, and submission preparation through its `prepare()` API. Picked
|
|
3078
|
+
* session mentions land as canonical `@[label](dsh-session:…)` tokens; on
|
|
3079
|
+
* submit the text is parsed back into readable `@label` text plus structured
|
|
3080
|
+
* references, snapshots are injected via `agent.inject()` before the readable
|
|
3081
|
+
* message wakes the driver (`followup` idle, `steer` running) — exactly the
|
|
3082
|
+
* upstream README's wiring.
|
|
3083
|
+
*
|
|
3084
|
+
* @module @deepseek-ai/dsh-code/mentions
|
|
3085
|
+
*/
|
|
3086
|
+
/** Directories never entered and files never listed during the scan. */
|
|
3087
|
+
const SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
3088
|
+
".git",
|
|
3089
|
+
"node_modules",
|
|
3090
|
+
"lib",
|
|
3091
|
+
"dist",
|
|
3092
|
+
"out",
|
|
3093
|
+
".omc",
|
|
3094
|
+
"coverage"
|
|
3095
|
+
]);
|
|
3096
|
+
const MAX_FILES = 4e3;
|
|
3097
|
+
const MAX_DEPTH = 12;
|
|
3098
|
+
/** Bounded async BFS scan of a workspace; unreadable entries are skipped. */
|
|
3099
|
+
async function scanWorkspaceFiles(root, signal) {
|
|
3100
|
+
const found = [];
|
|
3101
|
+
const pending = [{
|
|
3102
|
+
absolute: root,
|
|
3103
|
+
relative: "",
|
|
3104
|
+
depth: 0
|
|
3105
|
+
}];
|
|
3106
|
+
const aborted = () => signal?.aborted === true;
|
|
3107
|
+
while (pending.length > 0 && found.length < MAX_FILES && !aborted()) {
|
|
3108
|
+
const current = pending.shift();
|
|
3109
|
+
if (current === void 0) break;
|
|
3110
|
+
let entries;
|
|
3111
|
+
try {
|
|
3112
|
+
entries = await readdir(current.absolute, { withFileTypes: true });
|
|
3113
|
+
} catch {
|
|
3114
|
+
continue;
|
|
920
3115
|
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
3116
|
+
for (const entry of entries) {
|
|
3117
|
+
if (found.length >= MAX_FILES || aborted()) return found;
|
|
3118
|
+
if (entry.name.startsWith(".")) continue;
|
|
3119
|
+
const relative = current.relative === "" ? entry.name : `${current.relative}/${entry.name}`;
|
|
3120
|
+
if (entry.isDirectory()) {
|
|
3121
|
+
if (SKIP_DIRS.has(entry.name) || current.depth + 1 > MAX_DEPTH) continue;
|
|
3122
|
+
pending.push({
|
|
3123
|
+
absolute: join(current.absolute, entry.name),
|
|
3124
|
+
relative,
|
|
3125
|
+
depth: current.depth + 1
|
|
3126
|
+
});
|
|
3127
|
+
} else if (entry.isFile()) found.push({
|
|
3128
|
+
path: relative,
|
|
3129
|
+
kind: "file"
|
|
933
3130
|
});
|
|
934
|
-
return {
|
|
935
|
-
...view,
|
|
936
|
-
entries,
|
|
937
|
-
stats: {
|
|
938
|
-
...view.stats,
|
|
939
|
-
toolMs: view.stats.toolMs + (started === void 0 ? 0 : Math.max(0, event.time - started))
|
|
940
|
-
}
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
case "todo/write": return {
|
|
944
|
-
...view,
|
|
945
|
-
todos: event.data.todos
|
|
946
|
-
};
|
|
947
|
-
case "turn/start": return {
|
|
948
|
-
...view,
|
|
949
|
-
busy: true,
|
|
950
|
-
todos: [],
|
|
951
|
-
stats: {
|
|
952
|
-
...view.stats,
|
|
953
|
-
turns: view.stats.turns + 1
|
|
954
|
-
}
|
|
955
|
-
};
|
|
956
|
-
case "step/start":
|
|
957
|
-
view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time);
|
|
958
|
-
return {
|
|
959
|
-
...view,
|
|
960
|
-
stats: {
|
|
961
|
-
...view.stats,
|
|
962
|
-
steps: view.stats.steps + 1
|
|
963
|
-
}
|
|
964
|
-
};
|
|
965
|
-
case "turn/end": {
|
|
966
|
-
const reason = event.data.reason;
|
|
967
|
-
if (reason.kind !== "error") return {
|
|
968
|
-
...view,
|
|
969
|
-
busy: false
|
|
970
|
-
};
|
|
971
|
-
return {
|
|
972
|
-
...view,
|
|
973
|
-
busy: false,
|
|
974
|
-
entries: [...view.entries, {
|
|
975
|
-
kind: "error",
|
|
976
|
-
text: `${reason.error.code}: ${reason.error.message}`
|
|
977
|
-
}]
|
|
978
|
-
};
|
|
979
3131
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
3132
|
+
}
|
|
3133
|
+
return found.sort((left, right) => left.path < right.path ? -1 : 1);
|
|
3134
|
+
}
|
|
3135
|
+
/** True when every query character appears in order in the haystack. */
|
|
3136
|
+
function isSubsequence(query, haystack) {
|
|
3137
|
+
let at = 0;
|
|
3138
|
+
for (const char of haystack) {
|
|
3139
|
+
if (char === query[at]) at += 1;
|
|
3140
|
+
if (at >= query.length) return true;
|
|
3141
|
+
}
|
|
3142
|
+
return at >= query.length;
|
|
3143
|
+
}
|
|
3144
|
+
/** Rank one file path against the typed query (community-TUI scoring shape). */
|
|
3145
|
+
function scoreFile(path, query) {
|
|
3146
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
3147
|
+
if (query === "") return 0;
|
|
3148
|
+
if (name === query) return 1e3;
|
|
3149
|
+
if (name.startsWith(query)) return 900;
|
|
3150
|
+
if (name.includes(query)) return 700;
|
|
3151
|
+
if (path.includes(query)) return 500;
|
|
3152
|
+
if (isSubsequence(query, name)) return 300;
|
|
3153
|
+
return 0;
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* Create the mention API for one agent's workspace. A missing
|
|
3157
|
+
* session-reference service degrades to file mentions only (the scan still
|
|
3158
|
+
* works); `prepare` then passes text through untouched.
|
|
3159
|
+
* @param ctx - context carrying the optional `sessionReferenceResolver`.
|
|
3160
|
+
* @param agent - the session owner; excluded from its own candidates.
|
|
3161
|
+
* @param cwd - workspace root to scan.
|
|
3162
|
+
*/
|
|
3163
|
+
function createMentions(ctx, agent, cwd) {
|
|
3164
|
+
const resolver = ctx.get("sessionReferenceResolver");
|
|
3165
|
+
let filesPromise;
|
|
3166
|
+
return {
|
|
3167
|
+
files() {
|
|
3168
|
+
filesPromise ??= scanWorkspaceFiles(cwd);
|
|
3169
|
+
return filesPromise;
|
|
3170
|
+
},
|
|
3171
|
+
async candidates(query, signal) {
|
|
3172
|
+
const needle = query.trim();
|
|
3173
|
+
const [files, sessions] = await Promise.all([this.files(), resolver === void 0 ? Promise.resolve([]) : resolver.listCandidates(agent, needle, 10, signal).catch(() => [])]);
|
|
3174
|
+
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) => ({
|
|
3175
|
+
label: candidate.path,
|
|
3176
|
+
description: candidate.kind === "directory" ? "Folder" : "File",
|
|
3177
|
+
kind: candidate.kind
|
|
3178
|
+
}));
|
|
3179
|
+
return [...sessions.map((candidate) => ({
|
|
3180
|
+
label: formatSessionReferenceMention(candidate),
|
|
3181
|
+
description: `Session · ${candidate.cwd ?? "(no cwd)"}`,
|
|
3182
|
+
kind: "session"
|
|
3183
|
+
})), ...fileRows];
|
|
3184
|
+
},
|
|
3185
|
+
parse(text) {
|
|
3186
|
+
return parseSessionReferenceText(text);
|
|
3187
|
+
},
|
|
3188
|
+
async prepare(parsed, signal) {
|
|
3189
|
+
if (parsed.references.length === 0 || resolver === void 0) return {
|
|
3190
|
+
text: parsed.text,
|
|
3191
|
+
references: parsed.references
|
|
985
3192
|
};
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
3193
|
+
const prepared = await resolver.prepare(agent, [{
|
|
3194
|
+
type: "text",
|
|
3195
|
+
text: parsed.text
|
|
3196
|
+
}], parsed.references, signal);
|
|
989
3197
|
return {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
commandId: data.commandId,
|
|
994
|
-
name: data.name,
|
|
995
|
-
args: data.args ?? "",
|
|
996
|
-
state: "running",
|
|
997
|
-
summary: ""
|
|
998
|
-
}]
|
|
3198
|
+
text: prepared.content.filter((block) => block.type === "text").map((block) => block.text).join(""),
|
|
3199
|
+
references: parsed.references,
|
|
3200
|
+
additionalContext: prepared.additionalContext
|
|
999
3201
|
};
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
return {
|
|
1006
|
-
...entry,
|
|
1007
|
-
state: data.kind === "success" ? "done" : "error",
|
|
1008
|
-
summary: boundContextSummary(data.text ?? "")
|
|
1009
|
-
};
|
|
3202
|
+
},
|
|
3203
|
+
sessionMention(candidate) {
|
|
3204
|
+
return formatSessionReferenceMention({
|
|
3205
|
+
sessionId: candidate.sessionId,
|
|
3206
|
+
label: candidate.label
|
|
1010
3207
|
});
|
|
1011
|
-
return {
|
|
1012
|
-
...view,
|
|
1013
|
-
entries
|
|
1014
|
-
};
|
|
1015
3208
|
}
|
|
1016
|
-
|
|
1017
|
-
}
|
|
3209
|
+
};
|
|
1018
3210
|
}
|
|
3211
|
+
//#endregion
|
|
3212
|
+
//#region src/questions.ts
|
|
3213
|
+
const ABORT_ERROR = new UserQuestionError("ask_user_question was interrupted before the user answered", "ASK_ABORTED");
|
|
1019
3214
|
/**
|
|
1020
|
-
*
|
|
1021
|
-
* @param
|
|
1022
|
-
* @returns the
|
|
3215
|
+
* Mount the single `ctx.userQuestions` UI provider over a FIFO queue.
|
|
3216
|
+
* @param ctx - context carrying the `userQuestions` service (dsh-base).
|
|
3217
|
+
* @returns the store the renderer subscribes to; a context without the
|
|
3218
|
+
* service yields a permanently empty store.
|
|
1023
3219
|
*/
|
|
1024
|
-
function
|
|
1025
|
-
|
|
3220
|
+
function mountQuestionProvider(ctx) {
|
|
3221
|
+
const service = ctx.get("userQuestions");
|
|
3222
|
+
let snapshot = { pending: void 0 };
|
|
3223
|
+
let active;
|
|
3224
|
+
const queue = [];
|
|
3225
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
3226
|
+
const set = (next) => {
|
|
3227
|
+
snapshot = next;
|
|
3228
|
+
for (const listener of listeners) listener();
|
|
3229
|
+
};
|
|
3230
|
+
/** Settle the active request and show the next queued one, if any. */
|
|
3231
|
+
const advance = () => {
|
|
3232
|
+
const next = queue.shift();
|
|
3233
|
+
active = next;
|
|
3234
|
+
set({ pending: next });
|
|
3235
|
+
};
|
|
3236
|
+
if (service !== void 0) service.registerProvider({ ask(request) {
|
|
3237
|
+
return new Promise((resolve, reject) => {
|
|
3238
|
+
const pending = {
|
|
3239
|
+
request,
|
|
3240
|
+
resolve,
|
|
3241
|
+
reject
|
|
3242
|
+
};
|
|
3243
|
+
const onAbort = () => {
|
|
3244
|
+
if (active === pending) {
|
|
3245
|
+
active = void 0;
|
|
3246
|
+
set({ pending: void 0 });
|
|
3247
|
+
advance();
|
|
3248
|
+
} else {
|
|
3249
|
+
const at = queue.indexOf(pending);
|
|
3250
|
+
if (at >= 0) queue.splice(at, 1);
|
|
3251
|
+
}
|
|
3252
|
+
reject(ABORT_ERROR);
|
|
3253
|
+
};
|
|
3254
|
+
if (request.signal?.aborted === true) {
|
|
3255
|
+
reject(ABORT_ERROR);
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3259
|
+
if (active === void 0) {
|
|
3260
|
+
active = pending;
|
|
3261
|
+
set({ pending });
|
|
3262
|
+
} else queue.push(pending);
|
|
3263
|
+
});
|
|
3264
|
+
} });
|
|
3265
|
+
return {
|
|
3266
|
+
subscribe(listener) {
|
|
3267
|
+
listeners.add(listener);
|
|
3268
|
+
return () => {
|
|
3269
|
+
listeners.delete(listener);
|
|
3270
|
+
};
|
|
3271
|
+
},
|
|
3272
|
+
getSnapshot() {
|
|
3273
|
+
return snapshot;
|
|
3274
|
+
},
|
|
3275
|
+
submit(pending, answers) {
|
|
3276
|
+
if (active !== pending) return;
|
|
3277
|
+
active = void 0;
|
|
3278
|
+
set({ pending: void 0 });
|
|
3279
|
+
pending.resolve(answers);
|
|
3280
|
+
advance();
|
|
3281
|
+
},
|
|
3282
|
+
cancel(pending) {
|
|
3283
|
+
if (active !== pending) return;
|
|
3284
|
+
active = void 0;
|
|
3285
|
+
set({ pending: void 0 });
|
|
3286
|
+
pending.reject(ABORT_ERROR);
|
|
3287
|
+
advance();
|
|
3288
|
+
}
|
|
3289
|
+
};
|
|
1026
3290
|
}
|
|
1027
3291
|
//#endregion
|
|
1028
3292
|
//#region src/store.ts
|
|
@@ -1052,6 +3316,10 @@ function createTranscriptStore(replay) {
|
|
|
1052
3316
|
if (next === view) return;
|
|
1053
3317
|
view = next;
|
|
1054
3318
|
for (const listener of listeners) listener();
|
|
3319
|
+
},
|
|
3320
|
+
reset() {
|
|
3321
|
+
view = createTranscriptView();
|
|
3322
|
+
for (const listener of listeners) listener();
|
|
1055
3323
|
}
|
|
1056
3324
|
};
|
|
1057
3325
|
}
|
|
@@ -1108,6 +3376,73 @@ function watchSkills(ctx) {
|
|
|
1108
3376
|
};
|
|
1109
3377
|
}
|
|
1110
3378
|
//#endregion
|
|
3379
|
+
//#region src/render/export.ts
|
|
3380
|
+
/**
|
|
3381
|
+
* Markdown export of one transcript view: the /export command's pure
|
|
3382
|
+
* formatter. Deterministic and side-effect free — the runner owns the file
|
|
3383
|
+
* write, so tests drive the builder with folded views directly.
|
|
3384
|
+
*
|
|
3385
|
+
* @module @deepseek-ai/dsh-code/render/export
|
|
3386
|
+
*/
|
|
3387
|
+
/**
|
|
3388
|
+
* Render the transcript as a standalone markdown document.
|
|
3389
|
+
* @param view - the folded transcript view to export.
|
|
3390
|
+
* @param sessionId - the full session identity for the header.
|
|
3391
|
+
* @returns the complete markdown text.
|
|
3392
|
+
*/
|
|
3393
|
+
function buildExportMarkdown(view, sessionId) {
|
|
3394
|
+
const out = [
|
|
3395
|
+
view.title === "" ? `# dsh session ${sessionId}` : `# ${view.title}`,
|
|
3396
|
+
`> session ${sessionId}`,
|
|
3397
|
+
""
|
|
3398
|
+
];
|
|
3399
|
+
for (const entry of view.entries) switch (entry.kind) {
|
|
3400
|
+
case "user":
|
|
3401
|
+
if (entry.notice) out.push(`> ⤷ context: ${entry.text}`, "");
|
|
3402
|
+
else out.push("## user", "", entry.text, "");
|
|
3403
|
+
break;
|
|
3404
|
+
case "assistant":
|
|
3405
|
+
if (entry.reasoning !== "") out.push("<details><summary>thinking</summary>", "", entry.reasoning, "", "</details>", "");
|
|
3406
|
+
out.push("## assistant", "", entry.text, "");
|
|
3407
|
+
break;
|
|
3408
|
+
case "tool":
|
|
3409
|
+
out.push(`### tool \`${entry.name}\``, "");
|
|
3410
|
+
if (entry.preview !== "") out.push(`- args: ${entry.preview}`);
|
|
3411
|
+
if (entry.summary !== "") out.push(`- ${entry.state === "error" ? "error" : "result"}: ${entry.summary}`);
|
|
3412
|
+
out.push("");
|
|
3413
|
+
break;
|
|
3414
|
+
case "command":
|
|
3415
|
+
out.push(`### /${entry.name}${entry.args === "" ? "" : ` ${entry.args}`}`, "");
|
|
3416
|
+
if (entry.summary !== "") out.push(`- ${entry.state === "error" ? "error" : "result"}: ${entry.summary}`);
|
|
3417
|
+
out.push("");
|
|
3418
|
+
break;
|
|
3419
|
+
case "error":
|
|
3420
|
+
out.push(`> ⨯ ${entry.text}`, "");
|
|
3421
|
+
break;
|
|
3422
|
+
case "turn-marker":
|
|
3423
|
+
out.push(`> ${entry.text}`, "");
|
|
3424
|
+
break;
|
|
3425
|
+
case "compaction":
|
|
3426
|
+
out.push(entry.ok ? `> compacted ~${entry.tokens} tokens` : `> compaction failed: ${entry.error}`, "");
|
|
3427
|
+
break;
|
|
3428
|
+
case "retry":
|
|
3429
|
+
out.push(`> retry ${entry.attempt}/${entry.max} (${entry.code})`, "");
|
|
3430
|
+
break;
|
|
3431
|
+
case "files":
|
|
3432
|
+
out.push(`> files changed: ${entry.paths.join(", ")}`, "");
|
|
3433
|
+
break;
|
|
3434
|
+
default: assertNever(entry, "transcript entry kind");
|
|
3435
|
+
}
|
|
3436
|
+
if (view.streaming !== "") out.push("## assistant (streaming)", "", view.streaming, "");
|
|
3437
|
+
const { stats } = view;
|
|
3438
|
+
out.push("---", "");
|
|
3439
|
+
out.push(`- model: ${view.model === "" ? "(none yet)" : view.model}`);
|
|
3440
|
+
out.push(`- turns: ${stats.turns} · steps: ${stats.steps}`);
|
|
3441
|
+
out.push(`- tokens: ↑${stats.usage.inputTokens} ↓${stats.usage.outputTokens} · cache read ${stats.usage.cacheReadTokens}`);
|
|
3442
|
+
out.push(`- todos: ${view.todos.length}`);
|
|
3443
|
+
return out.join("\n");
|
|
3444
|
+
}
|
|
3445
|
+
//#endregion
|
|
1111
3446
|
//#region src/index.ts
|
|
1112
3447
|
/**
|
|
1113
3448
|
* @deepseek-ai/dsh-code — the interactive terminal driver. The bundle patch
|
|
@@ -1199,25 +3534,7 @@ function approvalCommandPreview(events, callId, toolName) {
|
|
|
1199
3534
|
if (callId === void 0) return "";
|
|
1200
3535
|
const entry = events.find((candidate) => candidate.kind === "tool" && candidate.callId === callId);
|
|
1201
3536
|
if (entry === void 0) return "";
|
|
1202
|
-
|
|
1203
|
-
try {
|
|
1204
|
-
const parsed = JSON.parse(args);
|
|
1205
|
-
if (parsed !== null && typeof parsed === "object") {
|
|
1206
|
-
const record = parsed;
|
|
1207
|
-
for (const key of [
|
|
1208
|
-
"command",
|
|
1209
|
-
"cmd",
|
|
1210
|
-
"description",
|
|
1211
|
-
"path",
|
|
1212
|
-
"pattern",
|
|
1213
|
-
"query"
|
|
1214
|
-
]) {
|
|
1215
|
-
const value = record[key];
|
|
1216
|
-
if (typeof value === "string" && value !== "") return value;
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
} catch {}
|
|
1220
|
-
return args.length > 80 ? `${args.slice(0, 77)}...` : args === "" ? toolName : args;
|
|
3537
|
+
return toolArgumentsPreview(entry.arguments ?? "", toolName);
|
|
1221
3538
|
}
|
|
1222
3539
|
/**
|
|
1223
3540
|
* Run the interactive terminal session: resolve the target session, create or
|
|
@@ -1298,6 +3615,8 @@ async function run(ctx, startup, io) {
|
|
|
1298
3615
|
const skills = watchSkills(ctx);
|
|
1299
3616
|
skills.setAgent(agent);
|
|
1300
3617
|
const approval = mountApprovalAnswerer(ctx, (candidate) => candidate.id === agent.id, (request) => approvalCommandPreview(store.getView().entries, request.callId, request.toolName));
|
|
3618
|
+
const questions = mountQuestionProvider(ctx);
|
|
3619
|
+
const mentions = createMentions(ctx, agent, session.header.cwd ?? cwd);
|
|
1301
3620
|
const bridge = { notify: () => {} };
|
|
1302
3621
|
const mountRef = {};
|
|
1303
3622
|
let quitting = false;
|
|
@@ -1312,37 +3631,70 @@ async function run(ctx, startup, io) {
|
|
|
1312
3631
|
io.exit(0);
|
|
1313
3632
|
});
|
|
1314
3633
|
};
|
|
1315
|
-
/**
|
|
1316
|
-
const
|
|
3634
|
+
/** Run one slash line through the command registry (closed namespace). */
|
|
3635
|
+
const runSlash = (line) => {
|
|
3636
|
+
const registry = ctx.get("commands");
|
|
3637
|
+
if (registry === void 0) {
|
|
3638
|
+
bridge.notify("no command registry is mounted in this composition");
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
const controller = new AbortController();
|
|
3642
|
+
registry.execute(agent, line, controller.signal).then((execution) => {
|
|
3643
|
+
if (execution === void 0) agent.followup(createUserMessage({
|
|
3644
|
+
content: [{
|
|
3645
|
+
type: "text",
|
|
3646
|
+
text: line
|
|
3647
|
+
}],
|
|
3648
|
+
source: { kind: "user" }
|
|
3649
|
+
}));
|
|
3650
|
+
}, (error) => {
|
|
3651
|
+
bridge.notify(`command failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3652
|
+
});
|
|
3653
|
+
};
|
|
3654
|
+
/** Deliver one readable line to the agent, expanding session mentions first. */
|
|
3655
|
+
const send = (text, mode) => {
|
|
1317
3656
|
const line = text.trim();
|
|
1318
3657
|
if (line === "") return;
|
|
1319
|
-
if (isSlashLine(line)) {
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
3658
|
+
if (isSlashLine(line) && mode === "followup") {
|
|
3659
|
+
runSlash(line);
|
|
3660
|
+
return;
|
|
3661
|
+
}
|
|
3662
|
+
let parsed;
|
|
3663
|
+
try {
|
|
3664
|
+
parsed = mentions.parse(line);
|
|
3665
|
+
} catch (error) {
|
|
3666
|
+
bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`);
|
|
3667
|
+
return;
|
|
3668
|
+
}
|
|
3669
|
+
const deliver = (readable, context) => {
|
|
3670
|
+
if (context !== void 0) agent.inject(context);
|
|
3671
|
+
const message = createUserMessage({
|
|
3672
|
+
content: [{
|
|
3673
|
+
type: "text",
|
|
3674
|
+
text: readable
|
|
3675
|
+
}],
|
|
3676
|
+
source: { kind: "user" }
|
|
1336
3677
|
});
|
|
3678
|
+
if (mode === "steer") {
|
|
3679
|
+
agent.steer(message);
|
|
3680
|
+
bridge.notify("steering queued — the next step sees it");
|
|
3681
|
+
} else agent.followup(message);
|
|
3682
|
+
};
|
|
3683
|
+
if (parsed.references.length === 0) {
|
|
3684
|
+
deliver(parsed.text);
|
|
1337
3685
|
return;
|
|
1338
3686
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
})
|
|
3687
|
+
const controller = new AbortController();
|
|
3688
|
+
mentions.prepare(parsed, controller.signal).then((prepared) => {
|
|
3689
|
+
deliver(prepared.text, prepared.additionalContext);
|
|
3690
|
+
}, (error) => {
|
|
3691
|
+
if (controller.signal.aborted) return;
|
|
3692
|
+
bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3693
|
+
});
|
|
3694
|
+
};
|
|
3695
|
+
/** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
|
|
3696
|
+
const dispatch = (text) => {
|
|
3697
|
+
send(text, "followup");
|
|
1346
3698
|
};
|
|
1347
3699
|
/**
|
|
1348
3700
|
* Submit steering: a running driver consumes the text at its next step
|
|
@@ -1350,16 +3702,7 @@ async function run(ctx, startup, io) {
|
|
|
1350
3702
|
* a turn, so this doubles as the busy-state submit path.
|
|
1351
3703
|
*/
|
|
1352
3704
|
const steer = (text) => {
|
|
1353
|
-
|
|
1354
|
-
if (line === "") return;
|
|
1355
|
-
agent.steer(createUserMessage({
|
|
1356
|
-
content: [{
|
|
1357
|
-
type: "text",
|
|
1358
|
-
text: line
|
|
1359
|
-
}],
|
|
1360
|
-
source: { kind: "user" }
|
|
1361
|
-
}));
|
|
1362
|
-
bridge.notify("steering queued — the next step sees it");
|
|
3705
|
+
send(text, "steer");
|
|
1363
3706
|
};
|
|
1364
3707
|
/** Interrupt the running turn (Esc); true when a turn was actually cancelled. */
|
|
1365
3708
|
const interrupt = () => {
|
|
@@ -1368,6 +3711,23 @@ async function run(ctx, startup, io) {
|
|
|
1368
3711
|
bridge.notify("turn cancelled — Ctrl+C or /quit to exit");
|
|
1369
3712
|
return true;
|
|
1370
3713
|
};
|
|
3714
|
+
/**
|
|
3715
|
+
* Cycle to the next permission preset (Shift+Tab, the Claude-Code
|
|
3716
|
+
* permission-mode convention mapped onto dsh presets). A session in a
|
|
3717
|
+
* custom knob state wraps to the first declared preset.
|
|
3718
|
+
*/
|
|
3719
|
+
const cyclePermission = () => {
|
|
3720
|
+
const service = ctx.get("permissionPresets");
|
|
3721
|
+
if (service === void 0 || service.names.length === 0) {
|
|
3722
|
+
bridge.notify("permission presets are not mounted in this composition");
|
|
3723
|
+
return "";
|
|
3724
|
+
}
|
|
3725
|
+
const at = service.names.indexOf(service.current(session.events));
|
|
3726
|
+
const next = service.names[(at + 1) % service.names.length] ?? "";
|
|
3727
|
+
if (next === "") return "";
|
|
3728
|
+
service.set(session, next);
|
|
3729
|
+
return next;
|
|
3730
|
+
};
|
|
1371
3731
|
/** Apply one /model selection: takes effect from the next assembled step. */
|
|
1372
3732
|
const selectModel = (row) => {
|
|
1373
3733
|
picked = {
|
|
@@ -1376,10 +3736,45 @@ async function run(ctx, startup, io) {
|
|
|
1376
3736
|
};
|
|
1377
3737
|
return `${row.provider}/${row.model}`;
|
|
1378
3738
|
};
|
|
3739
|
+
/**
|
|
3740
|
+
* Export the folded transcript to a markdown file (/export). The default
|
|
3741
|
+
* target sits beside the session's cwd so the file lands in the user's
|
|
3742
|
+
* workspace; an absolute or cwd-relative argument overrides it.
|
|
3743
|
+
*/
|
|
3744
|
+
const exportTranscript = async (argument) => {
|
|
3745
|
+
const wanted = argument.trim();
|
|
3746
|
+
const defaultName = `dsh-session-${session.id.slice(-8)}.md`;
|
|
3747
|
+
const target = wanted === "" ? join(cwd, defaultName) : /^[a-zA-Z]:[\\/]/u.test(wanted) || wanted.startsWith("/") ? wanted : join(cwd, wanted);
|
|
3748
|
+
const markdown = buildExportMarkdown(store.getView(), session.id);
|
|
3749
|
+
try {
|
|
3750
|
+
await writeFile(target, `${markdown}\n`, "utf8");
|
|
3751
|
+
bridge.notify(`exported to ${target}`);
|
|
3752
|
+
} catch (error) {
|
|
3753
|
+
bridge.notify(`export failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3754
|
+
}
|
|
3755
|
+
};
|
|
3756
|
+
/**
|
|
3757
|
+
* Rename the session (/title): a user title pins the session and stops
|
|
3758
|
+
* automatic generation (the service's own contract). The appended
|
|
3759
|
+
* `session/title` event flows back through the store into the status line.
|
|
3760
|
+
*/
|
|
3761
|
+
const renameTitle = (argument) => {
|
|
3762
|
+
const title = argument.trim();
|
|
3763
|
+
if (title === "") return "usage: /title <text>";
|
|
3764
|
+
const service = ctx.get("sessionTitle");
|
|
3765
|
+
if (service === void 0) return "session titles are unavailable in this profile";
|
|
3766
|
+
try {
|
|
3767
|
+
service.rename(session, title);
|
|
3768
|
+
return `title → ${title}`;
|
|
3769
|
+
} catch (error) {
|
|
3770
|
+
return `rename failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
3771
|
+
}
|
|
3772
|
+
};
|
|
1379
3773
|
const initialModel = store.getView().model !== "" ? store.getView().model : `${defaults.provider}/${defaults.model}`;
|
|
1380
3774
|
mountRef.current = io.mount(createElement(App, {
|
|
1381
3775
|
store,
|
|
1382
3776
|
approval,
|
|
3777
|
+
questions,
|
|
1383
3778
|
commands,
|
|
1384
3779
|
skills,
|
|
1385
3780
|
model: initialModel,
|
|
@@ -1392,7 +3787,11 @@ async function run(ctx, startup, io) {
|
|
|
1392
3787
|
interrupt,
|
|
1393
3788
|
quit,
|
|
1394
3789
|
loadModels: () => loadModelDirectory(ctx),
|
|
3790
|
+
loadMentions: mentions.candidates,
|
|
3791
|
+
cyclePermission,
|
|
1395
3792
|
selectModel,
|
|
3793
|
+
exportTranscript,
|
|
3794
|
+
renameTitle,
|
|
1396
3795
|
onBridgeReady: (instance) => {
|
|
1397
3796
|
bridge.notify = instance.notify;
|
|
1398
3797
|
}
|