@code-yeongyu/senpi-codemode 2026.10.10-9 → 2026.10.11

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 (49) hide show
  1. package/CHANGELOG.md +72 -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/globals.jl +128 -0
  10. package/src/kernels/jl/prelude.jl +15 -2
  11. package/src/kernels/jl/runner.jl +35 -143
  12. package/src/kernels/js/group-signal-notice.js +117 -0
  13. package/src/kernels/js/inline-worker.ts +2 -0
  14. package/src/kernels/js/local-module-loader.ts +8 -0
  15. package/src/kernels/js/process-tree-host.ts +4 -8
  16. package/src/kernels/js/process-tree.d.ts +11 -0
  17. package/src/kernels/js/process-tree.js +55 -10
  18. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  19. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  20. package/src/kernels/js/windows-tree-kill.js +100 -0
  21. package/src/kernels/js/worker-cwd.js +41 -10
  22. package/src/kernels/js/worker-runtime.js +23 -4
  23. package/src/kernels/js/worker-shell-capture.js +4 -0
  24. package/src/kernels/js/worker-slot.ts +2 -3
  25. package/src/kernels/py/prelude.py +36 -25
  26. package/src/kernels/py/transport.ts +4 -0
  27. package/src/kernels/rb/runner.rb +9 -19
  28. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  29. package/src/kernels/shared/bun-ffi.ts +24 -0
  30. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  31. package/src/kernels/shared/process-cpu.ts +7 -3
  32. package/src/kernels/shared/process-group-cpu.ts +6 -3
  33. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  34. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  35. package/src/kernels/shared/subprocess-process.ts +20 -12
  36. package/src/kernels/shared/subprocess-queue.ts +1 -0
  37. package/src/tool/eval-tool.ts +2 -1
  38. package/src/tool/render-agent.ts +110 -0
  39. package/src/tool/render-blocks.ts +252 -0
  40. package/src/tool/render-call.ts +130 -0
  41. package/src/tool/render-cell.ts +165 -0
  42. package/src/tool/render-detail.ts +152 -0
  43. package/src/tool/render-live.ts +288 -0
  44. package/src/tool/render-result.ts +202 -0
  45. package/src/tool/render-status.ts +166 -0
  46. package/src/tool/render-tail.ts +139 -0
  47. package/src/tool/render.ts +3 -1128
  48. package/src/tool/run-eval-cell.ts +7 -12
  49. package/src/tool/types.ts +11 -2
@@ -1,9 +1,13 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
2
  import { createInterface, type Interface as ReadlineInterface } from "node:readline";
3
+ import { killWindowsTree } from "../js/windows-tree-kill-host.ts";
3
4
 
