@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.
- package/CHANGELOG.md +27 -48
- package/dist/types/render/output-block.d.ts +2 -0
- package/dist/types/render/tool-card.d.ts +2 -0
- package/dist/types/terminal-capabilities.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/default-renderer.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +9 -8
- package/dist/types/tools/json-tree.d.ts +2 -0
- package/dist/types/tools/line-ranges.d.ts +8 -1
- package/dist/types/tools/mcp.d.ts +23 -0
- package/dist/types/tools/read.d.ts +8 -2
- package/dist/types/tools/renderer.d.ts +8 -2
- package/dist/types/tools/result-card.d.ts +1 -1
- package/package.json +9 -9
- package/src/chat/tool-execution.ts +35 -0
- package/src/chrome/transcript-container.ts +85 -12
- package/src/components/markdown.ts +11 -3
- package/src/native/backend.ts +25 -5
- package/src/overlays/jobs-panel.ts +14 -2
- package/src/render/output-block.ts +25 -12
- package/src/render/tool-card.ts +6 -0
- package/src/terminal-capabilities.ts +39 -2
- package/src/tools/bash.ts +25 -9
- package/src/tools/default-renderer.ts +54 -17
- package/src/tools/eval.ts +32 -14
- package/src/tools/find.ts +4 -5
- package/src/tools/glob.ts +4 -6
- package/src/tools/grep.ts +4 -6
- package/src/tools/json-tree.ts +8 -0
- package/src/tools/line-ranges.ts +23 -13
- package/src/tools/mcp.ts +44 -1
- package/src/tools/read.ts +64 -21
- package/src/tools/renderer.ts +8 -2
- package/src/tools/result-card.ts +1 -2
- package/src/tools/wait.ts +208 -123
package/src/native/backend.ts
CHANGED
|
@@ -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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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);
|
package/src/render/tool-card.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
580
|
-
statsParts.
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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 (
|
|
107
|
+
if (isTreeSafeJson(textContent)) {
|
|
108
108
|
try {
|
|
109
109
|
const parsed = JSON.parse(textContent);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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()
|
|
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 (
|
|
206
|
+
if (isTreeSafeJson(output)) {
|
|
207
|
+
let parsed: unknown;
|
|
182
208
|
try {
|
|
183
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 `
|
|
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 (
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
267
|
-
* cards read them, and so does
|
|
268
|
-
*
|
|
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(
|
|
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.
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
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;
|