@code-yeongyu/senpi-codemode 2026.7.28-2 → 2026.7.28-3

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 CHANGED
@@ -12,6 +12,20 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.7.28-3] - 2026-07-28
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Add nested tool-call widgets that render the real call shape of tools invoked from eval cells, with truthful status, duration, and sanitized previews ([#444](https://github.com/code-yeongyu/senpi/pull/444)).
22
+
23
+ ### Changed
24
+
25
+ ### Fixed
26
+
27
+ ### Removed
28
+
15
29
  ## [2026.7.28-2] - 2026-07-28
16
30
 
17
31
  ### Breaking Changes
package/README.md CHANGED
@@ -185,3 +185,18 @@ Direct real-surface QA drivers live in `scripts/qa-*.ts`: kernel cells
185
185
  (`qa-py-cell.ts`, `qa-js-cell.ts`, `qa-rb-cell.ts`, `qa-jl-cell.ts`), end-to-end
186
186
  extension execution (`qa-e2e-eval.ts`), and renderer output
187
187
  (`qa-render-dump.ts`).
188
+
189
+ ### Nested tool-call widgets
190
+
191
+ When an eval cell invokes `tool.<name>(...)`, the result panel can render a
192
+ nested widget for the invoked tool. The widget captures bounded args, duration,
193
+ and a sanitized 160 code points result preview; the rendering path is
194
+ always-on and does not depend on any toggle or session flag.
195
+
196
+ The capture budget is fixed at 30 enriched calls per cell, with a 4096-character
197
+ serialized args budget. Previews are capped at 160 code points, and collapsed
198
+ widgets stay within the 8 lines collapsed widget budget.
199
+
200
+ Entries without args — including old sessions, reserved/completion rows, and
201
+ calls past the cap — render as plain rows. Edit renders a fallback row by
202
+ design, even when its args are present.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.7.28-2",
3
+ "version": "2026.7.28-3",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "7.29.7",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.28-2",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.28-3",
34
34
  "typebox": "1.1.38"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@code-yeongyu/senpi": "*"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.7.28-2"
40
+ "@code-yeongyu/senpi": "2026.7.28-3"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -0,0 +1,93 @@
1
+ import { type AgentToolResult, sanitizeTerminalLabel } from "@code-yeongyu/senpi";
2
+
3
+ export const MAX_ENRICHED_TOOL_CALLS = 30;
4
+
5
+ const MAX_ARGUMENT_STRING_CODE_POINTS = 512;
6
+ const MAX_ARGUMENT_ENTRIES = 32;
7
+ const MAX_ARGUMENT_DEPTH = 6;
8
+ const MAX_SERIALIZED_ARGUMENT_LENGTH = 4096;
9
+ const MAX_RESULT_PREVIEW_CODE_POINTS = 160;
10
+
11
+ type BoundedValue = {
12
+ readonly value: unknown;
13
+ readonly truncated: boolean;
14
+ };
15
+
16
+ export function capCodePoints(text: string, max: number): string {
17
+ let end = 0;
18
+ let count = 0;
19
+ while (count < max && end < text.length) {
20
+ const firstCodeUnit = text.charCodeAt(end);
21
+ const secondCodeUnit = text.charCodeAt(end + 1);
22
+ const isSurrogatePair =
23
+ firstCodeUnit >= 0xd800 && firstCodeUnit <= 0xdbff && secondCodeUnit >= 0xdc00 && secondCodeUnit <= 0xdfff;
24
+ end += isSurrogatePair ? 2 : 1;
25
+ count += 1;
26
+ }
27
+ return end === text.length ? text : `${text.slice(0, end)}…`;
28
+ }
29
+
30
+ function boundValue(value: unknown, depth: number, ancestors: WeakSet<object>): BoundedValue {
31
+ if (typeof value === "string") {
32
+ const capped = capCodePoints(value, MAX_ARGUMENT_STRING_CODE_POINTS);
33
+ return { value: capped, truncated: capped !== value };
34
+ }
35
+
36
+ if (value === null || typeof value !== "object") return { value, truncated: false };
37
+ if (depth >= MAX_ARGUMENT_DEPTH) return { value: "…", truncated: true };
38
+ if (ancestors.has(value)) throw new Error("cyclic tool-call arguments");
39
+
40
+ ancestors.add(value);
41
+ try {
42
+ if (Array.isArray(value)) {
43
+ const retainedLength = Math.min(value.length, MAX_ARGUMENT_ENTRIES);
44
+ const clone: unknown[] = [];
45
+ let truncated = value.length > MAX_ARGUMENT_ENTRIES;
46
+ for (let index = 0; index < retainedLength; index += 1) {
47
+ const bounded = boundValue(value[index], depth + 1, ancestors);
48
+ clone.push(bounded.value);
49
+ truncated ||= bounded.truncated;
50
+ }
51
+ return { value: clone, truncated };
52
+ }
53
+
54
+ const entries: [string, unknown][] = [];
55
+ let truncated = false;
56
+ let count = 0;
57
+ for (const [key, nestedValue] of Object.entries(value)) {
58
+ if (count >= MAX_ARGUMENT_ENTRIES) {
59
+ truncated = true;
60
+ break;
61
+ }
62
+ const bounded = boundValue(nestedValue, depth + 1, ancestors);
63
+ entries.push([key, bounded.value]);
64
+ truncated ||= bounded.truncated;
65
+ count += 1;
66
+ }
67
+ return { value: Object.fromEntries(entries), truncated };
68
+ } finally {
69
+ ancestors.delete(value);
70
+ }
71
+ }
72
+
73
+ export function boundToolCallArgs(args: unknown): { args: unknown; truncated: boolean } {
74
+ try {
75
+ const bounded = boundValue(args, 0, new WeakSet<object>());
76
+ const serialized = JSON.stringify(bounded.value);
77
+ if (typeof serialized !== "string" || serialized.length > MAX_SERIALIZED_ARGUMENT_LENGTH) {
78
+ return { args: undefined, truncated: true };
79
+ }
80
+ return { args: bounded.value, truncated: bounded.truncated };
81
+ } catch {
82
+ return { args: undefined, truncated: true };
83
+ }
84
+ }
85
+
86
+ export function toolCallResultPreview(result: AgentToolResult<unknown>): string | undefined {
87
+ for (const part of result.content) {
88
+ if (part.type !== "text") continue;
89
+ const preview = sanitizeTerminalLabel(part.text).replace(/\s+/gu, " ").trim();
90
+ return preview.length === 0 ? undefined : capCodePoints(preview, MAX_RESULT_PREVIEW_CODE_POINTS);
91
+ }
92
+ return undefined;
93
+ }
@@ -1,4 +1,9 @@
1
- import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@code-yeongyu/senpi";
1
+ import {
2
+ type AgentToolResult,
3
+ type AgentToolUpdateCallback,
4
+ type ExtensionContext,
5
+ sanitizeTerminalLabel,
6
+ } from "@code-yeongyu/senpi";
2
7
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
8
  import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
4
9
  import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
@@ -7,6 +12,7 @@ import { appendSchemaHint } from "../bridges/schema-hint.ts";
7
12
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
8
13
  import { handleCompletionToolCall } from "../completion/tool-bridge.ts";
9
14
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
15
+ import { boundToolCallArgs, capCodePoints, MAX_ENRICHED_TOOL_CALLS, toolCallResultPreview } from "./call-capture.ts";
10
16
  import {
11
17
  type EvalImageResizer,
12
18
  EvalOutputCollector,
@@ -17,7 +23,19 @@ import {
17
23
  import { upsertStatusEvent } from "./status-events.ts";
18
24
  import type { EvalKernel, EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
19
25
 
20
- type ResolvedToolReply = { readonly value: unknown; readonly toolCallOk: boolean };
26
+ type ResolvedToolReply = {
27
+ readonly value: unknown;
28
+ readonly toolCallOk: boolean;
29
+ readonly resultPreview?: string;
30
+ readonly errorText?: string;
31
+ };
32
+
33
+ type ToolCallEnrichment = {
34
+ readonly callId: string;
35
+ readonly args: unknown;
36
+ readonly startedAt: number;
37
+ readonly argsTruncated?: true;
38
+ };
21
39
 
22
40
  const LIVE_OUTPUT_PREVIEW_LINES = 8;
23
41
 
@@ -187,20 +205,53 @@ export class CellHandler {
187
205
  this.#emitUpdate(false);
188
206
  return;
189
207
  }
190
- await this.#deliverToolReply(message, async () => {
191
- const result = await this.#runtime.executeTool(message.toolName, message.args, { signal: this.#state.signal });
192
- return { value: marshalToolResult(result), toolCallOk: !toolResultIsError(result) };
193
- });
208
+ const capturedArgs = boundToolCallArgs(message.args);
209
+ const startedAt = Date.now();
210
+ await this.#deliverToolReply(
211
+ message,
212
+ async () => {
213
+ const result = await this.#runtime.executeTool(message.toolName, message.args, {
214
+ signal: this.#state.signal,
215
+ });
216
+ const toolCallOk = !toolResultIsError(result);
217
+ if (toolCallOk) {
218
+ const resultPreview = toolCallResultPreview(result);
219
+ return {
220
+ value: marshalToolResult(result),
221
+ toolCallOk,
222
+ ...(resultPreview === undefined ? {} : { resultPreview }),
223
+ };
224
+ }
225
+ let errorText: string | undefined;
226
+ for (const part of result.content) {
227
+ if (part.type !== "text") continue;
228
+ errorText = capCodePoints(sanitizeTerminalLabel(part.text), 512);
229
+ break;
230
+ }
231
+ return {
232
+ value: marshalToolResult(result),
233
+ toolCallOk,
234
+ ...(errorText === undefined ? {} : { errorText }),
235
+ };
236
+ },
237
+ {
238
+ callId: message.callId,
239
+ args: capturedArgs.args,
240
+ startedAt,
241
+ ...(capturedArgs.truncated ? { argsTruncated: true } : {}),
242
+ },
243
+ );
194
244
  }
