@code-yeongyu/senpi-codemode 2026.7.28 → 2026.7.29-3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +19 -0
- package/package.json +5 -5
- package/src/extension/eval-status.ts +44 -0
- package/src/extension/runtime-factory.ts +3 -0
- package/src/extension/session-manager.ts +39 -3
- package/src/index.ts +20 -3
- package/src/kernels/js/rewrite-imports.ts +6 -1
- package/src/tool/call-capture.ts +93 -0
- package/src/tool/cell-handler.ts +82 -8
- package/src/tool/detached-cell-manager.ts +35 -1
- package/src/tool/render.ts +47 -27
- package/src/tool/tool-widgets.ts +259 -0
- package/src/tool/types.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,75 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.7.29-3] - 2026-07-29
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
## [2026.7.29-2] - 2026-07-29
|
|
28
|
+
|
|
29
|
+
### Breaking Changes
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
## [2026.7.29] - 2026-07-29
|
|
40
|
+
|
|
41
|
+
### Breaking Changes
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Show every live detached eval cell in the interactive footer, using a highlighted `↗ <language> · <title>` status for one cell and a bounded packed summary for multiple cells; clear the status immediately when the final detached cell settles ([#483](https://github.com/code-yeongyu/senpi/pull/483)).
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- Route reserved `agent()`, `output()`, and `tool_schema()` bridge calls from Python and other subprocess kernels through the reserved HTTP handler instead of attempting to execute nonexistent `__agent__`, `__output__`, and `__schema__` tools; ordinary bridge tool calls remain unchanged ([#462](https://github.com/code-yeongyu/senpi/pull/462)).
|
|
52
|
+
|
|
53
|
+
### Removed
|
|
54
|
+
|
|
55
|
+
## [2026.7.28-3] - 2026-07-28
|
|
56
|
+
|
|
57
|
+
### Breaking Changes
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- Add nested tool-call widgets that render the real call shape of tools invoked from eval cells, with truthful status, duration, and sanitized previews ([#444](https://github.com/code-yeongyu/senpi/pull/444)).
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
### Removed
|
|
68
|
+
|
|
69
|
+
## [2026.7.28-2] - 2026-07-28
|
|
70
|
+
|
|
71
|
+
### Breaking Changes
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- Start a fresh eval cell when a caller reuses the ID of a terminal cell, preventing completed or failed results from being replayed as though new code had executed ([#439](https://github.com/code-yeongyu/senpi/pull/439)).
|
|
80
|
+
- Omit the eval `took` duration when timing metadata is unavailable, avoiding misleading zero-duration status output for detached or restored cell results ([#439](https://github.com/code-yeongyu/senpi/pull/439)).
|
|
81
|
+
|
|
82
|
+
### Removed
|
|
83
|
+
|
|
15
84
|
## [2026.7.28] - 2026-07-28
|
|
16
85
|
|
|
17
86
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -133,6 +133,10 @@ cell keeps only its own language kernel busy. A new same-language call returns
|
|
|
133
133
|
a busy error with its cell id and output tail; calls in other languages continue
|
|
134
134
|
normally. Do not re-run the cell.
|
|
135
135
|
|
|
136
|
+
While any cell is detached, the interactive footer shows a highlighted
|
|
137
|
+
`↗ <language> · <title>` status on the extension status line (the cell id when
|
|
138
|
+
the call had no title), clearing as soon as the last detached cell settles.
|
|
139
|
+
|
|
136
140
|
Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
|
|
137
141
|
`eval({ action: "stop", cell_id })` to cancel it. Python stop interrupts the
|
|
138
142
|
existing kernel and preserves variables. JavaScript stop kills and restarts its
|
|
@@ -185,3 +189,18 @@ Direct real-surface QA drivers live in `scripts/qa-*.ts`: kernel cells
|
|
|
185
189
|
(`qa-py-cell.ts`, `qa-js-cell.ts`, `qa-rb-cell.ts`, `qa-jl-cell.ts`), end-to-end
|
|
186
190
|
extension execution (`qa-e2e-eval.ts`), and renderer output
|
|
187
191
|
(`qa-render-dump.ts`).
|
|
192
|
+
|
|
193
|
+
### Nested tool-call widgets
|
|
194
|
+
|
|
195
|
+
When an eval cell invokes `tool.<name>(...)`, the result panel can render a
|
|
196
|
+
nested widget for the invoked tool. The widget captures bounded args, duration,
|
|
197
|
+
and a sanitized 160 code points result preview; the rendering path is
|
|
198
|
+
always-on and does not depend on any toggle or session flag.
|
|
199
|
+
|
|
200
|
+
The capture budget is fixed at 30 enriched calls per cell, with a 4096-character
|
|
201
|
+
serialized args budget. Previews are capped at 160 code points, and collapsed
|
|
202
|
+
widgets stay within the 8 lines collapsed widget budget.
|
|
203
|
+
|
|
204
|
+
Entries without args — including old sessions, reserved/completion rows, and
|
|
205
|
+
calls past the cap — render as plain rows. Edit renders a fallback row by
|
|
206
|
+
design, even when its args are present.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.7.
|
|
3
|
+
"version": "2026.7.29-3",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -29,15 +29,15 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@babel/parser": "
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.
|
|
34
|
-
"typebox": "1.
|
|
32
|
+
"@babel/parser": "8.0.4",
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.29-3",
|
|
34
|
+
"typebox": "1.3.8"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@code-yeongyu/senpi": "*"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.7.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.7.29-3"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EvalDetachedCellStatusEntry } from "../tool/detached-cell-manager.ts";
|
|
2
|
+
|
|
3
|
+
export const EVAL_CELLS_STATUS_KEY = "eval-cells";
|
|
4
|
+
|
|
5
|
+
/** The footer shares one status line with other extensions; keep this brief. */
|
|
6
|
+
const MAX_STATUS_LENGTH = 48;
|
|
7
|
+
/** Same glyph the transcript uses for a detached cell, so the two surfaces read as one state. */
|
|
8
|
+
const DETACHED_GLYPH = "↗";
|
|
9
|
+
|
|
10
|
+
function truncateEnd(text: string, max: number): string {
|
|
11
|
+
if (text.length <= max) return text;
|
|
12
|
+
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fits as many whole labels as possible into the budget, folding the rest into
|
|
17
|
+
* a `+N more` counter so the detached-cell count is never truncated away.
|
|
18
|
+
*/
|
|
19
|
+
function packLabels(labels: readonly string[], budget: number): string {
|
|
20
|
+
for (let kept = labels.length; kept >= 1; kept--) {
|
|
21
|
+
const hiddenCount = labels.length - kept;
|
|
22
|
+
const tail = hiddenCount > 0 ? ` +${hiddenCount} more` : "";
|
|
23
|
+
const joined = labels.slice(0, kept).join(", ");
|
|
24
|
+
if (joined.length + tail.length <= budget) return joined + tail;
|
|
25
|
+
}
|
|
26
|
+
const tail = labels.length > 1 ? ` +${labels.length - 1} more` : "";
|
|
27
|
+
return truncateEnd(labels[0] ?? "", Math.max(1, budget - tail.length)) + tail;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function labelOf(entry: EvalDetachedCellStatusEntry): string {
|
|
31
|
+
return entry.title === undefined || entry.title.length === 0 ? entry.cellId : entry.title;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Brief footer text for the cells still running detached; undefined clears the status. */
|
|
35
|
+
export function formatEvalCellStatus(entries: readonly EvalDetachedCellStatusEntry[]): string | undefined {
|
|
36
|
+
const first = entries[0];
|
|
37
|
+
if (first === undefined) return undefined;
|
|
38
|
+
if (entries.length === 1) {
|
|
39
|
+
const head = `${DETACHED_GLYPH} ${first.language} · `;
|
|
40
|
+
return head + truncateEnd(labelOf(first), MAX_STATUS_LENGTH - head.length);
|
|
41
|
+
}
|
|
42
|
+
const head = `${DETACHED_GLYPH} eval ${entries.length}: `;
|
|
43
|
+
return head + packLabels(entries.map(labelOf), MAX_STATUS_LENGTH - head.length);
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
2
|
import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
|
|
3
|
+
import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
|
|
3
4
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
4
5
|
import {
|
|
5
6
|
type CodemodeSettings,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
export interface CodemodeRuntimeAPI {
|
|
24
25
|
readonly executeTool: AgentExecuteTool;
|
|
25
26
|
getActiveTools(): string[];
|
|
27
|
+
getAllTools(): readonly EvalSchemaToolInfo[];
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface RuntimeFactoryOptions {
|
|
@@ -71,6 +73,7 @@ export async function createRuntime(
|
|
|
71
73
|
availability,
|
|
72
74
|
artifactsDir: artifacts.dir,
|
|
73
75
|
executeTool,
|
|
76
|
+
listTools: () => pi.getAllTools(),
|
|
74
77
|
complete,
|
|
75
78
|
});
|
|
76
79
|
return {
|
|
@@ -2,19 +2,28 @@ import { join } from "node:path";
|
|
|
2
2
|
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
3
3
|
import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
|
|
4
4
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
5
|
+
import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
|
|
6
|
+
import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
|
|
5
7
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
6
|
-
import type
|
|
8
|
+
import { type CodemodeSettings, defaultCodemodeSettings } from "../config/settings.ts";
|
|
7
9
|
import type { InterpreterAvailability } from "../interpreters/detect.ts";
|
|
8
10
|
import { JuliaKernel } from "../kernels/jl/kernel.ts";
|
|
9
11
|
import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
10
12
|
import { PythonKernel } from "../kernels/py/kernel.ts";
|
|
11
13
|
import { RubyKernel } from "../kernels/rb/kernel.ts";
|
|
14
|
+
import { marshalToolResult } from "../tool/image.ts";
|
|
12
15
|
import type { EvalKernel, EvalKernelManager, EvalLanguage, ExecuteTool } from "../tool/types.ts";
|
|
13
16
|
|
|
14
17
|
export interface CodemodeSessionManager extends EvalKernelManager {
|
|
15
18
|
dispose(): Promise<void>;
|
|
16
19
|
complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult>;
|
|
17
20
|
setContext?(ctx: ExtensionContext): void;
|
|
21
|
+
bridgeEndpoint?(): BridgeEndpoint;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BridgeEndpoint {
|
|
25
|
+
readonly port: number;
|
|
26
|
+
readonly token: string;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
export interface EvalExecutionTracker {
|
|
@@ -32,6 +41,7 @@ export interface CreateCodemodeSessionManagerOptions {
|
|
|
32
41
|
/** Session-adjacent directory used for persisted eval artifacts. */
|
|
33
42
|
readonly artifactsDir?: string;
|
|
34
43
|
readonly executeTool: ExecuteTool;
|
|
44
|
+
readonly listTools?: () => readonly EvalSchemaToolInfo[];
|
|
35
45
|
readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
|
|
36
46
|
}
|
|
37
47
|
|
|
@@ -75,14 +85,34 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
75
85
|
|
|
76
86
|
async start(): Promise<void> {
|
|
77
87
|
this.#bridge = await startBridgeServer({
|
|
78
|
-
onCall: async (request) =>
|
|
79
|
-
this.#options.executeTool(request.toolName, request.args, { signal: request.signal }),
|
|
88
|
+
onCall: async (request) => await this.#call(request),
|
|
80
89
|
onEmit: async () => undefined,
|
|
81
90
|
onCompletion: async (request) =>
|
|
82
91
|
this.#options.complete({ prompt: request.prompt, opts: request.opts }, this.#contextFor(request.signal)),
|
|
83
92
|
});
|
|
84
93
|
}
|
|
85
94
|
|
|
95
|
+
// Subprocess kernels (py/rb/jl) reach the host only through this route, so reserved
|
|
96
|
+
// helper names must dispatch exactly as the in-process JS path does in tool/cell-handler.ts.
|
|
97
|
+
// Forwarding them to executeTool made agent() fail with "Unknown tool __agent__".
|
|
98
|
+
async #call(request: { toolName: string; args: unknown; callId: string; signal: AbortSignal }): Promise<unknown> {
|
|
99
|
+
if (!isReservedToolName(request.toolName)) {
|
|
100
|
+
return await this.#options.executeTool(request.toolName, request.args, { signal: request.signal });
|
|
101
|
+
}
|
|
102
|
+
const taskTools = this.#options.settings.taskTools ?? defaultCodemodeSettings.taskTools;
|
|
103
|
+
return await runReservedTool(request.toolName, {
|
|
104
|
+
callId: request.callId,
|
|
105
|
+
args: request.args,
|
|
106
|
+
executeTool: this.#options.executeTool,
|
|
107
|
+
taskToolName: taskTools.task,
|
|
108
|
+
taskOutputToolName: taskTools.output,
|
|
109
|
+
listTools: this.#options.listTools,
|
|
110
|
+
signal: request.signal,
|
|
111
|
+
emitStatus: () => {},
|
|
112
|
+
marshalToolResult,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
86
116
|
async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
|
|
87
117
|
if (this.#disposePromise) throw new CodemodeSessionDisposedError();
|
|
88
118
|
// Persistent kernels are reused across cells, but each cell needs its OWN
|
|
@@ -109,6 +139,12 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
109
139
|
return await this.#options.complete(request, ctx);
|
|
110
140
|
}
|
|
111
141
|
|
|
142
|
+
bridgeEndpoint(): BridgeEndpoint {
|
|
143
|
+
const bridge = this.#bridge;
|
|
144
|
+
if (!bridge) throw new Error("codemode bridge server is not running");
|
|
145
|
+
return { port: bridge.port, token: bridge.token };
|
|
146
|
+
}
|
|
147
|
+
|
|
112
148
|
setContext(ctx: ExtensionContext): void {
|
|
113
149
|
this.#context = ctx;
|
|
114
150
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { EvalSchemaToolInfo } from "./bridges/schema-bridge.ts";
|
|
|
5
5
|
import { type CompletionRequest, type CompletionResult, createCompletionHandler } from "./completion/handler.ts";
|
|
6
6
|
import { defaultCodemodeSettings } from "./config/settings.ts";
|
|
7
7
|
import { EvalNotifier } from "./extension/eval-notifier.ts";
|
|
8
|
+
import { EVAL_CELLS_STATUS_KEY, formatEvalCellStatus } from "./extension/eval-status.ts";
|
|
8
9
|
import {
|
|
9
10
|
createExecuteTool,
|
|
10
11
|
createRuntime,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
} from "./extension/runtime-factory.ts";
|
|
14
15
|
import type { CodemodeSessionManager, CreateCodemodeSessionManagerOptions } from "./extension/session-manager.ts";
|
|
15
16
|
import { SessionManagerProxy } from "./extension/session-manager-proxy.ts";
|
|
16
|
-
import { EvalDetachedCellManager } from "./tool/detached-cell-manager.ts";
|
|
17
|
+
import { EvalDetachedCellManager, type EvalDetachedCellStatusEntry } from "./tool/detached-cell-manager.ts";
|
|
17
18
|
import { createEvalTool } from "./tool/eval-tool.ts";
|
|
18
19
|
import { renderEvalCall, renderEvalResult } from "./tool/render.ts";
|
|
19
20
|
|
|
@@ -58,6 +59,18 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
58
59
|
getContext: () => activeContext,
|
|
59
60
|
getMode: () => "wake",
|
|
60
61
|
});
|
|
62
|
+
const showDetachedCells = (entries: readonly EvalDetachedCellStatusEntry[]): void => {
|
|
63
|
+
const ctx = activeContext;
|
|
64
|
+
if (ctx?.ui?.setStatus === undefined) return;
|
|
65
|
+
const status = formatEvalCellStatus(entries);
|
|
66
|
+
const theme = ctx.ui.theme;
|
|
67
|
+
ctx.ui.setStatus(
|
|
68
|
+
EVAL_CELLS_STATUS_KEY,
|
|
69
|
+
status === undefined || ctx.mode !== "tui" || theme === undefined
|
|
70
|
+
? status
|
|
71
|
+
: theme.bg("selectedBg", theme.fg("text", status)),
|
|
72
|
+
);
|
|
73
|
+
};
|
|
61
74
|
const registerEvalForRuntime = (
|
|
62
75
|
runtime: SessionRuntime,
|
|
63
76
|
modelId: string | undefined,
|
|
@@ -101,7 +114,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
101
114
|
listTools: () => pi.getAllTools(),
|
|
102
115
|
complete,
|
|
103
116
|
settings: defaultCodemodeSettings,
|
|
104
|
-
cellManager: new EvalDetachedCellManager({ notifier }),
|
|
117
|
+
cellManager: new EvalDetachedCellManager({ notifier, onStatusChange: showDetachedCells }),
|
|
105
118
|
executionTracker: manager,
|
|
106
119
|
renderers,
|
|
107
120
|
hostLine: hostLine(),
|
|
@@ -126,7 +139,11 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
126
139
|
if (!replaced) return;
|
|
127
140
|
notifier.reset();
|
|
128
141
|
activeContext = ctx;
|
|
129
|
-
const cellManager = new EvalDetachedCellManager({
|
|
142
|
+
const cellManager = new EvalDetachedCellManager({
|
|
143
|
+
artifactsDir: runtime.artifactsDir,
|
|
144
|
+
notifier,
|
|
145
|
+
onStatusChange: showDetachedCells,
|
|
146
|
+
});
|
|
130
147
|
activeCells = cellManager;
|
|
131
148
|
activeRuntime = runtime;
|
|
132
149
|
activeModelId = ctx.model?.id;
|
|
@@ -49,7 +49,7 @@ function parseProgram(code: string): ReturnType<typeof parse> | undefined {
|
|
|
49
49
|
allowSuperOutsideMethod: true,
|
|
50
50
|
allowUndeclaredExports: true,
|
|
51
51
|
errorRecovery: true,
|
|
52
|
-
plugins: ["typescript"
|
|
52
|
+
plugins: ["typescript"],
|
|
53
53
|
});
|
|
54
54
|
} catch (error) {
|
|
55
55
|
if (error instanceof SyntaxError) return undefined;
|
|
@@ -130,6 +130,11 @@ function rewriteImportDeclaration(node: ImportDeclaration): string {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
function dynamicImportEdit(node: AstNode): TextEdit | undefined {
|
|
133
|
+
// Babel 8 parses dynamic import() as an ImportExpression node; Babel 7 used
|
|
134
|
+
// a CallExpression with an Import callee. Handle both shapes.
|
|
135
|
+
if (node.type === "ImportExpression") {
|
|
136
|
+
return { start: node.start, end: node.start + "import".length, text: DYNAMIC_IMPORT_CALLEE };
|
|
137
|
+
}
|
|
133
138
|
if (node.type !== "CallExpression") return undefined;
|
|
134
139
|
const callee = nodeFrom(node.value.callee);
|
|
135
140
|
if (callee?.type !== "Import") return undefined;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type AgentToolResult, sanitizeTerminalLabel } from "@code-yeongyu/senpi";
|
|
2
|
+
|
|
3
|
+
export const MAX_ENRICHED_TOOL_CALLS = 30;
|
|
4
|
+
|
|
5
|
+
const MAX_ARGUMENT_STRING_CODE_POINTS = 512;
|
|
6
|
+
const MAX_ARGUMENT_ENTRIES = 32;
|
|
7
|
+
const MAX_ARGUMENT_DEPTH = 6;
|
|
8
|
+
const MAX_SERIALIZED_ARGUMENT_LENGTH = 4096;
|
|
9
|
+
const MAX_RESULT_PREVIEW_CODE_POINTS = 160;
|
|
10
|
+
|
|
11
|
+
type BoundedValue = {
|
|
12
|
+
readonly value: unknown;
|
|
13
|
+
readonly truncated: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function capCodePoints(text: string, max: number): string {
|
|
17
|
+
let end = 0;
|
|
18
|
+
let count = 0;
|
|
19
|
+
while (count < max && end < text.length) {
|
|
20
|
+
const firstCodeUnit = text.charCodeAt(end);
|
|
21
|
+
const secondCodeUnit = text.charCodeAt(end + 1);
|
|
22
|
+
const isSurrogatePair =
|
|
23
|
+
firstCodeUnit >= 0xd800 && firstCodeUnit <= 0xdbff && secondCodeUnit >= 0xdc00 && secondCodeUnit <= 0xdfff;
|
|
24
|
+
end += isSurrogatePair ? 2 : 1;
|
|
25
|
+
count += 1;
|
|
26
|
+
}
|
|
27
|
+
return end === text.length ? text : `${text.slice(0, end)}…`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function boundValue(value: unknown, depth: number, ancestors: WeakSet<object>): BoundedValue {
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
const capped = capCodePoints(value, MAX_ARGUMENT_STRING_CODE_POINTS);
|
|
33
|
+
return { value: capped, truncated: capped !== value };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (value === null || typeof value !== "object") return { value, truncated: false };
|
|
37
|
+
if (depth >= MAX_ARGUMENT_DEPTH) return { value: "…", truncated: true };
|
|
38
|
+
if (ancestors.has(value)) throw new Error("cyclic tool-call arguments");
|
|
39
|
+
|
|
40
|
+
ancestors.add(value);
|
|
41
|
+
try {
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
const retainedLength = Math.min(value.length, MAX_ARGUMENT_ENTRIES);
|
|
44
|
+
const clone: unknown[] = [];
|
|
45
|
+
let truncated = value.length > MAX_ARGUMENT_ENTRIES;
|
|
46
|
+
for (let index = 0; index < retainedLength; index += 1) {
|
|
47
|
+
const bounded = boundValue(value[index], depth + 1, ancestors);
|
|
48
|
+
clone.push(bounded.value);
|
|
49
|
+
truncated ||= bounded.truncated;
|
|
50
|
+
}
|
|
51
|
+
return { value: clone, truncated };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entries: [string, unknown][] = [];
|
|
55
|
+
let truncated = false;
|
|
56
|
+
let count = 0;
|
|
57
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
58
|
+
if (count >= MAX_ARGUMENT_ENTRIES) {
|
|
59
|
+
truncated = true;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
const bounded = boundValue(nestedValue, depth + 1, ancestors);
|
|
63
|
+
entries.push([key, bounded.value]);
|
|
64
|
+
truncated ||= bounded.truncated;
|
|
65
|
+
count += 1;
|
|
66
|
+
}
|
|
67
|
+
return { value: Object.fromEntries(entries), truncated };
|
|
68
|
+
} finally {
|
|
69
|
+
ancestors.delete(value);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function boundToolCallArgs(args: unknown): { args: unknown; truncated: boolean } {
|
|
74
|
+
try {
|
|
75
|
+
const bounded = boundValue(args, 0, new WeakSet<object>());
|
|
76
|
+
const serialized = JSON.stringify(bounded.value);
|
|
77
|
+
if (typeof serialized !== "string" || serialized.length > MAX_SERIALIZED_ARGUMENT_LENGTH) {
|
|
78
|
+
return { args: undefined, truncated: true };
|
|
79
|
+
}
|
|
80
|
+
return { args: bounded.value, truncated: bounded.truncated };
|
|
81
|
+
} catch {
|
|
82
|
+
return { args: undefined, truncated: true };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toolCallResultPreview(result: AgentToolResult<unknown>): string | undefined {
|
|
87
|
+
for (const part of result.content) {
|
|
88
|
+
if (part.type !== "text") continue;
|
|
89
|
+
const preview = sanitizeTerminalLabel(part.text).replace(/\s+/gu, " ").trim();
|
|
90
|
+
return preview.length === 0 ? undefined : capCodePoints(preview, MAX_RESULT_PREVIEW_CODE_POINTS);
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
package/src/tool/cell-handler.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
type AgentToolUpdateCallback,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
sanitizeTerminalLabel,
|
|
6
|
+
} from "@code-yeongyu/senpi";
|
|
2
7
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
3
8
|
import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
|
|
4
9
|
import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
|
|
@@ -7,6 +12,7 @@ import { appendSchemaHint } from "../bridges/schema-hint.ts";
|
|
|
7
12
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
8
13
|
import { handleCompletionToolCall } from "../completion/tool-bridge.ts";
|
|
9
14
|
import type { ResolvedCodemodeSettings } from "../config/settings.ts";
|
|
15
|
+
import { boundToolCallArgs, capCodePoints, MAX_ENRICHED_TOOL_CALLS, toolCallResultPreview } from "./call-capture.ts";
|
|
10
16
|
import {
|
|
11
17
|
type EvalImageResizer,
|
|
12
18
|
EvalOutputCollector,
|
|
@@ -17,7 +23,19 @@ import {
|
|
|
17
23
|
import { upsertStatusEvent } from "./status-events.ts";
|
|
18
24
|
import type { EvalKernel, EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
19
25
|
|
|
20
|
-
type ResolvedToolReply = {
|
|
26
|
+
type ResolvedToolReply = {
|
|
27
|
+
readonly value: unknown;
|
|
28
|
+
readonly toolCallOk: boolean;
|
|
29
|
+
readonly resultPreview?: string;
|
|
30
|
+
readonly errorText?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ToolCallEnrichment = {
|
|
34
|
+
readonly callId: string;
|
|
35
|
+
readonly args: unknown;
|
|
36
|
+
readonly startedAt: number;
|
|
37
|
+
readonly argsTruncated?: true;
|
|
38
|
+
};
|
|
21
39
|
|
|
22
40
|
const LIVE_OUTPUT_PREVIEW_LINES = 8;
|
|
23
41
|
|
|
@@ -187,20 +205,53 @@ export class CellHandler {
|
|
|
187
205
|
this.#emitUpdate(false);
|
|
188
206
|
return;
|
|
189
207
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
208
|
+
const capturedArgs = boundToolCallArgs(message.args);
|
|
209
|
+
const startedAt = Date.now();
|
|
210
|
+
await this.#deliverToolReply(
|
|
211
|
+
message,
|
|
212
|
+
async () => {
|
|
213
|
+
const result = await this.#runtime.executeTool(message.toolName, message.args, {
|
|
214
|
+
signal: this.#state.signal,
|
|
215
|
+
});
|
|
216
|
+
const toolCallOk = !toolResultIsError(result);
|
|
217
|
+
if (toolCallOk) {
|
|
218
|
+
const resultPreview = toolCallResultPreview(result);
|
|
219
|
+
return {
|
|
220
|
+
value: marshalToolResult(result),
|
|
221
|
+
toolCallOk,
|
|
222
|
+
...(resultPreview === undefined ? {} : { resultPreview }),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
let errorText: string | undefined;
|
|
226
|
+
for (const part of result.content) {
|
|
227
|
+
if (part.type !== "text") continue;
|
|
228
|
+
errorText = capCodePoints(sanitizeTerminalLabel(part.text), 512);
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
value: marshalToolResult(result),
|
|
233
|
+
toolCallOk,
|
|
234
|
+
...(errorText === undefined ? {} : { errorText }),
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
callId: message.callId,
|
|
239
|
+
args: capturedArgs.args,
|
|
240
|
+
startedAt,
|
|
241
|
+
...(capturedArgs.truncated ? { argsTruncated: true } : {}),
|
|
242
|
+
},
|
|
243
|
+
);
|
|
194
244
|
}
|
|
195
245
|
|
|
196
246
|
async #deliverToolReply(
|
|
197
247
|
message: Extract<KernelToHostMessage, { type: "tool-call" }>,
|
|
198
248
|
resolve: () => Promise<ResolvedToolReply>,
|
|
249
|
+
enrich?: ToolCallEnrichment,
|
|
199
250
|
): Promise<void> {
|
|
200
251
|
try {
|
|
201
252
|
const reply = await resolve();
|
|
202
253
|
if (!this.#state.active) return;
|
|
203
|
-
this.#
|
|
254
|
+
this.#pushToolCall(message.toolName, reply.toolCallOk, enrich, reply.resultPreview, reply.errorText);
|
|
204
255
|
this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: true, value: reply.value });
|
|
205
256
|
} catch (error) {
|
|
206
257
|
if (!this.#state.active) return;
|
|
@@ -209,7 +260,7 @@ export class CellHandler {
|
|
|
209
260
|
message.toolName,
|
|
210
261
|
this.#toolParameters(message.toolName),
|
|
211
262
|
);
|
|
212
|
-
this.#
|
|
263
|
+
this.#pushToolCall(message.toolName, false, enrich, undefined, text);
|
|
213
264
|
this.#kernel.deliverToolReply({
|
|
214
265
|
type: "tool-reply",
|
|
215
266
|
callId: message.callId,
|
|
@@ -220,6 +271,29 @@ export class CellHandler {
|
|
|
220
271
|
this.#emitUpdate(false);
|
|
221
272
|
}
|
|
222
273
|
|
|
274
|
+
#pushToolCall(
|
|
275
|
+
name: string,
|
|
276
|
+
ok: boolean,
|
|
277
|
+
enrich: ToolCallEnrichment | undefined,
|
|
278
|
+
resultPreview: string | undefined,
|
|
279
|
+
error: string | undefined,
|
|
280
|
+
): void {
|
|
281
|
+
const summary = { name, ok, ...(error === undefined ? {} : { error }) };
|
|
282
|
+
const enrichedCount = this.#state.toolCalls.filter((toolCall) => toolCall.callId !== undefined).length;
|
|
283
|
+
if (enrich === undefined || enrichedCount >= MAX_ENRICHED_TOOL_CALLS) {
|
|
284
|
+
this.#state.toolCalls.push(summary);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.#state.toolCalls.push({
|
|
288
|
+
...summary,
|
|
289
|
+
callId: enrich.callId,
|
|
290
|
+
args: enrich.args,
|
|
291
|
+
durationMs: Date.now() - enrich.startedAt,
|
|
292
|
+
...(enrich.argsTruncated === true ? { argsTruncated: true } : {}),
|
|
293
|
+
...(resultPreview === undefined ? {} : { resultPreview }),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
223
297
|
#toolParameters(toolName: string): unknown {
|
|
224
298
|
return this.#runtime.listTools?.().find((tool) => tool.name === toolName)?.parameters;
|
|
225
299
|
}
|
|
@@ -41,9 +41,18 @@ export interface EvalDetachedCellNotifier {
|
|
|
41
41
|
notify(cells: readonly EvalDetachedCellNotification[]): void;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** One live detached cell, as shown in the footer status line. */
|
|
45
|
+
export interface EvalDetachedCellStatusEntry {
|
|
46
|
+
readonly cellId: string;
|
|
47
|
+
readonly language: EvalLanguage;
|
|
48
|
+
readonly title?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
export interface EvalDetachedCellManagerOptions {
|
|
45
52
|
readonly artifactsDir?: string;
|
|
46
53
|
readonly notifier?: EvalDetachedCellNotifier;
|
|
54
|
+
/** Called with every detached cell whenever that set changes; empty clears the status. */
|
|
55
|
+
readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
/**
|
|
@@ -56,6 +65,7 @@ export interface EvalDetachedCellManagerOptions {
|
|
|
56
65
|
export class EvalDetachedCellManager {
|
|
57
66
|
readonly #artifactsDir: string | undefined;
|
|
58
67
|
readonly #notifier: EvalDetachedCellNotifier | undefined;
|
|
68
|
+
readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
|
|
59
69
|
readonly #cells = new Map<string, ManagedCell>();
|
|
60
70
|
readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
|
|
61
71
|
#notificationQueue: ManagedCell[] = [];
|
|
@@ -64,11 +74,15 @@ export class EvalDetachedCellManager {
|
|
|
64
74
|
constructor(options: EvalDetachedCellManagerOptions = {}) {
|
|
65
75
|
this.#artifactsDir = options.artifactsDir;
|
|
66
76
|
this.#notifier = options.notifier;
|
|
77
|
+
this.#onStatusChange = options.onStatusChange;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
create(cellId: string, input: EvalToolInput): ManagedCell {
|
|
70
81
|
const existing = this.#cells.get(cellId);
|
|
71
|
-
if (existing !== undefined)
|
|
82
|
+
if (existing !== undefined) {
|
|
83
|
+
if (existing.state === "running" || existing.state === "detached") throw activeCellReuseError(existing);
|
|
84
|
+
this.#cells.delete(cellId);
|
|
85
|
+
}
|
|
72
86
|
const spillPath =
|
|
73
87
|
this.#artifactsDir === undefined
|
|
74
88
|
? undefined
|
|
@@ -102,6 +116,7 @@ export class EvalDetachedCellManager {
|
|
|
102
116
|
if (!cell.canDetach || !this.#transition(cell, "detached")) return false;
|
|
103
117
|
cell.wasDetached = true;
|
|
104
118
|
this.#detachedByLanguage.set(cell.input.language, cell);
|
|
119
|
+
this.#emitStatus();
|
|
105
120
|
return true;
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -161,11 +176,24 @@ export class EvalDetachedCellManager {
|
|
|
161
176
|
if (cell.wasDetached && next !== "detached") {
|
|
162
177
|
if (this.#detachedByLanguage.get(cell.input.language) === cell)
|
|
163
178
|
this.#detachedByLanguage.delete(cell.input.language);
|
|
179
|
+
this.#emitStatus();
|
|
164
180
|
this.#queueNotification(cell);
|
|
165
181
|
}
|
|
166
182
|
return true;
|
|
167
183
|
}
|
|
168
184
|
|
|
185
|
+
#emitStatus(): void {
|
|
186
|
+
const emit = this.#onStatusChange;
|
|
187
|
+
if (emit === undefined) return;
|
|
188
|
+
emit(
|
|
189
|
+
[...this.#detachedByLanguage.values()].map((cell) => ({
|
|
190
|
+
cellId: cell.cellId,
|
|
191
|
+
language: cell.input.language,
|
|
192
|
+
...(cell.input.title === undefined ? {} : { title: cell.input.title }),
|
|
193
|
+
})),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
#queueNotification(cell: ManagedCell): void {
|
|
170
198
|
if (cell.notificationQueued) return;
|
|
171
199
|
cell.notificationQueued = true;
|
|
@@ -232,6 +260,12 @@ export class EvalDetachedCellManager {
|
|
|
232
260
|
}
|
|
233
261
|
}
|
|
234
262
|
|
|
263
|
+
function activeCellReuseError(cell: ManagedCell): Error {
|
|
264
|
+
return new Error(
|
|
265
|
+
`Eval cell ${cell.cellId} from a previous call is still ${cell.state} in the ${cell.input.language} kernel. Use eval({ action: "peek", cell_id: "${cell.cellId}" }) to read it or eval({ action: "stop", cell_id: "${cell.cellId}" }) to end it before its id can be reused.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
235
269
|
function allowsTransition(from: EvalDetachedCellState, to: EvalDetachedCellState): boolean {
|
|
236
270
|
if (from === "running") return to === "detached" || to === "completed" || to === "failed" || to === "cancelled";
|
|
237
271
|
if (from === "detached") return to === "completed" || to === "failed" || to === "cancelled";
|
package/src/tool/render.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
JSON_TREE_SCALAR_LEN_EXPANDED,
|
|
20
20
|
renderJsonTreeLines,
|
|
21
21
|
} from "./json-tree.ts";
|
|
22
|
+
import { codePointPrefix, formatDuration, renderToolCallWidget } from "./tool-widgets.ts";
|
|
22
23
|
import type {
|
|
23
24
|
EvalCellResult,
|
|
24
25
|
EvalInputSchema,
|
|
@@ -116,18 +117,6 @@ function appendLines(target: string[], source: readonly string[]): void {
|
|
|
116
117
|
for (const line of source) target.push(line);
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function codePointPrefix(text: string, maxCodePoints: number): string {
|
|
120
|
-
let end = 0;
|
|
121
|
-
for (let count = 0; count < maxCodePoints && end < text.length; count += 1) {
|
|
122
|
-
const firstCodeUnit = text.charCodeAt(end);
|
|
123
|
-
const secondCodeUnit = text.charCodeAt(end + 1);
|
|
124
|
-
const isSurrogatePair =
|
|
125
|
-
firstCodeUnit >= 0xd800 && firstCodeUnit <= 0xdbff && secondCodeUnit >= 0xdc00 && secondCodeUnit <= 0xdfff;
|
|
126
|
-
end += isSurrogatePair ? 2 : 1;
|
|
127
|
-
}
|
|
128
|
-
return text.slice(0, end);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
120
|
function renderAllVisualLines(text: string, width: number): string[] {
|
|
132
121
|
return truncateToVisualLines(text, Number.POSITIVE_INFINITY, width).visualLines.map((line) => line.trimEnd());
|
|
133
122
|
}
|
|
@@ -232,18 +221,6 @@ function highlightedCode(code: string, language: EvalLanguage, theme: Theme | un
|
|
|
232
221
|
return (theme === undefined ? lines.map((line) => line.replace(/\u001b\[[0-9;]*m/gu, "")) : lines).join("\n");
|
|
233
222
|
}
|
|
234
223
|
|
|
235
|
-
function formatDuration(milliseconds: number): string {
|
|
236
|
-
const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1_000);
|
|
237
|
-
if (totalSeconds < 1) return "<1s";
|
|
238
|
-
const seconds = totalSeconds % 60;
|
|
239
|
-
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
240
|
-
if (totalMinutes < 1) return `${seconds}s`;
|
|
241
|
-
const minutes = totalMinutes % 60;
|
|
242
|
-
const hours = Math.floor(totalMinutes / 60);
|
|
243
|
-
if (hours < 1) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
|
244
|
-
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
224
|
function spinner(frame: number | undefined): string {
|
|
248
225
|
return SPINNER_FRAMES.at((frame ?? 0) % SPINNER_FRAMES.length) ?? SPINNER_FRAMES[0];
|
|
249
226
|
}
|
|
@@ -695,6 +672,46 @@ function toolCallRows(details: EvalToolDetails | undefined): ToolCallRow[] {
|
|
|
695
672
|
});
|
|
696
673
|
}
|
|
697
674
|
|
|
675
|
+
function nestedToolCallBlock(
|
|
676
|
+
details: EvalToolDetails | undefined,
|
|
677
|
+
theme: Theme | undefined,
|
|
678
|
+
cwd: string,
|
|
679
|
+
expanded: boolean,
|
|
680
|
+
): RenderBlock | undefined {
|
|
681
|
+
const toolCalls = details?.toolCalls;
|
|
682
|
+
if (theme === undefined || toolCalls === undefined || !toolCalls.some((call) => call.args !== undefined)) {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
const legacyRows = toolCallRows(details);
|
|
686
|
+
return {
|
|
687
|
+
kind: "dynamic",
|
|
688
|
+
render: (width) => {
|
|
689
|
+
const retainedCalls = expanded ? toolCalls : toolCalls.slice(-TOOL_CALL_PREVIEW_COUNT);
|
|
690
|
+
const retainedRows = expanded ? legacyRows : legacyRows.slice(-TOOL_CALL_PREVIEW_COUNT);
|
|
691
|
+
const skippedCount = toolCalls.length - retainedCalls.length;
|
|
692
|
+
const toolCallNoun = skippedCount === 1 ? "call" : "calls";
|
|
693
|
+
const lines =
|
|
694
|
+
expanded || skippedCount === 0
|
|
695
|
+
? []
|
|
696
|
+
: renderAllVisualLines(style(theme, "muted", `${skippedCount} earlier tool ${toolCallNoun}`), width);
|
|
697
|
+
const legacyBlock: Extract<RenderBlock, { kind: "toolCalls" }> = {
|
|
698
|
+
kind: "toolCalls",
|
|
699
|
+
calls: retainedRows,
|
|
700
|
+
expanded,
|
|
701
|
+
theme,
|
|
702
|
+
};
|
|
703
|
+
for (const [index, call] of retainedCalls.entries()) {
|
|
704
|
+
if (call.args !== undefined) {
|
|
705
|
+
appendLines(lines, renderToolCallWidget(call, { cwd, theme, expanded, width }));
|
|
706
|
+
} else {
|
|
707
|
+
appendLines(lines, renderToolCall(retainedRows[index], legacyBlock, width));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return lines;
|
|
711
|
+
},
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
698
715
|
function resultStatus(
|
|
699
716
|
details: EvalToolDetails | undefined,
|
|
700
717
|
options: ToolRenderResultOptions,
|
|
@@ -732,7 +749,7 @@ function resultMetadata(
|
|
|
732
749
|
): RenderBlock[] {
|
|
733
750
|
const metadata: string[] = [];
|
|
734
751
|
if (details?.phase) metadata.push(`phase ${details.phase}`);
|
|
735
|
-
if (!options.isPartial && details) metadata.push(`took ${details.durationMs}ms`);
|
|
752
|
+
if (!options.isPartial && typeof details?.durationMs === "number") metadata.push(`took ${details.durationMs}ms`);
|
|
736
753
|
if (metadata.length === 0) return [];
|
|
737
754
|
return [{ kind: "text", text: style(theme, "muted", metadata.join(" | ")) }];
|
|
738
755
|
}
|
|
@@ -824,7 +841,9 @@ export function renderEvalResult(
|
|
|
824
841
|
},
|
|
825
842
|
];
|
|
826
843
|
const calls = toolCallRows(details);
|
|
827
|
-
|
|
844
|
+
const nestedCalls = nestedToolCallBlock(details, theme, context.cwd, expanded);
|
|
845
|
+
if (calls.length > 0)
|
|
846
|
+
blocks.push({ kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme });
|
|
828
847
|
component.setBlocks(blocks);
|
|
829
848
|
return component;
|
|
830
849
|
}
|
|
@@ -888,7 +907,8 @@ export function renderEvalResult(
|
|
|
888
907
|
);
|
|
889
908
|
}
|
|
890
909
|
const calls = toolCallRows(details);
|
|
891
|
-
|
|
910
|
+
const nestedCalls = nestedToolCallBlock(details, theme, context.cwd, expanded);
|
|
911
|
+
if (calls.length > 0) blocks.push({ kind: "blank" }, nestedCalls ?? { kind: "toolCalls", calls, expanded, theme });
|
|
892
912
|
if (details?.notice !== undefined)
|
|
893
913
|
blocks.push({ kind: "blank" }, { kind: "text", text: style(theme, "dim", details.notice) });
|
|
894
914
|
const warning = formatTruncationWarning(details?.meta) ?? (details?.truncated ? "[eval output truncated]" : null);
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBashToolDefinition,
|
|
3
|
+
createFindToolDefinition,
|
|
4
|
+
createGrepToolDefinition,
|
|
5
|
+
createLsToolDefinition,
|
|
6
|
+
createReadToolDefinition,
|
|
7
|
+
createWriteToolDefinition,
|
|
8
|
+
sanitizeTerminalLabel,
|
|
9
|
+
type Theme,
|
|
10
|
+
type ThemeColor,
|
|
11
|
+
type ToolDefinition,
|
|
12
|
+
truncateToVisualLines,
|
|
13
|
+
} from "@code-yeongyu/senpi";
|
|
14
|
+
import type { TSchema } from "typebox";
|
|
15
|
+
import { Check } from "typebox/value";
|
|
16
|
+
import type { EvalToolCallSummary } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export interface WidgetOptions {
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly theme: Theme | undefined;
|
|
21
|
+
readonly expanded: boolean;
|
|
22
|
+
readonly width: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type AnyToolDef<TParams extends TSchema, TDetails, TState> = ToolDefinition<TParams, TDetails, TState>;
|
|
26
|
+
type RenderCallOf<TParams extends TSchema, TDetails, TState> = NonNullable<
|
|
27
|
+
AnyToolDef<TParams, TDetails, TState>["renderCall"]
|
|
28
|
+
>;
|
|
29
|
+
type ToolRenderContext<TParams extends TSchema, TDetails, TState> = Parameters<
|
|
30
|
+
RenderCallOf<TParams, TDetails, TState>
|
|
31
|
+
>[2];
|
|
32
|
+
type Component<TParams extends TSchema, TDetails, TState> = ReturnType<RenderCallOf<TParams, TDetails, TState>>;
|
|
33
|
+
|
|
34
|
+
type CoreWidgetName = "bash" | "read" | "write" | "grep" | "find" | "ls";
|
|
35
|
+
type CoreWidgetRenderer = (summary: EvalToolCallSummary, options: WidgetOptions) => string[] | undefined;
|
|
36
|
+
type CoreWidgets = Readonly<Record<CoreWidgetName, CoreWidgetRenderer>>;
|
|
37
|
+
|
|
38
|
+
const MIN_RENDERER_WIDTH = 20;
|
|
39
|
+
const MAX_RENDERER_LINES = 5;
|
|
40
|
+
const MAX_FALLBACK_ARGUMENT_CODE_POINTS = 120;
|
|
41
|
+
const MAX_COLLAPSED_ERROR_CODE_POINTS = 512;
|
|
42
|
+
const MAX_COLLAPSED_ERROR_LINES = 4;
|
|
43
|
+
const MAX_PREVIEW_LINES = 2;
|
|
44
|
+
const MAX_COLLAPSED_WIDGET_LINES = 8;
|
|
45
|
+
const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]";
|
|
46
|
+
const WIDGET_TRUNCATION_MARKER = "… (widget truncated)";
|
|
47
|
+
const TERMINAL_ESCAPE_PATTERN =
|
|
48
|
+
/(?:\u001B\][\s\S]*?(?:\u0007|\u001B\\|\u009C))|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
|
|
49
|
+
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g;
|
|
50
|
+
const coreWidgetsByCwd = new Map<string, CoreWidgets>();
|
|
51
|
+
|
|
52
|
+
export function codePointPrefix(text: string, maxCodePoints: number): string {
|
|
53
|
+
let end = 0;
|
|
54
|
+
for (let count = 0; count < maxCodePoints && end < text.length; count += 1) {
|
|
55
|
+
const firstCodeUnit = text.charCodeAt(end);
|
|
56
|
+
const secondCodeUnit = text.charCodeAt(end + 1);
|
|
57
|
+
const isSurrogatePair =
|
|
58
|
+
firstCodeUnit >= 0xd800 && firstCodeUnit <= 0xdbff && secondCodeUnit >= 0xdc00 && secondCodeUnit <= 0xdfff;
|
|
59
|
+
end += isSurrogatePair ? 2 : 1;
|
|
60
|
+
}
|
|
61
|
+
return text.slice(0, end);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatDuration(milliseconds: number): string {
|
|
65
|
+
const totalSeconds = Math.floor(Math.max(0, milliseconds) / 1_000);
|
|
66
|
+
if (totalSeconds < 1) return "<1s";
|
|
67
|
+
const seconds = totalSeconds % 60;
|
|
68
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
69
|
+
if (totalMinutes < 1) return `${seconds}s`;
|
|
70
|
+
const minutes = totalMinutes % 60;
|
|
71
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
72
|
+
if (hours < 1) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
|
73
|
+
return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function style(theme: Theme | undefined, color: ThemeColor, text: string): string {
|
|
77
|
+
return theme === undefined ? text : theme.fg(color, text);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderAllVisualLines(text: string, width: number): string[] {
|
|
81
|
+
return truncateToVisualLines(text, Number.POSITIVE_INFINITY, width).visualLines.map((line) => line.trimEnd());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderFirstVisualLines(text: string, maxLines: number, width: number): string[] {
|
|
85
|
+
const truncated = truncateToVisualLines(text, maxLines, width);
|
|
86
|
+
if (truncated.skippedCount === 0) return truncated.visualLines.map((line) => line.trimEnd());
|
|
87
|
+
return renderAllVisualLines(text, width).slice(0, maxLines);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function indent(lines: readonly string[]): string[] {
|
|
91
|
+
return lines.map((line) => ` ${line}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderWith<TParams extends TSchema, TDetails, TState>(
|
|
95
|
+
definition: ToolDefinition<TParams, TDetails, TState>,
|
|
96
|
+
summary: EvalToolCallSummary,
|
|
97
|
+
options: WidgetOptions,
|
|
98
|
+
initialState: TState,
|
|
99
|
+
): string[] | undefined {
|
|
100
|
+
let preparedArgs: unknown = summary.args;
|
|
101
|
+
try {
|
|
102
|
+
if (definition.prepareArguments !== undefined) preparedArgs = definition.prepareArguments(preparedArgs);
|
|
103
|
+
if (!Check(definition.parameters, preparedArgs)) return undefined;
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const theme = options.theme;
|
|
109
|
+
const innerWidth = options.width - 2;
|
|
110
|
+
const renderCall = definition.renderCall;
|
|
111
|
+
if (theme === undefined || innerWidth < MIN_RENDERER_WIDTH || renderCall === undefined) return undefined;
|
|
112
|
+
|
|
113
|
+
const context: ToolRenderContext<TParams, TDetails, TState> = {
|
|
114
|
+
args: preparedArgs,
|
|
115
|
+
toolCallId: summary.callId ?? summary.name,
|
|
116
|
+
invalidate: () => {},
|
|
117
|
+
lastComponent: undefined,
|
|
118
|
+
state: initialState,
|
|
119
|
+
cwd: options.cwd,
|
|
120
|
+
executionStarted: true,
|
|
121
|
+
argsComplete: true,
|
|
122
|
+
isPartial: false,
|
|
123
|
+
expanded: options.expanded,
|
|
124
|
+
showImages: false,
|
|
125
|
+
isError: !summary.ok,
|
|
126
|
+
hasResult: false,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const component: Component<TParams, TDetails, TState> = renderCall(preparedArgs, theme, context);
|
|
131
|
+
const renderedLines = component.render(innerWidth).map((line) => line.trimEnd());
|
|
132
|
+
const retainedLines = renderedLines.slice(0, MAX_RENDERER_LINES);
|
|
133
|
+
if (renderedLines.length > retainedLines.length) {
|
|
134
|
+
retainedLines.push(style(theme, "muted", WIDGET_TRUNCATION_MARKER));
|
|
135
|
+
}
|
|
136
|
+
return indent(retainedLines);
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createCoreWidgets(cwd: string): CoreWidgets {
|
|
143
|
+
const bashDefinition = createBashToolDefinition(cwd);
|
|
144
|
+
const readDefinition = createReadToolDefinition(cwd);
|
|
145
|
+
const writeDefinition = createWriteToolDefinition(cwd);
|
|
146
|
+
const grepDefinition = createGrepToolDefinition(cwd);
|
|
147
|
+
const findDefinition = createFindToolDefinition(cwd);
|
|
148
|
+
const lsDefinition = createLsToolDefinition(cwd);
|
|
149
|
+
const widgets: CoreWidgets = {
|
|
150
|
+
bash: (summary, options) =>
|
|
151
|
+
renderWith(bashDefinition, summary, options, {
|
|
152
|
+
startedAt: undefined,
|
|
153
|
+
endedAt: undefined,
|
|
154
|
+
interval: undefined,
|
|
155
|
+
}),
|
|
156
|
+
read: (summary, options) => renderWith(readDefinition, summary, options, {}),
|
|
157
|
+
write: (summary, options) => renderWith(writeDefinition, summary, options, {}),
|
|
158
|
+
grep: (summary, options) => renderWith(grepDefinition, summary, options, {}),
|
|
159
|
+
find: (summary, options) => renderWith(findDefinition, summary, options, {}),
|
|
160
|
+
ls: (summary, options) => renderWith(lsDefinition, summary, options, {}),
|
|
161
|
+
};
|
|
162
|
+
return widgets;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function coreWidgetsFor(cwd: string): CoreWidgets {
|
|
166
|
+
const cached = coreWidgetsByCwd.get(cwd);
|
|
167
|
+
if (cached !== undefined) return cached;
|
|
168
|
+
const widgets = createCoreWidgets(cwd);
|
|
169
|
+
coreWidgetsByCwd.set(cwd, widgets);
|
|
170
|
+
return widgets;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isCoreWidgetName(name: string): name is CoreWidgetName {
|
|
174
|
+
switch (name) {
|
|
175
|
+
case "bash":
|
|
176
|
+
case "read":
|
|
177
|
+
case "write":
|
|
178
|
+
case "grep":
|
|
179
|
+
case "find":
|
|
180
|
+
case "ls":
|
|
181
|
+
return true;
|
|
182
|
+
default:
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function compactArguments(args: unknown): string {
|
|
188
|
+
if (args === undefined) return "";
|
|
189
|
+
try {
|
|
190
|
+
const serialized = JSON.stringify(args);
|
|
191
|
+
if (serialized === undefined) return "";
|
|
192
|
+
return codePointPrefix(sanitizeTerminalLabel(serialized), MAX_FALLBACK_ARGUMENT_CODE_POINTS);
|
|
193
|
+
} catch {
|
|
194
|
+
return "[unserializable args]";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderFallback(summary: EvalToolCallSummary, width: number): string[] {
|
|
199
|
+
const name = sanitizeTerminalLabel(summary.name);
|
|
200
|
+
const fallback = `tool.${name}(${compactArguments(summary.args)})`;
|
|
201
|
+
return indent(renderAllVisualLines(fallback, width));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderStatus(summary: EvalToolCallSummary, options: WidgetOptions, width: number): string[] {
|
|
205
|
+
const icon = style(options.theme, summary.ok ? "success" : "error", summary.ok ? "✓" : "✗");
|
|
206
|
+
const parts = [icon];
|
|
207
|
+
if (summary.durationMs !== undefined) parts.push(formatDuration(summary.durationMs));
|
|
208
|
+
if (summary.argsTruncated) parts.push("(args truncated)");
|
|
209
|
+
return indent(renderFirstVisualLines(parts.join(" "), 1, width));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sanitizeTerminalControl(text: string): string {
|
|
213
|
+
return text.replace(TERMINAL_ESCAPE_PATTERN, "").replace(TERMINAL_CONTROL_PATTERN, " ");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderPreview(preview: string, options: WidgetOptions, width: number): string[] {
|
|
217
|
+
const sanitized = sanitizeTerminalControl(preview);
|
|
218
|
+
if (sanitized.length === 0) return [];
|
|
219
|
+
return indent(renderFirstVisualLines(style(options.theme, "muted", sanitized), MAX_PREVIEW_LINES, width));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderCollapsedError(error: string, options: WidgetOptions, width: number): string[] {
|
|
223
|
+
const guardedError = codePointPrefix(error, MAX_COLLAPSED_ERROR_CODE_POINTS);
|
|
224
|
+
const guardedLines = renderAllVisualLines(style(options.theme, "error", guardedError), width);
|
|
225
|
+
if (guardedError.length === error.length && guardedLines.length <= MAX_COLLAPSED_ERROR_LINES) {
|
|
226
|
+
return indent(guardedLines);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const markerLines = renderAllVisualLines(style(options.theme, "muted", TOOL_ERROR_OMISSION_MARKER), width);
|
|
230
|
+
const errorBudget = Math.max(0, MAX_COLLAPSED_ERROR_LINES - markerLines.length);
|
|
231
|
+
const lines = guardedLines.slice(0, errorBudget);
|
|
232
|
+
lines.push(...markerLines.slice(0, MAX_COLLAPSED_ERROR_LINES - lines.length));
|
|
233
|
+
return indent(lines);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderError(error: string, options: WidgetOptions, width: number): string[] {
|
|
237
|
+
const sanitized = sanitizeTerminalControl(error);
|
|
238
|
+
if (options.expanded) return indent(renderAllVisualLines(style(options.theme, "error", sanitized), width));
|
|
239
|
+
return renderCollapsedError(sanitized, options, width);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The theme-less fallback is QA-only. Production's gate lives in render.ts:
|
|
244
|
+
* nestedToolCallBlock routes theme-less renders to legacy rows before this function is called.
|
|
245
|
+
*/
|
|
246
|
+
export function renderToolCallWidget(summary: EvalToolCallSummary, options: WidgetOptions): string[] {
|
|
247
|
+
const innerWidth = Math.max(1, options.width - 2);
|
|
248
|
+
const coreLines = isCoreWidgetName(summary.name)
|
|
249
|
+
? coreWidgetsFor(options.cwd)[summary.name](summary, options)
|
|
250
|
+
: undefined;
|
|
251
|
+
const lines = coreLines ?? renderFallback(summary, innerWidth);
|
|
252
|
+
lines.push(...renderStatus(summary, options, innerWidth));
|
|
253
|
+
if (summary.ok && summary.resultPreview !== undefined) {
|
|
254
|
+
lines.push(...renderPreview(summary.resultPreview, options, innerWidth));
|
|
255
|
+
} else if (!summary.ok && summary.error !== undefined) {
|
|
256
|
+
lines.push(...renderError(summary.error, options, innerWidth));
|
|
257
|
+
}
|
|
258
|
+
return options.expanded ? lines : lines.slice(0, MAX_COLLAPSED_WIDGET_LINES);
|
|
259
|
+
}
|
package/src/tool/types.ts
CHANGED
|
@@ -117,6 +117,11 @@ export interface EvalToolCallSummary {
|
|
|
117
117
|
readonly name: string;
|
|
118
118
|
readonly ok: boolean;
|
|
119
119
|
readonly error?: string;
|
|
120
|
+
readonly callId?: string;
|
|
121
|
+
readonly args?: unknown;
|
|
122
|
+
readonly argsTruncated?: boolean;
|
|
123
|
+
readonly durationMs?: number;
|
|
124
|
+
readonly resultPreview?: string;
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
export type EvalStatusEvent = { readonly op: string } & Readonly<Record<string, unknown>>;
|