@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
@@ -10,6 +10,7 @@ import {
10
10
  renderKittyPlaceholderLines,
11
11
  setKittyGraphics,
12
12
  } from "./kitty-graphics";
13
+ import { ensureSixelRasterWidth } from "./sixel";
13
14
  import {
14
15
  isInsideTerminalMultiplexer,
15
16
  isTerminalBackgroundQueryTrusted,
@@ -403,7 +404,11 @@ export function detectStyledUnderlineSupport(terminalId: TerminalId, env: NodeJS
403
404
  // render. Enable only on a confirmed major.minor >= 3.5; an absent or
404
405
  // unparseable version keeps the flat fallback so only proven terminals
405
406
  // get the colon form.
406
- const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
407
+ //
408
+ // `LC_TERMINAL_VERSION` carries the same version over SSH, where
409
+ // `TERM_PROGRAM_VERSION` is stripped, so it is the fallback source.
410
+ const version =
411
+ parseMajorMinorVersion(env.TERM_PROGRAM_VERSION) ?? parseMajorMinorVersion(env.LC_TERMINAL_VERSION);
407
412
  return version !== null && (version.major > 3 || (version.major === 3 && version.minor >= 5));
408
413
  }
409
414
  default:
@@ -672,6 +677,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
672
677
  VSCODE_PID,
673
678
  ALACRITTY_WINDOW_ID,
674
679
  TERM_PROGRAM,
680
+ LC_TERMINAL,
675
681
  TERM,
676
682
  COLORTERM,
677
683
  } = env;
@@ -686,6 +692,22 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
686
692
  const programId = fromProgram(TERM_PROGRAM);
687
693
  if (programId) return programId;
688
694
 
695
+ // iTerm2 sets `LC_TERMINAL=iTerm2` (plus `LC_TERMINAL_VERSION`) in the local
696
+ // session precisely so it survives SSH: openssh/mosh forward `LC_*` to the
697
+ // remote host, while `ITERM_SESSION_ID` and `TERM_PROGRAM` are stripped. Its
698
+ // own shell integration says the same: "TERM_PROGRAM is set by the local
699
+ // terminal but not forwarded over SSH. LC_TERMINAL is set by iTerm2 and may
700
+ // be forwarded over SSH." Without this branch the remote session falls to
701
+ // the true-color profile and loses the iTerm2 inline-image protocol, OSC 8
702
+ // hyperlinks and OSC 9 notifications.
703
+ //
704
+ // Gated on the absence of a multiplexer: iTerm2's OSC 1337 encoder is the
705
+ // only image encoder that does not wrap its payload in a tmux passthrough
706
+ // envelope, so inside a multiplexer the sequence would be swallowed by the
707
+ // multiplexer instead of reaching the outer terminal. The multiplexer path
708
+ // resolves the outer client on its own.
709
+ if (LC_TERMINAL && caseEq(LC_TERMINAL, "iTerm2") && !isInsideTerminalMultiplexer(env)) return "iterm2";
710
+
689
711
  // tmux >= 3.2 replaces the pane's identity with `TERM_PROGRAM=tmux`.
690
712
  // Its server still holds the attached client's terminal-type reply and TERM.