4
5
  export interface SubprocessLike {
5
6
  readonly pid?: number;
6
- readonly stdin: { write(chunk: string): unknown };
7
+ readonly stdin: {
8
+ write(chunk: string): unknown;
9
+ on?(event: "error", listener: (error: Error) => void): unknown;
10
+ };
7
11
  readonly stdout: NodeJS.ReadableStream;
8
12
  readonly stderr: NodeJS.ReadableStream;
9
13
  on(event: "error", listener: (error: Error) => void): this;
@@ -46,11 +50,13 @@ export class SubprocessProcess {
46
50
  private readonly stdoutReader: ReadlineInterface;
47
51
  private readonly stderrListener: (chunk: string | Buffer) => void;
48
52
  private readonly errorListener: (error: Error) => void;
53
+ private readonly stdinErrorListener: (error: Error) => void;
49
54
  private exited = false;
50
55
  private retiring = false;
51
56
  private outputDetached = false;
52
57
  private errorReported = false;
53
58
  private terminationPromise: Promise<boolean> | null = null;
59
+ private windowsTreeKill: Promise<void> | undefined;
54
60
 
55
61
  constructor(child: SubprocessLike, handlers: SubprocessProcessHandlers) {
56
62
  this.child = child;
@@ -67,8 +73,16 @@ export class SubprocessProcess {
67
73
  this.stdoutReader.on("line", (line) => {
68
74
  if (!this.retiring) this.handlers.onLine(this, `${line}\n`);
69
75
  });
76
+ // senpi#3016: a frame written while the child is dying fails on the stdin stream (EPIPE), which emits there
77
+ // and not on the ChildProcess. Unhandled, it is a process-level error that can end the host; once the child is
78
+ // gone or being retired the write failure carries no news, otherwise it is this process's error.
79
+ this.stdinErrorListener = (error) => {
80
+ if (this.exited || this.retiring) return;
81
+ this.errorListener(error);
82
+ };
70
83
  child.stderr.on("data", this.stderrListener);
71
84
  child.on("error", this.errorListener);
85
+ child.stdin.on?.("error", this.stdinErrorListener);
72
86
  child.once("exit", (code, signal) => {
73
87
  this.exited = true;
74
88
  this.detachOutput();
@@ -83,7 +97,7 @@ export class SubprocessProcess {
83
97
  }
84
98
 
85
99
  send(frame: string): boolean {
86
- if (this.retiring) return false;
100
+ if (this.retiring || this.exited) return false;
87
101
  this.child.stdin.write(frame);
88
102
  return true;
89
103
  }
@@ -125,6 +139,8 @@ export class SubprocessProcess {
125
139
  this.retire();
126
140
  if (this.exited) return true;
127
141
  this.kill(initialSignal);
142
+ // On Windows the kill first lists processes (up to 5 s); the exit wait starts once taskkill has run.
143
+ if (this.windowsTreeKill) await this.windowsTreeKill;
128
144
  if (await this.waitForExit(escalationMs)) return true;
129
145
  this.kill("SIGKILL");
130
146
  return await this.waitForExit(forcedExitWaitMs);
@@ -152,7 +168,8 @@ export class SubprocessProcess {
152
168
 
153
169
  private kill(signal: NodeJS.Signals): void {
154
170
  if (globalThis.process.platform === "win32") {
155
- if (this.child.pid !== undefined) killProcessTree(this.child.pid);
171
+ // taskkill /F is already forced, so escalation reuses the first kill instead of listing again.
172
+ if (this.child.pid !== undefined) this.windowsTreeKill ??= killWindowsTree(this.child.pid);
156
173
  else this.child.kill(signal);
157
174
  return;
158
175
  }
@@ -168,15 +185,6 @@ export class SubprocessProcess {
168
185
  }
169
186
  }
170
187
 
171
- // Windows has no process-group signal: taskkill /T /F walks the tree rooted at the child.
172
- function killProcessTree(pid: number): void {
173
- try {
174
- nodeSpawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
175
- } catch (error) {
176
- if (!(error instanceof Error)) throw error;
177
- }
178
- }
179
-
180
188
  export function spawnSubprocess(spawn: SubprocessSpawn | undefined, request: SubprocessSpawnRequest): SubprocessLike {
181
189
  if (spawn) return spawn(request.command, request.args, { cwd: request.cwd, env: request.env });
182
190
  const child = nodeSpawn(request.command, [...request.args], {
@@ -120,6 +120,7 @@ export class SubprocessRunQueue {
120
120
  return false;
121
121
  case "webview-connect":
122
122
  case "memory-query-result":
123
+ case "memory-globals-result":
123
124
  case "kernel-tools-defined":
124
125
  return false;
125
126
  default: {
@@ -25,6 +25,7 @@ import {
25
25
  type EvalListInput,
26
26
  type EvalResultDetails,
27
27
  type EvalToolDetails,
28
+ type EvalToolInput,
28
29
  type EvalToolRequest,
29
30
  enabledLanguageList,
30
31
  } from "./types.ts";
@@ -34,7 +35,7 @@ export type { CreateEvalToolOptions } from "./eval-tool-options.ts";
34
35
  export type { EnabledEvalLanguages, EvalKernel, EvalKernelManager } from "./types.ts";
35
36
 
36
37
  type EvalExecuteArgs<Request extends EvalToolRequest> = Parameters<
37
- ToolDefinition<TUnsafe<Request>, EvalToolDetails>["execute"]
38
+ ToolDefinition<TUnsafe<Request | Partial<EvalToolInput>>, EvalToolDetails>["execute"]
38
39
  >;
39
40
 
40
41
  export function createEvalTool(options: CreateEvalToolOptions) {
@@ -0,0 +1,110 @@
1
+ import {
2
+ appendLines,
3
+ assertNever,
4
+ type RenderEnvironment,
5
+ renderPrefixed,
6
+ type StatusPresentation,
7
+ spinner,
8
+ style,
9
+ } from "./render-blocks.ts";
10
+ import { eventString } from "./render-status.ts";
11
+ import { formatDuration } from "./tool-widgets.ts";
12
+ import type { EvalStatusEvent } from "./types.ts";
13
+
14
+ type AgentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
15
+
16
+ function eventNumber(value: unknown): number {
17
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
18
+ }
19
+
20
+ function agentStatus(value: unknown): AgentStatus {
21
+ switch (value) {
22
+ case "pending":
23
+ case "running":
24
+ case "completed":
25
+ case "failed":
26
+ case "aborted":
27
+ return value;
28
+ default:
29
+ return "running";
30
+ }
31
+ }
32
+
33
+ function coalesceAgentEvents(events: readonly EvalStatusEvent[]): EvalStatusEvent[] {
34
+ const rows: EvalStatusEvent[] = [];
35
+ const indexes = new Map<string, number>();
36
+ for (const event of events) {
37
+ const id = eventString(event.id);
38
+ if (id === undefined) {
39
+ rows.push(event);
40
+ continue;
41
+ }
42
+ const index = indexes.get(id);
43
+ if (index === undefined) {
44
+ indexes.set(id, rows.length);
45
+ rows.push(event);
46
+ } else rows[index] = event;
47
+ }
48
+ return rows;
49
+ }
50
+
51
+ function agentPresentation(status: AgentStatus, spinnerFrame: number | undefined): StatusPresentation {
52
+ switch (status) {
53
+ case "pending":
54
+ return { label: "pending", icon: "○", color: "muted" };
55
+ case "running":
56
+ return { label: "running", icon: spinner(spinnerFrame), color: "warning" };
57
+ case "completed":
58
+ return { label: "done", icon: "✓", color: "success" };
59
+ case "failed":
60
+ return { label: "failed", icon: "✗", color: "error" };
61
+ case "aborted":
62
+ return { label: "aborted", icon: "×", color: "error" };
63
+ default:
64
+ return assertNever(status);
65
+ }
66
+ }
67
+
68
+ export function renderAgentProgressEvents(
69
+ events: readonly EvalStatusEvent[],
70
+ environment: RenderEnvironment,
71
+ ): string[] {
72
+ const rows = coalesceAgentEvents(events);
73
+ const lines: string[] = [];
74
+ // Senpi Theme has no tree-token API; fixed ├/└/│ glyphs intentionally mirror omp.
75
+ for (const [index, event] of rows.entries()) {
76
+ const isLast = index === rows.length - 1;
77
+ const status = agentStatus(event.status);
78
+ const presentation = agentPresentation(status, environment.spinnerFrame);
79
+ const id = eventString(event.id) ?? "agent";
80
+ const styledId = environment.theme === undefined ? id : environment.theme.bold(id);
81
+ let body = `${style(environment.theme, presentation.color, presentation.icon)} ${styledId} ${presentation.label}`;
82
+ if (status === "completed" || status === "failed" || status === "aborted") {
83
+ const duration = eventNumber(event.durationMs);
84
+ if (duration > 0) body += ` · ${style(environment.theme, "dim", formatDuration(duration))}`;
85
+ }
86
+ const branch = isLast ? "└ " : "├ ";
87
+ const continuation = isLast ? " " : "│ ";
88
+ appendLines(
89
+ lines,
90
+ renderPrefixed(body, environment, { prefix: branch, continuation: continuation, color: "dim" }),
91
+ );
92
+ if (status !== "running") continue;
93
+ const currentTool = eventString(event.currentTool);
94
+ const lastIntent = eventString(event.lastIntent);
95
+ if (currentTool === undefined && lastIntent === undefined) continue;
96
+ const detail =
97
+ currentTool === undefined
98
+ ? (lastIntent ?? "")
99
+ : `${currentTool}${lastIntent === undefined ? "" : `: ${lastIntent}`}`;
100
+ appendLines(
101
+ lines,
102
+ renderPrefixed(detail, environment, {
103
+ prefix: `${continuation}└ `,
104
+ continuation: `${continuation} `,
105
+ color: "dim",
106
+ }),
107
+ );
108
+ }
109
+ return lines;
110
+ }
@@ -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
+ }