195
245
 
196
246
  async #deliverToolReply(
197
247
  message: Extract<KernelToHostMessage, { type: "tool-call" }>,
198
248
  resolve: () => Promise<ResolvedToolReply>,
249
+ enrich?: ToolCallEnrichment,
199
250
  ): Promise<void> {
200
251
  try {
201
252
  const reply = await resolve();
202
253
  if (!this.#state.active) return;
203
- this.#state.toolCalls.push({ name: message.toolName, ok: reply.toolCallOk });
254
+ this.#pushToolCall(message.toolName, reply.toolCallOk, enrich, reply.resultPreview, reply.errorText);
204
255
  this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: true, value: reply.value });
205
256
  } catch (error) {
206
257
  if (!this.#state.active) return;
@@ -209,7 +260,7 @@ export class CellHandler {
209
260
  message.toolName,
210
261
  this.#toolParameters(message.toolName),
211
262
  );
212
- this.#state.toolCalls.push({ name: message.toolName, ok: false, error: text });
263
+ this.#pushToolCall(message.toolName, false, enrich, undefined, text);
213
264
  this.#kernel.deliverToolReply({
214
265
  type: "tool-reply",
215
266
  callId: message.callId,
@@ -220,6 +271,29 @@ export class CellHandler {
220
271
  this.#emitUpdate(false);
221
272
  }
222
273
 
274
+ #pushToolCall(
275
+ name: string,
276
+ ok: boolean,
277
+ enrich: ToolCallEnrichment | undefined,
278
+ resultPreview: string | undefined,
279
+ error: string | undefined,
280
+ ): void {
281
+ const summary = { name, ok, ...(error === undefined ? {} : { error }) };
282
+ const enrichedCount = this.#state.toolCalls.filter((toolCall) => toolCall.callId !== undefined).length;
283
+ if (enrich === undefined || enrichedCount >= MAX_ENRICHED_TOOL_CALLS) {
284
+ this.#state.toolCalls.push(summary);
285
+ return;
286
+ }
287
+ this.#state.toolCalls.push({
288
+ ...summary,
289
+ callId: enrich.callId,
290
+ args: enrich.args,
291
+ durationMs: Date.now() - enrich.startedAt,
292
+ ...(enrich.argsTruncated === true ? { argsTruncated: true } : {}),
293
+ ...(resultPreview === undefined ? {} : { resultPreview }),
294
+ });
295
+ }
296
+
223
297
  #toolParameters(toolName: string): unknown {