691
713
  for (const clientProgram of resolveTmuxClientTerminalNames(env)) {
@@ -1087,6 +1109,19 @@ export function encodeITerm2(
1087
1109
  return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
1088
1110
  }
1089
1111
 
1112
+ /**
1113
+ * The encoded image `encodeITerm2` wrote into `line`, without the size
1114
+ * parameters a rewrap changes; undefined for a line without one.
1115
+ */
1116
+ export function iterm2ImagePayload(line: string): string | undefined {
1117
+ const start = line.indexOf(ImageProtocol.Iterm2);
1118
+ if (start === -1) return undefined;
1119
+ const colon = line.indexOf(":", start + ImageProtocol.Iterm2.length);
1120
+ if (colon === -1) return undefined;
1121
+ const end = line.indexOf("\x07", colon + 1);
1122
+ return line.slice(colon + 1, end === -1 ? undefined : end);
1123
+ }
1124
+
1090
1125
  export function calculateImageRows(
1091
1126
  imageDimensions: ImageDimensions,
1092
1127
  targetWidthCells: number,
@@ -1281,7 +1316,9 @@ export function encodeSixelNow(
1281
1316
  backgroundRgb = getTerminalBackgroundColor(),
1282
1317
  ): string | null {
1283
1318
  try {
1284
- return encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx, backgroundRgb);
1319
+ return ensureSixelRasterWidth(
1320
+ encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx, backgroundRgb),
1321
+ );
1285
1322
  } catch {
1286
1323
  return null;
1287
1324
  }
@@ -1354,9 +1391,12 @@ export function renderImage(
1354
1391
  return { sequence, rows };
1355
1392
  }
1356
1393
  if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) {
1394
+ // The height is the rows reserved for the image, not `auto`: iTerm2
1395
+ // resolves `auto` only for a session on screen, so an image written to a
1396
+ // background tab is dropped, and its rows stay blank in scrollback too.
1357
1397
  const sequence = encodeITerm2(base64Data, {
1358
1398
  width: fit.columns,
1359
- height: "auto",
1399
+ height: fit.rows,
1360
1400
  preserveAspectRatio: options.preserveAspectRatio ?? true,
1361
1401
  });
1362
1402
  return { sequence, rows: fit.rows };
@@ -628,9 +628,11 @@ const UNICODE_SYMBOLS: SymbolMap = {
628
628
  "lang.pdf": "📕",
629
629
  "lang.archive": "🗜",
630
630
  "lang.binary": "⚙",
631
- // Composer attachment chips
632
- "chip.image": "🖼",
633
- "chip.video": "🎞",
631
+ // Composer attachment chips. These sit inside the editable buffer, so the caret column
632
+ // depends on their width: use Emoji_Presentation glyphs, which every terminal draws 2 cells
633
+ // wide. Text-presentation pictographs (🖼, 🎞) measure 1 cell but many terminals draw them 2.
634
+ "chip.image": "📷",
635
+ "chip.video": "🎬",
634
636
  "chip.paste": "📄",
635
637
  // Settings tabs
636
638
  "tab.appearance": "🎨",
package/src/tools/bash.ts CHANGED
@@ -7,6 +7,7 @@ import { formatOutputPaneLines } from "../render/output-pane";
7
7
  import {
8
8
  capPreviewLines,
9
9
  DEFAULT_TERMINAL_PREVIEW_LINES,
10
+ formatDuration,
10
11
  formatToolWorkingDirectory,
11
12
  previewWindowRows,
12
13
  replaceTabs,
@@ -470,9 +471,14 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
470
471
  },
471
472
  uiTheme,
472
473
  );
474
+ const elapsedMs = options.executionStarted ? options.elapsedMs : undefined;
473
475
  return {
474
476
  header,
475
477
  phase: options.spinnerFrame !== undefined ? "running" : "pending",
478
+ footerMeta:
479
+ elapsedMs === undefined || elapsedMs < 1000
480
+ ? undefined
481
+ : uiTheme.fg("dim", `elapsed ${formatDuration(Math.floor(elapsedMs / 1000) * 1000)}`),
476
482
  sections: [{ content: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
477
483
  };
478
484
  });
@@ -528,6 +534,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
528
534
  let cachedIsPartial: boolean | undefined;
529
535
  let cachedPreviewWindow: number | undefined;
530
536
  let cachedSnapshot: ToolCardSnapshot | undefined;
537
+ let cachedElapsedSecond: number | undefined;
531
538
  const dropSnapshot = () => {
532
539
  cachedSnapshot = undefined;
533
540
  cachedWidth = undefined;
@@ -536,6 +543,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
536
543
  cachedRawOutput = undefined;
537
544
  cachedIsPartial = undefined;
538
545
  cachedPreviewWindow = undefined;
546
+ cachedElapsedSecond = undefined;
539
547
  };
540
548
 
541
549
  return framedToolCard(
@@ -553,6 +561,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
553
561
 
554
562
  const isPartial = options.isPartial === true;
555
563
  const previewWindow = previewWindowRows();
564
+ // A running command shows its live elapsed time in the bottom
565
+ // border; key the cache on whole seconds so it advances once a
566
+ // second instead of rebuilding every spinner frame.
567
+ const liveElapsedMs = isPartial && details?.wallTimeMs === undefined ? options.elapsedMs : undefined;
568
+ const elapsedSecond = liveElapsedMs === undefined ? undefined : Math.floor(liveElapsedMs / 1000);
556
569
 
557
570
  if (
558
571
  cachedSnapshot !== undefined &&
@@ -561,7 +574,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
561
574
  cachedExpanded === expanded &&
562
575
  cachedRawOutput === rawOutput &&
563
576
  cachedIsPartial === isPartial &&
564
- cachedPreviewWindow === previewWindow
577
+ cachedPreviewWindow === previewWindow &&
578
+ cachedElapsedSecond === elapsedSecond
565
579
  ) {
566
580
  return cachedSnapshot;
567
581
  }
@@ -576,13 +590,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
576
590
  rawOutputArtifact.artifactId,
577
591
  isError,
578
592
  );
579
- const timeoutLine =
580
- statsParts.length > 0
581
- ? uiTheme.fg(
582
- "dim",
583
- `${uiTheme.format.bracketLeft}${statsParts.join(" | ")}${uiTheme.format.bracketRight}`,
584
- )
585
- : undefined;
593
+ if (elapsedSecond !== undefined && elapsedSecond > 0) {
594
+ statsParts.unshift(`elapsed ${formatDuration(elapsedSecond * 1000)}`);
595
+ }
596
+ const footerMeta =
597
+ statsParts.length > 0 ? uiTheme.fg("dim", statsParts.join(uiTheme.sep.dot)) : undefined;
586
598
  let warningLine: string | undefined;
587
599
  if (details?.meta?.artifactError || (details?.meta?.truncation && !showingFullOutput)) {
588
600
  warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
@@ -610,12 +622,12 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
610
622
  uiTheme,
611
623
  );
612
624
  const outputLines: string[] = [...formatted.lines];
613
- if (timeoutLine) outputLines.push(timeoutLine);
614
625
  if (warningLine) outputLines.push(warningLine);
615
626
 
616
627
  const snapshot: ToolCardSnapshot = {
617
628
  header,
618
629
  phase: isPartial ? "partial" : isError ? (isTimeout ? "warning" : "error") : "success",
630
+ footerMeta,
619
631
  sections: [
620
632
  {
621
633
  // Viewport-sized tail window in every state — streaming and final
@@ -636,6 +648,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
636
648
  cachedRawOutput = rawOutput;
637
649
  cachedIsPartial = isPartial;
638
650
  cachedPreviewWindow = previewWindow;
651
+ cachedElapsedSecond = elapsedSecond;
639
652
  cachedSnapshot = snapshot;
640
653
  return snapshot;
641
654
  },
@@ -714,6 +727,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
714
727
  },
715
728
  mergeCallAndResult: true,
716
729
  inline: true,
730
+ // The live elapsed footer reads `options.elapsedMs` at render time; a
731
+ // once-a-second repaint keeps it moving for a quiet running command.
732
+ tickElapsed: true,
717
733
  } satisfies ToolRenderer<TArgs, BashToolDetails>;
718
734
  }
