@code-yeongyu/senpi-codemode 2026.10.10-10 → 2026.10.10-12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/package.json +4 -4
  3. package/src/bridge/memory-protocol.ts +8 -1
  4. package/src/bridge/protocol.ts +1 -1
  5. package/src/bridges/eval-environment-schemas.ts +5 -1
  6. package/src/environments/editable-check.ts +1 -1
  7. package/src/extension/session-cwd.ts +25 -9
  8. package/src/extension/session-manager.ts +4 -4
  9. package/src/kernels/jl/runner.jl +11 -22
  10. package/src/kernels/js/group-signal-notice.js +117 -0
  11. package/src/kernels/js/inline-worker.ts +2 -0
  12. package/src/kernels/js/local-module-loader.ts +8 -0
  13. package/src/kernels/js/process-tree-host.ts +4 -8
  14. package/src/kernels/js/process-tree.d.ts +11 -0
  15. package/src/kernels/js/process-tree.js +55 -10
  16. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  17. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  18. package/src/kernels/js/windows-tree-kill.js +100 -0
  19. package/src/kernels/js/worker-cwd.js +41 -10
  20. package/src/kernels/js/worker-runtime.js +23 -4
  21. package/src/kernels/js/worker-shell-capture.js +4 -0
  22. package/src/kernels/js/worker-slot.ts +2 -3
  23. package/src/kernels/py/prelude.py +36 -25
  24. package/src/kernels/py/transport.ts +4 -0
  25. package/src/kernels/rb/runner.rb +9 -19
  26. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  27. package/src/kernels/shared/bun-ffi.ts +24 -0
  28. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  29. package/src/kernels/shared/process-cpu.ts +7 -3
  30. package/src/kernels/shared/process-group-cpu.ts +6 -3
  31. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  32. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  33. package/src/kernels/shared/subprocess-process.ts +20 -12
  34. package/src/kernels/shared/subprocess-queue.ts +1 -0
  35. package/src/tool/eval-tool.ts +2 -1
  36. package/src/tool/render-agent.ts +110 -0
  37. package/src/tool/render-blocks.ts +252 -0
  38. package/src/tool/render-call.ts +130 -0
  39. package/src/tool/render-cell.ts +165 -0
  40. package/src/tool/render-detail.ts +152 -0
  41. package/src/tool/render-live.ts +288 -0
  42. package/src/tool/render-result.ts +202 -0
  43. package/src/tool/render-status.ts +166 -0
  44. package/src/tool/render-tail.ts +139 -0
  45. package/src/tool/render.ts +3 -1128
  46. package/src/tool/run-eval-cell.ts +7 -12
  47. package/src/tool/types.ts +11 -2