224
298
  return this.#runtime.listTools?.().find((tool) => tool.name === toolName)?.parameters;
225
299
  }
@@ -19,6 +19,7 @@ import {
19
19
  JSON_TREE_SCALAR_LEN_EXPANDED,
20
20
  renderJsonTreeLines,
21
21
  } from "./json-tree.ts";
22
+ import { codePointPrefix, formatDuration, renderToolCallWidget } from "./tool-widgets.ts";
22
23
  import type {
23
24
  EvalCellResult,
24
25
  EvalInputSchema,
@@ -116,18 +117,6 @@ function appendLines(target: string[], source: readonly string[]): void {
116
117
  for (const line of source) target.push(line);
117
118
  }
118
119
 
119
- function codePointPrefix(text: string, maxCodePoints: number): string {
120
- let end = 0;
121
- for (let count = 0; count < maxCodePoints && end < text.length; count += 1) {
122
- const firstCodeUnit = text.charCodeAt(end);
123
- const secondCodeUnit = text.charCodeAt(end + 1);
124
- const isSurrogatePair =
125
- firstCodeUnit >= 0xd800 && firstCodeUnit <= 0xdbff && secondCodeUnit >= 0xdc00 && secondCodeUnit <= 0xdfff;
126
- end += isSurrogatePair ? 2 : 1;
127
- }
128
- return text.slice(0, end);
129
- }
130
-
131
120
  function renderAllVisualLines(text: string, width: number): string[] {
132
121
  return truncateToVisualLines(text, Number.POSITIVE_INFINITY, width).visualLines.map((line) => line.trimEnd());
133
122
  }
@@ -232,18 +221,6 @@ function highlightedCode(code: string, language: EvalLanguage, theme: Theme | un
232
221
  return (theme === undefined ? lines.map((line) => line.replace(/\u001b\[[0-9;]*m/gu, "")) : lines).join("\n");
233
222
  }
234
223
 
235
- function formatDuration(milliseconds: number): string {
236
- const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1_000);
237
- if (totalSeconds < 1) return "<1s";
238
- const seconds = totalSeconds % 60;
239
- const totalMinutes = Math.floor(totalSeconds / 60);
240
- if (totalMinutes < 1) return `${seconds}s`;
241
- const minutes = totalMinutes % 60;
242
- const hours = Math.floor(totalMinutes / 60);
243
- if (hours < 1) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
244
- return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
245
- }
246
-
247
224
  function spinner(frame: number | undefined): string {
248
225
  return SPINNER_FRAMES.at((frame ?? 0) % SPINNER_FRAMES.length) ?? SPINNER_FRAMES[0];
249
226
  }
@@ -695,6 +672,46 @@ function toolCallRows(details: EvalToolDetails | undefined): ToolCallRow[] {
695
672
  });
