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:
@@ -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
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user