@oh-my-pi/pi-tui 18.12.3 → 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.
@@ -244,6 +244,10 @@ class Surface {
244
244
  acked = 0;
245
245
  /** Send times of unacknowledged frames, oldest first. */
246
246
  unacked: number[] = [];
247
+ /** Send time of the oldest frame left unanswered when the current stall began; cleared by the next ack. */
248
+ stalledSince: number | undefined;
249
+ /** Credit resets since {@link stalledSince}. */
250
+ stallResets = 0;
247
251
  focus: string | null = null;
248
252
  dirty = false;
249
253
 
@@ -456,6 +460,8 @@ export class NativeBackend {
456
460
  surface.reconciler.detachLive();
457
461
  surface.unacked = [];
458
462
  surface.acked = surface.seq;
463
+ surface.stalledSince = undefined;
464
+ surface.stallResets = 0;
459
465
  surface.focus = null;
460
466
  surface.dirty = false;
461
467
  this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: SESSION_ROLE, adopt: true });
@@ -612,11 +618,16 @@ export class NativeBackend {
612
618
  if (surface.unacked.length < this.#credits) return true;
613
619
  const oldest = surface.unacked[0]!;
614
620
  if (this.#scheduler.now() - oldest < STALLED_ACK_MS) return false;
615
- logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
616
- sf: surface.id,
617
- s: surface.seq,
618
- acked: surface.acked,
619
- });
621
+ // One warning per stall: a terminal that never acks would otherwise log one every `STALLED_ACK_MS`.
622
+ if (surface.stalledSince === undefined) {
623
+ surface.stalledSince = oldest;
624
+ logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
625
+ sf: surface.id,
626
+ s: surface.seq,
627
+ acked: surface.acked,
628
+ });
629
+ }
630
+ surface.stallResets++;
620
631
  surface.unacked = [];
621
632
  surface.acked = surface.seq;
622
633
  return true;