696
673
  }
697
674
 
675
+ function nestedToolCallBlock(
676
+ details: EvalToolDetails | undefined,
677
+ theme: Theme | undefined,
678
+ cwd: string,
679
+ expanded: boolean,
680
+ ): RenderBlock | undefined {
681
+ const toolCalls = details?.toolCalls;
682
+ if (theme === undefined || toolCalls === undefined || !toolCalls.some((call) => call.args !== undefined)) {
683
+ return undefined;
684
+ }
685
+ const legacyRows = toolCallRows(details);
686
+ return {
687
+ kind: "dynamic",
688
+ render: (width) => {
689
+ const retainedCalls = expanded ? toolCalls : toolCalls.slice(-TOOL_CALL_PREVIEW_COUNT);
690
+ const retainedRows = expanded ? legacyRows : legacyRows.slice(-TOOL_CALL_PREVIEW_COUNT);
691
+ const skippedCount = toolCalls.length - retainedCalls.length;
692
+ const toolCallNoun = skippedCount === 1 ? "call" : "calls";
693
+ const lines =
694
+ expanded || skippedCount === 0
695
+ ? []
696
+ : renderAllVisualLines(style(theme, "muted", `${skippedCount} earlier tool ${toolCallNoun}`), width);
697
+ const legacyBlock: Extract<RenderBlock, { kind: "toolCalls" }> = {
698
+ kind: "toolCalls",
699
+ calls: retainedRows,
700
+ expanded,
701
+ theme,
702
+ };
703
+ for (const [index, call] of retainedCalls.entries()) {
704
+ if (call.args !== undefined) {
705
+ appendLines(lines, renderToolCallWidget(call, { cwd, theme, expanded, width }));
706
+ } else {
707
+ appendLines(lines, renderToolCall(retainedRows[index], legacyBlock, width));
708
+ }
709
+ }
710
+ return lines;
711
+ },
712
+ };
713
+ }
714
+
698
715
  function resultStatus(
699
716
  details: EvalToolDetails | undefined,
700
717
  options: ToolRenderResultOptions,
@@ -824,7 +841,9 @@ export function renderEvalResult(
824
841
  },
825
842
  ];
