@oh-my-pi/pi-tui 18.12.2 → 18.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +44 -48
  2. package/dist/types/autocomplete.d.ts +6 -0
  3. package/dist/types/chat/skill-message.d.ts +3 -4
  4. package/dist/types/components/sparkline.d.ts +34 -0
  5. package/dist/types/index.d.ts +1 -0
  6. package/dist/types/native/describe.d.ts +7 -1
  7. package/dist/types/native/tone.d.ts +2 -0
  8. package/dist/types/overlays/agent-hub.d.ts +3 -3
  9. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  10. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  11. package/dist/types/overlays/model-browser.d.ts +1 -1
  12. package/dist/types/render/output-block.d.ts +2 -0
  13. package/dist/types/render/tool-card.d.ts +2 -0
  14. package/dist/types/sixel.d.ts +28 -0
  15. package/dist/types/status-line/host.d.ts +3 -0
  16. package/dist/types/terminal-capabilities.d.ts +5 -0
  17. package/dist/types/tools/bash.d.ts +2 -0
  18. package/dist/types/tools/default-renderer.d.ts +3 -0
  19. package/dist/types/tools/eval.d.ts +9 -8
  20. package/dist/types/tools/irc.d.ts +6 -0
  21. package/dist/types/tools/job-widget.d.ts +226 -0
  22. package/dist/types/tools/json-tree.d.ts +2 -0
  23. package/dist/types/tools/line-ranges.d.ts +8 -1
  24. package/dist/types/tools/mcp.d.ts +23 -0
  25. package/dist/types/tools/output-meta.d.ts +1 -1
  26. package/dist/types/tools/read.d.ts +8 -2
  27. package/dist/types/tools/renderer.d.ts +8 -2
  28. package/dist/types/tools/result-card.d.ts +1 -1
  29. package/dist/types/tools/streaming-output.d.ts +1 -0
  30. package/dist/types/tools/task.d.ts +12 -0
  31. package/dist/types/tools/wait.d.ts +5 -0
  32. package/package.json +9 -9
  33. package/src/autocomplete.ts +12 -1
  34. package/src/chat/skill-message.ts +19 -21
  35. package/src/chat/tool-execution.ts +35 -0
  36. package/src/chrome/transcript-container.ts +85 -12
  37. package/src/components/editor.ts +17 -7
  38. package/src/components/image.ts +5 -4
  39. package/src/components/markdown.ts +11 -3
  40. package/src/components/sparkline.ts +89 -0
  41. package/src/index.ts +1 -0
  42. package/src/native/backend.ts +25 -5
  43. package/src/native/describe.ts +18 -1
  44. package/src/native/tone.ts +22 -0
  45. package/src/overlays/agent-hub.ts +14 -10
  46. package/src/overlays/agent-transcript-viewer.ts +2 -2
  47. package/src/overlays/approval-dialog.ts +59 -13
  48. package/src/overlays/jobs-panel.ts +24 -2
  49. package/src/overlays/model-browser.ts +3 -0
  50. package/src/prompt/attachment-chips.ts +1 -1
  51. package/src/render/output-block.ts +25 -12
  52. package/src/render/tool-card.ts +6 -0
  53. package/src/sixel.ts +185 -0
  54. package/src/status-line/component.ts +19 -2
  55. package/src/status-line/host.ts +3 -0
  56. package/src/terminal-capabilities.ts +43 -3
  57. package/src/theme/symbols.ts +5 -3
  58. package/src/tools/bash.ts +25 -9
  59. package/src/tools/default-renderer.ts +54 -17
  60. package/src/tools/eval.ts +32 -14
  61. package/src/tools/find.ts +4 -5
  62. package/src/tools/glob.ts +4 -6
  63. package/src/tools/grep.ts +4 -6
  64. package/src/tools/irc.ts +6 -0
  65. package/src/tools/job-widget.ts +1036 -0
  66. package/src/tools/json-tree.ts +8 -0
  67. package/src/tools/line-ranges.ts +23 -13
  68. package/src/tools/mcp.ts +44 -1
  69. package/src/tools/output-meta.ts +1 -1
  70. package/src/tools/read.ts +64 -21
  71. package/src/tools/renderer.ts +8 -2
  72. package/src/tools/result-card.ts +1 -2
  73. package/src/tools/streaming-output.ts +11 -9
  74. package/src/tools/task.ts +25 -1
  75. package/src/tools/wait.ts +288 -147
  76. package/src/tui.ts +45 -5
