@songtonyli/dsh-cli 0.1.9 → 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-api-authorization-controller/README.i18n.yaml +2 -2
- package/node_modules/@deepseek-ai/dsh-api-authorization-controller/README.md +1 -1
- package/node_modules/@deepseek-ai/dsh-api-authorization-controller/README.zh.md +1 -1
- package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/index.js +1 -0
- package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/typert.host.js +2 -1
- package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/typert.remote-client.js +1 -0
- package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/types/index.js +1 -0
- package/node_modules/@deepseek-ai/dsh-api-remotes/lib/client.js +1 -0
- package/node_modules/@deepseek-ai/dsh-authorization/README.i18n.yaml +2 -2
- package/node_modules/@deepseek-ai/dsh-authorization/README.md +1 -1
- package/node_modules/@deepseek-ai/dsh-authorization/README.zh.md +1 -1
- package/node_modules/@deepseek-ai/dsh-client-ui-settings-signin/lib/client.js +1 -0
- package/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js +1 -1
- package/node_modules/@deepseek-ai/dsh-llm-pi-ai/README.i18n.yaml +2 -2
- package/node_modules/@deepseek-ai/dsh-llm-pi-ai/README.md +1 -1
- package/node_modules/@deepseek-ai/dsh-llm-pi-ai/README.zh.md +1 -1
- package/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js +3 -1
- package/node_modules/@deepseek-ai/dsh-native-command/README.i18n.yaml +2 -2
- package/node_modules/@deepseek-ai/dsh-native-command/README.md +7 -2
- package/node_modules/@deepseek-ai/dsh-native-command/README.zh.md +7 -2
- package/node_modules/@deepseek-ai/dsh-native-command/lib/index.js +93 -2
- package/node_modules/@deepseek-ai/dsh-native-command/package.json +4 -0
- package/node_modules/@deepseek-ai/dsh-tool-cordis/lib/index.js +2 -2
- package/node_modules/@deepseek-ai/dsh-tui-app/README.i18n.yaml +2 -2
- package/node_modules/@deepseek-ai/dsh-tui-app/README.md +47 -12
- package/node_modules/@deepseek-ai/dsh-tui-app/README.zh.md +47 -12
- package/node_modules/@deepseek-ai/dsh-tui-app/cordis.patch.yml +3 -2
- package/node_modules/@deepseek-ai/dsh-tui-app/lib/index.js +3087 -1012
- package/node_modules/@deepseek-ai/dsh-tui-app/lib/startup.js +3 -2
- package/node_modules/@deepseek-ai/dsh-tui-app/package.json +2 -0
- package/node_modules/@deepseek-ai/dsh-web-app/lib/index.js +2 -77
- package/node_modules/@deepseek-ai/dsh-web-app/package.json +1 -2
- package/node_modules/@types/node/README.md +1 -1
- package/node_modules/@types/node/package.json +2 -2
- package/package.json +2 -2
|
@@ -1,11 +1,15 @@
|
|
|
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
|
+
import { launchEnvironmentOf, launchedThroughSsh } from "@deepseek-ai/dsh-launch-environment";
|
|
7
|
+
import { canOpenNativePath, openNativeUrl } from "@deepseek-ai/dsh-native-command";
|
|
6
8
|
import { SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import "@deepseek-ai/cordis";
|
|
11
|
+
import { HarnessError, ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
12
|
import { activeAtToken, formatFileMention } from "@deepseek-ai/dsh-file-reference";
|
|
8
|
-
import { ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
9
13
|
import { formatSessionReferenceMention } from "@deepseek-ai/dsh-session-reference";
|
|
10
14
|
import { assertNever } from "@deepseek-ai/dsh-util-values";
|
|
11
15
|
import { readFile } from "node:fs/promises";
|
|
@@ -16,6 +20,31 @@ import { Readable } from "node:stream";
|
|
|
16
20
|
import { pipeline } from "node:stream/promises";
|
|
17
21
|
import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, readSessionLogText, sessionLogExportDeps, sessionLogZipFilename, streamSessionLogZip } from "@deepseek-ai/dsh-session-log-export";
|
|
18
22
|
import { planReviewOptions } from "@deepseek-ai/dsh-user-questions";
|
|
23
|
+
//#region ../../credentials/authorization/src/index.ts
|
|
24
|
+
/** Stable error taxonomy for authorization failures. */
|
|
25
|
+
var AuthorizationError = class extends HarnessError {
|
|
26
|
+
constructor(message, code, options) {
|
|
27
|
+
super(message, code, options);
|
|
28
|
+
this.name = "AuthorizationError";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The rejection an {@link AuthorizationInteraction.prompt} uses to say the
|
|
33
|
+
* human declined — dismissed the question, chose not to answer — rather than
|
|
34
|
+
* that the surface broke. An attempt whose flow fails after a prompt was
|
|
35
|
+
* declined settles as `cancelled`, the same outcome as a withdrawn signal,
|
|
36
|
+
* because the human saying no is a refusal, not a breakage. Only a human's
|
|
37
|
+
* "no" may reject with this class: a prompt withdrawn by its own `signal` (a
|
|
38
|
+
* flow retiring the losing question of a race) must reject with something
|
|
39
|
+
* else, or a later genuine failure would be misread as a decline.
|
|
40
|
+
*/
|
|
41
|
+
var AuthorizationDeclinedError = class extends AuthorizationError {
|
|
42
|
+
constructor(message = "the authorization prompt was declined") {
|
|
43
|
+
super(message, "DECLINED");
|
|
44
|
+
this.name = "AuthorizationDeclinedError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
19
48
|
//#region lib/types/attach.js
|
|
20
49
|
/**
|
|
21
50
|
* `/attach`: read a local file and admit it into the attachment store as the
|
|
@@ -72,585 +101,251 @@ async function attachLocalFile(store, cwd, path) {
|
|
|
72
101
|
};
|
|
73
102
|
}
|
|
74
103
|
//#endregion
|
|
75
|
-
//#region lib/types/
|
|
104
|
+
//#region lib/types/diff.js
|
|
76
105
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* @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
|
|
81
109
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
if (dispose === void 0) {
|
|
91
|
-
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
92
|
-
dispose = value[Symbol.dispose];
|
|
93
|
-
if (async) inner = dispose;
|
|
94
|
-
}
|
|
95
|
-
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
96
|
-
if (inner) dispose = function() {
|
|
97
|
-
try {
|
|
98
|
-
inner.call(this);
|
|
99
|
-
} catch (e) {
|
|
100
|
-
return Promise.reject(e);
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
env.stack.push({
|
|
104
|
-
value,
|
|
105
|
-
dispose,
|
|
106
|
-
async
|
|
107
|
-
});
|
|
108
|
-
} else if (async) env.stack.push({ async: true });
|
|
109
|
-
return value;
|
|
110
|
-
};
|
|
111
|
-
var __disposeResources$1 = (function(SuppressedError) {
|
|
112
|
-
return function(env) {
|
|
113
|
-
function fail(e) {
|
|
114
|
-
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
115
|
-
env.hasError = true;
|
|
116
|
-
}
|
|
117
|
-
var r, s = 0;
|
|
118
|
-
function next() {
|
|
119
|
-
while (r = env.stack.pop()) try {
|
|
120
|
-
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
121
|
-
if (r.dispose) {
|
|
122
|
-
var result = r.dispose.call(r.value);
|
|
123
|
-
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
124
|
-
fail(e);
|
|
125
|
-
return next();
|
|
126
|
-
});
|
|
127
|
-
} else s |= 1;
|
|
128
|
-
} catch (e) {
|
|
129
|
-
fail(e);
|
|
130
|
-
}
|
|
131
|
-
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
132
|
-
if (env.hasError) throw env.error;
|
|
133
|
-
}
|
|
134
|
-
return next();
|
|
135
|
-
};
|
|
136
|
-
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
137
|
-
var e = new Error(message);
|
|
138
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
139
|
-
});
|
|
140
|
-
/** Two-space indentation per nesting level. */
|
|
141
|
-
function indent(depth) {
|
|
142
|
-
return " ".repeat(depth);
|
|
143
|
-
}
|
|
144
|
-
/** The settings service, or a printable error naming its absence. */
|
|
145
|
-
function requireSettings(ctx) {
|
|
146
|
-
const settings = ctx.get("settings");
|
|
147
|
-
if (settings === void 0) throw new Error("settings are not mounted in this profile");
|
|
148
|
-
return settings;
|
|
149
|
-
}
|
|
150
|
-
/** One namespace's redacted descriptor, or a printable error when it is not registered. */
|
|
151
|
-
function requireDescriptor(settings, ns) {
|
|
152
|
-
const descriptor = settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
|
|
153
|
-
if (descriptor === void 0) throw new Error(`settings namespace "${ns}" is not registered`);
|
|
154
|
-
return descriptor;
|
|
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;
|
|
155
117
|
}
|
|
156
118
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* @
|
|
161
|
-
* @returns the rows in registration order.
|
|
162
|
-
* @throws {Error} when no settings service is mounted.
|
|
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.
|
|
163
123
|
*/
|
|
164
|
-
function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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]
|
|
168
171
|
});
|
|
172
|
+
return out;
|
|
169
173
|
}
|
|
170
174
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* @param
|
|
175
|
-
* @
|
|
176
|
-
* @returns the JSON lines, then the secret-slot rows.
|
|
177
|
-
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
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.
|
|
178
180
|
*/
|
|
179
|
-
function
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
return out;
|
|
184
199
|
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region lib/types/transcript.js
|
|
185
202
|
/**
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* @param ctx - plugin context carrying the optional settings service.
|
|
191
|
-
* @param ns - the registered namespace to edit.
|
|
192
|
-
* @param path - dot-separated field path (`api.timeoutMs`), or `''` for the root.
|
|
193
|
-
* @param rawValue - the typed value, JSON or plain text.
|
|
194
|
-
* @returns a confirmation row naming the namespace, path, and stored value.
|
|
195
|
-
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
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
|
|
196
207
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const { revision } = requireDescriptor(settings, ns);
|
|
200
|
-
const value = parseSettingValue(rawValue);
|
|
201
|
-
const parts = path === "" ? [] : path.split(".");
|
|
202
|
-
await settings.mutate(ns, [{
|
|
203
|
-
op: "set",
|
|
204
|
-
path: parts,
|
|
205
|
-
value
|
|
206
|
-
}], revision);
|
|
207
|
-
return `settings ${ns}: set ${path === "" ? "(root)" : path} = ${JSON.stringify(value)}`;
|
|
208
|
-
}
|
|
209
|
-
/** JSON when the text parses, otherwise the text itself. */
|
|
210
|
-
function parseSettingValue(rawValue) {
|
|
211
|
-
try {
|
|
212
|
-
return JSON.parse(rawValue);
|
|
213
|
-
} catch {
|
|
214
|
-
return rawValue;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
208
|
+
/** Unchanged rows shown around each diff hunk. */
|
|
209
|
+
const DIFF_CONTEXT_LINES = 2;
|
|
217
210
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* @param
|
|
221
|
-
* @
|
|
222
|
-
* @returns a confirmation row.
|
|
223
|
-
* @throws {Error} when no settings service is mounted or `ns` is not registered.
|
|
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.
|
|
224
215
|
*/
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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");
|
|
230
222
|
}
|
|
231
223
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* @
|
|
235
|
-
* @returns the rows in Loader order.
|
|
236
|
-
* @throws {Error} when no Loader is mounted.
|
|
237
|
-
*/
|
|
238
|
-
function listPlugins(ctx) {
|
|
239
|
-
const loader = ctx.get("loader");
|
|
240
|
-
if (loader === void 0) throw new Error("the plugin loader is not mounted in this profile");
|
|
241
|
-
const rows = [];
|
|
242
|
-
for (const entry of loader.entries()) {
|
|
243
|
-
if (entry.options.group) continue;
|
|
244
|
-
const phase = pluginFiberPhase(entry.fiber?.state);
|
|
245
|
-
rows.push(`${entry.id} ${entry.options.name} ${entry.disabled ? "disabled" : "enabled"} ${phase ?? "none"}`);
|
|
246
|
-
}
|
|
247
|
-
return rows;
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Every session-backed subagent below one session in pre-order, one row each,
|
|
251
|
-
* indented by tree depth: the child session id (usable with `/sessions` and
|
|
252
|
-
* `--resume`), its activity, mode, and label; a diagnostic candidate shows its
|
|
253
|
-
* reason instead.
|
|
254
|
-
* @param ctx - plugin context carrying the optional subagent runtime.
|
|
255
|
-
* @param sessionId - the root session whose descendants are listed.
|
|
256
|
-
* @param signal - cancels the listing.
|
|
257
|
-
* @returns the rows, empty when the session has no subagents.
|
|
258
|
-
* @throws {Error} when no subagent runtime is mounted.
|
|
224
|
+
* Compact token count: `950`, `1.2k`, `3.4M`.
|
|
225
|
+
* @param count - a non-negative token count.
|
|
226
|
+
* @returns the formatted count.
|
|
259
227
|
*/
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
return (
|
|
264
|
-
const prefix = `${indent(entry.depth - 1)}${entry.id}`;
|
|
265
|
-
if (entry.kind === "diagnostic") return `${prefix} ${entry.reason}`;
|
|
266
|
-
const label = entry.label === void 0 ? "" : ` ${entry.label}`;
|
|
267
|
-
return `${prefix} ${entry.activity} ${entry.mode}${label}`;
|
|
268
|
-
});
|
|
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`;
|
|
269
232
|
}
|
|
270
233
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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.
|
|
279
244
|
*/
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (query === void 0) throw new Error("the session query engine is not mounted in this profile");
|
|
289
|
-
const observation = __addDisposableResource$1(env_1, await query.observeSession(sessionId, {
|
|
290
|
-
signal,
|
|
291
|
-
projectionMode: "none"
|
|
292
|
-
}), false);
|
|
293
|
-
const turns = /* @__PURE__ */ new Map();
|
|
294
|
-
for (const event of observation.events) {
|
|
295
|
-
if (event.type !== "deliverables/presented") continue;
|
|
296
|
-
const files = turns.get(event.data.turn) ?? [];
|
|
297
|
-
files.push(...event.data.files);
|
|
298
|
-
turns.set(event.data.turn, files);
|
|
299
|
-
}
|
|
300
|
-
const rows = [];
|
|
301
|
-
for (const [turn, files] of turns) {
|
|
302
|
-
rows.push(`turn ${String(turn)}`);
|
|
303
|
-
for (const file of files) rows.push(`${indent(1)}${file.path}${file.description === void 0 ? "" : ` ${file.description}`}`);
|
|
304
|
-
}
|
|
305
|
-
return rows;
|
|
306
|
-
} catch (e_1) {
|
|
307
|
-
env_1.error = e_1;
|
|
308
|
-
env_1.hasError = true;
|
|
309
|
-
} finally {
|
|
310
|
-
__disposeResources$1(env_1);
|
|
311
|
-
}
|
|
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`;
|
|
312
253
|
}
|
|
313
254
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* @
|
|
318
|
-
* @param session - the live session whose outline is read.
|
|
319
|
-
* @returns the rows, empty before the first turn starts.
|
|
320
|
-
* @throws {Error} when no projection registry is mounted or the `turnOutline` unit is not registered.
|
|
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.
|
|
321
259
|
*/
|
|
322
|
-
function
|
|
323
|
-
|
|
324
|
-
if (projections === void 0) throw new Error("session projections are not mounted in this profile");
|
|
325
|
-
const outline = projections.snapshot(session, ["turnOutline"]).values.turnOutline;
|
|
326
|
-
if (outline === void 0) throw new Error("the turnOutline projection is not registered in this profile");
|
|
327
|
-
const rows = [];
|
|
328
|
-
for (const entry of outline) {
|
|
329
|
-
rows.push(`${String(entry.turn)}. ${entry.prompt === "" ? "(no prompt)" : entry.prompt}`);
|
|
330
|
-
if (entry.response !== "") rows.push(`${indent(1)}→ ${entry.response}`);
|
|
331
|
-
}
|
|
332
|
-
return rows;
|
|
260
|
+
function formatTimestamp(ms) {
|
|
261
|
+
return new Date(ms).toISOString().slice(0, 16).replace("T", " ");
|
|
333
262
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
* @module @deepseek-ai/dsh-tui-app/style
|
|
341
|
-
*/
|
|
342
|
-
const ESC = "\x1B[";
|
|
343
|
-
/** SGR open/close code pairs by role. */
|
|
344
|
-
const SGR = {
|
|
345
|
-
dim: ["2", "22"],
|
|
346
|
-
bold: ["1", "22"],
|
|
347
|
-
italic: ["3", "23"],
|
|
348
|
-
underline: ["4", "24"],
|
|
349
|
-
accent: ["36", "39"],
|
|
350
|
-
success: ["32", "39"],
|
|
351
|
-
warning: ["33", "39"],
|
|
352
|
-
error: ["31", "39"],
|
|
353
|
-
inverse: ["7", "27"]
|
|
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
|
|
354
269
|
};
|
|
355
270
|
/**
|
|
356
|
-
*
|
|
357
|
-
* @param
|
|
358
|
-
* @
|
|
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.
|
|
359
275
|
*/
|
|
360
|
-
function
|
|
361
|
-
const role = (name) => {
|
|
362
|
-
const [open, close] = SGR[name];
|
|
363
|
-
return enabled ? (text) => `${ESC}${open}m${text}${ESC}${close}m` : (text) => text;
|
|
364
|
-
};
|
|
276
|
+
function addUsage(totals, usage) {
|
|
365
277
|
return {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
accent: role("accent"),
|
|
371
|
-
success: role("success"),
|
|
372
|
-
warning: role("warning"),
|
|
373
|
-
error: role("error"),
|
|
374
|
-
inverse: role("inverse"),
|
|
375
|
-
enabled
|
|
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)
|
|
376
282
|
};
|
|
377
283
|
}
|
|
378
284
|
/**
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
* @
|
|
382
|
-
* @param isTty - whether stdout is a terminal.
|
|
383
|
-
* @returns true when SGR styling should be emitted.
|
|
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.
|
|
384
288
|
*/
|
|
385
|
-
function
|
|
386
|
-
if (
|
|
387
|
-
|
|
388
|
-
|
|
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(" ");
|
|
389
295
|
}
|
|
390
296
|
/**
|
|
391
|
-
* The
|
|
392
|
-
* @param
|
|
393
|
-
* @returns
|
|
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.
|
|
394
300
|
*/
|
|
395
|
-
function
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
code:
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
hr: palette.dim,
|
|
406
|
-
listBullet: palette.accent,
|
|
407
|
-
bold: palette.bold,
|
|
408
|
-
italic: palette.italic,
|
|
409
|
-
strikethrough: palette.dim,
|
|
410
|
-
underline: palette.underline
|
|
411
|
-
};
|
|
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
|
+
}
|
|
412
311
|
}
|
|
413
312
|
/**
|
|
414
|
-
*
|
|
415
|
-
* @param
|
|
416
|
-
* @returns
|
|
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.
|
|
417
316
|
*/
|
|
418
|
-
function
|
|
419
|
-
return
|
|
420
|
-
selectedPrefix: palette.accent,
|
|
421
|
-
selectedText: (text) => palette.bold(palette.accent(text)),
|
|
422
|
-
description: palette.dim,
|
|
423
|
-
scrollInfo: palette.dim,
|
|
424
|
-
noMatch: palette.dim
|
|
425
|
-
};
|
|
317
|
+
function describeFailure(error) {
|
|
318
|
+
return error instanceof Error ? error.message : String(error);
|
|
426
319
|
}
|
|
427
320
|
/**
|
|
428
|
-
*
|
|
429
|
-
* @param
|
|
430
|
-
* @returns
|
|
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.
|
|
431
324
|
*/
|
|
432
|
-
function
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
325
|
+
function parseArguments(argumentsJson) {
|
|
326
|
+
try {
|
|
327
|
+
return JSON.parse(argumentsJson);
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
437
331
|
}
|
|
438
|
-
//#endregion
|
|
439
|
-
//#region lib/types/diff.js
|
|
440
332
|
/**
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
* @
|
|
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.
|
|
444
336
|
*/
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (lines.at(-1) === "") lines.pop();
|
|
451
|
-
return lines;
|
|
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
|
+
});
|
|
452
342
|
}
|
|
453
343
|
/**
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* @param
|
|
457
|
-
* @
|
|
458
|
-
|
|
459
|
-
function diffLines(oldText, newText) {
|
|
460
|
-
const next = splitLines(newText);
|
|
461
|
-
if (oldText === null) return next.map((text) => ({
|
|
462
|
-
kind: "added",
|
|
463
|
-
text
|
|
464
|
-
}));
|
|
465
|
-
const previous = splitLines(oldText);
|
|
466
|
-
if (previous.length * next.length > MAX_ALIGNMENT_CELLS) return [...previous.map((text) => ({
|
|
467
|
-
kind: "removed",
|
|
468
|
-
text
|
|
469
|
-
})), ...next.map((text) => ({
|
|
470
|
-
kind: "added",
|
|
471
|
-
text
|
|
472
|
-
}))];
|
|
473
|
-
const cols = next.length + 1;
|
|
474
|
-
const lcs = new Uint32Array((previous.length + 1) * cols);
|
|
475
|
-
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]);
|
|
476
|
-
const out = [];
|
|
477
|
-
let i = 0;
|
|
478
|
-
let j = 0;
|
|
479
|
-
while (i < previous.length && j < next.length) if (previous[i] === next[j]) {
|
|
480
|
-
out.push({
|
|
481
|
-
kind: "context",
|
|
482
|
-
text: previous[i]
|
|
483
|
-
});
|
|
484
|
-
i++;
|
|
485
|
-
j++;
|
|
486
|
-
} else if (lcs[(i + 1) * cols + j] >= lcs[i * cols + j + 1]) {
|
|
487
|
-
out.push({
|
|
488
|
-
kind: "removed",
|
|
489
|
-
text: previous[i]
|
|
490
|
-
});
|
|
491
|
-
i++;
|
|
492
|
-
} else {
|
|
493
|
-
out.push({
|
|
494
|
-
kind: "added",
|
|
495
|
-
text: next[j]
|
|
496
|
-
});
|
|
497
|
-
j++;
|
|
498
|
-
}
|
|
499
|
-
for (; i < previous.length; i++) out.push({
|
|
500
|
-
kind: "removed",
|
|
501
|
-
text: previous[i]
|
|
502
|
-
});
|
|
503
|
-
for (; j < next.length; j++) out.push({
|
|
504
|
-
kind: "added",
|
|
505
|
-
text: next[j]
|
|
506
|
-
});
|
|
507
|
-
return out;
|
|
508
|
-
}
|
|
509
|
-
/**
|
|
510
|
-
* Keep changed rows plus `context` unchanged rows around each change, so a
|
|
511
|
-
* card shows the hunks rather than the whole file.
|
|
512
|
-
* @param lines - the aligned rows.
|
|
513
|
-
* @param context - unchanged rows kept on each side of a change.
|
|
514
|
-
* @returns the rows to display, with an `undefined` gap marker where rows were elided.
|
|
515
|
-
*/
|
|
516
|
-
function hunks(lines, context) {
|
|
517
|
-
const keep = new Array(lines.length).fill(false);
|
|
518
|
-
lines.forEach((line, index) => {
|
|
519
|
-
if (line.kind === "context") return;
|
|
520
|
-
for (let k = Math.max(0, index - context); k <= Math.min(lines.length - 1, index + context); k++) keep[k] = true;
|
|
521
|
-
});
|
|
522
|
-
const out = [];
|
|
523
|
-
let gap = false;
|
|
524
|
-
lines.forEach((line, index) => {
|
|
525
|
-
if (keep[index] === true) {
|
|
526
|
-
out.push(line);
|
|
527
|
-
gap = false;
|
|
528
|
-
} else if (!gap) {
|
|
529
|
-
out.push(void 0);
|
|
530
|
-
gap = true;
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
return out;
|
|
534
|
-
}
|
|
535
|
-
//#endregion
|
|
536
|
-
//#region lib/types/transcript.js
|
|
537
|
-
/**
|
|
538
|
-
* Pure transcript formatting: durable session facts and tool presentation
|
|
539
|
-
* views become plain text rows for the terminal components to draw. Nothing
|
|
540
|
-
* here touches the terminal, the palette, or the agent.
|
|
541
|
-
* @module @deepseek-ai/dsh-tui-app/transcript
|
|
542
|
-
*/
|
|
543
|
-
/** Unchanged rows shown around each diff hunk. */
|
|
544
|
-
const DIFF_CONTEXT_LINES = 2;
|
|
545
|
-
/**
|
|
546
|
-
* Join the text of the text blocks in `blocks`; other block kinds are
|
|
547
|
-
* summarized in brackets so a card never hides that they exist.
|
|
548
|
-
* @param blocks - model or tool content.
|
|
549
|
-
* @returns the readable text.
|
|
550
|
-
*/
|
|
551
|
-
function contentText(blocks) {
|
|
552
|
-
return blocks.map((block) => {
|
|
553
|
-
if (block.type === "text") return block.text;
|
|
554
|
-
if (block.type === "reasoning") return "";
|
|
555
|
-
return `[${block.type}]`;
|
|
556
|
-
}).filter((part) => part !== "").join("\n");
|
|
557
|
-
}
|
|
558
|
-
/**
|
|
559
|
-
* Compact token count: `950`, `1.2k`, `3.4M`.
|
|
560
|
-
* @param count - a non-negative token count.
|
|
561
|
-
* @returns the formatted count.
|
|
562
|
-
*/
|
|
563
|
-
function formatTokens(count) {
|
|
564
|
-
if (count < 1e3) return String(count);
|
|
565
|
-
if (count < 1e6) return `${(count / 1e3).toFixed(count < 1e4 ? 1 : 0)}k`;
|
|
566
|
-
return `${(count / 1e6).toFixed(1)}M`;
|
|
567
|
-
}
|
|
568
|
-
/** The zero totals a fresh session starts from. */
|
|
569
|
-
const EMPTY_USAGE = {
|
|
570
|
-
inputTokens: 0,
|
|
571
|
-
outputTokens: 0,
|
|
572
|
-
cacheReadTokens: 0,
|
|
573
|
-
lastInputTokens: 0
|
|
574
|
-
};
|
|
575
|
-
/**
|
|
576
|
-
* Fold one committed usage record into the totals.
|
|
577
|
-
* @param totals - the totals so far.
|
|
578
|
-
* @param usage - the message's usage.
|
|
579
|
-
* @returns the new totals.
|
|
580
|
-
*/
|
|
581
|
-
function addUsage(totals, usage) {
|
|
582
|
-
return {
|
|
583
|
-
inputTokens: totals.inputTokens + usage.inputTokens,
|
|
584
|
-
outputTokens: totals.outputTokens + usage.outputTokens,
|
|
585
|
-
cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
|
|
586
|
-
lastInputTokens: usage.inputTokens + (usage.cacheReadTokens ?? 0)
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* The one-line usage summary drawn in the footer.
|
|
591
|
-
* @param totals - the cumulative totals.
|
|
592
|
-
* @returns the summary, or an empty string before the first committed message.
|
|
593
|
-
*/
|
|
594
|
-
function formatUsage(totals) {
|
|
595
|
-
if (totals.inputTokens === 0 && totals.outputTokens === 0) return "";
|
|
596
|
-
const parts = [`↑${formatTokens(totals.inputTokens)}`, `↓${formatTokens(totals.outputTokens)}`];
|
|
597
|
-
if (totals.cacheReadTokens > 0) parts.push(`cache ${formatTokens(totals.cacheReadTokens)}`);
|
|
598
|
-
parts.push(`ctx ${formatTokens(totals.lastInputTokens)}`);
|
|
599
|
-
return parts.join(" ");
|
|
600
|
-
}
|
|
601
|
-
/**
|
|
602
|
-
* The notice a closed turn earns, or undefined for an ordinary completion.
|
|
603
|
-
* @param reason - the durable turn-end reason.
|
|
604
|
-
* @returns the notice text.
|
|
605
|
-
*/
|
|
606
|
-
function turnEndNotice(reason) {
|
|
607
|
-
switch (reason.kind) {
|
|
608
|
-
case "completed": return;
|
|
609
|
-
case "aborted": return "turn stopped";
|
|
610
|
-
case "blocked": return "turn blocked: the model produced nothing the loop could continue";
|
|
611
|
-
case "error": return `turn failed: ${reason.error.code}: ${reason.error.message}`;
|
|
612
|
-
case "max-tokens": return "turn reached the output token ceiling";
|
|
613
|
-
case "interrupted": return "turn was interrupted by an earlier process exit";
|
|
614
|
-
default: return assertNever(reason, "tui turn-end reason");
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
/**
|
|
618
|
-
* One-line text for a failure value from a handler, a service, or the host.
|
|
619
|
-
* @param error - the thrown or rejected value.
|
|
620
|
-
* @returns the error message, or the value rendered as a string.
|
|
621
|
-
*/
|
|
622
|
-
function describeFailure(error) {
|
|
623
|
-
return error instanceof Error ? error.message : String(error);
|
|
624
|
-
}
|
|
625
|
-
/**
|
|
626
|
-
* Parse the model's raw argument JSON for presentation.
|
|
627
|
-
* @param argumentsJson - the `tool/call` event's verbatim argument string.
|
|
628
|
-
* @returns the parsed value, or undefined when it is not JSON.
|
|
629
|
-
*/
|
|
630
|
-
function parseArguments(argumentsJson) {
|
|
631
|
-
try {
|
|
632
|
-
return JSON.parse(argumentsJson);
|
|
633
|
-
} catch {
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
/**
|
|
638
|
-
* Render a diff for a card body.
|
|
639
|
-
* @param diff - one file's old and new text.
|
|
640
|
-
* @returns rows prefixed with `+`, `-`, or two spaces, plus `…` gap markers.
|
|
641
|
-
*/
|
|
642
|
-
function diffRows(diff) {
|
|
643
|
-
return hunks(diffLines(diff.oldText, diff.newText), DIFF_CONTEXT_LINES).map((row) => {
|
|
644
|
-
if (row === void 0) return " …";
|
|
645
|
-
return (row.kind === "added" ? "+ " : row.kind === "removed" ? "- " : " ") + row.text;
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Text for a tool call from its presentation view, falling back to the raw
|
|
650
|
-
* arguments when the tool declares no presenter.
|
|
651
|
-
* @param argumentsJson - the verbatim argument JSON.
|
|
652
|
-
* @param view - the tool's `presentCall` view, when it has one.
|
|
653
|
-
* @returns the card's call text.
|
|
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.
|
|
654
349
|
*/
|
|
655
350
|
function toolCallText(argumentsJson, view) {
|
|
656
351
|
if (view === void 0) {
|
|
@@ -733,292 +428,1822 @@ function previewLines(lines, previewLines, expanded) {
|
|
|
733
428
|
return [...lines.slice(0, previewLines), `… ${String(hidden)} more line${hidden === 1 ? "" : "s"} (Ctrl+O expands)`];
|
|
734
429
|
}
|
|
735
430
|
//#endregion
|
|
736
|
-
//#region lib/types/
|
|
431
|
+
//#region lib/types/catalog.js
|
|
737
432
|
/**
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
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
|
|
742
438
|
*/
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
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
|
+
});
|
|
465
|
+
} else if (async) env.stack.push({ async: true });
|
|
466
|
+
return value;
|
|
770
467
|
};
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
+
};
|
|
1486
|
+
}
|
|
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
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region lib/types/editor.js
|
|
1515
|
+
/**
|
|
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
|
|
774
1521
|
*/
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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);
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
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.
|
|
1578
|
+
*/
|
|
1579
|
+
var BarCursorEditor = class extends Editor {
|
|
796
1580
|
/**
|
|
797
|
-
*
|
|
798
|
-
* @param
|
|
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.
|
|
799
1584
|
*/
|
|
800
|
-
|
|
801
|
-
this.
|
|
802
|
-
this.
|
|
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
|
+
});
|
|
803
1598
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
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.
|
|
1618
|
+
*/
|
|
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
|
+
}))];
|
|
1630
|
+
}
|
|
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`;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
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.
|
|
1646
|
+
*/
|
|
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;
|
|
1652
|
+
}
|
|
1653
|
+
//#endregion
|
|
1654
|
+
//#region lib/types/export.js
|
|
1655
|
+
/**
|
|
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
|
|
1659
|
+
*/
|
|
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;
|
|
1686
|
+
}
|
|
1687
|
+
//#endregion
|
|
1688
|
+
//#region lib/types/todos.js
|
|
1689
|
+
/**
|
|
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
|
|
1696
|
+
*/
|
|
1697
|
+
/**
|
|
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.
|
|
1704
|
+
*/
|
|
1705
|
+
const TODO_LABEL_COLUMNS = 64;
|
|
1706
|
+
/**
|
|
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.
|
|
1712
|
+
*/
|
|
1713
|
+
const TODO_DETAIL_COLUMNS = 72;
|
|
1714
|
+
/**
|
|
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.
|
|
1718
|
+
*/
|
|
1719
|
+
const TODO_GLYPH = {
|
|
1720
|
+
completed: "✓",
|
|
1721
|
+
in_progress: "▸",
|
|
1722
|
+
pending: "○"
|
|
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
|
+
];
|
|
1736
|
+
/**
|
|
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.
|
|
1744
|
+
*/
|
|
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
|
+
}));
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
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.
|
|
1763
|
+
*/
|
|
1764
|
+
function labelContent(content) {
|
|
1765
|
+
if (visibleWidth(content) <= TODO_LABEL_COLUMNS) return content;
|
|
1766
|
+
return `${sliceByColumn(content, 0, TODO_LABEL_COLUMNS - 1, true)}…`;
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
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.
|
|
1772
|
+
*/
|
|
1773
|
+
function describeStatus(status) {
|
|
1774
|
+
return status === "in_progress" ? `${TODO_WORD[status]} · being worked on now` : TODO_WORD[status];
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
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.
|
|
1788
|
+
*/
|
|
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)}`);
|
|
816
1801
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1802
|
+
return rows;
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
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`.
|
|
1809
|
+
*/
|
|
1810
|
+
function countsRow(items) {
|
|
1811
|
+
return TODO_STATUSES.map((status) => `${String(items.filter((item) => item.status === status).length)} ${TODO_WORD[status]}`).join(" · ");
|
|
1812
|
+
}
|
|
1813
|
+
//#endregion
|
|
1814
|
+
//#region lib/types/status.js
|
|
1815
|
+
/**
|
|
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
|
|
1822
|
+
*/
|
|
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
|
+
];
|
|
1834
|
+
/**
|
|
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.
|
|
1839
|
+
*/
|
|
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
|
+
};
|
|
820
1861
|
}
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
+
};
|
|
827
1871
|
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1872
|
+
if (values.plan !== void 0) facts.plan = values.plan;
|
|
1873
|
+
if (values.permissions !== void 0) facts.permissions = values.permissions;
|
|
1874
|
+
return facts;
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
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.
|
|
1881
|
+
*/
|
|
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)}%`
|
|
1887
|
+
});
|
|
1888
|
+
if (facts.todos !== void 0) parts.push({
|
|
1889
|
+
id: "todo",
|
|
1890
|
+
label: `todo ${String(facts.todos.done)}/${String(facts.todos.items.length)}`
|
|
1891
|
+
});
|
|
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;
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
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.
|
|
1911
|
+
*/
|
|
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)}%)`);
|
|
841
1917
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
* @param isError - whether the tool reported failure.
|
|
846
|
-
*/
|
|
847
|
-
setResult(lines, isError) {
|
|
848
|
-
this.resultLines = lines;
|
|
849
|
-
this.status = isError ? "error" : "done";
|
|
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)}`);
|
|
850
1921
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1922
|
+
return lines;
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
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.
|
|
1929
|
+
*/
|
|
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(" · ")}`);
|
|
857
1943
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
return [
|
|
866
|
-
"",
|
|
867
|
-
...wrapTextWithAnsi(header, width),
|
|
868
|
-
...rows
|
|
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)}`
|
|
869
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(" · ")}`);
|
|
870
1955
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
//#region lib/types/completion.js
|
|
1956
|
+
return lines;
|
|
1957
|
+
}
|
|
874
1958
|
/**
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
* @module @deepseek-ai/dsh-tui-app/completion
|
|
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.
|
|
879
1962
|
*/
|
|
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}`)];
|
|
1967
|
+
}
|
|
880
1968
|
/**
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
* @param
|
|
884
|
-
* @returns the
|
|
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.
|
|
885
1973
|
*/
|
|
886
|
-
function
|
|
887
|
-
return
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
const at = activeAtToken(line, cursorCol);
|
|
893
|
-
if (at !== void 0) {
|
|
894
|
-
const items = (await sources.references(at.query, at.quoted, options.signal)).map((reference) => ({
|
|
895
|
-
value: reference.mention,
|
|
896
|
-
label: reference.label,
|
|
897
|
-
...reference.description === void 0 ? {} : { description: reference.description }
|
|
898
|
-
}));
|
|
899
|
-
return items.length === 0 ? null : {
|
|
900
|
-
items,
|
|
901
|
-
prefix: at.prefix
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
if (cursorLine !== 0 || !before.startsWith("/") || /\s/u.test(before)) return null;
|
|
905
|
-
const prefix = before;
|
|
906
|
-
const items = sources.commands().filter((command) => `/${command.name}`.startsWith(prefix)).map((command) => ({
|
|
907
|
-
value: `/${command.name}`,
|
|
908
|
-
label: `/${command.name}`,
|
|
909
|
-
description: command.hint === void 0 ? command.description : `${command.description} · ${command.hint}`
|
|
910
|
-
}));
|
|
911
|
-
return items.length === 0 ? null : {
|
|
912
|
-
items,
|
|
913
|
-
prefix
|
|
914
|
-
};
|
|
915
|
-
},
|
|
916
|
-
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
917
|
-
const line = lines[cursorLine] ?? "";
|
|
918
|
-
const start = cursorCol - prefix.length;
|
|
919
|
-
const replaced = `${line.slice(0, start)}${item.value} ${line.slice(cursorCol)}`;
|
|
920
|
-
const next = [...lines];
|
|
921
|
-
next[cursorLine] = replaced;
|
|
922
|
-
return {
|
|
923
|
-
lines: next,
|
|
924
|
-
cursorLine,
|
|
925
|
-
cursorCol: start + item.value.length + 1
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
};
|
|
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;
|
|
929
1980
|
}
|
|
930
|
-
/** What the provider-default row is called. */
|
|
931
|
-
const PROVIDER_DEFAULT_LABEL = "Provider default";
|
|
932
|
-
/** The `/effort` argument that restores the provider default. */
|
|
933
|
-
const DEFAULT_ARGUMENT = "default";
|
|
934
1981
|
/**
|
|
935
|
-
* The
|
|
936
|
-
* @param
|
|
937
|
-
* @
|
|
938
|
-
* @returns the declared name, the raw id when the model no longer declares it, or the provider-default label.
|
|
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.
|
|
939
1985
|
*/
|
|
940
|
-
function
|
|
941
|
-
if (
|
|
942
|
-
return
|
|
1986
|
+
function planLines(facts) {
|
|
1987
|
+
if (facts.plan === void 0) return [];
|
|
1988
|
+
return [`plan: ${facts.plan.active ? "on" : "off"}${facts.plan.pending ? " (switching)" : ""}`];
|
|
943
1989
|
}
|
|
944
1990
|
/**
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
* @
|
|
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.
|
|
1995
|
+
*/
|
|
1996
|
+
function permissionLines(facts) {
|
|
1997
|
+
if (facts.permissions === void 0) return [];
|
|
1998
|
+
return [`permission: ${facts.permissions.currentValue}`];
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
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.
|
|
2007
|
+
*/
|
|
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;
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
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.
|
|
2024
|
+
*/
|
|
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);
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Compact duration: `45.2s` under a minute, `2m42s` from there on.
|
|
2035
|
+
* @param ms - duration in milliseconds.
|
|
2036
|
+
* @returns the formatted duration.
|
|
2037
|
+
*/
|
|
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`;
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
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)`.
|
|
2048
|
+
*/
|
|
2049
|
+
function compactionNotice(data) {
|
|
2050
|
+
const count = data.shadowedSeqs.length;
|
|
2051
|
+
return `compacted ${String(count)} ${plural(count, "item")} (~${formatTokens(data.shadowedTokenCount)} tokens)`;
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
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)`.
|
|
2058
|
+
*/
|
|
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`;
|
|
2064
|
+
}
|
|
2065
|
+
//#endregion
|
|
2066
|
+
//#region lib/types/footer.js
|
|
2067
|
+
/**
|
|
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
|
|
2076
|
+
*/
|
|
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 = " · ";
|
|
2081
|
+
/**
|
|
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.
|
|
2086
|
+
*/
|
|
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";
|
|
2096
|
+
/**
|
|
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.
|
|
2101
|
+
*/
|
|
2102
|
+
const STATUS_SECTIONS = {
|
|
2103
|
+
context: contextLines,
|
|
2104
|
+
todo: { kind: "page" },
|
|
2105
|
+
goal: goalLines,
|
|
2106
|
+
plan: planLines
|
|
2107
|
+
};
|
|
2108
|
+
/**
|
|
2109
|
+
* Detail rows as the segment carries them.
|
|
2110
|
+
* @param rows - the rows the app prints; never empty.
|
|
2111
|
+
* @returns the detail.
|
|
2112
|
+
*/
|
|
2113
|
+
function printed(rows) {
|
|
2114
|
+
return {
|
|
2115
|
+
kind: "rows",
|
|
2116
|
+
rows
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
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.
|
|
948
2125
|
*/
|
|
949
|
-
function
|
|
950
|
-
const
|
|
951
|
-
return
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
...resolved === void 0 ? {} : { description: resolved }
|
|
955
|
-
}, ...reasoning.efforts.map((effort) => ({
|
|
956
|
-
value: effort.id,
|
|
957
|
-
label: effort.name,
|
|
958
|
-
...effort.description === void 0 ? {} : { description: effort.description }
|
|
959
|
-
}))];
|
|
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]}`;
|
|
960
2131
|
}
|
|
961
2132
|
/**
|
|
962
|
-
*
|
|
963
|
-
* @param
|
|
964
|
-
* @param
|
|
965
|
-
* @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`.
|
|
966
2137
|
*/
|
|
967
|
-
function
|
|
968
|
-
|
|
2138
|
+
function startsWithDirectory(path, directory) {
|
|
2139
|
+
if (!path.startsWith(directory)) return false;
|
|
2140
|
+
const next = path.charAt(directory.length);
|
|
2141
|
+
return next === "" || next === "/" || next === "\\";
|
|
969
2142
|
}
|
|
970
2143
|
/**
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
* @
|
|
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.
|
|
976
2150
|
*/
|
|
977
|
-
function
|
|
978
|
-
const
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
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;
|
|
982
2215
|
}
|
|
983
|
-
//#endregion
|
|
984
|
-
//#region lib/types/export.js
|
|
985
2216
|
/**
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
* @
|
|
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.
|
|
989
2221
|
*/
|
|
2222
|
+
function footerSelectionIndex(segments, selected) {
|
|
2223
|
+
const index = segments.findIndex((segment) => segment.id === selected);
|
|
2224
|
+
return index === -1 ? 0 : index;
|
|
2225
|
+
}
|
|
990
2226
|
/**
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
* @param
|
|
996
|
-
* @param
|
|
997
|
-
* @returns the
|
|
998
|
-
* @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.
|
|
999
2234
|
*/
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
sessionQuery: deps.sessionQuery,
|
|
1006
|
-
sessionPersistence: deps.sessionPersistence,
|
|
1007
|
-
attachments: deps.attachments
|
|
1008
|
-
};
|
|
1009
|
-
await flushLiveSessionLog(deps, sessionId, signal);
|
|
1010
|
-
const root = await readSessionLogText(deps.sessionPersistence, sessionId, signal);
|
|
1011
|
-
if (root === void 0) throw new Error(`session ${sessionId} has no persisted log to export`);
|
|
1012
|
-
const path = join(directory, sessionLogZipFilename(sessionId));
|
|
1013
|
-
const archive = streamSessionLogZip(ready, root, sessionId, true, DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, signal);
|
|
1014
|
-
await pipeline(Readable.fromWeb(archive), createWriteStream(path), { signal });
|
|
1015
|
-
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)}`;
|
|
1016
2240
|
}
|
|
1017
2241
|
//#endregion
|
|
1018
2242
|
//#region lib/types/prompts.js
|
|
1019
2243
|
/**
|
|
1020
|
-
* Modal prompts the
|
|
1021
|
-
* 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.
|
|
1022
2247
|
* @module @deepseek-ai/dsh-tui-app/prompts
|
|
1023
2248
|
*/
|
|
1024
2249
|
/** Rows a select list shows before scrolling. */
|
|
@@ -1063,22 +2288,46 @@ var Settlement = class {
|
|
|
1063
2288
|
var ListPrompt = class {
|
|
1064
2289
|
palette;
|
|
1065
2290
|
body;
|
|
2291
|
+
choose;
|
|
2292
|
+
cancel;
|
|
2293
|
+
layout;
|
|
1066
2294
|
settled;
|
|
1067
2295
|
settlement = new Settlement();
|
|
1068
2296
|
heading;
|
|
1069
2297
|
list;
|
|
1070
|
-
constructor(palette, heading, body, items, choose, cancel) {
|
|
2298
|
+
constructor(palette, heading, body, items, choose, cancel, layout) {
|
|
1071
2299
|
this.palette = palette;
|
|
1072
2300
|
this.body = body;
|
|
2301
|
+
this.choose = choose;
|
|
2302
|
+
this.cancel = cancel;
|
|
2303
|
+
this.layout = layout;
|
|
1073
2304
|
this.settled = this.settlement.settled;
|
|
1074
2305
|
this.heading = new Text(heading, 0, 0);
|
|
1075
|
-
this.list =
|
|
1076
|
-
|
|
1077
|
-
|
|
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));
|
|
1078
2319
|
};
|
|
1079
|
-
|
|
1080
|
-
this.settlement.settle(cancel());
|
|
2320
|
+
list.onCancel = () => {
|
|
2321
|
+
this.settlement.settle(this.cancel());
|
|
1081
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);
|
|
1082
2331
|
}
|
|
1083
2332
|
/**
|
|
1084
2333
|
* Settle from outside the list, for withdrawal.
|
|
@@ -1108,9 +2357,18 @@ var ListPrompt = class {
|
|
|
1108
2357
|
"",
|
|
1109
2358
|
...this.heading.render(width),
|
|
1110
2359
|
...body,
|
|
1111
|
-
...this.
|
|
2360
|
+
...this.listLines(width)
|
|
1112
2361
|
];
|
|
1113
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
|
+
}
|
|
1114
2372
|
};
|
|
1115
2373
|
/**
|
|
1116
2374
|
* Approval question: allow this one tool call or reject it; Escape rejects.
|
|
@@ -1136,19 +2394,168 @@ var ApprovalPrompt = class extends ListPrompt {
|
|
|
1136
2394
|
};
|
|
1137
2395
|
/** Marks the row a picker opened on, so it stays visible after the highlight moves. */
|
|
1138
2396
|
const CURRENT_MARK = " ✓";
|
|
1139
|
-
/**
|
|
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
|
+
*/
|
|
1140
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;
|
|
1141
2438
|
constructor(palette, title, items, options = {}) {
|
|
2439
|
+
const rows = items.map((item) => ({ ...item }));
|
|
1142
2440
|
const inForce = items.findIndex((item) => item.value === options.current);
|
|
1143
|
-
|
|
1144
|
-
...
|
|
1145
|
-
label: `${
|
|
1146
|
-
} :
|
|
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;
|
|
1147
2450
|
if (inForce > 0) this.highlight(inForce);
|
|
1148
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
|
+
}
|
|
1149
2463
|
withdraw() {
|
|
1150
2464
|
this.settle(void 0);
|
|
1151
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
|
+
}
|
|
1152
2559
|
};
|
|
1153
2560
|
/** Marker value of the free-text row. */
|
|
1154
2561
|
const CUSTOM_VALUE = "\0custom";
|
|
@@ -1400,200 +2807,96 @@ async function listSessionChoices(ctx, currentId, signal) {
|
|
|
1400
2807
|
* The picker row for one session: title or id, then the workspace and date.
|
|
1401
2808
|
* @param choice - the session.
|
|
1402
2809
|
* @returns the label and description.
|
|
1403
|
-
*/
|
|
1404
|
-
function describeSession(choice) {
|
|
1405
|
-
const parts = [
|
|
1406
|
-
if (choice.cwd !== void 0) parts.push(choice.cwd);
|
|
1407
|
-
if (choice.current) parts.push("current");
|
|
1408
|
-
return {
|
|
1409
|
-
label: choice.title ?? choice.id,
|
|
1410
|
-
description: parts.join(" · ")
|
|
1411
|
-
};
|
|
1412
|
-
}
|
|
1413
|
-
//#endregion
|
|
1414
|
-
//#region lib/types/status.js
|
|
1415
|
-
/**
|
|
1416
|
-
* Persistent session status for the terminal: one read of the session
|
|
1417
|
-
* projections becomes plain facts, and pure formatters turn those facts into
|
|
1418
|
-
* footer parts, the `/status` report, and the one-line notices for
|
|
1419
|
-
* compaction and model-request retries. Nothing here touches the terminal,
|
|
1420
|
-
* the palette, or the agent.
|
|
1421
|
-
* @module @deepseek-ai/dsh-tui-app/status
|
|
1422
|
-
*/
|
|
1423
|
-
/** The client-visible keys one status read selects. */
|
|
1424
|
-
const STATUS_KEYS = [
|
|
1425
|
-
"contextPressure",
|
|
1426
|
-
"contextBreakdown",
|
|
1427
|
-
"tokenUsage",
|
|
1428
|
-
"sessionStats",
|
|
1429
|
-
"todos",
|
|
1430
|
-
"goal",
|
|
1431
|
-
"plan",
|
|
1432
|
-
"permissions"
|
|
1433
|
-
];
|
|
1434
|
-
/**
|
|
1435
|
-
* Read one consistent cut of the status projections for `session`.
|
|
1436
|
-
* @param projections - the session-projection registry (`ctx.get('sessionProjections')`).
|
|
1437
|
-
* @param session - the session whose status is read.
|
|
1438
|
-
* @returns the facts every registered key yields; unregistered keys leave their fact absent.
|
|
1439
|
-
*/
|
|
1440
|
-
function readStatusFacts(projections, session) {
|
|
1441
|
-
const { values } = projections.snapshot(session, STATUS_KEYS);
|
|
1442
|
-
const facts = {};
|
|
1443
|
-
const pressure = values.contextPressure;
|
|
1444
|
-
const used = pressure?.projectedTokens ?? pressure?.pressureTokens;
|
|
1445
|
-
if (used !== void 0 && pressure?.contextWindow !== void 0) facts.context = {
|
|
1446
|
-
used,
|
|
1447
|
-
window: pressure.contextWindow,
|
|
1448
|
-
percent: Math.min(100, Math.round(used / pressure.contextWindow * 100))
|
|
1449
|
-
};
|
|
1450
|
-
if (values.contextBreakdown !== void 0) facts.breakdown = values.contextBreakdown;
|
|
1451
|
-
if (values.tokenUsage !== void 0) facts.tokenUsage = values.tokenUsage;
|
|
1452
|
-
if (values.sessionStats !== void 0) facts.stats = values.sessionStats;
|
|
1453
|
-
if (values.todos !== void 0 && values.todos !== null) {
|
|
1454
|
-
const items = values.todos;
|
|
1455
|
-
facts.todos = {
|
|
1456
|
-
items,
|
|
1457
|
-
done: items.filter((item) => item.status === "completed").length,
|
|
1458
|
-
active: items.filter((item) => item.status === "in_progress").length,
|
|
1459
|
-
pending: items.filter((item) => item.status === "pending").length
|
|
1460
|
-
};
|
|
1461
|
-
}
|
|
1462
|
-
if (values.goal !== void 0 && values.goal !== null) {
|
|
1463
|
-
const { goal, roundsStarted } = values.goal;
|
|
1464
|
-
facts.goal = {
|
|
1465
|
-
phase: goal.phase,
|
|
1466
|
-
objective: goal.objective,
|
|
1467
|
-
round: roundsStarted,
|
|
1468
|
-
maxRounds: goal.maxGoalRounds,
|
|
1469
|
-
...goal.blockedReason === void 0 ? {} : { blockedReason: goal.blockedReason.message }
|
|
1470
|
-
};
|
|
1471
|
-
}
|
|
1472
|
-
if (values.plan !== void 0) facts.plan = values.plan;
|
|
1473
|
-
if (values.permissions !== void 0) facts.permissions = values.permissions;
|
|
1474
|
-
return facts;
|
|
1475
|
-
}
|
|
1476
|
-
/**
|
|
1477
|
-
* The short footer parts: `ctx 42%`, `todo 2/5` (done over total),
|
|
1478
|
-
* `goal active`, and `plan` (`plan…` while a mode switch is pending).
|
|
1479
|
-
* @param facts - the facts one status read produced.
|
|
1480
|
-
* @returns one part per present fact, in footer order; empty when nothing is known.
|
|
1481
|
-
*/
|
|
1482
|
-
function footerStatus(facts) {
|
|
1483
|
-
const parts = [];
|
|
1484
|
-
if (facts.context !== void 0) parts.push(`ctx ${String(facts.context.percent)}%`);
|
|
1485
|
-
if (facts.todos !== void 0) parts.push(`todo ${String(facts.todos.done)}/${String(facts.todos.items.length)}`);
|
|
1486
|
-
if (facts.goal !== void 0) parts.push(`goal ${facts.goal.phase}`);
|
|
1487
|
-
if (facts.plan?.pending === true) parts.push("plan…");
|
|
1488
|
-
else if (facts.plan?.active === true) parts.push("plan");
|
|
1489
|
-
return parts;
|
|
1490
|
-
}
|
|
1491
|
-
/** Glyph per todo status, matching the browser's list markers. */
|
|
1492
|
-
const TODO_GLYPH = {
|
|
1493
|
-
completed: "✓",
|
|
1494
|
-
in_progress: "▸",
|
|
1495
|
-
pending: "○"
|
|
1496
|
-
};
|
|
1497
|
-
/**
|
|
1498
|
-
* The multi-line `/status` report: context occupancy and composition, token
|
|
1499
|
-
* usage with the cache-hit share, session stats, the todo list, the goal,
|
|
1500
|
-
* plan mode, and the permission preset — one section per present fact.
|
|
1501
|
-
* @param facts - the facts one status read produced.
|
|
1502
|
-
* @returns the report lines; a single explanatory line when nothing is known.
|
|
1503
|
-
*/
|
|
1504
|
-
function statusReport(facts) {
|
|
1505
|
-
const lines = [];
|
|
1506
|
-
if (facts.context !== void 0) {
|
|
1507
|
-
const { used, window, percent } = facts.context;
|
|
1508
|
-
lines.push(`context: ~${formatTokens(used)} / ${formatTokens(window)} (${String(percent)}%)`);
|
|
1509
|
-
}
|
|
1510
|
-
if (facts.breakdown !== void 0) {
|
|
1511
|
-
const { systemTokens, toolsTokens, messageTokens } = facts.breakdown;
|
|
1512
|
-
lines.push(` system ~${formatTokens(systemTokens)} · tools ~${formatTokens(toolsTokens)} · messages ~${formatTokens(messageTokens)}`);
|
|
1513
|
-
}
|
|
1514
|
-
if (facts.tokenUsage !== void 0) {
|
|
1515
|
-
const usage = facts.tokenUsage;
|
|
1516
|
-
const parts = [
|
|
1517
|
-
`↑${formatTokens(usage.uncachedInputTokens)} uncached`,
|
|
1518
|
-
`cache read ${formatTokens(usage.cacheReadTokens)}`,
|
|
1519
|
-
`cache write ${formatTokens(usage.cacheWriteTokens)}`,
|
|
1520
|
-
`↓${formatTokens(usage.outputTokens)}`
|
|
1521
|
-
];
|
|
1522
|
-
const hit = cacheHitPercent(usage);
|
|
1523
|
-
if (hit !== null) parts.push(`cache hit ${hit}%`);
|
|
1524
|
-
lines.push(`tokens: ${parts.join(" · ")}`);
|
|
1525
|
-
}
|
|
1526
|
-
if (facts.stats !== void 0) {
|
|
1527
|
-
const stats = facts.stats;
|
|
1528
|
-
const parts = [
|
|
1529
|
-
`${String(stats.turns)} ${plural(stats.turns, "turn")}`,
|
|
1530
|
-
`${String(stats.steps)} ${plural(stats.steps, "step")}`,
|
|
1531
|
-
`model ${formatDuration(stats.llmMs)}`,
|
|
1532
|
-
`tools ${formatDuration(stats.toolMs)}`
|
|
1533
|
-
];
|
|
1534
|
-
if (stats.ttftSteps > 0) parts.push(`first token ${formatDuration(stats.ttftMs / stats.ttftSteps)} avg`);
|
|
1535
|
-
if (stats.decodeMs > 0) parts.push(`${String(Math.round(stats.decodeTokens / stats.decodeMs * 1e3))} tok/s`);
|
|
1536
|
-
lines.push(`session: ${parts.join(" · ")}`);
|
|
1537
|
-
}
|
|
1538
|
-
if (facts.todos !== void 0) {
|
|
1539
|
-
const { items, done, active, pending } = facts.todos;
|
|
1540
|
-
lines.push(`todos: ${String(done)} done · ${String(active)} active · ${String(pending)} pending`);
|
|
1541
|
-
for (const item of items) lines.push(` ${TODO_GLYPH[item.status]} ${item.content}`);
|
|
1542
|
-
}
|
|
1543
|
-
if (facts.goal !== void 0) {
|
|
1544
|
-
const goal = facts.goal;
|
|
1545
|
-
lines.push(`goal: ${goal.phase} · round ${String(goal.round)}/${String(goal.maxRounds)} · ${goal.objective}`);
|
|
1546
|
-
if (goal.blockedReason !== void 0) lines.push(` blocked: ${goal.blockedReason}`);
|
|
1547
|
-
}
|
|
1548
|
-
if (facts.plan !== void 0) lines.push(`plan: ${facts.plan.active ? "on" : "off"}${facts.plan.pending ? " (switching)" : ""}`);
|
|
1549
|
-
if (facts.permissions !== void 0) lines.push(`permission: ${facts.permissions.currentValue}`);
|
|
1550
|
-
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
|
+
};
|
|
1551
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`;
|
|
1552
2826
|
/**
|
|
1553
|
-
* The
|
|
1554
|
-
*
|
|
1555
|
-
* @
|
|
1556
|
-
*
|
|
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.
|
|
1557
2832
|
*/
|
|
1558
|
-
function
|
|
1559
|
-
|
|
1560
|
-
if (
|
|
1561
|
-
if (promptTokens === usage.cacheReadTokens) return "100";
|
|
1562
|
-
const percent = usage.cacheReadTokens / promptTokens * 100;
|
|
1563
|
-
const rounded = Math.round(percent);
|
|
1564
|
-
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);
|
|
1565
2836
|
}
|
|
1566
2837
|
/**
|
|
1567
|
-
*
|
|
1568
|
-
* @param
|
|
1569
|
-
* @
|
|
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.
|
|
1570
2843
|
*/
|
|
1571
|
-
function
|
|
1572
|
-
const
|
|
1573
|
-
if (
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
+
};
|
|
1576
2866
|
}
|
|
1577
2867
|
/**
|
|
1578
|
-
*
|
|
1579
|
-
*
|
|
1580
|
-
* @
|
|
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.
|
|
1581
2872
|
*/
|
|
1582
|
-
function
|
|
1583
|
-
const
|
|
1584
|
-
|
|
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
|
+
};
|
|
1585
2881
|
}
|
|
1586
2882
|
/**
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
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.
|
|
1591
2890
|
*/
|
|
1592
|
-
function
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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");
|
|
1597
2900
|
}
|
|
1598
2901
|
//#endregion
|
|
1599
2902
|
//#region lib/types/app.js
|
|
@@ -1601,11 +2904,76 @@ function plural(count, noun) {
|
|
|
1601
2904
|
* The interactive terminal application: it renders the durable session log
|
|
1602
2905
|
* and the live assistant stream of one Agent at a time into a pi-tui tree,
|
|
1603
2906
|
* turns keystrokes into agent input, answers the approval and user-questions
|
|
1604
|
-
* 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.
|
|
1605
2912
|
* @module @deepseek-ai/dsh-tui-app/app
|
|
1606
2913
|
*/
|
|
1607
2914
|
/** A second Ctrl+C inside this window quits. */
|
|
1608
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
|
+
}
|
|
1609
2977
|
/** The terminal's own commands, handled before the shared command registry. */
|
|
1610
2978
|
const LOCAL_COMMANDS = [
|
|
1611
2979
|
{
|
|
@@ -1664,6 +3032,10 @@ const LOCAL_COMMANDS = [
|
|
|
1664
3032
|
name: "status",
|
|
1665
3033
|
description: "Show context usage, token totals, session stats, todos, goal, plan, and permission"
|
|
1666
3034
|
},
|
|
3035
|
+
{
|
|
3036
|
+
name: "todos",
|
|
3037
|
+
description: "Browse the agent's todo list (Enter shows one item in full)"
|
|
3038
|
+
},
|
|
1667
3039
|
{
|
|
1668
3040
|
name: "outline",
|
|
1669
3041
|
description: "List the turns of this session with their prompts and replies"
|
|
@@ -1674,7 +3046,7 @@ const LOCAL_COMMANDS = [
|
|
|
1674
3046
|
},
|
|
1675
3047
|
{
|
|
1676
3048
|
name: "subagents",
|
|
1677
|
-
description: "
|
|
3049
|
+
description: "Browse the subagent sessions under this session (Enter shows one session's details)"
|
|
1678
3050
|
},
|
|
1679
3051
|
{
|
|
1680
3052
|
name: "settings",
|
|
@@ -1715,6 +3087,9 @@ var TuiApp = class {
|
|
|
1715
3087
|
loader;
|
|
1716
3088
|
modalSlot = new Container();
|
|
1717
3089
|
editor;
|
|
3090
|
+
/** Holds {@link panel} exactly while the bound session has subagent rows. */
|
|
3091
|
+
panelSlot = new Container();
|
|
3092
|
+
panel;
|
|
1718
3093
|
footer;
|
|
1719
3094
|
modals;
|
|
1720
3095
|
theme;
|
|
@@ -1722,7 +3097,43 @@ var TuiApp = class {
|
|
|
1722
3097
|
toolArguments = /* @__PURE__ */ new Map();
|
|
1723
3098
|
submittedIds = /* @__PURE__ */ new Set();
|
|
1724
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;
|
|
1725
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;
|
|
1726
3137
|
bound;
|
|
1727
3138
|
streaming;
|
|
1728
3139
|
toolsExpanded = false;
|
|
@@ -1730,6 +3141,8 @@ var TuiApp = class {
|
|
|
1730
3141
|
switching = false;
|
|
1731
3142
|
/** Serializes Shift+Tab effort cycles so rapid presses apply in order. */
|
|
1732
3143
|
effortCycle = Promise.resolve();
|
|
3144
|
+
/** Serializes `/attach` reads so pending attachments keep the typed order. */
|
|
3145
|
+
attaching = Promise.resolve();
|
|
1733
3146
|
usage = EMPTY_USAGE;
|
|
1734
3147
|
lastCtrlC = 0;
|
|
1735
3148
|
stopped = false;
|
|
@@ -1741,11 +3154,11 @@ var TuiApp = class {
|
|
|
1741
3154
|
palette,
|
|
1742
3155
|
toolPreviewLines: deps.toolPreviewLines
|
|
1743
3156
|
};
|
|
1744
|
-
this.tui = new TuiMainScreen(deps.terminal);
|
|
3157
|
+
this.tui = new TuiMainScreen(deps.terminal, true);
|
|
1745
3158
|
this.header = new Text("", 0, 0);
|
|
1746
3159
|
this.loader = new Loader(this.tui, palette.accent, palette.dim, "thinking");
|
|
1747
3160
|
this.loader.stop();
|
|
1748
|
-
this.editor = new
|
|
3161
|
+
this.editor = new BarCursorEditor(this.tui, editorTheme(palette), { paddingX: 1 });
|
|
1749
3162
|
this.editor.setAutocompleteProvider(editorCompletion({
|
|
1750
3163
|
commands: () => this.completableCommands(),
|
|
1751
3164
|
references: (query, quoted, signal) => this.references(query, quoted, signal)
|
|
@@ -1753,6 +3166,7 @@ var TuiApp = class {
|
|
|
1753
3166
|
this.editor.onSubmit = (text) => {
|
|
1754
3167
|
this.onSubmit(text);
|
|
1755
3168
|
};
|
|
3169
|
+
this.panel = new Text("", 0, 0);
|
|
1756
3170
|
this.footer = new Text("", 0, 0);
|
|
1757
3171
|
this.modals = new ModalQueue({
|
|
1758
3172
|
tui: this.tui,
|
|
@@ -1765,6 +3179,7 @@ var TuiApp = class {
|
|
|
1765
3179
|
this.statusSlot,
|
|
1766
3180
|
this.modalSlot,
|
|
1767
3181
|
this.editor,
|
|
3182
|
+
this.panelSlot,
|
|
1768
3183
|
this.footer
|
|
1769
3184
|
]) this.tui.addChild(child);
|
|
1770
3185
|
}
|
|
@@ -1784,8 +3199,15 @@ var TuiApp = class {
|
|
|
1784
3199
|
if (subject !== this.agent) return;
|
|
1785
3200
|
this.onStreamFrame(frame);
|
|
1786
3201
|
}), ctx.on("agent/status", ({ agent: subject, status }) => {
|
|
1787
|
-
if (subject !== this.agent)
|
|
3202
|
+
if (subject !== this.agent) {
|
|
3203
|
+
this.markSubagentsStale();
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
1788
3206
|
this.setWorking(status === "running");
|
|
3207
|
+
}), ctx.on("subagent/start", () => {
|
|
3208
|
+
this.markSubagentsStale();
|
|
3209
|
+
}), ctx.on("subagent/end", () => {
|
|
3210
|
+
this.markSubagentsStale();
|
|
1789
3211
|
}), ctx.on("approval/request", (request, next) => {
|
|
1790
3212
|
if (request.agent !== this.agent) return next();
|
|
1791
3213
|
return this.askApproval(request.toolName, request.reason, request.callId, request.signal);
|
|
@@ -1796,20 +3218,61 @@ var TuiApp = class {
|
|
|
1796
3218
|
const projections = ctx.get("sessionProjections");
|
|
1797
3219
|
if (projections !== void 0) this.disposers.push(projections.onChanged((session) => {
|
|
1798
3220
|
if (session === this.agent.session) this.refreshFooter();
|
|
3221
|
+
else this.markSubagentsStale();
|
|
1799
3222
|
}));
|
|
1800
3223
|
this.deps.terminal.setTitle(`dsh · ${this.deps.cwd}`);
|
|
1801
3224
|
this.tui.setFocus(this.editor);
|
|
1802
3225
|
this.tui.start();
|
|
3226
|
+
this.deps.terminal.write(SET_BLINKING_BAR_CURSOR);
|
|
3227
|
+
this.startFade();
|
|
1803
3228
|
this.bind(this.bound);
|
|
1804
3229
|
if (initialPrompt !== void 0) this.submit(initialPrompt);
|
|
1805
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
|
+
}
|
|
1806
3266
|
/** Release the terminal and tell the host to exit; later calls are no-ops. */
|
|
1807
3267
|
stop() {
|
|
1808
3268
|
if (this.stopped) return;
|
|
1809
3269
|
this.stopped = true;
|
|
3270
|
+
this.updateTicker();
|
|
3271
|
+
this.updateFadeTicker();
|
|
1810
3272
|
for (const dispose of this.disposers.splice(0)) dispose();
|
|
1811
3273
|
this.modals.withdrawActive();
|
|
1812
3274
|
this.loader.stop();
|
|
3275
|
+
this.deps.terminal.write(SET_TERMINAL_DEFAULT_CURSOR);
|
|
1813
3276
|
this.tui.stop();
|
|
1814
3277
|
this.deps.releaseInput();
|
|
1815
3278
|
this.deps.onQuit(this.bound);
|
|
@@ -1821,13 +3284,23 @@ var TuiApp = class {
|
|
|
1821
3284
|
this.toolBlocks.clear();
|
|
1822
3285
|
this.toolArguments.clear();
|
|
1823
3286
|
this.submittedIds.clear();
|
|
3287
|
+
this.todoTurns.clear();
|
|
3288
|
+
this.turn = 0;
|
|
3289
|
+
this.turnStartedAt = void 0;
|
|
1824
3290
|
this.streaming = void 0;
|
|
3291
|
+
this.endFade();
|
|
1825
3292
|
this.usage = EMPTY_USAGE;
|
|
1826
3293
|
this.pending = [];
|
|
3294
|
+
this.subagentEntries = [];
|
|
3295
|
+
this.panelSelection = void 0;
|
|
3296
|
+
this.listingFailure = void 0;
|
|
3297
|
+
this.subagentsStale = false;
|
|
1827
3298
|
this.setWorking(next.agent.status === "running");
|
|
1828
3299
|
for (const event of next.history) this.onSessionEvent(next.agent.session, event);
|
|
1829
3300
|
this.refreshHeader();
|
|
1830
3301
|
this.refreshFooter();
|
|
3302
|
+
this.refreshSubagentPanel();
|
|
3303
|
+
this.reconcileSubagents();
|
|
1831
3304
|
}
|
|
1832
3305
|
/**
|
|
1833
3306
|
* Move the terminal to the session `open` resolves, releasing the current one.
|
|
@@ -1931,20 +3404,185 @@ var TuiApp = class {
|
|
|
1931
3404
|
}
|
|
1932
3405
|
refreshFooter() {
|
|
1933
3406
|
const palette = this.deps.palette;
|
|
1934
|
-
const selection = this.currentSelection();
|
|
1935
|
-
const parts = [`${selection.provider}/${selection.model}`];
|
|
1936
|
-
if (selection.reasoningEffort !== void 0) parts.push(`effort ${selection.reasoningEffort}`);
|
|
1937
3407
|
const permission = this.deps.ctx.get("permissionPresets")?.current(this.agent.session);
|
|
1938
|
-
|
|
1939
|
-
const
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
+
});
|
|
1944
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";
|
|
1945
|
-
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();
|
|
1946
3466
|
this.tui.requestRender();
|
|
1947
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
|
+
}
|
|
1948
3586
|
statusFacts() {
|
|
1949
3587
|
const projections = this.deps.ctx.get("sessionProjections");
|
|
1950
3588
|
return projections === void 0 ? {} : readStatusFacts(projections, this.agent.session);
|
|
@@ -1955,9 +3593,129 @@ var TuiApp = class {
|
|
|
1955
3593
|
this.notice(empty);
|
|
1956
3594
|
return;
|
|
1957
3595
|
}
|
|
3596
|
+
this.showBlock(rows);
|
|
3597
|
+
}
|
|
3598
|
+
/** Print `rows` into the transcript as one block. */
|
|
3599
|
+
showBlock(rows) {
|
|
1958
3600
|
this.chat.addChild(new Text(rows.join("\n"), 0, 1));
|
|
1959
3601
|
this.tui.requestRender();
|
|
1960
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
|
+
}
|
|
1961
3719
|
async settings(argument) {
|
|
1962
3720
|
const [first, second, ...rest] = argument === "" ? [] : argument.split(/\s+/u);
|
|
1963
3721
|
if (first === void 0) {
|
|
@@ -1984,8 +3742,7 @@ var TuiApp = class {
|
|
|
1984
3742
|
this.notice(await setSetting(this.deps.ctx, first, second, value), "success");
|
|
1985
3743
|
}
|
|
1986
3744
|
showStatus() {
|
|
1987
|
-
this.
|
|
1988
|
-
this.tui.requestRender();
|
|
3745
|
+
this.showBlock(statusReport(this.statusFacts()));
|
|
1989
3746
|
}
|
|
1990
3747
|
currentSelection() {
|
|
1991
3748
|
const { selection, agent } = this.bound;
|
|
@@ -2003,6 +3760,7 @@ var TuiApp = class {
|
|
|
2003
3760
|
return { consume: true };
|
|
2004
3761
|
}
|
|
2005
3762
|
if (matchesKey(data, "ctrl+c")) {
|
|
3763
|
+
this.focusEditor();
|
|
2006
3764
|
const now = Date.now();
|
|
2007
3765
|
if (now - this.lastCtrlC < QUIT_DOUBLE_PRESS_MS) {
|
|
2008
3766
|
this.stop();
|
|
@@ -2014,9 +3772,20 @@ var TuiApp = class {
|
|
|
2014
3772
|
return { consume: true };
|
|
2015
3773
|
}
|
|
2016
3774
|
if (matchesKey(data, "ctrl+d")) {
|
|
3775
|
+
this.focusEditor();
|
|
2017
3776
|
if (this.editor.getText() === "") this.stop();
|
|
2018
3777
|
return { consume: true };
|
|
2019
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
|
+
}
|
|
2020
3789
|
if (matchesKey(data, "escape") && !this.editor.isShowingAutocomplete()) {
|
|
2021
3790
|
if (this.agent.status === "running") {
|
|
2022
3791
|
this.agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
@@ -2042,6 +3811,140 @@ var TuiApp = class {
|
|
|
2042
3811
|
return { consume: true };
|
|
2043
3812
|
}
|
|
2044
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
|
+
}
|
|
2045
3948
|
toggleTools() {
|
|
2046
3949
|
this.toolsExpanded = !this.toolsExpanded;
|
|
2047
3950
|
for (const block of this.toolBlocks.values()) block.setExpanded(this.toolsExpanded);
|
|
@@ -2154,15 +4057,24 @@ var TuiApp = class {
|
|
|
2154
4057
|
case "status":
|
|
2155
4058
|
this.showStatus();
|
|
2156
4059
|
return;
|
|
4060
|
+
case "todos":
|
|
4061
|
+
await this.browseTodos();
|
|
4062
|
+
return;
|
|
2157
4063
|
case "outline":
|
|
2158
4064
|
this.showRows(sessionOutline(this.deps.ctx, this.agent.session), "no completed turn yet");
|
|
2159
4065
|
return;
|
|
2160
4066
|
case "deliverables":
|
|
2161
4067
|
this.showRows(await listDeliverables(this.deps.ctx, this.agent.session.id, new AbortController().signal), "nothing presented yet");
|
|
2162
4068
|
return;
|
|
2163
|
-
case "subagents":
|
|
2164
|
-
|
|
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)));
|
|
2165
4076
|
return;
|
|
4077
|
+
}
|
|
2166
4078
|
case "settings":
|
|
2167
4079
|
await this.settings(argument);
|
|
2168
4080
|
return;
|
|
@@ -2181,6 +4093,8 @@ var TuiApp = class {
|
|
|
2181
4093
|
"@ completes paths and sessions (workspace, ../, ~/, absolute) · / completes commands",
|
|
2182
4094
|
"Esc stops the running turn · Ctrl+O expands or collapses tool output",
|
|
2183
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",
|
|
2184
4098
|
"Ctrl+C clears the input (twice quits) · Ctrl+D on an empty input quits"
|
|
2185
4099
|
];
|
|
2186
4100
|
this.chat.addChild(new Text([
|
|
@@ -2242,7 +4156,7 @@ var TuiApp = class {
|
|
|
2242
4156
|
return;
|
|
2243
4157
|
}
|
|
2244
4158
|
const current = this.currentSelection();
|
|
2245
|
-
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}` }));
|
|
2246
4160
|
if (picked === void 0) return;
|
|
2247
4161
|
const slash = picked.value.indexOf("/");
|
|
2248
4162
|
next = {
|
|
@@ -2275,7 +4189,7 @@ var TuiApp = class {
|
|
|
2275
4189
|
if (lookup.kind !== "ready") return void 0;
|
|
2276
4190
|
const current = this.currentSelection();
|
|
2277
4191
|
const sameModel = current.provider === model.provider && current.model === model.model;
|
|
2278
|
-
const picked = await this.
|
|
4192
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, `Reasoning effort · ${model.provider}/${model.model}`, effortItems(lookup.reasoning), {
|
|
2279
4193
|
body: ["Esc cancels the model change"],
|
|
2280
4194
|
current: (sameModel ? current.reasoningEffort : void 0) ?? ""
|
|
2281
4195
|
}));
|
|
@@ -2307,7 +4221,7 @@ var TuiApp = class {
|
|
|
2307
4221
|
this.applyEffort(current, matched);
|
|
2308
4222
|
return;
|
|
2309
4223
|
}
|
|
2310
|
-
const picked = await this.
|
|
4224
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, `Reasoning effort · ${current.provider}/${current.model}`, effortItems(reasoning), {
|
|
2311
4225
|
body: [effortHint(reasoning, current.reasoningEffort)],
|
|
2312
4226
|
current: current.reasoningEffort ?? ""
|
|
2313
4227
|
}));
|
|
@@ -2422,7 +4336,7 @@ var TuiApp = class {
|
|
|
2422
4336
|
value: choice.id,
|
|
2423
4337
|
...describeSession(choice)
|
|
2424
4338
|
}));
|
|
2425
|
-
const picked = await this.
|
|
4339
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, "Switch to a session", items));
|
|
2426
4340
|
const target = choices.find((choice) => choice.id === picked?.value);
|
|
2427
4341
|
if (target === void 0 || target.current) return;
|
|
2428
4342
|
await this.switchSession(() => this.deps.host.resume(target.id), "resumed");
|
|
@@ -2444,7 +4358,21 @@ var TuiApp = class {
|
|
|
2444
4358
|
this.notice(`rename failed: ${describeFailure(error)}`, "error");
|
|
2445
4359
|
}
|
|
2446
4360
|
}
|
|
2447
|
-
|
|
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) {
|
|
2448
4376
|
if (argument === "") {
|
|
2449
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(", ")}`);
|
|
2450
4378
|
return;
|
|
@@ -2517,7 +4445,7 @@ var TuiApp = class {
|
|
|
2517
4445
|
}
|
|
2518
4446
|
let key = argument;
|
|
2519
4447
|
if (key === "") {
|
|
2520
|
-
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) => ({
|
|
2521
4449
|
value: entry.key,
|
|
2522
4450
|
label: entry.label,
|
|
2523
4451
|
description: entry.methods.map((method) => method.label).join(", ")
|
|
@@ -2532,7 +4460,7 @@ var TuiApp = class {
|
|
|
2532
4460
|
}
|
|
2533
4461
|
let method = entry.methods[0]?.id;
|
|
2534
4462
|
if (entry.methods.length > 1) {
|
|
2535
|
-
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) => ({
|
|
2536
4464
|
value: candidate.id,
|
|
2537
4465
|
label: candidate.label
|
|
2538
4466
|
}))));
|
|
@@ -2549,6 +4477,9 @@ var TuiApp = class {
|
|
|
2549
4477
|
if (notice.url !== void 0) parts.push(notice.url);
|
|
2550
4478
|
if (notice.code !== void 0) parts.push(`code: ${notice.code}`);
|
|
2551
4479
|
this.notice(parts.join(" "));
|
|
4480
|
+
if (notice.url !== void 0 && notice.openInBrowser === true && this.deps.openUrl !== void 0) this.deps.openUrl(notice.url).catch((error) => {
|
|
4481
|
+
this.notice(`could not open sign-in page: ${describeFailure(error)}`, "error");
|
|
4482
|
+
});
|
|
2552
4483
|
},
|
|
2553
4484
|
prompt: (prompt) => this.answerAuthorizationPrompt(prompt)
|
|
2554
4485
|
}
|
|
@@ -2560,20 +4491,20 @@ var TuiApp = class {
|
|
|
2560
4491
|
}
|
|
2561
4492
|
async answerAuthorizationPrompt(prompt) {
|
|
2562
4493
|
if (prompt.kind === "select") {
|
|
2563
|
-
const picked = await this.
|
|
4494
|
+
const picked = await this.showModal(new PickPrompt(this.deps.palette, prompt.message, prompt.options.map((option) => ({
|
|
2564
4495
|
value: option.id,
|
|
2565
4496
|
label: option.label,
|
|
2566
4497
|
...option.description === void 0 ? {} : { description: option.description }
|
|
2567
4498
|
}))), prompt.signal);
|
|
2568
|
-
if (picked === void 0) throw new
|
|
4499
|
+
if (picked === void 0) throw new AuthorizationDeclinedError("the sign-in prompt was dismissed");
|
|
2569
4500
|
return picked.value;
|
|
2570
4501
|
}
|
|
2571
|
-
const answer = await this.
|
|
4502
|
+
const answer = await this.showModal(new QuestionPrompt(this.deps.palette, {
|
|
2572
4503
|
id: "authorization",
|
|
2573
4504
|
question: prompt.message,
|
|
2574
4505
|
...prompt.placeholder === void 0 ? {} : { detail: prompt.placeholder }
|
|
2575
4506
|
}), prompt.signal);
|
|
2576
|
-
if (answer?.custom === void 0) throw new
|
|
4507
|
+
if (answer?.custom === void 0) throw new AuthorizationDeclinedError("the sign-in prompt was dismissed");
|
|
2577
4508
|
return answer.custom;
|
|
2578
4509
|
}
|
|
2579
4510
|
async exportSession(argument) {
|
|
@@ -2588,7 +4519,7 @@ var TuiApp = class {
|
|
|
2588
4519
|
async askApproval(toolName, reason, callId, signal) {
|
|
2589
4520
|
const args = callId === void 0 ? void 0 : this.toolArguments.get(callId);
|
|
2590
4521
|
const detail = args === void 0 ? [] : toolCallText(JSON.stringify(args), this.presentCall(toolName, args)).lines;
|
|
2591
|
-
const outcome = await this.
|
|
4522
|
+
const outcome = await this.showModal(new ApprovalPrompt(this.deps.palette, toolName, reason, detail), signal);
|
|
2592
4523
|
const tone = outcome === "allowed-once" ? "success" : "dim";
|
|
2593
4524
|
this.notice(`${toolName}: ${outcome === "allowed-once" ? "allowed once" : outcome}`, tone);
|
|
2594
4525
|
return outcome;
|
|
@@ -2596,7 +4527,7 @@ var TuiApp = class {
|
|
|
2596
4527
|
async askQuestions(questions, signal) {
|
|
2597
4528
|
const answers = [];
|
|
2598
4529
|
for (const question of questions) {
|
|
2599
|
-
const answer = await this.
|
|
4530
|
+
const answer = await this.showModal(new QuestionPrompt(this.deps.palette, question), signal);
|
|
2600
4531
|
if (answer === null) throw new Error("the question was dismissed");
|
|
2601
4532
|
answers.push(answer);
|
|
2602
4533
|
}
|
|
@@ -2609,16 +4540,89 @@ var TuiApp = class {
|
|
|
2609
4540
|
}
|
|
2610
4541
|
return this.streaming;
|
|
2611
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
|
+
}
|
|
2612
4615
|
onStreamFrame(frame) {
|
|
2613
4616
|
switch (frame.type) {
|
|
2614
4617
|
case "start":
|
|
2615
4618
|
this.streaming = void 0;
|
|
4619
|
+
this.endFade();
|
|
2616
4620
|
return;
|
|
2617
4621
|
case "chunk": {
|
|
2618
4622
|
const chunk = frame.chunk;
|
|
2619
4623
|
switch (chunk.type) {
|
|
2620
4624
|
case "text-delta":
|
|
2621
|
-
if (chunk.text !== "") this.
|
|
4625
|
+
if (chunk.text !== "") this.appendStreamedText(chunk.text);
|
|
2622
4626
|
break;
|
|
2623
4627
|
case "reasoning-delta":
|
|
2624
4628
|
if (chunk.text !== "") this.streamingBlock().appendReasoning(chunk.text);
|
|
@@ -2638,6 +4642,7 @@ var TuiApp = class {
|
|
|
2638
4642
|
}
|
|
2639
4643
|
case "end":
|
|
2640
4644
|
this.streaming = void 0;
|
|
4645
|
+
this.endFade();
|
|
2641
4646
|
this.tui.requestRender();
|
|
2642
4647
|
return;
|
|
2643
4648
|
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
@@ -2645,8 +4650,20 @@ var TuiApp = class {
|
|
|
2645
4650
|
}
|
|
2646
4651
|
}
|
|
2647
4652
|
onSessionEvent(session, event) {
|
|
2648
|
-
if (session !== this.agent.session)
|
|
4653
|
+
if (session !== this.agent.session) {
|
|
4654
|
+
this.markSubagentsStale();
|
|
4655
|
+
return;
|
|
4656
|
+
}
|
|
2649
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;
|
|
2650
4667
|
case "user/message":
|
|
2651
4668
|
this.onUserMessage(event.data);
|
|
2652
4669
|
break;
|
|
@@ -2656,6 +4673,7 @@ var TuiApp = class {
|
|
|
2656
4673
|
const reasoning = message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
|
|
2657
4674
|
this.streamingBlock().commit(text, reasoning, interrupted === true);
|
|
2658
4675
|
this.streaming = void 0;
|
|
4676
|
+
this.endFade();
|
|
2659
4677
|
if (usage !== void 0) {
|
|
2660
4678
|
this.usage = addUsage(this.usage, usage);
|
|
2661
4679
|
this.refreshFooter();
|
|
@@ -2683,6 +4701,10 @@ var TuiApp = class {
|
|
|
2683
4701
|
break;
|
|
2684
4702
|
}
|
|
2685
4703
|
case "turn/end": {
|
|
4704
|
+
this.turnStartedAt = void 0;
|
|
4705
|
+
this.updateTicker();
|
|
4706
|
+
this.endFade();
|
|
4707
|
+
this.refreshFooter();
|
|
2686
4708
|
const notice = turnEndNotice(event.data.reason);
|
|
2687
4709
|
if (notice !== void 0) this.notice(notice, event.data.reason.kind === "error" ? "error" : "dim");
|
|
2688
4710
|
break;
|
|
@@ -2703,6 +4725,31 @@ var TuiApp = class {
|
|
|
2703
4725
|
}
|
|
2704
4726
|
this.tui.requestRender();
|
|
2705
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
|
+
}
|
|
2706
4753
|
onUserMessage(message) {
|
|
2707
4754
|
const source = message.source;
|
|
2708
4755
|
if (source.kind === "user") {
|
|
@@ -2816,7 +4863,12 @@ const inject = [
|
|
|
2816
4863
|
const Config = z.object({
|
|
2817
4864
|
prompt: z.string(),
|
|
2818
4865
|
resume: z.string(),
|
|
2819
|
-
toolPreviewLines: z.natural().min(1).default(8)
|
|
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),
|
|
4871
|
+
openBrowser: z.boolean().default(true)
|
|
2820
4872
|
});
|
|
2821
4873
|
/** The process-bound pieces of the host; tests substitute a fake terminal and captured streams. */
|
|
2822
4874
|
const internals = {
|
|
@@ -2827,7 +4879,9 @@ const internals = {
|
|
|
2827
4879
|
process.stdin.unref();
|
|
2828
4880
|
},
|
|
2829
4881
|
stderr: process.stderr,
|
|
2830
|
-
color: colorEnabled(process.env, process.stdout.isTTY)
|
|
4882
|
+
color: colorEnabled(process.env, process.stdout.isTTY),
|
|
4883
|
+
canOpenUrl: () => canOpenNativePath(),
|
|
4884
|
+
openUrl: openNativeUrl
|
|
2831
4885
|
};
|
|
2832
4886
|
/** Events read per page when a persisted session is resumed, through the storage handle before the Agent takes the log over. */
|
|
2833
4887
|
const HISTORY_PAGE = 256;
|
|
@@ -2976,14 +5030,33 @@ async function run(ctx, config, host) {
|
|
|
2976
5030
|
if (core === void 0) return;
|
|
2977
5031
|
const cwd = process.cwd();
|
|
2978
5032
|
const sessions = sessionHost(ctx, core, cwd);
|
|
5033
|
+
const initial = config.resume === void 0 ? await sessions.create() : await sessions.resume(brandString(config.resume));
|
|
5034
|
+
const openUrl = config.openBrowser && host.canOpenUrl() && !launchedThroughSsh(launchEnvironmentOf(ctx)) ? (url) => host.openUrl(url) : void 0;
|
|
2979
5035
|
new TuiApp({
|
|
2980
5036
|
ctx,
|
|
2981
5037
|
host: sessions,
|
|
2982
|
-
initial
|
|
5038
|
+
initial,
|
|
2983
5039
|
terminal: host.createTerminal(),
|
|
2984
5040
|
palette: createPalette(host.color),
|
|
2985
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
|
+
},
|
|
2986
5058
|
cwd,
|
|
5059
|
+
...openUrl === void 0 ? {} : { openUrl },
|
|
2987
5060
|
releaseInput: () => {
|
|
2988
5061
|
host.releaseInput();
|
|
2989
5062
|
},
|
|
@@ -3015,6 +5088,8 @@ function apply(ctx, config) {
|
|
|
3015
5088
|
releaseInput: internals.releaseInput,
|
|
3016
5089
|
stderr: internals.stderr,
|
|
3017
5090
|
color: internals.color,
|
|
5091
|
+
canOpenUrl: internals.canOpenUrl,
|
|
5092
|
+
openUrl: internals.openUrl,
|
|
3018
5093
|
exit
|
|
3019
5094
|
};
|
|
3020
5095
|
run(ctx, config, host).catch((error) => {
|