@@ -801,6 +812,15 @@ export class NativeBackend {
801
812
  const newly = Math.min(event.s, surface.seq) - surface.acked;
802
813
  surface.acked = Math.min(event.s, surface.seq);
803
814
  surface.unacked.splice(0, newly);
815
+ if (surface.stalledSince !== undefined) {
816
+ logger.debug("TSP: terminal acknowledging frames again", {
817
+ sf: surface.id,
818
+ stalledMs: this.#scheduler.now() - surface.stalledSince,
819
+ resets: surface.stallResets,
820
+ });
821
+ surface.stalledSince = undefined;
822
+ surface.stallResets = 0;
823
+ }
804
824
  this.#clearStallTimer();
805
825
  if (surface.dirty) this.#host.requestRender();
806
826
  return;
@@ -6,12 +6,13 @@
6
6
  * (cwd, live pids, exit code, artifact) and live output.
7
7
  */
8
8
  import type { TspSpan, TspTone } from "@oh-my-pi/pi-wire";
9
- import { formatDuration } from "@oh-my-pi/pi-utils";
9
+ import { formatDuration, sanitizeText } from "@oh-my-pi/pi-utils";
10
10
  import type { Component } from "../tui";
11
11
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
12
12
  import { matchesKey } from "../keys";
13
- import { truncateToWidth } from "../utils";
13
+ import { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "../utils";
14
14
  import { collapseCommand } from "../apps/ps-data";
15
+ import { PREVIEW_LIMITS, shortenEmbeddedPaths } from "../render/render-utils";
15
16
  import { ansi, code, col, compact, item, node, row, span, stableKey, text } from "../native/describe";
16
17
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
17
18
  import { actionBar, actionButton, selectList } from "../native/overlay";
@@ -202,6 +203,17 @@ export class JobsSheet implements Component {
202
203
  }
203
204
  const detail = selected && this.#source.inspect(selected.id);
204
205
  if (detail) {
206
+ // Rows show the cut label; the selected job's command wraps here,
207
+ // bounded so a long script never pushes the facts and output off the sheet.
208
+ if (detail.command) {
209
+ const commandRows = wrapTextWithAnsi(
210
+ shortenEmbeddedPaths(replaceTabs(sanitizeText(detail.command))),
211
+ Math.max(1, width - 3),
212
+ );
213
+ for (const line of commandRows.slice(0, PREVIEW_LIMITS.EXPANDED_LINES)) lines.push(` ${line}`);
214
+ const hidden = commandRows.length - PREVIEW_LIMITS.EXPANDED_LINES;
215
+ if (hidden > 0) lines.push(truncateToWidth(` … ${hidden} more command lines`, width));
216
+ }
205
217
  const facts = compact([
206
218
  detail.cwd && `cwd ${detail.cwd}`,
207
219
  detail.pids.length > 0 && `pid ${detail.pids.join(", ")}`,
@@ -17,6 +17,8 @@ import { getStateBgColor, Hasher, padToWidth } from "./utils";
17
17
  export interface OutputBlockOptions {
18
18
  header?: string;
19
19
  headerMeta?: string;
20
+ /** Facts drawn inside the bottom border (`╰── Wall 0.16s · Timeout 300s ──╯`). */
21
+ footerMeta?: string;
20
22
  state?: State;
21
23
  sections?: Array<{ label?: string; lines: readonly string[]; separator?: boolean }>;
22
24
  width: number;
@@ -46,7 +48,7 @@ export function isFramedBlockComponent(component: Component): boolean {
46
48
 
47
49
  type BlockRow =
48
50
  | { kind: "bar"; leftChar: string; rightChar: string; label?: string; meta?: string }
49
- | { kind: "bottom"; leftChar: string; rightChar: string }
51
+ | { kind: "bottom"; leftChar: string; rightChar: string; meta?: string }
50
52
  | { kind: "content"; inner: string }
51
53
  | { kind: "sixel"; raw: string };
52
54
 
@@ -157,7 +159,26 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
157
159
  }
158
160
  }
159
161
 
160
- rows.push({ kind: "bottom", leftChar: theme.boxRound.bottomLeft, rightChar: theme.boxRound.bottomRight });
162
+ // Footer facts ride the bottom border only when they fit whole; a clipped
163
+ // border would silently drop trailing facts (exit code, artifact id), so an
164
+ // overflowing footer wraps into body rows above a plain border instead.
165
+ const footerMeta = options.footerMeta;
166
+ const footerFits =
167
+ footerMeta === undefined ||
168
+ visibleWidth(` ${footerMeta} `) <=
169
+ lineWidth - visibleWidth(`${theme.boxRound.bottomLeft}${cap}`) - visibleWidth(theme.boxRound.bottomRight);
170
+ if (footerMeta !== undefined && !footerFits) {
171
+ for (const wrappedLine of wrapTextWithAnsi(footerMeta, contentWidth)) {
172
+ const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(wrappedLine)));
173
+ rows.push({ kind: "content", inner: `${wrappedLine}${innerPadding}` });
174
+ }
175
+ }
176
+ rows.push({
177
+ kind: "bottom",
178
+ leftChar: theme.boxRound.bottomLeft,
179
+ rightChar: theme.boxRound.bottomRight,
180
+ meta: footerFits ? footerMeta : undefined,
181
+ });
161
182
 
162
183
  const H = rows.length;
163
184
 
@@ -182,14 +203,6 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
182
203
  return `${border(leftGlyphs)}${trimmedLabel}${border(fillGlyphs)}${border(rightGlyph)}`;
183
204
  };
184
205
 
185
- const renderBottom = (row: { leftChar: string; rightChar: string }): string => {
186
- const leftGlyphs = `${row.leftChar}${cap}`;
187
- const rightGlyph = row.rightChar;
188
- const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
189
- const fillGlyphs = h.repeat(fillCount);
190
- return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
191
- };
192
-
193
206
  const renderContent = (inner: string): string =>
194
207
  `${border(v)}${contentLeftPadding}${inner}${contentRightPadding}${border(v)}`;
195
208
 
@@ -201,8 +214,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
201
214
  lines.push(row.raw);
202
215
  continue;
203
216
  }
204
- const line =
205
- row.kind === "bar" ? renderBar(row) : row.kind === "bottom" ? renderBottom(row) : renderContent(row.inner);
217
+ const line = row.kind === "bar" || row.kind === "bottom" ? renderBar(row) : renderContent(row.inner);
206
218
  lines.push(padToWidth(clipFrame ? truncateToWidth(line, lineWidth, Ellipsis.Omit) : line, lineWidth, bgFn));
207
219
  }
208
220
 
@@ -360,6 +372,7 @@ export class CachedOutputBlock {
360
372
  );
361
373
  h.optional(options.header);
362
374
  h.optional(options.headerMeta);
375
+ h.optional(options.footerMeta);
363
376
  h.optional(options.state);
364
377
  h.optional(options.borderColor);
