@songtonyli/dsh-cli 0.1.10 → 0.1.11
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 +1 -1
- package/bin/dsh.mjs +1 -1
- package/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js +1 -1
- package/node_modules/@deepseek-ai/dsh-tui-app/README.i18n.yaml +2 -2
- package/node_modules/@deepseek-ai/dsh-tui-app/README.md +37 -8
- package/node_modules/@deepseek-ai/dsh-tui-app/README.zh.md +37 -8
- package/node_modules/@deepseek-ai/dsh-tui-app/lib/index.js +3085 -1049
- package/package.json +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { Container, Editor, Input, Loader, Markdown, ProcessTerminal, SelectList, Text, TuiMainScreen, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import { CURSOR_MARKER, Container, Editor, Input, Loader, Markdown, ProcessTerminal, SelectList, Text, TuiMainScreen, decodeKittyPrintable, fuzzyFilter, matchesKey, sliceByColumn, stripTerminalSequences, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
3
|
import z from "@deepseek-ai/schemastery";
|
|
4
4
|
import { brandString } from "@deepseek-ai/dsh-brand";
|
|
5
5
|
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
6
6
|
import { launchEnvironmentOf, launchedThroughSsh } from "@deepseek-ai/dsh-launch-environment";
|
|
7
7
|
import { canOpenNativePath, openNativeUrl } from "@deepseek-ai/dsh-native-command";
|
|
8
8
|
import { SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
9
|
+
import { homedir } from "node:os";
|
|
9
10
|
import "@deepseek-ai/cordis";
|
|
10
11
|
import { HarnessError, ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
11
12
|
import { activeAtToken, formatFileMention } from "@deepseek-ai/dsh-file-reference";
|
|
@@ -100,953 +101,2149 @@ async function attachLocalFile(store, cwd, path) {
|
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
//#endregion
|
|
103
|
-
//#region lib/types/
|
|
104
|
+
//#region lib/types/diff.js
|
|
104
105
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* @module @deepseek-ai/dsh-tui-app/catalog
|
|
106
|
+
* Line diff for tool cards: a longest-common-subsequence alignment of old and
|
|
107
|
+
* new text, bounded so a huge file falls back to a plain replacement view.
|
|
108
|
+
* @module @deepseek-ai/dsh-tui-app/diff
|
|
109
109
|
*/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
110
|
+
/** Above this many old×new line pairs the quadratic alignment is skipped. */
|
|
111
|
+
const MAX_ALIGNMENT_CELLS = 4e6;
|
|
112
|
+
function splitLines(text) {
|
|
113
|
+
if (text === "") return [];
|
|
114
|
+
const lines = text.split("\n");
|
|
115
|
+
if (lines.at(-1) === "") lines.pop();
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Align `oldText` against `newText` line by line.
|
|
120
|
+
* @param oldText - the previous file content, or null when the file is new.
|
|
121
|
+
* @param newText - the new file content.
|
|
122
|
+
* @returns the rows in display order; a new file yields only added rows.
|
|
123
|
+
*/
|
|
124
|
+
function diffLines(oldText, newText) {
|
|
125
|
+
const next = splitLines(newText);
|
|
126
|
+
if (oldText === null) return next.map((text) => ({
|
|
127
|
+
kind: "added",
|
|
128
|
+
text
|
|
129
|
+
}));
|
|
130
|
+
const previous = splitLines(oldText);
|
|
131
|
+
if (previous.length * next.length > MAX_ALIGNMENT_CELLS) return [...previous.map((text) => ({
|
|
132
|
+
kind: "removed",
|
|
133
|
+
text
|
|
134
|
+
})), ...next.map((text) => ({
|
|
135
|
+
kind: "added",
|
|
136
|
+
text
|
|
137
|
+
}))];
|
|
138
|
+
const cols = next.length + 1;
|
|
139
|
+
const lcs = new Uint32Array((previous.length + 1) * cols);
|
|
140
|
+
for (let i = previous.length - 1; i >= 0; i--) for (let j = next.length - 1; j >= 0; j--) lcs[i * cols + j] = previous[i] === next[j] ? lcs[(i + 1) * cols + j + 1] + 1 : Math.max(lcs[(i + 1) * cols + j], lcs[i * cols + j + 1]);
|
|
141
|
+
const out = [];
|
|
142
|
+
let i = 0;
|
|
143
|
+
let j = 0;
|
|
144
|
+
while (i < previous.length && j < next.length) if (previous[i] === next[j]) {
|
|
145
|
+
out.push({
|
|
146
|
+
kind: "context",
|
|
147
|
+
text: previous[i]
|
|
148
|
+
});
|
|
149
|
+
i++;
|
|
150
|
+
j++;
|
|
151
|
+
} else if (lcs[(i + 1) * cols + j] >= lcs[i * cols + j + 1]) {
|
|
152
|
+
out.push({
|
|
153
|
+
kind: "removed",
|
|
154
|
+
text: previous[i]
|
|
155
|
+
});
|
|
156
|
+
i++;
|
|
157
|
+
} else {
|
|
158
|
+
out.push({
|
|
159
|
+
kind: "added",
|
|
160
|
+
text: next[j]
|
|
161
|
+
});
|
|
162
|
+
j++;
|
|
163
|
+
}
|
|
164
|
+
for (; i < previous.length; i++) out.push({
|
|
165
|
+
kind: "removed",
|
|
166
|
+
text: previous[i]
|
|
167
|
+
});
|
|
168
|
+
for (; j < next.length; j++) out.push({
|
|
169
|
+
kind: "added",
|
|
170
|
+
text: next[j]
|
|
171
|
+
});
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Keep changed rows plus `context` unchanged rows around each change, so a
|
|
176
|
+
* card shows the hunks rather than the whole file.
|
|
177
|
+
* @param lines - the aligned rows.
|
|
178
|
+
* @param context - unchanged rows kept on each side of a change.
|
|
179
|
+
* @returns the rows to display, with an `undefined` gap marker where rows were elided.
|
|
180
|
+
*/
|
|
181
|
+
function hunks(lines, context) {
|
|
182
|
+
const keep = new Array(lines.length).fill(false);
|
|
183
|
+
lines.forEach((line, index) => {
|
|
184
|
+
if (line.kind === "context") return;
|
|
185
|
+
for (let k = Math.max(0, index - context); k <= Math.min(lines.length - 1, index + context); k++) keep[k] = true;
|
|
186
|
+
});
|
|
187
|
+
const out = [];
|
|
188
|
+
let gap = false;
|
|
189
|
+
lines.forEach((line, index) => {
|
|
190
|
+
if (keep[index] === true) {
|
|
191
|
+
out.push(line);
|
|
192
|
+
gap = false;
|
|
193
|
+
} else if (!gap) {
|
|
194
|
+
out.push(void 0);
|
|
195
|
+
gap = true;
|
|
122
196
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
197
|
+
});
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region lib/types/transcript.js
|
|
202
|
+
/**
|
|
203
|
+
* Pure transcript formatting: durable session facts and tool presentation
|
|
204
|
+
* views become plain text rows for the terminal components to draw. Nothing
|
|
205
|
+
* here touches the terminal, the palette, or the agent.
|
|
206
|
+
* @module @deepseek-ai/dsh-tui-app/transcript
|
|
207
|
+
*/
|
|
208
|
+
/** Unchanged rows shown around each diff hunk. */
|
|
209
|
+
const DIFF_CONTEXT_LINES = 2;
|
|
210
|
+
/**
|
|
211
|
+
* Join the text of the text blocks in `blocks`; other block kinds are
|
|
212
|
+
* summarized in brackets so a card never hides that they exist.
|
|
213
|
+
* @param blocks - model or tool content.
|
|
214
|
+
* @returns the readable text.
|
|
215
|
+
*/
|
|
216
|
+
function contentText(blocks) {
|
|
217
|
+
return blocks.map((block) => {
|
|
218
|
+
if (block.type === "text") return block.text;
|
|
219
|
+
if (block.type === "reasoning") return "";
|
|
220
|
+
return `[${block.type}]`;
|
|
221
|
+
}).filter((part) => part !== "").join("\n");
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Compact token count: `950`, `1.2k`, `3.4M`.
|
|
225
|
+
* @param count - a non-negative token count.
|
|
226
|
+
* @returns the formatted count.
|
|
227
|
+
*/
|
|
228
|
+
function formatTokens(count) {
|
|
229
|
+
if (count < 1e3) return String(count);
|
|
230
|
+
if (count < 1e6) return `${(count / 1e3).toFixed(count < 1e4 ? 1 : 0)}k`;
|
|
231
|
+
return `${(count / 1e6).toFixed(1)}M`;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Compact elapsed time for the live counters: `0s`, `8s`, `1m12s`, `1h04m`,
|
|
235
|
+
* `26h07m`. Seconds are truncated rather than rounded, so a counter never
|
|
236
|
+
* reports a second that has not passed, and a negative input — a clock that
|
|
237
|
+
* moved backwards between two samples — reads `0s`. A unit under a larger one
|
|
238
|
+
* is padded to two digits so the text keeps its width while a counter runs,
|
|
239
|
+
* and past an hour the seconds are dropped. `formatDuration` in `status.ts`
|
|
240
|
+
* reports recorded model and tool times instead: tenths under a minute, and
|
|
241
|
+
* no hour unit.
|
|
242
|
+
* @param ms - elapsed milliseconds.
|
|
243
|
+
* @returns the formatted duration.
|
|
244
|
+
*/
|
|
245
|
+
function formatElapsed(ms) {
|
|
246
|
+
const total = Math.max(0, Math.floor(ms / 1e3));
|
|
247
|
+
const hours = Math.floor(total / 3600);
|
|
248
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
249
|
+
const seconds = total % 60;
|
|
250
|
+
if (hours > 0) return `${String(hours)}h${String(minutes).padStart(2, "0")}m`;
|
|
251
|
+
if (minutes > 0) return `${String(minutes)}m${String(seconds).padStart(2, "0")}s`;
|
|
252
|
+
return `${String(seconds)}s`;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* `YYYY-MM-DD HH:MM` in UTC: how this terminal dates a recorded moment, in
|
|
256
|
+
* the session picker, the subagent details, and the status bar alike.
|
|
257
|
+
* @param ms - a Unix timestamp in milliseconds.
|
|
258
|
+
* @returns the formatted timestamp.
|
|
259
|
+
*/
|
|
260
|
+
function formatTimestamp(ms) {
|
|
261
|
+
return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
|
|
262
|
+
}
|
|
263
|
+
/** The zero totals a fresh session starts from. */
|
|
264
|
+
const EMPTY_USAGE = {
|
|
265
|
+
inputTokens: 0,
|
|
266
|
+
outputTokens: 0,
|
|
267
|
+
cacheReadTokens: 0,
|
|
268
|
+
lastInputTokens: 0
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Fold one committed usage record into the totals.
|
|
272
|
+
* @param totals - the totals so far.
|
|
273
|
+
* @param usage - the message's usage.
|
|
274
|
+
* @returns the new totals.
|
|
275
|
+
*/
|
|
276
|
+
function addUsage(totals, usage) {
|
|
277
|
+
return {
|
|
278
|
+
inputTokens: totals.inputTokens + usage.inputTokens,
|
|
279
|
+
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
280
|
+
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
|
|
281
|
+
lastInputTokens: usage.inputTokens + (usage.cacheReadTokens ?? 0)
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The one-line usage summary drawn in the footer.
|
|
286
|
+
* @param totals - the cumulative totals.
|
|
287
|
+
* @returns the summary, or an empty string before the first committed message.
|
|
288
|
+
*/
|
|
289
|
+
function formatUsage(totals) {
|
|
290
|
+
if (totals.inputTokens === 0 && totals.outputTokens === 0) return "";
|
|
291
|
+
const parts = [`↑${formatTokens(totals.inputTokens)}`, `↓${formatTokens(totals.outputTokens)}`];
|
|
292
|
+
if (totals.cacheReadTokens > 0) parts.push(`cache ${formatTokens(totals.cacheReadTokens)}`);
|
|
293
|
+
parts.push(`ctx ${formatTokens(totals.lastInputTokens)}`);
|
|
294
|
+
return parts.join(" ");
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The notice a closed turn earns, or undefined for an ordinary completion.
|
|
298
|
+
* @param reason - the durable turn-end reason.
|
|
299
|
+
* @returns the notice text.
|
|
300
|
+
*/
|
|
301
|
+
function turnEndNotice(reason) {
|
|
302
|
+
switch (reason.kind) {
|
|
303
|
+
case "completed": return;
|
|
304
|
+
case "aborted": return "turn stopped";
|
|
305
|
+
case "blocked": return "turn blocked: the model produced nothing the loop could continue";
|
|
306
|
+
case "error": return `turn failed: ${reason.error.code}: ${reason.error.message}`;
|
|
307
|
+
case "max-tokens": return "turn reached the output token ceiling";
|
|
308
|
+
case "interrupted": return "turn was interrupted by an earlier process exit";
|
|
309
|
+
default: return assertNever(reason, "tui turn-end reason");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* One-line text for a failure value from a handler, a service, or the host.
|
|
314
|
+
* @param error - the thrown or rejected value.
|
|
315
|
+
* @returns the error message, or the value rendered as a string.
|
|
316
|
+
*/
|
|
317
|
+
function describeFailure(error) {
|
|
318
|
+
return error instanceof Error ? error.message : String(error);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Parse the model's raw argument JSON for presentation.
|
|
322
|
+
* @param argumentsJson - the `tool/call` event's verbatim argument string.
|
|
323
|
+
* @returns the parsed value, or undefined when it is not JSON.
|
|
324
|
+
*/
|
|
325
|
+
function parseArguments(argumentsJson) {
|
|
326
|
+
try {
|
|
327
|
+
return JSON.parse(argumentsJson);
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Render a diff for a card body.
|
|
334
|
+
* @param diff - one file's old and new text.
|
|
335
|
+
* @returns rows prefixed with `+`, `-`, or two spaces, plus `…` gap markers.
|
|
336
|
+
*/
|
|
337
|
+
function diffRows(diff) {
|
|
338
|
+
return hunks(diffLines(diff.oldText, diff.newText), DIFF_CONTEXT_LINES).map((row) => {
|
|
339
|
+
if (row === void 0) return " …";
|
|
340
|
+
return (row.kind === "added" ? "+ " : row.kind === "removed" ? "- " : " ") + row.text;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Text for a tool call from its presentation view, falling back to the raw
|
|
345
|
+
* arguments when the tool declares no presenter.
|
|
346
|
+
* @param argumentsJson - the verbatim argument JSON.
|
|
347
|
+
* @param view - the tool's `presentCall` view, when it has one.
|
|
348
|
+
* @returns the card's call text.
|
|
349
|
+
*/
|
|
350
|
+
function toolCallText(argumentsJson, view) {
|
|
351
|
+
if (view === void 0) {
|
|
352
|
+
const parsed = parseArguments(argumentsJson);
|
|
353
|
+
const summary = parsed === void 0 ? argumentsJson : JSON.stringify(parsed);
|
|
354
|
+
return {
|
|
355
|
+
title: "",
|
|
356
|
+
lines: summary === "{}" || summary === "" ? [] : [summary]
|
|
130
357
|
};
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
358
|
+
}
|
|
359
|
+
switch (view.card) {
|
|
360
|
+
case "generic": {
|
|
361
|
+
const lines = [];
|
|
362
|
+
if (view.content !== void 0) lines.push(...contentText(view.content).split("\n"));
|
|
363
|
+
return {
|
|
364
|
+
title: view.title,
|
|
365
|
+
lines
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
case "terminal": {
|
|
369
|
+
const lines = [];
|
|
370
|
+
if (view.description !== void 0) lines.push(view.description);
|
|
371
|
+
if (view.cwd !== void 0) lines.push(`cwd: ${view.cwd}`);
|
|
372
|
+
return {
|
|
373
|
+
title: view.title,
|
|
374
|
+
lines
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
case "diff": return {
|
|
378
|
+
title: view.title,
|
|
379
|
+
lines: view.diffs.flatMap((diff) => [diff.path, ...diffRows(diff)])
|
|
380
|
+
};
|
|
381
|
+
default: return assertNever(view, "tui tool call view");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Text rows for a completed tool call from its presentation view, falling
|
|
386
|
+
* back to the model-facing result content.
|
|
387
|
+
* @param view - the tool's `presentResult` view, when it has one.
|
|
388
|
+
* @param content - the model-facing result content.
|
|
389
|
+
* @returns the rows, before any preview truncation.
|
|
390
|
+
*/
|
|
391
|
+
function toolResultLines(view, content) {
|
|
392
|
+
if (view === void 0) return contentText(content).split("\n").filter((line) => line !== "");
|
|
393
|
+
switch (view.card) {
|
|
394
|
+
case "generic": return contentText(view.content ?? content).split("\n").filter((line) => line !== "");
|
|
395
|
+
case "terminal": {
|
|
396
|
+
const lines = (view.output ?? "").split("\n");
|
|
397
|
+
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
|
398
|
+
if (view.exitCode !== void 0 && view.exitCode !== 0) lines.push(`exit ${String(view.exitCode)}`);
|
|
399
|
+
if (view.signal !== void 0) lines.push(`signal ${view.signal}`);
|
|
400
|
+
return lines;
|
|
401
|
+
}
|
|
402
|
+
case "diff": return view.diffs.flatMap((diff) => [diff.path, ...diffRows(diff)]);
|
|
403
|
+
case "search": {
|
|
404
|
+
const lines = view.shape === "matches" ? view.files.flatMap((file) => file.matches.map((match) => `${file.path}:${String(match.lineNumber)}: ${match.line}`)) : [...view.paths];
|
|
405
|
+
if (view.truncated) lines.push(`… ${String(view.total)} total`);
|
|
406
|
+
return lines;
|
|
407
|
+
}
|
|
408
|
+
case "read": return view.lines.map((line) => `${String(line.number).padStart(4)}│ ${line.text}`);
|
|
409
|
+
case "web": {
|
|
410
|
+
if (view.kind === "fetch") return [`${view.url} (${String(view.statusCode)})`];
|
|
411
|
+
const lines = view.sources.map((source) => source.title === void 0 ? source.url : `${source.title} — ${source.url}`);
|
|
412
|
+
if (view.answer !== void 0) lines.unshift(view.answer);
|
|
413
|
+
return lines;
|
|
414
|
+
}
|
|
415
|
+
default: return assertNever(view, "tui tool result view");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Cut a card body to its collapsed preview.
|
|
420
|
+
* @param lines - the full body rows.
|
|
421
|
+
* @param previewLines - rows kept when collapsed.
|
|
422
|
+
* @param expanded - whether the user expanded tool cards.
|
|
423
|
+
* @returns the rows to draw, with a trailing count of hidden rows when cut.
|
|
424
|
+
*/
|
|
425
|
+
function previewLines(lines, previewLines, expanded) {
|
|
426
|
+
if (expanded || lines.length <= previewLines) return [...lines];
|
|
427
|
+
const hidden = lines.length - previewLines;
|
|
428
|
+
return [...lines.slice(0, previewLines), `… ${String(hidden)} more line${hidden === 1 ? "" : "s"} (Ctrl+O expands)`];
|
|
429
|
+
}
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region lib/types/catalog.js
|
|
432
|
+
/**
|
|
433
|
+
* Terminal rows for the browser's settings, plugins, subagents, deliverables,
|
|
434
|
+
* and turn-outline pages. Each reader resolves its service through `ctx.get`
|
|
435
|
+
* and throws an `Error` naming the absent service; the app prints the message.
|
|
436
|
+
* `subagentDetail` is the exception: it returns that message as a row.
|
|
437
|
+
* @module @deepseek-ai/dsh-tui-app/catalog
|
|
438
|
+
*/
|
|
439
|
+
var __addDisposableResource$1 = function(env, value, async) {
|
|
440
|
+
if (value !== null && value !== void 0) {
|
|
441
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
442
|
+
var dispose, inner;
|
|
443
|
+
if (async) {
|
|
444
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
445
|
+
dispose = value[Symbol.asyncDispose];
|
|
446
|
+
}
|
|
447
|
+
if (dispose === void 0) {
|
|
448
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
449
|
+
dispose = value[Symbol.dispose];
|
|
450
|
+
if (async) inner = dispose;
|
|
451
|
+
}
|
|
452
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
453
|
+
if (inner) dispose = function() {
|
|
454
|
+
try {
|
|
455
|
+
inner.call(this);
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return Promise.reject(e);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
env.stack.push({
|
|
461
|
+
value,
|
|
462
|
+
dispose,
|
|
463
|
+
async
|
|
464
|
+
});
|
|
136
465
|
} else if (async) env.stack.push({ async: true });
|
|
137
466
|
return value;
|
|
138
467
|
};
|
|
139
|
-
var __disposeResources$1 = (function(SuppressedError) {
|
|
140
|
-
return function(env) {
|
|
141
|
-
function fail(e) {
|
|
142
|
-
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
143
|
-
env.hasError = true;
|
|
144
|
-
}
|
|
145
|
-
var r, s = 0;
|
|
146
|
-
function next() {
|
|
147
|
-
while (r = env.stack.pop()) try {
|
|
148
|
-
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
149
|
-
if (r.dispose) {
|
|
150
|
-
var result = r.dispose.call(r.value);
|
|
151
|
-
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
152
|
-
fail(e);
|
|
153
|
-
return next();
|
|
154
|
-
});
|
|
155
|
-
} else s |= 1;
|
|
156
|
-
} catch (e) {
|
|
157
|
-
fail(e);
|
|
468
|
+
var __disposeResources$1 = (function(SuppressedError) {
|
|
469
|
+
return function(env) {
|
|
470
|
+
function fail(e) {
|
|
471
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
472
|
+
env.hasError = true;
|
|
473
|
+
}
|
|
474
|
+
var r, s = 0;
|
|
475
|
+
function next() {
|
|
476
|
+
while (r = env.stack.pop()) try {
|
|
477
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
478
|
+
if (r.dispose) {
|
|
479
|
+
var result = r.dispose.call(r.value);
|
|
480
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
481
|
+
fail(e);
|
|
482
|
+
return next();
|
|
483
|
+
});
|
|
484
|
+
} else s |= 1;
|
|
485
|
+
} catch (e) {
|
|
486
|
+
fail(e);
|
|
487
|
+
}
|
|
488
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
489
|
+
if (env.hasError) throw env.error;
|
|
490
|
+
}
|
|
491
|
+
return next();
|
|
492
|
+
};
|
|
493
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
494
|
+
var e = new Error(message);
|
|
495
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
496
|
+
});
|
|
497
|
+
/**
|
|
498
|
+
* Turns and presented files a subagent detail page lists in full; the rest of
|
|
499
|
+
* each list folds into one `… <n> more …` row.
|
|
500
|
+
*/
|
|
501
|
+
const SUBAGENT_DETAIL_LIMIT = 8;
|
|
502
|
+
/** Two-space indentation per nesting level. */
|
|
503
|
+
function indent(depth) {
|
|
504
|
+
return " ".repeat(depth);
|
|
505
|
+
}
|
|
506
|
+
/** The settings service, or a printable error naming its absence. */
|
|
507
|
+
function requireSettings(ctx) {
|
|
508
|
+
const settings = ctx.get("settings");
|
|
509
|
+
if (settings === void 0) throw new Error("settings are not mounted in this profile");
|
|
510
|
+
return settings;
|
|
511
|
+
}
|
|
512
|
+
/** One namespace's redacted descriptor, or a printable error when it is not registered. */
|
|
513
|
+
function requireDescriptor(settings, ns) {
|
|
514
|
+
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
515
|
+
if (descriptor === void 0) throw new Error(`settings namespace "${ns}" is not registered`);
|
|
516
|
+
return descriptor;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* One row per registered settings namespace: the namespace, its revision,
|
|
520
|
+
* whether the stored user section overrides anything, and when its owner
|
|
521
|
+
* applies changes.
|
|
522
|
+
* @param ctx - plugin context carrying the optional settings service.
|
|
523
|
+
* @returns the rows in registration order.
|
|
524
|
+
* @throws {Error} when no settings service is mounted.
|
|
525
|
+
*/
|
|
526
|
+
function listSettings(ctx) {
|
|
527
|
+
return requireSettings(ctx).describe({ redactSecrets: true }).map((descriptor) => {
|
|
528
|
+
const overridden = Object.keys(descriptor.user ?? {}).length > 0;
|
|
529
|
+
return `${descriptor.ns} rev ${String(descriptor.revision)} ${overridden ? "user-overridden" : "inherited"} applies ${descriptor.applies}`;
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Pretty-printed JSON of one namespace's resolved value with every secret
|
|
534
|
+
* field removed, followed by one row per secret slot stating whether it holds
|
|
535
|
+
* a value.
|
|
536
|
+
* @param ctx - plugin context carrying the optional settings service.
|
|
537
|
+
* @param ns - the registered namespace to show.
|
|
538
|
+
* @returns the JSON lines, then the secret-slot rows.
|
|
539
|
+
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
540
|
+
*/
|
|
541
|
+
function showSetting(ctx, ns) {
|
|
542
|
+
const descriptor = requireDescriptor(requireSettings(ctx), ns);
|
|
543
|
+
const rows = JSON.stringify(descriptor.value, null, 2).split("\n");
|
|
544
|
+
for (const secret of descriptor.secrets ?? []) rows.push(`secret ${secret.path.join(".")}: ${secret.set ? "set" : "unset"}`);
|
|
545
|
+
return rows;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Write one field of a namespace's user section through a path-addressed
|
|
549
|
+
* `set` against the revision read at call time. `rawValue` is parsed as JSON
|
|
550
|
+
* when it is valid JSON and stored as a string otherwise; an empty `path`
|
|
551
|
+
* addresses the section root, which then requires a JSON object.
|
|
552
|
+
* @param ctx - plugin context carrying the optional settings service.
|
|
553
|
+
* @param ns - the registered namespace to edit.
|
|
554
|
+
* @param path - dot-separated field path (`api.timeoutMs`), or `''` for the root.
|
|
555
|
+
* @param rawValue - the typed value, JSON or plain text.
|
|
556
|
+
* @returns a confirmation row naming the namespace, path, and stored value.
|
|
557
|
+
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
558
|
+
*/
|
|
559
|
+
async function setSetting(ctx, ns, path, rawValue) {
|
|
560
|
+
const settings = requireSettings(ctx);
|
|
561
|
+
const { revision } = requireDescriptor(settings, ns);
|
|
562
|
+
const value = parseSettingValue(rawValue);
|
|
563
|
+
const parts = path === "" ? [] : path.split(".");
|
|
564
|
+
await settings.mutate(ns, [{
|
|
565
|
+
op: "set",
|
|
566
|
+
path: parts,
|
|
567
|
+
value
|
|
568
|
+
}], revision);
|
|
569
|
+
return `settings ${ns}: set ${path === "" ? "(root)" : path} = ${JSON.stringify(value)}`;
|
|
570
|
+
}
|
|
571
|
+
/** JSON when the text parses, otherwise the text itself. */
|
|
572
|
+
function parseSettingValue(rawValue) {
|
|
573
|
+
try {
|
|
574
|
+
return JSON.parse(rawValue);
|
|
575
|
+
} catch {
|
|
576
|
+
return rawValue;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Empty one namespace's user section so every field re-inherits its
|
|
581
|
+
* composition base and schema default.
|
|
582
|
+
* @param ctx - plugin context carrying the optional settings service.
|
|
583
|
+
* @param ns - the registered namespace to reset.
|
|
584
|
+
* @returns a confirmation row.
|
|
585
|
+
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
586
|
+
*/
|
|
587
|
+
async function resetSetting(ctx, ns) {
|
|
588
|
+
const settings = requireSettings(ctx);
|
|
589
|
+
const { revision } = requireDescriptor(settings, ns);
|
|
590
|
+
await settings.replace(ns, {}, revision);
|
|
591
|
+
return `settings ${ns}: reset to defaults`;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* One row per non-group Loader entry: entry id, module name, effective
|
|
595
|
+
* enablement, and the root Fiber phase (`none` without a live root Fiber).
|
|
596
|
+
* @param ctx - plugin context carrying the optional Cordis Loader.
|
|
597
|
+
* @returns the rows in Loader order.
|
|
598
|
+
* @throws {Error} when no Loader is mounted.
|
|
599
|
+
*/
|
|
600
|
+
function listPlugins(ctx) {
|
|
601
|
+
const loader = ctx.get("loader");
|
|
602
|
+
if (loader === void 0) throw new Error("the plugin loader is not mounted in this profile");
|
|
603
|
+
const rows = [];
|
|
604
|
+
for (const entry of loader.entries()) {
|
|
605
|
+
if (entry.options.group) continue;
|
|
606
|
+
const phase = pluginFiberPhase(entry.fiber?.state);
|
|
607
|
+
rows.push(`${entry.id} ${entry.options.name} ${entry.disabled ? "disabled" : "enabled"} ${phase ?? "none"}`);
|
|
608
|
+
}
|
|
609
|
+
return rows;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* One descendant listing entry as a picker row: the child session id (usable
|
|
613
|
+
* with `/sessions` and `--resume`), its activity, mode, and label, or a
|
|
614
|
+
* diagnostic candidate's reason. The live panel enters the same row.
|
|
615
|
+
* @param entry - the listing entry.
|
|
616
|
+
* @returns the row; `enterable` is false exactly for a diagnostic entry.
|
|
617
|
+
*/
|
|
618
|
+
function subagentChoice(entry) {
|
|
619
|
+
const prefix = indent(entry.depth - 1);
|
|
620
|
+
switch (entry.kind) {
|
|
621
|
+
case "child": {
|
|
622
|
+
const parts = [entry.activity, entry.mode];
|
|
623
|
+
if (entry.label !== void 0) parts.push(entry.id);
|
|
624
|
+
return {
|
|
625
|
+
id: entry.id,
|
|
626
|
+
depth: entry.depth,
|
|
627
|
+
label: `${prefix}${entry.label ?? entry.id}`,
|
|
628
|
+
description: parts.join(" · "),
|
|
629
|
+
enterable: true
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
case "diagnostic": return {
|
|
633
|
+
id: entry.id,
|
|
634
|
+
depth: entry.depth,
|
|
635
|
+
label: `${prefix}${entry.id}`,
|
|
636
|
+
description: entry.reason,
|
|
637
|
+
enterable: false
|
|
638
|
+
};
|
|
639
|
+
default: return assertNever(entry, "tui subagent list entry");
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Every session-backed subagent below one session in pre-order as picker rows.
|
|
644
|
+
* @param ctx - plugin context carrying the optional subagent runtime.
|
|
645
|
+
* @param sessionId - the root session whose descendants are listed.
|
|
646
|
+
* @param signal - cancels the listing.
|
|
647
|
+
* @returns one choice per descendant in listing order, empty when the session has no subagents.
|
|
648
|
+
* @throws {Error} when no subagent runtime is mounted.
|
|
649
|
+
*/
|
|
650
|
+
async function listSubagentChoices(ctx, sessionId, signal) {
|
|
651
|
+
const subagents = ctx.get("subagents");
|
|
652
|
+
if (subagents === void 0) throw new Error("subagents are not mounted in this profile");
|
|
653
|
+
return (await subagents.listDescendants(sessionId, signal)).map(subagentChoice);
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* The rows shown when the user enters one subagent picker row: the row's own
|
|
657
|
+
* label and description, the session's creation time and workspace, its
|
|
658
|
+
* title, its turn outline, and the files it presented. Turns and presented
|
|
659
|
+
* files each stop at {@link SUBAGENT_DETAIL_LIMIT} entries and fold the rest
|
|
660
|
+
* into one counting row; a fact this profile keeps no projection or header
|
|
661
|
+
* field for is skipped.
|
|
662
|
+
*
|
|
663
|
+
* Reading the session never throws here: a row that is not `enterable`, an
|
|
664
|
+
* absent session query engine, and a failed read each return explanatory
|
|
665
|
+
* rows instead.
|
|
666
|
+
* @param ctx - plugin context carrying the optional session query engine.
|
|
667
|
+
* @param choice - the entered picker row.
|
|
668
|
+
* @param signal - cancels a cold log read.
|
|
669
|
+
* @returns the detail rows; never empty.
|
|
670
|
+
*/
|
|
671
|
+
async function subagentDetail(ctx, choice, signal) {
|
|
672
|
+
if (!choice.enterable) return [choice.id, `unreadable subagent session: ${choice.description}`];
|
|
673
|
+
const query = ctx.get("sessionQuery");
|
|
674
|
+
if (query === void 0) return [`cannot read ${choice.id}: the session query engine is not mounted in this profile`];
|
|
675
|
+
try {
|
|
676
|
+
const env_1 = {
|
|
677
|
+
stack: [],
|
|
678
|
+
error: void 0,
|
|
679
|
+
hasError: false
|
|
680
|
+
};
|
|
681
|
+
try {
|
|
682
|
+
const observation = __addDisposableResource$1(env_1, await query.observeSession(choice.id, {
|
|
683
|
+
signal,
|
|
684
|
+
projectionMode: "all"
|
|
685
|
+
}), false);
|
|
686
|
+
const rows = [choice.label.slice(indent(choice.depth - 1).length), choice.description];
|
|
687
|
+
rows.push(`created: ${formatTimestamp(observation.header.createdAt)}`);
|
|
688
|
+
const { cwd } = observation.header;
|
|
689
|
+
if (cwd !== void 0) rows.push(`workspace: ${cwd}`);
|
|
690
|
+
const values = observation.projections?.values;
|
|
691
|
+
const title = values?.title;
|
|
692
|
+
if (title !== void 0 && title !== null) rows.push(`title: ${title}`);
|
|
693
|
+
rows.push(...foldDetailRows(values?.turnOutline ?? [], outlineRows, "turn"));
|
|
694
|
+
rows.push(...foldDetailRows(presentedPaths(observation.events), (path) => [`presented: ${path}`], "file"));
|
|
695
|
+
return rows;
|
|
696
|
+
} catch (e_1) {
|
|
697
|
+
env_1.error = e_1;
|
|
698
|
+
env_1.hasError = true;
|
|
699
|
+
} finally {
|
|
700
|
+
__disposeResources$1(env_1);
|
|
701
|
+
}
|
|
702
|
+
} catch (error) {
|
|
703
|
+
return [`cannot read ${choice.id}: ${describeFailure(error)}`];
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
/** Every presented path in log order, one entry per file of every `deliverables/presented` event. */
|
|
707
|
+
function presentedPaths(events) {
|
|
708
|
+
const paths = [];
|
|
709
|
+
for (const event of events) {
|
|
710
|
+
if (event.type !== "deliverables/presented") continue;
|
|
711
|
+
for (const file of event.data.files) paths.push(file.path);
|
|
712
|
+
}
|
|
713
|
+
return paths;
|
|
714
|
+
}
|
|
715
|
+
/** The leading entries rendered in full, then one row counting the entries left out. */
|
|
716
|
+
function foldDetailRows(entries, render, noun) {
|
|
717
|
+
const rows = entries.slice(0, SUBAGENT_DETAIL_LIMIT).flatMap(render);
|
|
718
|
+
const hidden = Math.max(0, entries.length - SUBAGENT_DETAIL_LIMIT);
|
|
719
|
+
if (hidden > 0) rows.push(`… ${String(hidden)} more ${noun}${hidden === 1 ? "" : "s"}`);
|
|
720
|
+
return rows;
|
|
721
|
+
}
|
|
722
|
+
/** One outline entry: the numbered prompt, then the indented response once the turn ended with text. */
|
|
723
|
+
function outlineRows(entry) {
|
|
724
|
+
const rows = [`${String(entry.turn)}. ${entry.prompt === "" ? "(no prompt)" : entry.prompt}`];
|
|
725
|
+
if (entry.response !== "") rows.push(`${indent(1)}→ ${entry.response}`);
|
|
726
|
+
return rows;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Files the model declared through the present tool, folded from the
|
|
730
|
+
* session's `deliverables/presented` events and grouped under one `turn N`
|
|
731
|
+
* heading per turn in log order.
|
|
732
|
+
* @param ctx - plugin context carrying the optional session query engine.
|
|
733
|
+
* @param sessionId - the session whose log is read.
|
|
734
|
+
* @param signal - cancels a cold log read.
|
|
735
|
+
* @returns the heading and file rows, empty when nothing was presented.
|
|
736
|
+
* @throws {Error} when no session query engine is mounted.
|
|
737
|
+
*/
|
|
738
|
+
async function listDeliverables(ctx, sessionId, signal) {
|
|
739
|
+
const env_2 = {
|
|
740
|
+
stack: [],
|
|
741
|
+
error: void 0,
|
|
742
|
+
hasError: false
|
|
743
|
+
};
|
|
744
|
+
try {
|
|
745
|
+
const query = ctx.get("sessionQuery");
|
|
746
|
+
if (query === void 0) throw new Error("the session query engine is not mounted in this profile");
|
|
747
|
+
const observation = __addDisposableResource$1(env_2, await query.observeSession(sessionId, {
|
|
748
|
+
signal,
|
|
749
|
+
projectionMode: "none"
|
|
750
|
+
}), false);
|
|
751
|
+
const turns = /* @__PURE__ */ new Map();
|
|
752
|
+
for (const event of observation.events) {
|
|
753
|
+
if (event.type !== "deliverables/presented") continue;
|
|
754
|
+
const files = turns.get(event.data.turn) ?? [];
|
|
755
|
+
files.push(...event.data.files);
|
|
756
|
+
turns.set(event.data.turn, files);
|
|
757
|
+
}
|
|
758
|
+
const rows = [];
|
|
759
|
+
for (const [turn, files] of turns) {
|
|
760
|
+
rows.push(`turn ${String(turn)}`);
|
|
761
|
+
for (const file of files) rows.push(`${indent(1)}${file.path}${file.description === void 0 ? "" : ` ${file.description}`}`);
|
|
762
|
+
}
|
|
763
|
+
return rows;
|
|
764
|
+
} catch (e_2) {
|
|
765
|
+
env_2.error = e_2;
|
|
766
|
+
env_2.hasError = true;
|
|
767
|
+
} finally {
|
|
768
|
+
__disposeResources$1(env_2);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* The session's turn outline: one numbered row per started turn with its
|
|
773
|
+
* prompt preview, followed by an indented response preview once the turn
|
|
774
|
+
* ended with assistant text.
|
|
775
|
+
* @param ctx - plugin context carrying the optional projection registry.
|
|
776
|
+
* @param session - the live session whose outline is read.
|
|
777
|
+
* @returns the rows, empty before the first turn starts.
|
|
778
|
+
* @throws {Error} when no projection registry is mounted or the `turnOutline` unit is not registered.
|
|
779
|
+
*/
|
|
780
|
+
function sessionOutline(ctx, session) {
|
|
781
|
+
const projections = ctx.get("sessionProjections");
|
|
782
|
+
if (projections === void 0) throw new Error("session projections are not mounted in this profile");
|
|
783
|
+
const outline = projections.snapshot(session, ["turnOutline"]).values.turnOutline;
|
|
784
|
+
if (outline === void 0) throw new Error("the turnOutline projection is not registered in this profile");
|
|
785
|
+
return outline.flatMap(outlineRows);
|
|
786
|
+
}
|
|
787
|
+
/** Control Sequence Introducer. */
|
|
788
|
+
const CSI = "\x1B[";
|
|
789
|
+
/**
|
|
790
|
+
* Ends the foreground color a recolored run set. SGR 39 restores the default
|
|
791
|
+
* foreground only: bold, italic, underline, inverse, and the background set by
|
|
792
|
+
* enclosing markdown styling stay in force.
|
|
793
|
+
*/
|
|
794
|
+
const RESET_FOREGROUND = `${CSI}39m`;
|
|
795
|
+
/** Faint intensity: the only ramp level the two-level mode draws. */
|
|
796
|
+
const DIM = `${CSI}2m`;
|
|
797
|
+
/** Ends faint intensity. SGR 22 also ends bold, which shares the code. */
|
|
798
|
+
const RESET_INTENSITY = `${CSI}22m`;
|
|
799
|
+
/** Ages the two-level mode draws faint; later ages draw as rendered. */
|
|
800
|
+
const DIM_AGES = 2;
|
|
801
|
+
/** First xterm 256-color grayscale index. */
|
|
802
|
+
const GRAY_FIRST_INDEX = 232;
|
|
803
|
+
/** Grayscale levels the indices 232..255 carry. */
|
|
804
|
+
const GRAY_LEVELS = 24;
|
|
805
|
+
/** Gray value index 232 carries. */
|
|
806
|
+
const GRAY_FIRST_VALUE = 8;
|
|
807
|
+
/** Gray value step between consecutive grayscale indices. */
|
|
808
|
+
const GRAY_VALUE_STEP = 10;
|
|
809
|
+
/** Relative-luminance weights used to pick the nearest gray. */
|
|
810
|
+
const LUMINANCE = {
|
|
811
|
+
r: .2126,
|
|
812
|
+
g: .7152,
|
|
813
|
+
b: .0722
|
|
814
|
+
};
|
|
815
|
+
/** Largest value one color channel encodes. */
|
|
816
|
+
const CHANNEL_MAX = 255;
|
|
817
|
+
/**
|
|
818
|
+
* Grapheme segmenter for the reverse column walk. pi-tui keeps its own
|
|
819
|
+
* segmenter private, so this module holds one; grapheme segmentation does not
|
|
820
|
+
* vary by locale.
|
|
821
|
+
*/
|
|
822
|
+
const GRAPHEMES = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
823
|
+
/**
|
|
824
|
+
* Decide how far the terminal can draw the ramp.
|
|
825
|
+
*
|
|
826
|
+
* Reduced motion, a disabled palette, a non-empty `NO_COLOR`, and `TERM=dumb`
|
|
827
|
+
* each yield `none`, which draws every chunk at the foreground with no ramp.
|
|
828
|
+
* Otherwise `COLORTERM` of `truecolor` or `24bit` yields `truecolor`, a `TERM`
|
|
829
|
+
* naming `256color` yields `ansi256`, and every other terminal falls back to
|
|
830
|
+
* the two-level `dim` mode.
|
|
831
|
+
* @param input - the palette flag, the environment, and the reduced-motion preference.
|
|
832
|
+
* @returns the capability {@link fadeSgr} and {@link recolorTail} encode under.
|
|
833
|
+
*/
|
|
834
|
+
function resolveFadeCapability(input) {
|
|
835
|
+
const { env } = input;
|
|
836
|
+
if (input.reducedMotion || !input.paletteEnabled) return "none";
|
|
837
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return "none";
|
|
838
|
+
const term = env.TERM ?? "";
|
|
839
|
+
if (term === "dumb") return "none";
|
|
840
|
+
const colorterm = (env.COLORTERM ?? "").toLowerCase();
|
|
841
|
+
if (colorterm === "truecolor" || colorterm === "24bit") return "truecolor";
|
|
842
|
+
return term.includes("256color") ? "ansi256" : "dim";
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Build the brightness ramp a chunk climbs, `ramp[k] = lerp(bg, fg, (k + 1) /
|
|
846
|
+
* steps)` in sRGB with each channel rounded to a byte. The last level is a
|
|
847
|
+
* copy of `fg` rather than a computed value, so settled text and the last
|
|
848
|
+
* faded frame carry identical color.
|
|
849
|
+
* @param bg - the terminal background color.
|
|
850
|
+
* @param fg - the normal foreground color.
|
|
851
|
+
* @param steps - brightness levels; defaults to {@link FADE_STEPS}.
|
|
852
|
+
* @returns `steps` levels, darkest first.
|
|
853
|
+
*/
|
|
854
|
+
function buildFadeRamp(bg, fg, steps = 5) {
|
|
855
|
+
return Array.from({ length: steps }, (_unused, level) => {
|
|
856
|
+
if (level === steps - 1) return {
|
|
857
|
+
r: fg.r,
|
|
858
|
+
g: fg.g,
|
|
859
|
+
b: fg.b
|
|
860
|
+
};
|
|
861
|
+
const ratio = (level + 1) / steps;
|
|
862
|
+
return {
|
|
863
|
+
r: mix(bg.r, fg.r, ratio),
|
|
864
|
+
g: mix(bg.g, fg.g, ratio),
|
|
865
|
+
b: mix(bg.b, fg.b, ratio)
|
|
866
|
+
};
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Interpolate one channel.
|
|
871
|
+
* @param from - the background channel.
|
|
872
|
+
* @param to - the foreground channel.
|
|
873
|
+
* @param ratio - position between them, 0 at `from` and 1 at `to`.
|
|
874
|
+
* @returns the channel value rounded to a byte.
|
|
875
|
+
*/
|
|
876
|
+
function mix(from, to, ratio) {
|
|
877
|
+
return Math.round(from + (to - from) * ratio);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* The SGR sequence one age draws under.
|
|
881
|
+
* @param style - the capability and the ramp.
|
|
882
|
+
* @param age - ticks since the chunk arrived; ages past the ramp draw its last level.
|
|
883
|
+
* @returns the sequence to open the run with, or the empty string when the
|
|
884
|
+
* chunk draws as the component rendered it, which is also what an empty ramp
|
|
885
|
+
* yields under a color capability.
|
|
886
|
+
*/
|
|
887
|
+
function fadeSgr(style, age) {
|
|
888
|
+
if (style.capability === "none") return "";
|
|
889
|
+
if (style.capability === "dim") return age < DIM_AGES ? DIM : "";
|
|
890
|
+
const level = style.ramp[Math.max(0, Math.min(age, style.ramp.length - 1))];
|
|
891
|
+
if (level === void 0) return "";
|
|
892
|
+
return style.capability === "truecolor" ? truecolorSgr(level) : grayscaleSgr(level);
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Encode one level as a 24-bit foreground.
|
|
896
|
+
* @param color - the ramp level.
|
|
897
|
+
* @returns `ESC[38;2;R;G;Bm` with each channel clamped to a byte.
|
|
898
|
+
*/
|
|
899
|
+
function truecolorSgr(color) {
|
|
900
|
+
return `${CSI}38;2;${channel(color.r)};${channel(color.g)};${channel(color.b)}m`;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Encode one level as the nearest xterm grayscale index.
|
|
904
|
+
* @param color - the ramp level.
|
|
905
|
+
* @returns `ESC[38;5;Nm` with N in 232..255.
|
|
906
|
+
*/
|
|
907
|
+
function grayscaleSgr(color) {
|
|
908
|
+
const luminance = LUMINANCE.r * color.r + LUMINANCE.g * color.g + LUMINANCE.b * color.b;
|
|
909
|
+
const step = Math.round((luminance - GRAY_FIRST_VALUE) / GRAY_VALUE_STEP);
|
|
910
|
+
return `${CSI}38;5;${GRAY_FIRST_INDEX + Math.max(0, Math.min(GRAY_LEVELS - 1, step))}m`;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Clamp one channel to what an SGR byte carries.
|
|
914
|
+
* @param value - the channel value.
|
|
915
|
+
* @returns an integer in 0..255.
|
|
916
|
+
*/
|
|
917
|
+
function channel(value) {
|
|
918
|
+
return Math.max(0, Math.min(CHANNEL_MAX, Math.round(value)));
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* The sequence that ends a recolored line.
|
|
922
|
+
* @param capability - the encoding the runs were opened with.
|
|
923
|
+
* @returns `ESC[22m` for the two-level mode, which set intensity, and
|
|
924
|
+
* `ESC[39m` for the color modes, which set a foreground.
|
|
925
|
+
*/
|
|
926
|
+
function restoreFor(capability) {
|
|
927
|
+
return capability === "dim" ? RESET_INTENSITY : RESET_FOREGROUND;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* The tail of one streaming assistant message: the chunks young enough to be
|
|
931
|
+
* recolored, advanced one tick at a time.
|
|
932
|
+
*
|
|
933
|
+
* Deltas are split at word boundaries, which reads more smoothly than raw
|
|
934
|
+
* token edges. A delta that ends mid-word leaves that word open, and the next
|
|
935
|
+
* delta extends it rather than starting a second chunk, so the word keeps the
|
|
936
|
+
* tick it first became visible on.
|
|
937
|
+
*
|
|
938
|
+
* Fast streams. While each of the last {@link FADE_FAST_WINDOW_TICKS}
|
|
939
|
+
* completed ticks received at least one chunk, the stream outruns the ramp and
|
|
940
|
+
* the effect turns off: the arriving chunk is not tracked and the whole tail is
|
|
941
|
+
* flushed, so nothing already on screen darkens. The decision is recoverable:
|
|
942
|
+
* one tick without an arrival ends it, and chunks appended afterwards fade
|
|
943
|
+
* again. Recovery only affects new chunks, so it never re-darkens settled text.
|
|
944
|
+
*/
|
|
945
|
+
var FadeTracker = class {
|
|
946
|
+
steps;
|
|
947
|
+
fastWindowTicks;
|
|
948
|
+
now = 0;
|
|
949
|
+
chunks = [];
|
|
950
|
+
openChunk = void 0;
|
|
951
|
+
arrivals = /* @__PURE__ */ new Set();
|
|
952
|
+
constructor(options) {
|
|
953
|
+
this.steps = options?.steps ?? 5;
|
|
954
|
+
this.fastWindowTicks = options?.fastWindowTicks ?? 10;
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Take one stream event.
|
|
958
|
+
* @param delta - the text delta; the empty string is not a stream event and is ignored.
|
|
959
|
+
*/
|
|
960
|
+
append(delta) {
|
|
961
|
+
if (delta === "") return;
|
|
962
|
+
this.arrivals.add(this.now);
|
|
963
|
+
if (this.isFastStream()) {
|
|
964
|
+
this.flush();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const open = this.openChunk;
|
|
968
|
+
const text = open === void 0 ? delta : open.text + delta;
|
|
969
|
+
const born = open === void 0 ? this.now : open.born;
|
|
970
|
+
if (open !== void 0) this.chunks.pop();
|
|
971
|
+
this.openChunk = void 0;
|
|
972
|
+
const words = text.match(/\s*\S+\s*/g);
|
|
973
|
+
if (words === null) {
|
|
974
|
+
this.openChunk = {
|
|
975
|
+
text,
|
|
976
|
+
born
|
|
977
|
+
};
|
|
978
|
+
this.chunks.push(this.openChunk);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
for (const [index, word] of words.entries()) this.chunks.push({
|
|
982
|
+
text: word,
|
|
983
|
+
born: index === 0 ? born : this.now
|
|
984
|
+
});
|
|
985
|
+
if (!/\s$/.test(text)) this.openChunk = this.chunks.at(-1);
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Advance one tick and drop the chunks that reached `steps`.
|
|
989
|
+
* @returns whether this tick changed a chunk's color, and so needs a repaint.
|
|
990
|
+
*/
|
|
991
|
+
tick() {
|
|
992
|
+
const changing = this.needsRepaint();
|
|
993
|
+
this.now += 1;
|
|
994
|
+
this.chunks = this.chunks.filter((chunk) => this.now - chunk.born < this.steps);
|
|
995
|
+
if (this.openChunk !== void 0 && !this.chunks.includes(this.openChunk)) this.openChunk = void 0;
|
|
996
|
+
for (const arrival of this.arrivals) if (arrival < this.now - this.fastWindowTicks) this.arrivals.delete(arrival);
|
|
997
|
+
return changing;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Whether the current frame still differs from the settled rendering.
|
|
1001
|
+
* @returns true while a tracked chunk draws below the last ramp level, which
|
|
1002
|
+
* is the only reason to keep ticking.
|
|
1003
|
+
*/
|
|
1004
|
+
needsRepaint() {
|
|
1005
|
+
return this.chunks.some((chunk) => this.now - chunk.born < this.steps - 1);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* The tail {@link recolorTail} recolors.
|
|
1009
|
+
* @returns one span per tracked chunk, oldest first, each with its current age.
|
|
1010
|
+
*/
|
|
1011
|
+
spans() {
|
|
1012
|
+
return this.chunks.map((chunk) => ({
|
|
1013
|
+
text: chunk.text,
|
|
1014
|
+
age: this.now - chunk.born
|
|
1015
|
+
}));
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Drop the whole tail so every chunk drawn so far renders at the foreground,
|
|
1019
|
+
* and start tracking again from the next delta. The application calls this on
|
|
1020
|
+
* a terminal width change and at stream end. The fast-stream window is kept:
|
|
1021
|
+
* a resize says nothing about the arrival rate.
|
|
1022
|
+
*/
|
|
1023
|
+
flush() {
|
|
1024
|
+
this.chunks = [];
|
|
1025
|
+
this.openChunk = void 0;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The fast-stream test.
|
|
1029
|
+
* @returns true while every completed tick of the window received at least one chunk.
|
|
1030
|
+
*/
|
|
1031
|
+
isFastStream() {
|
|
1032
|
+
let covered = 0;
|
|
1033
|
+
for (let tick = this.now - this.fastWindowTicks; tick < this.now; tick += 1) if (this.arrivals.has(tick)) covered += 1;
|
|
1034
|
+
return covered >= this.fastWindowTicks;
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
/**
|
|
1038
|
+
* Recolor the tail inside lines a component already rendered.
|
|
1039
|
+
*
|
|
1040
|
+
* The walk runs backwards over visible columns only: escape sequences, wide
|
|
1041
|
+
* characters, and grapheme clusters are stepped over as units, never split.
|
|
1042
|
+
* Whitespace is skipped on both sides while matching, so a chunk still matches
|
|
1043
|
+
* after the renderer turned its space into a line break or trimmed it.
|
|
1044
|
+
*
|
|
1045
|
+
* Markdown rewrites text, so a chunk may not appear in the rendered output at
|
|
1046
|
+
* all. Matching then stops: that chunk and every older chunk of the tail draw
|
|
1047
|
+
* at the foreground, unchanged. Because older chunks are already the brightest
|
|
1048
|
+
* levels, the degradation is the least visible one available.
|
|
1049
|
+
*
|
|
1050
|
+
* Styling inside a recolored run survives. The run reasserts the sequences in
|
|
1051
|
+
* force at its start, so an enclosing bold or italic continues across it; an
|
|
1052
|
+
* enclosing foreground color reasserted there wins over the ramp, and that run
|
|
1053
|
+
* simply does not fade. Each recolored line ends with `ESC[39m` (or `ESC[22m`
|
|
1054
|
+
* in the two-level mode, which also ends bold) after the line's own closing
|
|
1055
|
+
* sequences, so the recolor never leaks past the line it was applied to.
|
|
1056
|
+
* @param lines - the rendered lines of the streaming block, newest text last.
|
|
1057
|
+
* @param spans - the tail from {@link FadeTracker.spans}, oldest first.
|
|
1058
|
+
* @param style - the capability and the ramp.
|
|
1059
|
+
* @returns the lines with the tail recolored; lines the tail does not cover
|
|
1060
|
+
* are returned byte-identical, so the renderer leaves them alone.
|
|
1061
|
+
*/
|
|
1062
|
+
function recolorTail(lines, spans, style) {
|
|
1063
|
+
if (style.capability === "none" || spans.length === 0 || lines.length === 0) return [...lines];
|
|
1064
|
+
const cells = cellsFromEnd(lines);
|
|
1065
|
+
const runs = [];
|
|
1066
|
+
for (const span of [...spans].reverse()) {
|
|
1067
|
+
const covered = consumeSpan(cells, span.text);
|
|
1068
|
+
if (covered === void 0) break;
|
|
1069
|
+
const sgr = fadeSgr(style, span.age);
|
|
1070
|
+
if (sgr !== "") collectRuns(runs, covered, sgr);
|
|
1071
|
+
}
|
|
1072
|
+
const byLine = /* @__PURE__ */ new Map();
|
|
1073
|
+
for (const run of runs) {
|
|
1074
|
+
const known = byLine.get(run.line);
|
|
1075
|
+
if (known === void 0) byLine.set(run.line, [run]);
|
|
1076
|
+
else known.unshift(run);
|
|
1077
|
+
}
|
|
1078
|
+
const restore = restoreFor(style.capability);
|
|
1079
|
+
return lines.map((text, index) => {
|
|
1080
|
+
const lineRuns = byLine.get(index);
|
|
1081
|
+
return lineRuns === void 0 ? text : paintLine(text, lineRuns, restore);
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Walk the rendered lines backwards, one visible grapheme at a time. Lazy: a
|
|
1086
|
+
* line is stripped and segmented only once the walk reaches it, so a long
|
|
1087
|
+
* settled message costs nothing beyond the lines the tail touches.
|
|
1088
|
+
* @param lines - the rendered lines.
|
|
1089
|
+
* @returns cells from the last column of the last line towards the first.
|
|
1090
|
+
*/
|
|
1091
|
+
function* cellsFromEnd(lines) {
|
|
1092
|
+
for (const [line, text] of [...lines.entries()].reverse()) {
|
|
1093
|
+
const cells = [];
|
|
1094
|
+
let column = 0;
|
|
1095
|
+
for (const part of GRAPHEMES.segment(stripTerminalSequences(text))) {
|
|
1096
|
+
const width = visibleWidth(part.segment);
|
|
1097
|
+
cells.push({
|
|
1098
|
+
line,
|
|
1099
|
+
column,
|
|
1100
|
+
width,
|
|
1101
|
+
grapheme: part.segment
|
|
1102
|
+
});
|
|
1103
|
+
column += width;
|
|
1104
|
+
}
|
|
1105
|
+
yield* cells.reverse();
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Take the cells one chunk covers off the walk.
|
|
1110
|
+
* @param cells - the backwards walk, positioned at the end of the region still unclaimed.
|
|
1111
|
+
* @param text - the chunk's text as it was appended.
|
|
1112
|
+
* @returns the cells the chunk covers, whitespace included, or undefined when
|
|
1113
|
+
* the chunk's visible characters are not there.
|
|
1114
|
+
*/
|
|
1115
|
+
function consumeSpan(cells, text) {
|
|
1116
|
+
const wanted = Array.from(GRAPHEMES.segment(text), (part) => part.segment).filter((part) => !isBlank(part)).reverse();
|
|
1117
|
+
const covered = [];
|
|
1118
|
+
for (const want of wanted) {
|
|
1119
|
+
let step = cells.next();
|
|
1120
|
+
while (!step.done && isBlank(step.value.grapheme)) {
|
|
1121
|
+
covered.push(step.value);
|
|
1122
|
+
step = cells.next();
|
|
1123
|
+
}
|
|
1124
|
+
if (step.done || step.value.grapheme !== want) return void 0;
|
|
1125
|
+
covered.push(step.value);
|
|
1126
|
+
}
|
|
1127
|
+
return covered;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Whether a grapheme draws nothing.
|
|
1131
|
+
* @param grapheme - one grapheme cluster.
|
|
1132
|
+
* @returns true for whitespace.
|
|
1133
|
+
*/
|
|
1134
|
+
function isBlank(grapheme) {
|
|
1135
|
+
return grapheme.trim() === "";
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Turn covered cells into runs, merging each cell into the run to its right
|
|
1139
|
+
* when they touch on the same line under the same sequence.
|
|
1140
|
+
* @param runs - runs collected so far, in the walk's right-to-left order.
|
|
1141
|
+
* @param covered - the cells one chunk covers.
|
|
1142
|
+
* @param sgr - the sequence the chunk draws under.
|
|
1143
|
+
*/
|
|
1144
|
+
function collectRuns(runs, covered, sgr) {
|
|
1145
|
+
for (const cell of covered) {
|
|
1146
|
+
const previous = runs.at(-1);
|
|
1147
|
+
if (previous !== void 0 && previous.line === cell.line && previous.sgr === sgr && previous.start === cell.column + cell.width) previous.start = cell.column;
|
|
1148
|
+
else runs.push({
|
|
1149
|
+
line: cell.line,
|
|
1150
|
+
start: cell.column,
|
|
1151
|
+
end: cell.column + cell.width,
|
|
1152
|
+
sgr
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Rebuild one line with its runs recolored.
|
|
1158
|
+
* @param text - the rendered line.
|
|
1159
|
+
* @param runs - the line's runs, in column order.
|
|
1160
|
+
* @param restore - the sequence that ends the last run.
|
|
1161
|
+
* @returns the line with each run opened by its sequence, the text between
|
|
1162
|
+
* runs untouched, and the line's own trailing sequences kept ahead of `restore`.
|
|
1163
|
+
*/
|
|
1164
|
+
function paintLine(text, runs, restore) {
|
|
1165
|
+
let out = "";
|
|
1166
|
+
let cursor = 0;
|
|
1167
|
+
for (const run of runs) {
|
|
1168
|
+
out += sliceByColumn(text, cursor, run.start - cursor);
|
|
1169
|
+
out += run.sgr + sliceByColumn(text, run.start, run.end - run.start);
|
|
1170
|
+
cursor = run.end;
|
|
1171
|
+
}
|
|
1172
|
+
return `${out}${sliceByColumn(text, cursor, text.length + 1)}${restore}`;
|
|
1173
|
+
}
|
|
1174
|
+
//#endregion
|
|
1175
|
+
//#region lib/types/style.js
|
|
1176
|
+
/**
|
|
1177
|
+
* The terminal palette: one table of SGR open/close pairs behind named roles,
|
|
1178
|
+
* so components never emit raw escape codes. A disabled palette returns text
|
|
1179
|
+
* unchanged for dumb terminals and captured output.
|
|
1180
|
+
* @module @deepseek-ai/dsh-tui-app/style
|
|
1181
|
+
*/
|
|
1182
|
+
const ESC = "\x1B[";
|
|
1183
|
+
/** SGR open/close code pairs by role. */
|
|
1184
|
+
const SGR = {
|
|
1185
|
+
dim: ["2", "22"],
|
|
1186
|
+
bold: ["1", "22"],
|
|
1187
|
+
italic: ["3", "23"],
|
|
1188
|
+
underline: ["4", "24"],
|
|
1189
|
+
accent: ["36", "39"],
|
|
1190
|
+
success: ["32", "39"],
|
|
1191
|
+
warning: ["33", "39"],
|
|
1192
|
+
error: ["31", "39"],
|
|
1193
|
+
inverse: ["7", "27"]
|
|
1194
|
+
};
|
|
1195
|
+
/**
|
|
1196
|
+
* Build the palette.
|
|
1197
|
+
* @param enabled - whether escape sequences are emitted; false makes every role the identity.
|
|
1198
|
+
* @returns the palette.
|
|
1199
|
+
*/
|
|
1200
|
+
function createPalette(enabled) {
|
|
1201
|
+
const role = (name) => {
|
|
1202
|
+
const [open, close] = SGR[name];
|
|
1203
|
+
return enabled ? (text) => `${ESC}${open}m${text}${ESC}${close}m` : (text) => text;
|
|
1204
|
+
};
|
|
1205
|
+
return {
|
|
1206
|
+
dim: role("dim"),
|
|
1207
|
+
bold: role("bold"),
|
|
1208
|
+
italic: role("italic"),
|
|
1209
|
+
underline: role("underline"),
|
|
1210
|
+
accent: role("accent"),
|
|
1211
|
+
success: role("success"),
|
|
1212
|
+
warning: role("warning"),
|
|
1213
|
+
error: role("error"),
|
|
1214
|
+
inverse: role("inverse"),
|
|
1215
|
+
enabled
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Decide whether the terminal gets color: `NO_COLOR` (any non-empty value)
|
|
1220
|
+
* wins, then a non-empty non-zero `FORCE_COLOR`, then whether stdout is a TTY.
|
|
1221
|
+
* @param env - the process environment.
|
|
1222
|
+
* @param isTty - whether stdout is a terminal.
|
|
1223
|
+
* @returns true when SGR styling should be emitted.
|
|
1224
|
+
*/
|
|
1225
|
+
function colorEnabled(env, isTty) {
|
|
1226
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
1227
|
+
if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
|
|
1228
|
+
return isTty;
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* The Markdown theme derived from the palette.
|
|
1232
|
+
* @param palette - the active palette.
|
|
1233
|
+
* @returns a complete pi-tui Markdown theme.
|
|
1234
|
+
*/
|
|
1235
|
+
function markdownTheme(palette) {
|
|
1236
|
+
return {
|
|
1237
|
+
heading: (text) => palette.bold(palette.accent(text)),
|
|
1238
|
+
link: palette.accent,
|
|
1239
|
+
linkUrl: palette.dim,
|
|
1240
|
+
code: palette.warning,
|
|
1241
|
+
codeBlock: (text) => text,
|
|
1242
|
+
codeBlockBorder: palette.dim,
|
|
1243
|
+
quote: palette.italic,
|
|
1244
|
+
quoteBorder: palette.dim,
|
|
1245
|
+
hr: palette.dim,
|
|
1246
|
+
listBullet: palette.accent,
|
|
1247
|
+
bold: palette.bold,
|
|
1248
|
+
italic: palette.italic,
|
|
1249
|
+
strikethrough: palette.dim,
|
|
1250
|
+
underline: palette.underline
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* The select-list theme derived from the palette.
|
|
1255
|
+
* @param palette - the active palette.
|
|
1256
|
+
* @returns a complete pi-tui select-list theme.
|
|
1257
|
+
*/
|
|
1258
|
+
function selectListTheme(palette) {
|
|
1259
|
+
return {
|
|
1260
|
+
selectedPrefix: palette.accent,
|
|
1261
|
+
selectedText: (text) => palette.bold(palette.accent(text)),
|
|
1262
|
+
description: palette.dim,
|
|
1263
|
+
scrollInfo: palette.dim,
|
|
1264
|
+
noMatch: palette.dim
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* The editor theme derived from the palette.
|
|
1269
|
+
* @param palette - the active palette.
|
|
1270
|
+
* @returns a complete pi-tui editor theme.
|
|
1271
|
+
*/
|
|
1272
|
+
function editorTheme(palette) {
|
|
1273
|
+
return {
|
|
1274
|
+
borderColor: palette.dim,
|
|
1275
|
+
selectList: selectListTheme(palette)
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
//#endregion
|
|
1279
|
+
//#region lib/types/blocks.js
|
|
1280
|
+
/**
|
|
1281
|
+
* Transcript components: one pi-tui component per rendered fact (a user
|
|
1282
|
+
* prompt, an assistant reply, a tool card, a notice). Each owns its display
|
|
1283
|
+
* state and re-renders from it at any width.
|
|
1284
|
+
* @module @deepseek-ai/dsh-tui-app/blocks
|
|
1285
|
+
*/
|
|
1286
|
+
/** A prompt the user submitted, drawn with a leading `›`. */
|
|
1287
|
+
var UserBlock = class {
|
|
1288
|
+
theme;
|
|
1289
|
+
text;
|
|
1290
|
+
constructor(theme, text) {
|
|
1291
|
+
this.theme = theme;
|
|
1292
|
+
this.text = text;
|
|
1293
|
+
}
|
|
1294
|
+
invalidate() {}
|
|
1295
|
+
render(width) {
|
|
1296
|
+
const palette = this.theme.palette;
|
|
1297
|
+
return ["", ...wrapTextWithAnsi(this.text, Math.max(1, width - 2)).map((line, index) => `${palette.accent(index === 0 ? "›" : " ")} ${palette.bold(line)}`)];
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
/** A dim one-line notice about the session (a stopped turn, a command result, a model switch). */
|
|
1301
|
+
var NoticeBlock = class {
|
|
1302
|
+
text;
|
|
1303
|
+
constructor(theme, text, tone = "dim") {
|
|
1304
|
+
const palette = theme.palette;
|
|
1305
|
+
this.text = new Text(palette[tone](`· ${text}`), 0, 0);
|
|
1306
|
+
}
|
|
1307
|
+
invalidate() {
|
|
1308
|
+
this.text.invalidate();
|
|
1309
|
+
}
|
|
1310
|
+
render(width) {
|
|
1311
|
+
return this.text.render(width);
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
/**
|
|
1315
|
+
* An assistant reply: streamed reasoning above streamed Markdown text. The
|
|
1316
|
+
* durable `assistant/message` replaces both with the committed content.
|
|
1317
|
+
*
|
|
1318
|
+
* A block that is streaming right now can carry a {@link FadeRender}, which
|
|
1319
|
+
* draws its newest text dimmed and brightening. A block rebuilt from history
|
|
1320
|
+
* carries none, and {@link AssistantBlock.commit} drops the one a streaming
|
|
1321
|
+
* block had, so settled text is never recolored.
|
|
1322
|
+
*/
|
|
1323
|
+
var AssistantBlock = class {
|
|
1324
|
+
theme;
|
|
1325
|
+
reasoning = "";
|
|
1326
|
+
text = "";
|
|
1327
|
+
interrupted = false;
|
|
1328
|
+
markdown;
|
|
1329
|
+
reasoningText;
|
|
1330
|
+
fade;
|
|
1331
|
+
/** Width of the last render that drew a tail; absent before the first one. */
|
|
1332
|
+
fadeWidth;
|
|
1333
|
+
constructor(theme) {
|
|
1334
|
+
this.theme = theme;
|
|
1335
|
+
const palette = theme.palette;
|
|
1336
|
+
this.markdown = new Markdown("", 0, 0, markdownTheme(palette));
|
|
1337
|
+
this.reasoningText = new Text("", 0, 0);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Draw this block's newest text through `fade` until it commits.
|
|
1341
|
+
* @param fade - the tail and drawing settings of the running stream.
|
|
1342
|
+
*/
|
|
1343
|
+
setFade(fade) {
|
|
1344
|
+
this.fade = fade;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Append streamed visible text.
|
|
1348
|
+
* @param delta - the text delta.
|
|
1349
|
+
*/
|
|
1350
|
+
appendText(delta) {
|
|
1351
|
+
this.text += delta;
|
|
1352
|
+
this.markdown.setText(this.text);
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Append streamed reasoning text.
|
|
1356
|
+
* @param delta - the reasoning delta.
|
|
1357
|
+
*/
|
|
1358
|
+
appendReasoning(delta) {
|
|
1359
|
+
this.reasoning += delta;
|
|
1360
|
+
this.reasoningText.setText(this.theme.palette.dim(this.theme.palette.italic(this.reasoning.trimEnd())));
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Replace the streamed content with the committed message.
|
|
1364
|
+
* @param text - the committed visible text.
|
|
1365
|
+
* @param reasoning - the committed reasoning text.
|
|
1366
|
+
* @param interrupted - whether the message was cut short.
|
|
1367
|
+
*/
|
|
1368
|
+
commit(text, reasoning, interrupted) {
|
|
1369
|
+
this.text = text;
|
|
1370
|
+
this.reasoning = reasoning;
|
|
1371
|
+
this.interrupted = interrupted;
|
|
1372
|
+
this.fade = void 0;
|
|
1373
|
+
this.markdown.setText(text);
|
|
1374
|
+
this.reasoningText.setText(this.theme.palette.dim(this.theme.palette.italic(reasoning.trimEnd())));
|
|
1375
|
+
}
|
|
1376
|
+
invalidate() {
|
|
1377
|
+
this.markdown.invalidate();
|
|
1378
|
+
this.reasoningText.invalidate();
|
|
1379
|
+
}
|
|
1380
|
+
render(width) {
|
|
1381
|
+
const lines = [""];
|
|
1382
|
+
if (this.reasoning.trim() !== "") lines.push(...this.reasoningText.render(width), "");
|
|
1383
|
+
if (this.text !== "") lines.push(...this.renderText(width));
|
|
1384
|
+
if (this.interrupted) lines.push(this.theme.palette.dim("[interrupted]"));
|
|
1385
|
+
return lines;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* The Markdown lines, with the streaming tail recolored.
|
|
1389
|
+
*
|
|
1390
|
+
* `recolorTail` matches the tail backwards from the end of what it is
|
|
1391
|
+
* given, so it gets the Markdown lines alone: the reasoning above them and
|
|
1392
|
+
* any marker below them would put the newest chunk somewhere other than the
|
|
1393
|
+
* end and drop the whole tail to the plain foreground.
|
|
1394
|
+
*
|
|
1395
|
+
* Only chunks younger than `steps - 1` are handed over. The last ramp level
|
|
1396
|
+
* is an assumed foreground - pi-tui reports the terminal background but not
|
|
1397
|
+
* its foreground - so the oldest visible level is left to draw in the
|
|
1398
|
+
* terminal's own foreground, which is also what the chunk draws in once it
|
|
1399
|
+
* settles. No chunk can therefore jump color as it leaves the tail.
|
|
1400
|
+
* @param width - the width the Markdown lays out in.
|
|
1401
|
+
* @returns the lines to draw.
|
|
1402
|
+
*/
|
|
1403
|
+
renderText(width) {
|
|
1404
|
+
const fade = this.fade;
|
|
1405
|
+
const lines = this.markdown.render(width);
|
|
1406
|
+
if (fade === void 0) return lines;
|
|
1407
|
+
if (this.fadeWidth !== void 0 && this.fadeWidth !== width) fade.flush();
|
|
1408
|
+
this.fadeWidth = width;
|
|
1409
|
+
return recolorTail(lines, fade.spans().filter((span) => span.age < fade.steps - 1), fade.style());
|
|
1410
|
+
}
|
|
1411
|
+
};
|
|
1412
|
+
/** A tool call card: status glyph, tool name, headline, then a foldable body. */
|
|
1413
|
+
var ToolBlock = class {
|
|
1414
|
+
theme;
|
|
1415
|
+
name;
|
|
1416
|
+
call;
|
|
1417
|
+
status = "running";
|
|
1418
|
+
resultLines = [];
|
|
1419
|
+
expanded = false;
|
|
1420
|
+
constructor(theme, name, call) {
|
|
1421
|
+
this.theme = theme;
|
|
1422
|
+
this.name = name;
|
|
1423
|
+
this.call = call;
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Attach the result rows and settle the status.
|
|
1427
|
+
* @param lines - the result rows before preview truncation.
|
|
1428
|
+
* @param isError - whether the tool reported failure.
|
|
1429
|
+
*/
|
|
1430
|
+
setResult(lines, isError) {
|
|
1431
|
+
this.resultLines = lines;
|
|
1432
|
+
this.status = isError ? "error" : "done";
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Fold or unfold the body.
|
|
1436
|
+
* @param expanded - whether the full body is shown.
|
|
1437
|
+
*/
|
|
1438
|
+
setExpanded(expanded) {
|
|
1439
|
+
this.expanded = expanded;
|
|
1440
|
+
}
|
|
1441
|
+
invalidate() {}
|
|
1442
|
+
render(width) {
|
|
1443
|
+
const palette = this.theme.palette;
|
|
1444
|
+
const header = `${this.status === "running" ? palette.warning("●") : this.status === "done" ? palette.success("●") : palette.error("●")} ${palette.bold(this.name)}${this.call.title === "" ? "" : ` ${palette.dim(this.call.title)}`}`;
|
|
1445
|
+
const shown = previewLines([...this.call.lines, ...this.resultLines], this.theme.toolPreviewLines, this.expanded);
|
|
1446
|
+
const inner = Math.max(1, width - 4);
|
|
1447
|
+
const rows = shown.flatMap((line) => wrapTextWithAnsi(line, inner).map((part) => ` ${palette.dim("│")} ${part}`));
|
|
1448
|
+
return [
|
|
1449
|
+
"",
|
|
1450
|
+
...wrapTextWithAnsi(header, width),
|
|
1451
|
+
...rows
|
|
1452
|
+
];
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
//#endregion
|
|
1456
|
+
//#region lib/types/completion.js
|
|
1457
|
+
/**
|
|
1458
|
+
* Editor completion: `/` at the start of the message opens the command list,
|
|
1459
|
+
* and `@` anywhere offers workspace paths and other sessions as references,
|
|
1460
|
+
* inserted in the same mention grammar the browser composer uses.
|
|
1461
|
+
* @module @deepseek-ai/dsh-tui-app/completion
|
|
1462
|
+
*/
|
|
1463
|
+
/**
|
|
1464
|
+
* Build the provider over live sources, so commands and references registered
|
|
1465
|
+
* after startup appear without re-creating the editor.
|
|
1466
|
+
* @param sources - the command and reference sources.
|
|
1467
|
+
* @returns the editor's autocomplete provider.
|
|
1468
|
+
*/
|
|
1469
|
+
function editorCompletion(sources) {
|
|
1470
|
+
return {
|
|
1471
|
+
triggerCharacters: ["/", "@"],
|
|
1472
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
1473
|
+
const line = lines[cursorLine] ?? "";
|
|
1474
|
+
const before = line.slice(0, cursorCol);
|
|
1475
|
+
const at = activeAtToken(line, cursorCol);
|
|
1476
|
+
if (at !== void 0) {
|
|
1477
|
+
const items = (await sources.references(at.query, at.quoted, options.signal)).map((reference) => ({
|
|
1478
|
+
value: reference.mention,
|
|
1479
|
+
label: reference.label,
|
|
1480
|
+
...reference.description === void 0 ? {} : { description: reference.description }
|
|
1481
|
+
}));
|
|
1482
|
+
return items.length === 0 ? null : {
|
|
1483
|
+
items,
|
|
1484
|
+
prefix: at.prefix
|
|
1485
|
+
};
|
|
158
1486
|
}
|
|
159
|
-
if (
|
|
160
|
-
|
|
1487
|
+
if (cursorLine !== 0 || !before.startsWith("/") || /\s/u.test(before)) return null;
|
|
1488
|
+
const prefix = before;
|
|
1489
|
+
const items = sources.commands().filter((command) => `/${command.name}`.startsWith(prefix)).map((command) => ({
|
|
1490
|
+
value: `/${command.name}`,
|
|
1491
|
+
label: `/${command.name}`,
|
|
1492
|
+
description: command.hint === void 0 ? command.description : `${command.description} · ${command.hint}`
|
|
1493
|
+
}));
|
|
1494
|
+
return items.length === 0 ? null : {
|
|
1495
|
+
items,
|
|
1496
|
+
prefix
|
|
1497
|
+
};
|
|
1498
|
+
},
|
|
1499
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
1500
|
+
const line = lines[cursorLine] ?? "";
|
|
1501
|
+
const start = cursorCol - prefix.length;
|
|
1502
|
+
const replaced = `${line.slice(0, start)}${item.value} ${line.slice(cursorCol)}`;
|
|
1503
|
+
const next = [...lines];
|
|
1504
|
+
next[cursorLine] = replaced;
|
|
1505
|
+
return {
|
|
1506
|
+
lines: next,
|
|
1507
|
+
cursorLine,
|
|
1508
|
+
cursorCol: start + item.value.length + 1
|
|
1509
|
+
};
|
|
161
1510
|
}
|
|
162
|
-
return next();
|
|
163
1511
|
};
|
|
164
|
-
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
165
|
-
var e = new Error(message);
|
|
166
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
167
|
-
});
|
|
168
|
-
/** Two-space indentation per nesting level. */
|
|
169
|
-
function indent(depth) {
|
|
170
|
-
return " ".repeat(depth);
|
|
171
|
-
}
|
|
172
|
-
/** The settings service, or a printable error naming its absence. */
|
|
173
|
-
function requireSettings(ctx) {
|
|
174
|
-
const settings = ctx.get("settings");
|
|
175
|
-
if (settings === void 0) throw new Error("settings are not mounted in this profile");
|
|
176
|
-
return settings;
|
|
177
|
-
}
|
|
178
|
-
/** One namespace's redacted descriptor, or a printable error when it is not registered. */
|
|
179
|
-
function requireDescriptor(settings, ns) {
|
|
180
|
-
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
181
|
-
if (descriptor === void 0) throw new Error(`settings namespace "${ns}" is not registered`);
|
|
182
|
-
return descriptor;
|
|
183
1512
|
}
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region lib/types/editor.js
|
|
184
1515
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* @
|
|
190
|
-
* @throws {Error} when no settings service is mounted.
|
|
1516
|
+
* The prompt editor's caret. pi-tui draws the caret cell itself in reverse
|
|
1517
|
+
* video; this module takes that block off the rendered lines and names the
|
|
1518
|
+
* DECSCUSR sequences the application writes so the terminal draws the caret
|
|
1519
|
+
* instead, as a blinking bar.
|
|
1520
|
+
* @module @deepseek-ai/dsh-tui-app/editor
|
|
191
1521
|
*/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
1522
|
+
/**
|
|
1523
|
+
* DECSCUSR `CSI 5 SP q` (`\x1b[5 q`): tell the terminal to draw its text
|
|
1524
|
+
* cursor as a blinking vertical bar.
|
|
1525
|
+
*/
|
|
1526
|
+
const SET_BLINKING_BAR_CURSOR = "\x1B[5 q";
|
|
1527
|
+
/**
|
|
1528
|
+
* DECSCUSR `CSI 0 SP q` (`\x1b[0 q`): tell the terminal to draw its text
|
|
1529
|
+
* cursor in the shape it is configured with, for the application to write
|
|
1530
|
+
* before it releases the terminal.
|
|
1531
|
+
*/
|
|
1532
|
+
const SET_TERMINAL_DEFAULT_CURSOR = "\x1B[0 q";
|
|
1533
|
+
/** SGR reverse video on, which pi-tui opens its drawn block cursor with. */
|
|
1534
|
+
const REVERSE_VIDEO_ON = "\x1B[7m";
|
|
1535
|
+
/** SGR reset, which pi-tui closes its drawn block cursor with. */
|
|
1536
|
+
const REVERSE_VIDEO_OFF = "\x1B[0m";
|
|
1537
|
+
/**
|
|
1538
|
+
* Take pi-tui's drawn block cursor off one rendered editor line.
|
|
1539
|
+
*
|
|
1540
|
+
* The removal is anchored at `CURSOR_MARKER`, which pi-tui emits immediately
|
|
1541
|
+
* before the cell it draws: only the `\x1b[7m` / `\x1b[0m` pair that follows
|
|
1542
|
+
* the marker is deleted, so reverse video inside the text the user typed or
|
|
1543
|
+
* pasted is never touched. The cell's own character stays - or, past the last
|
|
1544
|
+
* character of the line, the space pi-tui drew instead - so the line keeps its
|
|
1545
|
+
* visible width and the marker keeps its column. A line with no marker, or a
|
|
1546
|
+
* marker the pair does not follow, comes back unchanged.
|
|
1547
|
+
* @param line - one line of `Editor.render` output.
|
|
1548
|
+
* @returns the line with the marker still in place and the block gone.
|
|
1549
|
+
*/
|
|
1550
|
+
function stripBlockCursor(line) {
|
|
1551
|
+
const marker = line.indexOf(CURSOR_MARKER);
|
|
1552
|
+
if (marker === -1) return line;
|
|
1553
|
+
const block = marker + CURSOR_MARKER.length;
|
|
1554
|
+
if (!line.startsWith(REVERSE_VIDEO_ON, block)) return line;
|
|
1555
|
+
const cell = block + 4;
|
|
1556
|
+
const close = line.indexOf(REVERSE_VIDEO_OFF, cell);
|
|
1557
|
+
if (close === -1) return line;
|
|
1558
|
+
return line.slice(0, block) + line.slice(cell, close) + line.slice(close + 4);
|
|
197
1559
|
}
|
|
198
1560
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
1561
|
+
* The prompt editor drawn without pi-tui's block cursor.
|
|
1562
|
+
*
|
|
1563
|
+
* pi-tui's `Editor` always draws the caret cell in reverse video and
|
|
1564
|
+
* `EditorOptions` carries no switch for it, while the real terminal cursor is
|
|
1565
|
+
* placed from the `CURSOR_MARKER` the editor emits at the same position. This
|
|
1566
|
+
* subclass removes that block from the lines `render` returns so the
|
|
1567
|
+
* terminal's own cursor - which the application shapes with
|
|
1568
|
+
* `SET_BLINKING_BAR_CURSOR` - is the only caret on screen. Everything else is
|
|
1569
|
+
* pi-tui's: text, autocomplete, padding, borders, scrolling, submission, and
|
|
1570
|
+
* history are untouched, and the component writes to no terminal itself.
|
|
1571
|
+
*
|
|
1572
|
+
* pi-tui emits the marker only while the editor is focused, yet it draws the
|
|
1573
|
+
* block either way, so an unfocused editor would keep a caret while another
|
|
1574
|
+
* region holds the keyboard. `render` therefore turns the marker on for the
|
|
1575
|
+
* `super.render` call to anchor the removal, then drops the marker again when
|
|
1576
|
+
* the editor is not focused: the unfocused editor shows no caret, and the
|
|
1577
|
+
* terminal cursor stays with the region that owns the keyboard.
|
|
206
1578
|
*/
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
1579
|
+
var BarCursorEditor = class extends Editor {
|
|
1580
|
+
/**
|
|
1581
|
+
* Render the editor without pi-tui's drawn block cursor.
|
|
1582
|
+
* @param width - the total width to lay the editor out in.
|
|
1583
|
+
* @returns the rendered lines, carrying `CURSOR_MARKER` only while focused.
|
|
1584
|
+
*/
|
|
1585
|
+
render(width) {
|
|
1586
|
+
const focused = this.focused;
|
|
1587
|
+
this.focused = true;
|
|
1588
|
+
let lines;
|
|
1589
|
+
try {
|
|
1590
|
+
lines = super.render(width);
|
|
1591
|
+
} finally {
|
|
1592
|
+
this.focused = focused;
|
|
1593
|
+
}
|
|
1594
|
+
return lines.map((line) => {
|
|
1595
|
+
const stripped = stripBlockCursor(line);
|
|
1596
|
+
return focused ? stripped : stripped.replaceAll(CURSOR_MARKER, "");
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
/** What the provider-default row is called. */
|
|
1601
|
+
const PROVIDER_DEFAULT_LABEL = "Provider default";
|
|
1602
|
+
/** The `/effort` argument that restores the provider default. */
|
|
1603
|
+
const DEFAULT_ARGUMENT = "default";
|
|
1604
|
+
/**
|
|
1605
|
+
* The display name of one effort.
|
|
1606
|
+
* @param reasoning - what the model declares.
|
|
1607
|
+
* @param effort - the selected effort, or undefined for the provider default.
|
|
1608
|
+
* @returns the declared name, the raw id when the model no longer declares it, or the provider-default label.
|
|
1609
|
+
*/
|
|
1610
|
+
function effortName(reasoning, effort) {
|
|
1611
|
+
if (effort === void 0) return PROVIDER_DEFAULT_LABEL;
|
|
1612
|
+
return reasoning.efforts.find((candidate) => candidate.id === effort)?.name ?? effort;
|
|
212
1613
|
}
|
|
213
1614
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* addresses the section root, which then requires a JSON object.
|
|
218
|
-
* @param ctx - plugin context carrying the optional settings service.
|
|
219
|
-
* @param ns - the registered namespace to edit.
|
|
220
|
-
* @param path - dot-separated field path (`api.timeoutMs`), or `''` for the root.
|
|
221
|
-
* @param rawValue - the typed value, JSON or plain text.
|
|
222
|
-
* @returns a confirmation row naming the namespace, path, and stored value.
|
|
223
|
-
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
1615
|
+
* Picker rows for one model: the provider default above the adapter's own order.
|
|
1616
|
+
* @param reasoning - what the model declares.
|
|
1617
|
+
* @returns the rows, provider default first.
|
|
224
1618
|
*/
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
1619
|
+
function effortItems(reasoning) {
|
|
1620
|
+
const resolved = reasoning.defaultEffort === void 0 ? void 0 : `resolves to ${effortName(reasoning, reasoning.defaultEffort)}`;
|
|
1621
|
+
return [{
|
|
1622
|
+
value: "",
|
|
1623
|
+
label: PROVIDER_DEFAULT_LABEL,
|
|
1624
|
+
...resolved === void 0 ? {} : { description: resolved }
|
|
1625
|
+
}, ...reasoning.efforts.map((effort) => ({
|
|
1626
|
+
value: effort.id,
|
|
1627
|
+
label: effort.name,
|
|
1628
|
+
...effort.description === void 0 ? {} : { description: effort.description }
|
|
1629
|
+
}))];
|
|
236
1630
|
}
|
|
237
|
-
/**
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
1631
|
+
/**
|
|
1632
|
+
* The dim row under the picker heading.
|
|
1633
|
+
* @param reasoning - what the model declares.
|
|
1634
|
+
* @param effort - the effort in force, or undefined for the provider default.
|
|
1635
|
+
* @returns one row naming the effort in force and the keys that change it.
|
|
1636
|
+
*/
|
|
1637
|
+
function effortHint(reasoning, effort) {
|
|
1638
|
+
return `current: ${effortName(reasoning, effort)} · Esc keeps it · Shift+Tab cycles`;
|
|
244
1639
|
}
|
|
245
1640
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* @param
|
|
249
|
-
* @param
|
|
250
|
-
* @returns
|
|
251
|
-
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
1641
|
+
* The effort a typed `/effort` argument names. A declared id wins over the
|
|
1642
|
+
* `default` keyword, so an adapter may own that id.
|
|
1643
|
+
* @param reasoning - what the model declares.
|
|
1644
|
+
* @param typed - the argument, matched against declared ids without case.
|
|
1645
|
+
* @returns the declared effort, undefined for the provider default, or null when the model declares no such effort.
|
|
252
1646
|
*/
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
return
|
|
1647
|
+
function matchEffort(reasoning, typed) {
|
|
1648
|
+
const wanted = typed.toLowerCase();
|
|
1649
|
+
const declared = reasoning.efforts.find((effort) => effort.id.toLowerCase() === wanted);
|
|
1650
|
+
if (declared !== void 0) return declared.id;
|
|
1651
|
+
return wanted === DEFAULT_ARGUMENT ? void 0 : null;
|
|
258
1652
|
}
|
|
1653
|
+
//#endregion
|
|
1654
|
+
//#region lib/types/export.js
|
|
259
1655
|
/**
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* @
|
|
263
|
-
* @returns the rows in Loader order.
|
|
264
|
-
* @throws {Error} when no Loader is mounted.
|
|
1656
|
+
* `/export`: the same session-log ZIP the browser downloads, written to a
|
|
1657
|
+
* local directory through the export package's archive helpers.
|
|
1658
|
+
* @module @deepseek-ai/dsh-tui-app/export
|
|
265
1659
|
*/
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
1660
|
+
/**
|
|
1661
|
+
* Write the session tree (the session, its sub-sessions, and attachments) as
|
|
1662
|
+
* a ZIP archive into `directory`.
|
|
1663
|
+
* @param ctx - plugin context carrying the query, persistence, and attachment services.
|
|
1664
|
+
* @param sessionId - the session to export.
|
|
1665
|
+
* @param directory - the directory the archive is written into.
|
|
1666
|
+
* @param signal - cancels the export.
|
|
1667
|
+
* @returns the archive's absolute path.
|
|
1668
|
+
* @throws when a required service is not composed or the session has no persisted log.
|
|
1669
|
+
*/
|
|
1670
|
+
async function exportSessionZip(ctx, sessionId, directory, signal) {
|
|
1671
|
+
const deps = sessionLogExportDeps(ctx);
|
|
1672
|
+
if (deps.sessionQuery === void 0 || deps.sessionPersistence === void 0 || deps.attachments === void 0) throw new Error("export needs the session query, persistence, and attachment services");
|
|
1673
|
+
const ready = {
|
|
1674
|
+
...deps,
|
|
1675
|
+
sessionQuery: deps.sessionQuery,
|
|
1676
|
+
sessionPersistence: deps.sessionPersistence,
|
|
1677
|
+
attachments: deps.attachments
|
|
1678
|
+
};
|
|
1679
|
+
await flushLiveSessionLog(deps, sessionId, signal);
|
|
1680
|
+
const root = await readSessionLogText(deps.sessionPersistence, sessionId, signal);
|
|
1681
|
+
if (root === void 0) throw new Error(`session ${sessionId} has no persisted log to export`);
|
|
1682
|
+
const path = join(directory, sessionLogZipFilename(sessionId));
|
|
1683
|
+
const archive = streamSessionLogZip(ready, root, sessionId, true, DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, signal);
|
|
1684
|
+
await pipeline(Readable.fromWeb(archive), createWriteStream(path), { signal });
|
|
1685
|
+
return path;
|
|
276
1686
|
}
|
|
1687
|
+
//#endregion
|
|
1688
|
+
//#region lib/types/todos.js
|
|
277
1689
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* @
|
|
284
|
-
* @param signal - cancels the listing.
|
|
285
|
-
* @returns the rows, empty when the session has no subagents.
|
|
286
|
-
* @throws {Error} when no subagent runtime is mounted.
|
|
1690
|
+
* The agent's todo list as terminal rows: the status glyphs every todo surface
|
|
1691
|
+
* of this terminal draws, the picker choices the list offers, and the detail
|
|
1692
|
+
* rows one entered item prints. Everything here is pure text work over plain
|
|
1693
|
+
* values; the single service touch is one optional read of the `todos` session
|
|
1694
|
+
* projection.
|
|
1695
|
+
* @module @deepseek-ai/dsh-tui-app/todos
|
|
287
1696
|
*/
|
|
288
|
-
async function listSubagents(ctx, sessionId, signal) {
|
|
289
|
-
const subagents = ctx.get("subagents");
|
|
290
|
-
if (subagents === void 0) throw new Error("subagents are not mounted in this profile");
|
|
291
|
-
return (await subagents.listDescendants(sessionId, signal)).map((entry) => {
|
|
292
|
-
const prefix = `${indent(entry.depth - 1)}${entry.id}`;
|
|
293
|
-
if (entry.kind === "diagnostic") return `${prefix} ${entry.reason}`;
|
|
294
|
-
const label = entry.label === void 0 ? "" : ` ${entry.label}`;
|
|
295
|
-
return `${prefix} ${entry.activity} ${entry.mode}${label}`;
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
1697
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* @returns the heading and file rows, empty when nothing was presented.
|
|
306
|
-
* @throws {Error} when no session query engine is mounted.
|
|
1698
|
+
* Terminal columns a picker row spends on content. The list draws one row per
|
|
1699
|
+
* todo, so content past this cap ends in `…` instead of wrapping onto a second
|
|
1700
|
+
* row; 64 columns plus the status glyph and the list's own row marker fit an
|
|
1701
|
+
* 80-column terminal. Columns, not characters: one wide character fills two of
|
|
1702
|
+
* them. A presentation choice of this terminal surface, not a deployment
|
|
1703
|
+
* setting.
|
|
307
1704
|
*/
|
|
308
|
-
|
|
309
|
-
const env_1 = {
|
|
310
|
-
stack: [],
|
|
311
|
-
error: void 0,
|
|
312
|
-
hasError: false
|
|
313
|
-
};
|
|
314
|
-
try {
|
|
315
|
-
const query = ctx.get("sessionQuery");
|
|
316
|
-
if (query === void 0) throw new Error("the session query engine is not mounted in this profile");
|
|
317
|
-
const observation = __addDisposableResource$1(env_1, await query.observeSession(sessionId, {
|
|
318
|
-
signal,
|
|
319
|
-
projectionMode: "none"
|
|
320
|
-
}), false);
|
|
321
|
-
const turns = /* @__PURE__ */ new Map();
|
|
322
|
-
for (const event of observation.events) {
|
|
323
|
-
if (event.type !== "deliverables/presented") continue;
|
|
324
|
-
const files = turns.get(event.data.turn) ?? [];
|
|
325
|
-
files.push(...event.data.files);
|
|
326
|
-
turns.set(event.data.turn, files);
|
|
327
|
-
}
|
|
328
|
-
const rows = [];
|
|
329
|
-
for (const [turn, files] of turns) {
|
|
330
|
-
rows.push(`turn ${String(turn)}`);
|
|
331
|
-
for (const file of files) rows.push(`${indent(1)}${file.path}${file.description === void 0 ? "" : ` ${file.description}`}`);
|
|
332
|
-
}
|
|
333
|
-
return rows;
|
|
334
|
-
} catch (e_1) {
|
|
335
|
-
env_1.error = e_1;
|
|
336
|
-
env_1.hasError = true;
|
|
337
|
-
} finally {
|
|
338
|
-
__disposeResources$1(env_1);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
1705
|
+
const TODO_LABEL_COLUMNS = 64;
|
|
341
1706
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
* @returns the rows, empty before the first turn starts.
|
|
348
|
-
* @throws {Error} when no projection registry is mounted or the `turnOutline` unit is not registered.
|
|
1707
|
+
* Columns the detail rows wrap one item's full content to. Fixed rather than
|
|
1708
|
+
* read from the terminal so these rows stay plain values; 72 columns fit an
|
|
1709
|
+
* 80-column terminal, and a narrower terminal wraps them again when it draws
|
|
1710
|
+
* them. A presentation choice of this terminal surface, not a deployment
|
|
1711
|
+
* setting.
|
|
349
1712
|
*/
|
|
350
|
-
|
|
351
|
-
const projections = ctx.get("sessionProjections");
|
|
352
|
-
if (projections === void 0) throw new Error("session projections are not mounted in this profile");
|
|
353
|
-
const outline = projections.snapshot(session, ["turnOutline"]).values.turnOutline;
|
|
354
|
-
if (outline === void 0) throw new Error("the turnOutline projection is not registered in this profile");
|
|
355
|
-
const rows = [];
|
|
356
|
-
for (const entry of outline) {
|
|
357
|
-
rows.push(`${String(entry.turn)}. ${entry.prompt === "" ? "(no prompt)" : entry.prompt}`);
|
|
358
|
-
if (entry.response !== "") rows.push(`${indent(1)}→ ${entry.response}`);
|
|
359
|
-
}
|
|
360
|
-
return rows;
|
|
361
|
-
}
|
|
362
|
-
//#endregion
|
|
363
|
-
//#region lib/types/style.js
|
|
1713
|
+
const TODO_DETAIL_COLUMNS = 72;
|
|
364
1714
|
/**
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
* @module @deepseek-ai/dsh-tui-app/style
|
|
1715
|
+
* Glyph per todo status: the marker a todo row leads with. This module is the
|
|
1716
|
+
* one home of the markers — every module of this terminal surface that prints
|
|
1717
|
+
* a todo row imports them from here rather than keeping its own table.
|
|
369
1718
|
*/
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
bold: ["1", "22"],
|
|
375
|
-
italic: ["3", "23"],
|
|
376
|
-
underline: ["4", "24"],
|
|
377
|
-
accent: ["36", "39"],
|
|
378
|
-
success: ["32", "39"],
|
|
379
|
-
warning: ["33", "39"],
|
|
380
|
-
error: ["31", "39"],
|
|
381
|
-
inverse: ["7", "27"]
|
|
1719
|
+
const TODO_GLYPH = {
|
|
1720
|
+
completed: "✓",
|
|
1721
|
+
in_progress: "▸",
|
|
1722
|
+
pending: "○"
|
|
382
1723
|
};
|
|
1724
|
+
/** The word each status is spelled out as. */
|
|
1725
|
+
const TODO_WORD = {
|
|
1726
|
+
completed: "completed",
|
|
1727
|
+
in_progress: "in progress",
|
|
1728
|
+
pending: "pending"
|
|
1729
|
+
};
|
|
1730
|
+
/** Every status, in the order a counts row lists them. */
|
|
1731
|
+
const TODO_STATUSES = [
|
|
1732
|
+
"completed",
|
|
1733
|
+
"in_progress",
|
|
1734
|
+
"pending"
|
|
1735
|
+
];
|
|
383
1736
|
/**
|
|
384
|
-
*
|
|
385
|
-
* @param
|
|
386
|
-
* @
|
|
1737
|
+
* The agent's current todo list as picker rows, in write order.
|
|
1738
|
+
* @param ctx - plugin context carrying the optional session-projection registry.
|
|
1739
|
+
* @param session - the session whose todo list is read.
|
|
1740
|
+
* @returns one choice per item. Empty in three cases: no projection registry
|
|
1741
|
+
* is composed in this profile, the registry has no `todos` unit registered, or
|
|
1742
|
+
* the value is `null` — which it is before the first `todo_write` of the
|
|
1743
|
+
* current turn, since every `turn/start` clears the list.
|
|
387
1744
|
*/
|
|
388
|
-
function
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
success: role("success"),
|
|
400
|
-
warning: role("warning"),
|
|
401
|
-
error: role("error"),
|
|
402
|
-
inverse: role("inverse"),
|
|
403
|
-
enabled
|
|
404
|
-
};
|
|
1745
|
+
function listTodoChoices(ctx, session) {
|
|
1746
|
+
const projections = ctx.get("sessionProjections");
|
|
1747
|
+
if (projections === void 0) return [];
|
|
1748
|
+
const items = projections.snapshot(session, ["todos"]).values.todos;
|
|
1749
|
+
if (items === void 0 || items === null) return [];
|
|
1750
|
+
return items.map((item, index) => ({
|
|
1751
|
+
index,
|
|
1752
|
+
label: `${TODO_GLYPH[item.status]} ${labelContent(item.content)}`,
|
|
1753
|
+
description: describeStatus(item.status),
|
|
1754
|
+
status: item.status
|
|
1755
|
+
}));
|
|
405
1756
|
}
|
|
406
1757
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* @
|
|
410
|
-
*
|
|
411
|
-
*
|
|
1758
|
+
* One content line cut to a single picker row.
|
|
1759
|
+
* @param content - the item's content.
|
|
1760
|
+
* @returns the content unchanged, or its leading columns followed by `…`,
|
|
1761
|
+
* together never wider than {@link TODO_LABEL_COLUMNS}. A wide character that
|
|
1762
|
+
* would straddle the cut is dropped rather than halved.
|
|
412
1763
|
*/
|
|
413
|
-
function
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
return isTty;
|
|
1764
|
+
function labelContent(content) {
|
|
1765
|
+
if (visibleWidth(content) <= TODO_LABEL_COLUMNS) return content;
|
|
1766
|
+
return `${sliceByColumn(content, 0, TODO_LABEL_COLUMNS - 1, true)}…`;
|
|
417
1767
|
}
|
|
418
1768
|
/**
|
|
419
|
-
* The
|
|
420
|
-
* @param
|
|
421
|
-
* @returns
|
|
1769
|
+
* The picker description for one status.
|
|
1770
|
+
* @param status - the item's status.
|
|
1771
|
+
* @returns the status word, and for `in_progress` that the item is being worked on now.
|
|
422
1772
|
*/
|
|
423
|
-
function
|
|
424
|
-
return {
|
|
425
|
-
heading: (text) => palette.bold(palette.accent(text)),
|
|
426
|
-
link: palette.accent,
|
|
427
|
-
linkUrl: palette.dim,
|
|
428
|
-
code: palette.warning,
|
|
429
|
-
codeBlock: (text) => text,
|
|
430
|
-
codeBlockBorder: palette.dim,
|
|
431
|
-
quote: palette.italic,
|
|
432
|
-
quoteBorder: palette.dim,
|
|
433
|
-
hr: palette.dim,
|
|
434
|
-
listBullet: palette.accent,
|
|
435
|
-
bold: palette.bold,
|
|
436
|
-
italic: palette.italic,
|
|
437
|
-
strikethrough: palette.dim,
|
|
438
|
-
underline: palette.underline
|
|
439
|
-
};
|
|
1773
|
+
function describeStatus(status) {
|
|
1774
|
+
return status === "in_progress" ? `${TODO_WORD[status]} · being worked on now` : TODO_WORD[status];
|
|
440
1775
|
}
|
|
441
1776
|
/**
|
|
442
|
-
* The
|
|
443
|
-
* @
|
|
444
|
-
*
|
|
1777
|
+
* The rows one entered todo prints: its full content wrapped to
|
|
1778
|
+
* {@link TODO_DETAIL_COLUMNS}, its status, its position in the list, the
|
|
1779
|
+
* list's counts by status, and — when the caller tracked them — the turn the
|
|
1780
|
+
* item first appeared in and the turn its status last changed in. Equal turns
|
|
1781
|
+
* print the appearance row alone: an item still holding the status it was
|
|
1782
|
+
* written with has no later change to report.
|
|
1783
|
+
* @param items - the list the entered row was drawn from.
|
|
1784
|
+
* @param index - zero-based position of the entered row.
|
|
1785
|
+
* @param transition - the turn facts the caller tracked for this item, when it has them.
|
|
1786
|
+
* @returns the detail rows; one row naming the missing item when `index` is
|
|
1787
|
+
* outside the list, which it is once a later `todo/write` shortened it.
|
|
445
1788
|
*/
|
|
446
|
-
function
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1789
|
+
function todoDetail(items, index, transition) {
|
|
1790
|
+
const item = items[index];
|
|
1791
|
+
if (item === void 0) return [`no todo item ${String(index + 1)} of ${String(items.length)}`];
|
|
1792
|
+
const rows = [
|
|
1793
|
+
...wrapTextWithAnsi(item.content, TODO_DETAIL_COLUMNS),
|
|
1794
|
+
`status: ${TODO_WORD[item.status]}`,
|
|
1795
|
+
`item ${String(index + 1)} of ${String(items.length)}`,
|
|
1796
|
+
countsRow(items)
|
|
1797
|
+
];
|
|
1798
|
+
if (transition !== void 0) {
|
|
1799
|
+
rows.push(`first written in turn ${String(transition.firstTurn)}`);
|
|
1800
|
+
if (transition.statusTurn !== transition.firstTurn) rows.push(`status last changed in turn ${String(transition.statusTurn)}`);
|
|
1801
|
+
}
|
|
1802
|
+
return rows;
|
|
454
1803
|
}
|
|
455
1804
|
/**
|
|
456
|
-
* The
|
|
457
|
-
* @param
|
|
458
|
-
* @returns
|
|
1805
|
+
* The whole list counted by status.
|
|
1806
|
+
* @param items - the current list.
|
|
1807
|
+
* @returns one count per status in {@link TODO_STATUSES} order, including the
|
|
1808
|
+
* zeroes, e.g. `2 completed · 1 in progress · 4 pending`.
|
|
459
1809
|
*/
|
|
460
|
-
function
|
|
461
|
-
return {
|
|
462
|
-
borderColor: palette.dim,
|
|
463
|
-
selectList: selectListTheme(palette)
|
|
464
|
-
};
|
|
1810
|
+
function countsRow(items) {
|
|
1811
|
+
return TODO_STATUSES.map((status) => `${String(items.filter((item) => item.status === status).length)} ${TODO_WORD[status]}`).join(" · ");
|
|
465
1812
|
}
|
|
466
1813
|
//#endregion
|
|
467
|
-
//#region lib/types/
|
|
1814
|
+
//#region lib/types/status.js
|
|
468
1815
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
1816
|
+
* Persistent session status for the terminal: one read of the session
|
|
1817
|
+
* projections becomes plain facts, and pure formatters turn those facts into
|
|
1818
|
+
* footer parts, the report sections `/status` and the status bar's segment
|
|
1819
|
+
* details share, and the one-line notices for compaction and model-request
|
|
1820
|
+
* retries. Nothing here touches the terminal, the palette, or the agent.
|
|
1821
|
+
* @module @deepseek-ai/dsh-tui-app/status
|
|
472
1822
|
*/
|
|
473
|
-
/**
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1823
|
+
/** The client-visible keys one status read selects. */
|
|
1824
|
+
const STATUS_KEYS = [
|
|
1825
|
+
"contextPressure",
|
|
1826
|
+
"contextBreakdown",
|
|
1827
|
+
"tokenUsage",
|
|
1828
|
+
"sessionStats",
|
|
1829
|
+
"todos",
|
|
1830
|
+
"goal",
|
|
1831
|
+
"plan",
|
|
1832
|
+
"permissions"
|
|
1833
|
+
];
|
|
481
1834
|
/**
|
|
482
|
-
*
|
|
483
|
-
* @param
|
|
484
|
-
* @param
|
|
485
|
-
* @returns the
|
|
1835
|
+
* Read one consistent cut of the status projections for `session`.
|
|
1836
|
+
* @param projections - the session-projection registry (`ctx.get('sessionProjections')`).
|
|
1837
|
+
* @param session - the session whose status is read.
|
|
1838
|
+
* @returns the facts every registered key yields; unregistered keys leave their fact absent.
|
|
486
1839
|
*/
|
|
487
|
-
function
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
out.push({
|
|
509
|
-
kind: "context",
|
|
510
|
-
text: previous[i]
|
|
511
|
-
});
|
|
512
|
-
i++;
|
|
513
|
-
j++;
|
|
514
|
-
} else if (lcs[(i + 1) * cols + j] >= lcs[i * cols + j + 1]) {
|
|
515
|
-
out.push({
|
|
516
|
-
kind: "removed",
|
|
517
|
-
text: previous[i]
|
|
518
|
-
});
|
|
519
|
-
i++;
|
|
520
|
-
} else {
|
|
521
|
-
out.push({
|
|
522
|
-
kind: "added",
|
|
523
|
-
text: next[j]
|
|
524
|
-
});
|
|
525
|
-
j++;
|
|
1840
|
+
function readStatusFacts(projections, session) {
|
|
1841
|
+
const { values } = projections.snapshot(session, STATUS_KEYS);
|
|
1842
|
+
const facts = {};
|
|
1843
|
+
const pressure = values.contextPressure;
|
|
1844
|
+
const used = pressure?.projectedTokens ?? pressure?.pressureTokens;
|
|
1845
|
+
if (used !== void 0 && pressure?.contextWindow !== void 0) facts.context = {
|
|
1846
|
+
used,
|
|
1847
|
+
window: pressure.contextWindow,
|
|
1848
|
+
percent: Math.min(100, Math.round(used / pressure.contextWindow * 100))
|
|
1849
|
+
};
|
|
1850
|
+
if (values.contextBreakdown !== void 0) facts.breakdown = values.contextBreakdown;
|
|
1851
|
+
if (values.tokenUsage !== void 0) facts.tokenUsage = values.tokenUsage;
|
|
1852
|
+
if (values.sessionStats !== void 0) facts.stats = values.sessionStats;
|
|
1853
|
+
if (values.todos !== void 0 && values.todos !== null) {
|
|
1854
|
+
const items = values.todos;
|
|
1855
|
+
facts.todos = {
|
|
1856
|
+
items,
|
|
1857
|
+
done: items.filter((item) => item.status === "completed").length,
|
|
1858
|
+
active: items.filter((item) => item.status === "in_progress").length,
|
|
1859
|
+
pending: items.filter((item) => item.status === "pending").length
|
|
1860
|
+
};
|
|
526
1861
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1862
|
+
if (values.goal !== void 0 && values.goal !== null) {
|
|
1863
|
+
const { goal, roundsStarted } = values.goal;
|
|
1864
|
+
facts.goal = {
|
|
1865
|
+
phase: goal.phase,
|
|
1866
|
+
objective: goal.objective,
|
|
1867
|
+
round: roundsStarted,
|
|
1868
|
+
maxRounds: goal.maxGoalRounds,
|
|
1869
|
+
...goal.blockedReason === void 0 ? {} : { blockedReason: goal.blockedReason.message }
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
if (values.plan !== void 0) facts.plan = values.plan;
|
|
1873
|
+
if (values.permissions !== void 0) facts.permissions = values.permissions;
|
|
1874
|
+
return facts;
|
|
536
1875
|
}
|
|
537
1876
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
* @param
|
|
541
|
-
* @
|
|
542
|
-
* @returns the rows to display, with an `undefined` gap marker where rows were elided.
|
|
1877
|
+
* The short footer parts: `ctx 42%`, `todo 2/5` (done over total),
|
|
1878
|
+
* `goal active`, and `plan` (`plan…` while a mode switch is pending).
|
|
1879
|
+
* @param facts - the facts one status read produced.
|
|
1880
|
+
* @returns one part per present fact, in footer order; empty when nothing is known.
|
|
543
1881
|
*/
|
|
544
|
-
function
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1882
|
+
function footerStatus(facts) {
|
|
1883
|
+
const parts = [];
|
|
1884
|
+
if (facts.context !== void 0) parts.push({
|
|
1885
|
+
id: "context",
|
|
1886
|
+
label: `ctx ${String(facts.context.percent)}%`
|
|
549
1887
|
});
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (keep[index] === true) {
|
|
554
|
-
out.push(line);
|
|
555
|
-
gap = false;
|
|
556
|
-
} else if (!gap) {
|
|
557
|
-
out.push(void 0);
|
|
558
|
-
gap = true;
|
|
559
|
-
}
|
|
1888
|
+
if (facts.todos !== void 0) parts.push({
|
|
1889
|
+
id: "todo",
|
|
1890
|
+
label: `todo ${String(facts.todos.done)}/${String(facts.todos.items.length)}`
|
|
560
1891
|
});
|
|
561
|
-
|
|
1892
|
+
if (facts.goal !== void 0) parts.push({
|
|
1893
|
+
id: "goal",
|
|
1894
|
+
label: `goal ${facts.goal.phase}`
|
|
1895
|
+
});
|
|
1896
|
+
if (facts.plan?.pending === true) parts.push({
|
|
1897
|
+
id: "plan",
|
|
1898
|
+
label: "plan…"
|
|
1899
|
+
});
|
|
1900
|
+
else if (facts.plan?.active === true) parts.push({
|
|
1901
|
+
id: "plan",
|
|
1902
|
+
label: "plan"
|
|
1903
|
+
});
|
|
1904
|
+
return parts;
|
|
562
1905
|
}
|
|
563
|
-
//#endregion
|
|
564
|
-
//#region lib/types/transcript.js
|
|
565
1906
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* @
|
|
570
|
-
*/
|
|
571
|
-
/** Unchanged rows shown around each diff hunk. */
|
|
572
|
-
const DIFF_CONTEXT_LINES = 2;
|
|
573
|
-
/**
|
|
574
|
-
* Join the text of the text blocks in `blocks`; other block kinds are
|
|
575
|
-
* summarized in brackets so a card never hides that they exist.
|
|
576
|
-
* @param blocks - model or tool content.
|
|
577
|
-
* @returns the readable text.
|
|
1907
|
+
* The context section: occupancy of the next request and, when the breakdown
|
|
1908
|
+
* is registered, its system/tools/messages composition.
|
|
1909
|
+
* @param facts - the facts one status read produced.
|
|
1910
|
+
* @returns the section lines; empty when neither fact is known.
|
|
578
1911
|
*/
|
|
579
|
-
function
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
1912
|
+
function contextLines(facts) {
|
|
1913
|
+
const lines = [];
|
|
1914
|
+
if (facts.context !== void 0) {
|
|
1915
|
+
const { used, window, percent } = facts.context;
|
|
1916
|
+
lines.push(`context: ~${formatTokens(used)} / ${formatTokens(window)} (${String(percent)}%)`);
|
|
1917
|
+
}
|
|
1918
|
+
if (facts.breakdown !== void 0) {
|
|
1919
|
+
const { systemTokens, toolsTokens, messageTokens } = facts.breakdown;
|
|
1920
|
+
lines.push(` system ~${formatTokens(systemTokens)} · tools ~${formatTokens(toolsTokens)} · messages ~${formatTokens(messageTokens)}`);
|
|
1921
|
+
}
|
|
1922
|
+
return lines;
|
|
585
1923
|
}
|
|
586
1924
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* @
|
|
1925
|
+
* The usage section: cumulative provider-reported tokens with the cache-hit
|
|
1926
|
+
* share, and the whole-log turn/step counts and wall times.
|
|
1927
|
+
* @param facts - the facts one status read produced.
|
|
1928
|
+
* @returns the section lines; empty when neither fact is known.
|
|
590
1929
|
*/
|
|
591
|
-
function
|
|
592
|
-
|
|
593
|
-
if (
|
|
594
|
-
|
|
1930
|
+
function usageLines(facts) {
|
|
1931
|
+
const lines = [];
|
|
1932
|
+
if (facts.tokenUsage !== void 0) {
|
|
1933
|
+
const usage = facts.tokenUsage;
|
|
1934
|
+
const parts = [
|
|
1935
|
+
`↑${formatTokens(usage.uncachedInputTokens)} uncached`,
|
|
1936
|
+
`cache read ${formatTokens(usage.cacheReadTokens)}`,
|
|
1937
|
+
`cache write ${formatTokens(usage.cacheWriteTokens)}`,
|
|
1938
|
+
`↓${formatTokens(usage.outputTokens)}`
|
|
1939
|
+
];
|
|
1940
|
+
const hit = cacheHitPercent(usage);
|
|
1941
|
+
if (hit !== null) parts.push(`cache hit ${hit}%`);
|
|
1942
|
+
lines.push(`tokens: ${parts.join(" · ")}`);
|
|
1943
|
+
}
|
|
1944
|
+
if (facts.stats !== void 0) {
|
|
1945
|
+
const stats = facts.stats;
|
|
1946
|
+
const parts = [
|
|
1947
|
+
`${String(stats.turns)} ${plural(stats.turns, "turn")}`,
|
|
1948
|
+
`${String(stats.steps)} ${plural(stats.steps, "step")}`,
|
|
1949
|
+
`model ${formatDuration(stats.llmMs)}`,
|
|
1950
|
+
`tools ${formatDuration(stats.toolMs)}`
|
|
1951
|
+
];
|
|
1952
|
+
if (stats.ttftSteps > 0) parts.push(`first token ${formatDuration(stats.ttftMs / stats.ttftSteps)} avg`);
|
|
1953
|
+
if (stats.decodeMs > 0) parts.push(`${String(Math.round(stats.decodeTokens / stats.decodeMs * 1e3))} tok/s`);
|
|
1954
|
+
lines.push(`session: ${parts.join(" · ")}`);
|
|
1955
|
+
}
|
|
1956
|
+
return lines;
|
|
595
1957
|
}
|
|
596
|
-
/** The zero totals a fresh session starts from. */
|
|
597
|
-
const EMPTY_USAGE = {
|
|
598
|
-
inputTokens: 0,
|
|
599
|
-
outputTokens: 0,
|
|
600
|
-
cacheReadTokens: 0,
|
|
601
|
-
lastInputTokens: 0
|
|
602
|
-
};
|
|
603
1958
|
/**
|
|
604
|
-
*
|
|
605
|
-
* @param
|
|
606
|
-
* @
|
|
607
|
-
* @returns the new totals.
|
|
1959
|
+
* The todo section: the counts by status and the list itself.
|
|
1960
|
+
* @param facts - the facts one status read produced.
|
|
1961
|
+
* @returns the section lines; empty when no todo list is known.
|
|
608
1962
|
*/
|
|
609
|
-
function
|
|
610
|
-
return
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
|
|
614
|
-
lastInputTokens: usage.inputTokens + (usage.cacheReadTokens ?? 0)
|
|
615
|
-
};
|
|
1963
|
+
function todoLines(facts) {
|
|
1964
|
+
if (facts.todos === void 0) return [];
|
|
1965
|
+
const { items, done, active, pending } = facts.todos;
|
|
1966
|
+
return [`todos: ${String(done)} done · ${String(active)} active · ${String(pending)} pending`, ...items.map((item) => ` ${TODO_GLYPH[item.status]} ${item.content}`)];
|
|
616
1967
|
}
|
|
617
1968
|
/**
|
|
618
|
-
* The
|
|
619
|
-
*
|
|
620
|
-
* @
|
|
1969
|
+
* The goal section: phase, admitted rounds, objective, and the blocking
|
|
1970
|
+
* condition while the goal is blocked.
|
|
1971
|
+
* @param facts - the facts one status read produced.
|
|
1972
|
+
* @returns the section lines; empty when the session carries no goal.
|
|
621
1973
|
*/
|
|
622
|
-
function
|
|
623
|
-
if (
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
return
|
|
1974
|
+
function goalLines(facts) {
|
|
1975
|
+
if (facts.goal === void 0) return [];
|
|
1976
|
+
const goal = facts.goal;
|
|
1977
|
+
const lines = [`goal: ${goal.phase} · round ${String(goal.round)}/${String(goal.maxRounds)} · ${goal.objective}`];
|
|
1978
|
+
if (goal.blockedReason !== void 0) lines.push(` blocked: ${goal.blockedReason}`);
|
|
1979
|
+
return lines;
|
|
628
1980
|
}
|
|
629
1981
|
/**
|
|
630
|
-
* The
|
|
631
|
-
* @param
|
|
632
|
-
* @returns the
|
|
1982
|
+
* The plan-mode section.
|
|
1983
|
+
* @param facts - the facts one status read produced.
|
|
1984
|
+
* @returns the one section line; empty when no plan-mode projection is registered.
|
|
633
1985
|
*/
|
|
634
|
-
function
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
case "aborted": return "turn stopped";
|
|
638
|
-
case "blocked": return "turn blocked: the model produced nothing the loop could continue";
|
|
639
|
-
case "error": return `turn failed: ${reason.error.code}: ${reason.error.message}`;
|
|
640
|
-
case "max-tokens": return "turn reached the output token ceiling";
|
|
641
|
-
case "interrupted": return "turn was interrupted by an earlier process exit";
|
|
642
|
-
default: return assertNever(reason, "tui turn-end reason");
|
|
643
|
-
}
|
|
1986
|
+
function planLines(facts) {
|
|
1987
|
+
if (facts.plan === void 0) return [];
|
|
1988
|
+
return [`plan: ${facts.plan.active ? "on" : "off"}${facts.plan.pending ? " (switching)" : ""}`];
|
|
644
1989
|
}
|
|
645
1990
|
/**
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
* @
|
|
1991
|
+
* The permission section. The projection carries the current value only; the
|
|
1992
|
+
* selectable options moved to the process-level catalog Remote.
|
|
1993
|
+
* @param facts - the facts one status read produced.
|
|
1994
|
+
* @returns the one section line; empty when no permission service is composed.
|
|
649
1995
|
*/
|
|
650
|
-
function
|
|
651
|
-
|
|
1996
|
+
function permissionLines(facts) {
|
|
1997
|
+
if (facts.permissions === void 0) return [];
|
|
1998
|
+
return [`permission: ${facts.permissions.currentValue}`];
|
|
652
1999
|
}
|
|
653
2000
|
/**
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
2001
|
+
* The multi-line `/status` report: context occupancy and composition, token
|
|
2002
|
+
* usage with the cache-hit share, session stats, the todo list, the goal,
|
|
2003
|
+
* plan mode, and the permission preset — one section per present fact. The
|
|
2004
|
+
* status bar prints the same sections as one segment's details.
|
|
2005
|
+
* @param facts - the facts one status read produced.
|
|
2006
|
+
* @returns the report lines; a single explanatory line when nothing is known.
|
|
657
2007
|
*/
|
|
658
|
-
function
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
2008
|
+
function statusReport(facts) {
|
|
2009
|
+
const lines = [
|
|
2010
|
+
...contextLines(facts),
|
|
2011
|
+
...usageLines(facts),
|
|
2012
|
+
...todoLines(facts),
|
|
2013
|
+
...goalLines(facts),
|
|
2014
|
+
...planLines(facts),
|
|
2015
|
+
...permissionLines(facts)
|
|
2016
|
+
];
|
|
2017
|
+
return lines.length === 0 ? ["no session status yet"] : lines;
|
|
664
2018
|
}
|
|
665
2019
|
/**
|
|
666
|
-
*
|
|
667
|
-
* @param
|
|
668
|
-
* @returns
|
|
2020
|
+
* The share of prompt-side input served from cache over the complete log.
|
|
2021
|
+
* @param usage - cumulative provider-reported usage.
|
|
2022
|
+
* @returns the percentage text; a partial hit never rounds up to `100`, and
|
|
2023
|
+
* no billed input returns null.
|
|
669
2024
|
*/
|
|
670
|
-
function
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
2025
|
+
function cacheHitPercent(usage) {
|
|
2026
|
+
const promptTokens = usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
|
|
2027
|
+
if (promptTokens === 0) return null;
|
|
2028
|
+
if (promptTokens === usage.cacheReadTokens) return "100";
|
|
2029
|
+
const percent = usage.cacheReadTokens / promptTokens * 100;
|
|
2030
|
+
const rounded = Math.round(percent);
|
|
2031
|
+
return rounded < 100 ? String(rounded) : String(Math.floor(percent * 10) / 10);
|
|
675
2032
|
}
|
|
676
2033
|
/**
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* @
|
|
680
|
-
* @param view - the tool's `presentCall` view, when it has one.
|
|
681
|
-
* @returns the card's call text.
|
|
2034
|
+
* Compact duration: `45.2s` under a minute, `2m42s` from there on.
|
|
2035
|
+
* @param ms - duration in milliseconds.
|
|
2036
|
+
* @returns the formatted duration.
|
|
682
2037
|
*/
|
|
683
|
-
function
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
title: "",
|
|
689
|
-
lines: summary === "{}" || summary === "" ? [] : [summary]
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
switch (view.card) {
|
|
693
|
-
case "generic": {
|
|
694
|
-
const lines = [];
|
|
695
|
-
if (view.content !== void 0) lines.push(...contentText(view.content).split("\n"));
|
|
696
|
-
return {
|
|
697
|
-
title: view.title,
|
|
698
|
-
lines
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
case "terminal": {
|
|
702
|
-
const lines = [];
|
|
703
|
-
if (view.description !== void 0) lines.push(view.description);
|
|
704
|
-
if (view.cwd !== void 0) lines.push(`cwd: ${view.cwd}`);
|
|
705
|
-
return {
|
|
706
|
-
title: view.title,
|
|
707
|
-
lines
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
case "diff": return {
|
|
711
|
-
title: view.title,
|
|
712
|
-
lines: view.diffs.flatMap((diff) => [diff.path, ...diffRows(diff)])
|
|
713
|
-
};
|
|
714
|
-
default: return assertNever(view, "tui tool call view");
|
|
715
|
-
}
|
|
2038
|
+
function formatDuration(ms) {
|
|
2039
|
+
const seconds = ms / 1e3;
|
|
2040
|
+
if (seconds < 60) return `${String(Math.round(seconds * 10) / 10)}s`;
|
|
2041
|
+
const whole = Math.round(seconds);
|
|
2042
|
+
return `${String(Math.floor(whole / 60))}m${String(whole % 60)}s`;
|
|
716
2043
|
}
|
|
717
2044
|
/**
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
* @
|
|
721
|
-
* @param content - the model-facing result content.
|
|
722
|
-
* @returns the rows, before any preview truncation.
|
|
2045
|
+
* The notice a completed compaction earns.
|
|
2046
|
+
* @param data - the `compaction/summary` event data.
|
|
2047
|
+
* @returns e.g. `compacted 12 items (~3.4k tokens)`.
|
|
723
2048
|
*/
|
|
724
|
-
function
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
case "generic": return contentText(view.content ?? content).split("\n").filter((line) => line !== "");
|
|
728
|
-
case "terminal": {
|
|
729
|
-
const lines = (view.output ?? "").split("\n");
|
|
730
|
-
while (lines.length > 0 && lines.at(-1) === "") lines.pop();
|
|
731
|
-
if (view.exitCode !== void 0 && view.exitCode !== 0) lines.push(`exit ${String(view.exitCode)}`);
|
|
732
|
-
if (view.signal !== void 0) lines.push(`signal ${view.signal}`);
|
|
733
|
-
return lines;
|
|
734
|
-
}
|
|
735
|
-
case "diff": return view.diffs.flatMap((diff) => [diff.path, ...diffRows(diff)]);
|
|
736
|
-
case "search": {
|
|
737
|
-
const lines = view.shape === "matches" ? view.files.flatMap((file) => file.matches.map((match) => `${file.path}:${String(match.lineNumber)}: ${match.line}`)) : [...view.paths];
|
|
738
|
-
if (view.truncated) lines.push(`… ${String(view.total)} total`);
|
|
739
|
-
return lines;
|
|
740
|
-
}
|
|
741
|
-
case "read": return view.lines.map((line) => `${String(line.number).padStart(4)}│ ${line.text}`);
|
|
742
|
-
case "web": {
|
|
743
|
-
if (view.kind === "fetch") return [`${view.url} (${String(view.statusCode)})`];
|
|
744
|
-
const lines = view.sources.map((source) => source.title === void 0 ? source.url : `${source.title} — ${source.url}`);
|
|
745
|
-
if (view.answer !== void 0) lines.unshift(view.answer);
|
|
746
|
-
return lines;
|
|
747
|
-
}
|
|
748
|
-
default: return assertNever(view, "tui tool result view");
|
|
749
|
-
}
|
|
2049
|
+
function compactionNotice(data) {
|
|
2050
|
+
const count = data.shadowedSeqs.length;
|
|
2051
|
+
return `compacted ${String(count)} ${plural(count, "item")} (~${formatTokens(data.shadowedTokenCount)} tokens)`;
|
|
750
2052
|
}
|
|
751
2053
|
/**
|
|
752
|
-
*
|
|
753
|
-
* @param
|
|
754
|
-
* @
|
|
755
|
-
*
|
|
756
|
-
* @returns the rows to draw, with a trailing count of hidden rows when cut.
|
|
2054
|
+
* The notice a scheduled model-request retry earns.
|
|
2055
|
+
* @param data - the `llm/retry` event data.
|
|
2056
|
+
* @returns e.g. `retrying (2/5) in 4s · RATE_LIMIT: provider busy`; an
|
|
2057
|
+
* `always` policy has no cap, so its attempt reads `(2)`.
|
|
757
2058
|
*/
|
|
758
|
-
function
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
2059
|
+
function retryMessage(data) {
|
|
2060
|
+
return `retrying (${data.mode === "normal" ? `${String(data.retry)}/${String(data.maxRetries)}` : String(data.retry)}) in ${formatDuration(data.delayMs)} · ${data.failure.code}: ${data.failure.message}`;
|
|
2061
|
+
}
|
|
2062
|
+
function plural(count, noun) {
|
|
2063
|
+
return count === 1 ? noun : `${noun}s`;
|
|
762
2064
|
}
|
|
763
2065
|
//#endregion
|
|
764
|
-
//#region lib/types/
|
|
2066
|
+
//#region lib/types/footer.js
|
|
765
2067
|
/**
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
*
|
|
2068
|
+
* The status bar under the editor: plain session facts become an ordered list
|
|
2069
|
+
* of segments, each with a stable id, the short label the bar draws, and what
|
|
2070
|
+
* `Enter` on it does — the rows the app prints, or the app's own navigable
|
|
2071
|
+
* page; a second function renders the segments as the footer's two lines, dim
|
|
2072
|
+
* while the editor holds focus and with the selected segment accented while
|
|
2073
|
+
* the bar does. Everything here is pure — no Context, no services, no
|
|
2074
|
+
* terminal, and no clock: elapsed values arrive already formatted.
|
|
2075
|
+
* @module @deepseek-ai/dsh-tui-app/footer
|
|
770
2076
|
*/
|
|
771
|
-
/**
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
constructor(theme, text) {
|
|
776
|
-
this.theme = theme;
|
|
777
|
-
this.text = text;
|
|
778
|
-
}
|
|
779
|
-
invalidate() {}
|
|
780
|
-
render(width) {
|
|
781
|
-
const palette = this.theme.palette;
|
|
782
|
-
return ["", ...wrapTextWithAnsi(this.text, Math.max(1, width - 2)).map((line, index) => `${palette.accent(index === 0 ? "›" : " ")} ${palette.bold(line)}`)];
|
|
783
|
-
}
|
|
784
|
-
};
|
|
785
|
-
/** A dim one-line notice about the session (a stopped turn, a command result, a model switch). */
|
|
786
|
-
var NoticeBlock = class {
|
|
787
|
-
text;
|
|
788
|
-
constructor(theme, text, tone = "dim") {
|
|
789
|
-
const palette = theme.palette;
|
|
790
|
-
this.text = new Text(palette[tone](`· ${text}`), 0, 0);
|
|
791
|
-
}
|
|
792
|
-
invalidate() {
|
|
793
|
-
this.text.invalidate();
|
|
794
|
-
}
|
|
795
|
-
render(width) {
|
|
796
|
-
return this.text.render(width);
|
|
797
|
-
}
|
|
798
|
-
};
|
|
2077
|
+
/** The segment focus enters the bar on; `buildFooterSegments` always emits it first. */
|
|
2078
|
+
const FIRST_FOOTER_SEGMENT = "model";
|
|
2079
|
+
/** What separates two segments, and two parts inside a hint line. */
|
|
2080
|
+
const SEPARATOR$1 = " · ";
|
|
799
2081
|
/**
|
|
800
|
-
*
|
|
801
|
-
*
|
|
2082
|
+
* Longest workspace label the bar draws in full. A wider path keeps its last
|
|
2083
|
+
* two segments behind `…/` so one long project path cannot crowd out the
|
|
2084
|
+
* facts beside it. This is a presentation choice of this terminal surface,
|
|
2085
|
+
* not a deployment setting.
|
|
802
2086
|
*/
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const palette = theme.palette;
|
|
813
|
-
this.markdown = new Markdown("", 0, 0, markdownTheme(palette));
|
|
814
|
-
this.reasoningText = new Text("", 0, 0);
|
|
815
|
-
}
|
|
816
|
-
/**
|
|
817
|
-
* Append streamed visible text.
|
|
818
|
-
* @param delta - the text delta.
|
|
819
|
-
*/
|
|
820
|
-
appendText(delta) {
|
|
821
|
-
this.text += delta;
|
|
822
|
-
this.markdown.setText(this.text);
|
|
823
|
-
}
|
|
824
|
-
/**
|
|
825
|
-
* Append streamed reasoning text.
|
|
826
|
-
* @param delta - the reasoning delta.
|
|
827
|
-
*/
|
|
828
|
-
appendReasoning(delta) {
|
|
829
|
-
this.reasoning += delta;
|
|
830
|
-
this.reasoningText.setText(this.theme.palette.dim(this.theme.palette.italic(this.reasoning.trimEnd())));
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* Replace the streamed content with the committed message.
|
|
834
|
-
* @param text - the committed visible text.
|
|
835
|
-
* @param reasoning - the committed reasoning text.
|
|
836
|
-
* @param interrupted - whether the message was cut short.
|
|
837
|
-
*/
|
|
838
|
-
commit(text, reasoning, interrupted) {
|
|
839
|
-
this.text = text;
|
|
840
|
-
this.reasoning = reasoning;
|
|
841
|
-
this.interrupted = interrupted;
|
|
842
|
-
this.markdown.setText(text);
|
|
843
|
-
this.reasoningText.setText(this.theme.palette.dim(this.theme.palette.italic(reasoning.trimEnd())));
|
|
844
|
-
}
|
|
845
|
-
invalidate() {
|
|
846
|
-
this.markdown.invalidate();
|
|
847
|
-
this.reasoningText.invalidate();
|
|
848
|
-
}
|
|
849
|
-
render(width) {
|
|
850
|
-
const lines = [""];
|
|
851
|
-
if (this.reasoning.trim() !== "") lines.push(...this.reasoningText.render(width), "");
|
|
852
|
-
if (this.text !== "") lines.push(...this.markdown.render(width));
|
|
853
|
-
if (this.interrupted) lines.push(this.theme.palette.dim("[interrupted]"));
|
|
854
|
-
return lines;
|
|
855
|
-
}
|
|
856
|
-
};
|
|
857
|
-
/** A tool call card: status glyph, tool name, headline, then a foldable body. */
|
|
858
|
-
var ToolBlock = class {
|
|
859
|
-
theme;
|
|
860
|
-
name;
|
|
861
|
-
call;
|
|
862
|
-
status = "running";
|
|
863
|
-
resultLines = [];
|
|
864
|
-
expanded = false;
|
|
865
|
-
constructor(theme, name, call) {
|
|
866
|
-
this.theme = theme;
|
|
867
|
-
this.name = name;
|
|
868
|
-
this.call = call;
|
|
869
|
-
}
|
|
870
|
-
/**
|
|
871
|
-
* Attach the result rows and settle the status.
|
|
872
|
-
* @param lines - the result rows before preview truncation.
|
|
873
|
-
* @param isError - whether the tool reported failure.
|
|
874
|
-
*/
|
|
875
|
-
setResult(lines, isError) {
|
|
876
|
-
this.resultLines = lines;
|
|
877
|
-
this.status = isError ? "error" : "done";
|
|
878
|
-
}
|
|
879
|
-
/**
|
|
880
|
-
* Fold or unfold the body.
|
|
881
|
-
* @param expanded - whether the full body is shown.
|
|
882
|
-
*/
|
|
883
|
-
setExpanded(expanded) {
|
|
884
|
-
this.expanded = expanded;
|
|
885
|
-
}
|
|
886
|
-
invalidate() {}
|
|
887
|
-
render(width) {
|
|
888
|
-
const palette = this.theme.palette;
|
|
889
|
-
const header = `${this.status === "running" ? palette.warning("●") : this.status === "done" ? palette.success("●") : palette.error("●")} ${palette.bold(this.name)}${this.call.title === "" ? "" : ` ${palette.dim(this.call.title)}`}`;
|
|
890
|
-
const shown = previewLines([...this.call.lines, ...this.resultLines], this.theme.toolPreviewLines, this.expanded);
|
|
891
|
-
const inner = Math.max(1, width - 4);
|
|
892
|
-
const rows = shown.flatMap((line) => wrapTextWithAnsi(line, inner).map((part) => ` ${palette.dim("│")} ${part}`));
|
|
893
|
-
return [
|
|
894
|
-
"",
|
|
895
|
-
...wrapTextWithAnsi(header, width),
|
|
896
|
-
...rows
|
|
897
|
-
];
|
|
898
|
-
}
|
|
899
|
-
};
|
|
900
|
-
//#endregion
|
|
901
|
-
//#region lib/types/completion.js
|
|
2087
|
+
const WORKSPACE_LABEL_WIDTH = 24;
|
|
2088
|
+
/** The last two segments of a path, with the separators that precede them. */
|
|
2089
|
+
const PATH_TAIL = /[/\\][^/\\]+[/\\][^/\\]+$/u;
|
|
2090
|
+
/** The keys the focused bar answers, replacing the usual hints. */
|
|
2091
|
+
const FOCUS_HINTS$1 = `← → select${SEPARATOR$1}Enter details${SEPARATOR$1}Esc back`;
|
|
2092
|
+
/** What the unfocused hints advertise as the way into the bar. */
|
|
2093
|
+
const ENTRY_HINT = "Shift+↑ status bar";
|
|
2094
|
+
/** The command that prints every projection section at once. */
|
|
2095
|
+
const STATUS_COMMAND_ROW = "/status prints all of these sections";
|
|
902
2096
|
/**
|
|
903
|
-
*
|
|
904
|
-
* and
|
|
905
|
-
*
|
|
906
|
-
*
|
|
2097
|
+
* What opening each projection-fact segment does: a section builder produces
|
|
2098
|
+
* the rows the app prints, and `OPENED_PAGE` names a fact the app has its own
|
|
2099
|
+
* navigable page for. The printed `/status` report still carries a todo
|
|
2100
|
+
* section; the bar hands the todo list to the app instead.
|
|
907
2101
|
*/
|
|
2102
|
+
const STATUS_SECTIONS = {
|
|
2103
|
+
context: contextLines,
|
|
2104
|
+
todo: { kind: "page" },
|
|
2105
|
+
goal: goalLines,
|
|
2106
|
+
plan: planLines
|
|
2107
|
+
};
|
|
908
2108
|
/**
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
* @
|
|
912
|
-
* @returns the editor's autocomplete provider.
|
|
2109
|
+
* Detail rows as the segment carries them.
|
|
2110
|
+
* @param rows - the rows the app prints; never empty.
|
|
2111
|
+
* @returns the detail.
|
|
913
2112
|
*/
|
|
914
|
-
function
|
|
2113
|
+
function printed(rows) {
|
|
915
2114
|
return {
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const line = lines[cursorLine] ?? "";
|
|
919
|
-
const before = line.slice(0, cursorCol);
|
|
920
|
-
const at = activeAtToken(line, cursorCol);
|
|
921
|
-
if (at !== void 0) {
|
|
922
|
-
const items = (await sources.references(at.query, at.quoted, options.signal)).map((reference) => ({
|
|
923
|
-
value: reference.mention,
|
|
924
|
-
label: reference.label,
|
|
925
|
-
...reference.description === void 0 ? {} : { description: reference.description }
|
|
926
|
-
}));
|
|
927
|
-
return items.length === 0 ? null : {
|
|
928
|
-
items,
|
|
929
|
-
prefix: at.prefix
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
if (cursorLine !== 0 || !before.startsWith("/") || /\s/u.test(before)) return null;
|
|
933
|
-
const prefix = before;
|
|
934
|
-
const items = sources.commands().filter((command) => `/${command.name}`.startsWith(prefix)).map((command) => ({
|
|
935
|
-
value: `/${command.name}`,
|
|
936
|
-
label: `/${command.name}`,
|
|
937
|
-
description: command.hint === void 0 ? command.description : `${command.description} · ${command.hint}`
|
|
938
|
-
}));
|
|
939
|
-
return items.length === 0 ? null : {
|
|
940
|
-
items,
|
|
941
|
-
prefix
|
|
942
|
-
};
|
|
943
|
-
},
|
|
944
|
-
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
945
|
-
const line = lines[cursorLine] ?? "";
|
|
946
|
-
const start = cursorCol - prefix.length;
|
|
947
|
-
const replaced = `${line.slice(0, start)}${item.value} ${line.slice(cursorCol)}`;
|
|
948
|
-
const next = [...lines];
|
|
949
|
-
next[cursorLine] = replaced;
|
|
950
|
-
return {
|
|
951
|
-
lines: next,
|
|
952
|
-
cursorLine,
|
|
953
|
-
cursorCol: start + item.value.length + 1
|
|
954
|
-
};
|
|
955
|
-
}
|
|
2115
|
+
kind: "rows",
|
|
2116
|
+
rows
|
|
956
2117
|
};
|
|
957
2118
|
}
|
|
958
|
-
/** What the provider-default row is called. */
|
|
959
|
-
const PROVIDER_DEFAULT_LABEL = "Provider default";
|
|
960
|
-
/** The `/effort` argument that restores the provider default. */
|
|
961
|
-
const DEFAULT_ARGUMENT = "default";
|
|
962
|
-
/**
|
|
963
|
-
* The display name of one effort.
|
|
964
|
-
* @param reasoning - what the model declares.
|
|
965
|
-
* @param effort - the selected effort, or undefined for the provider default.
|
|
966
|
-
* @returns the declared name, the raw id when the model no longer declares it, or the provider-default label.
|
|
967
|
-
*/
|
|
968
|
-
function effortName(reasoning, effort) {
|
|
969
|
-
if (effort === void 0) return PROVIDER_DEFAULT_LABEL;
|
|
970
|
-
return reasoning.efforts.find((candidate) => candidate.id === effort)?.name ?? effort;
|
|
971
|
-
}
|
|
972
2119
|
/**
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
* @
|
|
2120
|
+
* The workspace label: a home-directory prefix becomes `~`, and a path still
|
|
2121
|
+
* wider than the bar allows keeps only its last two segments.
|
|
2122
|
+
* @param cwd - the workspace root.
|
|
2123
|
+
* @param home - the home directory to fold into `~`; absent leaves `cwd` alone.
|
|
2124
|
+
* @returns the label the bar draws.
|
|
976
2125
|
*/
|
|
977
|
-
function
|
|
978
|
-
const
|
|
979
|
-
return
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
...resolved === void 0 ? {} : { description: resolved }
|
|
983
|
-
}, ...reasoning.efforts.map((effort) => ({
|
|
984
|
-
value: effort.id,
|
|
985
|
-
label: effort.name,
|
|
986
|
-
...effort.description === void 0 ? {} : { description: effort.description }
|
|
987
|
-
}))];
|
|
2126
|
+
function workspaceLabel(cwd, home) {
|
|
2127
|
+
const folded = home !== void 0 && home !== "" && startsWithDirectory(cwd, home) ? `~${cwd.slice(home.length)}` : cwd;
|
|
2128
|
+
if (folded.length <= WORKSPACE_LABEL_WIDTH) return folded;
|
|
2129
|
+
const tail = PATH_TAIL.exec(folded);
|
|
2130
|
+
return tail === null ? folded : `…${tail[0]}`;
|
|
988
2131
|
}
|
|
989
2132
|
/**
|
|
990
|
-
*
|
|
991
|
-
* @param
|
|
992
|
-
* @param
|
|
993
|
-
* @returns
|
|
2133
|
+
* Whether `path` is `directory` itself or lies under it.
|
|
2134
|
+
* @param path - the path to test.
|
|
2135
|
+
* @param directory - the candidate prefix, without a trailing separator.
|
|
2136
|
+
* @returns true when `path` starts at `directory`.
|
|
994
2137
|
*/
|
|
995
|
-
function
|
|
996
|
-
|
|
2138
|
+
function startsWithDirectory(path, directory) {
|
|
2139
|
+
if (!path.startsWith(directory)) return false;
|
|
2140
|
+
const next = path.charAt(directory.length);
|
|
2141
|
+
return next === "" || next === "/" || next === "\\";
|
|
997
2142
|
}
|
|
998
2143
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
* @
|
|
2144
|
+
* Build the status bar's segments in the order it draws them: model, effort,
|
|
2145
|
+
* permission, turn, usage, the projection facts, workspace, attachments. Only
|
|
2146
|
+
* the model and workspace segments are always present; every other segment
|
|
2147
|
+
* needs its fact.
|
|
2148
|
+
* @param inputs - the facts the app read for the bound session.
|
|
2149
|
+
* @returns the segments, the model segment first.
|
|
1004
2150
|
*/
|
|
1005
|
-
function
|
|
1006
|
-
const
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
2151
|
+
function buildFooterSegments(inputs) {
|
|
2152
|
+
const { selection, facts, turn } = inputs;
|
|
2153
|
+
const effort = selection.reasoningEffort;
|
|
2154
|
+
const segments = [{
|
|
2155
|
+
id: "model",
|
|
2156
|
+
label: `${selection.provider}/${selection.model}`,
|
|
2157
|
+
detail: printed([
|
|
2158
|
+
`provider: ${selection.provider}`,
|
|
2159
|
+
`model: ${selection.model}`,
|
|
2160
|
+
effort === void 0 ? "reasoning effort: the model's own default" : `reasoning effort: ${effort}`,
|
|
2161
|
+
"/model picks the provider and model for the next request"
|
|
2162
|
+
])
|
|
2163
|
+
}];
|
|
2164
|
+
if (effort !== void 0) segments.push({
|
|
2165
|
+
id: "effort",
|
|
2166
|
+
label: `effort ${effort}`,
|
|
2167
|
+
detail: printed([`reasoning effort: ${effort}`, `Shift+Tab cycles it${SEPARATOR$1}/effort picks one`])
|
|
2168
|
+
});
|
|
2169
|
+
if (inputs.permission !== void 0) {
|
|
2170
|
+
const projected = permissionLines(facts);
|
|
2171
|
+
segments.push({
|
|
2172
|
+
id: "permission",
|
|
2173
|
+
label: `permission ${inputs.permission}`,
|
|
2174
|
+
detail: printed([...projected.length === 0 ? [`permission: ${inputs.permission}`] : projected, "/permission <preset> changes the sandbox mode and the approval policy"])
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
if (turn !== void 0) segments.push({
|
|
2178
|
+
id: "turn",
|
|
2179
|
+
label: `turn ${turn.elapsed}`,
|
|
2180
|
+
detail: printed([
|
|
2181
|
+
`turn ${String(turn.number)}`,
|
|
2182
|
+
`started: ${formatTimestamp(turn.startedAt)}`,
|
|
2183
|
+
`elapsed: ${turn.elapsed}`,
|
|
2184
|
+
`queued: ${String(turn.queuedNextTurn)} for the next turn${SEPARATOR$1}${String(turn.queuedNextStep)} for the next step`
|
|
2185
|
+
])
|
|
2186
|
+
});
|
|
2187
|
+
if (inputs.usage !== "") segments.push({
|
|
2188
|
+
id: "usage",
|
|
2189
|
+
label: inputs.usage,
|
|
2190
|
+
detail: printed([
|
|
2191
|
+
`this terminal: ${inputs.usage}`,
|
|
2192
|
+
...usageLines(facts),
|
|
2193
|
+
STATUS_COMMAND_ROW
|
|
2194
|
+
])
|
|
2195
|
+
});
|
|
2196
|
+
for (const part of footerStatus(facts)) {
|
|
2197
|
+
const section = STATUS_SECTIONS[part.id];
|
|
2198
|
+
segments.push({
|
|
2199
|
+
id: part.id,
|
|
2200
|
+
label: part.label,
|
|
2201
|
+
detail: typeof section === "function" ? printed([...section(facts), STATUS_COMMAND_ROW]) : section
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
segments.push({
|
|
2205
|
+
id: "workspace",
|
|
2206
|
+
label: workspaceLabel(inputs.cwd, inputs.home),
|
|
2207
|
+
detail: printed([`workspace: ${inputs.cwd}`])
|
|
2208
|
+
});
|
|
2209
|
+
if (inputs.attachments.length > 0) segments.push({
|
|
2210
|
+
id: "attachments",
|
|
2211
|
+
label: `${String(inputs.attachments.length)} attached`,
|
|
2212
|
+
detail: printed([...inputs.attachments.map((attachment) => `${attachment.kind}: ${attachment.name}`), `/attach <path> adds one${SEPARATOR$1}/attach clear drops them all`])
|
|
2213
|
+
});
|
|
2214
|
+
return segments;
|
|
1010
2215
|
}
|
|
1011
|
-
//#endregion
|
|
1012
|
-
//#region lib/types/export.js
|
|
1013
2216
|
/**
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
* @
|
|
2217
|
+
* Where the bar draws its selection.
|
|
2218
|
+
* @param segments - the segments in bar order.
|
|
2219
|
+
* @param selected - the segment id the bar holds.
|
|
2220
|
+
* @returns that segment's index, or 0 once the fact behind it is gone.
|
|
1017
2221
|
*/
|
|
2222
|
+
function footerSelectionIndex(segments, selected) {
|
|
2223
|
+
const index = segments.findIndex((segment) => segment.id === selected);
|
|
2224
|
+
return index === -1 ? 0 : index;
|
|
2225
|
+
}
|
|
1018
2226
|
/**
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
* @param
|
|
1024
|
-
* @param
|
|
1025
|
-
* @returns the
|
|
1026
|
-
* @throws when a required service is not composed or the session has no persisted log.
|
|
2227
|
+
* Render the footer's two lines: the segment labels, then the key hints.
|
|
2228
|
+
* Unfocused the whole bar is dim and the hints advertise the entry key;
|
|
2229
|
+
* focused the selected segment is accented and the hints name the navigation
|
|
2230
|
+
* keys instead.
|
|
2231
|
+
* @param segments - the segments in bar order.
|
|
2232
|
+
* @param render - the palette, the selected index, and the unfocused hints.
|
|
2233
|
+
* @returns the footer text, two lines separated by a newline.
|
|
1027
2234
|
*/
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
sessionQuery: deps.sessionQuery,
|
|
1034
|
-
sessionPersistence: deps.sessionPersistence,
|
|
1035
|
-
attachments: deps.attachments
|
|
1036
|
-
};
|
|
1037
|
-
await flushLiveSessionLog(deps, sessionId, signal);
|
|
1038
|
-
const root = await readSessionLogText(deps.sessionPersistence, sessionId, signal);
|
|
1039
|
-
if (root === void 0) throw new Error(`session ${sessionId} has no persisted log to export`);
|
|
1040
|
-
const path = join(directory, sessionLogZipFilename(sessionId));
|
|
1041
|
-
const archive = streamSessionLogZip(ready, root, sessionId, true, DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, signal);
|
|
1042
|
-
await pipeline(Readable.fromWeb(archive), createWriteStream(path), { signal });
|
|
1043
|
-
return path;
|
|
2235
|
+
function renderFooter(segments, render) {
|
|
2236
|
+
const { palette, selected, hints } = render;
|
|
2237
|
+
const labels = segments.map((segment) => segment.label);
|
|
2238
|
+
if (selected === void 0) return `${palette.dim(labels.join(SEPARATOR$1))}\n${palette.dim(`${hints}${SEPARATOR$1}${ENTRY_HINT}`)}`;
|
|
2239
|
+
return `${labels.map((label, index) => index === selected ? palette.bold(palette.accent(label)) : palette.dim(label)).join(palette.dim(SEPARATOR$1))}\n${palette.dim(FOCUS_HINTS$1)}`;
|
|
1044
2240
|
}
|
|
1045
2241
|
//#endregion
|
|
1046
2242
|
//#region lib/types/prompts.js
|
|
1047
2243
|
/**
|
|
1048
|
-
* Modal prompts the
|
|
1049
|
-
* seams,
|
|
2244
|
+
* Modal prompts the terminal shows above the editor — the approval and
|
|
2245
|
+
* user-questions seams, list pickers, and read-only detail pages — and the
|
|
2246
|
+
* queue that shows them one at a time.
|
|
1050
2247
|
* @module @deepseek-ai/dsh-tui-app/prompts
|
|
1051
2248
|
*/
|
|
1052
2249
|
/** Rows a select list shows before scrolling. */
|
|
@@ -1091,22 +2288,46 @@ var Settlement = class {
|
|
|
1091
2288
|
var ListPrompt = class {
|
|
1092
2289
|
palette;
|
|
1093
2290
|
body;
|
|
2291
|
+
choose;
|
|
2292
|
+
cancel;
|
|
2293
|
+
layout;
|
|
1094
2294
|
settled;
|
|
1095
2295
|
settlement = new Settlement();
|
|
1096
2296
|
heading;
|
|
1097
2297
|
list;
|
|
1098
|
-
constructor(palette, heading, body, items, choose, cancel) {
|
|
2298
|
+
constructor(palette, heading, body, items, choose, cancel, layout) {
|
|
1099
2299
|
this.palette = palette;
|
|
1100
2300
|
this.body = body;
|
|
2301
|
+
this.choose = choose;
|
|
2302
|
+
this.cancel = cancel;
|
|
2303
|
+
this.layout = layout;
|
|
1101
2304
|
this.settled = this.settlement.settled;
|
|
1102
2305
|
this.heading = new Text(heading, 0, 0);
|
|
1103
|
-
this.list =
|
|
1104
|
-
|
|
1105
|
-
|
|
2306
|
+
this.list = this.listOver(items);
|
|
2307
|
+
}
|
|
2308
|
+
/**
|
|
2309
|
+
* A select list over `items` wired to this prompt's settlement. `SelectList`
|
|
2310
|
+
* takes its rows at construction and exposes no way to replace them, so a
|
|
2311
|
+
* different row set means a different list.
|
|
2312
|
+
* @param items - the rows to show.
|
|
2313
|
+
* @returns the list, highlighting its first row.
|
|
2314
|
+
*/
|
|
2315
|
+
listOver(items) {
|
|
2316
|
+
const list = new SelectList(items, SELECT_MAX_VISIBLE, selectListTheme(this.palette), this.layout);
|
|
2317
|
+
list.onSelect = (item) => {
|
|
2318
|
+
this.settlement.settle(this.choose(item));
|
|
1106
2319
|
};
|
|
1107
|
-
|
|
1108
|
-
this.settlement.settle(cancel());
|
|
2320
|
+
list.onCancel = () => {
|
|
2321
|
+
this.settlement.settle(this.cancel());
|
|
1109
2322
|
};
|
|
2323
|
+
return list;
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Show `items` instead of the current rows, highlighting the first one.
|
|
2327
|
+
* @param items - the rows to show.
|
|
2328
|
+
*/
|
|
2329
|
+
setRows(items) {
|
|
2330
|
+
this.list = this.listOver(items);
|
|
1110
2331
|
}
|
|
1111
2332
|
/**
|
|
1112
2333
|
* Settle from outside the list, for withdrawal.
|
|
@@ -1136,9 +2357,18 @@ var ListPrompt = class {
|
|
|
1136
2357
|
"",
|
|
1137
2358
|
...this.heading.render(width),
|
|
1138
2359
|
...body,
|
|
1139
|
-
...this.
|
|
2360
|
+
...this.listLines(width)
|
|
1140
2361
|
];
|
|
1141
2362
|
}
|
|
2363
|
+
/**
|
|
2364
|
+
* The lines under the body rows: the select list, which a subclass may
|
|
2365
|
+
* precede or replace.
|
|
2366
|
+
* @param width - the terminal width.
|
|
2367
|
+
* @returns the rendered lines.
|
|
2368
|
+
*/
|
|
2369
|
+
listLines(width) {
|
|
2370
|
+
return this.list.render(width);
|
|
2371
|
+
}
|
|
1142
2372
|
};
|
|
1143
2373
|
/**
|
|
1144
2374
|
* Approval question: allow this one tool call or reject it; Escape rejects.
|
|
@@ -1164,19 +2394,168 @@ var ApprovalPrompt = class extends ListPrompt {
|
|
|
1164
2394
|
};
|
|
1165
2395
|
/** Marks the row a picker opened on, so it stays visible after the highlight moves. */
|
|
1166
2396
|
const CURRENT_MARK = " ✓";
|
|
1167
|
-
/**
|
|
2397
|
+
/** What the filter line says while the query is empty. */
|
|
2398
|
+
const FILTER_HINT = "type to filter · Enter selects · Esc cancels";
|
|
2399
|
+
/** Characters a typed query never carries: C0 controls, DEL, and C1 controls. */
|
|
2400
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
2401
|
+
/**
|
|
2402
|
+
* The text one key press types.
|
|
2403
|
+
* @param data - the bytes the terminal sent.
|
|
2404
|
+
* @returns the characters to append to a query, or undefined for control keys and escape sequences.
|
|
2405
|
+
*/
|
|
2406
|
+
function typedText(data) {
|
|
2407
|
+
const kitty = decodeKittyPrintable(data);
|
|
2408
|
+
if (kitty !== void 0) return kitty;
|
|
2409
|
+
return CONTROL_CHARACTERS.test(data) ? void 0 : data;
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* The text a picker row is matched against.
|
|
2413
|
+
* @param row - the row.
|
|
2414
|
+
* @returns the label and description joined, so one query can span both.
|
|
2415
|
+
*/
|
|
2416
|
+
function rowText(row) {
|
|
2417
|
+
return row.description === void 0 ? row.label : `${row.label} ${row.description}`;
|
|
2418
|
+
}
|
|
2419
|
+
/**
|
|
2420
|
+
* A generic list picker (models, sessions) with a type-to-filter query.
|
|
2421
|
+
* Printable keys extend the query, Backspace drops its last character, and
|
|
2422
|
+
* Ctrl+U clears it; Escape clears a non-empty query and settles undefined
|
|
2423
|
+
* once the query is empty. The visible rows are the query's fuzzy matches
|
|
2424
|
+
* against each row's label and description, best match first; the row in
|
|
2425
|
+
* force keeps its mark and stays highlighted only while the query is empty.
|
|
2426
|
+
*/
|
|
1168
2427
|
var PickPrompt = class extends ListPrompt {
|
|
2428
|
+
/** Every row in declared order, without the mark, as matched against. */
|
|
2429
|
+
rows;
|
|
2430
|
+
/** The same rows with the in-force one marked, shown while the query is empty. */
|
|
2431
|
+
markedRows;
|
|
2432
|
+
/** Index of the row in force, or -1 when no row is. */
|
|
2433
|
+
inForce;
|
|
2434
|
+
/** What the user has typed, empty until the first printable key. */
|
|
2435
|
+
query = "";
|
|
2436
|
+
/** The rows the list shows: all of them, or the query's matches. */
|
|
2437
|
+
visible;
|
|
1169
2438
|
constructor(palette, title, items, options = {}) {
|
|
2439
|
+
const rows = items.map((item) => ({ ...item }));
|
|
1170
2440
|
const inForce = items.findIndex((item) => item.value === options.current);
|
|
1171
|
-
|
|
1172
|
-
...
|
|
1173
|
-
label: `${
|
|
1174
|
-
} :
|
|
2441
|
+
const markedRows = rows.map((row, index) => index === inForce ? {
|
|
2442
|
+
...row,
|
|
2443
|
+
label: `${row.label}${CURRENT_MARK}`
|
|
2444
|
+
} : row);
|
|
2445
|
+
super(palette, `${palette.accent("?")} ${palette.bold(title)}`, options.body ?? [], [...markedRows], (row) => items.find((item) => item.value === row.value), () => void 0, options.layout);
|
|
2446
|
+
this.rows = rows;
|
|
2447
|
+
this.markedRows = markedRows;
|
|
2448
|
+
this.inForce = inForce;
|
|
2449
|
+
this.visible = markedRows;
|
|
1175
2450
|
if (inForce > 0) this.highlight(inForce);
|
|
1176
2451
|
}
|
|
2452
|
+
/**
|
|
2453
|
+
* Apply `query` and rebuild the visible rows from it.
|
|
2454
|
+
* @param query - the new query; an empty one restores the declared order, the mark, and the row in force.
|
|
2455
|
+
*/
|
|
2456
|
+
setQuery(query) {
|
|
2457
|
+
if (query === this.query) return;
|
|
2458
|
+
this.query = query;
|
|
2459
|
+
this.visible = query === "" ? this.markedRows : fuzzyFilter([...this.rows], query, rowText);
|
|
2460
|
+
this.setRows([...this.visible]);
|
|
2461
|
+
if (query === "" && this.inForce > 0) this.highlight(this.inForce);
|
|
2462
|
+
}
|
|
1177
2463
|
withdraw() {
|
|
1178
2464
|
this.settle(void 0);
|
|
1179
2465
|
}
|
|
2466
|
+
handleInput(data) {
|
|
2467
|
+
if (matchesKey(data, "escape") && this.query !== "") {
|
|
2468
|
+
this.setQuery("");
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
if (matchesKey(data, "backspace")) {
|
|
2472
|
+
this.setQuery(this.query.slice(0, -1));
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
2476
|
+
this.setQuery("");
|
|
2477
|
+
return;
|
|
2478
|
+
}
|
|
2479
|
+
const typed = typedText(data);
|
|
2480
|
+
if (typed === void 0) {
|
|
2481
|
+
super.handleInput(data);
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
this.setQuery(this.query + typed);
|
|
2485
|
+
}
|
|
2486
|
+
listLines(width) {
|
|
2487
|
+
const filter = this.query === "" ? FILTER_HINT : `filter: ${this.query} · ${String(this.visible.length)}/${String(this.rows.length)}`;
|
|
2488
|
+
const shown = this.query !== "" && this.visible.length === 0 ? [this.palette.dim(` no row matches "${this.query}"`)] : super.listLines(width);
|
|
2489
|
+
return [this.palette.dim(filter), ...shown];
|
|
2490
|
+
}
|
|
2491
|
+
};
|
|
2492
|
+
/** Detail rows a read-only page draws at once; the rest wait behind a scroll. */
|
|
2493
|
+
const DETAIL_MAX_VISIBLE = 16;
|
|
2494
|
+
/** The keys a read-only detail page answers, drawn dim under its rows. */
|
|
2495
|
+
const DETAIL_HINT = "↑ ↓ scroll · Enter, Esc, or ← returns";
|
|
2496
|
+
/**
|
|
2497
|
+
* A read-only page over rows the caller already rendered: an accented
|
|
2498
|
+
* heading, the rows wrapped to the terminal width, and the hint line. `Up`
|
|
2499
|
+
* and `Down` move one row, `PageUp` and `PageDown` a full page, and the hint
|
|
2500
|
+
* line carries the first visible row and the total once the rows pass
|
|
2501
|
+
* {@link DETAIL_MAX_VISIBLE}. `Enter`, `Escape`, and `Left` settle it; every
|
|
2502
|
+
* other key is ignored.
|
|
2503
|
+
*/
|
|
2504
|
+
var DetailPrompt = class {
|
|
2505
|
+
palette;
|
|
2506
|
+
rows;
|
|
2507
|
+
settled;
|
|
2508
|
+
settlement = new Settlement();
|
|
2509
|
+
heading;
|
|
2510
|
+
/** Index of the first visible wrapped row. */
|
|
2511
|
+
offset = 0;
|
|
2512
|
+
/** Wrapped rows the last render produced, which bounds scrolling; 0 before the first render. */
|
|
2513
|
+
total = 0;
|
|
2514
|
+
constructor(palette, heading, rows) {
|
|
2515
|
+
this.palette = palette;
|
|
2516
|
+
this.rows = rows;
|
|
2517
|
+
this.settled = this.settlement.settled;
|
|
2518
|
+
this.heading = new Text(palette.bold(palette.accent(heading)), 0, 0);
|
|
2519
|
+
}
|
|
2520
|
+
/**
|
|
2521
|
+
* The largest first-visible row index; 0 while every row fits at once.
|
|
2522
|
+
* @returns the index scrolling stops at.
|
|
2523
|
+
*/
|
|
2524
|
+
maxOffset() {
|
|
2525
|
+
return Math.max(0, this.total - DETAIL_MAX_VISIBLE);
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* Move the visible window, stopping at both ends.
|
|
2529
|
+
* @param step - rows to move by; negative scrolls toward the first row.
|
|
2530
|
+
*/
|
|
2531
|
+
scroll(step) {
|
|
2532
|
+
this.offset = Math.max(0, Math.min(this.offset + step, this.maxOffset()));
|
|
2533
|
+
}
|
|
2534
|
+
withdraw() {
|
|
2535
|
+
this.settlement.settle();
|
|
2536
|
+
}
|
|
2537
|
+
handleInput(data) {
|
|
2538
|
+
if (matchesKey(data, "up")) this.scroll(-1);
|
|
2539
|
+
else if (matchesKey(data, "down")) this.scroll(1);
|
|
2540
|
+
else if (matchesKey(data, "pageUp")) this.scroll(-16);
|
|
2541
|
+
else if (matchesKey(data, "pageDown")) this.scroll(DETAIL_MAX_VISIBLE);
|
|
2542
|
+
else if (matchesKey(data, "enter") || matchesKey(data, "escape") || matchesKey(data, "left")) this.settlement.settle();
|
|
2543
|
+
}
|
|
2544
|
+
invalidate() {
|
|
2545
|
+
this.heading.invalidate();
|
|
2546
|
+
}
|
|
2547
|
+
render(width) {
|
|
2548
|
+
const wrapped = this.rows.flatMap((row) => wrapTextWithAnsi(row, Math.max(1, width)));
|
|
2549
|
+
this.total = wrapped.length;
|
|
2550
|
+
this.offset = Math.min(this.offset, this.maxOffset());
|
|
2551
|
+
const position = this.maxOffset() === 0 ? "" : ` · (${String(this.offset + 1)}/${String(this.total)})`;
|
|
2552
|
+
return [
|
|
2553
|
+
"",
|
|
2554
|
+
...this.heading.render(width),
|
|
2555
|
+
...wrapped.slice(this.offset, this.offset + DETAIL_MAX_VISIBLE),
|
|
2556
|
+
this.palette.dim(`${DETAIL_HINT}${position}`)
|
|
2557
|
+
];
|
|
2558
|
+
}
|
|
1180
2559
|
};
|
|
1181
2560
|
/** Marker value of the free-text row. */
|
|
1182
2561
|
const CUSTOM_VALUE = "\0custom";
|
|
@@ -1428,200 +2807,96 @@ async function listSessionChoices(ctx, currentId, signal) {
|
|
|
1428
2807
|
* The picker row for one session: title or id, then the workspace and date.
|
|
1429
2808
|
* @param choice - the session.
|
|
1430
2809
|
* @returns the label and description.
|
|
1431
|
-
*/
|
|
1432
|
-
function describeSession(choice) {
|
|
1433
|
-
const parts = [
|
|
1434
|
-
if (choice.cwd !== void 0) parts.push(choice.cwd);
|
|
1435
|
-
if (choice.current) parts.push("current");
|
|
1436
|
-
return {
|
|
1437
|
-
label: choice.title ?? choice.id,
|
|
1438
|
-
description: parts.join(" · ")
|
|
1439
|
-
};
|
|
1440
|
-
}
|
|
1441
|
-
//#endregion
|
|
1442
|
-
//#region lib/types/status.js
|
|
1443
|
-
/**
|
|
1444
|
-
* Persistent session status for the terminal: one read of the session
|
|
1445
|
-
* projections becomes plain facts, and pure formatters turn those facts into
|
|
1446
|
-
* footer parts, the `/status` report, and the one-line notices for
|
|
1447
|
-
* compaction and model-request retries. Nothing here touches the terminal,
|
|
1448
|
-
* the palette, or the agent.
|
|
1449
|
-
* @module @deepseek-ai/dsh-tui-app/status
|
|
1450
|
-
*/
|
|
1451
|
-
/** The client-visible keys one status read selects. */
|
|
1452
|
-
const STATUS_KEYS = [
|
|
1453
|
-
"contextPressure",
|
|
1454
|
-
"contextBreakdown",
|
|
1455
|
-
"tokenUsage",
|
|
1456
|
-
"sessionStats",
|
|
1457
|
-
"todos",
|
|
1458
|
-
"goal",
|
|
1459
|
-
"plan",
|
|
1460
|
-
"permissions"
|
|
1461
|
-
];
|
|
1462
|
-
/**
|
|
1463
|
-
* Read one consistent cut of the status projections for `session`.
|
|
1464
|
-
* @param projections - the session-projection registry (`ctx.get('sessionProjections')`).
|
|
1465
|
-
* @param session - the session whose status is read.
|
|
1466
|
-
* @returns the facts every registered key yields; unregistered keys leave their fact absent.
|
|
1467
|
-
*/
|
|
1468
|
-
function readStatusFacts(projections, session) {
|
|
1469
|
-
const { values } = projections.snapshot(session, STATUS_KEYS);
|
|
1470
|
-
const facts = {};
|
|
1471
|
-
const pressure = values.contextPressure;
|
|
1472
|
-
const used = pressure?.projectedTokens ?? pressure?.pressureTokens;
|
|
1473
|
-
if (used !== void 0 && pressure?.contextWindow !== void 0) facts.context = {
|
|
1474
|
-
used,
|
|
1475
|
-
window: pressure.contextWindow,
|
|
1476
|
-
percent: Math.min(100, Math.round(used / pressure.contextWindow * 100))
|
|
1477
|
-
};
|
|
1478
|
-
if (values.contextBreakdown !== void 0) facts.breakdown = values.contextBreakdown;
|
|
1479
|
-
if (values.tokenUsage !== void 0) facts.tokenUsage = values.tokenUsage;
|
|
1480
|
-
if (values.sessionStats !== void 0) facts.stats = values.sessionStats;
|
|
1481
|
-
if (values.todos !== void 0 && values.todos !== null) {
|
|
1482
|
-
const items = values.todos;
|
|
1483
|
-
facts.todos = {
|
|
1484
|
-
items,
|
|
1485
|
-
done: items.filter((item) => item.status === "completed").length,
|
|
1486
|
-
active: items.filter((item) => item.status === "in_progress").length,
|
|
1487
|
-
pending: items.filter((item) => item.status === "pending").length
|
|
1488
|
-
};
|
|
1489
|
-
}
|
|
1490
|
-
if (values.goal !== void 0 && values.goal !== null) {
|
|
1491
|
-
const { goal, roundsStarted } = values.goal;
|
|
1492
|
-
facts.goal = {
|
|
1493
|
-
phase: goal.phase,
|
|
1494
|
-
objective: goal.objective,
|
|
1495
|
-
round: roundsStarted,
|
|
1496
|
-
maxRounds: goal.maxGoalRounds,
|
|
1497
|
-
...goal.blockedReason === void 0 ? {} : { blockedReason: goal.blockedReason.message }
|
|
1498
|
-
};
|
|
1499
|
-
}
|
|
1500
|
-
if (values.plan !== void 0) facts.plan = values.plan;
|
|
1501
|
-
if (values.permissions !== void 0) facts.permissions = values.permissions;
|
|
1502
|
-
return facts;
|
|
1503
|
-
}
|
|
1504
|
-
/**
|
|
1505
|
-
* The short footer parts: `ctx 42%`, `todo 2/5` (done over total),
|
|
1506
|
-
* `goal active`, and `plan` (`plan…` while a mode switch is pending).
|
|
1507
|
-
* @param facts - the facts one status read produced.
|
|
1508
|
-
* @returns one part per present fact, in footer order; empty when nothing is known.
|
|
1509
|
-
*/
|
|
1510
|
-
function footerStatus(facts) {
|
|
1511
|
-
const parts = [];
|
|
1512
|
-
if (facts.context !== void 0) parts.push(`ctx ${String(facts.context.percent)}%`);
|
|
1513
|
-
if (facts.todos !== void 0) parts.push(`todo ${String(facts.todos.done)}/${String(facts.todos.items.length)}`);
|
|
1514
|
-
if (facts.goal !== void 0) parts.push(`goal ${facts.goal.phase}`);
|
|
1515
|
-
if (facts.plan?.pending === true) parts.push("plan…");
|
|
1516
|
-
else if (facts.plan?.active === true) parts.push("plan");
|
|
1517
|
-
return parts;
|
|
1518
|
-
}
|
|
1519
|
-
/** Glyph per todo status, matching the browser's list markers. */
|
|
1520
|
-
const TODO_GLYPH = {
|
|
1521
|
-
completed: "✓",
|
|
1522
|
-
in_progress: "▸",
|
|
1523
|
-
pending: "○"
|
|
1524
|
-
};
|
|
1525
|
-
/**
|
|
1526
|
-
* The multi-line `/status` report: context occupancy and composition, token
|
|
1527
|
-
* usage with the cache-hit share, session stats, the todo list, the goal,
|
|
1528
|
-
* plan mode, and the permission preset — one section per present fact.
|
|
1529
|
-
* @param facts - the facts one status read produced.
|
|
1530
|
-
* @returns the report lines; a single explanatory line when nothing is known.
|
|
1531
|
-
*/
|
|
1532
|
-
function statusReport(facts) {
|
|
1533
|
-
const lines = [];
|
|
1534
|
-
if (facts.context !== void 0) {
|
|
1535
|
-
const { used, window, percent } = facts.context;
|
|
1536
|
-
lines.push(`context: ~${formatTokens(used)} / ${formatTokens(window)} (${String(percent)}%)`);
|
|
1537
|
-
}
|
|
1538
|
-
if (facts.breakdown !== void 0) {
|
|
1539
|
-
const { systemTokens, toolsTokens, messageTokens } = facts.breakdown;
|
|
1540
|
-
lines.push(` system ~${formatTokens(systemTokens)} · tools ~${formatTokens(toolsTokens)} · messages ~${formatTokens(messageTokens)}`);
|
|
1541
|
-
}
|
|
1542
|
-
if (facts.tokenUsage !== void 0) {
|
|
1543
|
-
const usage = facts.tokenUsage;
|
|
1544
|
-
const parts = [
|
|
1545
|
-
`↑${formatTokens(usage.uncachedInputTokens)} uncached`,
|
|
1546
|
-
`cache read ${formatTokens(usage.cacheReadTokens)}`,
|
|
1547
|
-
`cache write ${formatTokens(usage.cacheWriteTokens)}`,
|
|
1548
|
-
`↓${formatTokens(usage.outputTokens)}`
|
|
1549
|
-
];
|
|
1550
|
-
const hit = cacheHitPercent(usage);
|
|
1551
|
-
if (hit !== null) parts.push(`cache hit ${hit}%`);
|
|
1552
|
-
lines.push(`tokens: ${parts.join(" · ")}`);
|
|
1553
|
-
}
|
|
1554
|
-
if (facts.stats !== void 0) {
|
|
1555
|
-
const stats = facts.stats;
|
|
1556
|
-
const parts = [
|
|
1557
|
-
`${String(stats.turns)} ${plural(stats.turns, "turn")}`,
|
|
1558
|
-
`${String(stats.steps)} ${plural(stats.steps, "step")}`,
|
|
1559
|
-
`model ${formatDuration(stats.llmMs)}`,
|
|
1560
|
-
`tools ${formatDuration(stats.toolMs)}`
|
|
1561
|
-
];
|
|
1562
|
-
if (stats.ttftSteps > 0) parts.push(`first token ${formatDuration(stats.ttftMs / stats.ttftSteps)} avg`);
|
|
1563
|
-
if (stats.decodeMs > 0) parts.push(`${String(Math.round(stats.decodeTokens / stats.decodeMs * 1e3))} tok/s`);
|
|
1564
|
-
lines.push(`session: ${parts.join(" · ")}`);
|
|
1565
|
-
}
|
|
1566
|
-
if (facts.todos !== void 0) {
|
|
1567
|
-
const { items, done, active, pending } = facts.todos;
|
|
1568
|
-
lines.push(`todos: ${String(done)} done · ${String(active)} active · ${String(pending)} pending`);
|
|
1569
|
-
for (const item of items) lines.push(` ${TODO_GLYPH[item.status]} ${item.content}`);
|
|
1570
|
-
}
|
|
1571
|
-
if (facts.goal !== void 0) {
|
|
1572
|
-
const goal = facts.goal;
|
|
1573
|
-
lines.push(`goal: ${goal.phase} · round ${String(goal.round)}/${String(goal.maxRounds)} · ${goal.objective}`);
|
|
1574
|
-
if (goal.blockedReason !== void 0) lines.push(` blocked: ${goal.blockedReason}`);
|
|
1575
|
-
}
|
|
1576
|
-
if (facts.plan !== void 0) lines.push(`plan: ${facts.plan.active ? "on" : "off"}${facts.plan.pending ? " (switching)" : ""}`);
|
|
1577
|
-
if (facts.permissions !== void 0) lines.push(`permission: ${facts.permissions.currentValue}`);
|
|
1578
|
-
return lines.length === 0 ? ["no session status yet"] : lines;
|
|
2810
|
+
*/
|
|
2811
|
+
function describeSession(choice) {
|
|
2812
|
+
const parts = [formatTimestamp(choice.createdAt)];
|
|
2813
|
+
if (choice.cwd !== void 0) parts.push(choice.cwd);
|
|
2814
|
+
if (choice.current) parts.push("current");
|
|
2815
|
+
return {
|
|
2816
|
+
label: choice.title ?? choice.id,
|
|
2817
|
+
description: parts.join(" · ")
|
|
2818
|
+
};
|
|
1579
2819
|
}
|
|
2820
|
+
/** Two-space indentation per nesting level, as the `/subagents` rows indent. */
|
|
2821
|
+
const DEPTH_INDENT = " ";
|
|
2822
|
+
/** What separates two facts inside one row, and two parts of the heading. */
|
|
2823
|
+
const SEPARATOR = " · ";
|
|
2824
|
+
/** The keys the focused panel answers, appended to its heading. */
|
|
2825
|
+
const FOCUS_HINTS = `↑ ↓ select${SEPARATOR}Enter details${SEPARATOR}Esc back`;
|
|
1580
2826
|
/**
|
|
1581
|
-
* The
|
|
1582
|
-
*
|
|
1583
|
-
* @
|
|
1584
|
-
*
|
|
2827
|
+
* The elapsed part of a row: the open turn's running time, else the total the
|
|
2828
|
+
* child's settled turns took. Absent when no timing projection is registered.
|
|
2829
|
+
* @param facts - the live facts sampled for the child, when it has any.
|
|
2830
|
+
* @param now - the wall clock the running time is measured against.
|
|
2831
|
+
* @returns the formatted elapsed time, or undefined when the child has none.
|
|
1585
2832
|
*/
|
|
1586
|
-
function
|
|
1587
|
-
|
|
1588
|
-
if (
|
|
1589
|
-
if (promptTokens === usage.cacheReadTokens) return "100";
|
|
1590
|
-
const percent = usage.cacheReadTokens / promptTokens * 100;
|
|
1591
|
-
const rounded = Math.round(percent);
|
|
1592
|
-
return rounded < 100 ? String(rounded) : String(Math.floor(percent * 10) / 10);
|
|
2833
|
+
function elapsedOf(facts, now) {
|
|
2834
|
+
if (facts?.activeSince !== void 0) return formatElapsed(now - facts.activeSince);
|
|
2835
|
+
if (facts?.settledMs !== void 0) return formatElapsed(facts.settledMs);
|
|
1593
2836
|
}
|
|
1594
2837
|
/**
|
|
1595
|
-
*
|
|
1596
|
-
* @param
|
|
1597
|
-
* @
|
|
2838
|
+
* One listing entry as a row.
|
|
2839
|
+
* @param entry - the listing entry.
|
|
2840
|
+
* @param facts - the live facts sampled for it, when it has any.
|
|
2841
|
+
* @param now - the wall clock the running time is measured against.
|
|
2842
|
+
* @returns the row.
|
|
1598
2843
|
*/
|
|
1599
|
-
function
|
|
1600
|
-
const
|
|
1601
|
-
if (
|
|
1602
|
-
|
|
1603
|
-
|
|
2844
|
+
function rowOf(entry, facts, now) {
|
|
2845
|
+
const indent = DEPTH_INDENT.repeat(entry.depth - 1);
|
|
2846
|
+
if (entry.kind === "diagnostic") return {
|
|
2847
|
+
id: entry.id,
|
|
2848
|
+
enterable: false,
|
|
2849
|
+
ticking: false,
|
|
2850
|
+
text: `${indent}${entry.id}${SEPARATOR}unreadable: ${entry.reason}`
|
|
2851
|
+
};
|
|
2852
|
+
const parts = [
|
|
2853
|
+
entry.mode,
|
|
2854
|
+
"resident",
|
|
2855
|
+
facts?.running === true ? "running" : "idle"
|
|
2856
|
+
];
|
|
2857
|
+
const elapsed = elapsedOf(facts, now);
|
|
2858
|
+
if (elapsed !== void 0) parts.push(elapsed);
|
|
2859
|
+
if (facts?.usage !== void 0) parts.push(`↑${formatTokens(facts.usage.inputTokens)} ↓${formatTokens(facts.usage.outputTokens)}`);
|
|
2860
|
+
return {
|
|
2861
|
+
id: entry.id,
|
|
2862
|
+
enterable: true,
|
|
2863
|
+
ticking: facts?.activeSince !== void 0,
|
|
2864
|
+
text: `${indent}${entry.label ?? entry.id}${SEPARATOR}${parts.join(SEPARATOR)}`
|
|
2865
|
+
};
|
|
1604
2866
|
}
|
|
1605
2867
|
/**
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
* @
|
|
2868
|
+
* Build one panel draw: every resident child and every diagnostic candidate
|
|
2869
|
+
* of the listing, in listing order, cut to {@link SUBAGENT_PANEL_MAX_ROWS}.
|
|
2870
|
+
* @param inputs - the listing, the live facts, and the current time.
|
|
2871
|
+
* @returns the drawn rows, the count they left out, and whether any of them advances with the clock.
|
|
1609
2872
|
*/
|
|
1610
|
-
function
|
|
1611
|
-
const
|
|
1612
|
-
|
|
2873
|
+
function subagentPanelView(inputs) {
|
|
2874
|
+
const listed = inputs.entries.filter((entry) => entry.kind === "diagnostic" || entry.activity === "running");
|
|
2875
|
+
const rows = listed.slice(0, 6).map((entry) => rowOf(entry, inputs.facts.get(entry.id), inputs.now));
|
|
2876
|
+
return {
|
|
2877
|
+
rows,
|
|
2878
|
+
hidden: listed.length - rows.length,
|
|
2879
|
+
ticking: rows.some((row) => row.ticking)
|
|
2880
|
+
};
|
|
1613
2881
|
}
|
|
1614
2882
|
/**
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
2883
|
+
* Render the panel: a heading counting the listed children, one line per
|
|
2884
|
+
* drawn row, the overflow count, and the last listing failure. The whole
|
|
2885
|
+
* panel is dim while the keyboard is elsewhere; the selected row is accented
|
|
2886
|
+
* while the panel holds it.
|
|
2887
|
+
* @param view - the rows one draw produced.
|
|
2888
|
+
* @param render - the palette, the selected row, and the listing failure.
|
|
2889
|
+
* @returns the panel text, one line per row.
|
|
1619
2890
|
*/
|
|
1620
|
-
function
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
2891
|
+
function renderSubagentPanel(view, render) {
|
|
2892
|
+
const { palette, selected } = render;
|
|
2893
|
+
const total = view.rows.length + view.hidden;
|
|
2894
|
+
const heading = `subagents${SEPARATOR}${String(total)} listed${selected === void 0 ? "" : `${SEPARATOR}${FOCUS_HINTS}`}`;
|
|
2895
|
+
const lines = [palette.dim(heading)];
|
|
2896
|
+
for (const [index, row] of view.rows.entries()) lines.push(index === selected ? palette.bold(palette.accent(row.text)) : palette.dim(row.text));
|
|
2897
|
+
if (view.hidden > 0) lines.push(palette.dim(`+${String(view.hidden)} more${SEPARATOR}/subagents lists them all`));
|
|
2898
|
+
if (render.failure !== void 0) lines.push(palette.dim(`listing failed: ${render.failure}`));
|
|
2899
|
+
return lines.join("\n");
|
|
1625
2900
|
}
|
|
1626
2901
|
//#endregion
|
|
1627
2902
|
//#region lib/types/app.js
|
|
@@ -1629,11 +2904,76 @@ function plural(count, noun) {
|
|
|
1629
2904
|
* The interactive terminal application: it renders the durable session log
|
|
1630
2905
|
* and the live assistant stream of one Agent at a time into a pi-tui tree,
|
|
1631
2906
|
* turns keystrokes into agent input, answers the approval and user-questions
|
|
1632
|
-
* seams for that Agent, and switches between sessions through its host.
|
|
2907
|
+
* seams for that Agent, and switches between sessions through its host. Under
|
|
2908
|
+
* the editor it keeps two docked regions the keyboard can take over — the
|
|
2909
|
+
* subagent panel and the status bar — and one repeating tick advances their
|
|
2910
|
+
* elapsed counters and re-reads a stale subagent listing. A second tick, at
|
|
2911
|
+
* its own period, brightens the text of the message streaming right now.
|
|
1633
2912
|
* @module @deepseek-ai/dsh-tui-app/app
|
|
1634
2913
|
*/
|
|
1635
2914
|
/** A second Ctrl+C inside this window quits. */
|
|
1636
2915
|
const QUIT_DOUBLE_PRESS_MS = 600;
|
|
2916
|
+
/**
|
|
2917
|
+
* Row layout of the todo picker: the label column grows with the widest todo
|
|
2918
|
+
* line instead of stopping at the list's 32-column default, which would cut a
|
|
2919
|
+
* todo well inside the width its rows are built for. 68 columns hold the
|
|
2920
|
+
* status glyph, the row's own content cap, and the gap before the status
|
|
2921
|
+
* column. A presentation choice of this terminal surface, not a deployment
|
|
2922
|
+
* setting.
|
|
2923
|
+
*/
|
|
2924
|
+
const TODO_ROW_LAYOUT = {
|
|
2925
|
+
minPrimaryColumnWidth: 1,
|
|
2926
|
+
maxPrimaryColumnWidth: 68
|
|
2927
|
+
};
|
|
2928
|
+
/** The panel draw of a session with no subagent rows. */
|
|
2929
|
+
const EMPTY_PANEL_VIEW = {
|
|
2930
|
+
rows: [],
|
|
2931
|
+
hidden: 0,
|
|
2932
|
+
ticking: false
|
|
2933
|
+
};
|
|
2934
|
+
/**
|
|
2935
|
+
* How long the application waits for the terminal to answer the OSC 11
|
|
2936
|
+
* background-color query it sends once at start. The query is a round trip to
|
|
2937
|
+
* the attached terminal, so this bounds one local handshake, not a deployment
|
|
2938
|
+
* choice; a terminal that stays silent leaves the fade in its two-level mode.
|
|
2939
|
+
*/
|
|
2940
|
+
const BACKGROUND_QUERY_TIMEOUT_MS = 200;
|
|
2941
|
+
/** Drawing settings before the background query settles, and whenever the terminal draws no ramp. */
|
|
2942
|
+
const NO_FADE = {
|
|
2943
|
+
capability: "none",
|
|
2944
|
+
ramp: []
|
|
2945
|
+
};
|
|
2946
|
+
/**
|
|
2947
|
+
* Relative luminance of the terminal background at which the foreground is
|
|
2948
|
+
* taken to be dark rather than light, as a fraction of a full channel.
|
|
2949
|
+
*/
|
|
2950
|
+
const DARK_BACKGROUND_LUMINANCE = .5;
|
|
2951
|
+
/** The foreground assumed over a dark background. */
|
|
2952
|
+
const LIGHT_FOREGROUND = {
|
|
2953
|
+
r: 255,
|
|
2954
|
+
g: 255,
|
|
2955
|
+
b: 255
|
|
2956
|
+
};
|
|
2957
|
+
/** The foreground assumed over a light background. */
|
|
2958
|
+
const DARK_FOREGROUND = {
|
|
2959
|
+
r: 0,
|
|
2960
|
+
g: 0,
|
|
2961
|
+
b: 0
|
|
2962
|
+
};
|
|
2963
|
+
/**
|
|
2964
|
+
* The foreground the ramp climbs towards.
|
|
2965
|
+
*
|
|
2966
|
+
* pi-tui reports the terminal background but never its foreground, so this is
|
|
2967
|
+
* an assumption: a light foreground over a dark background and the reverse.
|
|
2968
|
+
* It is never drawn - the streaming block withholds the oldest visible level,
|
|
2969
|
+
* which is the only level this color reaches - and only sets the direction
|
|
2970
|
+
* and spacing of the levels below it.
|
|
2971
|
+
* @param background - the background the terminal reported.
|
|
2972
|
+
* @returns the assumed foreground.
|
|
2973
|
+
*/
|
|
2974
|
+
function assumedForeground(background) {
|
|
2975
|
+
return (.2126 * background.r + .7152 * background.g + .0722 * background.b) / 255 < DARK_BACKGROUND_LUMINANCE ? LIGHT_FOREGROUND : DARK_FOREGROUND;
|
|
2976
|
+
}
|
|
1637
2977
|
/** The terminal's own commands, handled before the shared command registry. */
|
|
1638
2978
|
const LOCAL_COMMANDS = [
|
|
1639
2979
|
{
|
|
@@ -1692,6 +3032,10 @@ const LOCAL_COMMANDS = [
|
|
|
1692
3032
|
name: "status",
|
|
1693
3033
|
description: "Show context usage, token totals, session stats, todos, goal, plan, and permission"
|
|
1694
3034
|
},
|
|
3035
|
+
{
|
|
3036
|
+
name: "todos",
|
|
3037
|
+
description: "Browse the agent's todo list (Enter shows one item in full)"
|
|
3038
|
+
},
|
|
1695
3039
|
{
|
|
1696
3040
|
name: "outline",
|
|
1697
3041
|
description: "List the turns of this session with their prompts and replies"
|
|
@@ -1702,7 +3046,7 @@ const LOCAL_COMMANDS = [
|
|
|
1702
3046
|
},
|
|
1703
3047
|
{
|
|
1704
3048
|
name: "subagents",
|
|
1705
|
-
description: "
|
|
3049
|
+
description: "Browse the subagent sessions under this session (Enter shows one session's details)"
|
|
1706
3050
|
},
|
|
1707
3051
|
{
|
|
1708
3052
|
name: "settings",
|
|
@@ -1743,6 +3087,9 @@ var TuiApp = class {
|
|
|
1743
3087
|
loader;
|
|
1744
3088
|
modalSlot = new Container();
|
|
1745
3089
|
editor;
|
|
3090
|
+
/** Holds {@link panel} exactly while the bound session has subagent rows. */
|
|
3091
|
+
panelSlot = new Container();
|
|
3092
|
+
panel;
|
|
1746
3093
|
footer;
|
|
1747
3094
|
modals;
|
|
1748
3095
|
theme;
|
|
@@ -1750,7 +3097,43 @@ var TuiApp = class {
|
|
|
1750
3097
|
toolArguments = /* @__PURE__ */ new Map();
|
|
1751
3098
|
submittedIds = /* @__PURE__ */ new Set();
|
|
1752
3099
|
disposers = [];
|
|
3100
|
+
/** Turn facts of the bound session's todo lines, keyed by content. */
|
|
3101
|
+
todoTurns = /* @__PURE__ */ new Map();
|
|
3102
|
+
/** The turn the last logged `turn/start` opened; 0 before the first one. */
|
|
3103
|
+
turn = 0;
|
|
3104
|
+
/** When the running turn started, from its `turn/start` envelope; absent between turns. */
|
|
3105
|
+
turnStartedAt;
|
|
1753
3106
|
pending = [];
|
|
3107
|
+
/** The home directory the footer shortens the workspace path against. */
|
|
3108
|
+
home = homedir();
|
|
3109
|
+
/** The segments of the last footer draw, in bar order. */
|
|
3110
|
+
segments = [];
|
|
3111
|
+
/** Which docked region owns the keyboard. */
|
|
3112
|
+
focus = "editor";
|
|
3113
|
+
/** The segment the status bar holds; read only while the bar has focus. */
|
|
3114
|
+
barSelection = FIRST_FOOTER_SEGMENT;
|
|
3115
|
+
/** The descendant listing the last reconcile produced, in pre-order. */
|
|
3116
|
+
subagentEntries = [];
|
|
3117
|
+
/** The rows of the last panel draw. */
|
|
3118
|
+
panelView = EMPTY_PANEL_VIEW;
|
|
3119
|
+
/** The panel row the selection sits on; absent before the first row is drawn. */
|
|
3120
|
+
panelSelection;
|
|
3121
|
+
/** Whether a live signal invalidated the listing since the last reconcile. */
|
|
3122
|
+
subagentsStale = false;
|
|
3123
|
+
/** Set while a listing is in flight, so two reconciles never overlap. */
|
|
3124
|
+
listing = false;
|
|
3125
|
+
/** Why the last listing failed; cleared by the next one that succeeds. */
|
|
3126
|
+
listingFailure;
|
|
3127
|
+
/** Disposer of the live-refresh tick while it is armed. */
|
|
3128
|
+
ticker;
|
|
3129
|
+
/** Disposer of the fade tick while it is armed. */
|
|
3130
|
+
fadeTicker;
|
|
3131
|
+
/** The tail of the message streaming right now; absent between messages. */
|
|
3132
|
+
fadeTail;
|
|
3133
|
+
/** Whether this terminal draws a ramp at all, decided once at start. */
|
|
3134
|
+
fading = false;
|
|
3135
|
+
/** How streamed text is drawn; `none` until the background query settles. */
|
|
3136
|
+
fadeStyle = NO_FADE;
|
|
1754
3137
|
bound;
|
|
1755
3138
|
streaming;
|
|
1756
3139
|
toolsExpanded = false;
|
|
@@ -1758,6 +3141,8 @@ var TuiApp = class {
|
|
|
1758
3141
|
switching = false;
|
|
1759
3142
|
/** Serializes Shift+Tab effort cycles so rapid presses apply in order. */
|
|
1760
3143
|
effortCycle = Promise.resolve();
|
|
3144
|
+
/** Serializes `/attach` reads so pending attachments keep the typed order. */
|
|
3145
|
+
attaching = Promise.resolve();
|
|
1761
3146
|
usage = EMPTY_USAGE;
|
|
1762
3147
|
lastCtrlC = 0;
|
|
1763
3148
|
stopped = false;
|
|
@@ -1769,11 +3154,11 @@ var TuiApp = class {
|
|
|
1769
3154
|
palette,
|
|
1770
3155
|
toolPreviewLines: deps.toolPreviewLines
|
|
1771
3156
|
};
|
|
1772
|
-
this.tui = new TuiMainScreen(deps.terminal);
|
|
3157
|
+
this.tui = new TuiMainScreen(deps.terminal, true);
|
|
1773
3158
|
this.header = new Text("", 0, 0);
|
|
1774
3159
|
this.loader = new Loader(this.tui, palette.accent, palette.dim, "thinking");
|
|
1775
3160
|
this.loader.stop();
|
|
1776
|
-
this.editor = new
|
|
3161
|
+
this.editor = new BarCursorEditor(this.tui, editorTheme(palette), { paddingX: 1 });
|
|
1777
3162
|
this.editor.setAutocompleteProvider(editorCompletion({
|
|
1778
3163
|
commands: () => this.completableCommands(),
|
|
1779
3164
|
references: (query, quoted, signal) => this.references(query, quoted, signal)
|
|
@@ -1781,6 +3166,7 @@ var TuiApp = class {
|
|
|
1781
3166
|
this.editor.onSubmit = (text) => {
|
|
1782
3167
|
this.onSubmit(text);
|
|
1783
3168
|
};
|
|
3169
|
+
this.panel = new Text("", 0, 0);
|
|
1784
3170
|
this.footer = new Text("", 0, 0);
|
|
1785
3171
|
this.modals = new ModalQueue({
|
|
1786
3172
|
tui: this.tui,
|
|
@@ -1793,6 +3179,7 @@ var TuiApp = class {
|
|
|
1793
3179
|
this.statusSlot,
|
|
1794
3180
|
this.modalSlot,
|
|
1795
3181
|
this.editor,
|
|
3182
|
+
this.panelSlot,
|
|
1796
3183
|
this.footer
|
|
1797
3184
|
]) this.tui.addChild(child);
|
|
1798
3185
|
}
|
|
@@ -1812,8 +3199,15 @@ var TuiApp = class {
|
|
|
1812
3199
|
if (subject !== this.agent) return;
|
|
1813
3200
|
this.onStreamFrame(frame);
|
|
1814
3201
|
}), ctx.on("agent/status", ({ agent: subject, status }) => {
|
|
1815
|
-
if (subject !== this.agent)
|
|
3202
|
+
if (subject !== this.agent) {
|
|
3203
|
+
this.markSubagentsStale();
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
1816
3206
|
this.setWorking(status === "running");
|
|
3207
|
+
}), ctx.on("subagent/start", () => {
|
|
3208
|
+
this.markSubagentsStale();
|
|
3209
|
+
}), ctx.on("subagent/end", () => {
|
|
3210
|
+
this.markSubagentsStale();
|
|
1817
3211
|
}), ctx.on("approval/request", (request, next) => {
|
|
1818
3212
|
if (request.agent !== this.agent) return next();
|
|
1819
3213
|
return this.askApproval(request.toolName, request.reason, request.callId, request.signal);
|
|
@@ -1824,20 +3218,61 @@ var TuiApp = class {
|
|
|
1824
3218
|
const projections = ctx.get("sessionProjections");
|
|
1825
3219
|
if (projections !== void 0) this.disposers.push(projections.onChanged((session) => {
|
|
1826
3220
|
if (session === this.agent.session) this.refreshFooter();
|
|
3221
|
+
else this.markSubagentsStale();
|
|
1827
3222
|
}));
|
|
1828
3223
|
this.deps.terminal.setTitle(`dsh · ${this.deps.cwd}`);
|
|
1829
3224
|
this.tui.setFocus(this.editor);
|
|
1830
3225
|
this.tui.start();
|
|
3226
|
+
this.deps.terminal.write(SET_BLINKING_BAR_CURSOR);
|
|
3227
|
+
this.startFade();
|
|
1831
3228
|
this.bind(this.bound);
|
|
1832
3229
|
if (initialPrompt !== void 0) this.submit(initialPrompt);
|
|
1833
3230
|
}
|
|
3231
|
+
/**
|
|
3232
|
+
* Decide whether streamed text fades at all, once per run, from the
|
|
3233
|
+
* palette, the environment, and the reduced-motion preference. A terminal
|
|
3234
|
+
* that draws no ramp tracks no tail and arms no fade tick, so streaming
|
|
3235
|
+
* costs there exactly what it did before the effect existed.
|
|
3236
|
+
*/
|
|
3237
|
+
startFade() {
|
|
3238
|
+
const capability = resolveFadeCapability({
|
|
3239
|
+
paletteEnabled: this.deps.palette.enabled,
|
|
3240
|
+
env: this.deps.env,
|
|
3241
|
+
reducedMotion: this.deps.reducedMotion
|
|
3242
|
+
});
|
|
3243
|
+
if (capability === "none") return;
|
|
3244
|
+
this.fading = true;
|
|
3245
|
+
this.resolveFadeRamp(capability);
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Ask the terminal for its background color, the only color a ramp can be
|
|
3249
|
+
* built from, and settle the drawing settings on the answer. A terminal
|
|
3250
|
+
* that answers nothing usable - the query timed out, or its reply did not
|
|
3251
|
+
* parse - leaves the two-level mode, which needs no colors. Text streamed
|
|
3252
|
+
* before the answer arrives draws as the Markdown component rendered it.
|
|
3253
|
+
* @param capability - how far this terminal encodes one ramp level.
|
|
3254
|
+
*/
|
|
3255
|
+
async resolveFadeRamp(capability) {
|
|
3256
|
+
const background = await this.tui.queryTerminalBackgroundColor({ timeoutMs: BACKGROUND_QUERY_TIMEOUT_MS });
|
|
3257
|
+
if (this.stopped) return;
|
|
3258
|
+
this.fadeStyle = background === void 0 ? {
|
|
3259
|
+
capability: "dim",
|
|
3260
|
+
ramp: []
|
|
3261
|
+
} : {
|
|
3262
|
+
capability,
|
|
3263
|
+
ramp: buildFadeRamp(background, assumedForeground(background), this.deps.fadeSteps)
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
1834
3266
|
/** Release the terminal and tell the host to exit; later calls are no-ops. */
|
|
1835
3267
|
stop() {
|
|
1836
3268
|
if (this.stopped) return;
|
|
1837
3269
|
this.stopped = true;
|
|
3270
|
+
this.updateTicker();
|
|
3271
|
+
this.updateFadeTicker();
|
|
1838
3272
|
for (const dispose of this.disposers.splice(0)) dispose();
|
|
1839
3273
|
this.modals.withdrawActive();
|
|
1840
3274
|
this.loader.stop();
|
|
3275
|
+
this.deps.terminal.write(SET_TERMINAL_DEFAULT_CURSOR);
|
|
1841
3276
|
this.tui.stop();
|
|
1842
3277
|
this.deps.releaseInput();
|
|
1843
3278
|
this.deps.onQuit(this.bound);
|
|
@@ -1849,13 +3284,23 @@ var TuiApp = class {
|
|
|
1849
3284
|
this.toolBlocks.clear();
|
|
1850
3285
|
this.toolArguments.clear();
|
|
1851
3286
|
this.submittedIds.clear();
|
|
3287
|
+
this.todoTurns.clear();
|
|
3288
|
+
this.turn = 0;
|
|
3289
|
+
this.turnStartedAt = void 0;
|
|
1852
3290
|
this.streaming = void 0;
|
|
3291
|
+
this.endFade();
|
|
1853
3292
|
this.usage = EMPTY_USAGE;
|
|
1854
3293
|
this.pending = [];
|
|
3294
|
+
this.subagentEntries = [];
|
|
3295
|
+
this.panelSelection = void 0;
|
|
3296
|
+
this.listingFailure = void 0;
|
|
3297
|
+
this.subagentsStale = false;
|
|
1855
3298
|
this.setWorking(next.agent.status === "running");
|
|
1856
3299
|
for (const event of next.history) this.onSessionEvent(next.agent.session, event);
|
|
1857
3300
|
this.refreshHeader();
|
|
1858
3301
|
this.refreshFooter();
|
|
3302
|
+
this.refreshSubagentPanel();
|
|
3303
|
+
this.reconcileSubagents();
|
|
1859
3304
|
}
|
|
1860
3305
|
/**
|
|
1861
3306
|
* Move the terminal to the session `open` resolves, releasing the current one.
|
|
@@ -1959,20 +3404,185 @@ var TuiApp = class {
|
|
|
1959
3404
|
}
|
|
1960
3405
|
refreshFooter() {
|
|
1961
3406
|
const palette = this.deps.palette;
|
|
1962
|
-
const selection = this.currentSelection();
|
|
1963
|
-
const parts = [`${selection.provider}/${selection.model}`];
|
|
1964
|
-
if (selection.reasoningEffort !== void 0) parts.push(`effort ${selection.reasoningEffort}`);
|
|
1965
3407
|
const permission = this.deps.ctx.get("permissionPresets")?.current(this.agent.session);
|
|
1966
|
-
|
|
1967
|
-
const
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
3408
|
+
const started = this.turnStartedAt;
|
|
3409
|
+
const inbox = this.agent.inbox;
|
|
3410
|
+
this.segments = buildFooterSegments({
|
|
3411
|
+
selection: this.currentSelection(),
|
|
3412
|
+
...permission === void 0 ? {} : { permission },
|
|
3413
|
+
...started === void 0 ? {} : { turn: {
|
|
3414
|
+
number: this.turn,
|
|
3415
|
+
startedAt: started,
|
|
3416
|
+
elapsed: formatElapsed(this.deps.now() - started),
|
|
3417
|
+
queuedNextTurn: inbox.nextTurn.length,
|
|
3418
|
+
queuedNextStep: inbox.nextStep.length
|
|
3419
|
+
} },
|
|
3420
|
+
usage: formatUsage(this.usage),
|
|
3421
|
+
facts: this.statusFacts(),
|
|
3422
|
+
cwd: this.deps.cwd,
|
|
3423
|
+
home: this.home,
|
|
3424
|
+
attachments: this.pending.map((attachment) => ({
|
|
3425
|
+
name: attachment.name,
|
|
3426
|
+
kind: attachment.block.type
|
|
3427
|
+
}))
|
|
3428
|
+
});
|
|
1972
3429
|
const hints = this.agent.status === "running" ? "Enter queues for the next turn · Ctrl+S steers this turn · Esc stops it · Ctrl+O tool output · Ctrl+C twice quits" : "Enter sends · Esc stops the turn · Ctrl+O tool output · Ctrl+C twice quits";
|
|
1973
|
-
this.footer.setText(
|
|
3430
|
+
this.footer.setText(renderFooter(this.segments, {
|
|
3431
|
+
palette,
|
|
3432
|
+
...this.focus === "bar" ? { selected: footerSelectionIndex(this.segments, this.barSelection) } : {},
|
|
3433
|
+
hints
|
|
3434
|
+
}));
|
|
3435
|
+
this.tui.requestRender();
|
|
3436
|
+
}
|
|
3437
|
+
/**
|
|
3438
|
+
* Redraw the panel from the last listing and a fresh sample of the live
|
|
3439
|
+
* facts. The panel is mounted exactly while it has a row, so the last
|
|
3440
|
+
* resident child leaving takes the panel with it — and the keyboard back to
|
|
3441
|
+
* the editor when the panel held it.
|
|
3442
|
+
*/
|
|
3443
|
+
refreshSubagentPanel() {
|
|
3444
|
+
const view = subagentPanelView({
|
|
3445
|
+
entries: this.subagentEntries,
|
|
3446
|
+
facts: this.subagentFacts(),
|
|
3447
|
+
now: this.deps.now()
|
|
3448
|
+
});
|
|
3449
|
+
this.panelView = view;
|
|
3450
|
+
const mounted = this.panelSlot.children.length > 0;
|
|
3451
|
+
if (view.rows.length === 0) {
|
|
3452
|
+
this.panelSelection = void 0;
|
|
3453
|
+
if (this.focus === "panel") this.setFocus("editor");
|
|
3454
|
+
if (mounted) this.panelSlot.removeChild(this.panel);
|
|
3455
|
+
} else {
|
|
3456
|
+
if (!mounted) this.panelSlot.addChild(this.panel);
|
|
3457
|
+
const selected = this.panelSelectionIndex(view.rows);
|
|
3458
|
+
this.panelSelection = view.rows[selected]?.id;
|
|
3459
|
+
this.panel.setText(renderSubagentPanel(view, {
|
|
3460
|
+
palette: this.deps.palette,
|
|
3461
|
+
...this.focus === "panel" ? { selected } : {},
|
|
3462
|
+
...this.listingFailure === void 0 ? {} : { failure: this.listingFailure }
|
|
3463
|
+
}));
|
|
3464
|
+
}
|
|
3465
|
+
this.updateTicker();
|
|
1974
3466
|
this.tui.requestRender();
|
|
1975
3467
|
}
|
|
3468
|
+
/**
|
|
3469
|
+
* Sample what this process knows about each listed child right now: whether
|
|
3470
|
+
* its Agent is running a turn, and one projection read for its timing and
|
|
3471
|
+
* token totals. Both reads are synchronous and touch no session log; a
|
|
3472
|
+
* child with no live Agent here contributes no facts.
|
|
3473
|
+
* @returns the facts by child session id.
|
|
3474
|
+
*/
|
|
3475
|
+
subagentFacts() {
|
|
3476
|
+
const facts = /* @__PURE__ */ new Map();
|
|
3477
|
+
const { ctx } = this.deps;
|
|
3478
|
+
const agents = ctx.get("agents");
|
|
3479
|
+
const projections = ctx.get("sessionProjections");
|
|
3480
|
+
for (const entry of this.subagentEntries) {
|
|
3481
|
+
const child = agents?.get(entry.id);
|
|
3482
|
+
if (child === void 0) continue;
|
|
3483
|
+
const live = { running: child.status === "running" };
|
|
3484
|
+
if (projections !== void 0) {
|
|
3485
|
+
const { values } = projections.snapshot(child.session, ["subagentTiming", "tokenUsage"]);
|
|
3486
|
+
const timing = values.subagentTiming;
|
|
3487
|
+
if (timing !== void 0) {
|
|
3488
|
+
live.settledMs = timing.settledMs;
|
|
3489
|
+
if (timing.active !== void 0) live.activeSince = timing.active.since;
|
|
3490
|
+
}
|
|
3491
|
+
const usage = values.tokenUsage;
|
|
3492
|
+
if (usage !== void 0) live.usage = {
|
|
3493
|
+
inputTokens: usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens,
|
|
3494
|
+
outputTokens: usage.outputTokens
|
|
3495
|
+
};
|
|
3496
|
+
}
|
|
3497
|
+
facts.set(entry.id, live);
|
|
3498
|
+
}
|
|
3499
|
+
return facts;
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* Where the panel draws its selection.
|
|
3503
|
+
* @param rows - the rows of the current draw.
|
|
3504
|
+
* @returns the selected row's index, or 0 once the child behind it is gone.
|
|
3505
|
+
*/
|
|
3506
|
+
panelSelectionIndex(rows) {
|
|
3507
|
+
const index = rows.findIndex((row) => row.id === this.panelSelection);
|
|
3508
|
+
return index === -1 ? 0 : index;
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* Mark the descendant listing out of date. The listing is never read from
|
|
3512
|
+
* the handler that noticed: the shared tick performs at most one read per
|
|
3513
|
+
* period, which bounds a burst of child events to one listing.
|
|
3514
|
+
*/
|
|
3515
|
+
markSubagentsStale() {
|
|
3516
|
+
if (this.deps.ctx.get("subagents") === void 0) return;
|
|
3517
|
+
this.subagentsStale = true;
|
|
3518
|
+
this.updateTicker();
|
|
3519
|
+
}
|
|
3520
|
+
/**
|
|
3521
|
+
* Re-read the descendant listing once. Two listings never overlap and a
|
|
3522
|
+
* result the terminal moved away from is discarded — both leave the listing
|
|
3523
|
+
* stale, so the next tick reads again. A rejection keeps the rows the last
|
|
3524
|
+
* good listing produced and records the reason without a fresh stale mark,
|
|
3525
|
+
* so a failing service is retried on the next live signal rather than once
|
|
3526
|
+
* per tick.
|
|
3527
|
+
*/
|
|
3528
|
+
async reconcileSubagents() {
|
|
3529
|
+
const subagents = this.deps.ctx.get("subagents");
|
|
3530
|
+
if (subagents === void 0) return;
|
|
3531
|
+
if (this.listing) {
|
|
3532
|
+
this.markSubagentsStale();
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
this.listing = true;
|
|
3536
|
+
this.subagentsStale = false;
|
|
3537
|
+
const session = this.agent.session;
|
|
3538
|
+
try {
|
|
3539
|
+
const entries = await subagents.listDescendants(session.id, new AbortController().signal);
|
|
3540
|
+
if (this.agent.session === session) {
|
|
3541
|
+
this.subagentEntries = entries;
|
|
3542
|
+
this.listingFailure = void 0;
|
|
3543
|
+
} else this.markSubagentsStale();
|
|
3544
|
+
} catch (error) {
|
|
3545
|
+
this.reportListingFailure(describeFailure(error));
|
|
3546
|
+
} finally {
|
|
3547
|
+
this.listing = false;
|
|
3548
|
+
if (!this.stopped) this.refreshSubagentPanel();
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
/**
|
|
3552
|
+
* Record why the listing failed. The panel carries the reason as one line
|
|
3553
|
+
* under its rows; the transcript hears only about a reason that changed, so
|
|
3554
|
+
* a service that keeps failing cannot fill the conversation with notices.
|
|
3555
|
+
* @param message - the failure text.
|
|
3556
|
+
*/
|
|
3557
|
+
reportListingFailure(message) {
|
|
3558
|
+
if (this.listingFailure === message) return;
|
|
3559
|
+
this.listingFailure = message;
|
|
3560
|
+
this.notice(`subagent listing failed: ${message}`, "error");
|
|
3561
|
+
}
|
|
3562
|
+
/**
|
|
3563
|
+
* Arm the live-refresh tick while something needs it and disarm it
|
|
3564
|
+
* otherwise: a running turn and a drawn row timing an open turn each need
|
|
3565
|
+
* one redraw per period, and a stale listing needs one reconcile. Exactly
|
|
3566
|
+
* one runs at a time, and a stopped app runs none.
|
|
3567
|
+
*/
|
|
3568
|
+
updateTicker() {
|
|
3569
|
+
if (!this.stopped && (this.turnStartedAt !== void 0 || this.subagentsStale || this.panelView.ticking)) {
|
|
3570
|
+
this.ticker ??= this.deps.tick(() => {
|
|
3571
|
+
this.onTick();
|
|
3572
|
+
}, this.deps.liveRefreshMs);
|
|
3573
|
+
return;
|
|
3574
|
+
}
|
|
3575
|
+
const ticker = this.ticker;
|
|
3576
|
+
if (ticker === void 0) return;
|
|
3577
|
+
this.ticker = void 0;
|
|
3578
|
+
ticker();
|
|
3579
|
+
}
|
|
3580
|
+
/** One live-refresh period: reconcile a stale listing, then redraw what the clock moved. */
|
|
3581
|
+
onTick() {
|
|
3582
|
+
if (this.subagentsStale) this.reconcileSubagents();
|
|
3583
|
+
this.refreshSubagentPanel();
|
|
3584
|
+
if (this.turnStartedAt !== void 0) this.refreshFooter();
|
|
3585
|
+
}
|
|
1976
3586
|
statusFacts() {
|
|
1977
3587
|
const projections = this.deps.ctx.get("sessionProjections");
|
|
1978
3588
|
return projections === void 0 ? {} : readStatusFacts(projections, this.agent.session);
|
|
@@ -1983,9 +3593,129 @@ var TuiApp = class {
|
|
|
1983
3593
|
this.notice(empty);
|
|
1984
3594
|
return;
|
|
1985
3595
|
}
|
|
3596
|
+
this.showBlock(rows);
|
|
3597
|
+
}
|
|
3598
|
+
/** Print `rows` into the transcript as one block. */
|
|
3599
|
+
showBlock(rows) {
|
|
1986
3600
|
this.chat.addChild(new Text(rows.join("\n"), 0, 1));
|
|
1987
3601
|
this.tui.requestRender();
|
|
1988
3602
|
}
|
|
3603
|
+
/**
|
|
3604
|
+
* Show one prompt through the modal queue. Whichever docked region holds
|
|
3605
|
+
* the keyboard gives it up first: the queue hands focus to the editor once
|
|
3606
|
+
* the prompt settles, and a bar or panel that still held it would swallow
|
|
3607
|
+
* every key typed after that.
|
|
3608
|
+
* @param prompt - the prompt to show.
|
|
3609
|
+
* @param signal - withdraws the prompt when aborted.
|
|
3610
|
+
* @returns the prompt's settled value.
|
|
3611
|
+
*/
|
|
3612
|
+
showModal(prompt, signal) {
|
|
3613
|
+
this.focusEditor();
|
|
3614
|
+
return this.modals.run(prompt, signal);
|
|
3615
|
+
}
|
|
3616
|
+
/**
|
|
3617
|
+
* Walk a list one entry at a time: the picker opens on `rows`, `Enter`
|
|
3618
|
+
* shows the picked entry's details, leaving the details returns to the
|
|
3619
|
+
* picker on the entry just read, and `Esc` at the picker returns to the
|
|
3620
|
+
* editor. An entry whose details cannot be read shows the failure in their
|
|
3621
|
+
* place, so the list stays open.
|
|
3622
|
+
* @param title - the picker heading.
|
|
3623
|
+
* @param rows - the entries to walk, in list order.
|
|
3624
|
+
* @param layout - how each row splits its width between label and description; the picker's default when omitted.
|
|
3625
|
+
*/
|
|
3626
|
+
async browse(title, rows, layout) {
|
|
3627
|
+
let visited;
|
|
3628
|
+
for (;;) {
|
|
3629
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, title, rows.map((row) => row.item), {
|
|
3630
|
+
...visited === void 0 ? {} : { current: visited },
|
|
3631
|
+
...layout === void 0 ? {} : { layout }
|
|
3632
|
+
}));
|
|
3633
|
+
if (picked === void 0) return;
|
|
3634
|
+
const row = rows.find((candidate) => candidate.item.value === picked.value);
|
|
3635
|
+
/* v8 ignore next -- the picker settles with one of the rows it was handed */
|
|
3636
|
+
if (row === void 0) return;
|
|
3637
|
+
visited = picked.value;
|
|
3638
|
+
await this.showDetail(row);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
/**
|
|
3642
|
+
* Show one entry's detail page, with a failed read printed in place of its
|
|
3643
|
+
* rows. Both the picker loop and the subagent panel enter a page this way.
|
|
3644
|
+
* @param row - the entry the user opened.
|
|
3645
|
+
*/
|
|
3646
|
+
async showDetail(row) {
|
|
3647
|
+
await this.showModal(new DetailPrompt(this.deps.palette, row.heading, await this.detailRows(row)));
|
|
3648
|
+
}
|
|
3649
|
+
/**
|
|
3650
|
+
* The rows one list entry's detail page shows.
|
|
3651
|
+
* @param row - the entry the user opened.
|
|
3652
|
+
* @returns its detail rows, or the failure text when the read fails.
|
|
3653
|
+
*/
|
|
3654
|
+
async detailRows(row) {
|
|
3655
|
+
try {
|
|
3656
|
+
return await row.detail();
|
|
3657
|
+
} catch (error) {
|
|
3658
|
+
/* v8 ignore next -- subagentDetail, the only resolver today, reports its own read failures as rows */
|
|
3659
|
+
return [describeFailure(error)];
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
/**
|
|
3663
|
+
* Walk the agent's todo list: one row per item, and entering a row shows
|
|
3664
|
+
* that item in full with its position, the list's counts by status, and the
|
|
3665
|
+
* turns it was first written in and last changed status in.
|
|
3666
|
+
*/
|
|
3667
|
+
async browseTodos() {
|
|
3668
|
+
const items = this.statusFacts().todos?.items ?? [];
|
|
3669
|
+
const choices = listTodoChoices(this.deps.ctx, this.agent.session);
|
|
3670
|
+
if (choices.length === 0) {
|
|
3671
|
+
this.notice("no todos yet");
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
const tracked = items.map((item) => this.todoTurns.get(item.content));
|
|
3675
|
+
await this.browse("Todos", choices.map((choice) => ({
|
|
3676
|
+
item: {
|
|
3677
|
+
value: String(choice.index),
|
|
3678
|
+
label: choice.label,
|
|
3679
|
+
description: choice.description
|
|
3680
|
+
},
|
|
3681
|
+
heading: `Todo ${String(choice.index + 1)}`,
|
|
3682
|
+
detail: () => Promise.resolve(todoDetail(items, choice.index, tracked[choice.index]))
|
|
3683
|
+
})), TODO_ROW_LAYOUT);
|
|
3684
|
+
}
|
|
3685
|
+
/**
|
|
3686
|
+
* The browsable entry behind one subagent row: the same detail page the
|
|
3687
|
+
* `/subagents` list and the live panel open.
|
|
3688
|
+
* @param choice - the listing row.
|
|
3689
|
+
* @returns the entry.
|
|
3690
|
+
*/
|
|
3691
|
+
subagentBrowseRow(choice) {
|
|
3692
|
+
return {
|
|
3693
|
+
item: {
|
|
3694
|
+
value: choice.id,
|
|
3695
|
+
label: choice.label,
|
|
3696
|
+
description: choice.description
|
|
3697
|
+
},
|
|
3698
|
+
heading: choice.id,
|
|
3699
|
+
detail: () => subagentDetail(this.deps.ctx, choice, new AbortController().signal)
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
/**
|
|
3703
|
+
* Open the selected panel row's session details, then give the keyboard
|
|
3704
|
+
* back to the panel unless its last row left while the page was open. A
|
|
3705
|
+
* diagnostic row explains itself in the panel and opens nothing.
|
|
3706
|
+
*/
|
|
3707
|
+
async openPanelRow() {
|
|
3708
|
+
const rows = this.panelView.rows;
|
|
3709
|
+
const row = rows[this.panelSelectionIndex(rows)];
|
|
3710
|
+
/* v8 ignore next -- the panel answers keys only while it has rows to select from */
|
|
3711
|
+
if (row === void 0) return;
|
|
3712
|
+
if (!row.enterable) return;
|
|
3713
|
+
const entry = this.subagentEntries.find((candidate) => candidate.id === row.id);
|
|
3714
|
+
/* v8 ignore next -- every drawn row comes from the entries of the last listing */
|
|
3715
|
+
if (entry === void 0) return;
|
|
3716
|
+
await this.showDetail(this.subagentBrowseRow(subagentChoice(entry)));
|
|
3717
|
+
if (this.panelView.rows.length > 0) this.focusRegion("panel");
|
|
3718
|
+
}
|
|
1989
3719
|
async settings(argument) {
|
|
1990
3720
|
const [first, second, ...rest] = argument === "" ? [] : argument.split(/\s+/u);
|
|
1991
3721
|
if (first === void 0) {
|
|
@@ -2012,8 +3742,7 @@ var TuiApp = class {
|
|
|
2012
3742
|
this.notice(await setSetting(this.deps.ctx, first, second, value), "success");
|
|
2013
3743
|
}
|
|
2014
3744
|
showStatus() {
|
|
2015
|
-
this.
|
|
2016
|
-
this.tui.requestRender();
|
|
3745
|
+
this.showBlock(statusReport(this.statusFacts()));
|
|
2017
3746
|
}
|
|
2018
3747
|
currentSelection() {
|
|
2019
3748
|
const { selection, agent } = this.bound;
|
|
@@ -2031,6 +3760,7 @@ var TuiApp = class {
|
|
|
2031
3760
|
return { consume: true };
|
|
2032
3761
|
}
|
|
2033
3762
|
if (matchesKey(data, "ctrl+c")) {
|
|
3763
|
+
this.focusEditor();
|
|
2034
3764
|
const now = Date.now();
|
|
2035
3765
|
if (now - this.lastCtrlC < QUIT_DOUBLE_PRESS_MS) {
|
|
2036
3766
|
this.stop();
|
|
@@ -2042,9 +3772,20 @@ var TuiApp = class {
|
|
|
2042
3772
|
return { consume: true };
|
|
2043
3773
|
}
|
|
2044
3774
|
if (matchesKey(data, "ctrl+d")) {
|
|
3775
|
+
this.focusEditor();
|
|
2045
3776
|
if (this.editor.getText() === "") this.stop();
|
|
2046
3777
|
return { consume: true };
|
|
2047
3778
|
}
|
|
3779
|
+
if (this.focus === "bar") return this.onStatusBarKey(data);
|
|
3780
|
+
if (this.focus === "panel") return this.onPanelKey(data);
|
|
3781
|
+
if (matchesKey(data, "shift+up")) {
|
|
3782
|
+
this.focusBar();
|
|
3783
|
+
return { consume: true };
|
|
3784
|
+
}
|
|
3785
|
+
if (matchesKey(data, "shift+down") && this.panelView.rows.length > 0) {
|
|
3786
|
+
this.focusRegion("panel");
|
|
3787
|
+
return { consume: true };
|
|
3788
|
+
}
|
|
2048
3789
|
if (matchesKey(data, "escape") && !this.editor.isShowingAutocomplete()) {
|
|
2049
3790
|
if (this.agent.status === "running") {
|
|
2050
3791
|
this.agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
@@ -2070,6 +3811,140 @@ var TuiApp = class {
|
|
|
2070
3811
|
return { consume: true };
|
|
2071
3812
|
}
|
|
2072
3813
|
}
|
|
3814
|
+
/**
|
|
3815
|
+
* Answer one key while the status bar holds focus. Every key is consumed
|
|
3816
|
+
* here, so nothing typed at the bar reaches the editor.
|
|
3817
|
+
* @param data - the raw key bytes.
|
|
3818
|
+
* @returns the consume marker the input listener returns.
|
|
3819
|
+
*/
|
|
3820
|
+
onStatusBarKey(data) {
|
|
3821
|
+
if (matchesKey(data, "escape") || matchesKey(data, "shift+down")) {
|
|
3822
|
+
this.focusEditor();
|
|
3823
|
+
return { consume: true };
|
|
3824
|
+
}
|
|
3825
|
+
if (matchesKey(data, "shift+up")) {
|
|
3826
|
+
this.focusRegion(this.panelView.rows.length > 0 ? "panel" : "editor");
|
|
3827
|
+
return { consume: true };
|
|
3828
|
+
}
|
|
3829
|
+
if (matchesKey(data, "left") || matchesKey(data, "shift+tab")) {
|
|
3830
|
+
this.moveStatusBar(-1);
|
|
3831
|
+
return { consume: true };
|
|
3832
|
+
}
|
|
3833
|
+
if (matchesKey(data, "right") || matchesKey(data, "tab")) {
|
|
3834
|
+
this.moveStatusBar(1);
|
|
3835
|
+
return { consume: true };
|
|
3836
|
+
}
|
|
3837
|
+
if (matchesKey(data, "enter")) this.openSegment(this.barSelection);
|
|
3838
|
+
return { consume: true };
|
|
3839
|
+
}
|
|
3840
|
+
/**
|
|
3841
|
+
* Answer one key while the subagent panel holds focus. Every key is
|
|
3842
|
+
* consumed here; `Ctrl+C` and `Ctrl+D` never reach this far, keeping their
|
|
3843
|
+
* global meaning.
|
|
3844
|
+
* @param data - the raw key bytes.
|
|
3845
|
+
* @returns the consume marker the input listener returns.
|
|
3846
|
+
*/
|
|
3847
|
+
onPanelKey(data) {
|
|
3848
|
+
if (matchesKey(data, "escape") || matchesKey(data, "shift+up")) {
|
|
3849
|
+
this.focusEditor();
|
|
3850
|
+
return { consume: true };
|
|
3851
|
+
}
|
|
3852
|
+
if (matchesKey(data, "shift+down")) {
|
|
3853
|
+
this.focusBar();
|
|
3854
|
+
return { consume: true };
|
|
3855
|
+
}
|
|
3856
|
+
if (matchesKey(data, "up")) {
|
|
3857
|
+
this.movePanel(-1);
|
|
3858
|
+
return { consume: true };
|
|
3859
|
+
}
|
|
3860
|
+
if (matchesKey(data, "down")) {
|
|
3861
|
+
this.movePanel(1);
|
|
3862
|
+
return { consume: true };
|
|
3863
|
+
}
|
|
3864
|
+
if (matchesKey(data, "enter")) this.navigate("subagent details", () => this.openPanelRow());
|
|
3865
|
+
return { consume: true };
|
|
3866
|
+
}
|
|
3867
|
+
/**
|
|
3868
|
+
* Open one page a docked region's `Enter` leads to, reporting a failure as
|
|
3869
|
+
* a notice instead of an unhandled rejection.
|
|
3870
|
+
* @param label - what the notice calls the page.
|
|
3871
|
+
* @param open - shows the page and settles when the user leaves it.
|
|
3872
|
+
*/
|
|
3873
|
+
navigate(label, open) {
|
|
3874
|
+
open().catch((error) => {
|
|
3875
|
+
this.notice(`${label} failed: ${describeFailure(error)}`, "error");
|
|
3876
|
+
});
|
|
3877
|
+
}
|
|
3878
|
+
/**
|
|
3879
|
+
* Answer `Enter` on the held segment: a segment whose fact the app has a
|
|
3880
|
+
* navigable page for opens that page, and every other segment prints its
|
|
3881
|
+
* detail rows into the transcript.
|
|
3882
|
+
* @param selected - the segment the bar holds.
|
|
3883
|
+
*/
|
|
3884
|
+
openSegment(selected) {
|
|
3885
|
+
const segment = this.segments[footerSelectionIndex(this.segments, selected)];
|
|
3886
|
+
/* v8 ignore next -- the bar draws at least the model segment, and an absent id falls back to it */
|
|
3887
|
+
if (segment === void 0) return;
|
|
3888
|
+
if (segment.detail.kind === "rows") {
|
|
3889
|
+
this.showBlock(segment.detail.rows);
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
this.navigate("/todos", () => this.browseTodos());
|
|
3893
|
+
}
|
|
3894
|
+
/**
|
|
3895
|
+
* Move the bar's selection, wrapping at both ends.
|
|
3896
|
+
* @param step - 1 for the next segment, -1 for the previous one.
|
|
3897
|
+
*/
|
|
3898
|
+
moveStatusBar(step) {
|
|
3899
|
+
const count = this.segments.length;
|
|
3900
|
+
const next = this.segments[(footerSelectionIndex(this.segments, this.barSelection) + step + count) % count];
|
|
3901
|
+
/* v8 ignore next -- the wrapped index stays inside the bar's own segments */
|
|
3902
|
+
if (next !== void 0) this.barSelection = next.id;
|
|
3903
|
+
this.refreshFooter();
|
|
3904
|
+
}
|
|
3905
|
+
/**
|
|
3906
|
+
* Move the panel's selection, wrapping at both ends of the drawn rows. The
|
|
3907
|
+
* rows behind a `+<n> more` row are not selectable; `/subagents` walks the
|
|
3908
|
+
* complete tree.
|
|
3909
|
+
* @param step - 1 for the next row, -1 for the previous one.
|
|
3910
|
+
*/
|
|
3911
|
+
movePanel(step) {
|
|
3912
|
+
const rows = this.panelView.rows;
|
|
3913
|
+
const count = rows.length;
|
|
3914
|
+
const next = rows[(this.panelSelectionIndex(rows) + step + count) % count];
|
|
3915
|
+
/* v8 ignore next -- the wrapped index stays inside the panel's own rows */
|
|
3916
|
+
if (next !== void 0) this.panelSelection = next.id;
|
|
3917
|
+
this.refreshSubagentPanel();
|
|
3918
|
+
}
|
|
3919
|
+
/** Give the keyboard to the status bar, starting at its first segment. */
|
|
3920
|
+
focusBar() {
|
|
3921
|
+
this.barSelection = FIRST_FOOTER_SEGMENT;
|
|
3922
|
+
this.focusRegion("bar");
|
|
3923
|
+
}
|
|
3924
|
+
/** Hand the keyboard back to the editor; a no-op while the editor already has it. */
|
|
3925
|
+
focusEditor() {
|
|
3926
|
+
this.focusRegion("editor");
|
|
3927
|
+
}
|
|
3928
|
+
/**
|
|
3929
|
+
* Move the keyboard between the docked regions and redraw both of them, so
|
|
3930
|
+
* the region losing focus stops drawing its selection.
|
|
3931
|
+
* @param region - the region that takes the keyboard.
|
|
3932
|
+
*/
|
|
3933
|
+
focusRegion(region) {
|
|
3934
|
+
if (this.focus === region) return;
|
|
3935
|
+
this.setFocus(region);
|
|
3936
|
+
this.refreshSubagentPanel();
|
|
3937
|
+
}
|
|
3938
|
+
/**
|
|
3939
|
+
* Give the keyboard to `region` and redraw the bar. The caller redraws the
|
|
3940
|
+
* panel; the panel's own refresh calls this when its last row leaves.
|
|
3941
|
+
* @param region - the region that takes the keyboard.
|
|
3942
|
+
*/
|
|
3943
|
+
setFocus(region) {
|
|
3944
|
+
this.focus = region;
|
|
3945
|
+
this.tui.setFocus(region === "editor" ? this.editor : null);
|
|
3946
|
+
this.refreshFooter();
|
|
3947
|
+
}
|
|
2073
3948
|
toggleTools() {
|
|
2074
3949
|
this.toolsExpanded = !this.toolsExpanded;
|
|
2075
3950
|
for (const block of this.toolBlocks.values()) block.setExpanded(this.toolsExpanded);
|
|
@@ -2182,15 +4057,24 @@ var TuiApp = class {
|
|
|
2182
4057
|
case "status":
|
|
2183
4058
|
this.showStatus();
|
|
2184
4059
|
return;
|
|
4060
|
+
case "todos":
|
|
4061
|
+
await this.browseTodos();
|
|
4062
|
+
return;
|
|
2185
4063
|
case "outline":
|
|
2186
4064
|
this.showRows(sessionOutline(this.deps.ctx, this.agent.session), "no completed turn yet");
|
|
2187
4065
|
return;
|
|
2188
4066
|
case "deliverables":
|
|
2189
4067
|
this.showRows(await listDeliverables(this.deps.ctx, this.agent.session.id, new AbortController().signal), "nothing presented yet");
|
|
2190
4068
|
return;
|
|
2191
|
-
case "subagents":
|
|
2192
|
-
|
|
4069
|
+
case "subagents": {
|
|
4070
|
+
const choices = await listSubagentChoices(this.deps.ctx, this.agent.session.id, new AbortController().signal);
|
|
4071
|
+
if (choices.length === 0) {
|
|
4072
|
+
this.notice("no subagent sessions");
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
await this.browse("Subagent sessions", choices.map((choice) => this.subagentBrowseRow(choice)));
|
|
2193
4076
|
return;
|
|
4077
|
+
}
|
|
2194
4078
|
case "settings":
|
|
2195
4079
|
await this.settings(argument);
|
|
2196
4080
|
return;
|
|
@@ -2209,6 +4093,8 @@ var TuiApp = class {
|
|
|
2209
4093
|
"@ completes paths and sessions (workspace, ../, ~/, absolute) · / completes commands",
|
|
2210
4094
|
"Esc stops the running turn · Ctrl+O expands or collapses tool output",
|
|
2211
4095
|
"Shift+Tab cycles the current model's reasoning effort for the next request",
|
|
4096
|
+
"Shift+Up focuses the status bar: ← → select a fact, Enter shows its details, Esc returns to the input",
|
|
4097
|
+
"Shift+Up again focuses the subagent panel while it is drawn: ↑ ↓ select a child, Enter shows its session",
|
|
2212
4098
|
"Ctrl+C clears the input (twice quits) · Ctrl+D on an empty input quits"
|
|
2213
4099
|
];
|
|
2214
4100
|
this.chat.addChild(new Text([
|
|
@@ -2270,7 +4156,7 @@ var TuiApp = class {
|
|
|
2270
4156
|
return;
|
|
2271
4157
|
}
|
|
2272
4158
|
const current = this.currentSelection();
|
|
2273
|
-
const picked = await this.
|
|
4159
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, "Model for the next request", items, { current: `${current.provider}/${current.model}` }));
|
|
2274
4160
|
if (picked === void 0) return;
|
|
2275
4161
|
const slash = picked.value.indexOf("/");
|
|
2276
4162
|
next = {
|
|
@@ -2303,7 +4189,7 @@ var TuiApp = class {
|
|
|
2303
4189
|
if (lookup.kind !== "ready") return void 0;
|
|
2304
4190
|
const current = this.currentSelection();
|
|
2305
4191
|
const sameModel = current.provider === model.provider && current.model === model.model;
|
|
2306
|
-
const picked = await this.
|
|
4192
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, `Reasoning effort · ${model.provider}/${model.model}`, effortItems(lookup.reasoning), {
|
|
2307
4193
|
body: ["Esc cancels the model change"],
|
|
2308
4194
|
current: (sameModel ? current.reasoningEffort : void 0) ?? ""
|
|
2309
4195
|
}));
|
|
@@ -2335,7 +4221,7 @@ var TuiApp = class {
|
|
|
2335
4221
|
this.applyEffort(current, matched);
|
|
2336
4222
|
return;
|
|
2337
4223
|
}
|
|
2338
|
-
const picked = await this.
|
|
4224
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, `Reasoning effort · ${current.provider}/${current.model}`, effortItems(reasoning), {
|
|
2339
4225
|
body: [effortHint(reasoning, current.reasoningEffort)],
|
|
2340
4226
|
current: current.reasoningEffort ?? ""
|
|
2341
4227
|
}));
|
|
@@ -2450,7 +4336,7 @@ var TuiApp = class {
|
|
|
2450
4336
|
value: choice.id,
|
|
2451
4337
|
...describeSession(choice)
|
|
2452
4338
|
}));
|
|
2453
|
-
const picked = await this.
|
|
4339
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, "Switch to a session", items));
|
|
2454
4340
|
const target = choices.find((choice) => choice.id === picked?.value);
|
|
2455
4341
|
if (target === void 0 || target.current) return;
|
|
2456
4342
|
await this.switchSession(() => this.deps.host.resume(target.id), "resumed");
|
|
@@ -2472,7 +4358,21 @@ var TuiApp = class {
|
|
|
2472
4358
|
this.notice(`rename failed: ${describeFailure(error)}`, "error");
|
|
2473
4359
|
}
|
|
2474
4360
|
}
|
|
2475
|
-
|
|
4361
|
+
/**
|
|
4362
|
+
* Run one `/attach` after every `/attach` typed before it. Each command
|
|
4363
|
+
* reaches this from its own unawaited dispatch, so two of them read their
|
|
4364
|
+
* files at the same time and the slower read would otherwise land second
|
|
4365
|
+
* whichever file the user named first. Queueing them keeps `pending`, the
|
|
4366
|
+
* footer count, and the notices in the order the user typed.
|
|
4367
|
+
* @param argument - the command argument: a path, `clear`, or nothing.
|
|
4368
|
+
* @returns when this attachment has settled.
|
|
4369
|
+
*/
|
|
4370
|
+
attach(argument) {
|
|
4371
|
+
const settled = this.attaching.then(() => this.attachNow(argument));
|
|
4372
|
+
this.attaching = settled;
|
|
4373
|
+
return settled;
|
|
4374
|
+
}
|
|
4375
|
+
async attachNow(argument) {
|
|
2476
4376
|
if (argument === "") {
|
|
2477
4377
|
this.notice(this.pending.length === 0 ? "nothing attached; /attach <path> attaches a file or image to the next prompt" : `attached: ${this.pending.map((attachment) => attachment.name).join(", ")}`);
|
|
2478
4378
|
return;
|
|
@@ -2545,7 +4445,7 @@ var TuiApp = class {
|
|
|
2545
4445
|
}
|
|
2546
4446
|
let key = argument;
|
|
2547
4447
|
if (key === "") {
|
|
2548
|
-
const picked = await this.
|
|
4448
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, subscriptionOnly ? "Log in with" : "Sign in to", entries.map((entry) => ({
|
|
2549
4449
|
value: entry.key,
|
|
2550
4450
|
label: entry.label,
|
|
2551
4451
|
description: entry.methods.map((method) => method.label).join(", ")
|
|
@@ -2560,7 +4460,7 @@ var TuiApp = class {
|
|
|
2560
4460
|
}
|
|
2561
4461
|
let method = entry.methods[0]?.id;
|
|
2562
4462
|
if (entry.methods.length > 1) {
|
|
2563
|
-
const picked = await this.
|
|
4463
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, `Sign-in method for ${entry.label}`, entry.methods.map((candidate) => ({
|
|
2564
4464
|
value: candidate.id,
|
|
2565
4465
|
label: candidate.label
|
|
2566
4466
|
}))));
|
|
@@ -2591,7 +4491,7 @@ var TuiApp = class {
|
|
|
2591
4491
|
}
|
|
2592
4492
|
async answerAuthorizationPrompt(prompt) {
|
|
2593
4493
|
if (prompt.kind === "select") {
|
|
2594
|
-
const picked = await this.
|
|
4494
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, prompt.message, prompt.options.map((option) => ({
|
|
2595
4495
|
value: option.id,
|
|
2596
4496
|
label: option.label,
|
|
2597
4497
|
...option.description === void 0 ? {} : { description: option.description }
|
|
@@ -2599,7 +4499,7 @@ var TuiApp = class {
|
|
|
2599
4499
|
if (picked === void 0) throw new AuthorizationDeclinedError("the sign-in prompt was dismissed");
|
|
2600
4500
|
return picked.value;
|
|
2601
4501
|
}
|
|
2602
|
-
const answer = await this.
|
|
4502
|
+
const answer = await this.showModal(new QuestionPrompt(this.deps.palette, {
|
|
2603
4503
|
id: "authorization",
|
|
2604
4504
|
question: prompt.message,
|
|
2605
4505
|
...prompt.placeholder === void 0 ? {} : { detail: prompt.placeholder }
|
|
@@ -2619,7 +4519,7 @@ var TuiApp = class {
|
|
|
2619
4519
|
async askApproval(toolName, reason, callId, signal) {
|
|
2620
4520
|
const args = callId === void 0 ? void 0 : this.toolArguments.get(callId);
|
|
2621
4521
|
const detail = args === void 0 ? [] : toolCallText(JSON.stringify(args), this.presentCall(toolName, args)).lines;
|
|
2622
|
-
const outcome = await this.
|
|
4522
|
+
const outcome = await this.showModal(new ApprovalPrompt(this.deps.palette, toolName, reason, detail), signal);
|
|
2623
4523
|
const tone = outcome === "allowed-once" ? "success" : "dim";
|
|
2624
4524
|
this.notice(`${toolName}: ${outcome === "allowed-once" ? "allowed once" : outcome}`, tone);
|
|
2625
4525
|
return outcome;
|
|
@@ -2627,7 +4527,7 @@ var TuiApp = class {
|
|
|
2627
4527
|
async askQuestions(questions, signal) {
|
|
2628
4528
|
const answers = [];
|
|
2629
4529
|
for (const question of questions) {
|
|
2630
|
-
const answer = await this.
|
|
4530
|
+
const answer = await this.showModal(new QuestionPrompt(this.deps.palette, question), signal);
|
|
2631
4531
|
if (answer === null) throw new Error("the question was dismissed");
|
|
2632
4532
|
answers.push(answer);
|
|
2633
4533
|
}
|
|
@@ -2640,16 +4540,89 @@ var TuiApp = class {
|
|
|
2640
4540
|
}
|
|
2641
4541
|
return this.streaming;
|
|
2642
4542
|
}
|
|
4543
|
+
/**
|
|
4544
|
+
* Take one visible text delta: the block draws it and the tail of that same
|
|
4545
|
+
* block ages it. The tail is created with the first delta of a message, so
|
|
4546
|
+
* a block rebuilt from history or committed from the log never carries one.
|
|
4547
|
+
* @param delta - the streamed text delta.
|
|
4548
|
+
*/
|
|
4549
|
+
appendStreamedText(delta) {
|
|
4550
|
+
const block = this.streamingBlock();
|
|
4551
|
+
block.appendText(delta);
|
|
4552
|
+
if (!this.fading) return;
|
|
4553
|
+
if (this.fadeTail === void 0) {
|
|
4554
|
+
this.fadeTail = new FadeTracker({ steps: this.deps.fadeSteps });
|
|
4555
|
+
block.setFade({
|
|
4556
|
+
spans: () => this.fadeTail?.spans() ?? [],
|
|
4557
|
+
style: () => this.fadeStyle,
|
|
4558
|
+
steps: this.deps.fadeSteps,
|
|
4559
|
+
flush: () => {
|
|
4560
|
+
this.flushFade();
|
|
4561
|
+
}
|
|
4562
|
+
});
|
|
4563
|
+
}
|
|
4564
|
+
this.fadeTail.append(delta);
|
|
4565
|
+
this.updateFadeTicker();
|
|
4566
|
+
}
|
|
4567
|
+
/**
|
|
4568
|
+
* Settle what the current block has drawn and stop tracking it: the tail
|
|
4569
|
+
* goes, so its text renders at the terminal's foreground from the next
|
|
4570
|
+
* render on, and no fade tick stays armed between messages.
|
|
4571
|
+
*/
|
|
4572
|
+
endFade() {
|
|
4573
|
+
this.fadeTail = void 0;
|
|
4574
|
+
this.updateFadeTicker();
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4577
|
+
* Settle what is drawn while the message keeps streaming, which the block
|
|
4578
|
+
* asks for after a width change. The tracker stays, so what it knows about
|
|
4579
|
+
* the arrival rate survives a resize and only the tail is dropped.
|
|
4580
|
+
*/
|
|
4581
|
+
flushFade() {
|
|
4582
|
+
this.fadeTail?.flush();
|
|
4583
|
+
this.updateFadeTicker();
|
|
4584
|
+
}
|
|
4585
|
+
/**
|
|
4586
|
+
* Arm the fade tick while a chunk is still below the last brightness level
|
|
4587
|
+
* and disarm it otherwise, so a session that is not streaming runs no fade
|
|
4588
|
+
* timer. Exactly one runs at a time, and a stopped app runs none.
|
|
4589
|
+
*/
|
|
4590
|
+
updateFadeTicker() {
|
|
4591
|
+
const tail = this.fadeTail;
|
|
4592
|
+
if (!this.stopped && tail !== void 0 && tail.needsRepaint()) {
|
|
4593
|
+
this.fadeTicker ??= this.deps.tick(() => {
|
|
4594
|
+
this.onFadeTick(tail);
|
|
4595
|
+
}, this.deps.fadeStepMs);
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
const ticker = this.fadeTicker;
|
|
4599
|
+
if (ticker === void 0) return;
|
|
4600
|
+
this.fadeTicker = void 0;
|
|
4601
|
+
ticker();
|
|
4602
|
+
}
|
|
4603
|
+
/**
|
|
4604
|
+
* One fade period over the tail the tick was armed for. Every change to the
|
|
4605
|
+
* tail runs {@link TuiApp.updateFadeTicker} again, so an armed period always
|
|
4606
|
+
* holds that same tail and always moves a chunk's color; the render request
|
|
4607
|
+
* redraws the lines it moved, and the disarm check follows the ageing.
|
|
4608
|
+
* @param tail - the tail this tick was armed for.
|
|
4609
|
+
*/
|
|
4610
|
+
onFadeTick(tail) {
|
|
4611
|
+
tail.tick();
|
|
4612
|
+
this.tui.requestRender();
|
|
4613
|
+
this.updateFadeTicker();
|
|
4614
|
+
}
|
|
2643
4615
|
onStreamFrame(frame) {
|
|
2644
4616
|
switch (frame.type) {
|
|
2645
4617
|
case "start":
|
|
2646
4618
|
this.streaming = void 0;
|
|
4619
|
+
this.endFade();
|
|
2647
4620
|
return;
|
|
2648
4621
|
case "chunk": {
|
|
2649
4622
|
const chunk = frame.chunk;
|
|
2650
4623
|
switch (chunk.type) {
|
|
2651
4624
|
case "text-delta":
|
|
2652
|
-
if (chunk.text !== "") this.
|
|
4625
|
+
if (chunk.text !== "") this.appendStreamedText(chunk.text);
|
|
2653
4626
|
break;
|
|
2654
4627
|
case "reasoning-delta":
|
|
2655
4628
|
if (chunk.text !== "") this.streamingBlock().appendReasoning(chunk.text);
|
|
@@ -2669,6 +4642,7 @@ var TuiApp = class {
|
|
|
2669
4642
|
}
|
|
2670
4643
|
case "end":
|
|
2671
4644
|
this.streaming = void 0;
|
|
4645
|
+
this.endFade();
|
|
2672
4646
|
this.tui.requestRender();
|
|
2673
4647
|
return;
|
|
2674
4648
|
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
@@ -2676,8 +4650,20 @@ var TuiApp = class {
|
|
|
2676
4650
|
}
|
|
2677
4651
|
}
|
|
2678
4652
|
onSessionEvent(session, event) {
|
|
2679
|
-
if (session !== this.agent.session)
|
|
4653
|
+
if (session !== this.agent.session) {
|
|
4654
|
+
this.markSubagentsStale();
|
|
4655
|
+
return;
|
|
4656
|
+
}
|
|
2680
4657
|
switch (event.type) {
|
|
4658
|
+
case "turn/start":
|
|
4659
|
+
this.turn = event.data.turn;
|
|
4660
|
+
this.turnStartedAt = event.time;
|
|
4661
|
+
this.updateTicker();
|
|
4662
|
+
this.refreshFooter();
|
|
4663
|
+
break;
|
|
4664
|
+
case "todo/write":
|
|
4665
|
+
this.trackTodoTurns(event.data.todos);
|
|
4666
|
+
break;
|
|
2681
4667
|
case "user/message":
|
|
2682
4668
|
this.onUserMessage(event.data);
|
|
2683
4669
|
break;
|
|
@@ -2687,6 +4673,7 @@ var TuiApp = class {
|
|
|
2687
4673
|
const reasoning = message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
|
|
2688
4674
|
this.streamingBlock().commit(text, reasoning, interrupted === true);
|
|
2689
4675
|
this.streaming = void 0;
|
|
4676
|
+
this.endFade();
|
|
2690
4677
|
if (usage !== void 0) {
|
|
2691
4678
|
this.usage = addUsage(this.usage, usage);
|
|
2692
4679
|
this.refreshFooter();
|
|
@@ -2714,6 +4701,10 @@ var TuiApp = class {
|
|
|
2714
4701
|
break;
|
|
2715
4702
|
}
|
|
2716
4703
|
case "turn/end": {
|
|
4704
|
+
this.turnStartedAt = void 0;
|
|
4705
|
+
this.updateTicker();
|
|
4706
|
+
this.endFade();
|
|
4707
|
+
this.refreshFooter();
|
|
2717
4708
|
const notice = turnEndNotice(event.data.reason);
|
|
2718
4709
|
if (notice !== void 0) this.notice(notice, event.data.reason.kind === "error" ? "error" : "dim");
|
|
2719
4710
|
break;
|
|
@@ -2734,6 +4725,31 @@ var TuiApp = class {
|
|
|
2734
4725
|
}
|
|
2735
4726
|
this.tui.requestRender();
|
|
2736
4727
|
}
|
|
4728
|
+
/**
|
|
4729
|
+
* Fold one whole-list todo write into the turn facts: a content this
|
|
4730
|
+
* session has not carried before starts at the current turn, a known
|
|
4731
|
+
* content whose status moved records that turn, and a content the write
|
|
4732
|
+
* dropped is forgotten.
|
|
4733
|
+
* @param todos - the list the write replaced the previous one with.
|
|
4734
|
+
*/
|
|
4735
|
+
trackTodoTurns(todos) {
|
|
4736
|
+
const tracked = /* @__PURE__ */ new Map();
|
|
4737
|
+
for (const item of todos) {
|
|
4738
|
+
const known = this.todoTurns.get(item.content);
|
|
4739
|
+
if (known === void 0) tracked.set(item.content, {
|
|
4740
|
+
firstTurn: this.turn,
|
|
4741
|
+
statusTurn: this.turn,
|
|
4742
|
+
status: item.status
|
|
4743
|
+
});
|
|
4744
|
+
else if (known.status === item.status) tracked.set(item.content, known);
|
|
4745
|
+
else tracked.set(item.content, {
|
|
4746
|
+
firstTurn: known.firstTurn,
|
|
4747
|
+
statusTurn: this.turn,
|
|
4748
|
+
status: item.status
|
|
4749
|
+
});
|
|
4750
|
+
}
|
|
4751
|
+
this.todoTurns = tracked;
|
|
4752
|
+
}
|
|
2737
4753
|
onUserMessage(message) {
|
|
2738
4754
|
const source = message.source;
|
|
2739
4755
|
if (source.kind === "user") {
|
|
@@ -2848,6 +4864,10 @@ const Config = z.object({
|
|
|
2848
4864
|
prompt: z.string(),
|
|
2849
4865
|
resume: z.string(),
|
|
2850
4866
|
toolPreviewLines: z.natural().min(1).default(8),
|
|
4867
|
+
liveRefreshMs: z.natural().min(100).default(1e3),
|
|
4868
|
+
streamFadeSteps: z.natural().min(2).default(5),
|
|
4869
|
+
streamFadeStepMs: z.natural().min(16).default(40),
|
|
4870
|
+
reducedMotion: z.boolean().default(false),
|
|
2851
4871
|
openBrowser: z.boolean().default(true)
|
|
2852
4872
|
});
|
|
2853
4873
|
/** The process-bound pieces of the host; tests substitute a fake terminal and captured streams. */
|
|
@@ -3019,6 +5039,22 @@ async function run(ctx, config, host) {
|
|
|
3019
5039
|
terminal: host.createTerminal(),
|
|
3020
5040
|
palette: createPalette(host.color),
|
|
3021
5041
|
toolPreviewLines: config.toolPreviewLines,
|
|
5042
|
+
liveRefreshMs: config.liveRefreshMs,
|
|
5043
|
+
fadeSteps: config.streamFadeSteps,
|
|
5044
|
+
fadeStepMs: config.streamFadeStepMs,
|
|
5045
|
+
reducedMotion: config.reducedMotion,
|
|
5046
|
+
env: process.env,
|
|
5047
|
+
now: () => Date.now(),
|
|
5048
|
+
tick: (callback, delayMs) => {
|
|
5049
|
+
const dispose = ctx.effect(() => {
|
|
5050
|
+
const timer = setInterval(callback, delayMs);
|
|
5051
|
+
timer.unref();
|
|
5052
|
+
return () => {
|
|
5053
|
+
clearInterval(timer);
|
|
5054
|
+
};
|
|
5055
|
+
}, "tui-app: repeating redraw");
|
|
5056
|
+
return () => void dispose();
|
|
5057
|
+
},
|
|
3022
5058
|
cwd,
|
|
3023
5059
|
...openUrl === void 0 ? {} : { openUrl },
|
|
3024
5060
|
releaseInput: () => {
|