@oh-my-pi/pi-coding-agent 14.0.5 → 14.1.1

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.
Files changed (101) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/package.json +8 -8
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +43 -10
  5. package/src/async/support.ts +5 -0
  6. package/src/cli/list-models.ts +96 -57
  7. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  8. package/src/commit/model-selection.ts +16 -13
  9. package/src/config/mcp-schema.json +1 -1
  10. package/src/config/model-equivalence.ts +675 -0
  11. package/src/config/model-registry.ts +242 -45
  12. package/src/config/model-resolver.ts +282 -65
  13. package/src/config/settings-schema.ts +27 -3
  14. package/src/config/settings.ts +1 -1
  15. package/src/cursor.ts +64 -23
  16. package/src/edit/index.ts +254 -89
  17. package/src/edit/modes/chunk.ts +336 -57
  18. package/src/edit/modes/hashline.ts +51 -26
  19. package/src/edit/modes/patch.ts +16 -10
  20. package/src/edit/modes/replace.ts +15 -7
  21. package/src/edit/renderer.ts +248 -94
  22. package/src/export/html/template.css +82 -0
  23. package/src/export/html/template.generated.ts +1 -1
  24. package/src/export/html/template.js +614 -97
  25. package/src/extensibility/custom-tools/types.ts +0 -3
  26. package/src/extensibility/extensions/loader.ts +16 -0
  27. package/src/extensibility/extensions/runner.ts +2 -7
  28. package/src/extensibility/extensions/types.ts +8 -4
  29. package/src/internal-urls/docs-index.generated.ts +4 -4
  30. package/src/internal-urls/jobs-protocol.ts +2 -1
  31. package/src/ipy/executor.ts +447 -52
  32. package/src/ipy/kernel.ts +39 -13
  33. package/src/lsp/client.ts +55 -1
  34. package/src/lsp/index.ts +8 -0
  35. package/src/lsp/types.ts +6 -0
  36. package/src/main.ts +6 -2
  37. package/src/memories/index.ts +7 -6
  38. package/src/modes/acp/acp-agent.ts +4 -1
  39. package/src/modes/components/bash-execution.ts +16 -4
  40. package/src/modes/components/model-selector.ts +221 -64
  41. package/src/modes/components/status-line/presets.ts +17 -6
  42. package/src/modes/components/status-line/segments.ts +15 -0
  43. package/src/modes/components/status-line-segment-editor.ts +1 -0
  44. package/src/modes/components/status-line.ts +7 -1
  45. package/src/modes/components/tool-execution.ts +145 -75
  46. package/src/modes/controllers/command-controller.ts +42 -1
  47. package/src/modes/controllers/event-controller.ts +4 -1
  48. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  49. package/src/modes/controllers/input-controller.ts +9 -3
  50. package/src/modes/controllers/selector-controller.ts +17 -6
  51. package/src/modes/interactive-mode.ts +19 -3
  52. package/src/modes/print-mode.ts +13 -4
  53. package/src/modes/prompt-action-autocomplete.ts +3 -5
  54. package/src/modes/rpc/rpc-mode.ts +8 -2
  55. package/src/modes/shared.ts +2 -2
  56. package/src/modes/types.ts +1 -0
  57. package/src/modes/utils/ui-helpers.ts +1 -0
  58. package/src/prompts/system/system-prompt.md +5 -1
  59. package/src/prompts/tools/bash.md +16 -1
  60. package/src/prompts/tools/cancel-job.md +1 -1
  61. package/src/prompts/tools/chunk-edit.md +191 -163
  62. package/src/prompts/tools/hashline.md +11 -11
  63. package/src/prompts/tools/patch.md +10 -5
  64. package/src/prompts/tools/{await.md → poll.md} +1 -1
  65. package/src/prompts/tools/read-chunk.md +12 -3
  66. package/src/prompts/tools/read.md +9 -0
  67. package/src/prompts/tools/task.md +2 -2
  68. package/src/prompts/tools/vim.md +98 -0
  69. package/src/prompts/tools/write.md +1 -0
  70. package/src/sdk.ts +758 -725
  71. package/src/session/agent-session.ts +187 -40
  72. package/src/session/session-manager.ts +50 -4
  73. package/src/slash-commands/builtin-registry.ts +17 -0
  74. package/src/task/executor.ts +9 -5
  75. package/src/task/index.ts +3 -5
  76. package/src/task/types.ts +2 -2
  77. package/src/tools/bash.ts +240 -57
  78. package/src/tools/cancel-job.ts +2 -1
  79. package/src/tools/find.ts +5 -2
  80. package/src/tools/grep.ts +77 -8
  81. package/src/tools/index.ts +48 -19
  82. package/src/tools/inspect-image.ts +1 -1
  83. package/src/tools/{await-tool.ts → poll-tool.ts} +38 -31
  84. package/src/tools/python.ts +293 -278
  85. package/src/tools/read.ts +218 -1
  86. package/src/tools/sqlite-reader.ts +623 -0
  87. package/src/tools/submit-result.ts +5 -2
  88. package/src/tools/todo-write.ts +8 -2
  89. package/src/tools/vim.ts +966 -0
  90. package/src/tools/write.ts +187 -1
  91. package/src/utils/commit-message-generator.ts +1 -0
  92. package/src/utils/edit-mode.ts +2 -1
  93. package/src/utils/git.ts +24 -1
  94. package/src/utils/session-color.ts +55 -0
  95. package/src/utils/title-generator.ts +16 -7
  96. package/src/vim/buffer.ts +309 -0
  97. package/src/vim/commands.ts +382 -0
  98. package/src/vim/engine.ts +2426 -0
  99. package/src/vim/parser.ts +151 -0
  100. package/src/vim/render.ts +252 -0
  101. package/src/vim/types.ts +197 -0