@@ -0,0 +1,152 @@
1
+ import { type AgentToolResult, sanitizeTerminalLabel, type Theme } from "@code-yeongyu/senpi";
2
+ import {
3
+ JSON_TREE_MAX_DEPTH_COLLAPSED,
4
+ JSON_TREE_MAX_DEPTH_EXPANDED,
5
+ JSON_TREE_MAX_LINES_COLLAPSED,
6
+ JSON_TREE_MAX_LINES_EXPANDED,
7
+ JSON_TREE_SCALAR_LEN_COLLAPSED,
8
+ JSON_TREE_SCALAR_LEN_EXPANDED,
9
+ renderJsonTreeLines,
10
+ } from "./json-tree.ts";
11
+ import {
12
+ appendLines,
13
+ isEvalRunInput,
14
+ type RenderBlock,
15
+ type RenderEnvironment,
16
+ renderAllVisualLines,
17
+ renderToolCall,
18
+ style,
19
+ TOOL_CALL_PREVIEW_COUNT,
20
+ } from "./render-blocks.ts";
21
+ import { renderCell } from "./render-cell.ts";
22
+ import { renderToolCallWidget } from "./tool-widgets.ts";
23
+ import type { EvalResultDetails, EvalToolDetails, EvalToolRequest } from "./types.ts";
24
+
25
+ type ToolCallRow = {
26
+ readonly summary: string;
27
+ readonly error?: string;
28
+ readonly color: "success" | "error";
29
+ };
30
+ type DetailedRenderContext = {
31
+ readonly environment: RenderEnvironment;
32
+ readonly args: EvalToolRequest;
33
+ readonly showImageFallback: boolean;
34
+ readonly isFinal: boolean;
35
+ };
36
+
37
+ export function renderJsonOutputs(values: readonly unknown[], environment: RenderEnvironment): string[] {
38
+ const lines: string[] = [];
39
+ const depth = environment.expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
40
+ const lineCap = environment.expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
41
+ const scalarLen = environment.expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
42
+ for (const [index, value] of values.entries()) {
43
+ appendLines(
44
+ lines,
45
+ renderAllVisualLines(style(environment.theme, "dim", `display[${index + 1}]`), environment.width),
46
+ );
47
+ const tree = renderJsonTreeLines(value, environment.theme, depth, lineCap, scalarLen);
48
+ for (const line of tree.lines) appendLines(lines, renderAllVisualLines(line, environment.width));
49
+ if (tree.truncated)
50
+ appendLines(lines, renderAllVisualLines(style(environment.theme, "dim", "…"), environment.width));
51
+ }
52
+ return lines;
53
+ }
54
+
55
+ export function renderDetailedLines(
56
+ details: EvalToolDetails,
57
+ result: AgentToolResult<EvalResultDetails>,
58
+ context: DetailedRenderContext,
59
+ ): string[] {
60
+ const lines: string[] = [];
61
+ const cells = details.cells ?? [];
62
+ for (const [index, cell] of cells.entries()) {
63
+ const run = isEvalRunInput(context.args) ? context.args : undefined;
64
+ const throughput =
65
+ context.isFinal &&
66
+ cells.length === 1 &&
67
+ cell.status === "complete" &&
68
+ typeof details.toolCallCount === "number"
69
+ ? { calls: details.toolCallCount, wallDurationMs: details.wallDurationMs }
70
+ : undefined;
71
+ const badges = {
72
+ reset: index === 0 && run?.reset === true,
73
+ timeout: index === 0 ? run?.timeout : undefined,
74
+ throughput,
75
+ };
76
+ appendLines(lines, renderCell(cell, context.environment, badges));
77
+ if (index < cells.length - 1) lines.push("");
78
+ }
79
+ const jsonOutputs = details.jsonOutputs ?? [];
80
+ if (jsonOutputs.length > 0) {
81
+ if (lines.length > 0) lines.push("");
82
+ appendLines(lines, renderJsonOutputs(jsonOutputs, context.environment));
83
+ }
84
+ if (context.showImageFallback) {
85
+ for (const part of result.content) {
86
+ if (part.type !== "image") continue;
87
+ if (lines.length > 0) lines.push("");
88
+ appendLines(
89
+ lines,
90
+ renderAllVisualLines(`[image: ${sanitizeTerminalLabel(part.mimeType)}]`, context.environment.width),
91
+ );
92
+ }
93
+ }
94
+ if (details.phase !== undefined)
95
+ appendLines(
96
+ lines,
97
+ renderAllVisualLines(
98
+ style(context.environment.theme, "muted", `phase ${details.phase}`),
99
+ context.environment.width,
100
+ ),
101
+ );
102
+ return lines;
103
+ }
104
+
105
+ export function toolCallRows(details: EvalToolDetails | undefined): ToolCallRow[] {
106
+ if (!details?.toolCalls || details.toolCalls.length === 0) return [];
107
+ return details.toolCalls.map((call) => {
108
+ const status = call.ok ? "ok" : "error";
109
+ const row = { summary: `- tool.${call.name}: ${status}`, color: call.ok ? "success" : "error" } as const;
110
+ return call.error === undefined ? row : { ...row, error: call.error };
111
+ });
112
+ }
113
+
114
+ export function nestedToolCallBlock(
115
+ details: EvalToolDetails | undefined,
116
+ theme: Theme | undefined,
117
+ cwd: string,
118
+ expanded: boolean,
119
+ ): RenderBlock | undefined {
120
+ const toolCalls = details?.toolCalls;
121
+ if (theme === undefined || toolCalls === undefined || !toolCalls.some((call) => call.args !== undefined)) {
122
+ return undefined;
123
+ }
124
+ const legacyRows = toolCallRows(details);
125
+ return {
126
+ kind: "dynamic",
127
+ render: (width) => {
128
+ const retainedCalls = expanded ? toolCalls : toolCalls.slice(-TOOL_CALL_PREVIEW_COUNT);
129
+ const retainedRows = expanded ? legacyRows : legacyRows.slice(-TOOL_CALL_PREVIEW_COUNT);
130
+ const skippedCount = toolCalls.length - retainedCalls.length;
131
+ const toolCallNoun = skippedCount === 1 ? "call" : "calls";
132
+ const lines =
133
+ expanded || skippedCount === 0
134
+ ? []
135
+ : renderAllVisualLines(style(theme, "muted", `${skippedCount} earlier tool ${toolCallNoun}`), width);
136
+ const legacyBlock: Extract<RenderBlock, { kind: "toolCalls" }> = {
137
+ kind: "toolCalls",
138
+ calls: retainedRows,
139
+ expanded,
140
+ theme,
141
+ };
142
+ for (const [index, call] of retainedCalls.entries()) {
143
+ if (call.args !== undefined) {
144
+ appendLines(lines, renderToolCallWidget(call, { cwd, theme, expanded, width }));
145
+ } else {
146
+ appendLines(lines, renderToolCall(retainedRows[index], legacyBlock, width));
147
+ }
148
+ }
149
+ return lines;
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,288 @@
1
+ import { sanitizeTerminalLabel, truncateToVisualLines, visibleWidth } from "@code-yeongyu/senpi";
2
+ import { highlightedCode } from "./code-preview.ts";
3
+ import { leadsWithHeadline, liveHeadline } from "./live-headline.ts";
4
+ import {
5
+ appendLines,
6
+ assertNever,
7
+ LIVE_RENDER_TICK_MS,
8
+ type PrefixStyle,
9
+ type RenderEnvironment,
10
+ renderPrefixed,
11
+ type StatusPresentation,
12
+ spinner,
13
+ style,
14
+ } from "./render-blocks.ts";
15
+ import { formatThroughputBadge } from "./render-status.ts";
16
+ import { cellOutputSection, cellStatusSection } from "./render-tail.ts";
17
+ import { formatRuntimeBadge } from "./runtime-label.ts";
18
+ import { formatDuration } from "./tool-widgets.ts";
19
+ import type { EvalCellResult, EvalToolDetails } from "./types.ts";
20
+
21
+ type CellStatus = EvalCellResult["status"];
22
+ export type CellThroughput = { readonly calls: number; readonly wallDurationMs: number | undefined };
23
+ export type CellBadges = {
24
+ readonly reset: boolean;
25
+ readonly timeout: number | undefined;
26
+ readonly throughput: CellThroughput | undefined;
27
+ /** The call lane still streaming its arguments: the header names the state instead of "running". */
28
+ readonly streaming?: boolean;
29
+ };
30
+
31
+ export const LIVE_LINE_PREFIX: PrefixStyle = { prefix: "╶─ ", continuation: " ", color: "borderAccent" };
32
+ const FRAME_HEADER_PREFIX: PrefixStyle = { prefix: "╭─ ", continuation: "│ ", color: "borderAccent" };
33
+ const FRAME_INNER_PREFIX: PrefixStyle = { prefix: "│ ", continuation: "│ ", color: "borderMuted" };
34
+ const FRAME_SECTION_PREFIX: PrefixStyle = { prefix: "├─ ", continuation: "│ ", color: "dim" };
35
+
36
+ export const LIVE_CODE_WINDOW_LINES = 6;
37
+ // The live block's total height never changes: header + 6 body rows + border. When output or
38
+ // status events exist, they take a fixed tail section and the code window shrinks inside the
39
+ // same total (review MEDIUM-2): 6 code rows alone, or 3 code + 3 tail rows.
40
+ const LIVE_BODY_ROWS = 6;
41
+ const LIVE_TAIL_ROWS = 3;
42
+
43
+ export function isLiveCellStatus(status: CellStatus): boolean {
44
+ return status === "pending" || status === "running";
45
+ }
46
+
47
+ export function hasLiveCell(details: EvalToolDetails | undefined): boolean {
48
+ return (details?.cells ?? []).some((cell) => isLiveCellStatus(cell.status) && cell.startedAt !== undefined);
49
+ }
50
+
51
+ export function cellPresentation(status: CellStatus, spinnerFrame: number | undefined): StatusPresentation {
52
+ switch (status) {
53
+ case "pending":
54
+ return { label: "pending", icon: "○", color: "muted" };
55
+ case "queued":
56
+ return { label: "queued", icon: "○", color: "muted" };
57
+ case "running":
58
+ return { label: "running", icon: spinner(spinnerFrame), color: "warning" };
59
+ case "detached":
60
+ return { label: "detached", icon: "↗", color: "warning" };
61
+ case "complete":
62
+ return { label: "done", icon: "✓", color: "success" };
63
+ case "error":
64
+ return { label: "error", icon: "✗", color: "error" };
65
+ case "cancelled":
66
+ return { label: "cancelled", icon: "×", color: "error" };
67
+ default:
68
+ return assertNever(status);
69
+ }
70
+ }
71
+
72
+ // A running cell only receives updates on output/status events, so a stored duration
73
+ // freezes between them. Non-terminal cells therefore derive elapsed time from the
74
+ // render-time clock; terminal cells keep their settled duration verbatim.
75
+ export function cellElapsedMs(cell: EvalCellResult, environment: RenderEnvironment): number | undefined {
76
+ if (!isLiveCellStatus(cell.status) || cell.startedAt === undefined) return cell.durationMs;
77
+ return Math.max(0, environment.now - cell.startedAt);
78
+ }
79
+
80
+ // An in-progress row leads with what the cell is doing (senpi#2802). Collapsed, the whole row
81
+ // (icon, headline and every badge) always fits one visual line (senpi#2933 review HIGH-1).
82
+ // Drop order: reset/timeout, then queued-behind/throughput, then the runtime badge; the
83
+ // headline is cut to its floor (never emptied) before elapsed is dropped, and only the final
84
+ // pass may drop the base label itself (review MEDIUM-2/3).
85
+ export function headlined(
86
+ icon: string,
87
+ summary: string | undefined,
88
+ code: string | undefined,
89
+ rest: string,
90
+ environment: RenderEnvironment,
91
+ options: { readonly badgelessRest?: string; readonly protectedTail?: string } = {},
92
+ ): string {
93
+ if (environment.expanded)
94
+ return `${icon} ${liveHeadline(summary, code, undefined)} · ${rest}${
95
+ options.protectedTail === undefined ? "" : ` · ${options.protectedTail}`
96
+ }`;
97
+ const headline = liveHeadline(summary, code, undefined);
98
+ const width = Math.max(1, environment.width - 3);
99
+ // When a badge rides the base label and the full rest cannot fit on one line, the badge
100
+ // drops before anything else is cut, and the badge-less form is fitted instead (review
101
+ // MEDIUM-1).
102
+ const fullTail = options.protectedTail === undefined ? rest : `${rest} · ${options.protectedTail}`;
103
+ if (
104
+ options.badgelessRest !== undefined &&
105
+ options.badgelessRest !== rest &&
106
+ visibleWidth(`${icon} ${headline} · ${fullTail}`) > width
107
+ )
108
+ return fitOneLine(icon, headline, options.badgelessRest, width, options.protectedTail);
109
+ return fitOneLine(icon, headline, rest, width, options.protectedTail);
110
+ }
111
+
112
+ const HEADLINE_FLOOR_CELLS = 12;
113
+
114
+ function fitOneLine(icon: string, headline: string, rest: string, width: number, protectedTail?: string): string {
115
+ const lead = `${icon} `;
116
+ const middle = " · ";
117
+ const tail = protectedTail === undefined ? "" : `${middle}${protectedTail}`;
118
+ const full = `${lead}${headline}${middle}${rest}${tail}`;
119
+ if (visibleWidth(full) <= width) return full;
120
+ // 1) Drop the lowest-priority tail segments (reset/timeout are last in rest), keeping
121
+ // elapsed appended, until one fits with the full headline.
122
+ let keptRest = rest;
123
+ for (;;) {
124
+ const cut = keptRest.lastIndexOf(middle);
125
+ if (cut <= 0) break;
126
+ const candidate = keptRest.slice(0, cut);
127
+ if (visibleWidth(`${lead}${headline}${middle}${candidate}${tail}`) <= width)
128
+ return `${lead}${headline}${middle}${candidate}${tail}`;
129
+ keptRest = candidate;
130
+ }
131
+ // 2) Cut the headline to its floor before dropping elapsed (review MEDIUM-3).
132
+ const floorBudget = Math.max(4, width - visibleWidth(`${lead}${middle}${keptRest}${tail}`) - 1);
133
+ const floor = Math.max(HEADLINE_FLOOR_CELLS, floorBudget);
134
+ let cutHeadline = headline;
135
+ if (visibleWidth(headline) > floor) {
136
+ const candidate = `${cellPrefixText(headline, floor - 1)}…`;
137
+ if (visibleWidth(`${lead}${candidate}${middle}${keptRest}${tail}`) <= width) cutHeadline = candidate;
138
+ }
139
+ // Step 1 already left only the base label in keptRest, so nothing else can drop before elapsed.
140
+ const finalRest = keptRest;
141
+ if (visibleWidth(`${lead}${cutHeadline}${middle}${finalRest}${tail}`) <= width)
142
+ return `${lead}${cutHeadline}${middle}${finalRest}${tail}`;
143
+ // 3) Drop elapsed, then cut the headline to whatever the base label leaves (review MEDIUM-3).
144
+ if (visibleWidth(`${lead}${cutHeadline}${middle}${finalRest}`) <= width)
145
+ return `${lead}${cutHeadline}${middle}${finalRest}`;
146
+ const budget = Math.max(4, width - visibleWidth(`${lead}${middle}${finalRest}`) - 1);
147
+ const shortened = visibleWidth(cutHeadline) <= budget ? cutHeadline : `${cellPrefixText(cutHeadline, budget - 1)}…`;
148
+ return `${lead}${shortened}${middle}${finalRest}`;
149
+ }
150
+
151
+ function cellPrefixText(text: string, cells: number): string {
152
+ let kept = "";
153
+ let used = 0;
154
+ for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
155
+ const width = visibleWidth(segment);
156
+ if (used + width > cells) break;
157
+ kept += segment;
158
+ used += width;
159
+ }
160
+ return kept;
161
+ }
162
+
163
+ export function cellHeader(cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges): string {
164
+ const spinnerFrame =
165
+ environment.spinnerFrame ?? Math.floor((cellElapsedMs(cell, environment) ?? 0) / LIVE_RENDER_TICK_MS);
166
+ const presentation =
167
+ badges.streaming === true
168
+ ? { label: "streaming", icon: spinner(spinnerFrame), color: "warning" as const }
169
+ : cellPresentation(cell.status, spinnerFrame);
170
+ const runtimeBadge = cell.runtime === undefined ? "" : ` (${formatRuntimeBadge(cell.language, cell.runtime)})`;
171
+ const base = leadsWithHeadline(cell.status)
172
+ ? `eval ${cell.language} ${presentation.label}`
173
+ : `eval ${cell.language} ${presentation.label} ${presentation.icon}`;
174
+ const segments: string[] = [];
175
+ // The runtime badge is droppable like any badge (review MEDIUM-2): it sits after
176
+ // queued-behind/throughput so those drop first. Elapsed is passed separately as the
177
+ // protected tail, so it is dropped only after the headline is cut (review MEDIUM-3).
178
+ if (cell.queuedBehind !== undefined && cell.queuedBehind.length > 0)
179
+ segments.push(`queued behind ${cell.queuedBehind.map(sanitizeTerminalLabel).join(", ")}`);
180
+ else if (cell.queuedBehind !== undefined && cell.status === "queued")
181
+ segments.push(`waiting for the ${cell.language} kernel to be ready`);
182
+ const throughputBadge = badges.throughput === undefined ? undefined : formatThroughputBadge(badges.throughput);
183
+ if (throughputBadge !== undefined) segments.push(throughputBadge);
184
+ if (runtimeBadge !== "") segments.push(runtimeBadge.trimStart());
185
+ if (badges.reset) segments.push("reset");
186
+ if (badges.timeout !== undefined) segments.push(`timeout ${badges.timeout}s`);
187
+ const elapsedMs = badges.throughput?.wallDurationMs ?? cellElapsedMs(cell, environment);
188
+ const protectedTail = elapsedMs === undefined ? undefined : formatDuration(elapsedMs);
189
+ const header = leadsWithHeadline(cell.status)
190
+ ? headlined(presentation.icon, cell.summary, cell.code, joinSegments(base, segments), environment, {
191
+ protectedTail,
192
+ })
193
+ : joinSegments(base, protectedTail === undefined ? segments : [...segments, protectedTail]);
194
+ return style(environment.theme, presentation.color, header);
195
+ }
196
+
197
+ function joinSegments(base: string, segments: readonly string[]): string {
198
+ return segments.length === 0 ? base : `${base} · ${segments.join(" · ")}`;
199
+ }
200
+
201
+ // A live row is a framed block of constant total height: header + LIVE_BODY_ROWS body rows +
202
+ // border, whether or not output or status events have arrived. New code lines scroll the code
203
+ // window upward inside its share; the hidden prefix folds into one "N earlier code lines" row
204
+ // counted inside the share, so the block never grows the transcript.
205
+ export function renderLiveCellFrame(
206
+ cell: EvalCellResult,
207
+ environment: RenderEnvironment,
208
+ badges: CellBadges,
209
+ ): string[] {
210
+ const lines = renderPrefixed(cellHeader(cell, environment, badges), environment, FRAME_HEADER_PREFIX);
211
+ const hasOutput = cell.output.trimEnd().length > 0;
212
+ const hasStatus = (cell.statusEvents ?? []).some((event) => event.op !== "agent");
213
+ // The tail always keeps its full row count (review HIGH-C): a short section is padded, and
214
+ // each section fits itself inside the budget in visual rows with exact omission markers.
215
+ const tail: string[] = [];
216
+ if (hasOutput) appendLines(tail, cellOutputSection(cell, environment, LIVE_TAIL_ROWS, LIVE_TAIL_ROWS));
217
+ else if (hasStatus) appendLines(tail, cellStatusSection(cell, environment, LIVE_TAIL_ROWS));
218
+ const codeRows = tail.length === 0 ? LIVE_BODY_ROWS : LIVE_BODY_ROWS - LIVE_TAIL_ROWS;
219
+ appendLines(lines, liveCodeWindow(cell, environment, codeRows));
220
+ appendLines(lines, tail);
221
+ lines.push(style(environment.theme, "borderMuted", "╰─"));
222
+ return lines;
223
+ }
224
+
225
+ function liveCodeWindow(cell: EvalCellResult, environment: RenderEnvironment, windowRows: number): string[] {
226
+ const innerWidth = Math.max(1, environment.width - 2);
227
+ // Streamed code is not yet trusted input: a hostile or half-arrived chunk can carry escape and
228
+ // control characters, and the collapsed row must stay inert in the terminal (senpi#2839).
229
+ const code = highlightedCode(sanitizeCellCode(cell.code), cell.language, environment.theme, environment.repaint);
230
+ // Fill from the bottom by VISUAL rows so the newest source line is always fully visible
231
+ // (review HIGH-B); the marker counts hidden SOURCE lines. When the source overflows, the
232
+ // marker takes one of the window's rows.
233
+ const allRows = visualLines(code, innerWidth);
234
+ const totalRows = allRows.length;
235
+ const fits = totalRows <= windowRows;
236
+ const budget = fits ? windowRows : windowRows - 1;
237
+ const kept = allRows.slice(Math.max(0, totalRows - budget));
238
+ const skippedVisual = totalRows - kept.length;
239
+ const hiddenSourceLines = countHiddenSourceLines(code, innerWidth, skippedVisual);
240
+ const windowLines: string[] = [];
241
+ if (!fits)
242
+ appendLines(
243
+ windowLines,
244
+ renderPrefixed(`${hiddenSourceLines} earlier code lines`, environment, {
245
+ prefix: "│ ",
246
+ continuation: "│ ",
247
+ color: "muted",
248
+ }),
249
+ );
250
+ for (const line of kept) appendLines(windowLines, renderPrefixed(line, environment, FRAME_INNER_PREFIX));
251
+ while (windowLines.length < windowRows) windowLines.push(style(environment.theme, "borderMuted", "│ "));
252
+ return windowLines;
253
+ }
254
+
255
+ // The source lines whose first visual row lies above the first shown row (review HIGH-B):
256
+ // walk the code's own lines, wrap each, and count a line as hidden as soon as the cut reaches
257
+ // its first row — a line the cut slices through is hidden even though its tail still shows.
258
+ function countHiddenSourceLines(code: string, innerWidth: number, skippedVisual: number): number {
259
+ if (skippedVisual <= 0) return 0;
260
+ let consumed = 0;
261
+ let hidden = 0;
262
+ for (const sourceLine of code.split("\n")) {
263
+ if (consumed >= skippedVisual) break;
264
+ hidden += 1;
265
+ consumed += Math.max(1, visualLines(sourceLine, innerWidth).length);
266
+ }
267
+ return hidden;
268
+ }
269
+
270
+ const TERMINAL_ESCAPE_SEQUENCE =
271
+ /(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
272
+ const TERMINAL_CONTROL_RUN = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]+/g;
273
+
274
+ // The window strips escape sequences and control characters (senpi#2839) but never collapses
275
+ // whitespace: indentation and inner spacing carry meaning in every language (review HIGH-A),
276
+ // and tabs expand to two spaces.
277
+ function visualLines(text: string, width: number): string[] {
278
+ return truncateToVisualLines(text, Number.POSITIVE_INFINITY, width).visualLines.map((line) => line.trimEnd());
279
+ }
280
+
281
+ function sanitizeCellCode(code: string): string {
282
+ return code
283
+ .split("\n")
284
+ .map((line) => line.replace(TERMINAL_ESCAPE_SEQUENCE, "").replace(TERMINAL_CONTROL_RUN, "").replace(/\t/g, " "))
285
+ .join("\n");
286
+ }
287
+
288
+ export { FRAME_HEADER_PREFIX, FRAME_INNER_PREFIX, FRAME_SECTION_PREFIX };
@@ -0,0 +1,202 @@
1
+ import {
2
+ type AgentToolResult,
3
+ sanitizeTerminalLabel,
4
+ type Theme,
5
+ type ToolRenderResultOptions,
6
+ } from "@code-yeongyu/senpi";
7
+ import { renderAgentProgressEvents } from "./render-agent.ts";
8
+ import {
9
+ componentFor,
10
+ type EvalRenderComponent,
11
+ OUTPUT_PREVIEW_LINES,
12
+ type RenderBlock,
13
+ type RenderEnvironment,
14
+ type ResultRenderContext,
15
+ renderNow,
16
+ style,
17
+ } from "./render-blocks.ts";
18
+ import { summaryBlock } from "./render-cell.ts";
19
+ import { nestedToolCallBlock, renderDetailedLines, renderJsonOutputs, toolCallRows } from "./render-detail.ts";
20
+ import { hasLiveCell } from "./render-live.ts";
21
+ import { formatThroughputBadge, renderStatusEvents } from "./render-status.ts";
22
+ import { formatRuntimeBadge } from "./runtime-label.ts";
23
+ import { formatDuration } from "./tool-widgets.ts";
24
+ import type { EvalResultDetails, EvalStatusEvent, EvalToolDetails } from "./types.ts";
25
+
26
+ function textOutput(result: AgentToolResult<EvalResultDetails>, showImageFallback: boolean): string {
27
+ const lines: string[] = [];
28
+ for (const part of result.content) {
29
+ if (part.type === "text" && part.audience !== "model") lines.push(part.text);
30
+ else if (showImageFallback && part.type === "image") {
31
+ lines.push(`[image: ${sanitizeTerminalLabel(part.mimeType)}]`);
32
+ }
33
+ }
34
+ return lines.join("\n");
35
+ }
36
+
37
+ function resultStatus(
38
+ details: EvalToolDetails | undefined,
39
+ options: ToolRenderResultOptions,
40
+ hostIsError: boolean,
41
+ ): "running" | "done" | "error" {
42
+ if (details?.isError || hostIsError) return "error";
43
+ return options.isPartial ? "running" : "done";
44
+ }
45
+
46
+ function resultHeader(
47
+ details: EvalToolDetails | undefined,
48
+ status: "running" | "done" | "error",
49
+ theme: Theme | undefined,
50
+ ): string {
51
+ let color: "warning" | "success" | "error";
52
+ switch (status) {
53
+ case "running":
54
+ color = "warning";
55
+ break;
56
+ case "done":
57
+ color = "success";
58
+ break;
59
+ case "error":
60
+ color = "error";
61
+ break;
62
+ }
63
+ const runtimeBadge =
64
+ details?.runtime === undefined ? "" : ` (${formatRuntimeBadge(details.language, details.runtime)})`;
65
+ return style(theme, color, `eval ${details?.language ?? "?"}${runtimeBadge} ${status}`);
66
+ }
67
+
68
+ function resultMetadata(
69
+ details: EvalToolDetails | undefined,
70
+ options: ToolRenderResultOptions,
71
+ theme: Theme | undefined,
72
+ status: "running" | "done" | "error",
73
+ ): RenderBlock[] {
74
+ const metadata: string[] = [];
75
+ if (details?.phase) metadata.push(`phase ${details.phase}`);
76
+ const elapsedMs = details?.wallDurationMs ?? details?.durationMs;
77
+ if (!options.isPartial && typeof elapsedMs === "number") metadata.push(`took ${formatDuration(elapsedMs)}`);
78
+ if (status === "done" && typeof details?.toolCallCount === "number") {
79
+ const badge = formatThroughputBadge({ calls: details.toolCallCount, wallDurationMs: details.wallDurationMs });
80
+ if (badge !== undefined) metadata.push(badge);
81
+ }
82
+ if (metadata.length === 0) return [];
83
+ return [{ kind: "text", text: style(theme, "muted", metadata.join(" | ")) }];
84
+ }
85
+
86
+ export function renderEvalResult(
87
+ result: AgentToolResult<EvalResultDetails>,
88
+ options: ToolRenderResultOptions,
89
+ theme: Theme | undefined,
90
+ context: ResultRenderContext,
91
+ ): EvalRenderComponent {
92
+ const component = componentFor(context);
93
+ const details = result.details;
94
+ if (details && "action" in details) {
95
+ component.syncLiveTicker(false, context.invalidate);
96
+ component.setBlocks([
97
+ { kind: "text", text: style(theme, "toolTitle", "eval list") },
98
+ { kind: "text", text: style(theme, "toolOutput", textOutput(result, false)) },
99
+ ]);
100
+ return component;
101
+ }
102
+ const expanded = options.expanded || context.expanded;
103
+ const imageProtocol = context.imageProtocol ?? null;
104
+ component.syncLiveTicker(hasLiveCell(details), context.invalidate);
105
+ if (details?.cells !== undefined && details.cells.length > 0) {
106
+ const blocks: RenderBlock[] = [
107
+ {
108
+ kind: "dynamic",
109
+ render: (width) =>
110
+ renderDetailedLines(details, result, {
111
+ environment: {
112
+ expanded,
113
+ theme,
114
+ spinnerFrame: context.spinnerFrame,
115
+ width,
116
+ meta: details.meta,
117
+ now: renderNow(context),
118
+ repaint: context.invalidate,
119
+ },
120
+ args: context.args,
121
+ showImageFallback: context.showImages && imageProtocol === null,
122
+ isFinal: !options.isPartial,
123
+ }),
124
+ },
125
+ ];
126
+ const calls = toolCallRows(details);
127
+ const nestedCalls = nestedToolCallBlock(details, theme, context.cwd, expanded);
128
+ if (calls.length > 0)
129
+ blocks.push({ kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme });
130
+ component.setBlocks(blocks);
131
+ return component;
132
+ }
133
+ const status = resultStatus(details, options, context.isError);
134
+ const blocks: RenderBlock[] = [
135
+ { kind: "text", text: resultHeader(details, status, theme) },
136
+ ...(details?.summary === undefined ? [] : [summaryBlock(details.summary, theme, expanded)]),
137
+ ...resultMetadata(details, options, theme, status),
138
+ { kind: "blank" },
139
+ ];
140
+ const rawOutput = textOutput(result, context.showImages && imageProtocol === null);
141
+ const output = rawOutput.trimEnd();
142
+ const hasRenderedImage =
143
+ context.showImages &&
144
+ imageProtocol !== null &&
145
+ result.content.some((part: { type: string }) => part.type === "image");
146
+ if (output.length > 0) {
147
+ blocks.push({
148
+ kind: "text",
149
+ text: style(theme, "toolOutput", output),
150
+ maxVisualLines: expanded ? undefined : OUTPUT_PREVIEW_LINES,
151
+ collapseKind: "output",
152
+ theme,
153
+ });
154
+ } else if (!hasRenderedImage) blocks.push({ kind: "text", text: style(theme, "muted", "(no output)") });
155
+ const statusEvents = details?.statusEvents ?? [];
156
+ const nonAgentEvents = statusEvents.filter((event: EvalStatusEvent) => event.op !== "agent");
157
+ const agentEvents = statusEvents.filter((event: EvalStatusEvent) => event.op === "agent");
158
+ if (nonAgentEvents.length > 0 || agentEvents.length > 0) {
159
+ blocks.push(
160
+ { kind: "blank" },
161
+ {
162
+ kind: "dynamic",
163
+ render: (width) => {
164
+ const environment: RenderEnvironment = {
165
+ expanded,
166
+ theme,
167
+ spinnerFrame: context.spinnerFrame,
168
+ width,
169
+ meta: details?.meta,
170
+ now: renderNow(context),
171
+ };
172
+ return [
173
+ ...renderStatusEvents(nonAgentEvents, environment),
174
+ ...renderAgentProgressEvents(agentEvents, environment),
175
+ ];
176
+ },
177
+ },
178
+ );
179
+ }
180
+ if ((details?.jsonOutputs?.length ?? 0) > 0) {
181
+ blocks.push(
182
+ { kind: "blank" },
183
+ {
184
+ kind: "dynamic",
185
+ render: (width) =>
186
+ renderJsonOutputs(details?.jsonOutputs ?? [], {
187
+ expanded,
188
+ theme,
189
+ spinnerFrame: context.spinnerFrame,
190
+ width,
191
+ meta: details?.meta,
192
+ now: renderNow(context),
193
+ }),
194
+ },
195
+ );
196
+ }
197
+ const calls = toolCallRows(details);
198
+ const nestedCalls = nestedToolCallBlock(details, theme, context.cwd, expanded);
199
+ if (calls.length > 0) blocks.push({ kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme });
200
+ component.setBlocks(blocks);
201
+ return component;
202
+ }