@oh-my-pi/pi-coding-agent 14.1.0 → 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 (82) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +8 -8
  3. package/src/async/job-manager.ts +43 -10
  4. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  5. package/src/config/mcp-schema.json +1 -1
  6. package/src/config/model-equivalence.ts +1 -0
  7. package/src/config/model-registry.ts +63 -34
  8. package/src/config/model-resolver.ts +111 -15
  9. package/src/config/settings-schema.ts +4 -3
  10. package/src/config/settings.ts +1 -1
  11. package/src/cursor.ts +64 -23
  12. package/src/edit/index.ts +254 -89
  13. package/src/edit/modes/chunk.ts +336 -57
  14. package/src/edit/modes/hashline.ts +51 -26
  15. package/src/edit/modes/patch.ts +16 -10
  16. package/src/edit/modes/replace.ts +15 -7
  17. package/src/edit/renderer.ts +248 -94
  18. package/src/export/html/template.generated.ts +1 -1
  19. package/src/export/html/template.js +6 -4
  20. package/src/extensibility/custom-tools/types.ts +0 -3
  21. package/src/extensibility/extensions/loader.ts +16 -0
  22. package/src/extensibility/extensions/runner.ts +2 -7
  23. package/src/extensibility/extensions/types.ts +8 -4
  24. package/src/internal-urls/docs-index.generated.ts +3 -3
  25. package/src/ipy/executor.ts +447 -52
  26. package/src/ipy/kernel.ts +39 -13
  27. package/src/lsp/client.ts +54 -0
  28. package/src/lsp/index.ts +8 -0
  29. package/src/lsp/types.ts +6 -0
  30. package/src/main.ts +0 -1
  31. package/src/modes/acp/acp-agent.ts +4 -1
  32. package/src/modes/components/bash-execution.ts +16 -4
  33. package/src/modes/components/status-line/presets.ts +17 -6
  34. package/src/modes/components/status-line/segments.ts +15 -0
  35. package/src/modes/components/status-line-segment-editor.ts +1 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +145 -75
  38. package/src/modes/controllers/command-controller.ts +24 -1
  39. package/src/modes/controllers/event-controller.ts +4 -1
  40. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  41. package/src/modes/controllers/input-controller.ts +9 -3
  42. package/src/modes/controllers/selector-controller.ts +4 -1
  43. package/src/modes/interactive-mode.ts +19 -3
  44. package/src/modes/print-mode.ts +13 -4
  45. package/src/modes/prompt-action-autocomplete.ts +3 -5
  46. package/src/modes/rpc/rpc-mode.ts +8 -2
  47. package/src/modes/shared.ts +2 -2
  48. package/src/modes/types.ts +1 -0
  49. package/src/modes/utils/ui-helpers.ts +1 -0
  50. package/src/prompts/tools/bash.md +2 -2
  51. package/src/prompts/tools/chunk-edit.md +191 -163
  52. package/src/prompts/tools/hashline.md +11 -11
  53. package/src/prompts/tools/patch.md +10 -5
  54. package/src/prompts/tools/{await.md → poll.md} +1 -1
  55. package/src/prompts/tools/read-chunk.md +3 -3
  56. package/src/prompts/tools/task.md +2 -2
  57. package/src/prompts/tools/vim.md +98 -0
  58. package/src/sdk.ts +754 -724
  59. package/src/session/agent-session.ts +164 -34
  60. package/src/session/session-manager.ts +50 -4
  61. package/src/slash-commands/builtin-registry.ts +17 -0
  62. package/src/task/executor.ts +4 -4
  63. package/src/task/index.ts +3 -5
  64. package/src/task/types.ts +2 -2
  65. package/src/tools/bash.ts +26 -8
  66. package/src/tools/find.ts +5 -2
  67. package/src/tools/grep.ts +77 -8
  68. package/src/tools/index.ts +48 -19
  69. package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
  70. package/src/tools/python.ts +293 -278
  71. package/src/tools/submit-result.ts +5 -2
  72. package/src/tools/todo-write.ts +8 -2
  73. package/src/tools/vim.ts +966 -0
  74. package/src/utils/edit-mode.ts +2 -1
  75. package/src/utils/session-color.ts +55 -0
  76. package/src/utils/title-generator.ts +15 -6
  77. package/src/vim/buffer.ts +309 -0
  78. package/src/vim/commands.ts +382 -0
  79. package/src/vim/engine.ts +2426 -0
  80. package/src/vim/parser.ts +151 -0
  81. package/src/vim/render.ts +252 -0
  82. package/src/vim/types.ts +197 -0