@@ -0,0 +1,309 @@
1
+ import { clonePosition, type Position, type VimBufferSnapshot, type VimFingerprint, type VimLoadedFile } from "./types";
2
+
3
+ function splitText(text: string): string[] {
4
+ if (text.length === 0) {
5
+ return [""];
6
+ }
7
+ return text.split("\n");
8
+ }
9
+
10
+ export function snapshotEqual(left: VimBufferSnapshot, right: VimBufferSnapshot): boolean {
11
+ if (
12
+ left.displayPath !== right.displayPath ||
13
+ left.filePath !== right.filePath ||
14
+ left.modified !== right.modified ||
15
+ left.trailingNewline !== right.trailingNewline ||
16
+ left.cursor.line !== right.cursor.line ||
17
+ left.cursor.col !== right.cursor.col ||
18
+ left.editabilityChecked !== right.editabilityChecked
19
+ ) {
20
+ return false;
21
+ }
22
+
23
+ if (left.baseFingerprint === null || right.baseFingerprint === null) {
24
+ if (left.baseFingerprint !== right.baseFingerprint) {
25
+ return false;
26
+ }
27
+ } else if (
28
+ left.baseFingerprint.exists !== right.baseFingerprint.exists ||
29
+ left.baseFingerprint.size !== right.baseFingerprint.size ||
30
+ left.baseFingerprint.mtimeMs !== right.baseFingerprint.mtimeMs ||
31
+ left.baseFingerprint.hash !== right.baseFingerprint.hash
32
+ ) {
33
+ return false;
34
+ }
35
+
36
+ if (left.lines.length !== right.lines.length) {
37
+ return false;
38
+ }
39
+
40
+ for (let index = 0; index < left.lines.length; index += 1) {
41
+ if (left.lines[index] !== right.lines[index]) {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ return true;
47
+ }
48
+
49
+ export class VimBuffer {
50
+ displayPath: string;
51
+ filePath: string;
52
+ lines: string[];
53
+ cursor: Position;
54
+ modified: boolean;
55
+ trailingNewline: boolean;
56
+ baseFingerprint: VimFingerprint | null;
57
+ editabilityChecked: boolean;
58
+
59
+ constructor(input: VimLoadedFile) {
60
+ this.displayPath = input.displayPath;
61
+ this.filePath = input.absolutePath;
62
+ this.lines = input.lines.length > 0 ? [...input.lines] : [""];
63
+ this.cursor = { line: 0, col: 0 };
64
+ this.modified = false;
65
+ this.trailingNewline = input.trailingNewline;
66
+ this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
67
+ this.editabilityChecked = false;
68
+ }
69
+
70
+ clone(): VimBuffer {
71
+ const clone = new VimBuffer({
72
+ absolutePath: this.filePath,
73
+ displayPath: this.displayPath,
74
+ lines: [...this.lines],
75
+ trailingNewline: this.trailingNewline,
76
+ fingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
77
+ });
78
+ clone.cursor = clonePosition(this.cursor);
79
+ clone.modified = this.modified;
80
+ clone.editabilityChecked = this.editabilityChecked;
81
+ return clone;
82
+ }
83
+
84
+ createSnapshot(): VimBufferSnapshot {
85
+ return {
86
+ displayPath: this.displayPath,
87
+ filePath: this.filePath,
88
+ lines: [...this.lines],
89
+ cursor: clonePosition(this.cursor),
90
+ modified: this.modified,
91
+ trailingNewline: this.trailingNewline,
92
+ baseFingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
93
+ editabilityChecked: this.editabilityChecked,
94
+ };
95
+ }
96
+
97
+ restore(snapshot: VimBufferSnapshot): void {
98
+ this.displayPath = snapshot.displayPath;
99
+ this.filePath = snapshot.filePath;
100
+ this.lines = snapshot.lines.length > 0 ? [...snapshot.lines] : [""];
101
+ this.cursor = clonePosition(snapshot.cursor);
102
+ this.modified = snapshot.modified;
103
+ this.trailingNewline = snapshot.trailingNewline;
104
+ this.baseFingerprint = snapshot.baseFingerprint ? { ...snapshot.baseFingerprint } : null;
105
+ this.editabilityChecked = snapshot.editabilityChecked;
106
+ this.clampCursor();
107
+ }
108
+
109
+ replaceLoadedFile(input: VimLoadedFile): void {
110
+ this.displayPath = input.displayPath;
111
+ this.filePath = input.absolutePath;
112
+ this.lines = input.lines.length > 0 ? [...input.lines] : [""];
113
+ this.cursor = { line: 0, col: 0 };
114
+ this.modified = false;
115
+ this.trailingNewline = input.trailingNewline;
116
+ this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
117
+ this.editabilityChecked = false;
118
+ }
119
+
120
+ markSaved(input: VimLoadedFile): void {
121
+ this.lines = input.lines.length > 0 ? [...input.lines] : [""];
122
+ this.modified = false;
123
+ this.trailingNewline = input.trailingNewline;
124
+ this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
125
+ this.clampCursor();
126
+ }
127
+
128
+ lineCount(): number {
129
+ return this.lines.length;
130
+ }
131
+
132
+ lastLineIndex(): number {
133
+ return Math.max(0, this.lines.length - 1);
134
+ }
135
+
136
+ getLine(line: number): string {
137
+ return this.lines[this.clampLine(line)] ?? "";
138
+ }
139
+
140
+ clampLine(line: number): number {
141
+ return Math.min(Math.max(line, 0), this.lastLineIndex());
142
+ }
143
+
144
+ clampCol(line: number, col: number): number {
145
+ return Math.min(Math.max(col, 0), this.getLine(line).length);
146
+ }
147
+
148
+ setCursor(position: Position): void {
149
+ this.cursor = {
150
+ line: this.clampLine(position.line),
151
+ col: this.clampCol(position.line, position.col),
152
+ };
153
+ }
154
+
155
+ clampCursor(): void {
156
+ this.setCursor(this.cursor);
157
+ }
158
+
159
+ firstNonBlank(line: number): number {
160
+ const content = this.getLine(line);
161
+ const index = content.search(/\S/);
162
+ return index === -1 ? 0 : index;
163
+ }
164
+
165
+ getText(): string {
166
+ return this.lines.join("\n");
167
+ }
168
+
169
+ setText(text: string, trailingNewline = this.trailingNewline): void {
170
+ const normalizedText = trailingNewline && text.endsWith("\n") ? text.slice(0, -1) : text;
171
+ this.lines = splitText(normalizedText);
172
+ this.trailingNewline = trailingNewline;
173
+ this.clampCursor();
174
+ }
175
+
176
+ currentOffset(): number {
177
+ return this.positionToOffset(this.cursor);
178
+ }
179
+
180
+ positionToOffset(position: Position): number {
181
+ const line = this.clampLine(position.line);
182
+ const col = this.clampCol(line, position.col);
183
+ let offset = 0;
184
+ for (let index = 0; index < line; index += 1) {
185
+ offset += this.lines[index]!.length + 1;
186
+ }
187
+ return offset + col;
188
+ }
189
+
190
+ offsetToPosition(offset: number): Position {
191
+ const text = this.getText();
192
+ const clamped = Math.min(Math.max(offset, 0), text.length);
193
+ let remaining = clamped;
194
+ for (let line = 0; line < this.lines.length; line += 1) {
195
+ const current = this.lines[line]!;
196
+ if (remaining <= current.length) {
197
+ return { line, col: remaining };
198
+ }
199
+ remaining -= current.length;
200
+ if (line < this.lines.length - 1) {
201
+ if (remaining === 0) {
202
+ return { line: line + 1, col: 0 };
203
+ }
204
+ remaining -= 1;
205
+ }
206
+ }
207
+ return { line: this.lastLineIndex(), col: this.getLine(this.lastLineIndex()).length };
208
+ }
209
+
210
+ setCursorFromOffset(offset: number): void {
211
+ this.cursor = this.offsetToPosition(offset);
212
+ }
213
+
214
+ replaceOffsets(start: number, end: number, replacement: string, cursorOffset = start + replacement.length): void {
215
+ const text = this.getText();
216
+ const normalizedStart = Math.min(Math.max(start, 0), text.length);
217
+ const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
218
+ const nextText = `${text.slice(0, normalizedStart)}${replacement}${text.slice(normalizedEnd)}`;
219
+ // getText() omits the trailing-newline marker, so any \n in the
220
+ // replacement is content (a line separator), not a file-trailing newline.
221
+ // Bypass setText() which would incorrectly strip it.
222
+ this.lines = splitText(nextText);
223
+ this.clampCursor();
224
+ this.setCursorFromOffset(cursorOffset);
225
+ }
226
+
227
+ deleteOffsets(start: number, end: number): string {
228
+ const text = this.getText();
229
+ const normalizedStart = Math.min(Math.max(start, 0), text.length);
230
+ const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
231
+ const removed = text.slice(normalizedStart, normalizedEnd);
232
+ this.replaceOffsets(normalizedStart, normalizedEnd, "", normalizedStart);
233
+ return removed;
234
+ }
235
+
236
+ deleteLines(startLine: number, endLine: number): string[] {
237
+ const start = this.clampLine(Math.min(startLine, endLine));
238
+ const end = this.clampLine(Math.max(startLine, endLine));
239
+ const removed = this.lines.slice(start, end + 1);
240
+ this.lines.splice(start, end - start + 1);
241
+ if (this.lines.length === 0) {
242
+ this.lines = [""];
243
+ }
244
+ this.setCursor({ line: Math.min(start, this.lastLineIndex()), col: 0 });
245
+ if (this.lines.length > 1 || removed.length > 1) {
246
+ this.trailingNewline = true;
247
+ }
248
+ return removed;
249
+ }
250
+
251
+ insertLines(index: number, newLines: string[]): void {
252
+ const at = Math.min(Math.max(index, 0), this.lines.length);
253
+ const normalized = newLines.length > 0 ? newLines : [""];
254
+ this.lines.splice(at, 0, ...normalized);
255
+ this.setCursor({ line: at, col: 0 });
256
+ this.trailingNewline = true;
257
+ }
258
+
259
+ replaceLine(line: number, content: string): void {
260
+ const target = this.clampLine(line);
261
+ this.lines[target] = content;
262
+ this.setCursor(this.cursor);
263
+ }
264
+
265
+ joinLines(startLine: number, count: number): void {
266
+ const start = this.clampLine(startLine);
267
+ const end = this.clampLine(start + Math.max(count, 1));
268
+ if (start >= end) {
269
+ return;
270
+ }
271
+ const joined = this.lines
272
+ .slice(start, end + 1)
273
+ .map(line => line.trim())
274
+ .join(" ");
275
+ this.lines.splice(start, end - start + 1, joined);
276
+ this.setCursor({ line: start, col: Math.max(0, joined.length - 1) });
277
+ }
278
+
279
+ indentLines(startLine: number, endLine: number, indentUnit: string, direction: 1 | -1): void {
280
+ const start = this.clampLine(Math.min(startLine, endLine));
281
+ const end = this.clampLine(Math.max(startLine, endLine));
282
+ for (let line = start; line <= end; line += 1) {
283
+ const content = this.lines[line] ?? "";
284
+ if (direction > 0) {
285
+ this.lines[line] = `${indentUnit}${content}`;
286
+ continue;
287
+ }
288
+ if (content.startsWith(indentUnit)) {
289
+ this.lines[line] = content.slice(indentUnit.length);
290
+ continue;
291
+ }
292
+ const spaces = content.match(/^ +/)?.[0].length ?? 0;
293
+ this.lines[line] = content.slice(Math.min(spaces, indentUnit.length));
294
+ }
295
+ this.setCursor(this.cursor);
296
+ }
297
+
298
+ getCharacterAtOffset(offset: number): string {
299
+ const text = this.getText();
300
+ if (offset < 0 || offset >= text.length) {
301
+ return "";
302
+ }
303
+ return text[offset] ?? "";
304
+ }
305
+
306
+ getCharacter(position: Position): string {
307
+ return this.getCharacterAtOffset(this.positionToOffset(position));
308
+ }
309
+ }
@@ -0,0 +1,382 @@
1
+ import type { VimExCommand, VimLineRange } from "./types";
2
+ import { VimInputError } from "./types";
3
+
4
+ export interface VimExParseContext {
5
+ currentLine: number;
6
+ lastLine: number;
7
+ }
8
+
9
+ interface ParsedLineAddress {
10
+ line: number;
11
+ nextIndex: number;
12
+ }
13
+
14
+ function clampLine(line: number, context: VimExParseContext): number {
15
+ return Math.min(Math.max(line, 1), Math.max(1, context.lastLine));
16
+ }
17
+
18
+ function readDigits(raw: string, start: number): { digits: string; nextIndex: number } {
19
+ let index = start;
20
+ let digits = "";
21
+ while (index < raw.length) {
22
+ const char = raw[index] ?? "";
23
+ if (!/^\d$/.test(char)) {
24
+ break;
25
+ }
26
+ digits += char;
27
+ index += 1;
28
+ }
29
+ return { digits, nextIndex: index };
30
+ }
31
+
32
+ function parseLineAddress(
33
+ raw: string,
34
+ start: number,
35
+ context: VimExParseContext,
36
+ relativeBase = context.currentLine,
37
+ ): ParsedLineAddress | undefined {
38
+ let index = start;
39
+ let line: number | undefined;
40
+ const first = raw[index] ?? "";
41
+
42
+ if (/^\d$/.test(first)) {
43
+ const { digits, nextIndex } = readDigits(raw, index);
44
+ line = Number.parseInt(digits, 10);
45
+ index = nextIndex;
46
+ } else if (first === ".") {
47
+ line = context.currentLine;
48
+ index += 1;
49
+ } else if (first === "$") {
50
+ line = context.lastLine;
51
+ index += 1;
52
+ } else if (first === "+" || first === "-") {
53
+ line = relativeBase;
54
+ } else {
55
+ return undefined;
56
+ }
57
+
58
+ while (index < raw.length) {
59
+ const sign = raw[index];
60
+ if (sign !== "+" && sign !== "-") {
61
+ break;
62
+ }
63
+ index += 1;
64
+ const { digits, nextIndex } = readDigits(raw, index);
65
+ index = nextIndex;
66
+ const offset = digits.length > 0 ? Number.parseInt(digits, 10) : 1;
67
+ line += sign === "+" ? offset : -offset;
68
+ }
69
+
70
+ return { line: clampLine(line, context), nextIndex: index };
71
+ }
72
+
73
+ function parseLineRange(raw: string, context?: VimExParseContext): { range?: VimLineRange | "all"; rest: string } {
74
+ if (raw.startsWith("%")) {
75
+ return { range: "all", rest: raw.slice(1).trimStart() };
76
+ }
77
+
78
+ if (!context) {
79
+ const match = raw.match(/^(\d+)(?:\s*,\s*(\d+))?/);
80
+ if (!match) {
81
+ return { rest: raw };
82
+ }
83
+
84
+ const start = Number.parseInt(match[1] ?? "", 10);
85
+ const end = Number.parseInt(match[2] ?? match[1] ?? "", 10);
86
+ return {
87
+ range: { start, end },
88
+ rest: raw.slice(match[0].length).trimStart(),
89
+ };
90
+ }
91
+
92
+ const first = parseLineAddress(raw, 0, context);
93
+ if (!first) {
94
+ return { rest: raw };
95
+ }
96
+
97
+ let index = first.nextIndex;
98
+ while (raw[index] === " ") {
99
+ index += 1;
100
+ }
101
+
102
+ const separator = raw[index];
103
+ if (separator !== "," && separator !== ";") {
104
+ return {
105
+ range: { start: first.line, end: first.line },
106
+ rest: raw.slice(index).trimStart(),
107
+ };
108
+ }
109
+
110
+ index += 1;
111
+ while (raw[index] === " ") {
112
+ index += 1;
113
+ }
114
+
115
+ const second = parseLineAddress(raw, index, context, separator === ";" ? first.line : context.currentLine);
116
+ if (!second) {
117
+ throw new VimInputError(`Missing line address after ${separator}`);
118
+ }
119
+
120
+ return {
121
+ range: { start: first.line, end: second.line },
122
+ rest: raw.slice(second.nextIndex).trimStart(),
123
+ };
124
+ }
125
+
126
+ function parseDelimitedSegments(raw: string): { pattern: string; replacement: string; flags: string } {
127
+ if (raw.length === 0) {
128
+ throw new VimInputError("Missing substitute delimiter");
129
+ }
130
+
131
+ const delimiter = raw[0] ?? "/";
132
+ const segments: string[] = [];
133
+ let current = "";
134
+ let escaped = false;
135
+
136
+ for (let index = 1; index < raw.length; index += 1) {
137
+ const char = raw[index] ?? "";
138
+ if (escaped) {
139
+ current += char;
140
+ escaped = false;
141
+ continue;
142
+ }
143
+ if (char === "\\") {
144
+ escaped = true;
145
+ current += char;
146
+ continue;
147
+ }
148
+ if (char === delimiter && segments.length < 2) {
149
+ segments.push(current);
150
+ current = "";
151
+ continue;
152
+ }
153
+ current += char;
154
+ }
155
+
156
+ if (segments.length !== 2) {
157
+ throw new VimInputError("Substitute command must look like :s/pattern/replacement/flags");
158
+ }
159
+
160
+ return {
161
+ pattern: segments[0] ?? "",
162
+ replacement: segments[1] ?? "",
163
+ flags: current.trim(),
164
+ };
165
+ }
166
+
167
+ function parseDestination(raw: string, context?: VimExParseContext): number {
168
+ const trimmed = raw.trim();
169
+ if (trimmed.length === 0) {
170
+ throw new VimInputError("Missing destination");
171
+ }
172
+
173
+ if (/^\d+$/.test(trimmed)) {
174
+ return Number.parseInt(trimmed, 10);
175
+ }
176
+
177
+ if (context) {
178
+ const address = parseLineAddress(trimmed, 0, context);
179
+ if (address && trimmed.slice(address.nextIndex).trim().length === 0) {
180
+ return address.line;
181
+ }
182
+ }
183
+
184
+ const destination = Number.parseInt(trimmed, 10);
185
+ if (Number.isNaN(destination)) {
186
+ throw new VimInputError("Invalid destination");
187
+ }
188
+ return destination;
189
+ }
190
+
191
+ function matchGlobalCommand(rest: string): { pattern: string; command: string; invert: boolean } | undefined {
192
+ const globalMatch = rest.match(/^(g|v|g!|global|global!|vglobal)\s*([/|#])(.+?)\2(.*)$/);
193
+ if (!globalMatch) {
194
+ return undefined;
195
+ }
196
+ return {
197
+ invert: globalMatch[1] === "v" || globalMatch[1] === "vglobal" || globalMatch[1]?.endsWith("!") === true,
198
+ pattern: globalMatch[3] ?? "",
199
+ command: (globalMatch[4] ?? "d").trim() || "d",
200
+ };
201
+ }
202
+
203
+ function matchDestinationCommand(rest: string, prefixes: readonly string[]): string | undefined {
204
+ for (const prefix of prefixes) {
205
+ if (!rest.startsWith(prefix)) {
206
+ continue;
207
+ }
208
+ const suffix = rest.slice(prefix.length);
209
+ if (suffix.length === 0) {
210
+ return "";
211
+ }
212
+ if (/^\s/.test(suffix) || /^[\d.$+-]/.test(suffix)) {
213
+ return suffix.trim();
214
+ }
215
+ }
216
+ return undefined;
217
+ }
218
+
219
+ export function parseExCommand(input: string, context?: VimExParseContext): VimExCommand {
220
+ const trimmed = input.trim();
221
+ const normalized = trimmed.startsWith(":") ? trimmed.slice(1).trimStart() : trimmed;
222
+ if (normalized.length === 0) {
223
+ throw new VimInputError("Empty ex command");
224
+ }
225
+
226
+ if (/^\d+$/.test(normalized)) {
227
+ return {
228
+ kind: "goto-line",
229
+ line: Number.parseInt(normalized, 10),
230
+ };
231
+ }
232
+
233
+ if (normalized === "w" || normalized === "write") {
234
+ return { kind: "write", force: false };
235
+ }
236
+ if (normalized === "w!" || normalized === "write!") {
237
+ return { kind: "write", force: true };
238
+ }
239
+ if (normalized === "update" || normalized === "up") {
240
+ return { kind: "update", force: false };
241
+ }
242
+ if (normalized === "update!" || normalized === "up!") {
243
+ return { kind: "update", force: true };
244
+ }
245
+ if (normalized === "wq" || normalized === "x" || normalized === "xit" || normalized === "exit") {
246
+ return { kind: "write-quit", force: false };
247
+ }
248
+ if (normalized === "wq!" || normalized === "x!" || normalized === "xit!" || normalized === "exit!") {
249
+ return { kind: "write-quit", force: true };
250
+ }
251
+ if (normalized === "q" || normalized === "quit") {
252
+ return { kind: "quit", force: false };
253
+ }
254
+ if (normalized === "q!" || normalized === "quit!") {
255
+ return { kind: "quit", force: true };
256
+ }
257
+ if (normalized === "e" || normalized === "edit") {
258
+ return { kind: "edit", force: false };
259
+ }
260
+ if (normalized === "e!" || normalized === "edit!") {
261
+ return { kind: "edit", force: true };
262
+ }
263
+ if (normalized.startsWith("e ") || normalized.startsWith("edit ")) {
264
+ const path = normalized.startsWith("edit ") ? normalized.slice(5).trim() : normalized.slice(2).trim();
265
+ return { kind: "edit", force: false, path };
266
+ }
267
+ if (normalized.startsWith("e! ") || normalized.startsWith("edit! ")) {
268
+ const path = normalized.startsWith("edit! ") ? normalized.slice(6).trim() : normalized.slice(3).trim();
269
+ return { kind: "edit", force: true, path };
270
+ }
271
+
272
+ const global = matchGlobalCommand(normalized);
273
+ if (global) {
274
+ return { kind: "global", ...global };
275
+ }
276
+
277
+ const { range, rest } = parseLineRange(normalized, context);
278
+ if (range && rest.length === 0) {
279
+ if (range === "all") {
280
+ throw new VimInputError(":% requires a following command");
281
+ }
282
+ return {
283
+ kind: "goto-line",
284
+ line: range.start,
285
+ };
286
+ }
287
+
288
+ const rangedGlobal = matchGlobalCommand(rest);
289
+ if (rangedGlobal) {
290
+ return { kind: "global", range, ...rangedGlobal };
291
+ }
292
+
293
+ if (rest === "sort" || rest.startsWith("sort ") || rest.startsWith("sort!")) {
294
+ const flags = rest.slice(4).trim();
295
+ return { kind: "sort", range: range ?? undefined, flags };
296
+ }
297
+ if (rest === "j" || rest === "join" || rest === "j!" || rest === "join!") {
298
+ return { kind: "join", range: range ?? undefined, trimWhitespace: !rest.endsWith("!") };
299
+ }
300
+
301
+ if (rest.startsWith("substitute")) {
302
+ const segments = parseDelimitedSegments(rest.slice("substitute".length));
303
+ return {
304
+ kind: "substitute",
305
+ range,
306
+ pattern: segments.pattern,
307
+ replacement: segments.replacement,
308
+ flags: segments.flags,
309
+ };
310
+ }
311
+
312
+ if (/^s(?:\W|$)/.test(rest)) {
313
+ const segments = parseDelimitedSegments(rest.slice(1));
314
+ return {
315
+ kind: "substitute",
316
+ range,
317
+ pattern: segments.pattern,
318
+ replacement: segments.replacement,
319
+ flags: segments.flags,
320
+ };
321
+ }
322
+
323
+ if (
324
+ rest === "d" ||
325
+ rest === "del" ||
326
+ rest === "delete" ||
327
+ rest.startsWith("d ") ||
328
+ rest.startsWith("del ") ||
329
+ rest.startsWith("delete ")
330
+ ) {
331
+ return {
332
+ kind: "delete",
333
+ range,
334
+ };
335
+ }
336
+
337
+ if (
338
+ rest === "y" ||
339
+ rest === "ya" ||
340
+ rest === "yank" ||
341
+ rest.startsWith("y ") ||
342
+ rest.startsWith("ya ") ||
343
+ rest.startsWith("yank ")
344
+ ) {
345
+ return {
346
+ kind: "yank",
347
+ range,
348
+ };
349
+ }
350
+
351
+ if (rest === "pu" || rest === "put" || rest === "pu!" || rest === "put!") {
352
+ return {
353
+ kind: "put",
354
+ range,
355
+ before: rest.endsWith("!"),
356
+ };
357
+ }
358
+
359
+ const copyDestination = matchDestinationCommand(rest, ["copy", "co", "t"]);
360
+ if (copyDestination !== undefined) {
361
+ const destination = parseDestination(copyDestination, context);
362
+ return { kind: "copy", range, destination };
363
+ }
364
+
365
+ const moveDestination = matchDestinationCommand(rest, ["move", "mo", "m"]);
366
+ if (moveDestination !== undefined) {
367
+ const destination = parseDestination(moveDestination, context);
368
+ return { kind: "move", range, destination };
369
+ }
370
+
371
+ if (rest === "a" || rest === "append" || rest.startsWith("a ") || rest.startsWith("append ")) {
372
+ const text = rest.startsWith("append") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
373
+ return { kind: "append", range: range === "all" ? undefined : range, text };
374
+ }
375
+
376
+ if (rest === "i" || rest === "insert" || rest.startsWith("i ") || rest.startsWith("insert ")) {
377
+ const text = rest.startsWith("insert") ? rest.slice(6).trimStart() : rest.slice(1).trimStart();
378
+ return { kind: "insert-before", range: range === "all" ? undefined : range, text };
379
+ }
380
+
381
+ throw new VimInputError(`Unsupported ex command: ${input}.`);
382
+ }