@linxiraos/pi-tui 1.3.2 → 1.4.1

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 (116) hide show
  1. package/CHANGELOG.md +181 -181
  2. package/dist/types/apps/session-picker.d.ts +13 -1
  3. package/dist/types/autocomplete.d.ts +6 -0
  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/overlay.d.ts +2 -0
  8. package/dist/types/native/tone.d.ts +2 -0
  9. package/dist/types/overlays/agent-hub.d.ts +3 -3
  10. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  11. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  12. package/dist/types/overlays/model-browser.d.ts +1 -1
  13. package/dist/types/overlays/resume-picker.d.ts +293 -0
  14. package/dist/types/overlays/resume-tree.d.ts +353 -0
  15. package/dist/types/overlays/session-selector.d.ts +22 -4
  16. package/dist/types/render/output-block.d.ts +2 -0
  17. package/dist/types/render/tool-card.d.ts +2 -0
  18. package/dist/types/sixel.d.ts +28 -0
  19. package/dist/types/status-line/host.d.ts +3 -0
  20. package/dist/types/terminal-capabilities.d.ts +6 -1
  21. package/dist/types/tools/bash.d.ts +2 -0
  22. package/dist/types/tools/default-renderer.d.ts +3 -0
  23. package/dist/types/tools/eval.d.ts +9 -8
  24. package/dist/types/tools/hub.d.ts +3 -1
  25. package/dist/types/tools/irc.d.ts +11 -1
  26. package/dist/types/tools/job-widget.d.ts +226 -0
  27. package/dist/types/tools/json-tree.d.ts +2 -0
  28. package/dist/types/tools/line-ranges.d.ts +8 -1
  29. package/dist/types/tools/mcp.d.ts +23 -0
  30. package/dist/types/tools/output-meta.d.ts +1 -1
  31. package/dist/types/tools/read.d.ts +8 -2
  32. package/dist/types/tools/renderer.d.ts +8 -2
  33. package/dist/types/tools/result-card.d.ts +1 -1
  34. package/dist/types/tools/streaming-output.d.ts +1 -0
  35. package/dist/types/tools/task.d.ts +12 -0
  36. package/dist/types/tools/wait.d.ts +11 -0
  37. package/package.json +9 -9
  38. package/src/apps/session-picker.ts +20 -2
  39. package/src/autocomplete.d.ts +6 -0
  40. package/src/autocomplete.ts +21 -6
  41. package/src/chat/skill-message.ts +16 -17
  42. package/src/chat/tool-execution.ts +35 -0
  43. package/src/chrome/transcript-container.ts +85 -13
  44. package/src/components/editor.ts +17 -7
  45. package/src/components/image.ts +5 -4
  46. package/src/components/markdown.ts +11 -3
  47. package/src/components/sparkline.d.ts +34 -0
  48. package/src/components/sparkline.ts +89 -0
  49. package/src/index.d.ts +1 -0
  50. package/src/index.ts +1 -0
  51. package/src/native/backend.ts +32 -6
  52. package/src/native/describe.d.ts +7 -1
  53. package/src/native/describe.ts +18 -1
  54. package/src/native/overlay.d.ts +2 -0
  55. package/src/native/overlay.ts +3 -0
  56. package/src/native/tone.d.ts +2 -0
  57. package/src/native/tone.ts +22 -0
  58. package/src/overlays/agent-hub.ts +14 -10
  59. package/src/overlays/agent-transcript-viewer.ts +2 -2
  60. package/src/overlays/agents-hub.ts +2 -1
  61. package/src/overlays/approval-dialog.ts +59 -13
  62. package/src/overlays/hub-frame.ts +2 -1
  63. package/src/overlays/jobs-panel.ts +24 -2
  64. package/src/overlays/model-browser.ts +3 -0
  65. package/src/overlays/model-hub.ts +2 -1
  66. package/src/overlays/resume-picker.ts +706 -0
  67. package/src/overlays/resume-tree.ts +1901 -0
  68. package/src/overlays/session-selector.ts +579 -39
  69. package/src/prompt/attachment-chips.ts +1 -1
  70. package/src/render/output-block.d.ts +2 -0
  71. package/src/render/output-block.ts +25 -12
  72. package/src/render/tool-card.d.ts +2 -0
  73. package/src/render/tool-card.ts +6 -0
  74. package/src/sixel.d.ts +28 -0
  75. package/src/sixel.ts +185 -0
  76. package/src/status-line/component.ts +19 -2
  77. package/src/status-line/host.ts +3 -0
  78. package/src/terminal-capabilities.d.ts +6 -1
  79. package/src/terminal-capabilities.ts +54 -3
  80. package/src/theme/symbols.ts +5 -3
  81. package/src/tools/bash.d.ts +2 -0
  82. package/src/tools/bash.ts +25 -9
  83. package/src/tools/default-renderer.d.ts +3 -0
  84. package/src/tools/default-renderer.ts +54 -17
  85. package/src/tools/eval.d.ts +9 -8
  86. package/src/tools/eval.ts +32 -14
  87. package/src/tools/find.ts +4 -5
  88. package/src/tools/glob.ts +4 -6
  89. package/src/tools/grep.ts +2 -4
  90. package/src/tools/hub.ts +4 -1
  91. package/src/tools/irc.d.ts +11 -1
  92. package/src/tools/irc.ts +11 -1
  93. package/src/tools/job-widget.d.ts +226 -0
  94. package/src/tools/job-widget.ts +1036 -0
  95. package/src/tools/json-tree.d.ts +2 -0
  96. package/src/tools/json-tree.ts +8 -0
  97. package/src/tools/line-ranges.d.ts +8 -1
  98. package/src/tools/line-ranges.ts +23 -13
  99. package/src/tools/mcp.d.ts +23 -0
  100. package/src/tools/mcp.ts +44 -1
  101. package/src/tools/output-meta.d.ts +1 -1
  102. package/src/tools/output-meta.ts +1 -1
  103. package/src/tools/proc-render.ts +1 -0
  104. package/src/tools/read.d.ts +8 -2
  105. package/src/tools/read.ts +64 -21
  106. package/src/tools/renderer.d.ts +8 -2
  107. package/src/tools/renderer.ts +8 -2
  108. package/src/tools/result-card.d.ts +1 -1
  109. package/src/tools/result-card.ts +1 -2
  110. package/src/tools/streaming-output.d.ts +1 -0
  111. package/src/tools/streaming-output.ts +11 -9
  112. package/src/tools/task.d.ts +12 -0
  113. package/src/tools/task.ts +25 -1
  114. package/src/tools/wait.d.ts +11 -0
  115. package/src/tools/wait.ts +296 -147
  116. package/src/tui.ts +45 -5