@@ -1,6 +1,6 @@
1
1
  import { $env, $flag } from "@oh-my-pi/pi-utils";
2
2
 
3
- export type EditMode = "replace" | "patch" | "hashline" | "chunk";
3
+ export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim";
4
4
 
5
5
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
6
6
 
@@ -9,6 +9,7 @@ const EDIT_MODE_IDS = {
9
9
  hashline: "hashline",
10
10
  patch: "patch",
11
11
  replace: "replace",
12
+ vim: "vim",
12
13
  } as const satisfies Record<string, EditMode>;
13
14
 
14
15
  export const EDIT_MODES = Object.keys(EDIT_MODE_IDS) as EditMode[];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Derive a stable hue (0-359) from a string using djb2 hash.
3
+ */
4
+ function nameToHue(name: string): number {
5
+ let hash = 5381;
6
+ for (let i = 0; i < name.length; i++) {
7
+ hash = ((hash << 5) + hash) ^ name.charCodeAt(i);
8
+ hash = hash >>> 0; // keep 32-bit unsigned
9
+ }
10
+ return hash % 360;
11
+ }
12
+
13
+ /**
14
+ * Convert HSL (h: 0-360, s: 0-1, l: 0-1) to a CSS hex string.
15
+ */
16
+ function hslToHex(h: number, s: number, l: number): string {
17
+ const a = s * Math.min(l, 1 - l);
18
+ const f = (n: number) => {
19
+ const k = (n + h / 30) % 12;
20
+ const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
21
+ return Math.round(255 * color)
22
+ .toString(16)
23
+ .padStart(2, "0");
24
+ };
25
+ return `#${f(0)}${f(8)}${f(4)}`;
26
+ }
27
+
28
+ /**
29
+ * Derive a stable CSS hex accent color from a session name.
30
+ * High saturation, vivid — suitable for both status bar text and border coloring.
31
+ */
32
+ export function getSessionAccentHex(name: string): string {
33
+ return hslToHex(nameToHue(name), 0.9, 0.72);
34
+ }
35
+
36
+ /**
37
+ * Auto-generated titles should not drive the session accent.
38
+ * Legacy sessions with unknown title source keep the old behavior.
39
+ */
40
+ export function getSessionAccentHexForTitle(
41
+ name: string | undefined,
42
+ titleSource: "auto" | "user" | undefined,
43
+ ): string | undefined {
44
+ if (!name || titleSource === "auto") return undefined;
45
+ return getSessionAccentHex(name);
46
+ }
47
+
48
+ /**
49
+ * Convert a hex accent color to an ANSI-16m foreground escape sequence.
50
+ * Returns `undefined` if `hex` is nullish or Bun.color conversion fails.
51
+ */
52
+ export function getSessionAccentAnsi(hex: string | undefined): string | undefined {
53
+ if (!hex) return undefined;
54
+ return Bun.color(hex, "ansi-16m") ?? undefined;
55
+ }
@@ -153,20 +153,29 @@ function getFallbackTerminalTitle(cwd: string | undefined): string | undefined {
153
153
  return sanitizeTerminalTitlePart(baseName);
154
154
  }
155
155
 
156
- export function formatSessionTerminalTitle(sessionName: string | undefined, cwd?: string): string {
157
- const label = sanitizeTerminalTitlePart(sessionName) ?? getFallbackTerminalTitle(cwd);
156
+ export function formatSessionTerminalTitle(
157
+ sessionName: string | undefined,
158
+ cwd?: string,
159
+ titleSource?: "auto" | "user" | undefined,
160
+ ): string {
161
+ const label =
162
+ sanitizeTerminalTitlePart(titleSource === "auto" ? undefined : sessionName) ?? getFallbackTerminalTitle(cwd);
158
163
  return label ? `${DEFAULT_TERMINAL_TITLE}: ${label}` : DEFAULT_TERMINAL_TITLE;
159
164
  }
160
165
 
161
166
  /**
162
- * Set the terminal title using OSC 2. Unsupported terminals ignore it.
167
+ * Set the terminal title using OSC 0 (sets both tab and window title). Unsupported terminals ignore it.
163
168
  */
164
169
  export function setTerminalTitle(title: string): void {
165
- process.stdout.write(`\x1b]2;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
170
+ process.stdout.write(`\x1b]0;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
166
171
  }
167
172
 
168
- export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
169
- setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
173
+ export function setSessionTerminalTitle(
174
+ sessionName: string | undefined,
175
+ cwd?: string,
176
+ titleSource?: "auto" | "user" | undefined,
177
+ ): void {
178
+ setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd, titleSource));
170
179
  }
171
180
 
172
181
  /**
@@ -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
+ }