@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/package.json +8 -8
- package/src/async/job-manager.ts +43 -10
- package/src/commit/agentic/tools/analyze-file.ts +1 -2
- package/src/config/mcp-schema.json +1 -1
- package/src/config/model-equivalence.ts +1 -0
- package/src/config/model-registry.ts +63 -34
- package/src/config/model-resolver.ts +111 -15
- package/src/config/settings-schema.ts +4 -3
- package/src/config/settings.ts +1 -1
- package/src/cursor.ts +64 -23
- package/src/edit/index.ts +254 -89
- package/src/edit/modes/chunk.ts +336 -57
- package/src/edit/modes/hashline.ts +51 -26
- package/src/edit/modes/patch.ts +16 -10
- package/src/edit/modes/replace.ts +15 -7
- package/src/edit/renderer.ts +248 -94
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +6 -4
- package/src/extensibility/custom-tools/types.ts +0 -3
- package/src/extensibility/extensions/loader.ts +16 -0
- package/src/extensibility/extensions/runner.ts +2 -7
- package/src/extensibility/extensions/types.ts +8 -4
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/ipy/executor.ts +447 -52
- package/src/ipy/kernel.ts +39 -13
- package/src/lsp/client.ts +54 -0
- package/src/lsp/index.ts +8 -0
- package/src/lsp/types.ts +6 -0
- package/src/main.ts +0 -1
- package/src/modes/acp/acp-agent.ts +4 -1
- package/src/modes/components/bash-execution.ts +16 -4
- package/src/modes/components/status-line/presets.ts +17 -6
- package/src/modes/components/status-line/segments.ts +15 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +7 -1
- package/src/modes/components/tool-execution.ts +145 -75
- package/src/modes/controllers/command-controller.ts +24 -1
- package/src/modes/controllers/event-controller.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +28 -5
- package/src/modes/controllers/input-controller.ts +9 -3
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +19 -3
- package/src/modes/print-mode.ts +13 -4
- package/src/modes/prompt-action-autocomplete.ts +3 -5
- package/src/modes/rpc/rpc-mode.ts +8 -2
- package/src/modes/shared.ts +2 -2
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/chunk-edit.md +191 -163
- package/src/prompts/tools/hashline.md +11 -11
- package/src/prompts/tools/patch.md +10 -5
- package/src/prompts/tools/{await.md → poll.md} +1 -1
- package/src/prompts/tools/read-chunk.md +3 -3
- package/src/prompts/tools/task.md +2 -2
- package/src/prompts/tools/vim.md +98 -0
- package/src/sdk.ts +754 -724
- package/src/session/agent-session.ts +164 -34
- package/src/session/session-manager.ts +50 -4
- package/src/slash-commands/builtin-registry.ts +17 -0
- package/src/task/executor.ts +4 -4
- package/src/task/index.ts +3 -5
- package/src/task/types.ts +2 -2
- package/src/tools/bash.ts +26 -8
- package/src/tools/find.ts +5 -2
- package/src/tools/grep.ts +77 -8
- package/src/tools/index.ts +48 -19
- package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
- package/src/tools/python.ts +293 -278
- package/src/tools/submit-result.ts +5 -2
- package/src/tools/todo-write.ts +8 -2
- package/src/tools/vim.ts +966 -0
- package/src/utils/edit-mode.ts +2 -1
- package/src/utils/session-color.ts +55 -0
- package/src/utils/title-generator.ts +15 -6
- package/src/vim/buffer.ts +309 -0
- package/src/vim/commands.ts +382 -0
- package/src/vim/engine.ts +2426 -0
- package/src/vim/parser.ts +151 -0
- package/src/vim/render.ts +252 -0
- package/src/vim/types.ts +197 -0
package/src/tools/vim.ts
ADDED
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import type { Component } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import { extractSegments, sliceWithWidth, Text } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import { isEnoent, logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
6
|
+
import * as Diff from "diff";
|
|
7
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
8
|
+
import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
|
|
9
|
+
import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
|
|
10
|
+
import vimDescription from "../prompts/tools/vim.md" with { type: "text" };
|
|
11
|
+
import { CachedOutputBlock } from "../tui/output-block";
|
|
12
|
+
import { renderStatusLine } from "../tui/status-line";
|
|
13
|
+
import { VimBuffer } from "../vim/buffer";
|
|
14
|
+
import { VimEngine, type VimSaveResult } from "../vim/engine";
|
|
15
|
+
import { parseKeySequences } from "../vim/parser";
|
|
16
|
+
import {
|
|
17
|
+
buildDetails,
|
|
18
|
+
computeViewport,
|
|
19
|
+
renderVimDetails,
|
|
20
|
+
VIM_DEFAULT_VIEWPORT_LINES,
|
|
21
|
+
VIM_OPEN_VIEWPORT_LINES,
|
|
22
|
+
} from "../vim/render";
|
|
23
|
+
import type { VimFingerprint, VimKeyToken, VimLoadedFile, VimToolDetails, VimViewportLine } from "../vim/types";
|
|
24
|
+
import { VimInputError } from "../vim/types";
|
|
25
|
+
import type { ToolSession } from ".";
|
|
26
|
+
import { parseArchivePathCandidates } from "./archive-reader";
|
|
27
|
+
import { assertEditableFile } from "./auto-generated-guard";
|
|
28
|
+
import { isReadableUrlPath } from "./fetch";
|
|
29
|
+
import { normalizePathLikeInput, resolveToCwd } from "./path-utils";
|
|
30
|
+
import { enforcePlanModeWrite } from "./plan-mode-guard";
|
|
31
|
+
import { formatDiagnostics, replaceTabs } from "./render-utils";
|
|
32
|
+
import { isSqliteFile, parseSqlitePathCandidates } from "./sqlite-reader";
|
|
33
|
+
import { ToolError } from "./tool-errors";
|
|
34
|
+
import { toolResult } from "./tool-result";
|
|
35
|
+
|
|
36
|
+
const INTERNAL_URL_PREFIX = /^(agent|artifact|skill|rule|local|mcp):\/\//;
|
|
37
|
+
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
38
|
+
|
|
39
|
+
const vimStepSchema = Type.Object({
|
|
40
|
+
kbd: Type.Array(Type.String(), {
|
|
41
|
+
description: "Vim key sequences ONLY (e.g. ggdGi, 3Go, dd). NEVER put file content here — use insert for text.",
|
|
42
|
+
}),
|
|
43
|
+
insert: Type.Optional(
|
|
44
|
+
Type.String({
|
|
45
|
+
description:
|
|
46
|
+
"Raw text to type into the buffer. kbd must leave INSERT mode active first (e.g. via o, O, i, cc).",
|
|
47
|
+
}),
|
|
48
|
+
),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const vimSchema = Type.Object({
|
|
52
|
+
file: Type.String({ description: "File path to edit." }),
|
|
53
|
+
steps: Type.Optional(
|
|
54
|
+
Type.Array(vimStepSchema, {
|
|
55
|
+
description:
|
|
56
|
+
"Ordered editing steps. Each step executes kbd sequences, then optionally inserts text. INSERT mode is auto-exited between steps.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
pause: Type.Optional(
|
|
60
|
+
Type.Boolean({
|
|
61
|
+
description:
|
|
62
|
+
"Advanced: skip auto-save after the last step. Rarely needed. Omit or set false for normal use — edits auto-save.",
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
type VimParams = Static<typeof vimSchema>;
|
|
68
|
+
type VimStep = Static<typeof vimStepSchema>;
|
|
69
|
+
|
|
70
|
+
interface VimRenderStep {
|
|
71
|
+
kbd?: string[];
|
|
72
|
+
insert?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface VimRenderArgs {
|
|
76
|
+
file?: string;
|
|
77
|
+
steps?: VimRenderStep[];
|
|
78
|
+
pause?: boolean;
|
|
79
|
+
__partialJson?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fingerprintEqual(left: VimFingerprint | null, right: VimFingerprint | null): boolean {
|
|
83
|
+
if (left === null || right === null) {
|
|
84
|
+
return left === right;
|
|
85
|
+
}
|
|
86
|
+
return (
|
|
87
|
+
left.exists === right.exists &&
|
|
88
|
+
left.size === right.size &&
|
|
89
|
+
left.mtimeMs === right.mtimeMs &&
|
|
90
|
+
left.hash === right.hash
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderText(text: string): Component {
|
|
95
|
+
return new Text(replaceTabs(text), 0, 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serializeBufferText(buffer: Pick<VimBuffer, "getText" | "trailingNewline">): string {
|
|
99
|
+
return `${buffer.getText()}${buffer.trailingNewline ? "\n" : ""}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildModelDiff(beforeText: string, afterText: string): string | undefined {
|
|
103
|
+
if (beforeText === afterText) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const patch = Diff.structuredPatch("", "", beforeText, afterText, "", "", { context: 3 });
|
|
107
|
+
const diff = patch.hunks
|
|
108
|
+
.flatMap(hunk => [`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`, ...hunk.lines])
|
|
109
|
+
.join("\n");
|
|
110
|
+
return diff.length > 0 ? diff : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderViewportCursor(line: VimViewportLine, styledText: string, uiTheme: Theme): string {
|
|
114
|
+
if (!line.isCursor || line.cursorCol === undefined) {
|
|
115
|
+
return styledText;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const totalWidth = Bun.stringWidth(line.text);
|
|
119
|
+
const cursorCol = Math.max(0, Math.min(line.cursorCol, totalWidth));
|
|
120
|
+
const cursorSlice = sliceWithWidth(line.text, cursorCol, 1, false);
|
|
121
|
+
const replaceWidth = cursorSlice.width;
|
|
122
|
+
const afterStart = Math.min(totalWidth, cursorCol + replaceWidth);
|
|
123
|
+
const segments = extractSegments(styledText, cursorCol, afterStart, Math.max(0, totalWidth - afterStart), true);
|
|
124
|
+
const cursorText = cursorSlice.text.length > 0 ? cursorSlice.text : " ";
|
|
125
|
+
const invertedCursor = uiTheme.inverse(cursorText);
|
|
126
|
+
const cursorHighlight = invertedCursor === cursorText ? `\x1b[7m${cursorText}\x1b[27m` : invertedCursor;
|
|
127
|
+
return `${segments.before}${cursorHighlight}${segments.after}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderViewportLine(line: VimViewportLine, styledText: string, padWidth: number, uiTheme: Theme): string {
|
|
131
|
+
const lineNoStr = String(line.line).padStart(padWidth, " ");
|
|
132
|
+
const lineNoStyled = line.isCursor
|
|
133
|
+
? uiTheme.fg("accent", lineNoStr)
|
|
134
|
+
: line.isSelected
|
|
135
|
+
? uiTheme.fg("warning", lineNoStr)
|
|
136
|
+
: uiTheme.fg("dim", lineNoStr);
|
|
137
|
+
const separator = uiTheme.fg("dim", "│");
|
|
138
|
+
const prefix = line.isCursor ? uiTheme.fg("accent", ">") : line.isSelected ? uiTheme.fg("warning", "*") : " ";
|
|
139
|
+
return `${prefix}${lineNoStyled}${separator}${renderViewportCursor(line, styledText, uiTheme)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function splitTokensBySequence(kbd: string[]): Array<{ sequence: string; tokens: VimKeyToken[] }> {
|
|
143
|
+
const groups = new Map<number, VimKeyToken[]>();
|
|
144
|
+
for (const token of parseKeySequences(kbd)) {
|
|
145
|
+
const group = groups.get(token.sequenceIndex);
|
|
146
|
+
if (group) {
|
|
147
|
+
group.push(token);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
groups.set(token.sequenceIndex, [token]);
|
|
151
|
+
}
|
|
152
|
+
return kbd.map((sequence, sequenceIndex) => ({ sequence, tokens: groups.get(sequenceIndex) ?? [] }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function executeKeySequences(
|
|
156
|
+
engine: VimEngine,
|
|
157
|
+
groups: Array<{ sequence: string; tokens: VimKeyToken[] }>,
|
|
158
|
+
commandText: string,
|
|
159
|
+
onStep?: () => Promise<void>,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
for (let index = 0; index < groups.length; index += 1) {
|
|
162
|
+
const group = groups[index]!;
|
|
163
|
+
if (group.tokens.length === 0) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
await engine.executeTokens(group.tokens, commandText, onStep);
|
|
167
|
+
if (index < groups.length - 1 && engine.inputMode === "insert") {
|
|
168
|
+
// Roll back partial changes to prevent buffer corruption across calls.
|
|
169
|
+
engine.rollbackPendingInsert();
|
|
170
|
+
const nextSeq = groups[index + 1]?.sequence ?? "";
|
|
171
|
+
const looksLikeText = nextSeq.length > 0 && /\s/.test(nextSeq) && !/^[:/%]/.test(nextSeq);
|
|
172
|
+
let hint =
|
|
173
|
+
"Use the insert field for inserted text, or include <Esc> to return to NORMAL mode before the next kbd entry.";
|
|
174
|
+
if (looksLikeText) {
|
|
175
|
+
hint += ` The next entry (\`${nextSeq.length > 40 ? `${nextSeq.slice(0, 37)}...` : nextSeq}\`) looks like text content — put it in the \`insert\` field instead. For another edit location, add a new \`steps\` entry instead of another kbd entry.`;
|
|
176
|
+
}
|
|
177
|
+
throw new VimInputError(
|
|
178
|
+
`Sequence ${index + 1} (\`${group.sequence}\`) entered INSERT mode — changes rolled back. ${hint}`,
|
|
179
|
+
group.tokens[group.tokens.length - 1],
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Module-level cache of the last real vim result so renderCall can reuse that
|
|
186
|
+
// viewport while the next tool call is still streaming.
|
|
187
|
+
let lastVimDetails: VimToolDetails | undefined;
|
|
188
|
+
function buildToolDetailsFromEngine(
|
|
189
|
+
engine: VimEngine,
|
|
190
|
+
viewportLines: number,
|
|
191
|
+
preferredStart?: number,
|
|
192
|
+
closed = false,
|
|
193
|
+
errorLocation?: VimToolDetails["errorLocation"],
|
|
194
|
+
statusMessage?: string,
|
|
195
|
+
): VimToolDetails {
|
|
196
|
+
const cursorLine = engine.buffer.cursor.line + 1;
|
|
197
|
+
const cursorCol = engine.buffer.cursor.col + 1;
|
|
198
|
+
const viewport = computeViewport(cursorLine, engine.buffer.lineCount(), viewportLines, preferredStart);
|
|
199
|
+
const details = buildDetails({
|
|
200
|
+
file: engine.buffer.displayPath,
|
|
201
|
+
mode: engine.getPublicMode(),
|
|
202
|
+
cursor: { line: cursorLine, col: cursorCol },
|
|
203
|
+
totalLines: engine.buffer.lineCount(),
|
|
204
|
+
modified: engine.buffer.modified,
|
|
205
|
+
lines: engine.buffer.lines,
|
|
206
|
+
viewport,
|
|
207
|
+
selection: engine.getSelection(),
|
|
208
|
+
lastCommand: engine.lastCommand,
|
|
209
|
+
statusMessage: statusMessage ?? engine.statusMessage,
|
|
210
|
+
pendingInput: engine.getPendingInput(),
|
|
211
|
+
errorLocation,
|
|
212
|
+
closed,
|
|
213
|
+
});
|
|
214
|
+
details.diagnostics = engine.diagnostics;
|
|
215
|
+
return details;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function getLastStepInsert(steps: readonly VimStep[] | undefined): string | undefined {
|
|
219
|
+
if (!steps || steps.length === 0) {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
return steps[steps.length - 1]?.insert;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getNormalizedSteps(steps: VimRenderArgs["steps"]): VimStep[] | undefined {
|
|
226
|
+
if (!Array.isArray(steps)) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
return steps.map(step => ({
|
|
230
|
+
kbd: Array.isArray(step?.kbd) ? [...step.kbd] : [],
|
|
231
|
+
...(step?.insert !== undefined ? { insert: step.insert } : {}),
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getStepsForDisplay(args: VimRenderArgs): VimStep[] | undefined {
|
|
236
|
+
const steps = getNormalizedSteps(args.steps);
|
|
237
|
+
if (!steps || steps.length === 0) {
|
|
238
|
+
return steps;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const partialInsert = extractPartialInsert(args.__partialJson);
|
|
242
|
+
if (partialInsert === undefined) {
|
|
243
|
+
return steps;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const lastStep = steps[steps.length - 1]!;
|
|
247
|
+
if (lastStep.insert === undefined || partialInsert.length >= lastStep.insert.length) {
|
|
248
|
+
lastStep.insert = partialInsert;
|
|
249
|
+
}
|
|
250
|
+
return steps;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function splitInsertIntoChunks(text: string): string[] {
|
|
254
|
+
const maxChunkChars = 32;
|
|
255
|
+
if (text.length <= maxChunkChars) {
|
|
256
|
+
return text.length === 0 ? [] : [text];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const chunks: string[] = [];
|
|
260
|
+
let start = 0;
|
|
261
|
+
while (start < text.length) {
|
|
262
|
+
let end = Math.min(start + maxChunkChars, text.length);
|
|
263
|
+
if (end < text.length) {
|
|
264
|
+
const lastNewline = text.lastIndexOf("\n", end - 1);
|
|
265
|
+
if (lastNewline >= start) {
|
|
266
|
+
end = lastNewline + 1;
|
|
267
|
+
} else {
|
|
268
|
+
const lastSpace = Math.max(text.lastIndexOf(" ", end - 1), text.lastIndexOf("\t", end - 1));
|
|
269
|
+
if (lastSpace >= start + Math.floor(maxChunkChars / 2)) {
|
|
270
|
+
end = lastSpace + 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (end <= start) {
|
|
275
|
+
end = Math.min(start + maxChunkChars, text.length);
|
|
276
|
+
}
|
|
277
|
+
chunks.push(text.slice(start, end));
|
|
278
|
+
start = end;
|
|
279
|
+
}
|
|
280
|
+
return chunks;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function applyInsertWithStreaming(
|
|
284
|
+
engine: VimEngine,
|
|
285
|
+
text: string,
|
|
286
|
+
exitInsertMode: boolean,
|
|
287
|
+
onStep?: () => Promise<void>,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const chunks = splitInsertIntoChunks(text);
|
|
290
|
+
if (chunks.length === 0) {
|
|
291
|
+
await engine.applyLiteralInsert("", exitInsertMode);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
296
|
+
await engine.applyLiteralInsert(chunks[index]!, exitInsertMode && index === chunks.length - 1);
|
|
297
|
+
await onStep?.();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface ExecuteVimStepsOptions {
|
|
302
|
+
pauseLastStep?: boolean;
|
|
303
|
+
onKbdStep?: () => Promise<void>;
|
|
304
|
+
onInsertStep?: () => Promise<void>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Auto-reorder line-positioned steps to descending order (bottom-up) when all steps
|
|
308
|
+
// are simple `NG<cmd>` patterns and appear in ascending order (top-down). Bottom-up
|
|
309
|
+
// ordering is safe for any mix of insert/replace commands because edits at higher
|
|
310
|
+
// line numbers never shift lower line numbers.
|
|
311
|
+
function autoReorderSteps(steps: readonly VimStep[]): VimStep[] {
|
|
312
|
+
if (steps.length < 2) return [...steps];
|
|
313
|
+
|
|
314
|
+
// Match single kbd entry of `<number>G<cmd>` where cmd enters insert mode
|
|
315
|
+
const linePattern = /^(\d+)G(o|O|cc|C|S|s|i|I|a|A)$/;
|
|
316
|
+
const parsed: Array<{ line: number; step: VimStep }> = [];
|
|
317
|
+
for (const step of steps) {
|
|
318
|
+
if (step.kbd.length !== 1) return [...steps];
|
|
319
|
+
const match = step.kbd[0]!.match(linePattern);
|
|
320
|
+
if (!match) return [...steps];
|
|
321
|
+
parsed.push({ line: Number(match[1]), step });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Only reorder if steps are in strictly ascending order (top-down, likely a mistake).
|
|
325
|
+
// If already descending, mixed, or equal, the model likely planned the order deliberately.
|
|
326
|
+
for (let i = 1; i < parsed.length; i++) {
|
|
327
|
+
if (parsed[i]!.line <= parsed[i - 1]!.line) {
|
|
328
|
+
return [...steps];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Sort by descending line number (bottom-up)
|
|
333
|
+
parsed.sort((a, b) => b.line - a.line);
|
|
334
|
+
logger.debug("vim: auto-reordered steps to bottom-up", {
|
|
335
|
+
original: steps.map(s => s.kbd[0]),
|
|
336
|
+
reordered: parsed.map(p => p.step.kbd[0]),
|
|
337
|
+
});
|
|
338
|
+
return parsed.map(p => p.step);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function executeVimSteps(
|
|
342
|
+
engine: VimEngine,
|
|
343
|
+
steps: readonly VimStep[],
|
|
344
|
+
options: ExecuteVimStepsOptions = {},
|
|
345
|
+
): Promise<void> {
|
|
346
|
+
// Auto-reorder ascending line-positioned steps to descending (bottom-up)
|
|
347
|
+
// to prevent line-shift corruption from top-down edits.
|
|
348
|
+
const orderedSteps = autoReorderSteps(steps);
|
|
349
|
+
for (let index = 0; index < orderedSteps.length; index += 1) {
|
|
350
|
+
if (engine.closed) {
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const step = orderedSteps[index]!;
|
|
355
|
+
const isLast = index === orderedSteps.length - 1;
|
|
356
|
+
const hasKbd = step.kbd.some(sequence => sequence.length > 0);
|
|
357
|
+
const preservePausedState = !hasKbd && step.insert === undefined && isLast && options.pauseLastStep === true;
|
|
358
|
+
if (engine.inputMode === "insert" && (hasKbd || step.insert === undefined) && !preservePausedState) {
|
|
359
|
+
engine.rollbackPendingInsert();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (step.kbd.length > 0) {
|
|
363
|
+
const commandText = step.kbd.join(" ");
|
|
364
|
+
const tokenGroups = splitTokensBySequence(step.kbd);
|
|
365
|
+
await executeKeySequences(engine, tokenGroups, commandText, options.onKbdStep);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!engine.closed && step.insert !== undefined && (step.insert.length > 0 || engine.inputMode === "insert")) {
|
|
369
|
+
// Strip trailing newline from insert text — `o`/`O` already create a line boundary,
|
|
370
|
+
// so a trailing \n would produce an unwanted blank line.
|
|
371
|
+
const normalizedInsert = step.insert.endsWith("\n") ? step.insert.slice(0, -1) : step.insert;
|
|
372
|
+
const exitInsertMode = !(isLast && options.pauseLastStep === true);
|
|
373
|
+
await applyInsertWithStreaming(engine, normalizedInsert, exitInsertMode, options.onInsertStep);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!isLast && engine.inputMode === "insert") {
|
|
377
|
+
engine.rollbackPendingInsert();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function statFingerprint(absolutePath: string): Promise<VimFingerprint | null> {
|
|
383
|
+
try {
|
|
384
|
+
const file = Bun.file(absolutePath);
|
|
385
|
+
const stat = await file.stat();
|
|
386
|
+
if (!stat.isFile()) {
|
|
387
|
+
throw new ToolError(`Not a regular file: ${absolutePath}`);
|
|
388
|
+
}
|
|
389
|
+
const bytes = await file.bytes();
|
|
390
|
+
return {
|
|
391
|
+
exists: true,
|
|
392
|
+
size: stat.size,
|
|
393
|
+
mtimeMs: stat.mtimeMs,
|
|
394
|
+
hash: String(Bun.hash(bytes)),
|
|
395
|
+
};
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (isEnoent(error)) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function readTextFile(
|
|
405
|
+
absolutePath: string,
|
|
406
|
+
): Promise<{ lines: string[]; trailingNewline: boolean; fingerprint: VimFingerprint | null }> {
|
|
407
|
+
try {
|
|
408
|
+
const file = Bun.file(absolutePath);
|
|
409
|
+
const stat = await file.stat();
|
|
410
|
+
if (!stat.isFile()) {
|
|
411
|
+
throw new ToolError(`Not a regular file: ${absolutePath}`);
|
|
412
|
+
}
|
|
413
|
+
const bytes = await file.bytes();
|
|
414
|
+
for (const byte of bytes) {
|
|
415
|
+
if (byte === 0) {
|
|
416
|
+
throw new ToolError("Edit tool in vim mode only supports UTF-8 text files in v1");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const text = utf8Decoder.decode(bytes);
|
|
420
|
+
const trailingNewline = text.endsWith("\n");
|
|
421
|
+
const body = trailingNewline ? text.slice(0, -1) : text;
|
|
422
|
+
return {
|
|
423
|
+
lines: body.length === 0 ? [""] : body.split("\n"),
|
|
424
|
+
trailingNewline,
|
|
425
|
+
fingerprint: {
|
|
426
|
+
exists: true,
|
|
427
|
+
size: stat.size,
|
|
428
|
+
mtimeMs: stat.mtimeMs,
|
|
429
|
+
hash: String(Bun.hash(bytes)),
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (isEnoent(error)) {
|
|
434
|
+
return {
|
|
435
|
+
lines: [""],
|
|
436
|
+
trailingNewline: false,
|
|
437
|
+
fingerprint: null,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (error instanceof TypeError) {
|
|
441
|
+
throw new ToolError("Edit tool in vim mode only supports UTF-8 text files in v1");
|
|
442
|
+
}
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function normalizeTargetPath(inputPath: string, cwd: string): { absolutePath: string; displayPath: string } {
|
|
448
|
+
const normalized = normalizePathLikeInput(inputPath);
|
|
449
|
+
if (INTERNAL_URL_PREFIX.test(normalized)) {
|
|
450
|
+
throw new ToolError("Edit tool in vim mode only supports regular filesystem paths in v1");
|
|
451
|
+
}
|
|
452
|
+
if (isReadableUrlPath(normalized)) {
|
|
453
|
+
throw new ToolError("Edit tool in vim mode only supports local filesystem paths in v1");
|
|
454
|
+
}
|
|
455
|
+
if (parseArchivePathCandidates(normalized).some(candidate => candidate.archivePath === normalized)) {
|
|
456
|
+
throw new ToolError("Edit tool in vim mode does not support archive targets in v1");
|
|
457
|
+
}
|
|
458
|
+
if (parseSqlitePathCandidates(normalized).some(candidate => candidate.sqlitePath === normalized)) {
|
|
459
|
+
throw new ToolError("Edit tool in vim mode does not support SQLite targets in v1");
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
absolutePath: resolveToCwd(normalized, cwd),
|
|
463
|
+
displayPath: normalized,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
|
|
468
|
+
readonly name = "vim";
|
|
469
|
+
readonly label = "Vim";
|
|
470
|
+
readonly description: string;
|
|
471
|
+
readonly parameters = vimSchema;
|
|
472
|
+
readonly concurrency = "exclusive";
|
|
473
|
+
|
|
474
|
+
#engines = new Map<string, VimEngine>();
|
|
475
|
+
#writethrough: WritethroughCallback;
|
|
476
|
+
|
|
477
|
+
constructor(private readonly session: ToolSession) {
|
|
478
|
+
const enableLsp = session.enableLsp ?? true;
|
|
479
|
+
const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
|
|
480
|
+
const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite");
|
|
481
|
+
this.#writethrough = enableLsp
|
|
482
|
+
? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics })
|
|
483
|
+
: writethroughNoop;
|
|
484
|
+
this.description = prompt.render(vimDescription);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async #loadBuffer(targetPath: string): Promise<VimLoadedFile> {
|
|
488
|
+
const { absolutePath, displayPath } = normalizeTargetPath(targetPath, this.session.cwd);
|
|
489
|
+
if (await isSqliteFile(absolutePath)) {
|
|
490
|
+
throw new ToolError("Edit tool in vim mode does not support SQLite targets in v1");
|
|
491
|
+
}
|
|
492
|
+
const loaded = await readTextFile(absolutePath);
|
|
493
|
+
return {
|
|
494
|
+
absolutePath,
|
|
495
|
+
displayPath,
|
|
496
|
+
lines: loaded.lines,
|
|
497
|
+
trailingNewline: loaded.trailingNewline,
|
|
498
|
+
fingerprint: loaded.fingerprint,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async #beforeMutate(buffer: VimBuffer): Promise<void> {
|
|
503
|
+
enforcePlanModeWrite(this.session, buffer.displayPath, { op: buffer.baseFingerprint ? "update" : "create" });
|
|
504
|
+
if (!buffer.editabilityChecked && buffer.baseFingerprint) {
|
|
505
|
+
await assertEditableFile(buffer.filePath, buffer.displayPath);
|
|
506
|
+
buffer.editabilityChecked = true;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async #saveBuffer(buffer: VimBuffer, options?: { force?: boolean }): Promise<VimSaveResult> {
|
|
511
|
+
enforcePlanModeWrite(this.session, buffer.displayPath, { op: buffer.baseFingerprint ? "update" : "create" });
|
|
512
|
+
if (buffer.baseFingerprint) {
|
|
513
|
+
await assertEditableFile(buffer.filePath, buffer.displayPath);
|
|
514
|
+
}
|
|
515
|
+
if (!options?.force) {
|
|
516
|
+
const diskFingerprint = await statFingerprint(buffer.filePath);
|
|
517
|
+
if (!fingerprintEqual(buffer.baseFingerprint, diskFingerprint)) {
|
|
518
|
+
throw new ToolError("File changed on disk since open; reload with :e! before saving.");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const content = `${buffer.getText()}${buffer.trailingNewline ? "\n" : ""}`;
|
|
522
|
+
const diagnostics = (await this.#writethrough(buffer.filePath, content)) as FileDiagnosticsResult | undefined;
|
|
523
|
+
const loaded = await this.#loadBuffer(buffer.displayPath);
|
|
524
|
+
return { loaded, diagnostics };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
#renderFromEngine(
|
|
528
|
+
engine: VimEngine,
|
|
529
|
+
viewportLines: number,
|
|
530
|
+
preferredStart?: number,
|
|
531
|
+
closed = false,
|
|
532
|
+
errorLocation?: VimToolDetails["errorLocation"],
|
|
533
|
+
statusMessage?: string,
|
|
534
|
+
modelDiff?: string,
|
|
535
|
+
): AgentToolResult<VimToolDetails> {
|
|
536
|
+
const details = buildToolDetailsFromEngine(
|
|
537
|
+
engine,
|
|
538
|
+
viewportLines,
|
|
539
|
+
preferredStart,
|
|
540
|
+
closed,
|
|
541
|
+
errorLocation,
|
|
542
|
+
statusMessage,
|
|
543
|
+
);
|
|
544
|
+
const resultText = modelDiff ? `${renderVimDetails(details)}\n\nDiff:\n${modelDiff}` : renderVimDetails(details);
|
|
545
|
+
const builder = toolResult<VimToolDetails>(details).text(resultText);
|
|
546
|
+
if (engine.diagnostics) {
|
|
547
|
+
builder.diagnostics(engine.diagnostics.summary, engine.diagnostics.messages ?? []);
|
|
548
|
+
}
|
|
549
|
+
lastVimDetails = details;
|
|
550
|
+
return builder.done();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#throwWithSnapshot(engine: VimEngine, error: unknown): never {
|
|
554
|
+
const location = error instanceof VimInputError ? error.location : undefined;
|
|
555
|
+
const statusMessage = error instanceof Error ? error.message : String(error);
|
|
556
|
+
const result = this.#renderFromEngine(
|
|
557
|
+
engine,
|
|
558
|
+
VIM_DEFAULT_VIEWPORT_LINES,
|
|
559
|
+
engine.viewportStart,
|
|
560
|
+
engine.closed,
|
|
561
|
+
location,
|
|
562
|
+
statusMessage,
|
|
563
|
+
);
|
|
564
|
+
const text = result.content.find(block => block.type === "text")?.text ?? statusMessage;
|
|
565
|
+
throw new ToolError(text);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async execute(
|
|
569
|
+
_toolCallId: string,
|
|
570
|
+
params: VimParams,
|
|
571
|
+
signal?: AbortSignal,
|
|
572
|
+
onUpdate?: AgentToolUpdateCallback<VimToolDetails>,
|
|
573
|
+
_context?: AgentToolContext,
|
|
574
|
+
): Promise<AgentToolResult<VimToolDetails>> {
|
|
575
|
+
return untilAborted(signal, async () => {
|
|
576
|
+
// Resolve file path and get-or-create engine for this buffer
|
|
577
|
+
const { absolutePath } = normalizeTargetPath(params.file, this.session.cwd);
|
|
578
|
+
let engine = this.#engines.get(absolutePath);
|
|
579
|
+
let isNewBuffer = false;
|
|
580
|
+
if (!engine) {
|
|
581
|
+
const loaded = await this.#loadBuffer(params.file);
|
|
582
|
+
engine = new VimEngine(new VimBuffer(loaded), {
|
|
583
|
+
beforeMutate: buffer => this.#beforeMutate(buffer),
|
|
584
|
+
loadBuffer: path => this.#loadBuffer(path),
|
|
585
|
+
saveBuffer: (buffer, options) => this.#saveBuffer(buffer, options),
|
|
586
|
+
});
|
|
587
|
+
engine.viewportStart = 1;
|
|
588
|
+
this.#engines.set(absolutePath, engine);
|
|
589
|
+
isNewBuffer = true;
|
|
590
|
+
} else if (!engine.buffer.modified) {
|
|
591
|
+
// Sync fingerprint from disk to handle LSP writethrough reformats
|
|
592
|
+
const fp = await statFingerprint(absolutePath);
|
|
593
|
+
if (fp) engine.buffer.baseFingerprint = fp;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const steps = params.steps;
|
|
597
|
+
if (!steps || steps.length === 0) {
|
|
598
|
+
// No steps — just show the file viewport
|
|
599
|
+
if (isNewBuffer) {
|
|
600
|
+
engine.statusMessage = `Opened ${engine.buffer.displayPath}`;
|
|
601
|
+
}
|
|
602
|
+
return this.#renderFromEngine(engine, VIM_OPEN_VIEWPORT_LINES, engine.viewportStart);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const beforeText = serializeBufferText(engine.buffer);
|
|
606
|
+
|
|
607
|
+
if (this.session.getPlanModeState?.()?.enabled) {
|
|
608
|
+
if (steps.some(step => step.insert !== undefined)) {
|
|
609
|
+
throw new ToolError("Plan mode: edit is read-only in vim mode; insert payloads are not allowed.");
|
|
610
|
+
}
|
|
611
|
+
const preview = engine.clone({
|
|
612
|
+
beforeMutate: async () => {
|
|
613
|
+
throw new VimInputError(
|
|
614
|
+
"Plan mode: edit is read-only in vim mode; only navigation, search, open, and close are allowed.",
|
|
615
|
+
);
|
|
616
|
+
},
|
|
617
|
+
saveBuffer: async () => {
|
|
618
|
+
throw new VimInputError("Plan mode: :w is not allowed.");
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
await executeVimSteps(preview, steps, { pauseLastStep: params.pause === true });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
const FRAME_INTERVAL_MS = 16; // ~60fps
|
|
626
|
+
let lastUpdateTime = 0;
|
|
627
|
+
|
|
628
|
+
const emitUpdate = onUpdate
|
|
629
|
+
? async (force = false) => {
|
|
630
|
+
const now = Date.now();
|
|
631
|
+
if (!force && now - lastUpdateTime < FRAME_INTERVAL_MS) {
|
|
632
|
+
return; // throttle: skip if too soon
|
|
633
|
+
}
|
|
634
|
+
onUpdate(this.#renderFromEngine(engine, VIM_DEFAULT_VIEWPORT_LINES, engine.viewportStart));
|
|
635
|
+
lastUpdateTime = Date.now();
|
|
636
|
+
await Bun.sleep(FRAME_INTERVAL_MS); // real delay for terminal to render
|
|
637
|
+
}
|
|
638
|
+
: undefined;
|
|
639
|
+
|
|
640
|
+
await executeVimSteps(engine, steps, {
|
|
641
|
+
pauseLastStep: params.pause === true,
|
|
642
|
+
onKbdStep: emitUpdate ? () => emitUpdate() : undefined,
|
|
643
|
+
onInsertStep: emitUpdate ? () => emitUpdate(true) : undefined,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
if (params.pause === true && !engine.closed && engine.getPendingInput()) {
|
|
647
|
+
engine.statusMessage = engine.statusMessage ?? `Paused in ${engine.getPublicMode()} mode`;
|
|
648
|
+
}
|
|
649
|
+
} catch (error) {
|
|
650
|
+
this.#throwWithSnapshot(engine, error);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (beforeText !== serializeBufferText(engine.buffer)) {
|
|
654
|
+
engine.centerViewportOnCursor();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Auto-save when buffer was modified
|
|
658
|
+
if (!engine.closed && engine.buffer.modified && params.pause !== true) {
|
|
659
|
+
try {
|
|
660
|
+
const result = await this.#saveBuffer(engine.buffer);
|
|
661
|
+
engine.buffer.markSaved(result.loaded);
|
|
662
|
+
engine.diagnostics = result.diagnostics;
|
|
663
|
+
if (beforeText !== serializeBufferText(engine.buffer)) {
|
|
664
|
+
engine.centerViewportOnCursor();
|
|
665
|
+
}
|
|
666
|
+
} catch (error) {
|
|
667
|
+
this.#throwWithSnapshot(engine, error);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const afterText = serializeBufferText(engine.buffer);
|
|
672
|
+
const modelDiff = buildModelDiff(beforeText, afterText);
|
|
673
|
+
|
|
674
|
+
const result = this.#renderFromEngine(
|
|
675
|
+
engine,
|
|
676
|
+
VIM_DEFAULT_VIEWPORT_LINES,
|
|
677
|
+
engine.viewportStart,
|
|
678
|
+
engine.closed,
|
|
679
|
+
undefined,
|
|
680
|
+
undefined,
|
|
681
|
+
modelDiff,
|
|
682
|
+
);
|
|
683
|
+
if (engine.closed) {
|
|
684
|
+
this.#engines.delete(absolutePath);
|
|
685
|
+
}
|
|
686
|
+
return result;
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Unescape JSON string escape sequences from a partial (potentially incomplete) JSON string value.
|
|
692
|
+
function unescapePartialJsonString(value: string): string {
|
|
693
|
+
let output = "";
|
|
694
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
695
|
+
const char = value[index];
|
|
696
|
+
if (char !== "\\") {
|
|
697
|
+
output += char;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const next = value[index + 1];
|
|
701
|
+
if (!next) {
|
|
702
|
+
output += "\\";
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
index += 1;
|
|
706
|
+
switch (next) {
|
|
707
|
+
case '"':
|
|
708
|
+
case "\\":
|
|
709
|
+
case "/":
|
|
710
|
+
output += next;
|
|
711
|
+
break;
|
|
712
|
+
case "b":
|
|
713
|
+
output += "\b";
|
|
714
|
+
break;
|
|
715
|
+
case "f":
|
|
716
|
+
output += "\f";
|
|
717
|
+
break;
|
|
718
|
+
case "n":
|
|
719
|
+
output += "\n";
|
|
720
|
+
break;
|
|
721
|
+
case "r":
|
|
722
|
+
output += "\r";
|
|
723
|
+
break;
|
|
724
|
+
case "t":
|
|
725
|
+
output += "\t";
|
|
726
|
+
break;
|
|
727
|
+
case "u": {
|
|
728
|
+
const codePoint = value.slice(index + 1, index + 5);
|
|
729
|
+
if (codePoint.length === 4) {
|
|
730
|
+
const parsed = parseInt(codePoint, 16);
|
|
731
|
+
if (!Number.isNaN(parsed)) {
|
|
732
|
+
output += String.fromCharCode(parsed);
|
|
733
|
+
index += 4;
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
output += "\\u";
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
default:
|
|
741
|
+
output += `\\${next}`;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return output;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Extract partial insert text from raw JSON buffer during streaming.
|
|
748
|
+
// partial-json often doesn't surface string values until the closing quote is seen.
|
|
749
|
+
function extractPartialInsert(partialJson: string | undefined): string | undefined {
|
|
750
|
+
if (!partialJson) {
|
|
751
|
+
return undefined;
|
|
752
|
+
}
|
|
753
|
+
const matches = Array.from(partialJson.matchAll(/"insert"\s*:\s*"((?:\\.|[^"\\])*)(?:"|$)/gu));
|
|
754
|
+
const match = matches[matches.length - 1];
|
|
755
|
+
if (!match) {
|
|
756
|
+
return undefined;
|
|
757
|
+
}
|
|
758
|
+
return unescapePartialJsonString(match[1]!);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function describeStepsForDisplay(args: VimRenderArgs): string {
|
|
762
|
+
const steps = getStepsForDisplay(args);
|
|
763
|
+
if (!steps || steps.length === 0) {
|
|
764
|
+
return "";
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const kbdSummary = steps.map(step => step.kbd.join(" ")).filter(summary => summary.length > 0);
|
|
768
|
+
let description = steps.length === 1 ? (kbdSummary[0] ?? "1 step") : `${steps.length} steps`;
|
|
769
|
+
if (steps.length > 1 && kbdSummary.length > 0) {
|
|
770
|
+
description += ` · ${kbdSummary.join(" → ")}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const insertText = getLastStepInsert(steps);
|
|
774
|
+
if (insertText !== undefined && insertText.length > 0) {
|
|
775
|
+
description += `${description.length > 0 ? " · " : ""}insert: ${insertText}`;
|
|
776
|
+
}
|
|
777
|
+
if (args.pause) {
|
|
778
|
+
description += `${description.length > 0 ? " · " : ""}pause`;
|
|
779
|
+
}
|
|
780
|
+
return description;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function resetVimRendererStateForTest(): void {
|
|
784
|
+
lastVimDetails = undefined;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export const vimToolRenderer = {
|
|
788
|
+
renderCall(args: VimRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
|
|
789
|
+
if (args.file && (!args.steps || args.steps.length === 0)) {
|
|
790
|
+
return renderText(`${uiTheme.bold("Edit")} open ${args.file}`);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Build a description of the streaming args for the header
|
|
794
|
+
const argsDescription = describeStepsForDisplay(args);
|
|
795
|
+
|
|
796
|
+
// Reuse the last real vim result for the same file while the next call is still streaming.
|
|
797
|
+
const details = lastVimDetails?.file === args.file ? lastVimDetails : undefined;
|
|
798
|
+
if (details?.viewportLines && details.viewportLines.length > 0) {
|
|
799
|
+
const lang = getLanguageFromPath(details.file);
|
|
800
|
+
const langIcon = uiTheme.getLangIcon(lang);
|
|
801
|
+
const modified = details.modified ? " [+]" : "";
|
|
802
|
+
const position = `L${details.cursor.line}:${details.cursor.col}`;
|
|
803
|
+
const padWidth = String(details.viewport.end).length;
|
|
804
|
+
const viewportLines = details.viewportLines;
|
|
805
|
+
const highlightedLines = highlightCode(viewportLines.map(line => line.text).join("\n"), lang);
|
|
806
|
+
const renderedLines = viewportLines.map((line, index) =>
|
|
807
|
+
renderViewportLine(line, highlightedLines[index] ?? line.text, padWidth, uiTheme),
|
|
808
|
+
);
|
|
809
|
+
if (details.statusMessage) {
|
|
810
|
+
renderedLines.push(uiTheme.fg("dim", details.statusMessage));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const outputBlock = new CachedOutputBlock();
|
|
814
|
+
let cached: { key: string; result: string[] } | undefined;
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
render: (width: number): string[] => {
|
|
818
|
+
const cacheKey = `${width}|${options.spinnerFrame ?? -1}|${argsDescription}`;
|
|
819
|
+
if (cached?.key === cacheKey) {
|
|
820
|
+
return cached.result;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const header = renderStatusLine(
|
|
824
|
+
{
|
|
825
|
+
icon: "pending",
|
|
826
|
+
spinnerFrame: options.spinnerFrame,
|
|
827
|
+
title: "Edit",
|
|
828
|
+
description: argsDescription || details.file + modified,
|
|
829
|
+
meta: [`${langIcon} ${details.totalLines} lines`, position],
|
|
830
|
+
},
|
|
831
|
+
uiTheme,
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
const lines = outputBlock.render(
|
|
835
|
+
{
|
|
836
|
+
header,
|
|
837
|
+
state: "pending",
|
|
838
|
+
sections: [{ lines: renderedLines }],
|
|
839
|
+
width,
|
|
840
|
+
},
|
|
841
|
+
uiTheme,
|
|
842
|
+
);
|
|
843
|
+
cached = { key: cacheKey, result: lines };
|
|
844
|
+
return lines;
|
|
845
|
+
},
|
|
846
|
+
invalidate: () => {
|
|
847
|
+
cached = undefined;
|
|
848
|
+
outputBlock.invalidate();
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Fallback: no previous viewport available (first vim call)
|
|
854
|
+
if (argsDescription) {
|
|
855
|
+
return renderText(`${uiTheme.bold("Edit")} ${argsDescription}`);
|
|
856
|
+
}
|
|
857
|
+
return renderText(`${uiTheme.bold("Edit")}`);
|
|
858
|
+
},
|
|
859
|
+
renderResult(
|
|
860
|
+
result: { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
|
|
861
|
+
options: RenderResultOptions,
|
|
862
|
+
uiTheme: Theme,
|
|
863
|
+
): Component {
|
|
864
|
+
const details = result.details;
|
|
865
|
+
const isError = result.isError === true;
|
|
866
|
+
|
|
867
|
+
// No structured details (e.g. closed): fall back to plain text
|
|
868
|
+
if (!details?.viewportLines || details.viewportLines.length === 0) {
|
|
869
|
+
if (details) {
|
|
870
|
+
return renderText(renderVimDetails(details));
|
|
871
|
+
}
|
|
872
|
+
const text = result.content.find(block => block.type === "text")?.text ?? "";
|
|
873
|
+
return renderText(text);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const lang = getLanguageFromPath(details.file);
|
|
877
|
+
const langIcon = uiTheme.getLangIcon(lang);
|
|
878
|
+
const modified = details.modified ? " [+]" : "";
|
|
879
|
+
const position = `L${details.cursor.line}:${details.cursor.col}`;
|
|
880
|
+
const padWidth = String(details.viewport.end).length;
|
|
881
|
+
const viewportLines = details.viewportLines;
|
|
882
|
+
const highlightedLines = highlightCode(viewportLines.map(line => line.text).join("\n"), lang);
|
|
883
|
+
const renderedLines = viewportLines.map((line, index) =>
|
|
884
|
+
renderViewportLine(line, highlightedLines[index] ?? line.text, padWidth, uiTheme),
|
|
885
|
+
);
|
|
886
|
+
if (details.statusMessage) {
|
|
887
|
+
renderedLines.push(uiTheme.fg("dim", details.statusMessage));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const sections: Array<{ label?: string; lines: string[] }> = [{ lines: renderedLines }];
|
|
891
|
+
if (details.diagnostics?.messages && details.diagnostics.messages.length > 0) {
|
|
892
|
+
const diagText = formatDiagnostics(
|
|
893
|
+
{
|
|
894
|
+
errored: isError,
|
|
895
|
+
summary: details.diagnostics.summary,
|
|
896
|
+
messages: details.diagnostics.messages,
|
|
897
|
+
},
|
|
898
|
+
options.expanded,
|
|
899
|
+
uiTheme,
|
|
900
|
+
(filePath: string) => uiTheme.getLangIcon(getLanguageFromPath(filePath)),
|
|
901
|
+
);
|
|
902
|
+
if (diagText) {
|
|
903
|
+
sections.push({ lines: [diagText] });
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const outputBlock = new CachedOutputBlock();
|
|
908
|
+
let cached: { key: string; result: string[] } | undefined;
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
render: (width: number): string[] => {
|
|
912
|
+
const cacheKey = `${width}|${options.isPartial ? 1 : 0}|${isError ? 1 : 0}|${options.spinnerFrame ?? -1}`;
|
|
913
|
+
if (cached?.key === cacheKey) {
|
|
914
|
+
return cached.result;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const icon = options.isPartial ? "pending" : isError ? "error" : "success";
|
|
918
|
+
|
|
919
|
+
// Mode badge
|
|
920
|
+
const modeBadge =
|
|
921
|
+
details.mode === "NORMAL"
|
|
922
|
+
? undefined
|
|
923
|
+
: {
|
|
924
|
+
label: details.mode,
|
|
925
|
+
color:
|
|
926
|
+
details.mode === "INSERT"
|
|
927
|
+
? ("success" as const)
|
|
928
|
+
: details.mode === "VISUAL" || details.mode === "VISUAL-LINE"
|
|
929
|
+
? ("warning" as const)
|
|
930
|
+
: ("accent" as const),
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const header = renderStatusLine(
|
|
934
|
+
{
|
|
935
|
+
icon,
|
|
936
|
+
spinnerFrame: options.spinnerFrame,
|
|
937
|
+
title: "Edit",
|
|
938
|
+
description: details.file + modified,
|
|
939
|
+
badge: modeBadge,
|
|
940
|
+
meta: [`${langIcon} ${details.totalLines} lines`, position],
|
|
941
|
+
},
|
|
942
|
+
uiTheme,
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
const lines = outputBlock.render(
|
|
946
|
+
{
|
|
947
|
+
header,
|
|
948
|
+
state: options.isPartial ? "pending" : isError ? "error" : "success",
|
|
949
|
+
sections,
|
|
950
|
+
width,
|
|
951
|
+
},
|
|
952
|
+
uiTheme,
|
|
953
|
+
);
|
|
954
|
+
cached = { key: cacheKey, result: lines };
|
|
955
|
+
return lines;
|
|
956
|
+
},
|
|
957
|
+
invalidate: () => {
|
|
958
|
+
cached = undefined;
|
|
959
|
+
outputBlock.invalidate();
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
},
|
|
963
|
+
mergeCallAndResult: true,
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
export { vimSchema };
|