826
843
  const calls = toolCallRows(details);
827
- if (calls.length > 0) blocks.push({ kind: "blank" }, { kind: "toolCalls", calls, expanded, theme });
844
+ const nestedCalls = nestedToolCallBlock(details, theme, context.cwd, expanded);
845
+ if (calls.length > 0)
846
+ blocks.push({ kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme });
828
847
  component.setBlocks(blocks);
829
848
  return component;
830
849
  }
@@ -888,7 +907,8 @@ export function renderEvalResult(
888
907
  );
889
908
  }
890
909
  const calls = toolCallRows(details);
891
- if (calls.length > 0) blocks.push({ kind: "blank" }, { kind: "toolCalls", calls, expanded, theme });
910
+ const nestedCalls = nestedToolCallBlock(details, theme, context.cwd, expanded);
911
+ if (calls.length > 0) blocks.push({ kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme });
892
912
  if (details?.notice !== undefined)
893
913
  blocks.push({ kind: "blank" }, { kind: "text", text: style(theme, "dim", details.notice) });
894
914
  const warning = formatTruncationWarning(details?.meta) ?? (details?.truncated ? "[eval output truncated]" : null);
@@ -0,0 +1,259 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ createFindToolDefinition,
4
+ createGrepToolDefinition,
5
+ createLsToolDefinition,
6
+ createReadToolDefinition,
7
+ createWriteToolDefinition,
8
+ sanitizeTerminalLabel,
9
+ type Theme,
10
+ type ThemeColor,
11
+ type ToolDefinition,
12
+ truncateToVisualLines,
13
+ } from "@code-yeongyu/senpi";
14
+ import type { TSchema } from "typebox";
15
+ import { Check } from "typebox/value";
16
+ import type { EvalToolCallSummary } from "./types.ts";
17
+
18
+ export interface WidgetOptions {
19
+ readonly cwd: string;
20
+ readonly theme: Theme | undefined;
21
+ readonly expanded: boolean;
22
+ readonly width: number;
23
+ }
24
+
25
+ type AnyToolDef<TParams extends TSchema, TDetails, TState> = ToolDefinition<TParams, TDetails, TState>;
26
+ type RenderCallOf<TParams extends TSchema, TDetails, TState> = NonNullable<
27
+ AnyToolDef<TParams, TDetails, TState>["renderCall"]
28
+ >;
29
+ type ToolRenderContext<TParams extends TSchema, TDetails, TState> = Parameters<
30
+ RenderCallOf<TParams, TDetails, TState>
31
+ >[2];
32
+ type Component<TParams extends TSchema, TDetails, TState> = ReturnType<RenderCallOf<TParams, TDetails, TState>>;
33
+
34
+ type CoreWidgetName = "bash" | "read" | "write" | "grep" | "find" | "ls";
35
+ type CoreWidgetRenderer = (summary: EvalToolCallSummary, options: WidgetOptions) => string[] | undefined;
36
+ type CoreWidgets = Readonly<Record<CoreWidgetName, CoreWidgetRenderer>>;
37
+
38
+ const MIN_RENDERER_WIDTH = 20;
39
+ const MAX_RENDERER_LINES = 5;
40
+ const MAX_FALLBACK_ARGUMENT_CODE_POINTS = 120;
41
+ const MAX_COLLAPSED_ERROR_CODE_POINTS = 512;
42
+ const MAX_COLLAPSED_ERROR_LINES = 4;
43
+ const MAX_PREVIEW_LINES = 2;
44
+ const MAX_COLLAPSED_WIDGET_LINES = 8;
45
+ const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]";
46
+ const WIDGET_TRUNCATION_MARKER = "… (widget truncated)";
47
+ const TERMINAL_ESCAPE_PATTERN =
48
+ /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
49
+ const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g;
50
+ const coreWidgetsByCwd = new Map<string, CoreWidgets>();
51
+
52
+ export function codePointPrefix(text: string, maxCodePoints: number): string {
53
+ let end = 0;
54
+ for (let count = 0; count < maxCodePoints && end < text.length; count += 1) {
55
+ const firstCodeUnit = text.charCodeAt(end);
56
+ const secondCodeUnit = text.charCodeAt(end + 1);
57
+ const isSurrogatePair =
58
+ firstCodeUnit >= 0xd800 && firstCodeUnit <= 0xdbff && secondCodeUnit >= 0xdc00 && secondCodeUnit <= 0xdfff;
59
+ end += isSurrogatePair ? 2 : 1;
60
+ }
61
+ return text.slice(0, end);
62
+ }
63
+
64
+ export function formatDuration(milliseconds: number): string {
65
+ const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1_000);
66
+ if (totalSeconds < 1) return "<1s";
67
+ const seconds = totalSeconds % 60;
68
+ const totalMinutes = Math.floor(totalSeconds / 60);
69
+ if (totalMinutes < 1) return `${seconds}s`;
70
+ const minutes = totalMinutes % 60;
71
+ const hours = Math.floor(totalMinutes / 60);
72
+ if (hours < 1) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
73
+ return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
74
+ }
75
+
76
+ function style(theme: Theme | undefined, color: ThemeColor, text: string): string {
77
+ return theme === undefined ? text : theme.fg(color, text);
78
+ }
79
+
80
+ function renderAllVisualLines(text: string, width: number): string[] {
81
+ return truncateToVisualLines(text, Number.POSITIVE_INFINITY, width).visualLines.map((line) => line.trimEnd());
82
+ }
83
+
84
+ function renderFirstVisualLines(text: string, maxLines: number, width: number): string[] {
85
+ const truncated = truncateToVisualLines(text, maxLines, width);
86
+ if (truncated.skippedCount === 0) return truncated.visualLines.map((line) => line.trimEnd());
87
+ return renderAllVisualLines(text, width).slice(0, maxLines);
88
+ }
89
+
90
+ function indent(lines: readonly string[]): string[] {
91
+ return lines.map((line) => ` ${line}`);
92
+ }
93
+
94
+ function renderWith<TParams extends TSchema, TDetails, TState>(
95
+ definition: ToolDefinition<TParams, TDetails, TState>,
96
+ summary: EvalToolCallSummary,
97
+ options: WidgetOptions,
98
+ initialState: TState,
99
+ ): string[] | undefined {
100
+ let preparedArgs: unknown = summary.args;
101
+ try {
102
+ if (definition.prepareArguments !== undefined) preparedArgs = definition.prepareArguments(preparedArgs);
103
+ if (!Check(definition.parameters, preparedArgs)) return undefined;
104
+ } catch {
105
+ return undefined;
106
+ }
107
+
108
+ const theme = options.theme;
109
+ const innerWidth = options.width - 2;
110
+ const renderCall = definition.renderCall;
111
+ if (theme === undefined || innerWidth < MIN_RENDERER_WIDTH || renderCall === undefined) return undefined;
112
+
113
+ const context: ToolRenderContext<TParams, TDetails, TState> = {
114
+ args: preparedArgs,
115
+ toolCallId: summary.callId ?? summary.name,
116
+ invalidate: () => {},
117
+ lastComponent: undefined,
118
+ state: initialState,
119
+ cwd: options.cwd,
120
+ executionStarted: true,
121
+ argsComplete: true,
122
+ isPartial: false,
123
+ expanded: options.expanded,
124
+ showImages: false,
125
+ isError: !summary.ok,
126
+ hasResult: false,
127
+ };
128
+
129
+ try {
130
+ const component: Component<TParams, TDetails, TState> = renderCall(preparedArgs, theme, context);
131
+ const renderedLines = component.render(innerWidth).map((line) => line.trimEnd());
132
+ const retainedLines = renderedLines.slice(0, MAX_RENDERER_LINES);
133
+ if (renderedLines.length > retainedLines.length) {
134
+ retainedLines.push(style(theme, "muted", WIDGET_TRUNCATION_MARKER));
135
+ }
136
+ return indent(retainedLines);
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ function createCoreWidgets(cwd: string): CoreWidgets {
143
+ const bashDefinition = createBashToolDefinition(cwd);
144
+ const readDefinition = createReadToolDefinition(cwd);
145
+ const writeDefinition = createWriteToolDefinition(cwd);
146
+ const grepDefinition = createGrepToolDefinition(cwd);
147
+ const findDefinition = createFindToolDefinition(cwd);
148
+ const lsDefinition = createLsToolDefinition(cwd);
149
+ const widgets: CoreWidgets = {
150
+ bash: (summary, options) =>
151
+ renderWith(bashDefinition, summary, options, {
152
+ startedAt: undefined,
153
+ endedAt: undefined,
154
+ interval: undefined,
155
+ }),
156
+ read: (summary, options) => renderWith(readDefinition, summary, options, {}),
157
+ write: (summary, options) => renderWith(writeDefinition, summary, options, {}),
158
+ grep: (summary, options) => renderWith(grepDefinition, summary, options, {}),
159
+ find: (summary, options) => renderWith(findDefinition, summary, options, {}),
160
+ ls: (summary, options) => renderWith(lsDefinition, summary, options, {}),
161
+ };
162
+ return widgets;
163
+ }
164
+
165
+ function coreWidgetsFor(cwd: string): CoreWidgets {
166
+ const cached = coreWidgetsByCwd.get(cwd);
167
+ if (cached !== undefined) return cached;
168
+ const widgets = createCoreWidgets(cwd);
169
+ coreWidgetsByCwd.set(cwd, widgets);
170
+ return widgets;
171
+ }
172
+
173
+ function isCoreWidgetName(name: string): name is CoreWidgetName {
174
+ switch (name) {
175
+ case "bash":
176
+ case "read":
177
+ case "write":
178
+ case "grep":
179
+ case "find":
180
+ case "ls":
181
+ return true;
182
+ default:
183
+ return false;
184
+ }
185
+ }
186
+
187
+ function compactArguments(args: unknown): string {
188
+ if (args === undefined) return "";
189
+ try {
190
+ const serialized = JSON.stringify(args);
191
+ if (serialized === undefined) return "";
192
+ return codePointPrefix(sanitizeTerminalLabel(serialized), MAX_FALLBACK_ARGUMENT_CODE_POINTS);
193
+ } catch {
194
+ return "[unserializable args]";
195
+ }
196
+ }
197
+
198
+ function renderFallback(summary: EvalToolCallSummary, width: number): string[] {
199
+ const name = sanitizeTerminalLabel(summary.name);
200
+ const fallback = `tool.${name}(${compactArguments(summary.args)})`;
201
+ return indent(renderAllVisualLines(fallback, width));
202
+ }
203
+
204
+ function renderStatus(summary: EvalToolCallSummary, options: WidgetOptions, width: number): string[] {
205
+ const icon = style(options.theme, summary.ok ? "success" : "error", summary.ok ? "✓" : "✗");
206
+ const parts = [icon];
207
+ if (summary.durationMs !== undefined) parts.push(formatDuration(summary.durationMs));
208
+ if (summary.argsTruncated) parts.push("(args truncated)");
209
+ return indent(renderFirstVisualLines(parts.join(" "), 1, width));
210
+ }
211
+
212
+ function sanitizeTerminalControl(text: string): string {
213
+ return text.replace(TERMINAL_ESCAPE_PATTERN, "").replace(TERMINAL_CONTROL_PATTERN, " ");
214
+ }
215
+
216
+ function renderPreview(preview: string, options: WidgetOptions, width: number): string[] {
217
+ const sanitized = sanitizeTerminalControl(preview);
218
+ if (sanitized.length === 0) return [];
219
+ return indent(renderFirstVisualLines(style(options.theme, "muted", sanitized), MAX_PREVIEW_LINES, width));
220
+ }
221
+
222
+ function renderCollapsedError(error: string, options: WidgetOptions, width: number): string[] {
223
+ const guardedError = codePointPrefix(error, MAX_COLLAPSED_ERROR_CODE_POINTS);
224
+ const guardedLines = renderAllVisualLines(style(options.theme, "error", guardedError), width);
225
+ if (guardedError.length === error.length && guardedLines.length <= MAX_COLLAPSED_ERROR_LINES) {
226
+ return indent(guardedLines);
227
+ }
228
+
229
+ const markerLines = renderAllVisualLines(style(options.theme, "muted", TOOL_ERROR_OMISSION_MARKER), width);
230
+ const errorBudget = Math.max(0, MAX_COLLAPSED_ERROR_LINES - markerLines.length);
231
+ const lines = guardedLines.slice(0, errorBudget);
232
+ lines.push(...markerLines.slice(0, MAX_COLLAPSED_ERROR_LINES - lines.length));
233
+ return indent(lines);
234
+ }
235
+
236
+ function renderError(error: string, options: WidgetOptions, width: number): string[] {
237
+ const sanitized = sanitizeTerminalControl(error);
238
+ if (options.expanded) return indent(renderAllVisualLines(style(options.theme, "error", sanitized), width));
239
+ return renderCollapsedError(sanitized, options, width);
240
+ }
241
+
242
+ /**
243
+ * The theme-less fallback is QA-only. Production's gate lives in render.ts:
244
+ * nestedToolCallBlock routes theme-less renders to legacy rows before this function is called.
245
+ */
246
+ export function renderToolCallWidget(summary: EvalToolCallSummary, options: WidgetOptions): string[] {
247
+ const innerWidth = Math.max(1, options.width - 2);
248
+ const coreLines = isCoreWidgetName(summary.name)
249
+ ? coreWidgetsFor(options.cwd)[summary.name](summary, options)
250
+ : undefined;
251
+ const lines = coreLines ?? renderFallback(summary, innerWidth);
252
+ lines.push(...renderStatus(summary, options, innerWidth));
253
+ if (summary.ok && summary.resultPreview !== undefined) {
254
+ lines.push(...renderPreview(summary.resultPreview, options, innerWidth));
255
+ } else if (!summary.ok && summary.error !== undefined) {
256
+ lines.push(...renderError(summary.error, options, innerWidth));
257
+ }
258
+ return options.expanded ? lines : lines.slice(0, MAX_COLLAPSED_WIDGET_LINES);
259
+ }
package/src/tool/types.ts CHANGED
@@ -117,6 +117,11 @@ export interface EvalToolCallSummary {
117
117
  readonly name: string;
118
118
  readonly ok: boolean;
119
119
  readonly error?: string;
120
+ readonly callId?: string;
121
+ readonly args?: unknown;
122
+ readonly argsTruncated?: boolean;
123
+ readonly durationMs?: number;
124
+ readonly resultPreview?: string;
120
125
  }
121
126
 
122
127
  export type EvalStatusEvent = { readonly op: string } & Readonly<Record<string, unknown>>;