fix(coding-agent): corrected vim arg handling by dropping metadata fields

- Removed live Vim preview state fields and priming/cleanup flow from tool execution and rendering.
- Changed vim insert-mode exit logic to close insert mode unless the final step is paused.
- Updated changelog entries and Vim edit-file prompts to clarify insertion behavior and examples.
- Updated vim tool argument handling to clone args directly and ignore __toolCallId/__cwd metadata.
This commit is contained in:
can1357
2026-04-13 22:59:04 +02:00
parent 755ada38a2
commit 1bfbf79fea
6 changed files with 32 additions and 510 deletions
+2 -10
View File
@@ -1,9 +1,9 @@
# Changelog
## [Unreleased]
### Breaking Changes
-
- Removed the `searchDb` field from session and extension tool contexts, so custom tools and extensions no longer receive a shared native search DB handle from `ToolSession`, `CustomToolContext`, `ExtensionContext`, and `CreateAgentSessionOptions`
- Changed the `vim` tool API to require either `open: "path"` or `kbd: [...]` per call and removed direct `line`/`col` cursor parameters from `open`, so callers must position the cursor via key sequences after opening
- Changed the `edit` schemas for patch, replace, hashline, and chunk modes from top-level request fields to `edits` array entries, requiring path/mode details on each edit and breaking callers that send legacy top-level `path`, `old_text`, `new_text`, `op`, `move`, or `delete` payloads
@@ -47,13 +47,9 @@
- Updated chunk-mode `grep` output to include match lines under their containing chunk entries with consistent line-number alignment based on file length
- Changed eager todo enforcement to only apply on the first user message of a conversation, skipping subsequent user turns that may correct, clarify, or redirect the prior task
### Deprecated
-
### Removed
-
- Removed live in-progress Vim tool previews during streaming call execution, so the TUI now shows only the last completed file viewport until the call finishes
### Fixed
@@ -74,10 +70,6 @@
- Session accent border color now applied on session resume and after auto-title generation, not only after an explicit `/rename`
- Fixed retained Python kernel ownership so `AgentSession.dispose()` only shuts down kernels owned by that session, including warmup-created kernels
### Security
-
## [14.1.0] - 2026-04-11
### Added
@@ -32,7 +32,6 @@ import {
import { PYTHON_DEFAULT_PREVIEW_LINES } from "../../tools/python";
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
import { toolRenderers } from "../../tools/renderers";
import { clearVimCallPreview, primeVimCallPreview } from "../../tools/vim";
import { renderStatusLine } from "../../tui";
import { convertToPng } from "../../utils/image-convert";
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
@@ -96,7 +95,6 @@ export class ToolExecutionComponent extends Container {
#tool?: AgentTool;
#ui: TUI;
#cwd: string;
#toolCallId?: string;
#result?: {
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
isError?: boolean;
@@ -112,8 +110,6 @@ export class ToolExecutionComponent extends Container {
#spinnerInterval?: NodeJS.Timeout;
// Track if args are still being streamed (for edit/write spinner)
#argsComplete = false;
#vimPreviewPrimeRunning = false;
#vimPreviewPrimeVersion = 0;
#renderState: {
spinnerFrame?: number;
expanded: boolean;
@@ -131,19 +127,18 @@ export class ToolExecutionComponent extends Container {
tool: AgentTool | undefined,
ui: TUI,
cwd: string = getProjectDir(),
toolCallId?: string,
_toolCallId?: string,
) {
super();
this.#toolName = toolName;
this.#toolLabel = tool?.label ?? toolName;
this.#toolCallId = toolCallId;
this.#showImages = options.showImages ?? true;
this.#editFuzzyThreshold = options.editFuzzyThreshold;
this.#editAllowFuzzy = options.editAllowFuzzy;
this.#tool = tool;
this.#ui = ui;
this.#cwd = cwd;
this.#args = this.#prepareArgsForRender(args, toolCallId);
this.#args = cloneToolArgs(args);
this.addChild(new Spacer(1));
@@ -160,16 +155,11 @@ export class ToolExecutionComponent extends Container {
this.addChild(this.#contentText);
}
this.#maybePrimeVimPreview();
this.#updateDisplay();
}
updateArgs(args: any, toolCallId?: string): void {
if (toolCallId) {
this.#toolCallId = toolCallId;
}
this.#args = this.#prepareArgsForRender(args, toolCallId ?? this.#toolCallId);
this.#maybePrimeVimPreview();
updateArgs(args: any, _toolCallId?: string): void {
this.#args = cloneToolArgs(args);
this.#updateSpinnerAnimation();
this.#updateDisplay();
}
@@ -266,9 +256,6 @@ export class ToolExecutionComponent extends Container {
): void {
this.#result = result;
this.#isPartial = isPartial;
if (this.#toolName === "vim") {
clearVimCallPreview(this.#toolCallId);
}
// When tool is complete, ensure args are marked complete so spinner stops
if (!isPartial) {
this.#argsComplete = true;
@@ -279,46 +266,6 @@ export class ToolExecutionComponent extends Container {
this.#maybeConvertImagesForKitty();
}
#prepareArgsForRender(args: any, toolCallId?: string): any {
const cloned = cloneToolArgs(args);
if (this.#toolName !== "vim" || !toolCallId || !cloned || typeof cloned !== "object" || Array.isArray(cloned)) {
return cloned;
}
return { ...cloned, __toolCallId: toolCallId, __cwd: this.#cwd };
}
#maybePrimeVimPreview(): void {
if (this.#toolName !== "vim" || this.#argsComplete || !this.#toolCallId) {
return;
}
this.#vimPreviewPrimeVersion += 1;
if (this.#vimPreviewPrimeRunning) {
return;
}
this.#vimPreviewPrimeRunning = true;
void (async () => {
try {
while (!this.#argsComplete && this.#toolCallId) {
const version = this.#vimPreviewPrimeVersion;
const toolCallId = this.#toolCallId;
const args = this.#args;
await primeVimCallPreview(toolCallId, args);
if (this.#toolCallId === toolCallId) {
this.#updateDisplay();
this.#ui.requestRender();
}
if (version === this.#vimPreviewPrimeVersion) {
break;
}
}
} finally {
this.#vimPreviewPrimeRunning = false;
}
})();
}
/**
* Get all image blocks from result content and details.images.
* Some tools (like generate_image) store images in details to avoid bloating model context.
@@ -1,6 +1,6 @@
Stateful Vim editor. Every call requires `file`; the buffer loads automatically on first use.
- `{"file": "path"}` - view file
- `{"file": "path", "steps": [{"kbd": ["..."], "insert": "..."}]}` - edit file
- `{"file": "path", "steps": [{"kbd": ["…"], "insert": "…"}]}` - edit file
## steps vs kbd vs insert
@@ -51,11 +51,11 @@ Replace entire file. `ggdGi` = go to top, delete all, enter INSERT. Use that exa
Multi-location edit — work bottom-up so earlier inserts don't shift later line numbers:
```json
{"file": "f.py", "steps": [
{"kbd": ["10Go"], "insert": "os.path.exists('...')"},
{"kbd": ["3Go"], "insert": "import os"}
{"kbd": ["8Go"], "insert": " print(result)"},
{"kbd": ["3Go"], "insert": "def helper(x):\n return x + 1"}
]}
```
When inserting at multiple lines in one call, edit the **highest line number first** and work upward. Each insert shifts lines below it, so bottom-up order keeps all line targets stable.
When inserting at multiple lines in one call, edit the **highest line number first** and work upward. Each `o`/`O` insert shifts lines below it, so bottom-up order keeps all line targets stable. Use `\n` within `insert` for multi-line content (each `\n` creates a new line).
Navigation or search step without insert:
```json
@@ -74,7 +74,6 @@ Delete line range:
Ex commands always start with `:` and end with `<CR>`. `3,5d` without `:` is NOT an ex command — it is interpreted as normal-mode keystrokes and will fail.
## Undo mistakes
- `{"file": "f.py", "steps": [{"kbd": ["u"]}]}` - undo last change
- `{"file": "f.py", "steps": [{"kbd": ["3u"]}]}` - undo last 3 changes
+1 -1
View File
@@ -14,7 +14,7 @@ export const JSON_TREE_SCALAR_LEN_COLLAPSED = 60;
export const JSON_TREE_SCALAR_LEN_EXPANDED = 2000;
/** Keys injected by the harness that should not be displayed to users */
const HIDDEN_ARG_KEYS = new Set([INTENT_FIELD, "__partialJson", "__toolCallId", "__cwd"]);
const HIDDEN_ARG_KEYS = new Set([INTENT_FIELD, "__partialJson"]);
/** Strip harness-internal keys from tool args for display */
export function stripInternalArgs(args: Record<string, unknown>): Record<string, unknown> {
+10 -297
View File
@@ -11,7 +11,7 @@ import vimDescription from "../prompts/tools/vim.md" with { type: "text" };
import { CachedOutputBlock } from "../tui/output-block";
import { renderStatusLine } from "../tui/status-line";
import { VimBuffer } from "../vim/buffer";
import { VimEngine, type VimEngineCallbacks, type VimSaveResult } from "../vim/engine";
import { VimEngine, type VimSaveResult } from "../vim/engine";
import { parseKeySequences } from "../vim/parser";
import {
buildDetails,
@@ -77,25 +77,6 @@ export interface VimRenderArgs {
steps?: VimRenderStep[];
pause?: boolean;
__partialJson?: string;
__toolCallId?: string;
__cwd?: string;
}
interface VimPreviewBuildResult {
details: VimToolDetails;
engine: VimEngine;
file?: string;
steps?: VimStep[];
pause: boolean;
}
interface VimCallPreviewState {
key: string;
details?: VimToolDetails;
engine?: VimEngine;
file?: string;
steps?: VimStep[];
pause?: boolean;
}
function fingerprintEqual(left: VimFingerprint | null, right: VimFingerprint | null): boolean {
@@ -201,73 +182,9 @@ async function executeKeySequences(
}
}
// Module-level cache of the last rendered VimToolDetails so that renderCall can
// show the current buffer viewport while the LLM is still streaming tool arguments.
// Module-level cache of the last real vim result so renderCall can reuse that
// viewport while the next tool call is still streaming.
let lastVimDetails: VimToolDetails | undefined;
const previewEnginesByFile = new Map<string, VimEngine>();
const previewStatesByToolCall = new Map<string, VimCallPreviewState>();
function clearStoredPreviewEngine(file: string): void {
previewEnginesByFile.delete(file);
}
function storePreviewEngine(engine: VimEngine): void {
const snapshot = engine.clone();
previewEnginesByFile.set(engine.buffer.displayPath, snapshot);
previewEnginesByFile.set(engine.buffer.filePath, snapshot);
}
function buildLoadedFileFromBuffer(buffer: VimBuffer): VimLoadedFile {
return {
absolutePath: buffer.filePath,
displayPath: buffer.displayPath,
lines: [...buffer.lines],
trailingNewline: buffer.trailingNewline,
fingerprint: buffer.baseFingerprint ? { ...buffer.baseFingerprint } : null,
};
}
function createPreviewCallbacks(): VimEngineCallbacks {
return {
beforeMutate: async () => {},
loadBuffer: async inputPath => ({
absolutePath: inputPath,
displayPath: inputPath,
lines: [""],
trailingNewline: false,
fingerprint: null,
}),
saveBuffer: async buffer => ({
loaded: buildLoadedFileFromBuffer(buffer),
}),
};
}
async function loadPreviewBaseEngine(args: VimRenderArgs): Promise<VimEngine | undefined> {
if (!args.file || !args.__cwd) {
return undefined;
}
try {
const { absolutePath, displayPath } = normalizeTargetPath(args.file, args.__cwd);
const loaded = await readTextFile(absolutePath);
const engine = new VimEngine(
new VimBuffer({
absolutePath,
displayPath,
lines: loaded.lines,
trailingNewline: loaded.trailingNewline,
fingerprint: loaded.fingerprint,
}),
createPreviewCallbacks(),
);
storePreviewEngine(engine);
return engine;
} catch {
return undefined;
}
}
function buildToolDetailsFromEngine(
engine: VimEngine,
viewportLines: number,
@@ -298,60 +215,6 @@ function buildToolDetailsFromEngine(
return details;
}
function buildPreviewKey(args: VimRenderArgs): string {
return JSON.stringify({
file: args.file,
steps: getStepsForDisplay(args) ?? [],
pause: args.pause === true,
});
}
function cloneSteps(steps: readonly VimStep[] | undefined): VimStep[] | undefined {
return steps?.map(step => ({
kbd: [...step.kbd],
...(step.insert !== undefined ? { insert: step.insert } : {}),
}));
}
function arraysEqual(left: string[] | undefined, right: string[] | undefined): boolean {
if (left === right) {
return true;
}
if (!left || !right || left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false;
}
}
return true;
}
function stepsEqualExceptLastInsert(
left: readonly VimStep[] | undefined,
right: readonly VimStep[] | undefined,
): boolean {
if (left === right) {
return true;
}
if (!left || !right || left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
const leftStep = left[index]!;
const rightStep = right[index]!;
if (!arraysEqual(leftStep.kbd, rightStep.kbd)) {
return false;
}
const isLast = index === left.length - 1;
if (!isLast && leftStep.insert !== rightStep.insert) {
return false;
}
}
return true;
}
function getLastStepInsert(steps: readonly VimStep[] | undefined): string | undefined {
if (!steps || steps.length === 0) {
return undefined;
@@ -387,20 +250,6 @@ function getStepsForDisplay(args: VimRenderArgs): VimStep[] | undefined {
return steps;
}
function buildPreviewState(key: string, preview: VimPreviewBuildResult | undefined): VimCallPreviewState {
if (!preview) {
return { key };
}
return {
key,
details: preview.details,
engine: preview.engine,
file: preview.file,
steps: cloneSteps(preview.steps),
pause: preview.pause,
};
}
function splitInsertIntoChunks(text: string): string[] {
const maxChunkChars = 32;
if (text.length <= maxChunkChars) {
@@ -451,7 +300,6 @@ async function applyInsertWithStreaming(
interface ExecuteVimStepsOptions {
pauseLastStep?: boolean;
keepFinalInsertOpen?: boolean;
onKbdStep?: () => Promise<void>;
onInsertStep?: () => Promise<void>;
}
@@ -481,9 +329,11 @@ async function executeVimSteps(
}
if (!engine.closed && step.insert !== undefined && (step.insert.length > 0 || engine.inputMode === "insert")) {
const exitInsertMode =
options.keepFinalInsertOpen === true ? !isLast : !(isLast && options.pauseLastStep === true);
await applyInsertWithStreaming(engine, step.insert, exitInsertMode, options.onInsertStep);
// Strip trailing newline from insert text — `o`/`O` already create a line boundary,
// so a trailing \n would produce an unwanted blank line.
const normalizedInsert = step.insert.endsWith("\n") ? step.insert.slice(0, -1) : step.insert;
const exitInsertMode = !(isLast && options.pauseLastStep === true);
await applyInsertWithStreaming(engine, normalizedInsert, exitInsertMode, options.onInsertStep);
}
if (!isLast && engine.inputMode === "insert") {
@@ -660,12 +510,6 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
builder.diagnostics(engine.diagnostics.summary, engine.diagnostics.messages ?? []);
}
lastVimDetails = details;
if (closed) {
clearStoredPreviewEngine(engine.buffer.displayPath);
clearStoredPreviewEngine(engine.buffer.filePath);
} else {
storePreviewEngine(engine);
}
return builder.done();
}
@@ -899,136 +743,8 @@ function describeStepsForDisplay(args: VimRenderArgs): string {
return description;
}
async function buildPreviewDetails(args: VimRenderArgs): Promise<VimPreviewBuildResult | undefined> {
if (!args.file) {
return undefined;
}
const baseEngine = previewEnginesByFile.get(args.file) ?? (await loadPreviewBaseEngine(args));
if (!baseEngine) {
return undefined;
}
const preview = baseEngine.clone(createPreviewCallbacks());
const steps = getStepsForDisplay(args) ?? [];
try {
await executeVimSteps(preview, steps, {
pauseLastStep: args.pause === true,
keepFinalInsertOpen: true,
});
} catch {
return undefined;
}
const insertText = getLastStepInsert(steps);
if (!preview.closed && insertText !== undefined) {
preview.statusMessage = preview.statusMessage ?? "Streaming insert preview";
}
return {
details: buildToolDetailsFromEngine(preview, VIM_DEFAULT_VIEWPORT_LINES, preview.viewportStart, preview.closed),
engine: preview,
file: args.file,
steps: cloneSteps(steps),
pause: args.pause === true,
};
}
async function extendPreviewDetails(
state: VimCallPreviewState | undefined,
args: VimRenderArgs,
): Promise<VimPreviewBuildResult | undefined> {
if (!state?.engine || !args.file || state.file !== args.file) {
return undefined;
}
const steps = getStepsForDisplay(args) ?? [];
if (!stepsEqualExceptLastInsert(state.steps, steps)) {
return undefined;
}
const pause = args.pause === true;
if ((state.pause ?? false) !== pause) {
return undefined;
}
const nextInsert = getLastStepInsert(steps);
const previousInsert = getLastStepInsert(state.steps);
if (nextInsert === previousInsert) {
return {
details:
state.details ??
buildToolDetailsFromEngine(
state.engine,
VIM_DEFAULT_VIEWPORT_LINES,
state.engine.viewportStart,
state.engine.closed,
),
engine: state.engine,
file: args.file,
steps: cloneSteps(steps),
pause,
};
}
if (nextInsert === undefined || previousInsert === undefined || !nextInsert.startsWith(previousInsert)) {
return undefined;
}
const preview = state.engine.clone(createPreviewCallbacks());
const suffix = nextInsert.slice(previousInsert.length);
if (suffix.length > 0) {
try {
await preview.applyLiteralInsert(suffix, false);
} catch {
return undefined;
}
}
preview.statusMessage = preview.statusMessage ?? "Streaming insert preview";
return {
details: buildToolDetailsFromEngine(preview, VIM_DEFAULT_VIEWPORT_LINES, preview.viewportStart, preview.closed),
engine: preview,
file: args.file,
steps: cloneSteps(steps),
pause,
};
}
export async function primeVimCallPreview(toolCallId: string | undefined, args: VimRenderArgs): Promise<void> {
if (!toolCallId) {
return;
}
const key = buildPreviewKey(args);
const existing = previewStatesByToolCall.get(toolCallId);
previewStatesByToolCall.set(toolCallId, {
key,
details: existing?.details,
engine: existing?.engine,
file: existing?.file,
steps: cloneSteps(existing?.steps),
pause: existing?.pause,
});
const preview = (await extendPreviewDetails(existing, args)) ?? (await buildPreviewDetails(args));
const current = previewStatesByToolCall.get(toolCallId);
if (!current || current.key !== key) {
return;
}
previewStatesByToolCall.set(toolCallId, buildPreviewState(key, preview));
}
export function clearVimCallPreview(toolCallId: string | undefined): void {
if (!toolCallId) {
return;
}
previewStatesByToolCall.delete(toolCallId);
}
export function resetVimRendererStateForTest(): void {
lastVimDetails = undefined;
previewEnginesByFile.clear();
previewStatesByToolCall.clear();
}
export const vimToolRenderer = {
@@ -1040,11 +756,8 @@ export const vimToolRenderer = {
// Build a description of the streaming args for the header
const argsDescription = describeStepsForDisplay(args);
// When a vim buffer is already open, show its viewport during LLM streaming
// instead of just a plain text line. This gives visual continuity.
const previewDetails =
args.__toolCallId !== undefined ? previewStatesByToolCall.get(args.__toolCallId)?.details : undefined;
const details = previewDetails ?? (lastVimDetails?.file === args.file ? lastVimDetails : undefined);
// Reuse the last real vim result for the same file while the next call is still streaming.
const details = lastVimDetails?.file === args.file ? lastVimDetails : undefined;
if (details?.viewportLines && details.viewportLines.length > 0) {
const lang = getLanguageFromPath(details.file);
const langIcon = uiTheme.getLangIcon(lang);
+11 -140
View File
@@ -6,12 +6,7 @@ import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/confi
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import {
primeVimCallPreview,
resetVimRendererStateForTest,
VimTool,
vimToolRenderer,
} from "@oh-my-pi/pi-coding-agent/tools/vim";
import { resetVimRendererStateForTest, VimTool, vimToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/vim";
import { VimBuffer } from "@oh-my-pi/pi-coding-agent/vim/buffer";
import { VimEngine } from "@oh-my-pi/pi-coding-agent/vim/engine";
import { parseKeySequences } from "@oh-my-pi/pi-coding-agent/vim/parser";
@@ -607,7 +602,7 @@ describe("vim tool", () => {
});
describe("vim renderer", () => {
it("previews streamed cursor jumps using the live vim buffer snapshot", async () => {
it("reuses the last real vim snapshot while args stream", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-preview-"));
const filePath = path.join(previewDir, "preview.ts");
await Bun.write(filePath, Array.from({ length: 900 }, (_, index) => `line ${index + 1};`).join("\n"));
@@ -617,80 +612,28 @@ describe("vim renderer", () => {
const uiTheme = theme!;
await tool.execute("open", { file: "preview.ts" });
await primeVimCallPreview("preview-call", {
file: "preview.ts",
steps: [step(["643G"])],
__toolCallId: "preview-call",
});
const component = vimToolRenderer.renderCall(
{ file: "preview.ts", steps: [step(["643G"])], __toolCallId: "preview-call" },
{ file: "preview.ts", steps: [step(["643G"])] },
{ expanded: false, isPartial: true, spinnerFrame: 0 },
uiTheme,
);
const rendered = Bun.stripANSI(component.render(160).join("\n"));
expect(rendered).toContain("643G");
expect(rendered).toContain("line 643;");
expect(rendered).toContain("line 1;");
expect(rendered).not.toContain("line 643;");
});
it("previews partial insert payloads before the tool call JSON closes", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-insert-preview-"));
const filePath = path.join(previewDir, "preview.txt");
await Bun.write(filePath, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\n");
const tool = new VimTool(createSession(previewDir));
it("renders first-call streamed inserts as text instead of priming a preview buffer", async () => {
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
await tool.execute("open", { file: "preview.txt" });
await primeVimCallPreview("insert-preview-call", {
file: "preview.txt",
steps: [step(["7Go"])],
__toolCallId: "insert-preview-call",
__partialJson:
'{"file":"preview.txt","steps":[{"kbd":["7Go"],"insert":"// long streamed comment still being typed by the model',
});
const component = vimToolRenderer.renderCall(
{
file: "preview.txt",
steps: [step(["7Go"])],
__toolCallId: "insert-preview-call",
__partialJson:
'{"file":"preview.txt","steps":[{"kbd":["7Go"],"insert":"// long streamed comment still being typed by the model',
},
{ expanded: false, isPartial: true, spinnerFrame: 0 },
uiTheme,
);
const rendered = component.render(160).join("\n");
expect(rendered).toContain("7Go");
expect(rendered).toContain("// long streamed comment still being typed by the model");
});
it("previews the target file on the first vim call without a prior open", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-first-call-"));
const filePath = path.join(previewDir, "preview.txt");
await Bun.write(filePath, "Line 1\nLine 2\nLine 3\n");
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
await primeVimCallPreview("first-call-preview", {
file: "preview.txt",
steps: [step(["ggdGi"])],
__toolCallId: "first-call-preview",
__cwd: previewDir,
__partialJson: '{"file":"preview.txt","steps":[{"kbd":["ggdGi"],"insert":"replacement',
});
const component = vimToolRenderer.renderCall(
{
file: "preview.txt",
steps: [step(["ggdGi"])],
__toolCallId: "first-call-preview",
__cwd: previewDir,
__partialJson: '{"file":"preview.txt","steps":[{"kbd":["ggdGi"],"insert":"replacement',
},
{ expanded: false, isPartial: true, spinnerFrame: 0 },
@@ -699,42 +642,12 @@ describe("vim renderer", () => {
const rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain("ggdGi");
expect(rendered).toContain(">1│replacement");
expect(rendered).toContain("insert: replacement");
expect(rendered).not.toContain(">1│replacement");
});
it("loads first-call vim previews through ToolExecutionComponent constructor state", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-first-call-component-"));
const filePath = path.join(previewDir, "preview.txt");
await Bun.write(filePath, "Line 1\nLine 2\nLine 3\n");
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
const uiStub = { requestRender() {} } as unknown as TUI;
const component = new ToolExecutionComponent(
"vim",
{
file: "preview.txt",
steps: [step(["ggdGi"])],
__partialJson: '{"file":"preview.txt","steps":[{"kbd":["ggdGi"],"insert":"replacement',
},
{},
undefined,
uiStub,
previewDir,
"first-call-component",
);
await Bun.sleep(50);
const rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain("ggdGi");
expect(rendered).toContain(">1│replacement");
});
it("extends streamed first-call insert previews across ToolExecutionComponent arg updates", async () => {
it("updates streamed vim args without async preview priming", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-growing-first-call-component-"));
const filePath = path.join(previewDir, "preview.txt");
await Bun.write(filePath, "Line 1\nLine 2\nLine 3\n");
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
@@ -753,10 +666,9 @@ describe("vim renderer", () => {
previewDir,
"growing-first-call-component",
);
await Bun.sleep(50);
let rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain(">1│rep");
expect(rendered).toContain("insert: rep");
component.updateArgs(
{
@@ -766,50 +678,9 @@ describe("vim renderer", () => {
},
"growing-first-call-component",
);
await Bun.sleep(50);
rendered = Bun.stripANSI(component.render(140).join("\n"));
expect(rendered).toContain(">1│replacement text");
});
it("keeps vim preview state alive after args complete until a result arrives", async () => {
const previewDir = await fs.mkdtemp(path.join(os.tmpdir(), "vim-render-complete-preview-"));
const filePath = path.join(previewDir, "preview.ts");
await Bun.write(filePath, Array.from({ length: 900 }, (_, index) => `line ${index + 1};`).join("\n"));
const tool = new VimTool(createSession(previewDir));
const theme = await themeModule.getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const uiStub = { requestRender() {} } as unknown as TUI;
await themeModule.initTheme(false, undefined, undefined, "dark", "light");
await tool.execute("open", { file: "preview.ts" });
await primeVimCallPreview("complete-preview-call", {
file: "preview.ts",
steps: [step(["643G"])],
__toolCallId: "complete-preview-call",
});
const component = new ToolExecutionComponent(
"vim",
{ file: "preview.ts", steps: [step(["643G"])] },
{},
undefined,
uiStub,
previewDir,
"complete-preview-call",
);
component.setArgsComplete("complete-preview-call");
const rendered = vimToolRenderer
.renderCall(
{ file: "preview.ts", steps: [step(["643G"])], __toolCallId: "complete-preview-call" },
{ expanded: false, isPartial: true, spinnerFrame: 0 },
uiTheme,
)
.render(160)
.join("\n");
expect(Bun.stripANSI(rendered)).toContain("line 643;");
expect(rendered).toContain("insert: replacement text");
});
it("caches repeated renders for the same viewport snapshot", async () => {