@@ -70,6 +70,8 @@ export interface JsonTreeDescribeOptions {
70
70
  openDepth?: number;
71
71
  /** Root object keys omitted. Defaults to internal tool argument metadata. */
72
72
  hiddenRootKeys?: readonly string[];
73
+ /** Called after the tree is built if a depth, node or scalar cap left part of the value out. */
74
+ onTruncated?: () => void;
73
75
  }
74
76
  /**
75
77
  * TSP `tree` of a JSON value: containers become expandable nodes labelled with
@@ -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
  }
@@ -10,12 +10,19 @@ export interface LineRange {
10
10
  export declare const LINE_RANGE_CHUNK_SOURCE: string;
11
11
  /** Parse a single `N`, `N-M`, `N-`, `N+K`, or `..`-aliased (`N..M`, `N..`) chunk. Throws via {@link ToolError} on invalid bounds. */
12
12
  export declare function parseLineRangeChunk(sel: string): LineRange | null;
13
+ /** What a bare `N` selects in a one-chunk list: lines `N` onward (`:50`), or the single line `N`. */
14
+ export type BareLineMode = "onward" | "single";
13
15
  /**
14
16
  * Parse a comma-separated list of line ranges (e.g. `5-16,960-973` or `19,59`). Returns
15
17
  * the ranges in ascending order with overlapping/adjacent ranges merged so
16
18
  * downstream consumers can stream the file in a single forward pass per range.
17
19
  */
18
- export declare function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null;
20
+ export declare function parseLineRanges(sel: string, bare?: BareLineMode): [LineRange, ...LineRange[]] | null;
21
+ /**
22
+ * Sort non-empty `ranges` by start line and merge the ones that overlap or touch;
23
+ * an open-ended range (`endLine` undefined) reaches EOF and absorbs every later one.
24
+ */
25
+ export declare function mergeLineRanges(ranges: readonly LineRange[]): [LineRange, ...LineRange[]];
19
26
  /**
20
27
  * Extract the line-range component from a read-tool selector that may also
21
28
  * carry a verbatim/index display mode (`raw`, `conflicts`) — alone or compounded
@@ -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;
@@ -63,6 +63,27 @@ export interface MCPResourceContent {
63
63
  }
64
64
  /** Supported MCP result content blocks retained in display metadata. */
65
65
  export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent;
