@songtonyli/dsh-cli 0.1.10 → 0.1.11

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