feat: added strict-mode fallback for OpenAI tool calls with all_strict

- Added `toolStrictMode` support with `all_strict`/`none`/`mixed` options to OpenAI compatibility.
- Fixed OpenAI-completion strict-mode flows by capturing failed HTTP responses and retrying once as non-strict.
- Fixed completion error reporting by surfacing captured status, headers, and JSON `type`/`param`/`code` details.
- Improved strict-schema enforcement with WeakMap memoization and circular-schema detection in sanitization.
- Fixed OpenRouter provider lookup by resolving fallback model IDs for suffix and date variants in registry resolution.
- Refactored benchmark tooling and added async RPC error-window tracking for scheduled run execution.
This commit is contained in:
can1357
2026-04-13 15:46:06 +02:00
parent b69dea4823
commit 212d56bc11
38 changed files with 1492 additions and 548 deletions
@@ -1,4 +1,4 @@
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
import { logger } from "@oh-my-pi/pi-utils";
const DELIVERY_RETRY_BASE_MS = 500;
const DELIVERY_RETRY_MAX_MS = 30_000;
@@ -79,7 +79,7 @@ export function createAnalyzeFileTool(options: {
});
const taskParams: TaskParams = {
agent: "quick_task",
schema: analyzeFileOutputSchema,
schema: JSON.stringify(analyzeFileOutputSchema),
tasks,
};
return taskTool.execute(toolCallId, taskParams, signal, onUpdate);
@@ -51,6 +51,7 @@ const TRAILING_CANONICAL_MARKERS = [
"xhigh",
"free",
"exacto",
"nitro",
"original",
"optimized",
"nvfp4",
@@ -28,7 +28,7 @@ import {
import { isRecord, logger } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { type ConfigError, ConfigFile } from "../config";
import { parseModelString } from "../config/model-resolver";
import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
import {
@@ -160,6 +160,7 @@ const OpenAICompatSchema = Type.Object({
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
extraBody: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
supportsStrictMode: Type.Optional(Type.Boolean()),
toolStrictMode: Type.Optional(Type.Union([Type.Literal("all_strict"), Type.Literal("none")])),
});
const EffortSchema = Type.Union([
@@ -1871,7 +1872,7 @@ export class ModelRegistry {
* Find a model by provider and ID.
*/
find(provider: string, modelId: string): Model<Api> | undefined {
return this.#models.find(m => m.provider === provider && m.id === modelId);
return resolveProviderModelReference(provider, modelId, this.#models);
}
/**
@@ -62,6 +62,101 @@ export function formatModelSelectorValue(selector: string, thinkingLevel: Thinki
return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector;
}
function getOpenRouterRouteSuffix(modelId: string): { baseId: string; suffix: string } | undefined {
const colonIdx = modelId.lastIndexOf(":");
if (colonIdx === -1) {
return undefined;
}
const suffix = modelId.slice(colonIdx + 1).trim();
if (!suffix || parseThinkingLevel(suffix)) {
return undefined;
}
return { baseId: modelId.slice(0, colonIdx), suffix };
}
function stripOpenRouterDateSuffix(modelId: string): string | undefined {
const stripped = modelId.replace(/-\d{8}(?=$|:)/i, "");
return stripped !== modelId ? stripped : undefined;
}
function getOpenRouterFallbackModelIds(modelId: string): string[] {
const orderedCandidates: string[] = [];
const queue = [modelId];
const seen = new Set<string>();
while (queue.length > 0) {
const candidate = queue.shift();
if (!candidate || seen.has(candidate)) {
continue;
}
seen.add(candidate);
orderedCandidates.push(candidate);
const routedSuffix = getOpenRouterRouteSuffix(candidate);
if (routedSuffix) {
queue.push(routedSuffix.baseId);
}
const strippedDate = stripOpenRouterDateSuffix(candidate);
if (strippedDate) {
queue.push(strippedDate);
}
}
return orderedCandidates;
}
function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Model<Api> {
return {
...model,
id: requestedId,
...(model.name === model.id ? { name: requestedId } : {}),
};
}
export function resolveProviderModelReference(
provider: string,
modelId: string,
availableModels: readonly Model<Api>[],
): Model<Api> | undefined {
const normalizedProvider = provider.trim().toLowerCase();
const normalizedModelId = modelId.trim().toLowerCase();
if (!normalizedProvider || !normalizedModelId) {
return undefined;
}
const exactMatches = availableModels.filter(
model => model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === normalizedModelId,
);
if (exactMatches.length === 1) {
return exactMatches[0];
}
if (exactMatches.length > 1) {
return undefined;
}
if (normalizedProvider !== "openrouter") {
return undefined;
}
for (const fallbackId of getOpenRouterFallbackModelIds(modelId).slice(1)) {
const baseMatches = availableModels.filter(
model =>
model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === fallbackId.toLowerCase(),
);
if (baseMatches.length === 1) {
return cloneModelWithRequestedId(baseMatches[0], modelId);
}
if (baseMatches.length > 1) {
return undefined;
}
}
return undefined;
}
export interface ModelMatchPreferences {
/** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */
usageOrder?: string[];
@@ -171,17 +266,7 @@ export function findExactModelReferenceMatch(
const provider = trimmedReference.substring(0, slashIndex).trim();
const modelId = trimmedReference.substring(slashIndex + 1).trim();
if (provider && modelId) {
const providerMatches = availableModels.filter(
model =>
model.provider.toLowerCase() === provider.toLowerCase() &&
model.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatches.length === 1) {
return providerMatches[0];
}
if (providerMatches.length > 1) {
return undefined;
}
return resolveProviderModelReference(provider, modelId, availableModels);
}
}
return undefined;
@@ -853,10 +938,8 @@ export function resolveCliModel(options: {
let exact: (typeof availableModels)[number] | undefined;
if (slashIdx !== -1) {
const prefix = lower.substring(0, slashIdx);
const suffix = lower.substring(slashIdx + 1);
exact = availableModels.find(
model => model.provider.toLowerCase() === prefix && model.id.toLowerCase() === suffix,
);
const suffix = trimmedModel.substring(slashIdx + 1);
exact = resolveProviderModelReference(prefix, suffix, availableModels);
}
if (!exact && !trimmedModel.includes(":")) {
const canonicalMatch = modelRegistry.resolveCanonicalModel?.(trimmedModel, { availableOnly: false });
@@ -905,6 +988,19 @@ export function resolveCliModel(options: {
}
}
if (provider) {
const exactProviderMatch = resolveProviderModelReference(provider, pattern, availableModels);
if (exactProviderMatch) {
return {
model: exactProviderMatch,
selector: formatModelString(exactProviderMatch),
warning: undefined,
thinkingLevel: undefined,
error: undefined,
};
}
}
const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, preferences, {
allowInvalidThinkingSelectorFallback: false,
+3 -1
View File
@@ -602,7 +602,9 @@ export async function waitForProjectLoaded(client: LspClient, signal?: AbortSign
if (signal?.aborted) return;
await Promise.race([
client.projectLoaded,
...(signal ? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))] : []),
...(signal
? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
: []),
]);
}
+1 -1
View File
@@ -19,8 +19,8 @@ import {
sendRequest,
setIdleTimeout,
syncContent,
waitForProjectLoaded,
WARMUP_TIMEOUT_MS,
waitForProjectLoaded,
} from "./client";
import { getLinterClient } from "./clients";
import { getServersForFile, type LspConfig, loadConfig } from "./config";
@@ -315,7 +315,8 @@ export class ToolExecutionComponent extends Container {
*/
#updateSpinnerAnimation(): void {
// Spinner for: task tool with partial result, or edit/write while args streaming
const isStreamingArgs = !this.#argsComplete && (this.#toolName === "edit" || this.#toolName === "write" || this.#toolName === "vim");
const isStreamingArgs =
!this.#argsComplete && (this.#toolName === "edit" || this.#toolName === "write" || this.#toolName === "vim");
const isBackgroundAsyncTask =
this.#toolName === "task" &&
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
@@ -1,5 +1,4 @@
Edits files via syntax-aware chunks. Run `read(path="file.ts")` first.
- `write` rewrites the entire targeted region — best for most edits.
- `replace` does surgical find-and-replace within a chunk — use when making small changes to a large chunk, or batching multiple substitutions.
@@ -13,7 +13,7 @@ Subagents lack your conversation history. Every decision, file content, and user
- `.description`: UI display only — subagent never sees it
- `.assignment`: Complete self-contained instructions. One-liners PROHIBITED; missing acceptance criteria = too vague.
- `context`: Shared background prepended to every assignment. Session-specific info only.
- `schema`: JTD schema for expected output. Format lives here — **MUST NOT** be duplicated in assignments.
- `schema`: JSON-encoded JTD schema for expected output. Format lives here — **MUST NOT** be duplicated in assignments.
- `tasks`: Tasks to execute in parallel.
- `isolated`: Run in isolated environment; returns patches. Use when tasks edit overlapping files.
</parameters>
+22 -22
View File
@@ -1,30 +1,30 @@
Stateful single-buffer Vim-style editor.
Stateful Vim-style editor with multi-buffer support.
Use this for surgical text edits when motions and compact viewport feedback are more efficient than rewriting full regions.
Every call requires `file` — the path to edit. The buffer is loaded automatically on first use.
Actions:
- `open`: load a file into the buffer (auto-saves any previous buffer)
- `kbd`: run Vim key sequences, then optionally insert literal text
- `{"file": "path/to/file.py"}` — view file (loads buffer if needed)
- `{"file": "path/to/file.py", "kbd": ["…"], "insert": "…"}` — edit file
How `kbd` + `insert` work together:
- `kbd` runs Vim key sequences (motions, commands, operators)
- `insert` is **raw text** (with real `\n` newlines in JSON) that gets typed into the buffer
- For `insert` to work, the last `kbd` entry **MUST** leave the buffer in INSERT mode (via `i`, `o`, `O`, `a`, `A`, `cc`, `C`, `s`, `S`, etc.)
- After the call, the tool auto-exits INSERT mode and auto-saves to disk
- Set `pause: true` to skip auto-save and stay in the current mode
Rules:
- One active buffer at a time; `open` replaces it
- Each `kbd` call auto-saves to disk (unless `pause: true`)
- `kbd` array entries run in order; every non-final entry must leave NORMAL mode — if an entry enters INSERT mode, end it with `<Esc>` or merge into one string
- `insert` is **raw text** (newlines = real `\n` in JSON), NOT Vim key syntax; the buffer must already be in INSERT mode (via `i`, `o`, `O`, `a`, `A`, `cc`, etc.)
- After `insert`, the tool exits INSERT mode and saves automatically (unless `pause: true`)
- `pause: true` keeps the current mode active and skips auto-save; use it for multi-step edits
- Use `:e!` to reload from disk and discard unsaved changes
- Each non-final `kbd` entry must end in NORMAL mode — use `<Esc>` or merge into one string
- To recover from mistakes: `{"file": "f.py", "kbd": ["u"]}` to undo, or `{"file": "f.py", "kbd": [":e!<CR>"]}` to reload from disk
Supported Vim subset: motions (`h/j/k/l`, `w/b/e`, `0/$`, `gg/G`, `{/}`, `f/t`), counts, `.` repeat, insert commands (`i/a/o/O/I/A/cc/C/s/S`), visual mode (`v/V`), operators (`d/c/y/p`), text objects (`iw/aw/i"/a"/i(/a(`), undo/redo (`u`/`<C-r>`), search (`/pattern<CR>`, `n/N`), ex commands (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`).
Supported: motions (`h/j/k/l`, `w/b/e`, `0/$`, `gg/G`, `{/}`, `f/t`), counts, `.` repeat, insert (`i/a/o/O/I/A/cc/C/s/S`), visual (`v/V`), operators (`d/c/y/p`), text objects (`iw/aw/i"/a"/i(/a(`), undo/redo (`u`/`<C-r>`), search (`/pattern<CR>`, `n/N`), ex (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`).
Special keys: `<Esc>` or `<Escape>`, `<CR>` or `<Enter>`, `<BS>`, `<Tab>`, `<C-d>`, `<C-u>`, `<C-r>`, `<C-w>`, `<C-o>`.
Special keys: `<Esc>`, `<Escape>`, `<CR>`, `<Enter>`, `<BS>`, `<Tab>`, `<C-d>`, `<C-u>`, `<C-r>`, `<C-w>`, `<C-o>`.
Examples:
- Open file: `{"open":"src/app.ts"}`
- Open at line: `{"open":"src/app.ts", "line":42}`
- Rename word: `{"kbd":["42G", "ciwnewName<Esc>"]}`
- Replace line with multi-line text: `{"kbd":["5G", "cc"], "insert":" if b == 0:\n return None"}`
- Add lines below: `{"kbd":["3G", "o"], "insert":"def multiply(a, b):\n return a * b"}`
- Global substitution: `{"kbd":[":%s/oldName/newName/g<CR>"]}`
- Search and delete: `{"kbd":["/TODO<CR>", "dd"]}`
- Delete range of lines: `{"kbd":[":3,5d<CR>"]}`
- `{"file": "src/app.ts"}` — view file
- `{"file": "src/app.ts", "kbd": ["3G", "ciwnewName<Esc>"]}` — rename word on line 3
- `{"file": "src/app.ts", "kbd": ["5G", "cc"], "insert": " if b == 0:\n return None"}` — replace line 5
- `{"file": "src/app.ts", "kbd": ["3G", "o"], "insert": "def multiply(a, b):\n return a * b"}` — insert after line 3
- `{"file": "src/app.ts", "kbd": [":%s/oldName/newName/g<CR>"]}` — find and replace
- `{"file": "src/app.ts", "kbd": ["/TODO<CR>", "dd"]}` — search and delete
- `{"file": "src/app.ts", "kbd": [":3,5d<CR>"]}` — delete line range
+2 -2
View File
@@ -82,9 +82,9 @@ const createTaskSchema = (options: { isolationEnabled: boolean }) => {
}),
),
schema: Type.Optional(
Type.Record(Type.String(), Type.Unknown(), {
Type.String({
description:
"JTD schema defining expected response structure. Use typed properties. Output format belongs here — never in context or assignment.",
"JSON-encoded JTD schema defining expected response structure. Output format belongs here — never in context or assignment.",
}),
),
tasks: Type.Array(taskItemSchema, {
+1 -3
View File
@@ -162,9 +162,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
const stat = await Bun.file(searchPath).stat();
isDirectory = stat.isDirectory();
} catch {
const hint = scopePath.includes(",")
? ` (comma-separated paths must each exist relative to cwd)`
: "";
const hint = scopePath.includes(",") ? ` (comma-separated paths must each exist relative to cwd)` : "";
throw new ToolError(`Path not found: ${scopePath}${hint}`);
}
+2 -3
View File
@@ -23,10 +23,8 @@ import { SearchTool } from "../web/search";
import { AskTool } from "./ask";
import { AstEditTool } from "./ast-edit";
import { AstGrepTool } from "./ast-grep";
import { PollTool } from "./poll-tool";
import { BashTool } from "./bash";
import { BrowserTool } from "./browser";
import { CalculatorTool } from "./calculator";
import { CancelJobTool } from "./cancel-job";
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
@@ -48,6 +46,7 @@ import { GrepTool } from "./grep";
import { InspectImageTool } from "./inspect-image";
import { NotebookTool } from "./notebook";
import { wrapToolWithMetaNotice } from "./output-meta";
import { PollTool } from "./poll-tool";
import { PythonTool } from "./python";
import { ReadTool } from "./read";
import { RenderMermaidTool } from "./render-mermaid";
@@ -73,7 +72,6 @@ export * from "../web/search";
export * from "./ask";
export * from "./ast-edit";
export * from "./ast-grep";
export * from "./poll-tool";
export * from "./bash";
export * from "./browser";
export * from "./calculator";
@@ -87,6 +85,7 @@ export * from "./gh";
export * from "./grep";
export * from "./inspect-image";
export * from "./notebook";
export * from "./poll-tool";
export * from "./python";
export * from "./read";
export * from "./render-mermaid";
@@ -57,7 +57,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
readonly label = "Submit Result";
readonly description =
"Finish the task with structured JSON output. Call exactly once at the end of the task.\n\n" +
"Pass `result: { data: <your output> }` for success, or `result: { error: \"message\" }` for failure.\n" +
'Pass `result: { data: <your output> }` for success, or `result: { error: "message" }` for failure.\n' +
"The `data`/`error` wrapper is required — do not put your output directly in `result`.";
readonly parameters: TSchema;
strict = true;
@@ -173,7 +173,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
}
if (errorMessage === undefined && data === undefined) {
throw new Error(
"result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: \"message\"}}` for failure.",
'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.',
);
}
+111 -116
View File
@@ -37,24 +37,27 @@ const INTERNAL_URL_PREFIX = /^(agent|artifact|skill|rule|local|mcp):\/\//;
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const vimSchema = Type.Object({
open: Type.Optional(Type.String({ description: "File path to open" })),
line: Type.Optional(Type.Number({ description: "1-indexed line to place cursor on open" })),
col: Type.Optional(Type.Number({ description: "1-indexed column to place cursor on open" })),
kbd: Type.Optional(Type.Array(Type.String(), { description: "Vim key sequences to execute" })),
insert: Type.Optional(Type.String({ description: "Raw text to insert literally while in INSERT mode" })),
pause: Type.Optional(Type.Boolean({ description: "Return an intermediate snapshot without forcing a mode exit" })),
file: Type.String({ description: "File path to edit." }),
kbd: Type.Optional(
Type.Array(Type.String(), {
description: "Vim key sequences to execute against the buffer. Null when just viewing the file.",
}),
),
insert: Type.Optional(
Type.String({
description:
"Raw text to type into the buffer. kbd must leave INSERT mode active first (e.g. via o, O, i, cc). Null when not inserting.",
}),
),
pause: Type.Optional(
Type.Boolean({
description: "If true, skip auto-save and keep current mode. Null or false for normal auto-save.",
}),
),
});
type VimParams = Static<typeof vimSchema>;
function isOpenParams(params: VimParams): boolean {
return params.open !== undefined;
}
function isKbdParams(params: VimParams): boolean {
return params.kbd! !== undefined;
}
function fingerprintEqual(left: VimFingerprint | null, right: VimFingerprint | null): boolean {
if (left === null || right === null) {
return left === right;
@@ -216,7 +219,7 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
readonly parameters = vimSchema;
readonly concurrency = "exclusive";
#engine: VimEngine | null = null;
#engines = new Map<string, VimEngine>();
#writethrough: WritethroughCallback;
constructor(private readonly session: ToolSession) {
@@ -329,125 +332,117 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
_context?: AgentToolContext,
): Promise<AgentToolResult<VimToolDetails>> {
return untilAborted(signal, async () => {
if (isOpenParams(params)) {
// Auto-save previous buffer before opening new file
if (this.#engine?.buffer.modified) {
await this.#saveBuffer(this.#engine.buffer);
}
this.#engine = null;
const loaded = await this.#loadBuffer(params.open!);
const engine = new VimEngine(new VimBuffer(loaded), {
// Resolve file path and get-or-create engine for this buffer
const { absolutePath } = normalizeTargetPath(params.file, this.session.cwd);
let engine = this.#engines.get(absolutePath);
let isNewBuffer = false;
if (!engine) {
const loaded = await this.#loadBuffer(params.file);
engine = new VimEngine(new VimBuffer(loaded), {
beforeMutate: buffer => this.#beforeMutate(buffer),
loadBuffer: path => this.#loadBuffer(path),
saveBuffer: (buffer, options) => this.#saveBuffer(buffer, options),
});
if (params.line || params.col) {
engine.setCursor(Math.max(0, (params.line ?? 1) - 1), Math.max(0, (params.col ?? 1) - 1));
}
engine.viewportStart = params.line ? Math.max(1, params.line - 20) : 1;
engine.statusMessage = `Opened ${engine.buffer.displayPath}`;
this.#engine = engine;
return this.#renderFromEngine(
engine,
VIM_OPEN_VIEWPORT_LINES,
params.line ? Math.max(1, params.line - 20) : 1,
);
engine.viewportStart = 1;
this.#engines.set(absolutePath, engine);
isNewBuffer = true;
}
if (isKbdParams(params)) {
if (!this.#engine) {
throw new ToolError("No active vim buffer. Open a file first.");
const sequences = Array.isArray(params.kbd) ? params.kbd : undefined;
if (!sequences) {
// No kbd — just show the file viewport
if (isNewBuffer) {
engine.statusMessage = `Opened ${engine.buffer.displayPath}`;
}
const engine = this.#engine;
const sequences = params.kbd!;
const commandText = sequences.join(" ");
const tokenGroups = splitTokensBySequence(sequences);
const beforeText = serializeBufferText(engine.buffer);
return this.#renderFromEngine(engine, VIM_OPEN_VIEWPORT_LINES, engine.viewportStart);
}
if (this.session.getPlanModeState?.()?.enabled) {
if (params.insert !== undefined) {
throw new ToolError("Plan mode: vim is read-only; insert payloads are not allowed.");
}
const preview = engine.clone({
beforeMutate: async () => {
throw new VimInputError(
"Plan mode: vim is read-only; only navigation, search, open, and close are allowed.",
);
},
saveBuffer: async () => {
throw new VimInputError("Plan mode: :w is not allowed.");
},
});
await executeKeySequences(preview, tokenGroups, commandText);
// Execute kbd sequences
const commandText = sequences.join(" ");
const tokenGroups = splitTokensBySequence(sequences);
const beforeText = serializeBufferText(engine.buffer);
if (this.session.getPlanModeState?.()?.enabled) {
if (params.insert !== undefined) {
throw new ToolError("Plan mode: vim is read-only; insert payloads are not allowed.");
}
const preview = engine.clone({
beforeMutate: async () => {
throw new VimInputError(
"Plan mode: vim is read-only; only navigation, search, open, and close are allowed.",
);
},
saveBuffer: async () => {
throw new VimInputError("Plan mode: :w is not allowed.");
},
});
await executeKeySequences(preview, tokenGroups, commandText);
}
try {
const FRAME_INTERVAL_MS = 16; // ~60fps
let lastUpdateTime = 0;
try {
const FRAME_INTERVAL_MS = 16; // ~60fps
let lastUpdateTime = 0;
const emitUpdate = onUpdate
? async () => {
const now = Date.now();
if (now - lastUpdateTime < FRAME_INTERVAL_MS) {
return; // throttle: skip if too soon
}
onUpdate(this.#renderFromEngine(engine, VIM_DEFAULT_VIEWPORT_LINES, engine.viewportStart));
lastUpdateTime = Date.now();
await Bun.sleep(FRAME_INTERVAL_MS); // real delay for terminal to render
const emitUpdate = onUpdate
? async () => {
const now = Date.now();
if (now - lastUpdateTime < FRAME_INTERVAL_MS) {
return; // throttle: skip if too soon
}
: undefined;
onUpdate(this.#renderFromEngine(engine, VIM_DEFAULT_VIEWPORT_LINES, engine.viewportStart));
lastUpdateTime = Date.now();
await Bun.sleep(FRAME_INTERVAL_MS); // real delay for terminal to render
}
: undefined;
await executeKeySequences(engine, tokenGroups, commandText, emitUpdate);
await executeKeySequences(engine, tokenGroups, commandText, emitUpdate);
if (!engine.closed && params.insert !== undefined) {
await engine.applyLiteralInsert(params.insert, params.pause !== true);
await emitUpdate?.();
}
if (!engine.closed && params.insert !== undefined) {
await engine.applyLiteralInsert(params.insert, params.pause !== true);
await emitUpdate?.();
}
if (params.pause === true && !engine.closed && engine.getPendingInput()) {
engine.statusMessage = engine.statusMessage ?? `Paused in ${engine.getPublicMode()} mode`;
if (params.pause === true && !engine.closed && engine.getPendingInput()) {
engine.statusMessage = engine.statusMessage ?? `Paused in ${engine.getPublicMode()} mode`;
}
} catch (error) {
this.#throwWithSnapshot(engine, error);
}
if (beforeText !== serializeBufferText(engine.buffer)) {
engine.centerViewportOnCursor();
}
// Auto-save when buffer was modified
if (!engine.closed && engine.buffer.modified && params.pause !== true) {
try {
const result = await this.#saveBuffer(engine.buffer);
engine.buffer.markSaved(result.loaded);
engine.diagnostics = result.diagnostics;
if (beforeText !== serializeBufferText(engine.buffer)) {
engine.centerViewportOnCursor();
}
} catch (error) {
this.#throwWithSnapshot(engine, error);
}
if (beforeText !== serializeBufferText(engine.buffer)) {
engine.centerViewportOnCursor();
}
// Auto-save when buffer was modified
if (!engine.closed && engine.buffer.modified && params.pause !== true) {
try {
const result = await this.#saveBuffer(engine.buffer);
engine.buffer.markSaved(result.loaded);
engine.diagnostics = result.diagnostics;
if (beforeText !== serializeBufferText(engine.buffer)) {
engine.centerViewportOnCursor();
}
} catch (error) {
this.#throwWithSnapshot(engine, error);
}
}
const afterText = serializeBufferText(engine.buffer);
const modelDiff = buildModelDiff(beforeText, afterText);
const result = this.#renderFromEngine(
engine,
VIM_DEFAULT_VIEWPORT_LINES,
engine.viewportStart,
engine.closed,
undefined,
undefined,
modelDiff,
);
if (engine.closed) {
this.#engine = null;
}
return result;
}
throw new ToolError("Invalid vim parameters");
const afterText = serializeBufferText(engine.buffer);
const modelDiff = buildModelDiff(beforeText, afterText);
const result = this.#renderFromEngine(
engine,
VIM_DEFAULT_VIEWPORT_LINES,
engine.viewportStart,
engine.closed,
undefined,
undefined,
modelDiff,
);
if (engine.closed) {
this.#engines.delete(absolutePath);
}
return result;
});
}
}
@@ -535,7 +530,7 @@ function getInsertForDisplay(args: VimRenderArgs): string | undefined {
}
interface VimRenderArgs {
open?: string;
file?: string;
kbd?: string[];
insert?: string;
pause?: boolean;
@@ -544,8 +539,8 @@ interface VimRenderArgs {
export const vimToolRenderer = {
renderCall(args: VimRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
if (args.open) {
return renderText(`${uiTheme.bold("Vim")} open ${args.open}`);
if (args.file && !args.kbd) {
return renderText(`${uiTheme.bold("Vim")} open ${args.file}`);
}
// Build a description of the streaming args for the header