feat(coding-agent): added edit tool consolidation via vim handlers

- Removed the standalone vim tool and normalized built-in/requested tooling to edit.
- Updated session and SDK tool activation to dedupe lowercase names and track edit state via the edit key.
- Added vim-mode argument detection and delegated edit rendering/execution into Vim handlers under edit.
- Updated Vim step handling to auto-reorder numeric-positioned commands, including cc/C/S/s/i/I/A cases.
- Renamed prompt/changelog text and test expectations to reflect edit-only tool naming and usage.
This commit is contained in:
can1357
2026-04-14 10:46:15 +02:00
parent 9324fc4432
commit ed2fd6e3f3
18 changed files with 177 additions and 189 deletions
+3
View File
@@ -1,8 +1,10 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed the standalone `vim` tool from built-in tool lists, so vim-style editing is now invoked through `edit` in `vim` mode
- 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
@@ -54,6 +56,7 @@
### Fixed
- Fixed vim-mode multi-step line edits by auto-reordering ascending line-positioned commands to descending order before execution
- Fixed Vim viewport rendering to display the inline highlighted cursor character and keep long cursor lines centered around the cursor in tool previews
- Fixed Vim `:global` command defaults to handle only supported subcommands and report unsupported ones explicitly
- Fixed Vim ex execution so parsed `:update`, `:yank`, and `:put` commands now run instead of falling through
+48 -16
View File
@@ -1,5 +1,6 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { prompt } from "@oh-my-pi/pi-utils";
import type { Static } from "@sinclair/typebox";
import {
createLspWritethrough,
type FileDiagnosticsResult,
@@ -12,7 +13,9 @@ import hashlineDescription from "../prompts/tools/hashline.md" with { type: "tex
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
import type { ToolSession } from "../tools";
import { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
import { VimTool, vimSchema } from "../tools/vim";
import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
import type { VimToolDetails } from "../vim/types";
import {
type ChunkParams,
type ChunkToolEdit,
@@ -60,10 +63,12 @@ type TInput =
| typeof replaceEditSchema
| typeof patchEditSchema
| typeof hashlineEditParamsSchema
| typeof chunkEditParamsSchema;
| typeof chunkEditParamsSchema
| typeof vimSchema;
type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams;
type EditToolMode = Exclude<EditMode, "vim">;
type VimParams = Static<typeof vimSchema>;
type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams;
type EditToolResultDetails = EditToolDetails | VimToolDetails;
type EditModeDefinition = {
description: (session: ToolSession) => string;
@@ -75,11 +80,11 @@ type EditModeDefinition = {
params: EditParams,
signal: AbortSignal | undefined,
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => Promise<AgentToolResult<EditToolDetails, TInput>>;
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
) => Promise<AgentToolResult<EditToolResultDetails, TInput>>;
};
function resolveConfiguredEditMode(rawEditMode: string): EditToolMode | undefined {
function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
if (!rawEditMode || rawEditMode === "auto") {
return undefined;
}
@@ -88,13 +93,14 @@ function resolveConfiguredEditMode(rawEditMode: string): EditToolMode | undefine
if (!editMode) {
throw new Error(`Invalid PI_EDIT_VARIANT: ${rawEditMode}`);
}
if (editMode === "vim") {
return undefined;
}
return editMode;
}
function isVimParams(params: EditParams): params is VimParams {
return typeof params === "object" && params !== null && "file" in params && typeof params.file === "string";
}
function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
switch (rawValue) {
case "true":
@@ -228,7 +234,8 @@ export class EditTool implements AgentTool<TInput> {
readonly #allowFuzzy: boolean;
readonly #fuzzyThreshold: number;
readonly #writethrough: WritethroughCallback;
readonly #editMode?: EditToolMode;
readonly #editMode?: EditMode;
readonly #vimTool: VimTool;
readonly #pendingDeferredFetches = new Map<string, AbortController>();
constructor(private readonly session: ToolSession) {
@@ -242,12 +249,12 @@ export class EditTool implements AgentTool<TInput> {
this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
this.#writethrough = createEditWritethrough(session);
this.#vimTool = new VimTool(session);
}
get mode(): EditToolMode {
get mode(): EditMode {
if (this.#editMode) return this.#editMode;
const mode = resolveEditMode(this.session);
return mode === "vim" ? (DEFAULT_EDIT_MODE as EditToolMode) : mode;
return resolveEditMode(this.session);
}
get description(): string {
@@ -262,9 +269,9 @@ export class EditTool implements AgentTool<TInput> {
_toolCallId: string,
params: EditParams,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
onUpdate?: AgentToolUpdateCallback<EditToolResultDetails, TInput>,
context?: AgentToolContext,
): Promise<AgentToolResult<EditToolDetails, TInput>> {
): Promise<AgentToolResult<EditToolResultDetails, TInput>> {
const modeDefinition = this.#getModeDefinition();
if (!modeDefinition.validate(params)) {
throw new Error(modeDefinition.invalidParamsMessage);
@@ -399,6 +406,31 @@ export class EditTool implements AgentTool<TInput> {
return executePerFile(entries, batchRequest, onUpdate);
},
},
vim: {
description: () => this.#vimTool.description,
parameters: vimSchema,
invalidParamsMessage: "Invalid edit parameters for vim mode.",
validate: isVimParams,
execute: async (
tool: EditTool,
params: EditParams,
signal: AbortSignal | undefined,
_batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolResultDetails, TInput>) => void,
) => {
const handleUpdate = onUpdate
? (partialResult: AgentToolResult<VimToolDetails>) => {
onUpdate(partialResult as AgentToolResult<EditToolResultDetails, TInput>);
}
: undefined;
return (await tool.#vimTool.execute(
"edit",
params as VimParams,
signal,
handleUpdate,
)) as AgentToolResult<EditToolResultDetails, TInput>;
},
},
}[this.mode];
}
@@ -21,7 +21,9 @@ import {
shortenPath,
truncateDiffByHunk,
} from "../tools/render-utils";
import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { VimToolDetails } from "../vim/types";
import type { DiffError, DiffResult } from "./diff";
import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
import type { HashlineToolEdit } from "./modes/hashline";
@@ -108,6 +110,31 @@ interface EditRenderArgs {
edits?: Partial<HashlineToolEdit | ChunkToolEdit>[];
}
function isVimRenderArgs(args: EditRenderArgs | VimRenderArgs): args is VimRenderArgs {
return (
typeof args === "object" &&
args !== null &&
typeof (args as { file?: unknown }).file === "string" &&
!("path" in args) &&
!("file_path" in args) &&
!("edits" in args)
);
}
function isVimToolDetails(details: unknown): details is VimToolDetails {
if (!details || typeof details !== "object" || Array.isArray(details)) {
return false;
}
const cursor = (details as { cursor?: unknown }).cursor;
const viewportLines = (details as { viewportLines?: unknown }).viewportLines;
return (
typeof (details as { file?: unknown }).file === "string" &&
typeof cursor === "object" &&
cursor !== null &&
Array.isArray(viewportLines)
);
}
/** Extended context for edit tool rendering */
export interface EditRenderContext {
/** Pre-computed diff preview (computed before tool executes) */
@@ -406,6 +433,10 @@ export const editToolRenderer = {
mergeCallAndResult: true,
renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
if (isVimRenderArgs(args)) {
return vimToolRenderer.renderCall(args, options, uiTheme);
}
// Extract path from first edit entry when top-level path is absent (new schema)
const firstEdit = Array.isArray(args.edits) && args.edits.length > 0 ? args.edits[0] : undefined;
const rawPath = args.file_path || args.path || (firstEdit as any)?.path || "";
@@ -431,6 +462,14 @@ export const editToolRenderer = {
uiTheme: Theme,
args?: EditRenderArgs,
): Component {
if (isVimToolDetails(result.details)) {
return vimToolRenderer.renderResult(
result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
options,
uiTheme,
);
}
const perFileResults = result.details?.perFileResults;
const totalFiles = Array.isArray(args?.edits) ? countEditFiles(args!.edits as any[]) : 0;
if (perFileResults && (perFileResults.length > 1 || totalFiles > 1)) {
@@ -316,8 +316,7 @@ 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");
const isBackgroundAsyncTask =
this.#toolName === "task" &&
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
@@ -1,4 +1,4 @@
Stateful Vim editor. Every call requires `file`; the buffer loads automatically on first use.
Vim-style `edit` mode. The tool name stays `edit`; every call requires `file`, and the buffer loads automatically on first use.
- `{"file": "path"}` - view file
- `{"file": "path", "steps": [{"kbd": ["…"], "insert": "…"}]}` - edit file
@@ -83,7 +83,7 @@ Ex commands always start with `:` and end with `<CR>`. `3,5d` without `:` is NOT
## Session persistence
The vim buffer persists across tool calls. Cursor position, undo history, and file state are maintained until you close the tool. Auto-save happens once after all steps in a non-paused call complete.
The edit buffer in vim mode persists across tool calls. Cursor position, undo history, and file state are maintained until you close the buffer. Auto-save happens once after all steps in a non-paused call complete.
## Supported
+7 -25
View File
@@ -131,12 +131,6 @@ import { ToolContextStore } from "./tools/context";
import { getGeminiImageTools } from "./tools/gemini-image";
import { wrapToolWithMetaNotice } from "./tools/output-meta";
import { queueResolveHandler } from "./tools/resolve";
import {
filterInactiveEditToolName,
normalizeToolNamesForEditMode,
resolveEditToolName,
resolveInactiveEditToolName,
} from "./utils/edit-mode";
import { EventBus } from "./utils/event-bus";
import { buildNamedToolChoice } from "./utils/tool-choice";
@@ -904,17 +898,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (model) return formatModelString(model);
return undefined;
};
const editModeSession = {
settings,
getActiveModelString,
} as const;
const toolSession: ToolSession = {
cwd,
hasUI: options.hasUI ?? false,
enableLsp,
get hasEditTool() {
const requestedToolNames = normalizeToolNamesForEditMode(options.toolNames, editModeSession);
return !requestedToolNames || requestedToolNames.includes(resolveEditToolName(editModeSession));
const requestedToolNames = options.toolNames
? [...new Set(options.toolNames.map(name => name.toLowerCase()))]
: undefined;
return !requestedToolNames || requestedToolNames.includes("edit");
},
skipPythonPreflight: options.skipPythonPreflight,
forcePythonWarmup: options.forcePythonWarmup,
@@ -1250,17 +1242,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
for (const tool of builtinTools) {
toolRegistry.set(tool.name, tool);
}
const inactiveEditToolName = resolveInactiveEditToolName(editModeSession);
if (!toolRegistry.has(inactiveEditToolName)) {
const inactiveEditTool = await logger.time(
`createTools:${inactiveEditToolName}:hidden`,
BUILTIN_TOOLS[inactiveEditToolName],
toolSession,
);
if (inactiveEditTool) {
toolRegistry.set(inactiveEditTool.name, wrapToolWithMetaNotice(inactiveEditTool));
}
}
for (const tool of wrappedExtensionTools) {
toolRegistry.set(tool.name, tool);
}
@@ -1368,9 +1349,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return options.systemPrompt(defaultPrompt);
};
const toolNamesFromRegistry = filterInactiveEditToolName(toolRegistry.keys(), editModeSession);
const toolNamesFromRegistry = Array.from(toolRegistry.keys());
const requestedToolNames =
normalizeToolNamesForEditMode(options.toolNames, editModeSession) ?? toolNamesFromRegistry;
(options.toolNames ? [...new Set(options.toolNames.map(name => name.toLowerCase()))] : undefined) ??
toolNamesFromRegistry;
const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
const includeExitPlanMode = requestedToolNames.includes("exit_plan_mode");
const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") ?? false;
@@ -138,13 +138,7 @@ import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from ".
import { ToolError } from "../tools/tool-errors";
import { clampTimeout } from "../tools/tool-timeouts";
import { parseCommandArgs } from "../utils/command-args";
import {
type EditMode,
filterInactiveEditToolName,
normalizeToolNamesForEditMode,
resolveEditMode,
resolveEditToolName,
} from "../utils/edit-mode";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { resolveFileDisplayMode } from "../utils/file-display-mode";
import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
import { buildNamedToolChoice } from "../utils/tool-choice";
@@ -1996,7 +1990,7 @@ export class AgentSession {
/** Whether the edit tool is registered in this session. */
get hasEditTool(): boolean {
return this.#toolRegistry.has("edit") || this.#toolRegistry.has("vim");
return this.#toolRegistry.has("edit");
}
/**
@@ -2010,7 +2004,7 @@ export class AgentSession {
* Get all configured tool names (built-in via --tools or default, plus custom tools).
*/
getAllToolNames(): string[] {
return filterInactiveEditToolName(this.#toolRegistry.keys(), this.#getEditModeSession());
return Array.from(this.#toolRegistry.keys());
}
#getEditModeSession() {
@@ -2025,26 +2019,8 @@ export class AgentSession {
}
async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
const activeToolNames = this.getActiveToolNames();
const currentEditMode = this.#resolveActiveEditMode();
const hasActiveEditTool = activeToolNames.some(name => name === "edit" || name === "vim");
if (!hasActiveEditTool) {
if (previousEditMode !== currentEditMode) {
await this.refreshBaseSystemPrompt();
}
return;
}
const normalizedToolNames = normalizeToolNamesForEditMode(activeToolNames, this.#getEditModeSession()) ?? [];
const toolNamesChanged =
normalizedToolNames.length !== activeToolNames.length ||
normalizedToolNames.some((name, index) => name !== activeToolNames[index]);
if (toolNamesChanged) {
await this.#applyActiveToolsByName(normalizedToolNames);
return;
}
if (previousEditMode !== currentEditMode) {
if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
await this.refreshBaseSystemPrompt();
}
}
@@ -2096,7 +2072,7 @@ export class AgentSession {
toolNames: string[],
options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
): Promise<void> {
toolNames = normalizeToolNamesForEditMode(toolNames, this.#getEditModeSession()) ?? [];
toolNames = [...new Set(toolNames.map(name => name.toLowerCase()))];
const previousSelectedMCPToolNames = options?.previousSelectedMCPToolNames ?? this.getSelectedMCPToolNames();
const tools: AgentTool[] = [];
const validToolNames: string[] = [];
@@ -2489,7 +2465,7 @@ export class AgentSession {
planExists,
askToolName: "ask",
writeToolName: "write",
editToolName: resolveEditToolName(this.#getEditModeSession()),
editToolName: "edit",
exitToolName: "exit_plan_mode",
reentry: state.reentry ?? false,
iterative: state.workflow === "iterative",
+4 -13
View File
@@ -16,7 +16,6 @@ import type { CustomMessage } from "../session/messages";
import type { ToolChoiceQueue } from "../session/tool-choice-queue";
import { TaskTool } from "../task";
import type { AgentOutputManager } from "../task/output-manager";
import { normalizeToolNamesForEditMode, resolveEditToolName } from "../utils/edit-mode";
import type { EventBus } from "../utils/event-bus";
import { SearchTool } from "../web/search";
import { AskTool } from "./ask";
@@ -56,7 +55,6 @@ import { SearchToolBm25Tool } from "./search-tool-bm25";
import { loadSshTool } from "./ssh";
import { SubmitResultTool } from "./submit-result";
import { type TodoPhase, TodoWriteTool } from "./todo-write";
import { VimTool } from "./vim";
import { WriteTool } from "./write";
// Exa MCP tools (22 tools)
@@ -243,7 +241,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
todo_write: s => new TodoWriteTool(s),
web_search: s => new SearchTool(s),
search_tool_bm25: SearchToolBm25Tool.createIf,
vim: s => new VimTool(s),
write: s => new WriteTool(s),
};
@@ -293,12 +290,8 @@ function getPythonModeFromEnv(): PythonToolMode | null {
export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
const includeSubmitResult = session.requireSubmitResultTool === true;
const enableLsp = session.enableLsp ?? true;
const requestedTools = normalizeToolNamesForEditMode(
toolNames && toolNames.length > 0 ? toolNames : undefined,
session,
);
const activeEditToolName = resolveEditToolName(session);
const inactiveEditToolName = activeEditToolName === "edit" ? "vim" : "edit";
const requestedTools =
toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
if (requestedTools && !requestedTools.includes("exit_plan_mode")) {
requestedTools.push("exit_plan_mode");
}
@@ -384,7 +377,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
requestedTools.push("ast_grep");
}
if (
requestedTools.includes(activeEditToolName) &&
requestedTools.includes("edit") &&
!requestedTools.includes("ast_edit") &&
session.settings.get("astEdit.enabled")
) {
@@ -427,9 +420,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
filteredRequestedTools !== undefined
? filteredRequestedTools.filter(name => name !== "resolve").map(name => [name, allTools[name]] as const)
: [
...Object.entries(BUILTIN_TOOLS).filter(
([name]) => name !== inactiveEditToolName && isToolAllowed(name),
),
...Object.entries(BUILTIN_TOOLS).filter(([name]) => isToolAllowed(name)),
...(includeSubmitResult ? ([["submit_result", HIDDEN_TOOLS.submit_result]] as const) : []),
...([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const),
];
@@ -27,7 +27,6 @@ import { resolveToolRenderer } from "./resolve";
import { searchToolBm25Renderer } from "./search-tool-bm25";
import { sshToolRenderer } from "./ssh";
import { todoWriteToolRenderer } from "./todo-write";
import { vimToolRenderer } from "./vim";
import { writeToolRenderer } from "./write";
type ToolRenderer = {
@@ -63,7 +62,6 @@ export const toolRenderers: Record<string, ToolRenderer> = {
ssh: sshToolRenderer as ToolRenderer,
task: taskToolRenderer as ToolRenderer,
todo_write: todoWriteToolRenderer as ToolRenderer,
vim: vimToolRenderer as ToolRenderer,
gh_run_watch: ghRunWatchToolRenderer as ToolRenderer,
web_search: webSearchToolRenderer as ToolRenderer,
write: writeToolRenderer as ToolRenderer,
+32 -30
View File
@@ -1,7 +1,7 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { extractSegments, sliceWithWidth, Text } from "@oh-my-pi/pi-tui";
import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { isEnoent, logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import * as Diff from "diff";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -304,35 +304,37 @@ interface ExecuteVimStepsOptions {
onInsertStep?: () => Promise<void>;
}
// Auto-reorder insertion steps to descending line order (bottom-up) when all steps
// are simple `NGo`/`NGO` patterns and appear to reference original line numbers.
// Only reorders when steps are in strictly ascending order (common top-down mistake).
function autoReorderInsertSteps(steps: readonly VimStep[]): VimStep[] {
// Auto-reorder line-positioned steps to descending order (bottom-up) when all steps
// are simple `NG<cmd>` patterns and appear in ascending order (top-down). Bottom-up
// ordering is safe for any mix of insert/replace commands because edits at higher
// line numbers never shift lower line numbers.
function autoReorderSteps(steps: readonly VimStep[]): VimStep[] {
if (steps.length < 2) return [...steps];
// Check if ALL steps follow the pattern: single kbd entry of `NGo` or `NGO`
const linePattern = /^(\d+)G([oO])$/;
// Match single kbd entry of `<number>G<cmd>` where cmd enters insert mode
const linePattern = /^(\d+)G(o|O|cc|C|S|s|i|I|a|A)$/;
const parsed: Array<{ line: number; step: VimStep }> = [];
for (const step of steps) {
if (step.kbd.length !== 1) return [...steps]; // Can't reorder non-simple steps
if (step.kbd.length !== 1) return [...steps];
const match = step.kbd[0]!.match(linePattern);
if (!match) return [...steps]; // Not all steps are NGo/NGO — don't reorder
if (!match) return [...steps];
parsed.push({ line: Number(match[1]), step });
}
// Only reorder if steps are in strictly ascending order (top-down, likely a mistake).
// If already descending, mixed, or equal, the model likely planned the order deliberately.
let isAscending = true;
for (let i = 1; i < parsed.length; i++) {
if (parsed[i]!.line <= parsed[i - 1]!.line) {
isAscending = false;
break;
return [...steps];
}
}
if (!isAscending) return [...steps];
// Sort by descending line number (bottom-up)
parsed.sort((a, b) => b.line - a.line);
logger.debug("vim: auto-reordered steps to bottom-up", {
original: steps.map(s => s.kbd[0]),
reordered: parsed.map(p => p.step.kbd[0]),
});
return parsed.map(p => p.step);
}
@@ -341,9 +343,9 @@ async function executeVimSteps(
steps: readonly VimStep[],
options: ExecuteVimStepsOptions = {},
): Promise<void> {
// Execute steps in the order specified. Models should use bottom-up ordering
// (highest line number first) when inserting at multiple locations.
const orderedSteps = [...steps];
// Auto-reorder ascending line-positioned steps to descending (bottom-up)
// to prevent line-shift corruption from top-down edits.
const orderedSteps = autoReorderSteps(steps);
for (let index = 0; index < orderedSteps.length; index += 1) {
if (engine.closed) {
break;
@@ -411,7 +413,7 @@ async function readTextFile(
const bytes = await file.bytes();
for (const byte of bytes) {
if (byte === 0) {
throw new ToolError("Vim only supports UTF-8 text files in v1");
throw new ToolError("Edit tool in vim mode only supports UTF-8 text files in v1");
}
}
const text = utf8Decoder.decode(bytes);
@@ -436,7 +438,7 @@ async function readTextFile(
};
}
if (error instanceof TypeError) {
throw new ToolError("Vim only supports UTF-8 text files in v1");
throw new ToolError("Edit tool in vim mode only supports UTF-8 text files in v1");
}
throw error;
}
@@ -445,16 +447,16 @@ async function readTextFile(
function normalizeTargetPath(inputPath: string, cwd: string): { absolutePath: string; displayPath: string } {
const normalized = normalizePathLikeInput(inputPath);
if (INTERNAL_URL_PREFIX.test(normalized)) {
throw new ToolError("Vim only supports regular filesystem paths in v1");
throw new ToolError("Edit tool in vim mode only supports regular filesystem paths in v1");
}
if (isReadableUrlPath(normalized)) {
throw new ToolError("Vim only supports local filesystem paths in v1");
throw new ToolError("Edit tool in vim mode only supports local filesystem paths in v1");
}
if (parseArchivePathCandidates(normalized).some(candidate => candidate.archivePath === normalized)) {
throw new ToolError("Vim does not support archive targets in v1");
throw new ToolError("Edit tool in vim mode does not support archive targets in v1");
}
if (parseSqlitePathCandidates(normalized).some(candidate => candidate.sqlitePath === normalized)) {
throw new ToolError("Vim does not support SQLite targets in v1");
throw new ToolError("Edit tool in vim mode does not support SQLite targets in v1");
}
return {
absolutePath: resolveToCwd(normalized, cwd),
@@ -485,7 +487,7 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
async #loadBuffer(targetPath: string): Promise<VimLoadedFile> {
const { absolutePath, displayPath } = normalizeTargetPath(targetPath, this.session.cwd);
if (await isSqliteFile(absolutePath)) {
throw new ToolError("Vim does not support SQLite targets in v1");
throw new ToolError("Edit tool in vim mode does not support SQLite targets in v1");
}
const loaded = await readTextFile(absolutePath);
return {
@@ -604,12 +606,12 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
if (this.session.getPlanModeState?.()?.enabled) {
if (steps.some(step => step.insert !== undefined)) {
throw new ToolError("Plan mode: vim is read-only; insert payloads are not allowed.");
throw new ToolError("Plan mode: edit is read-only in vim mode; 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.",
"Plan mode: edit is read-only in vim mode; only navigation, search, open, and close are allowed.",
);
},
saveBuffer: async () => {
@@ -785,7 +787,7 @@ export function resetVimRendererStateForTest(): void {
export const vimToolRenderer = {
renderCall(args: VimRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
if (args.file && (!args.steps || args.steps.length === 0)) {
return renderText(`${uiTheme.bold("Vim")} open ${args.file}`);
return renderText(`${uiTheme.bold("Edit")} open ${args.file}`);
}
// Build a description of the streaming args for the header
@@ -822,7 +824,7 @@ export const vimToolRenderer = {
{
icon: "pending",
spinnerFrame: options.spinnerFrame,
title: "Vim",
title: "Edit",
description: argsDescription || details.file + modified,
meta: [`${langIcon} ${details.totalLines} lines`, position],
},
@@ -850,9 +852,9 @@ export const vimToolRenderer = {
// Fallback: no previous viewport available (first vim call)
if (argsDescription) {
return renderText(`${uiTheme.bold("Vim")} ${argsDescription}`);
return renderText(`${uiTheme.bold("Edit")} ${argsDescription}`);
}
return renderText(`${uiTheme.bold("Vim")}`);
return renderText(`${uiTheme.bold("Edit")}`);
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
@@ -932,7 +934,7 @@ export const vimToolRenderer = {
{
icon,
spinnerFrame: options.spinnerFrame,
title: "Vim",
title: "Edit",
description: details.file + modified,
badge: modeBadge,
meta: [`${langIcon} ${details.totalLines} lines`, position],
@@ -1,7 +1,6 @@
import { $env, $flag } from "@oh-my-pi/pi-utils";
export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim";
export type EditToolName = "edit" | "vim";
export const DEFAULT_EDIT_MODE: EditMode = "hashline";
@@ -49,37 +48,3 @@ export function resolveEditMode(session: EditModeSessionLike): EditMode {
const settingsMode = normalizeEditMode(String(session.settings.get("edit.mode") ?? ""));
return settingsMode ?? DEFAULT_EDIT_MODE;
}
export function resolveEditToolName(session: EditModeSessionLike): EditToolName {
return resolveEditMode(session) === "vim" ? "vim" : "edit";
}
export function resolveInactiveEditToolName(session: EditModeSessionLike): EditToolName {
return resolveEditToolName(session) === "edit" ? "vim" : "edit";
}
export function filterInactiveEditToolName(toolNames: Iterable<string>, session: EditModeSessionLike): string[] {
const inactiveEditToolName = resolveInactiveEditToolName(session);
return Array.from(toolNames).filter(name => name !== inactiveEditToolName);
}
export function normalizeToolNamesForEditMode(
toolNames: Iterable<string> | undefined,
session: EditModeSessionLike,
): string[] | undefined {
if (!toolNames) return undefined;
const normalized: string[] = [];
const seen = new Set<string>();
const activeEditToolName = resolveEditToolName(session);
for (const rawName of toolNames) {
const lowerName = rawName.toLowerCase();
const nextName = lowerName === "edit" || lowerName === "vim" ? activeEditToolName : lowerName;
if (seen.has(nextName)) continue;
seen.add(nextName);
normalized.push(nextName);
}
return normalized;
}
@@ -120,7 +120,7 @@ describe("AgentSession message pipeline", () => {
{
type: "toolCall",
id: "call_1",
name: "vim",
name: "edit",
arguments: {},
partialJson: '{"file":"preview.txt","steps":[{"kbd":["ggdGi"],"insert":"rep',
},
@@ -112,7 +112,7 @@ describe("createAgentSession defaultInactive tool activation", () => {
}
});
it("normalizes edit tool activation to vim when vim edit mode is configured", async () => {
it("keeps edit active when vim edit mode is configured", async () => {
const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`);
tempDirs.push(tempDir);
fs.mkdirSync(tempDir, { recursive: true });
@@ -135,21 +135,21 @@ describe("createAgentSession defaultInactive tool activation", () => {
});
try {
expect(session.getActiveToolNames()).toContain("vim");
expect(session.getActiveToolNames()).not.toContain("edit");
expect(session.getAllToolNames()).toContain("vim");
expect(session.getAllToolNames()).not.toContain("edit");
expect(session.getActiveToolNames()).toContain("edit");
expect(session.getActiveToolNames()).not.toContain("vim");
expect(session.getAllToolNames()).toContain("edit");
expect(session.getAllToolNames()).not.toContain("vim");
await session.setActiveToolsByName(["read", "edit"]);
expect(session.getActiveToolNames()).toContain("vim");
expect(session.getActiveToolNames()).not.toContain("edit");
expect(session.getActiveToolNames()).toContain("edit");
expect(session.getActiveToolNames()).not.toContain("vim");
} finally {
await session.dispose();
}
});
it("swaps the visible edit-capable tool when the active model changes edit modes", async () => {
it("keeps the visible edit tool stable when the active model changes edit modes", async () => {
const tempDir = path.join(os.tmpdir(), `pi-sdk-tool-activation-${Snowflake.next()}`);
tempDirs.push(tempDir);
fs.mkdirSync(tempDir, { recursive: true });
@@ -195,10 +195,10 @@ describe("createAgentSession defaultInactive tool activation", () => {
await session.setModel(vimModel);
expect(session.getActiveToolNames()).toContain("vim");
expect(session.getActiveToolNames()).not.toContain("edit");
expect(session.getAllToolNames()).toContain("vim");
expect(session.getAllToolNames()).not.toContain("edit");
expect(session.getActiveToolNames()).toContain("edit");
expect(session.getActiveToolNames()).not.toContain("vim");
expect(session.getAllToolNames()).toContain("edit");
expect(session.getAllToolNames()).not.toContain("vim");
} finally {
await session.dispose();
}
+5 -4
View File
@@ -669,12 +669,13 @@ describe("Coding Agent Tools", () => {
const result = await editTool.execute("test-call-5", {
edits: [{ path: testFile, old_text: "world", new_text: "testing" }],
});
const details = result.details as { diff?: string } | undefined;
expect(getTextOutput(result)).toContain("Successfully replaced");
expect(result.details).toBeDefined();
expect(result.details!.diff).toBeDefined();
expect(typeof result.details!.diff).toBe("string");
expect(result.details!.diff).toContain("testing");
expect(details).toBeDefined();
expect(details?.diff).toBeDefined();
expect(typeof details?.diff).toBe("string");
expect(details?.diff).toContain("testing");
});
it("should fail if text not found", async () => {
@@ -70,7 +70,7 @@ describe("createTools", () => {
expect(names).not.toContain("vim");
});
it("exposes vim instead of edit when vim edit mode is active", async () => {
it("keeps edit visible when vim edit mode is active", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
"edit.mode": "vim",
@@ -79,8 +79,8 @@ describe("createTools", () => {
const tools = await createTools(session);
const names = tools.map(t => t.name);
expect(names).toContain("vim");
expect(names).not.toContain("edit");
expect(names).toContain("edit");
expect(names).not.toContain("vim");
});
it("includes bash and python when python mode is both", async () => {
@@ -149,16 +149,16 @@ describe("createTools", () => {
expect(names).toEqual(["read", "write", "exit_plan_mode"]);
});
it("maps requested edit to vim when vim edit mode is active", async () => {
it("ignores vim as an unknown requested tool even when vim edit mode is active", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
"edit.mode": "vim",
}),
});
const tools = await createTools(session, ["read", "edit"]);
const tools = await createTools(session, ["read", "vim"]);
const names = tools.map(t => t.name);
expect(names).toEqual(["read", "vim", "exit_plan_mode", "ast_edit"]);
expect(names).toEqual(["read", "exit_plan_mode"]);
});
it("lowercases requested tool subset", async () => {
+1 -1
View File
@@ -667,7 +667,7 @@ describe("vim renderer", () => {
const uiStub = { requestRender() {} } as unknown as TUI;
const component = new ToolExecutionComponent(
"vim",
"edit",
{
file: "preview.txt",
steps: [step(["ggdGi"])],
+5 -5
View File
@@ -722,7 +722,7 @@ def run_benchmark_for_model(
token_input = 0
token_output = 0
turns_used = 0
edit_vim_tool_calls = 0
edit_tool_calls = 0
success = False
feedback = ""
error_msg: str | None = None
@@ -747,11 +747,11 @@ def run_benchmark_for_model(
def handle_tool_count(event: ToolExecutionStartEvent) -> None:
nonlocal edit_vim_tool_calls, turns_used
nonlocal edit_tool_calls, turns_used
if counting_edit_turns:
turns_used += 1
if event.tool_name in {"edit", "vim"}:
edit_vim_tool_calls += 1
if event.tool_name == "edit":
edit_tool_calls += 1
tool_count_remover = client.on_tool_execution_start(handle_tool_count)
@@ -808,7 +808,7 @@ def run_benchmark_for_model(
success=success,
turns_used=turns_used,
prompt_attempts=prompt_attempts,
edit_calls=edit_vim_tool_calls,
edit_calls=edit_tool_calls,
token_input=token_input,
token_output=token_output,
feedback=feedback.strip(),
+5 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Vim edit benchmark: tests the vim tool across models with a simple edit task.
Vim edit-mode benchmark: tests the edit tool in vim mode across models with a simple edit task.
"""
from __future__ import annotations
@@ -8,7 +8,7 @@ from edit_benchmark_common import BenchmarkSpec, EDIT_DIFF, EXPECTED_CONTENT, ru
EDIT_PROMPT = f"""\
Use the `read` tool to inspect `test.rs`, then use the `vim` tool to make `test.rs` exactly match the requested change.
Use the `read` tool to inspect `test.rs`, then use the `edit` tool in vim mode to make `test.rs` exactly match the requested change.
Apply this diff:
```diff
@@ -20,12 +20,12 @@ Final expected file content:
"""
VIM_BENCHMARK = BenchmarkSpec(
description="Benchmark vim tool across models with simple edit tasks.",
description="Benchmark edit tool in vim mode across models with simple edit tasks.",
workspace_prefix="vim-benchmark",
tools=("vim", "read"),
tools=("edit", "read"),
env={"PI_EDIT_VARIANT": "vim", "PI_STRICT_EDIT_MODE": "1"},
initial_prompt=EDIT_PROMPT,
retry_instruction="Please try again using the vim tool.",
retry_instruction="Please try again using the edit tool in vim mode.",
)