719
735
 
@@ -104,21 +104,22 @@ function buildDefaultToolBody(input: DefaultToolRenderInput, uiTheme: Theme, con
104
104
  body.push(uiTheme.fg("dim", "(no output)"));
105
105
  } else {
106
106
  let renderedAsJson = false;
107
- if (textContent.startsWith("{") || textContent.startsWith("[")) {
107
+ if (isTreeSafeJson(textContent)) {
108
108
  try {
109
109
  const parsed = JSON.parse(textContent);
110
- const maxDepth = options.expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
111
- const maxLines = options.expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
112
- const maxScalarLen = options.expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
113
- const tree = renderJsonTreeLines(parsed, uiTheme, maxDepth, maxLines, maxScalarLen);
110
+ const tree = renderJsonTreeLines(parsed, uiTheme, {
111
+ maxDepth: options.expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED,
112
+ maxLines: options.expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED,
113
+ maxScalarLen: options.expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED,
114
+ // A result's own `i` or `__partialJson` keys are data, not call metadata.
115
+ hiddenRootKeys: [],
116
+ });
114
117
 
115
- if (tree.lines.length > 0) {
118
+ // An expanded tree that hit a cap would show less than the raw output, so
119
+ // Ctrl+O falls through to the uncapped raw view instead.
120
+ if (tree.lines.length > 0 && !(options.expanded && tree.truncated)) {
116
121
  body.push(...tree.lines);
117
- if (!options.expanded) {
118
- body.push(formatExpandHint(uiTheme, options.expanded, true));
119
- } else if (tree.truncated) {
120
- body.push(uiTheme.fg("dim", "…"));
121
- }
122
+ if (!options.expanded) body.push(formatExpandHint(uiTheme, options.expanded, true));
122
123
  renderedAsJson = true;
123
124
  }
124
125
  } catch {
@@ -126,13 +127,14 @@ function buildDefaultToolBody(input: DefaultToolRenderInput, uiTheme: Theme, con
126
127
  }
127
128
  }
128
129
  if (!renderedAsJson) {
130
+ // No expanded cap: the terminal's own disclosure clamp is the only
131
+ // bound, so Ctrl+O reveals every row the collapsed view promised.
129
132
  body.push(
130
133
  ...formatOutputPaneLines(
131
134
  {
132
135
  lines: textContent.split("\n"),
133
136
  expanded: options.expanded,
134
137
  collapsedMaxLines: 4,
135
- expandedMaxLines: 12,
136
138
  styleLine: line =>
137
139
  clipCollapsed(styleToolOutputLine(line, uiTheme), contentWidth, options.expanded),
138
140
  showExpandHintWhenUncapped: true,
@@ -157,14 +159,37 @@ export function formatDefaultToolExecution(
157
159
  return [renderStatusLine(status, uiTheme), ...buildDefaultToolBody(input, uiTheme, contentWidth)].join("\n");
158
160
  }
159
161
 
162
+ /**
163
+ * Serialized JSON past this size keeps the raw view: the tree caps out at a few
164
+ * hundred rows anyway, and parsing and indexing a huge payload only to discard
165
+ * the tree can stall the UI.
166
+ */
167
+ const JSON_TREE_MAX_CHARS = 64 * 1024;
168
+ /** An integer literal of 16+ digits may exceed `Number.MAX_SAFE_INTEGER` and change when parsed. */
169
+ const UNSAFE_INTEGER_RE = /(?<![\w.])-?\d{16,}(?![\w.])/;
170
+
171
+ /**
172
+ * Whether a tool result can render as a JSON tree without losing anything the
173
+ * raw text shows: a bracketed payload small enough to index, with no integer a
174
+ * double would round (64-bit ids).
175
+ */
176
+ function isTreeSafeJson(text: string): boolean {
177
+ return (
178
+ (text.startsWith("{") || text.startsWith("[")) &&
179
+ text.length <= JSON_TREE_MAX_CHARS &&
180
+ !UNSAFE_INTEGER_RE.test(text)
181
+ );
182
+ }
183
+
160
184
  /** Inline args summary budget in characters (a data cap, not a width). */
161
185
  const NATIVE_ARGS_SUMMARY_CHARS = 160;
162
- /** Result text kept for the native card body. */
163
- const NATIVE_RESULT_MAX_CHARS = 64 * 1024;
164
186
 
165
187
  /**
166
188
  * TSP view of the generic fallback card: the tool label with a one-line args
167
189
  * summary as the head target; the result as a JSON tree or raw `ansi` output.
190
+ * The complete output goes over the wire — the card-level disclosure clamp the
191
+ * terminal applies is the only bound, so an expanded card keeps every row. JSON
192
+ * the tree's depth, node or scalar caps would cut keeps the raw path instead.
168
193
  */
169
194
  export function describeDefaultToolExecution(input: DefaultToolRenderInput): NativeToolView {
170
195
  const { result } = input;
@@ -175,15 +200,27 @@ export function describeDefaultToolExecution(input: DefaultToolRenderInput): Nat
175
200
  : undefined;
176
201
  const tool: NativeToolHead = { title: input.label, target: summary || undefined, targetKind: "text" };
177
202
  if (!result) return { tool };
178
- const output = result.output.trimEnd().slice(0, NATIVE_RESULT_MAX_CHARS);
203
+ const output = result.output.trimEnd();
179
204
  const tone = result.skipped ? "info" : result.isError ? "error" : undefined;
180
205
  if (!output) return { tool, tone, body: [noteText("(no output)")] };
181
- if (output.startsWith("{") || output.startsWith("[")) {
206
+ if (isTreeSafeJson(output)) {
207
+ let parsed: unknown;
182
208
  try {
183
- return { tool, tone, body: [describeJsonTree(JSON.parse(output))] };
209
+ parsed = JSON.parse(output);
184
210
  } catch {
185
211
  // Not JSON: shown as raw output below.
186
212
  }
213
+ if (parsed !== undefined) {
214
+ let truncated = false;
215
+ const tree = describeJsonTree(parsed, {
216
+ // A result's own `i` or `__partialJson` keys are data, not call metadata.
217
+ hiddenRootKeys: [],
218
+ onTruncated: () => {
219
+ truncated = true;
220
+ },
221
+ });
222
+ if (!truncated) return { tool, tone, body: [tree] };
223
+ }
187
224
  }
188
225
  return { tool, tone, body: [ansi(output)] };
189
226
  }
package/src/tools/eval.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  renderJsonTreeLines,
36
36
  } from "./json-tree";
37
37
  import { formatStyledTruncationWarning, stripOutputNotice } from "./output-meta";
38
+ import { formatMCPProgress, parseMCPProgress } from "./mcp";
38
39
  import { splitPathAndSel } from "./read";
39
40
  import {
40
41
  DEFAULT_TERMINAL_PREVIEW_LINES,
@@ -108,7 +109,7 @@ export interface EvalCellResult {
108
109
  durationMs?: number;
109
110
  exitCode?: number;
110
111
  statusEvents?: EvalStatusEvent[];
111
- /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
112
+ /** Evictable events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
112
113
  statusEventsElided?: number;
113
114
  }
114
115
 
@@ -117,7 +118,7 @@ export interface EvalToolDetails {
117
118
  cells?: EvalCellResult[];
118
119
  images?: ImageContent[];
119
120
  statusEvents?: EvalStatusEvent[];
120
- /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
121
+ /** Evictable events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
121
122
  statusEventsElided?: number;
122
123
  isError?: boolean;
123
124
  meta?: OutputMeta;
@@ -222,26 +223,29 @@ function pushCodeCellLines(
222
223
  type AgentEventStatus = "pending" | "running" | "completed" | "failed" | "aborted";
223
224
 
224
225
  /**
225
- * Coalescing key of a progress-snapshot event: `agent` and `judge_batch`
226
+ * Coalescing key of a progress-snapshot event: `agent`, `judge_batch`, and `mcp_progress`
226
227
  * events keyed by `id`, where only the newest snapshot matters. Discrete
227
228
  * actions (everything else) have no key. Shared by {@link upsertStatusEvent}
228
229
  * and the executors' display collectors so both coalesce identically.
229
230
  */
230
231
  export function statusEventKey(event: { op: string; [key: string]: unknown }): string | undefined {
231
- if ((event.op === "agent" || event.op === "judge_batch") && typeof event.id === "string") {
232
+ if (
233
+ (event.op === "agent" || event.op === "judge_batch" || event.op === "mcp_progress") &&
234
+ typeof event.id === "string"
235
+ ) {
232
236
  return `${event.op}\0${event.id}`;
233
237
  }
234
238
  return undefined;
235
239
  }
236
240
 
237
241
  /**
238
- * Discrete status events a log keeps. A cell polling a helper in a loop emits
242
+ * Evictable status events a log keeps. A cell polling a helper in a loop emits
239
243
  * one event per call (hundreds of thousands), and every live update and the
240
244
  * persisted tool result would otherwise carry them all.
241
245
  */
242
246
  export const MAX_STATUS_EVENTS = 200;
243
247
 
244
- /** A status event list plus the count of discrete events dropped from its front. */
248
+ /** A status event list plus the count of evictable events dropped from its front. */
245
249
  export type StatusEventLog = Pick<EvalCellResult, "statusEvents" | "statusEventsElided">;
246
250
 
247
251
  /**
@@ -263,9 +267,10 @@ export function upsertStatusEvent(events: EvalStatusEvent[], event: EvalStatusEv
263
267
 
264
268
  /**
265
269
  * {@link upsertStatusEvent} into a bounded log. Past {@link MAX_STATUS_EVENTS},
266
- * the oldest discrete event is dropped and counted. Snapshots stay, since agent
267
- * cards read them, and so does the newest committed `todo` result, which the
268
- * session todo panel refreshes from. Renderers already show only the newest
270
+ * the oldest discrete event or MCP progress snapshot is dropped and counted.
271
+ * Agent/judgment-batch snapshots stay, since agent cards read them, and so does
272
+ * the newest committed `todo` result, which the session todo panel refreshes
273
+ * from. Renderers already show only the newest
269
274
  * events behind an "… N earlier" row.
270
275
  */
271
276
  export function recordStatusEvent(log: StatusEventLog, event: EvalStatusEvent): void {
@@ -273,7 +278,9 @@ export function recordStatusEvent(log: StatusEventLog, event: EvalStatusEvent):
273
278
  upsertStatusEvent(events, event);
274
279
  if (events.length <= MAX_STATUS_EVENTS) return;
275
280
  const keep = events.findLastIndex(e => e.op === "todo" && e.committed === true);
276
- const oldest = events.findIndex((e, i) => i !== keep && statusEventKey(e) === undefined);
281
+ const oldest = events.findIndex(
282
+ (e, i) => i !== keep && (e.op === "mcp_progress" || statusEventKey(e) === undefined),
283
+ );
277
284
  if (oldest < 0) return;
278
285
  events.splice(oldest, 1);
279
286
  log.statusEventsElided = (log.statusEventsElided ?? 0) + 1;
@@ -414,6 +421,12 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
414
421
  }
415
422
 
416
423
  switch (op) {
424
+ case "mcp_progress": {
425
+ if (data.tool) parts.push(previewLine(sanitizeText(String(data.tool)), STATUS_VALUE_WIDTH));
426
+ const progress = parseMCPProgress(data);
427
+ if (progress) parts.push(formatMCPProgress(progress, STATUS_VALUE_WIDTH));
428
+ break;
429
+ }
417
430
  case "read":
418
431
  parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
419
432
  if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
@@ -693,20 +706,25 @@ const STATUS_VALUE_WIDTH = 200;
693
706
 
694
707
  /**
695
708
  * Plain one-line summary of a status event: op plus its scalar fields, each
696
- * collapsed to one line. A `preview` (file or command text a helper returned)
697
- * never joins the line: an expanded cell shows it under the line as a code
698
- * block holding its first {@link PREVIEW_LIMITS.OUTPUT_EXPANDED} lines, folded
699
- * to {@link PREVIEW_LIMITS.COLLAPSED_LINES}.
709
+ * collapsed to one line. MCP progress shows its tool and then the shared
710
+ * {@link formatMCPProgress} text; its call id is internal. A `preview` (file or
711
+ * command text a helper returned) never joins the line: an expanded cell shows
712
+ * it under the line as a code block holding its first
713
+ * {@link PREVIEW_LIMITS.OUTPUT_EXPANDED} lines, folded to
714
+ * {@link PREVIEW_LIMITS.COLLAPSED_LINES}.
700
715
  */
701
716
  function describeStatusEvent(event: EvalStatusEvent, expanded: boolean): NativeNode {
702
717
  const spans: TspSpan[] = [span(event.op, "accent")];
718
+ const mcpProgress = event.op === "mcp_progress" ? parseMCPProgress(event) : undefined;
703
719
  for (const key in event) {
704
720
  if (key === "op" || key === "preview" || key === "resolvedThinkingLevel") continue;
721
+ if (event.op === "mcp_progress" && key !== "tool") continue;
705
722
  const value = event[key];
706
723
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
707
724
  spans.push(span(` ${key}=`, "muted"), span(previewLine(sanitizeText(String(value)), STATUS_VALUE_WIDTH)));
708
725
  }
709
726
  }
727
+ if (mcpProgress) spans.push(span(` ${formatMCPProgress(mcpProgress, STATUS_VALUE_WIDTH)}`));
710
728
  const line = text(spans, { truncate: "end", role: "omp.tool.eval.status" });
711
729
  const preview = expanded ? eventString(event.preview)?.trimEnd() : undefined;
712
730
  if (!preview) return line;
package/src/tools/find.ts CHANGED
@@ -256,7 +256,7 @@ export const findToolRenderer = {
256
256
  },
257
257
  uiTheme,
258
258
  );
259
- return new Text(text, 1, 0);
259
+ return new Text(text, 0, 0);
260
260
  },
261
261
 
262
262
  renderResult(
@@ -269,7 +269,7 @@ export const findToolRenderer = {
269
269
  const text = result.content?.find(c => c.type === "text")?.text ?? "";
270
270
 
271
271
  if (result.isError) {
272
- return new Text(formatErrorMessage(text || "Unknown error", uiTheme), 1, 0);
272
+ return new Text(formatErrorMessage(text || "Unknown error", uiTheme), 0, 0);
273
273
  }
274
274
 
275
275
  // Streaming progress: the tool reports the phase it is in.
@@ -285,7 +285,7 @@ export const findToolRenderer = {
285
285
  },
286
286
  uiTheme,
287
287
  );
288
- return new Text(header, 1, 0);
288
+ return new Text(header, 0, 0);
289
289
  }
290
290
 
291
291
  const { hits, stats, threshold } = details;
@@ -317,7 +317,7 @@ export const findToolRenderer = {
317
317
  : "No relevant passages found";
318
318
  const lines = [header, formatEmptyMessage(empty, uiTheme)];
319
319
  for (const failure of stats.failures) lines.push(uiTheme.fg("warning", failure));
320
- return new Text(lines.join("\n"), 1, 0);
320
+ return new Text(lines.join("\n"), 0, 0);
321
321
  }
322
322
 
323
323
  const header = renderStatusLine(
@@ -359,7 +359,6 @@ export const findToolRenderer = {
359
359
  }
360
360
  return [header, ...hitLines, ...extra].map(line => truncateToWidth(line, width, Ellipsis.Omit));
361
361
  },
362
- { paddingX: 1 },
363
362
  );
364
363
  },
365
364
  describeCall(args: FindRenderArgs): NativeToolView {
package/src/tools/glob.ts CHANGED
@@ -151,7 +151,7 @@ export const globToolRenderer = {
151
151
  },
152
152
  uiTheme,
153
153
  );
154
- return new Text(text, 1, 0);
154
+ return new Text(text, 0, 0);
155
155
  },
156
156
 
157
157
  renderResult(
@@ -165,7 +165,7 @@ export const globToolRenderer = {
165
165
 
166
166
  if (result.isError || details?.error) {
167
167
  const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
168
- return new Text(formatErrorMessage(errorText, uiTheme), 1, 0);
168
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
169
169
  }
170
170
 
171
171
  const hasDetailedData = details?.fileCount !== undefined;
@@ -178,7 +178,7 @@ export const globToolRenderer = {
178
178
  textContent.includes("No files found") ||
179
179
  textContent.trim() === ""
180
180
  ) {
181
- return new Text(formatEmptyMessage("No files found", uiTheme), 1, 0);
181
+ return new Text(formatEmptyMessage("No files found", uiTheme), 0, 0);
182
182
  }
183
183
 
184
184
  const lines = textContent.split("\n").filter(l => l.trim());
@@ -207,7 +207,6 @@ export const globToolRenderer = {
207
207
  );
208
208
  return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
209
209
  },
210
- { paddingX: 1 },
211
210
  );
212
211
  }
213
212
 
@@ -244,7 +243,7 @@ export const globToolRenderer = {
244
243
  );
245
244
  const lines = [header, formatEmptyMessage(emptyLabel, uiTheme)];
246
245
  if (missingNote) lines.push(missingNote);
247
- return new Text(lines.join("\n"), 1, 0);
246
+ return new Text(lines.join("\n"), 0, 0);
248
247
  }
249
248
  const meta: string[] = [formatCount("file", fileCount)];
250
249
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
@@ -298,7 +297,6 @@ export const globToolRenderer = {
298
297
  );
299
298
  return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
300
299
  },
301
- { paddingX: 1 },
302
300
  );
303
301
  },
304
302
  describeCall(args: GlobRenderArgs): NativeToolView {
package/src/tools/grep.ts CHANGED
@@ -364,7 +364,7 @@ export const grepToolRenderer = {
364
364
  { icon: "pending", title: "Grep", titleColor: "toolTitle", description: args.pattern || "?", meta },
365
365
  uiTheme,
366
366
  );
367
- return new Text(text, 1, 0);
367
+ return new Text(text, 0, 0);
368
368
  },
369
369
 
370
370
  renderResult(
@@ -377,7 +377,7 @@ export const grepToolRenderer = {
377
377
 
378
378
  if (result.isError || details?.error) {
379
379
  const errorText = details?.error || result.content?.find(c => c.type === "text")?.text || "Unknown error";
380
- return new Text(formatErrorMessage(errorText, uiTheme), 1, 0);
380
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
381
381
  }
382
382
 
383
383
  const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined;
@@ -385,7 +385,7 @@ export const grepToolRenderer = {
385
385
  if (!hasDetailedData) {
386
386
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text;
387
387
  if (!textContent || textContent === "No matches found") {
388
- return new Text(formatEmptyMessage("No matches found", uiTheme), 1, 0);
388
+ return new Text(formatEmptyMessage("No matches found", uiTheme), 0, 0);
389
389
  }
390
390
  const lines = textContent.split("\n").filter(line => line.trim() !== "");
391
391
  const description = args?.pattern ?? undefined;
@@ -415,7 +415,6 @@ export const grepToolRenderer = {
415
415
  );
416
416
  return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
417
417
  },
418
- { paddingX: 1 },
419
418
  );
420
419
  }
421
420
  const linkTargets = grepLinkTargets(details, options.renderContext?.rawDetails);
@@ -442,7 +441,7 @@ export const grepToolRenderer = {
442
441
  );
443
442
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
444
443
  if (missingNote) lines.push(missingNote);
445
- return new Text(lines.join("\n"), 1, 0);
444
+ return new Text(lines.join("\n"), 0, 0);
446
445
  }
447
446
 
448
447
  const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
@@ -500,7 +499,6 @@ export const grepToolRenderer = {
500
499
  );
501
500
  return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
502
501
  },
503
- { paddingX: 1 },
504
502
  );
505
503
  },
506
504
  describeCall(args: GrepRenderArgs): NativeToolView {
package/src/tools/irc.ts CHANGED
@@ -21,6 +21,12 @@ export interface IrcMessage {
21
21
  * ping-pong forever.
22
22
  */
23
23
  wakeRelay?: boolean;
24
+ /**
25
+ * One leg of an `all` broadcast. Like relays, broadcasts never draw an
26
+ * automatic wake-turn reply: every woken idle peer would otherwise answer
27
+ * the broadcaster, mostly to say the message was not for it.
28
+ */
29
+ broadcast?: boolean;
24
30
  }
25
31
 
26
32
  /** Delivery outcome for one peer recipient. */