66
+ /** Server-reported progress of an in-flight MCP request. */
67
+ export interface MCPProgress {
68
+ progress: number;
69
+ total?: number;
70
+ message?: string;
71
+ }
72
+ /**
73
+ * Read MCP progress fields from an untrusted record: `progress` must be a
74
+ * finite number; `total` is kept only when finite and `message` only when a
75
+ * string. Shared by the transports, the Eval bridge, and the status renderer.
76
+ */
77
+ export declare function parseMCPProgress(value: unknown): MCPProgress | undefined;
78
+ /**
79
+ * One-line display text for MCP progress, at most `maxWidth` cells: the
80
+ * message, with `progress/total` when the server reports a total. The bare
81
+ * progress value appears only when there is no message: without a total it is
82
+ * an opaque counter, not a percentage. Home paths in the message show as `~`,
83
+ * and a long message is cut before the count, so the count stays visible.
84
+ * Every progress display (tool card and both Eval status renderers) uses it.
85
+ */
86
+ export declare function formatMCPProgress(progress: MCPProgress, maxWidth?: number): string;
66
87
  /** MCP result details shared by renderers and programmatic tool consumers. */
67
88
  export interface MCPToolDetails {
68
89
  /** Server name */
@@ -81,6 +102,8 @@ export interface MCPToolDetails {
81
102
  provider?: string;
82
103
  /** Provider display name (e.g., "Claude Code", "MCP Config") */
83
104
  providerName?: string;
105
+ /** Latest progress of a running call; present only on partial updates. */
106
+ progress?: MCPProgress;
84
107
  /** Structured output metadata (set by the spill wrapper when output is truncated to an artifact). */
85
108
  meta?: OutputMeta;
86
109
  }
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 "@linxiraos/pi-utils";
10
11
  import { INTENT_FIELD } from "@linxiraos/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
  }
