@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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/bin/dsh.mjs +1 -1
  3. package/node_modules/@deepseek-ai/dsh-api-authorization-controller/README.i18n.yaml +2 -2
  4. package/node_modules/@deepseek-ai/dsh-api-authorization-controller/README.md +1 -1
  5. package/node_modules/@deepseek-ai/dsh-api-authorization-controller/README.zh.md +1 -1
  6. package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/index.js +1 -0
  7. package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/typert.host.js +2 -1
  8. package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/typert.remote-client.js +1 -0
  9. package/node_modules/@deepseek-ai/dsh-api-authorization-controller/lib/types/index.js +1 -0
  10. package/node_modules/@deepseek-ai/dsh-api-remotes/lib/client.js +1 -0
  11. package/node_modules/@deepseek-ai/dsh-authorization/README.i18n.yaml +2 -2
  12. package/node_modules/@deepseek-ai/dsh-authorization/README.md +1 -1
  13. package/node_modules/@deepseek-ai/dsh-authorization/README.zh.md +1 -1
  14. package/node_modules/@deepseek-ai/dsh-client-ui-settings-signin/lib/client.js +1 -0
  15. package/node_modules/@deepseek-ai/dsh-client-ui-sidebar/lib/client.js +1 -1
  16. package/node_modules/@deepseek-ai/dsh-llm-pi-ai/README.i18n.yaml +2 -2
  17. package/node_modules/@deepseek-ai/dsh-llm-pi-ai/README.md +1 -1
  18. package/node_modules/@deepseek-ai/dsh-llm-pi-ai/README.zh.md +1 -1
  19. package/node_modules/@deepseek-ai/dsh-llm-pi-ai/lib/index.js +3 -1
  20. package/node_modules/@deepseek-ai/dsh-native-command/README.i18n.yaml +2 -2
  21. package/node_modules/@deepseek-ai/dsh-native-command/README.md +7 -2
  22. package/node_modules/@deepseek-ai/dsh-native-command/README.zh.md +7 -2
  23. package/node_modules/@deepseek-ai/dsh-native-command/lib/index.js +93 -2
  24. package/node_modules/@deepseek-ai/dsh-native-command/package.json +4 -0
  25. package/node_modules/@deepseek-ai/dsh-tool-cordis/lib/index.js +2 -2
  26. package/node_modules/@deepseek-ai/dsh-tui-app/README.i18n.yaml +2 -2
  27. package/node_modules/@deepseek-ai/dsh-tui-app/README.md +47 -12
  28. package/node_modules/@deepseek-ai/dsh-tui-app/README.zh.md +47 -12
  29. package/node_modules/@deepseek-ai/dsh-tui-app/cordis.patch.yml +3 -2
  30. package/node_modules/@deepseek-ai/dsh-tui-app/lib/index.js +3087 -1012
  31. package/node_modules/@deepseek-ai/dsh-tui-app/lib/startup.js +3 -2
  32. package/node_modules/@deepseek-ai/dsh-tui-app/package.json +2 -0
  33. package/node_modules/@deepseek-ai/dsh-web-app/lib/index.js +2 -77
  34. package/node_modules/@deepseek-ai/dsh-web-app/package.json +1 -2
  35. package/node_modules/@types/node/README.md +1 -1
  36. package/node_modules/@types/node/package.json +2 -2
  37. 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/catalog.js