@@ -402,6 +402,8 @@ export interface JsonTreeDescribeOptions {
402
402
  openDepth?: number;
403
403
  /** Root object keys omitted. Defaults to internal tool argument metadata. */
404
404
  hiddenRootKeys?: readonly string[];
405
+ /** Called after the tree is built if a depth, node or scalar cap left part of the value out. */
406
+ onTruncated?: () => void;
405
407
  }
406
408
 
407
409
  type JsonEntry = readonly [key: string, value: unknown];
@@ -438,6 +440,7 @@ export function describeJsonTree(value: unknown, options: JsonTreeDescribeOption
438
440
  const maxScalarLen = Math.max(1, options.maxScalarLen ?? JSON_TREE_SCALAR_LEN_EXPANDED);
439
441
  const openDepth = options.openDepth ?? JSON_TREE_MAX_DEPTH_COLLAPSED;
440
442
  let budget = Math.max(1, options.maxNodes ?? JSON_TREE_MAX_LINES_EXPANDED);
443
+ let truncated = false;
441
444
  let nextId = 0;
442
445
  const leaf = (label: readonly TspSpan[]): TspTreeNode => ({ id: `${nextId++}`, label });
443
446
 
@@ -445,6 +448,7 @@ export function describeJsonTree(value: unknown, options: JsonTreeDescribeOption
445
448
  const out: TspTreeNode[] = [];
446
449
  for (let index = 0; index < entries.length; index++) {
447
450
  if (budget <= 0) {
451
+ truncated = true;
448
452
  out.push(leaf([span(`… ${entries.length - index} more`, "dim")]));
449
453
  break;
450
454
  }
@@ -463,17 +467,20 @@ export function describeJsonTree(value: unknown, options: JsonTreeDescribeOption
463
467
  const size = Array.isArray(entry) ? `[${entry.length}]` : `{${entries.length}}`;
464
468
  const label = keySpans.length > 0 ? [...keySpans, span(` ${size}`, "dim")] : [span(size, "dim")];
465
469
  if (entries.length === 0) return { id, label };
470
+ if (depth >= maxDepth) truncated = true;
466
471
  const children = depth >= maxDepth ? [leaf([span("…", "dim")])] : describeEntries(entries, depth + 1);
467
472
  return { id, label, open: depth < openDepth, children };
468
473
  }
469
474
  const prefix = keySpans.length > 0 ? [...keySpans, span(": ", "muted")] : [];
470
475
  if (typeof entry === "string") {
471
476
  const lines = plainText(entry).split("\n");
477
+ if (lines.some(line => line.length > maxScalarLen)) truncated = true;
472
478
  const first = clipScalar(lines[0], maxScalarLen);
473
479
  if (lines.length === 1) return { id, label: [...prefix, span(`"${first}"`, "syntaxString")] };
474
480
  const children: TspTreeNode[] = [];
475
481
  for (let index = 0; index < lines.length; index++) {
476
482
  if (budget <= 0) {
483
+ truncated = true;
477
484
  children.push(leaf([span(`… ${lines.length - index} more lines`, "dim")]));
478
485
  break;
479
486
  }
@@ -499,5 +506,6 @@ export function describeJsonTree(value: unknown, options: JsonTreeDescribeOption
499
506
  } else {
500
507
  nodes = [describeValue(undefined, value, 0)];
501
508
  }
509
+ if (truncated) options.onTruncated?.();
502
510
  return node("tree", { nodes });
503
511
  }
@@ -49,16 +49,19 @@ function parseChunk(sel: string, pinBare: boolean): LineRange | null {
49
49
  return { startLine: rawStart, endLine: rawEnd };
50
50
  }
51
51
 
52
+ /** What a bare `N` selects in a one-chunk list: lines `N` onward (`:50`), or the single line `N`. */
53
+ export type BareLineMode = "onward" | "single";
54
+
52
55
  /**
53
56
  * Parse a comma-separated list of line ranges (e.g. `5-16,960-973` or `19,59`). Returns
54
57
  * the ranges in ascending order with overlapping/adjacent ranges merged so
55
58
  * downstream consumers can stream the file in a single forward pass per range.
56
59
  */
57
- export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null {
60
+ export function parseLineRanges(sel: string, bare: BareLineMode = "onward"): [LineRange, ...LineRange[]] | null {
58
61
  const chunks = sel.split(",");
59
- // A lone `:50` means "from line 50". Inside a comma list, a bare number is
60
- // that one line; otherwise `:19,59` collapses to "from 19 through EOF".
61
- const pinBare = chunks.length > 1;
62
+ // A lone `:50` means "from line 50" unless `bare` pins it. Inside a comma list,
63
+ // a bare number is that one line; otherwise `:19,59` collapses to "from 19 through EOF".
64
+ const pinBare = bare === "single" || chunks.length > 1;
62
65
  const parsed: LineRange[] = [];
63
66
  for (const chunk of chunks) {
64
67
  const range = parseChunk(chunk, pinBare);
@@ -66,14 +69,19 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
66
69
  parsed.push(range);
67
70
  }
68
71
  if (parsed.length === 0) return null;
69
- parsed.sort((a, b) => a.startLine - b.startLine);
72
+ return mergeLineRanges(parsed);
73
+ }
70
74
 
71
- const merged: LineRange[] = [parsed[0]];
72
- for (let i = 1; i < parsed.length; i++) {
73
- const current = parsed[i];
75
+ /**
76
+ * Sort non-empty `ranges` by start line and merge the ones that overlap or touch;
77
+ * an open-ended range (`endLine` undefined) reaches EOF and absorbs every later one.
78
+ */
79
+ export function mergeLineRanges(ranges: readonly LineRange[]): [LineRange, ...LineRange[]] {
80
+ const [first, ...rest] = [...ranges].sort((a, b) => a.startLine - b.startLine);
81
+ const merged: [LineRange, ...LineRange[]] = [first];
82
+ for (const current of rest) {
74
83
  const last = merged[merged.length - 1];
75
- // Open-ended (endLine undefined) means "to EOF" — any later range is absorbed.
76
- if (last.endLine === undefined) continue;
84
+ if (last.endLine === undefined) break;
77
85
  // Merge when current starts within (or immediately after) the last range.
78
86
  if (current.startLine <= last.endLine + 1) {
79
87
  if (current.endLine === undefined || current.endLine > last.endLine) {
@@ -83,7 +91,7 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
83
91
  }
84
92
  merged.push(current);
85
93
  }
86
- return merged as [LineRange, ...LineRange[]];
94
+ return merged;
87
95
  }
88
96
 
89
97
  /**
@@ -98,10 +106,12 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
98
106
  */
99
107
  export function selectorLineRanges(sel: string | undefined): [LineRange, ...LineRange[]] | undefined {
100
108
  if (!sel) return undefined;
101
- for (const chunk of sel.split(":")) {
109
+ const chunks = sel.split(":");
110
+ const bare = chunks.some(chunk => chunk.toLowerCase() === "raw") ? "single" : "onward";
111
+ for (const chunk of chunks) {
102
112
  const lower = chunk.toLowerCase();
103
113
  if (lower === "raw" || lower === "conflicts") continue;
104
- const ranges = parseLineRanges(chunk);
114
+ const ranges = parseLineRanges(chunk, bare);
105
115
  if (ranges) return ranges;
106
116
  }
107
117
  return undefined;
package/src/tools/mcp.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { type Component, Markdown } from "../index";
8
8
 
9
9
  import type { NativeToolHead, NativeToolView, RenderResultOptions } from "./renderer";
10
+ import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
10
11
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
11
12
  import { ansi, md } from "../native/describe";
12
13
  import type { NativeChild } from "../native/node";
@@ -26,7 +27,8 @@ import {
26
27
  renderJsonTreeLines,
27
28
  } from "./json-tree";
28
29
  import { formatStyledTruncationWarning, stripOutputNotice } from "./output-meta";
29
- import { clipCollapsed, formatExpandHint } from "../render/render-utils";
30
+ import { clipCollapsed, flattenLine, formatExpandHint, shortenEmbeddedPaths } from "../render/render-utils";
31
+ import { truncateToWidth, visibleWidth } from "../utils";
30
32
  import { formatOutputPaneLines, styleToolOutputLine } from "../render/output-pane";
31
33
  import type { StatusLineOptions } from "../render/status-line";
32
34
  import { plainToolCard, type ToolCardPhase } from "../render/tool-card";
@@ -388,6 +390,45 @@ export interface MCPResourceContent {
388
390
  /** Supported MCP result content blocks retained in display metadata. */
389
391
  export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent;
390
392
 
393
+ /** Server-reported progress of an in-flight MCP request. */
394
+ export interface MCPProgress {
395
+ progress: number;
396
+ total?: number;
397
+ message?: string;
398
+ }
399
+
400
+ /**
401
+ * Read MCP progress fields from an untrusted record: `progress` must be a
402
+ * finite number; `total` is kept only when finite and `message` only when a
403
+ * string. Shared by the transports, the Eval bridge, and the status renderer.
404
+ */
405
+ export function parseMCPProgress(value: unknown): MCPProgress | undefined {
406
+ if (!isRecord(value) || typeof value.progress !== "number" || !Number.isFinite(value.progress)) return undefined;
407
+ const progress: MCPProgress = { progress: value.progress };
408
+ if (typeof value.total === "number" && Number.isFinite(value.total)) progress.total = value.total;
409
+ if (typeof value.message === "string") progress.message = value.message;
410
+ return progress;
411
+ }
412
+
413
+ /**
414
+ * One-line display text for MCP progress, at most `maxWidth` cells: the
415
+ * message, with `progress/total` when the server reports a total. The bare
416
+ * progress value appears only when there is no message: without a total it is
417
+ * an opaque counter, not a percentage. Home paths in the message show as `~`,
418
+ * and a long message is cut before the count, so the count stays visible.
419
+ * Every progress display (tool card and both Eval status renderers) uses it.
420
+ */
421
+ export function formatMCPProgress(progress: MCPProgress, maxWidth = 200): string {
422
+ const message =
423
+ progress.message === undefined ? "" : shortenEmbeddedPaths(flattenLine(sanitizeText(progress.message)));
424
+ const amount = progress.total === undefined ? `${progress.progress}` : `${progress.progress}/${progress.total}`;
425
+ if (!message) return truncateToWidth(amount, maxWidth);
426
+ if (progress.total === undefined) return truncateToWidth(message, maxWidth);
427
+ const suffix = ` (${amount})`;
428
+ const messageWidth = maxWidth - visibleWidth(suffix);
429
+ return messageWidth > 0 ? `${truncateToWidth(message, messageWidth)}${suffix}` : truncateToWidth(amount, maxWidth);
430
+ }
431
+
391
432
  /** MCP result details shared by renderers and programmatic tool consumers. */
392
433
  export interface MCPToolDetails {
393
434
  /** Server name */
@@ -406,6 +447,8 @@ export interface MCPToolDetails {
406
447
  provider?: string;
407
448
  /** Provider display name (e.g., "Claude Code", "MCP Config") */
408
449
  providerName?: string;
450
+ /** Latest progress of a running call; present only on partial updates. */
451
+ progress?: MCPProgress;
409
452
  /** Structured output metadata (set by the spill wrapper when output is truncated to an artifact). */
410
453
  meta?: OutputMeta;
411
454
  }
@@ -87,7 +87,7 @@ export interface OutputMeta {
87
87
  /** Capture failure of this output itself; aggregate reports keep source failures on their entries. */
88
88
  artifactError?: OutputArtifactError;
89
89
  source?: SourceMeta;
90
- /** The output is a bounded page of session artifact storage its `source` re-reads with line selectors; spill-to-artifact skips it. */
90
+ /** The output is a bounded page of a file or session artifact its `source` re-reads with line selectors; spill-to-artifact skips it. */
91
91
  pagedSource?: true;
92
92
  diagnostics?: DiagnosticMeta;
93
93
  limits?: LimitsMeta;
package/src/tools/read.ts CHANGED
@@ -104,11 +104,13 @@ export interface ReadToolDetails {
104
104
  // Parsing also recognizes incomplete counts to explain their errors; path splitting
105
105
  // only peels complete selectors (not a trailing `+` or `L`).
106
106
  const RANGE_SELECTOR_CHUNK = `${LINE_RANGE_CHUNK_SOURCE}(?<=[\\d.-])`;
107
- const RANGE_LIST_SRC = `${RANGE_SELECTOR_CHUNK}(?:,${RANGE_SELECTOR_CHUNK})*`;
108
107
  // A tail selector: `-N` reads the last N lines. Keep in sync with TAIL_SELECTOR_RE.
109
108
  const TAIL_CHUNK_SRC = String.raw`-\d+`;
110
- const FILE_LINE_RANGE_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|${TAIL_CHUNK_SRC}|raw|conflicts|img)$`, "i");
111
- const FILE_LINE_RANGE_ONLY_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|${TAIL_CHUNK_SRC})$`, "i");
109
+ // A comma list may mix ranges and tails (`1-12,-15`); a lone tail is a one-chunk list.
110
+ const LIST_CHUNK_SRC = `(?:${RANGE_SELECTOR_CHUNK}|${TAIL_CHUNK_SRC})`;
111
+ const RANGE_LIST_SRC = `${LIST_CHUNK_SRC}(?:,${LIST_CHUNK_SRC})*`;
112
+ const FILE_LINE_RANGE_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts|img)$`, "i");
113
+ const FILE_LINE_RANGE_ONLY_RE = new RegExp(`^(?:${RANGE_LIST_SRC})$`, "i");
112
114
  const FILE_RAW_ONLY_RE = /^raw$/i;
113
115
  // Permissive selector chunk for internal URLs — accepts well-formed selectors
114
116
  // plus common malformed shapes (e.g. `:-N-M`) so the read tool peels the entire
@@ -117,8 +119,33 @@ const INTERNAL_URL_SELECTOR_PART_RE = new RegExp(
117
119
  String.raw`^(?:raw|conflicts|img|${RANGE_LIST_SRC}|-\d+(?:[-+]\d+)?)$`,
118
120
  "i",
119
121
  );
120
- /** Split a filesystem path from its trailing read selector. */
121
- export function splitPathAndSel(rawPath: string): { path: string; sel?: string } {
122
+
123
+ /**
124
+ * Fold the list chunks that repeat the selector colon after a comma (`a:1-30,:-30`)
125
+ * into the `list` that follows them, so the pair reads as `a:1-30,-30`. Only
126
+ * colons at or after `minColon` count. Each chunk is tested once.
127
+ */
128
+ function foldRepeatedListColons(basePath: string, list: string, minColon: number): { basePath: string; list: string } {
129
+ const chunks = [list];
130
+ while (basePath.endsWith(",")) {
131
+ const colon = basePath.lastIndexOf(":");
132
+ const chunk = basePath.slice(colon + 1, -1);
133
+ if (colon < minColon || !FILE_LINE_RANGE_ONLY_RE.test(chunk)) break;
134
+ chunks.push(chunk);
135
+ basePath = basePath.slice(0, colon);
136
+ }
137
+ return { basePath, list: chunks.reverse().join(",") };
138
+ }
139
+
140
+ /**
141
+ * Split a filesystem path from its trailing read selector. `foldListColons: false`
142
+ * keeps a colon repeated inside a list as part of the path (`a:1-30,:-30` peels
143
+ * only `:-30`), for callers checking whether that shorter split names a real file.
144
+ */
145
+ export function splitPathAndSel(
146
+ rawPath: string,
147
+ options?: { foldListColons?: boolean },
148
+ ): { path: string; sel?: string } {
122
149
  const colon = rawPath.lastIndexOf(":");
123
150
  if (colon <= 0) return { path: rawPath };
124
151
 
@@ -126,25 +153,33 @@ export function splitPathAndSel(rawPath: string): { path: string; sel?: string }
126
153
  if (!FILE_LINE_RANGE_RE.test(candidate)) return { path: rawPath };
127
154
 
128
155
  let basePath = rawPath.slice(0, colon);
129
- let sel = candidate;
130
-
131
156
  // Allow a compound trailing selector: `path:1-50:raw`, `path:raw:1-50`, or
132
- // `path:raw:-60`. The two chunks must be one line-range (or tail) plus one
133
- // `raw`, in either order.
134
- const innerColon = basePath.lastIndexOf(":");
135
- if (innerColon > 0) {
157
+ // `path:raw:-60`. The two chunks must be one line list (ranges and tails)
158
+ // plus one `raw`, in either order.
159
+ let list: string | undefined;
160
+ let rawSuffix = "";
161
+ if (FILE_LINE_RANGE_ONLY_RE.test(candidate)) {
162
+ list = candidate;
163
+ } else if (FILE_RAW_ONLY_RE.test(candidate)) {
164
+ const innerColon = basePath.lastIndexOf(":");
136
165
  const innerCandidate = basePath.slice(innerColon + 1);
137
- const innerIsRaw = FILE_RAW_ONLY_RE.test(innerCandidate);
138
- const outerIsRaw = FILE_RAW_ONLY_RE.test(candidate);
139
- const innerIsRange = FILE_LINE_RANGE_ONLY_RE.test(innerCandidate);
140
- const outerIsRange = FILE_LINE_RANGE_ONLY_RE.test(candidate);
141
- if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) {
142
- sel = `${innerCandidate}:${candidate}`;
166
+ if (innerColon > 0 && FILE_LINE_RANGE_ONLY_RE.test(innerCandidate)) {
167
+ list = innerCandidate;
168
+ rawSuffix = `:${candidate}`;
143
169
  basePath = basePath.slice(0, innerColon);
144
170
  }
145
171
  }
172
+ if (list === undefined) return { path: basePath, sel: candidate };
146
173
 
147
- return { path: basePath, sel };
174
+ if (options?.foldListColons !== false) ({ basePath, list } = foldRepeatedListColons(basePath, list, 1));
175
+ if (!rawSuffix) {
176
+ const innerColon = basePath.lastIndexOf(":");
177
+ const innerCandidate = basePath.slice(innerColon + 1);
178
+ if (innerColon > 0 && FILE_RAW_ONLY_RE.test(innerCandidate)) {
179
+ return { path: basePath.slice(0, innerColon), sel: `${innerCandidate}:${list}` };
180
+ }
181
+ }
182
+ return { path: basePath, sel: `${list}${rawSuffix}` };
148
183
  }
149
184
 
150
185
  /**
@@ -189,9 +224,17 @@ export function splitInternalUrlSel(
189
224
  // Stop before crossing into the scheme separator `://`.
190
225
  if (colon < schemeEnd) break;
191
226
  const tail = path.slice(colon + 1);
192
- if (!INTERNAL_URL_SELECTOR_PART_RE.test(tail)) break;
193
- chunks.unshift(tail);
194
- path = path.slice(0, colon);
227
+ if (INTERNAL_URL_SELECTOR_PART_RE.test(tail)) {
228
+ chunks.unshift(tail);
229
+ path = path.slice(0, colon);
230
+ continue;
231
+ }
232
+ // `artifact://7:1-30,:-30`: a list chunk ending in a comma joins the list peeled after it.
233
+ if (chunks.length === 0 || !FILE_LINE_RANGE_ONLY_RE.test(chunks[0])) break;
234
+ const folded = foldRepeatedListColons(path, chunks[0], schemeEnd);
235
+ if (folded.basePath === path) break;
236
+ chunks[0] = folded.list;
237
+ path = folded.basePath;
195
238
  }
196
239
  if (chunks.length === 0) return { path: rawPath };
197
240
  return { path, sel: chunks.join(":") };
@@ -28,8 +28,9 @@ export interface RenderResultOptions {
28
28
  executionStarted?: boolean;
29
29
  /**
30
30
  * Milliseconds since this call began executing (`tool_execution_start`),
31
- * or its total once settled; undefined before it starts. Native describe
32
- * hooks only.
31
+ * or its total once settled; undefined before it starts. The terminal
32
+ * transcript's options object computes it on read, so a render-time read
33
+ * is live while the block's spinner ticks.
33
34
  */
34
35
  elapsedMs?: number;
35
36
  /** The call was abandoned without a final result (turn aborted, sealed). Native describe hooks only. */
@@ -195,6 +196,11 @@ export interface ToolRenderer<TArgs = unknown, TDetails = unknown> {
195
196
  * `options.spinnerFrame`.
196
197
  */
197
198
  animatedPartialResult?: boolean | ((args: TArgs) => boolean);
199
+ /**
200
+ * Repaint the block once a second while its call executes, for renderers
201
+ * that show live `options.elapsedMs` without consuming the spinner frame.
202
+ */
203
+ tickElapsed?: boolean;
198
204
  /**
199
205
  * Whether replacing a pending call render with the first result requires a
200
206
  * full viewport repaint. Use for merged renderers whose pending rows can be
@@ -23,7 +23,7 @@ export function safe(value: string): string {
23
23
  return replaceTabs(value).replace(/\r/g, "");
24
24
  }
25
25
 
26
- /** Padded card whose lines are sanitized and truncated, re-rendered when the expanded state flips. */
26
+ /** Card whose lines are sanitized and truncated, re-rendered when the expanded state flips. */
27
27
  export function card(lines: (width: number, expanded: boolean) => string[], options: RenderResultOptions): Component {
28
28
  return createCachedComponent(
29
29
  () => Boolean(options.expanded),
@@ -31,6 +31,5 @@ export function card(lines: (width: number, expanded: boolean) => string[], opti
31
31
  lines(width, expanded)
32
32
  .flatMap(line => line.split("\n"))
33
33
  .map(line => truncateToWidth(safe(line), width, Ellipsis.Unicode)),
34
- { paddingX: 1 },
35
34
  );
36
35
  }
@@ -1432,6 +1432,7 @@ export class OutputSink {
1432
1432
  * Replace the in-memory buffer with the given text. Used when an upstream
1433
1433
  * minimizer rewrites the captured output after the raw bytes have already
1434
1434
  * been streamed.
1435
+ * The replacement is held to the per-line column cap like streamed output.
1435
1436
  *
1436
1437
  * After this call the buffer is authoritative: streaming counters realign
1437
1438
  * to the replacement, the retained head window is cleared, and head
@@ -1442,23 +1443,24 @@ export class OutputSink {
1442
1443
  */
1443
1444
  replace(text: string): void {
1444
1445
  this.#clearPendingChunkTimer();
1445
- this.#buffer = text;
1446
- this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1446
+ this.#currentLineBytes = 0;
1447
+ this.#columnEllipsisAdded = false;
1448
+ this.#columnDroppedBytes = 0;
1449
+ this.#columnTruncatedLines = 0;
1450
+ // The artifact keeps the replaced stream, so later line numbers no longer match it.
1451
+ this.#columnCutLinesUnmapped = true;
1452
+ const capped = this.#maxColumns > 0 ? this.#applyColumnCap(text, 0) : text;
1453
+ this.#buffer = capped;
1454
+ this.#bufferBytes = Buffer.byteLength(capped, "utf-8");
1447
1455
  this.#tailUntrimmed = false;
1448
1456
  this.#head = "";
1449
1457
  this.#headBytes = 0;
1450
1458
  this.#headLines = 0;
1451
1459
  this.#headRetentionDisabled = true;
1452
- this.#totalBytes = this.#bufferBytes;
1460
+ this.#totalBytes = Buffer.byteLength(text, "utf-8");
1453
1461
  this.#totalLines = countNewlines(text);
1454
1462
  this.#sawData = text.length > 0;
1455
1463
  this.#truncated = false;
1456
- this.#currentLineBytes = 0;
1457
- this.#columnEllipsisAdded = false;
1458
- this.#columnDroppedBytes = 0;
1459
- this.#columnTruncatedLines = 0;
1460
- // The artifact keeps the replaced stream, so later line numbers no longer match it.
1461
- this.#columnCutLinesUnmapped = true;
1462
1464
  this.#pendingChunk = "";
1463
1465
  this.#pendingCarriageReturn = false;
1464
1466
  }
package/src/tools/task.ts CHANGED
@@ -764,6 +764,15 @@ function renderAgentProgress(
764
764
 
765
765
  // Current tool (if running) or most recent completed tool
766
766
  if (progress.status === "running") {
767
+ if (progress.stage) {
768
+ lines.push(
769
+ truncateTaskRow(
770
+ `${continuePrefix}${theme.fg("muted", replaceTabs(sanitizeText(progress.stage)))}`,
771
+ maxWidth,
772
+ "",
773
+ ),
774
+ );
775
+ }
767
776
  if (progress.currentTool) {
768
777
  let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
769
778
  const toolDetail = toolCallDetail(
@@ -1873,6 +1882,9 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
1873
1882
  age: progress.currentToolStartMs ? Math.max(0, nowMs - progress.currentToolStartMs) : undefined,
1874
1883
  }
1875
1884
  : null;
1885
+ const summary = taskSummary(progress.description || progress.assignment || progress.task);
1886
+ // The agent's own stage line (from its completion probe) rides beside the task while it runs.
1887
+ const stage = running && progress.stage ? plainText(progress.stage) : "";
1876
1888
  const retry =
1877
1889
  running && progress.retryState
1878
1890
  ? {
@@ -1887,7 +1899,7 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
1887
1899
  {
1888
1900
  name: formatTaskId(progress.id),
1889
1901
  agent: progress.agent || undefined,
1890
- task: taskSummary(progress.description || progress.assignment || progress.task) || undefined,
1902
+ task: stage ? (summary ? [span(summary), span(` · ${stage}`, "muted")] : stage) : summary || undefined,
1891
1903
  status,
1892
1904
  model: progress.resolvedModelIdentity ?? progress.resolvedModel,
1893
1905
  thinking: thinkingToken(progress.resolvedThinkingLevel),
@@ -2383,6 +2395,18 @@ export interface AgentProgress {
2383
2395
  advisor?: boolean;
2384
2396
  /** The agent's latest self-estimate of task completion (0–100), from the periodic `task.completionProbe` side request. */
2385
2397
  completionPercent?: number;
2398
+ /**
2399
+ * The agent's current step in its own words (one line, ≤ 80 chars), from the
2400
+ * same probe reply as {@link completionPercent}. Running rows prefer it over
2401
+ * `lastIntent`; the job widget uses it as its stage line.
2402
+ */
2403
+ stage?: string;
2404
+ /**
2405
+ * What the agent says shows its progress live (one line, ≤ 200 chars), e.g.
2406
+ * "BENCH lines in bg_2 pipe:1, tok_s higher is better, target 70% of roofline";
2407
+ * from the probe reply. Feeds the job widget author's brief; never rendered.
2408
+ */
2409
+ watch?: string;
2386
2410
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
2387
2411
  extractedToolData?: Record<string, unknown[]>;
2388
2412
  /**