@@ -110,7 +110,7 @@ export interface OutputMeta {
110
110
  /** Capture failure of this output itself; aggregate reports keep source failures on their entries. */
111
111
  artifactError?: OutputArtifactError;
112
112
  source?: SourceMeta;
113
- /** The output is a bounded page of session artifact storage its `source` re-reads with line selectors; spill-to-artifact skips it. */
113
+ /** The output is a bounded page of a file or session artifact its `source` re-reads with line selectors; spill-to-artifact skips it. */
114
114
  pagedSource?: true;
115
115
  diagnostics?: DiagnosticMeta;
116
116
  limits?: LimitsMeta;
@@ -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;
@@ -321,6 +321,7 @@ const RECEIPT_TONE: Record<IrcDeliveryReceipt["outcome"], TspTone> = {
321
321
  injected: "success",
322
322
  woken: "success",
323
323
  revived: "warning",
324
+ queued: "warning",
324
325
  failed: "error",
325
326
  };
326
327
 
@@ -71,8 +71,14 @@ export interface ReadToolDetails {
71
71
  */
72
72
  displayReadTargetLinks?: Array<string | null>;
73
73
  }
74
- /** Split a filesystem path from its trailing read selector. */
75
- export declare function splitPathAndSel(rawPath: string): {
74
+ /**
75
+ * Split a filesystem path from its trailing read selector. `foldListColons: false`
76
+ * keeps a colon repeated inside a list as part of the path (`a:1-30,:-30` peels
77
+ * only `:-30`), for callers checking whether that shorter split names a real file.
78
+ */
79
+ export declare function splitPathAndSel(rawPath: string, options?: {
80
+ foldListColons?: boolean;
81
+ }): {
76
82
  path: string;
77
83
  sel?: string;
78
84
  };
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(":") };
@@ -27,8 +27,9 @@ export interface RenderResultOptions {
27
27
  executionStarted?: boolean;
28
28
  /**
29
29
  * Milliseconds since this call began executing (`tool_execution_start`),
30
- * or its total once settled; undefined before it starts. Native describe
31
- * hooks only.
30
+ * or its total once settled; undefined before it starts. The terminal
31
+ * transcript's options object computes it on read, so a render-time read
32
+ * is live while the block's spinner ticks.
32
33
  */
33
34
  elapsedMs?: number;
34
35
  /** The call was abandoned without a final result (turn aborted, sealed). Native describe hooks only. */
@@ -177,6 +178,11 @@ export interface ToolRenderer<TArgs = unknown, TDetails = unknown> {
177
178
  * `options.spinnerFrame`.
178
179
  */
179
180
  animatedPartialResult?: boolean | ((args: TArgs) => boolean);
181
+ /**
182
+ * Repaint the block once a second while its call executes, for renderers
183
+ * that show live `options.elapsedMs` without consuming the spinner frame.
184
+ */
185
+ tickElapsed?: boolean;
180
186
  /**
181
187
  * Whether replacing a pending call render with the first result requires a
182
188
  * full viewport repaint. Use for merged renderers whose pending rows can be
@@ -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
@@ -16,5 +16,5 @@ export interface CardToolResult {
16
16
  export declare function firstText(result: CardToolResult): string;
17
17
  /** Sanitize one display line: tabs become spaces, carriage returns are dropped. */
18
18
  export declare function safe(value: string): string;
19
- /** Padded card whose lines are sanitized and truncated, re-rendered when the expanded state flips. */
19
+ /** Card whose lines are sanitized and truncated, re-rendered when the expanded state flips. */
20
20
  export declare function card(lines: (width: number, expanded: boolean) => string[], options: RenderResultOptions): Component;
@@ -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
  }
@@ -261,6 +261,7 @@ export declare class OutputSink {
261
261
  * Replace the in-memory buffer with the given text. Used when an upstream
262
262
  * minimizer rewrites the captured output after the raw bytes have already
263
263
  * been streamed.
264
+ * The replacement is held to the per-line column cap like streamed output.
264
265
  *
265
266
  * After this call the buffer is authoritative: streaming counters realign
266
267
  * to the replacement, the retained head window is cleared, and head
@@ -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
  }
@@ -264,6 +264,18 @@ export interface AgentProgress {
264
264
  advisor?: boolean;
265
265
  /** The agent's latest self-estimate of task completion (0–100), from the periodic `task.completionProbe` side request. */
266
266
  completionPercent?: number;
267
+ /**
268
+ * The agent's current step in its own words (one line, ≤ 80 chars), from the
269
+ * same probe reply as {@link completionPercent}. Running rows prefer it over
270
+ * `lastIntent`; the job widget uses it as its stage line.
271
+ */
272
+ stage?: string;
273
+ /**
274
+ * What the agent says shows its progress live (one line, ≤ 200 chars), e.g.
275
+ * "BENCH lines in bg_2 pipe:1, tok_s higher is better, target 70% of roofline";
276
+ * from the probe reply. Feeds the job widget author's brief; never rendered.
277
+ */
278
+ watch?: string;
267
279
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
268
280
  extractedToolData?: Record<string, unknown[]>;
269
281
  /**
package/src/tools/task.ts CHANGED
@@ -760,6 +760,15 @@ function renderAgentProgress(
760
760
 
761
761
  // Current tool (if running) or most recent completed tool
762
762
  if (progress.status === "running") {
763
+ if (progress.stage) {
764
+ lines.push(
765
+ truncateTaskRow(
766
+ `${continuePrefix}${theme.fg("muted", replaceTabs(sanitizeText(progress.stage)))}`,
767
+ maxWidth,
768
+ "",
769
+ ),
770
+ );
771
+ }
763
772
  if (progress.currentTool) {
764
773
  let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
765
774
  const toolDetail = toolCallDetail(
@@ -1869,6 +1878,9 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
1869
1878
  age: progress.currentToolStartMs ? Math.max(0, nowMs - progress.currentToolStartMs) : undefined,
1870
1879
  }
1871
1880
  : null;
1881
+ const summary = taskSummary(progress.description || progress.assignment || progress.task);
1882
+ // The agent's own stage line (from its completion probe) rides beside the task while it runs.
1883
+ const stage = running && progress.stage ? plainText(progress.stage) : "";
1872
1884
  const retry =
1873
1885
  running && progress.retryState
1874
1886
  ? {
@@ -1883,7 +1895,7 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
1883
1895
  {
1884
1896
  name: formatTaskId(progress.id),
1885
1897
  agent: progress.agent || undefined,
1886
- task: taskSummary(progress.description || progress.assignment || progress.task) || undefined,
1898
+ task: stage ? (summary ? [span(summary), span(` · ${stage}`, "muted")] : stage) : summary || undefined,
1887
1899
  status,
1888
1900
  model: progress.resolvedModelIdentity ?? progress.resolvedModel,
1889
1901
  thinking: thinkingToken(progress.resolvedThinkingLevel),
@@ -2379,6 +2391,18 @@ export interface AgentProgress {
2379
2391
  advisor?: boolean;
2380
2392
  /** The agent's latest self-estimate of task completion (0–100), from the periodic `task.completionProbe` side request. */
2381
2393
  completionPercent?: number;
2394
+ /**
2395
+ * The agent's current step in its own words (one line, ≤ 80 chars), from the
2396
+ * same probe reply as {@link completionPercent}. Running rows prefer it over
2397
+ * `lastIntent`; the job widget uses it as its stage line.
2398
+ */
2399
+ stage?: string;
2400
+ /**
2401
+ * What the agent says shows its progress live (one line, ≤ 200 chars), e.g.
2402
+ * "BENCH lines in bg_2 pipe:1, tok_s higher is better, target 70% of roofline";
2403
+ * from the probe reply. Feeds the job widget author's brief; never rendered.
2404
+ */
2405
+ watch?: string;
2382
2406
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
2383
2407
  extractedToolData?: Record<string, unknown[]>;
2384
2408
  /**