@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.
- package/CHANGELOG.md +72 -0
- package/package.json +4 -4
- package/src/bridge/memory-protocol.ts +8 -1
- package/src/bridge/protocol.ts +1 -1
- package/src/bridges/eval-environment-schemas.ts +5 -1
- package/src/environments/editable-check.ts +1 -1
- package/src/extension/session-cwd.ts +25 -9
- package/src/extension/session-manager.ts +4 -4
- package/src/kernels/jl/globals.jl +128 -0
- package/src/kernels/jl/prelude.jl +15 -2
- package/src/kernels/jl/runner.jl +35 -143
- package/src/kernels/js/group-signal-notice.js +117 -0
- package/src/kernels/js/inline-worker.ts +2 -0
- package/src/kernels/js/local-module-loader.ts +8 -0
- package/src/kernels/js/process-tree-host.ts +4 -8
- package/src/kernels/js/process-tree.d.ts +11 -0
- package/src/kernels/js/process-tree.js +55 -10
- package/src/kernels/js/windows-tree-kill-host.ts +117 -0
- package/src/kernels/js/windows-tree-kill.d.ts +19 -0
- package/src/kernels/js/windows-tree-kill.js +100 -0
- package/src/kernels/js/worker-cwd.js +41 -10
- package/src/kernels/js/worker-runtime.js +23 -4
- package/src/kernels/js/worker-shell-capture.js +4 -0
- package/src/kernels/js/worker-slot.ts +2 -3
- package/src/kernels/py/prelude.py +36 -25
- package/src/kernels/py/transport.ts +4 -0
- package/src/kernels/rb/runner.rb +9 -19
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
- package/src/kernels/shared/bun-ffi.ts +24 -0
- package/src/kernels/shared/kernel-memory-host.ts +30 -7
- package/src/kernels/shared/process-cpu.ts +7 -3
- package/src/kernels/shared/process-group-cpu.ts +6 -3
- package/src/kernels/shared/subprocess-kernel.ts +29 -3
- package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
- package/src/kernels/shared/subprocess-process.ts +20 -12
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/tool/eval-tool.ts +2 -1
- package/src/tool/render-agent.ts +110 -0
- package/src/tool/render-blocks.ts +252 -0
- package/src/tool/render-call.ts +130 -0
- package/src/tool/render-cell.ts +165 -0
- package/src/tool/render-detail.ts +152 -0
- package/src/tool/render-live.ts +288 -0
- package/src/tool/render-result.ts +202 -0
- package/src/tool/render-status.ts +166 -0
- package/src/tool/render-tail.ts +139 -0
- package/src/tool/render.ts +3 -1128
- package/src/tool/run-eval-cell.ts +7 -12
- 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: {
|
|
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
|
-
|
|
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], {
|
package/src/tool/eval-tool.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|