104
+ //#region lib/types/diff.js
76
105
  /**
77
- * Terminal rows for the browser's settings, plugins, subagents, deliverables,
78
- * and turn-outline pages. Each reader resolves its service through `ctx.get`
79
- * and throws an `Error` naming the absent service; the app prints the message.
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
- var __addDisposableResource$1 = function(env, value, async) {
83
- if (value !== null && value !== void 0) {
84
- if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
85
- var dispose, inner;
86
- if (async) {
87
- if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
88
- dispose = value[Symbol.asyncDispose];
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
- * One row per registered settings namespace: the namespace, its revision,
158
- * whether the stored user section overrides anything, and when its owner
159
- * applies changes.
160
- * @param ctx - plugin context carrying the optional settings service.
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 listSettings(ctx) {
165
- return requireSettings(ctx).describe({ redactSecrets: true }).map((descriptor) => {
166
- const overridden = Object.keys(descriptor.user ?? {}).length > 0;
167
- return `${descriptor.ns} rev ${String(descriptor.revision)} ${overridden ? "user-overridden" : "inherited"} applies ${descriptor.applies}`;
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
- * Pretty-printed JSON of one namespace's resolved value with every secret
172
- * field removed, followed by one row per secret slot stating whether it holds
173
- * a value.
174
- * @param ctx - plugin context carrying the optional settings service.
175
- * @param ns - the registered namespace to show.
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 showSetting(ctx, ns) {
180
- const descriptor = requireDescriptor(requireSettings(ctx), ns);
181
- const rows = JSON.stringify(descriptor.value, null, 2).split("\n");
182
- for (const secret of descriptor.secrets ?? []) rows.push(`secret ${secret.path.join(".")}: ${secret.set ? "set" : "unset"}`);
183
- return rows;
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
- * Write one field of a namespace's user section through a path-addressed
187
- * `set` against the revision read at call time. `rawValue` is parsed as JSON
188
- * when it is valid JSON and stored as a string otherwise; an empty `path`
189
- * addresses the section root, which then requires a JSON object.
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
- async function setSetting(ctx, ns, path, rawValue) {
198
- const settings = requireSettings(ctx);
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
- * Empty one namespace's user section so every field re-inherits its
219
- * composition base and schema default.
220
- * @param ctx - plugin context carrying the optional settings service.
221
- * @param ns - the registered namespace to reset.
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
- async function resetSetting(ctx, ns) {
226
- const settings = requireSettings(ctx);
227
- const { revision } = requireDescriptor(settings, ns);
228
- await settings.replace(ns, {}, revision);
229
- return `settings ${ns}: reset to defaults`;
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
- * One row per non-group Loader entry: entry id, module name, effective
233
- * enablement, and the root Fiber phase (`none` without a live root Fiber).
234
- * @param ctx - plugin context carrying the optional Cordis Loader.
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
- async function listSubagents(ctx, sessionId, signal) {
261
- const subagents = ctx.get("subagents");
262
- if (subagents === void 0) throw new Error("subagents are not mounted in this profile");
263
- return (await subagents.listDescendants(sessionId, signal)).map((entry) => {
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
- * Files the model declared through the present tool, folded from the
272
- * session's `deliverables/presented` events and grouped under one `turn N`
273
- * heading per turn in log order.
274
- * @param ctx - plugin context carrying the optional session query engine.
275
- * @param sessionId - the session whose log is read.
276
- * @param signal - cancels a cold log read.
277
- * @returns the heading and file rows, empty when nothing was presented.
278
- * @throws {Error} when no session query engine is mounted.
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
- async function listDeliverables(ctx, sessionId, signal) {
281
- const env_1 = {
282
- stack: [],
283
- error: void 0,
284
- hasError: false
285
- };
286
- try {
287
- const query = ctx.get("sessionQuery");
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
- * The session's turn outline: one numbered row per started turn with its
315
- * prompt preview, followed by an indented response preview once the turn
316
- * ended with assistant text.
317
- * @param ctx - plugin context carrying the optional projection registry.
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 sessionOutline(ctx, session) {
323
- const projections = ctx.get("sessionProjections");
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
- //#endregion
335
- //#region lib/types/style.js
336
- /**
337
- * The terminal palette: one table of SGR open/close pairs behind named roles,
338
- * so components never emit raw escape codes. A disabled palette returns text
339
- * unchanged for dumb terminals and captured output.
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
- * Build the palette.
357
- * @param enabled - whether escape sequences are emitted; false makes every role the identity.
358
- * @returns the palette.
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 createPalette(enabled) {
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
- dim: role("dim"),
367
- bold: role("bold"),
368
- italic: role("italic"),
369
- underline: role("underline"),
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
- * Decide whether the terminal gets color: `NO_COLOR` (any non-empty value)
380
- * wins, then a non-empty non-zero `FORCE_COLOR`, then whether stdout is a TTY.
381
- * @param env - the process environment.
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 colorEnabled(env, isTty) {
386
- if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
387
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0") return true;
388
- return isTty;
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 Markdown theme derived from the palette.
392
- * @param palette - the active palette.
393
- * @returns a complete pi-tui Markdown theme.
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 markdownTheme(palette) {
396
- return {
397
- heading: (text) => palette.bold(palette.accent(text)),
398
- link: palette.accent,
399
- linkUrl: palette.dim,
400
- code: palette.warning,
401
- codeBlock: (text) => text,
402
- codeBlockBorder: palette.dim,
403
- quote: palette.italic,
404
- quoteBorder: palette.dim,
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
- * The select-list theme derived from the palette.
415
- * @param palette - the active palette.
416
- * @returns a complete pi-tui select-list theme.
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 selectListTheme(palette) {
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
- * The editor theme derived from the palette.
429
- * @param palette - the active palette.
430
- * @returns a complete pi-tui editor theme.
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 editorTheme(palette) {
433
- return {
434
- borderColor: palette.dim,
435
- selectList: selectListTheme(palette)
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
- * Line diff for tool cards: a longest-common-subsequence alignment of old and
442
- * new text, bounded so a huge file falls back to a plain replacement view.
443
- * @module @deepseek-ai/dsh-tui-app/diff
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
- /** Above this many old×new line pairs the quadratic alignment is skipped. */
446
- const MAX_ALIGNMENT_CELLS = 4e6;
447
- function splitLines(text) {
448
- if (text === "") return [];
449
- const lines = text.split("\n");
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
- * Align `oldText` against `newText` line by line.
455
- * @param oldText - the previous file content, or null when the file is new.
456
- * @param newText - the new file content.
457
- * @returns the rows in display order; a new file yields only added rows.
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/blocks.js
431
+ //#region lib/types/catalog.js
737
432
  /**
738
- * Transcript components: one pi-tui component per rendered fact (a user
739
- * prompt, an assistant reply, a tool card, a notice). Each owns its display
740
- * state and re-renders from it at any width.
741
- * @module @deepseek-ai/dsh-tui-app/blocks
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
- /** A prompt the user submitted, drawn with a leading `›`. */
744
- var UserBlock = class {
745
- theme;
746
- text;
747
- constructor(theme, text) {
748
- this.theme = theme;
749
- this.text = text;
750
- }
751
- invalidate() {}
752
- render(width) {
753
- const palette = this.theme.palette;
754
- return ["", ...wrapTextWithAnsi(this.text, Math.max(1, width - 2)).map((line, index) => `${palette.accent(index === 0 ? "›" : " ")} ${palette.bold(line)}`)];
755
- }
756
- };
757
- /** A dim one-line notice about the session (a stopped turn, a command result, a model switch). */
758
- var NoticeBlock = class {
759
- text;
760
- constructor(theme, text, tone = "dim") {
761
- const palette = theme.palette;
762
- this.text = new Text(palette[tone](`· ${text}`), 0, 0);
763
- }
764
- invalidate() {
765
- this.text.invalidate();
766
- }
767
- render(width) {
768
- return this.text.render(width);
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
- * An assistant reply: streamed reasoning above streamed Markdown text. The
773
- * durable `assistant/message` replaces both with the committed content.
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
- var AssistantBlock = class {
776
- theme;
777
- reasoning = "";
778
- text = "";
779
- interrupted = false;
780
- markdown;
781
- reasoningText;
782
- constructor(theme) {
783
- this.theme = theme;
784
- const palette = theme.palette;
785
- this.markdown = new Markdown("", 0, 0, markdownTheme(palette));
786
- this.reasoningText = new Text("", 0, 0);
787
- }
788
- /**
789
- * Append streamed visible text.
790
- * @param delta - the text delta.
791
- */
792
- appendText(delta) {
793
- this.text += delta;
794
- this.markdown.setText(this.text);
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
- * Append streamed reasoning text.
798
- * @param delta - the reasoning delta.
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
- appendReasoning(delta) {
801
- this.reasoning += delta;
802
- this.reasoningText.setText(this.theme.palette.dim(this.theme.palette.italic(this.reasoning.trimEnd())));
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
- * Replace the streamed content with the committed message.
806
- * @param text - the committed visible text.
807
- * @param reasoning - the committed reasoning text.
808
- * @param interrupted - whether the message was cut short.
809
- */
810
- commit(text, reasoning, interrupted) {
811
- this.text = text;
812
- this.reasoning = reasoning;
813
- this.interrupted = interrupted;
814
- this.markdown.setText(text);
815
- this.reasoningText.setText(this.theme.palette.dim(this.theme.palette.italic(reasoning.trimEnd())));
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
- invalidate() {
818
- this.markdown.invalidate();
819
- this.reasoningText.invalidate();
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
- render(width) {
822
- const lines = [""];
823
- if (this.reasoning.trim() !== "") lines.push(...this.reasoningText.render(width), "");
824
- if (this.text !== "") lines.push(...this.markdown.render(width));
825
- if (this.interrupted) lines.push(this.theme.palette.dim("[interrupted]"));
826
- return lines;
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
- /** A tool call card: status glyph, tool name, headline, then a foldable body. */
830
- var ToolBlock = class {
831
- theme;
832
- name;
833
- call;
834
- status = "running";
835
- resultLines = [];
836
- expanded = false;
837
- constructor(theme, name, call) {
838
- this.theme = theme;
839
- this.name = name;
840
- this.call = call;
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
- * Attach the result rows and settle the status.
844
- * @param lines - the result rows before preview truncation.
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
- * Fold or unfold the body.
853
- * @param expanded - whether the full body is shown.
854
- */
855
- setExpanded(expanded) {
856
- this.expanded = expanded;
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
- invalidate() {}
859
- render(width) {
860
- const palette = this.theme.palette;
861
- 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)}`}`;
862
- const shown = previewLines([...this.call.lines, ...this.resultLines], this.theme.toolPreviewLines, this.expanded);
863
- const inner = Math.max(1, width - 4);
864
- const rows = shown.flatMap((line) => wrapTextWithAnsi(line, inner).map((part) => ` ${palette.dim("│")} ${part}`));
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
- //#endregion
873
- //#region lib/types/completion.js
1956
+ return lines;
1957
+ }
874
1958
  /**
875
- * Editor completion: `/` at the start of the message opens the command list,
876
- * and `@` anywhere offers workspace paths and other sessions as references,
877
- * inserted in the same mention grammar the browser composer uses.
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
- * Build the provider over live sources, so commands and references registered
882
- * after startup appear without re-creating the editor.
883
- * @param sources - the command and reference sources.
884
- * @returns the editor's autocomplete provider.
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 editorCompletion(sources) {
887
- return {
888
- triggerCharacters: ["/", "@"],
889
- async getSuggestions(lines, cursorLine, cursorCol, options) {
890
- const line = lines[cursorLine] ?? "";
891
- const before = line.slice(0, cursorCol);
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 display name of one effort.
936
- * @param reasoning - what the model declares.
937
- * @param effort - the selected effort, or undefined for the provider default.
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 effortName(reasoning, effort) {
941
- if (effort === void 0) return PROVIDER_DEFAULT_LABEL;
942
- return reasoning.efforts.find((candidate) => candidate.id === effort)?.name ?? effort;
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
- * Picker rows for one model: the provider default above the adapter's own order.
946
- * @param reasoning - what the model declares.
947
- * @returns the rows, provider default first.
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 effortItems(reasoning) {
950
- const resolved = reasoning.defaultEffort === void 0 ? void 0 : `resolves to ${effortName(reasoning, reasoning.defaultEffort)}`;
951
- return [{
952
- value: "",
953
- label: PROVIDER_DEFAULT_LABEL,
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
- * The dim row under the picker heading.
963
- * @param reasoning - what the model declares.
964
- * @param effort - the effort in force, or undefined for the provider default.
965
- * @returns one row naming the effort in force and the keys that change it.
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 effortHint(reasoning, effort) {
968
- return `current: ${effortName(reasoning, effort)} · Esc keeps it · Shift+Tab cycles`;
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
- * The effort a typed `/effort` argument names. A declared id wins over the
972
- * `default` keyword, so an adapter may own that id.
973
- * @param reasoning - what the model declares.
974
- * @param typed - the argument, matched against declared ids without case.
975
- * @returns the declared effort, undefined for the provider default, or null when the model declares no such effort.
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 matchEffort(reasoning, typed) {
978
- const wanted = typed.toLowerCase();
979
- const declared = reasoning.efforts.find((effort) => effort.id.toLowerCase() === wanted);
980
- if (declared !== void 0) return declared.id;
981
- return wanted === DEFAULT_ARGUMENT ? void 0 : null;
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
- * `/export`: the same session-log ZIP the browser downloads, written to a
987
- * local directory through the export package's archive helpers.
988
- * @module @deepseek-ai/dsh-tui-app/export
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
- * Write the session tree (the session, its sub-sessions, and attachments) as
992
- * a ZIP archive into `directory`.
993
- * @param ctx - plugin context carrying the query, persistence, and attachment services.
994
- * @param sessionId - the session to export.
995
- * @param directory - the directory the archive is written into.
996
- * @param signal - cancels the export.
997
- * @returns the archive's absolute path.
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
- async function exportSessionZip(ctx, sessionId, directory, signal) {
1001
- const deps = sessionLogExportDeps(ctx);
1002
- 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");
1003
- const ready = {
1004
- ...deps,
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 agent raises through the approval and user-questions
1021
- * seams, and the queue that shows them one at a time above the editor.
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 = new SelectList(items, SELECT_MAX_VISIBLE, selectListTheme(palette));
1076
- this.list.onSelect = (item) => {
1077
- this.settlement.settle(choose(item));
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
- this.list.onCancel = () => {
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.list.render(width)
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
- /** A generic list picker (models, sessions); Escape settles undefined. */
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
- super(palette, `${palette.accent("?")} ${palette.bold(title)}`, options.body ?? [], items.map((item, index) => index === inForce ? {
1144
- ...item,
1145
- label: `${item.label}${CURRENT_MARK}`
1146
- } : { ...item }), (row) => items.find((item) => item.value === row.value), () => void 0);
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 = [new Date(choice.createdAt).toISOString().slice(0, 16).replace("T", " ")];
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 share of prompt-side input served from cache over the complete log.
1554
- * @param usage - cumulative provider-reported usage.
1555
- * @returns the percentage text; a partial hit never rounds up to `100`, and
1556
- * no billed input returns null.
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 cacheHitPercent(usage) {
1559
- const promptTokens = usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
1560
- if (promptTokens === 0) return null;
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
- * Compact duration: `45.2s` under a minute, `2m42s` from there on.
1568
- * @param ms - duration in milliseconds.
1569
- * @returns the formatted duration.
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 formatDuration(ms) {
1572
- const seconds = ms / 1e3;
1573
- if (seconds < 60) return `${String(Math.round(seconds * 10) / 10)}s`;
1574
- const whole = Math.round(seconds);
1575
- return `${String(Math.floor(whole / 60))}m${String(whole % 60)}s`;
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
- * The notice a completed compaction earns.
1579
- * @param data - the `compaction/summary` event data.
1580
- * @returns e.g. `compacted 12 items (~3.4k tokens)`.
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 compactionNotice(data) {
1583
- const count = data.shadowedSeqs.length;
1584
- return `compacted ${String(count)} ${plural(count, "item")} (~${formatTokens(data.shadowedTokenCount)} tokens)`;
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
- * The notice a scheduled model-request retry earns.
1588
- * @param data - the `llm/retry` event data.
1589
- * @returns e.g. `retrying (2/5) in 4s · RATE_LIMIT: provider busy`; an
1590
- * `always` policy has no cap, so its attempt reads `(2)`.
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 retryMessage(data) {
1593
- return `retrying (${data.mode === "normal" ? `${String(data.retry)}/${String(data.maxRetries)}` : String(data.retry)}) in ${formatDuration(data.delayMs)} · ${data.failure.code}: ${data.failure.message}`;
1594
- }
1595
- function plural(count, noun) {
1596
- return count === 1 ? noun : `${noun}s`;
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: "List the subagent sessions under this session"
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 Editor(this.tui, editorTheme(palette), { paddingX: 1 });
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) return;
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
- if (permission !== void 0) parts.push(`permission ${permission}`);
1939
- const usage = formatUsage(this.usage);
1940
- if (usage !== "") parts.push(usage);
1941
- parts.push(...footerStatus(this.statusFacts()));
1942
- parts.push(this.deps.cwd);
1943
- if (this.pending.length > 0) parts.push(`${String(this.pending.length)} attached`);
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(`${palette.dim(parts.join(" · "))}\n${palette.dim(hints)}`);
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.chat.addChild(new Text(statusReport(this.statusFacts()).join("\n"), 0, 1));
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
- this.showRows(await listSubagents(this.deps.ctx, this.agent.session.id, new AbortController().signal), "no subagent sessions");
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.modals.run(new PickPrompt(this.deps.palette, "Model for the next request", items, { current: `${current.provider}/${current.model}` }));
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.modals.run(new PickPrompt(this.deps.palette, `Reasoning effort · ${model.provider}/${model.model}`, effortItems(lookup.reasoning), {
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.modals.run(new PickPrompt(this.deps.palette, `Reasoning effort · ${current.provider}/${current.model}`, effortItems(reasoning), {
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.modals.run(new PickPrompt(this.deps.palette, "Switch to a session", items));
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
- async attach(argument) {
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.modals.run(new PickPrompt(this.deps.palette, subscriptionOnly ? "Log in with" : "Sign in to", entries.map((entry) => ({
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.modals.run(new PickPrompt(this.deps.palette, `Sign-in method for ${entry.label}`, entry.methods.map((candidate) => ({
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.modals.run(new PickPrompt(this.deps.palette, prompt.message, prompt.options.map((option) => ({
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 Error("the sign-in prompt was dismissed");
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.modals.run(new QuestionPrompt(this.deps.palette, {
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 Error("the sign-in prompt was dismissed");
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.modals.run(new ApprovalPrompt(this.deps.palette, toolName, reason, detail), signal);
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.modals.run(new QuestionPrompt(this.deps.palette, question), signal);
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.streamingBlock().appendText(chunk.text);
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) return;
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: config.resume === void 0 ? await sessions.create() : await sessions.resume(brandString(config.resume)),
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) => {