365
378
  h.bool(options.applyBg ?? true);
@@ -43,6 +43,8 @@ export interface ToolCardSnapshot {
43
43
  /** Structured status header, rendered by the shared status-line primitive. */
44
44
  status?: StatusLineOptions;
45
45
  headerMeta?: string;
46
+ /** Short facts drawn inside the frame's bottom border (framed); a trailing row otherwise. */
47
+ footerMeta?: string;
46
48
  phase?: ToolCardPhase;
47
49
  body?: ToolCardContent;
48
50
  sections?: readonly ToolCardSection[];
@@ -219,6 +221,7 @@ export class ToolCard implements Component {
219
221
  previousOptions?.width === width &&
220
222
  previousOptions.header === header &&
221
223
  previousOptions.headerMeta === snapshot.headerMeta &&
224
+ previousOptions.footerMeta === snapshot.footerMeta &&
222
225
  previousOptions.state === state &&
223
226
  previousOptions.applyBg === snapshot.applyBg &&
224
227
  previousOptions.contentPaddingLeft === snapshot.contentPaddingLeft &&
@@ -230,6 +233,7 @@ export class ToolCard implements Component {
230
233
  const blockOptions: OutputBlockOptions = reusableOptions ?? {
231
234
  header,
232
235
  headerMeta: snapshot.headerMeta,
236
+ footerMeta: snapshot.footerMeta,
233
237
  state,
234
238
  sections,
235
239
  width,
@@ -251,6 +255,7 @@ export class ToolCard implements Component {
251
255
  if (section.label) lines.push(section.label);
252
256
  lines.push(...section.lines);
253
257
  }
258
+ if (snapshot.footerMeta) lines.push(snapshot.footerMeta);
254
259
  const text = lines.join("\n");
255
260
  const key = `${width}:${text.length}:${Bun.hash(text).toString(36)}:${state ?? "info"}:${snapshot.applyBg ?? true}`;
256
261
  if (key !== this.#plainKey) {
@@ -279,6 +284,7 @@ export class ToolCard implements Component {
279
284
  }
280
285
  if (snapshot.footer)
281
286
  slotInputs.push({ label: undefined, separator: slotInputs.length > 0, content: snapshot.footer });
287
+ if (snapshot.footerMeta) slotInputs.push({ label: undefined, separator: false, content: [snapshot.footerMeta] });
282
288
 
283
289
  const nextChildren: Component[] = [];
284
290
  const slots = slotInputs.map((input, index) => {
@@ -404,7 +404,11 @@ export function detectStyledUnderlineSupport(terminalId: TerminalId, env: NodeJS
404
404
  // render. Enable only on a confirmed major.minor >= 3.5; an absent or
405
405
  // unparseable version keeps the flat fallback so only proven terminals
406
406
  // get the colon form.
407
- 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);
408
412
  return version !== null && (version.major > 3 || (version.major === 3 && version.minor >= 5));
409
413
  }
410
414
  default:
@@ -673,6 +677,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
673
677
  VSCODE_PID,
674
678
  ALACRITTY_WINDOW_ID,
675
679
  TERM_PROGRAM,
680
+ LC_TERMINAL,
676
681
  TERM,
677
682
  COLORTERM,
678
683
  } = env;
@@ -687,6 +692,22 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
687
692
  const programId = fromProgram(TERM_PROGRAM);
688
693
  if (programId) return programId;
689
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
+
690
711
  // tmux >= 3.2 replaces the pane's identity with `TERM_PROGRAM=tmux`.
691
712
  // Its server still holds the attached client's terminal-type reply and TERM.
692
713
  for (const clientProgram of resolveTmuxClientTerminalNames(env)) {
@@ -1088,6 +1109,19 @@ export function encodeITerm2(
1088
1109
  return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
1089
1110
  }
1090
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
+
1091
1125
  export function calculateImageRows(
1092
1126
  imageDimensions: ImageDimensions,
1093
1127
  targetWidthCells: number,
@@ -1357,9 +1391,12 @@ export function renderImage(
1357
1391
  return { sequence, rows };
1358
1392
  }
1359
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.
1360
1397
  const sequence = encodeITerm2(base64Data, {
1361
1398
  width: fit.columns,
1362
- height: "auto",
1399
+ height: fit.rows,
1363
1400
  preserveAspectRatio: options.preserveAspectRatio ?? true,
1364
1401
  });
1365
1402
  return { sequence, rows: fit.rows };
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;