@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,252 @@
1
+ import { type Theme, type ThemeColor, type ToolDefinition, truncateToVisualLines } from "@code-yeongyu/senpi";
2
+ import type { TruncationMeta } from "../output/output-meta.ts";
3
+ import { codePointPrefix } from "./tool-widgets.ts";
4
+ import type { EvalInputSchema, EvalResultDetails, EvalToolInput, EvalToolRequest } from "./types.ts";
5
+
6
+ type EvalToolDefinition = ToolDefinition<EvalInputSchema, EvalResultDetails>;
7
+ export type CallRenderContext = Parameters<NonNullable<EvalToolDefinition["renderCall"]>>[2];
8
+ export type ResultRenderContext = Parameters<NonNullable<EvalToolDefinition["renderResult"]>>[3];
9
+ type CollapsibleKind = "code" | "output";
10
+
11
+ export interface EvalRenderComponent {
12
+ render(width: number): string[];
13
+ invalidate(): void;
14
+ }
15
+ type ToolCallRow = {
16
+ readonly summary: string;
17
+ readonly error?: string;
18
+ readonly color: "success" | "error";
19
+ };
20
+ export type RenderBlock =
21
+ | { readonly kind: "blank" }
22
+ | {
23
+ readonly kind: "text";
24
+ readonly text: string;
25
+ readonly maxVisualLines?: number;
26
+ readonly collapseKind?: CollapsibleKind;
27
+ readonly theme?: Theme;
28
+ }
29
+ | {
30
+ readonly kind: "toolCalls";
31
+ readonly calls: readonly ToolCallRow[];
32
+ readonly expanded: boolean;
33
+ readonly theme?: Theme;
34
+ }
35
+ | { readonly kind: "dynamic"; readonly render: (width: number) => readonly string[] };
36
+
37
+ export const CODE_PREVIEW_LINES = 4;
38
+ export const SUMMARY_PREVIEW_LINES = 3;
39
+ export const OUTPUT_PREVIEW_LINES = 8;
40
+ export const STATUS_PREVIEW_COUNT = 3;
41
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
42
+ export const TOOL_CALL_PREVIEW_COUNT = 5;
43
+ const TOOL_CALL_COLLAPSED_VISUAL_LINES = 4;
44
+ const TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS = 512;
45
+ const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]";
46
+ export const LIVE_RENDER_TICK_MS = 100;
47
+ // A live row repaints on every tick, so this many ticks without a render means the row is gone.
48
+ const LIVE_TICKER_MAX_IDLE_TICKS = 600;
49
+
50
+ export type StatusPresentation = { readonly label: string; readonly icon: string; readonly color: ThemeColor };
51
+ export type RenderEnvironment = {
52
+ readonly expanded: boolean;
53
+ readonly theme: Theme | undefined;
54
+ readonly spinnerFrame: number | undefined;
55
+ readonly width: number;
56
+ readonly meta: TruncationMeta | undefined;
57
+ /** Render-time clock, injected so elapsed time is deterministic under test. */
58
+ readonly now: number;
59
+ /** Repaints the row once a background-formatted code preview is ready; absent while args stream. */
60
+ readonly repaint?: () => void;
61
+ };
62
+ export type PrefixStyle = { readonly prefix: string; readonly continuation: string; readonly color: ThemeColor };
63
+
64
+ class PlainTextComponent implements EvalRenderComponent {
65
+ #blocks: readonly RenderBlock[] = [];
66
+ #ticker: ReturnType<typeof setInterval> | undefined;
67
+ #live = false;
68
+ #invalidate: (() => void) | undefined;
69
+ #idleTicks = 0;
70
+
71
+ setBlocks(blocks: readonly RenderBlock[]): void {
72
+ this.#blocks = blocks;
73
+ }
74
+
75
+ /**
76
+ * The host only animates tool rows for streaming args, `task`, and results carrying
77
+ * `details.progress`; an eval row matches none of them, so nothing repaints it between
78
+ * update events. While a cell is non-terminal this drives the repaint itself so the
79
+ * header's spinner and elapsed time advance, with no tool updates or RPC traffic. Detached
80
+ * and terminal cards never arm it, and a ticker whose row stopped rendering (transcript
81
+ * rebuild, session switch) stops itself after LIVE_TICKER_MAX_IDLE_TICKS and rearms on
82
+ * the next render, so dropped rows cannot accumulate intervals.
83
+ */
84
+ syncLiveTicker(isLive: boolean, invalidate: () => void): void {
85
+ this.#live = isLive;
86
+ this.#invalidate = invalidate;
87
+ if (!isLive) {
88
+ this.stopLiveTicker();
89
+ return;
90
+ }
91
+ this.#armTicker();
92
+ }
93
+
94
+ stopLiveTicker(): void {
95
+ if (this.#ticker === undefined) return;
96
+ clearInterval(this.#ticker);
97
+ this.#ticker = undefined;
98
+ }
99
+
100
+ #armTicker(): void {
101
+ if (this.#ticker !== undefined || this.#invalidate === undefined) return;
102
+ this.#ticker = setInterval(() => this.#tick(), LIVE_RENDER_TICK_MS);
103
+ this.#ticker.unref?.();
104
+ }
105
+
106
+ #tick(): void {
107
+ this.#idleTicks += 1;
108
+ if (this.#idleTicks >= LIVE_TICKER_MAX_IDLE_TICKS) {
109
+ this.stopLiveTicker();
110
+ return;
111
+ }
112
+ this.#invalidate?.();
113
+ }
114
+
115
+ render(width: number): string[] {
116
+ this.#idleTicks = 0;
117
+ if (this.#live) this.#armTicker();
118
+ const lines: string[] = [];
119
+ for (const block of this.#blocks) {
120
+ switch (block.kind) {
121
+ case "blank":
122
+ lines.push("");
123
+ break;
124
+ case "toolCalls":
125
+ appendLines(lines, renderToolCallBlock(block, width));
126
+ break;
127
+ case "text":
128
+ appendLines(lines, renderTextBlock(block, width));
129
+ break;
130
+ case "dynamic":
131
+ appendLines(lines, block.render(width));
132
+ break;
133
+ default:
134
+ assertNever(block);
135
+ }
136
+ }
137
+ return lines;
138
+ }
139
+
140
+ invalidate(): void {}
141
+ }
142
+
143
+ export function componentFor(context: CallRenderContext | ResultRenderContext): PlainTextComponent {
144
+ const existing = context.lastComponent;
145
+ if (existing instanceof PlainTextComponent) return existing;
146
+ return new PlainTextComponent();
147
+ }
148
+
149
+ /** Render-time clock; tests inject a fixed value so elapsed output never depends on wall time. */
150
+ export function renderNow(context: CallRenderContext | ResultRenderContext): number {
151
+ const injected: unknown = Reflect.get(context, "now");
152
+ return typeof injected === "number" && Number.isFinite(injected) ? injected : Date.now();
153
+ }
154
+
155
+ export function isEvalRunInput(args: EvalToolRequest | Partial<EvalToolInput>): args is EvalToolInput {
156
+ return args.action === undefined || args.action === "run";
157
+ }
158
+
159
+ export function style(theme: Theme | undefined, color: ThemeColor, text: string): string {
160
+ return theme ? theme.fg(color, text) : text;
161
+ }
162
+
163
+ export function appendLines(target: string[], source: readonly string[]): void {
164
+ for (const line of source) target.push(line);
165
+ }
166
+
167
+ export function renderAllVisualLines(text: string, width: number): string[] {
168
+ return truncateToVisualLines(text, Number.POSITIVE_INFINITY, width).visualLines.map((line) => line.trimEnd());
169
+ }
170
+
171
+ function renderTextBlock(block: Extract<RenderBlock, { kind: "text" }>, width: number): string[] {
172
+ if (block.maxVisualLines === undefined) return renderAllVisualLines(block.text, width);
173
+ const result = truncateToVisualLines(block.text, block.maxVisualLines, width);
174
+ const visualLines = result.visualLines.map((line) => line.trimEnd());
175
+ if (result.skippedCount === 0 || block.collapseKind === undefined) return visualLines;
176
+ return [
177
+ ...renderAllVisualLines(
178
+ style(block.theme, "muted", `${result.skippedCount} earlier ${block.collapseKind} lines`),
179
+ width,
180
+ ),
181
+ ...visualLines,
182
+ ];
183
+ }
184
+
185
+ export function renderToolCall(
186
+ call: ToolCallRow,
187
+ block: Extract<RenderBlock, { kind: "toolCalls" }>,
188
+ width: number,
189
+ ): string[] {
190
+ if (call.error === undefined) return renderAllVisualLines(style(block.theme, call.color, call.summary), width);
191
+ if (block.expanded)
192
+ return renderAllVisualLines(style(block.theme, call.color, `${call.summary} (${call.error})`), width);
193
+
194
+ const guardedError = codePointPrefix(call.error, TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS);
195
+ const guardedLines = renderAllVisualLines(
196
+ style(block.theme, call.color, `${call.summary} (${guardedError})`),
197
+ width,
198
+ );
199
+ if (guardedError.length === call.error.length && guardedLines.length <= TOOL_CALL_COLLAPSED_VISUAL_LINES)
200
+ return guardedLines;
201
+
202
+ const summaryLines = renderAllVisualLines(style(block.theme, call.color, call.summary), width);
203
+ const errorLines = renderAllVisualLines(style(block.theme, call.color, ` (${guardedError})`), width);
204
+ const markerLines = renderAllVisualLines(style(block.theme, "muted", TOOL_ERROR_OMISSION_MARKER), width);
205
+ const lines: string[] = [];
206
+ const summaryBudget = Math.max(1, TOOL_CALL_COLLAPSED_VISUAL_LINES - markerLines.length);
207
+ appendLines(lines, summaryLines.slice(0, summaryBudget));
208
+ const errorBudget = Math.max(0, TOOL_CALL_COLLAPSED_VISUAL_LINES - lines.length - markerLines.length);
209
+ appendLines(lines, errorLines.slice(0, errorBudget));
210
+ appendLines(lines, markerLines.slice(0, TOOL_CALL_COLLAPSED_VISUAL_LINES - lines.length));
211
+ return lines;
212
+ }
213
+
214
+ function renderToolCallBlock(block: Extract<RenderBlock, { kind: "toolCalls" }>, width: number): string[] {
215
+ const retainedCalls = block.expanded ? block.calls : block.calls.slice(-TOOL_CALL_PREVIEW_COUNT);
216
+ const skippedCount = block.calls.length - retainedCalls.length;
217
+ const toolCallNoun = skippedCount === 1 ? "call" : "calls";
218
+ const lines =
219
+ block.expanded || skippedCount === 0
220
+ ? []
221
+ : renderAllVisualLines(style(block.theme, "muted", `${skippedCount} earlier tool ${toolCallNoun}`), width);
222
+ for (const call of retainedCalls) {
223
+ appendLines(lines, renderToolCall(call, block, width));
224
+ }
225
+ return lines;
226
+ }
227
+
228
+ export function assertNever(value: never): never {
229
+ throw new TypeError(`Unhandled eval render variant: ${String(value)}`);
230
+ }
231
+
232
+ export function spinner(frame: number | undefined): string {
233
+ return SPINNER_FRAMES.at((frame ?? 0) % SPINNER_FRAMES.length) ?? SPINNER_FRAMES[0];
234
+ }
235
+
236
+ export function renderPrefixed(text: string, environment: RenderEnvironment, prefixStyle: PrefixStyle): string[] {
237
+ const bodyLines = renderAllVisualLines(text, Math.max(1, environment.width - prefixStyle.prefix.length));
238
+ if (bodyLines.length === 0) return [style(environment.theme, prefixStyle.color, prefixStyle.prefix.trimEnd())];
239
+ return bodyLines.map(
240
+ (line, index) =>
241
+ `${style(environment.theme, prefixStyle.color, index === 0 ? prefixStyle.prefix : prefixStyle.continuation)}${line}`,
242
+ );
243
+ }
244
+
245
+ export function previewText(
246
+ text: string,
247
+ maxLines: number,
248
+ width: number,
249
+ ): { readonly lines: string[]; readonly skipped: number } {
250
+ const preview = truncateToVisualLines(text, maxLines, Math.max(1, width));
251
+ return { lines: preview.visualLines.map((line) => line.trimEnd()), skipped: preview.skippedCount };
252
+ }
@@ -0,0 +1,130 @@
1
+ import { sanitizeTerminalLabel, type Theme } from "@code-yeongyu/senpi";
2
+ import { displayCode } from "./display-code.ts";
3
+ import { normalizeEvalSummary } from "./eval-request.ts";
4
+ import { knownLanguage } from "./live-headline.ts";
5
+ import {
6
+ type CallRenderContext,
7
+ CODE_PREVIEW_LINES,
8
+ componentFor,
9
+ type EvalRenderComponent,
10
+ isEvalRunInput,
11
+ type RenderEnvironment,
12
+ renderNow,
13
+ renderPrefixed,
14
+ spinner,
15
+ style,
16
+ } from "./render-blocks.ts";
17
+ import { renderCell, summaryBlock } from "./render-cell.ts";
18
+ import { headlined, LIVE_LINE_PREFIX } from "./render-live.ts";
19
+ import type { EvalCellResult, EvalToolInput, EvalToolRequest } from "./types.ts";
20
+
21
+ // The call renderer reads the assistant message's raw arguments, which keep the provider's
22
+ // original summary (senpi#1472 detached preparation from the message), so it normalizes here,
23
+ // idempotently: an already-normalized value is returned unchanged.
24
+ function displaySummary(summary: string | undefined): string | undefined {
25
+ return normalizeEvalSummary(summary);
26
+ }
27
+
28
+ function cellIdSuffix(cellId: unknown): string {
29
+ return typeof cellId === "string" && cellId !== "" ? ` ${sanitizeTerminalLabel(cellId)}` : "";
30
+ }
31
+
32
+ export function renderEvalCall(
33
+ args: EvalToolRequest | Partial<EvalToolInput>,
34
+ theme: Theme | undefined,
35
+ context: CallRenderContext,
36
+ ): EvalRenderComponent {
37
+ const component = componentFor(context);
38
+ if (context.hasResult === true) {
39
+ // The result renderer owns the full pending -> running -> done frame once a result exists.
40
+ // Rendering the call frame too would stack a duplicate box, so yield to it here.
41
+ component.syncLiveTicker(false, context.invalidate);
42
+ component.setBlocks([]);
43
+ return component;
44
+ }
45
+ if (!isEvalRunInput(args)) {
46
+ // While a peek/stop call streams in, `cell_id` can still be missing; the title is the action alone until it arrives.
47
+ const cellId = "cell_id" in args ? args.cell_id : undefined;
48
+ const title = args.action === "list" ? "eval list" : `eval ${args.action}${cellIdSuffix(cellId)}`;
49
+ component.syncLiveTicker(false, context.invalidate);
50
+ component.setBlocks([{ kind: "text", text: style(theme, "toolTitle", title) }]);
51
+ return component;
52
+ }
53
+ // While the model streams the call, `code` usually arrives before `language` and `summary`; a partial call still
54
+ // renders (the host would otherwise fall back to a raw key=value row).
55
+ const code = typeof args.code === "string" ? args.code : "";
56
+ const language = knownLanguage(args.language);
57
+ component.syncLiveTicker(theme !== undefined, context.invalidate);
58
+ if (theme === undefined && context.spinnerFrame === undefined) {
59
+ const reset = args.reset === true ? " reset" : "";
60
+ const timeout = args.timeout === undefined ? "" : ` timeout ${args.timeout}s`;
61
+ component.setBlocks([
62
+ {
63
+ kind: "text",
64
+ text: style(theme, "toolTitle", `eval${language === undefined ? "" : ` ${language}`}${reset}${timeout}`),
65
+ },
66
+ ...(displaySummary(args.summary) === undefined
67
+ ? []
68
+ : [summaryBlock(displaySummary(args.summary) ?? "", theme, context.expanded)]),
69
+ {
70
+ kind: "text",
71
+ text: style(
72
+ theme,
73
+ "mdCodeBlock",
74
+ code.trim().length === 0
75
+ ? "..."
76
+ : language === undefined
77
+ ? code
78
+ : displayCode(code, language, context.argsComplete ? context.invalidate : undefined),
79
+ ),
80
+ maxVisualLines: context.expanded ? undefined : CODE_PREVIEW_LINES,
81
+ collapseKind: "code",
82
+ theme,
83
+ },
84
+ ]);
85
+ return component;
86
+ }
87
+ component.setBlocks([
88
+ {
89
+ kind: "dynamic",
90
+ render: (width) => {
91
+ const environment: RenderEnvironment = {
92
+ expanded: context.expanded,
93
+ theme,
94
+ spinnerFrame: context.spinnerFrame,
95
+ width,
96
+ meta: undefined,
97
+ now: renderNow(context),
98
+ ...(context.argsComplete ? { repaint: context.invalidate } : {}),
99
+ };
100
+ const summary = displaySummary(args.summary);
101
+ if (language === undefined) return streamingCallLines(summary, code, environment);
102
+ // The host never supplies a spinner frame for an eval call, so the state comes from
103
+ // the call lane itself: run args are present and no result exists yet (review MEDIUM-1).
104
+ // The ticker (armed in renderEvalCall) repaints the block, so the spinner advances.
105
+ const streaming = context.hasResult !== true;
106
+ const cell: EvalCellResult = {
107
+ index: 0,
108
+ ...(summary === undefined ? {} : { summary }),
109
+ code,
110
+ language,
111
+ output: "",
112
+ status: streaming ? "running" : "pending",
113
+ };
114
+ return renderCell(cell, environment, {
115
+ reset: args.reset === true,
116
+ timeout: args.timeout,
117
+ throughput: undefined,
118
+ streaming,
119
+ });
120
+ },
121
+ },
122
+ ]);
123
+ return component;
124
+ }
125
+
126
+ /** A call whose language has not streamed in yet: the headline row alone, until the full frame can render. */
127
+ function streamingCallLines(summary: string | undefined, code: string, environment: RenderEnvironment): string[] {
128
+ const icon = environment.spinnerFrame === undefined ? "○" : spinner(environment.spinnerFrame);
129
+ return renderPrefixed(headlined(icon, summary, code, "eval", environment), environment, LIVE_LINE_PREFIX);
130
+ }
@@ -0,0 +1,165 @@
1
+ import { highlightedCode } from "./code-preview.ts";
2
+ import { leadsWithHeadline } from "./live-headline.ts";
3
+ import { renderAgentProgressEvents } from "./render-agent.ts";
4
+ import {
5
+ appendLines,
6
+ assertNever,
7
+ CODE_PREVIEW_LINES,
8
+ OUTPUT_PREVIEW_LINES,
9
+ previewText,
10
+ type RenderBlock,
11
+ type RenderEnvironment,
12
+ renderAllVisualLines,
13
+ renderPrefixed,
14
+ SUMMARY_PREVIEW_LINES,
15
+ style,
16
+ } from "./render-blocks.ts";
17
+ import {
18
+ type CellBadges,
19
+ cellElapsedMs,
20
+ cellHeader,
21
+ cellPresentation,
22
+ FRAME_HEADER_PREFIX,
23
+ FRAME_INNER_PREFIX,
24
+ headlined,
25
+ LIVE_LINE_PREFIX,
26
+ renderLiveCellFrame,
27
+ } from "./render-live.ts";
28
+ import { formatThroughputBadge } from "./render-status.ts";
29
+ import { cellOutputSection, cellStatusSection } from "./render-tail.ts";
30
+ import { formatRuntimeBadge } from "./runtime-label.ts";
31
+ import { formatDuration } from "./tool-widgets.ts";
32
+ import type { EvalCellResult } from "./types.ts";
33
+
34
+ export type { CellBadges } from "./render-live.ts";
35
+ export { LIVE_CODE_WINDOW_LINES } from "./render-live.ts";
36
+
37
+ // A summary has no length limit, so a collapsed block shows its first lines and marks the cut.
38
+ function summaryVisualLines(summary: string, width: number, expanded: boolean): string[] {
39
+ const lines = renderAllVisualLines(summary, width);
40
+ if (expanded || lines.length <= SUMMARY_PREVIEW_LINES) return lines;
41
+ const kept = renderAllVisualLines(summary, Math.max(1, width - 1)).slice(0, SUMMARY_PREVIEW_LINES);
42
+ kept[SUMMARY_PREVIEW_LINES - 1] = `${kept[SUMMARY_PREVIEW_LINES - 1] ?? ""}…`;
43
+ return kept;
44
+ }
45
+
46
+ export function summaryBlock(summary: string, theme: RenderEnvironment["theme"], expanded: boolean): RenderBlock {
47
+ return {
48
+ kind: "dynamic",
49
+ render: (width) => summaryVisualLines(summary, width, expanded).map((line) => style(theme, "muted", line)),
50
+ };
51
+ }
52
+
53
+ export function renderCell(cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges): string[] {
54
+ const agentEvents = (cell.statusEvents ?? []).filter((event) => event.op === "agent");
55
+ const lines = renderCellFrame(cell, environment, badges);
56
+ if (agentEvents.length > 0) appendLines(lines, renderAgentProgressEvents(agentEvents, environment));
57
+ return lines;
58
+ }
59
+
60
+ function renderCellFrame(cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges): string[] {
61
+ switch (cell.status) {
62
+ case "pending":
63
+ case "queued":
64
+ case "running":
65
+ return environment.expanded
66
+ ? renderExpandedCellFrame(cell, environment, badges)
67
+ : renderLiveCellFrame(cell, environment, badges);
68
+ case "complete":
69
+ case "error":
70
+ case "cancelled":
71
+ return environment.expanded
72
+ ? renderExpandedCellFrame(cell, environment, badges)
73
+ : renderTerminalCellLine(cell, environment, badges);
74
+ case "detached":
75
+ return environment.expanded
76
+ ? renderExpandedCellFrame(cell, environment, badges)
77
+ : renderDetachedCellFrame(cell, environment, badges);
78
+ default:
79
+ return assertNever(cell.status);
80
+ }
81
+ }
82
+
83
+ // A terminal row collapses to one line: icon, the cell's headline (summary or first code line),
84
+ // status and duration. The full frame stays reachable through the expand action.
85
+ function renderTerminalCellLine(cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges): string[] {
86
+ const presentation = cellPresentation(cell.status, environment.spinnerFrame);
87
+ const runtimeBadge = cell.runtime === undefined ? "" : ` (${formatRuntimeBadge(cell.language, cell.runtime)})`;
88
+ // The runtime badge rides the base label when it fits (review MEDIUM-1); when it would wrap
89
+ // the row it drops like any badge, so the done row stays one line at any width.
90
+ const segments: string[] = [];
91
+ if (badges.throughput !== undefined) {
92
+ const badge = formatThroughputBadge(badges.throughput);
93
+ if (badge !== undefined) segments.push(badge);
94
+ }
95
+ const elapsedMs = badges.throughput?.wallDurationMs ?? cellElapsedMs(cell, environment);
96
+ if (elapsedMs !== undefined) segments.push(formatDuration(elapsedMs));
97
+ if (badges.reset) segments.push("reset");
98
+ if (badges.timeout !== undefined) segments.push(`timeout ${badges.timeout}s`);
99
+ const tail = segments.length === 0 ? "" : ` · ${segments.join(" · ")}`;
100
+ const rest = `eval ${cell.language}${runtimeBadge} ${presentation.label}${tail}`;
101
+ const badgelessRest = runtimeBadge === "" ? rest : `eval ${cell.language} ${presentation.label}${tail}`;
102
+ const line = style(
103
+ environment.theme,
104
+ presentation.color,
105
+ headlined(presentation.icon, cell.summary, cell.code, rest, environment, { badgelessRest }),
106
+ );
107
+ return renderPrefixed(line, environment, LIVE_LINE_PREFIX);
108
+ }
109
+
110
+ // A detached row keeps its collapsed frame: the cell is no longer live, but its output and status
111
+ // history are the reason it is inspected, so they stay visible without expand.
112
+ function renderDetachedCellFrame(cell: EvalCellResult, environment: RenderEnvironment, badges: CellBadges): string[] {
113
+ if (cell.output.trim().length === 0) {
114
+ const statusEvents = (cell.statusEvents ?? []).filter((event) => event.op !== "agent");
115
+ if (statusEvents.length === 0)
116
+ return renderPrefixed(cellHeader(cell, environment, badges), environment, LIVE_LINE_PREFIX);
117
+ }
118
+ return renderExpandedCellFrame(cell, environment, badges, { collapsedPreviews: true });
119
+ }
120
+
121
+ function renderExpandedCellFrame(
122
+ cell: EvalCellResult,
123
+ environment: RenderEnvironment,
124
+ badges: CellBadges,
125
+ options: { readonly collapsedPreviews?: boolean } = {},
126
+ ): string[] {
127
+ const previewsCollapsed = options.collapsedPreviews === true && !environment.expanded;
128
+ const lines = renderPrefixed(cellHeader(cell, environment, badges), environment, FRAME_HEADER_PREFIX);
129
+ if (cell.summary !== undefined && !leadsWithHeadline(cell.status)) {
130
+ appendLines(
131
+ lines,
132
+ summaryVisualLines(cell.summary, Math.max(1, environment.width - 2), environment.expanded).map(
133
+ (line) => `${style(environment.theme, "muted", "│ ")}${style(environment.theme, "muted", line)}`,
134
+ ),
135
+ );
136
+ }
137
+ const innerWidth = Math.max(1, environment.width - 2);
138
+ const codePreview = previewText(
139
+ highlightedCodeForFrame(cell, environment),
140
+ previewsCollapsed ? CODE_PREVIEW_LINES : Number.POSITIVE_INFINITY,
141
+ innerWidth,
142
+ );
143
+ if (codePreview.skipped > 0) {
144
+ appendLines(
145
+ lines,
146
+ renderPrefixed(`${codePreview.skipped} earlier code lines`, environment, {
147
+ prefix: "│ ",
148
+ continuation: "│ ",
149
+ color: "muted",
150
+ }),
151
+ );
152
+ }
153
+ for (const line of codePreview.lines) appendLines(lines, renderPrefixed(line, environment, FRAME_INNER_PREFIX));
154
+ appendLines(
155
+ lines,
156
+ cellOutputSection(cell, environment, previewsCollapsed ? OUTPUT_PREVIEW_LINES : Number.POSITIVE_INFINITY),
157
+ );
158
+ appendLines(lines, cellStatusSection(cell, environment));
159
+ lines.push(style(environment.theme, "borderMuted", "╰─"));
160
+ return lines;
161
+ }
162
+
163
+ function highlightedCodeForFrame(cell: EvalCellResult, environment: RenderEnvironment): string {
164
+ return highlightedCode(cell.code, cell.language, environment.theme, environment.repaint);
165
+ }