@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.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.
Files changed (235) hide show
  1. package/CHANGELOG.md +316 -1
  2. package/package.json +86 -24
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +116 -30
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +123 -178
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -8
  28. package/src/commit/agentic/index.ts +22 -26
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  31. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  32. package/src/commit/agentic/tools/git-overview.ts +6 -9
  33. package/src/commit/agentic/tools/index.ts +6 -8
  34. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  35. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  36. package/src/commit/agentic/tools/split-commit.ts +4 -4
  37. package/src/commit/agentic/validation.ts +1 -1
  38. package/src/commit/analysis/conventional.ts +4 -4
  39. package/src/commit/analysis/summary.ts +3 -3
  40. package/src/commit/changelog/generate.ts +4 -4
  41. package/src/commit/changelog/index.ts +5 -9
  42. package/src/commit/map-reduce/map-phase.ts +4 -4
  43. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  44. package/src/commit/pipeline.ts +13 -16
  45. package/src/config/keybindings.ts +7 -6
  46. package/src/config/prompt-templates.ts +44 -226
  47. package/src/config/resolve-config-value.ts +4 -2
  48. package/src/config/settings-schema.ts +98 -2
  49. package/src/config/settings.ts +25 -26
  50. package/src/dap/client.ts +674 -0
  51. package/src/dap/config.ts +150 -0
  52. package/src/dap/defaults.json +211 -0
  53. package/src/dap/index.ts +4 -0
  54. package/src/dap/session.ts +1255 -0
  55. package/src/dap/types.ts +600 -0
  56. package/src/debug/log-viewer.ts +3 -2
  57. package/src/discovery/builtin.ts +1 -2
  58. package/src/discovery/codex.ts +2 -2
  59. package/src/discovery/github.ts +2 -1
  60. package/src/discovery/helpers.ts +2 -2
  61. package/src/discovery/opencode.ts +2 -2
  62. package/src/edit/diff.ts +818 -0
  63. package/src/edit/index.ts +309 -0
  64. package/src/edit/line-hash.ts +67 -0
  65. package/src/edit/modes/chunk.ts +454 -0
  66. package/src/{patch → edit/modes}/hashline.ts +741 -361
  67. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  68. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  69. package/src/{patch → edit}/normalize.ts +97 -76
  70. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  71. package/src/exec/bash-executor.ts +4 -2
  72. package/src/exec/idle-timeout-watchdog.ts +126 -0
  73. package/src/exec/non-interactive-env.ts +5 -0
  74. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
  75. package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
  76. package/src/extensibility/custom-commands/loader.ts +1 -2
  77. package/src/extensibility/custom-tools/loader.ts +34 -11
  78. package/src/extensibility/custom-tools/types.ts +1 -1
  79. package/src/extensibility/extensions/loader.ts +9 -4
  80. package/src/extensibility/extensions/runner.ts +24 -1
  81. package/src/extensibility/extensions/types.ts +4 -2
  82. package/src/extensibility/hooks/loader.ts +5 -6
  83. package/src/extensibility/hooks/types.ts +2 -2
  84. package/src/extensibility/plugins/doctor.ts +2 -1
  85. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  86. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  87. package/src/extensibility/slash-commands.ts +3 -7
  88. package/src/index.ts +3 -1
  89. package/src/internal-urls/docs-index.generated.ts +11 -11
  90. package/src/ipy/executor.ts +58 -17
  91. package/src/ipy/gateway-coordinator.ts +6 -4
  92. package/src/ipy/kernel.ts +45 -22
  93. package/src/ipy/runtime.ts +2 -2
  94. package/src/lsp/client.ts +7 -4
  95. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  96. package/src/lsp/config.ts +2 -2
  97. package/src/lsp/defaults.json +688 -154
  98. package/src/lsp/index.ts +234 -45
  99. package/src/lsp/lspmux.ts +2 -2
  100. package/src/lsp/startup-events.ts +13 -0
  101. package/src/lsp/types.ts +12 -1
  102. package/src/lsp/utils.ts +8 -1
  103. package/src/main.ts +125 -47
  104. package/src/memories/index.ts +4 -5
  105. package/src/modes/acp/acp-agent.ts +563 -163
  106. package/src/modes/acp/acp-event-mapper.ts +9 -1
  107. package/src/modes/acp/acp-mode.ts +4 -2
  108. package/src/modes/components/agent-dashboard.ts +3 -4
  109. package/src/modes/components/diff.ts +6 -7
  110. package/src/modes/components/footer.ts +9 -29
  111. package/src/modes/components/hook-editor.ts +3 -3
  112. package/src/modes/components/hook-selector.ts +6 -1
  113. package/src/modes/components/read-tool-group.ts +6 -12
  114. package/src/modes/components/session-observer-overlay.ts +472 -0
  115. package/src/modes/components/settings-defs.ts +24 -0
  116. package/src/modes/components/status-line.ts +15 -61
  117. package/src/modes/components/tool-execution.ts +1 -1
  118. package/src/modes/components/welcome.ts +1 -1
  119. package/src/modes/controllers/btw-controller.ts +2 -2
  120. package/src/modes/controllers/command-controller.ts +4 -2
  121. package/src/modes/controllers/event-controller.ts +59 -2
  122. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  123. package/src/modes/controllers/input-controller.ts +15 -8
  124. package/src/modes/controllers/selector-controller.ts +26 -0
  125. package/src/modes/index.ts +20 -2
  126. package/src/modes/interactive-mode.ts +278 -69
  127. package/src/modes/rpc/host-tools.ts +186 -0
  128. package/src/modes/rpc/rpc-client.ts +178 -13
  129. package/src/modes/rpc/rpc-mode.ts +73 -3
  130. package/src/modes/rpc/rpc-types.ts +53 -1
  131. package/src/modes/session-observer-registry.ts +146 -0
  132. package/src/modes/shared.ts +0 -42
  133. package/src/modes/theme/theme.ts +80 -8
  134. package/src/modes/types.ts +4 -2
  135. package/src/modes/utils/keybinding-matchers.ts +9 -0
  136. package/src/prompts/system/custom-system-prompt.md +5 -0
  137. package/src/prompts/system/system-prompt.md +8 -1
  138. package/src/prompts/tools/chunk-edit.md +219 -0
  139. package/src/prompts/tools/debug.md +43 -0
  140. package/src/prompts/tools/grep.md +3 -0
  141. package/src/prompts/tools/lsp.md +5 -5
  142. package/src/prompts/tools/read-chunk.md +17 -0
  143. package/src/prompts/tools/read.md +19 -5
  144. package/src/sdk.ts +216 -165
  145. package/src/secrets/index.ts +1 -1
  146. package/src/secrets/obfuscator.ts +25 -17
  147. package/src/session/agent-session.ts +381 -286
  148. package/src/session/agent-storage.ts +12 -12
  149. package/src/session/compaction/branch-summarization.ts +3 -3
  150. package/src/session/compaction/compaction.ts +5 -6
  151. package/src/session/compaction/utils.ts +3 -3
  152. package/src/session/history-storage.ts +62 -19
  153. package/src/session/messages.ts +3 -3
  154. package/src/session/session-dump-format.ts +203 -0
  155. package/src/session/session-manager.ts +15 -5
  156. package/src/session/session-storage.ts +4 -2
  157. package/src/session/streaming-output.ts +1 -1
  158. package/src/session/tool-choice-queue.ts +213 -0
  159. package/src/slash-commands/builtin-registry.ts +56 -8
  160. package/src/ssh/connection-manager.ts +2 -2
  161. package/src/ssh/sshfs-mount.ts +5 -5
  162. package/src/stt/downloader.ts +4 -4
  163. package/src/stt/recorder.ts +4 -4
  164. package/src/stt/transcriber.ts +2 -2
  165. package/src/system-prompt.ts +25 -13
  166. package/src/task/agents.ts +5 -6
  167. package/src/task/commands.ts +2 -5
  168. package/src/task/executor.ts +32 -4
  169. package/src/task/index.ts +91 -82
  170. package/src/task/template.ts +2 -2
  171. package/src/task/types.ts +25 -0
  172. package/src/task/worktree.ts +131 -149
  173. package/src/tools/ask.ts +2 -3
  174. package/src/tools/ast-edit.ts +7 -7
  175. package/src/tools/ast-grep.ts +7 -7
  176. package/src/tools/auto-generated-guard.ts +36 -41
  177. package/src/tools/await-tool.ts +2 -2
  178. package/src/tools/bash.ts +5 -23
  179. package/src/tools/browser.ts +4 -5
  180. package/src/tools/calculator.ts +2 -3
  181. package/src/tools/cancel-job.ts +2 -2
  182. package/src/tools/checkpoint.ts +3 -3
  183. package/src/tools/debug.ts +1007 -0
  184. package/src/tools/exit-plan-mode.ts +3 -3
  185. package/src/tools/fetch.ts +67 -3
  186. package/src/tools/find.ts +4 -5
  187. package/src/tools/fs-cache-invalidation.ts +5 -0
  188. package/src/tools/gemini-image.ts +13 -5
  189. package/src/tools/gh.ts +130 -308
  190. package/src/tools/grep.ts +57 -9
  191. package/src/tools/index.ts +44 -22
  192. package/src/tools/inspect-image.ts +4 -4
  193. package/src/tools/output-meta.ts +1 -1
  194. package/src/tools/python.ts +19 -6
  195. package/src/tools/read.ts +211 -146
  196. package/src/tools/render-mermaid.ts +2 -3
  197. package/src/tools/render-utils.ts +20 -6
  198. package/src/tools/renderers.ts +3 -1
  199. package/src/tools/report-tool-issue.ts +80 -0
  200. package/src/tools/resolve.ts +70 -39
  201. package/src/tools/search-tool-bm25.ts +2 -2
  202. package/src/tools/ssh.ts +2 -2
  203. package/src/tools/todo-write.ts +2 -2
  204. package/src/tools/tool-timeouts.ts +1 -0
  205. package/src/tools/write.ts +5 -6
  206. package/src/tui/tree-list.ts +3 -1
  207. package/src/utils/clipboard.ts +80 -0
  208. package/src/utils/commit-message-generator.ts +2 -3
  209. package/src/utils/edit-mode.ts +49 -0
  210. package/src/utils/external-editor.ts +11 -5
  211. package/src/utils/file-display-mode.ts +6 -5
  212. package/src/utils/file-mentions.ts +8 -7
  213. package/src/utils/git.ts +1400 -0
  214. package/src/utils/image-loading.ts +98 -0
  215. package/src/utils/title-generator.ts +2 -3
  216. package/src/utils/tools-manager.ts +6 -6
  217. package/src/web/scrapers/choosealicense.ts +1 -1
  218. package/src/web/search/index.ts +3 -3
  219. package/src/web/search/render.ts +6 -4
  220. package/src/autoresearch/command-initialize.md +0 -34
  221. package/src/commit/git/errors.ts +0 -9
  222. package/src/commit/git/index.ts +0 -210
  223. package/src/commit/git/operations.ts +0 -54
  224. package/src/patch/diff.ts +0 -433
  225. package/src/patch/index.ts +0 -888
  226. package/src/patch/parser.ts +0 -532
  227. package/src/patch/types.ts +0 -292
  228. package/src/prompts/agents/oracle.md +0 -77
  229. package/src/tools/gh-cli.ts +0 -125
  230. package/src/tools/pending-action.ts +0 -49
  231. package/src/utils/child-process.ts +0 -88
  232. package/src/utils/frontmatter.ts +0 -117
  233. package/src/utils/image-input.ts +0 -274
  234. package/src/utils/mime.ts +0 -53
  235. package/src/utils/prompt-format.ts +0 -170
@@ -70,12 +70,16 @@ export function getLeadingWhitespace(line: string): string {
70
70
  return line.slice(0, countLeadingWhitespace(line));
71
71
  }
72
72
 
73
+ function isNonEmptyLine(line: string): boolean {
74
+ return line.trim().length > 0;
75
+ }
76
+
73
77
  /** Compute minimum indentation of non-empty lines */
74
78
  export function minIndent(text: string): number {
75
79
  const lines = text.split("\n");
76
80
  let min = Infinity;
77
81
  for (const line of lines) {
78
- if (line.trim().length > 0) {
82
+ if (isNonEmptyLine(line)) {
79
83
  min = Math.min(min, countLeadingWhitespace(line));
80
84
  }
81
85
  }
@@ -107,9 +111,7 @@ function gcd(a: number, b: number): number {
107
111
 
108
112
  interface IndentProfile {
109
113
  lines: string[];
110
- indentStrings: string[];
111
114
  indentCounts: number[];
112
- min: number;
113
115
  char: " " | "\t" | undefined;
114
116
  spaceOnly: boolean;
115
117
  tabOnly: boolean;
@@ -120,9 +122,7 @@ interface IndentProfile {
120
122
 
121
123
  function buildIndentProfile(text: string): IndentProfile {
122
124
  const lines = text.split("\n");
123
- const indentStrings: string[] = [];
124
125
  const indentCounts: number[] = [];
125
- let min = Infinity;
126
126
  let char: " " | "\t" | undefined;
127
127
  let spaceOnly = true;
128
128
  let tabOnly = true;
@@ -131,12 +131,10 @@ function buildIndentProfile(text: string): IndentProfile {
131
131
  let unit = 0;
132
132
 
133
133
  for (const line of lines) {
134
- if (line.trim().length === 0) continue;
134
+ if (!isNonEmptyLine(line)) continue;
135
135
  nonEmptyCount++;
136
136
  const indent = getLeadingWhitespace(line);
137
- indentStrings.push(indent);
138
137
  indentCounts.push(indent.length);
139
- min = Math.min(min, indent.length);
140
138
  if (indent.includes(" ")) {
141
139
  tabOnly = false;
142
140
  }
@@ -156,10 +154,6 @@ function buildIndentProfile(text: string): IndentProfile {
156
154
  }
157
155
  }
158
156
 
159
- if (min === Infinity) {
160
- min = 0;
161
- }
162
-
163
157
  if (spaceOnly && nonEmptyCount > 0) {
164
158
  let current = 0;
165
159
  for (const count of indentCounts) {
@@ -175,9 +169,7 @@ function buildIndentProfile(text: string): IndentProfile {
175
169
 
176
170
  return {
177
171
  lines,
178
- indentStrings,
179
172
  indentCounts,
180
- min,
181
173
  char,
182
174
  spaceOnly,
183
175
  tabOnly,
@@ -246,6 +238,87 @@ export function normalizeForFuzzy(line: string): string {
246
238
  .replace(/[ \t]+/g, " ");
247
239
  }
248
240
 
241
+ function isIndentationOnlyRewrite(oldText: string, newText: string): boolean {
242
+ const oldLines = oldText.split("\n");
243
+ const newLines = newText.split("\n");
244
+ if (oldLines.length !== newLines.length) {
245
+ return false;
246
+ }
247
+ for (let i = 0; i < oldLines.length; i++) {
248
+ if (oldLines[i].trim() !== newLines[i].trim()) {
249
+ return false;
250
+ }
251
+ }
252
+ return true;
253
+ }
254
+
255
+ function maybeConvertTabIndentation(
256
+ oldProfile: IndentProfile,
257
+ actualProfile: IndentProfile,
258
+ newProfile: IndentProfile,
259
+ newText: string,
260
+ ): string | undefined {
261
+ if (!actualProfile.spaceOnly || !oldProfile.tabOnly || !newProfile.tabOnly || actualProfile.unit <= 0) {
262
+ return undefined;
263
+ }
264
+
265
+ const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
266
+ for (let i = 0; i < lineCount; i++) {
267
+ const oldLine = oldProfile.lines[i];
268
+ const actualLine = actualProfile.lines[i];
269
+ if (!isNonEmptyLine(oldLine) || !isNonEmptyLine(actualLine)) continue;
270
+ const oldIndent = getLeadingWhitespace(oldLine);
271
+ if (oldIndent.length === 0) continue;
272
+ const actualIndent = getLeadingWhitespace(actualLine);
273
+ if (actualIndent.length !== oldIndent.length * actualProfile.unit) {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ return convertLeadingTabsToSpaces(newText, actualProfile.unit);
279
+ }
280
+
281
+ function computeUniformIndentDelta(oldProfile: IndentProfile, actualProfile: IndentProfile): number | undefined {
282
+ const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
283
+ const deltas: number[] = [];
284
+ for (let i = 0; i < lineCount; i++) {
285
+ const oldLine = oldProfile.lines[i];
286
+ const actualLine = actualProfile.lines[i];
287
+ if (!isNonEmptyLine(oldLine) || !isNonEmptyLine(actualLine)) continue;
288
+ deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine));
289
+ }
290
+
291
+ if (deltas.length === 0) {
292
+ return undefined;
293
+ }
294
+
295
+ const delta = deltas[0];
296
+ return deltas.every(value => value === delta) ? delta : undefined;
297
+ }
298
+
299
+ function applyIndentDelta(text: string, delta: number, indentChar: string): string {
300
+ const adjusted = text.split("\n").map(line => {
301
+ if (!isNonEmptyLine(line)) {
302
+ return line;
303
+ }
304
+ if (delta > 0) {
305
+ return indentChar.repeat(delta) + line;
306
+ }
307
+ const toRemove = Math.min(-delta, countLeadingWhitespace(line));
308
+ return line.slice(toRemove);
309
+ });
310
+
311
+ return adjusted.join("\n");
312
+ }
313
+
314
+ function hasNonEmptyIndentProfiles(...profiles: IndentProfile[]): boolean {
315
+ return profiles.every(profile => profile.nonEmptyCount > 0);
316
+ }
317
+
318
+ function hasMixedIndentation(...profiles: IndentProfile[]): boolean {
319
+ return profiles.some(profile => profile.mixed);
320
+ }
321
+
249
322
  // ═══════════════════════════════════════════════════════════════════════════
250
323
  // Indentation Adjustment
251
324
  // ═══════════════════════════════════════════════════════════════════════════
@@ -264,73 +337,32 @@ export function adjustIndentation(oldText: string, actualText: string, newText:
264
337
  }
265
338
 
266
339
  // If the patch is purely an indentation change (same trimmed content), apply exactly as specified
267
- const oldLines = oldText.split("\n");
268
- const newLines = newText.split("\n");
269
- if (oldLines.length === newLines.length) {
270
- let indentationOnly = true;
271
- for (let i = 0; i < oldLines.length; i++) {
272
- if (oldLines[i].trim() !== newLines[i].trim()) {
273
- indentationOnly = false;
274
- break;
275
- }
276
- }
277
- if (indentationOnly) {
278
- return newText;
279
- }
340
+ if (isIndentationOnlyRewrite(oldText, newText)) {
341
+ return newText;
280
342
  }
281
343
 
282
344
  const oldProfile = buildIndentProfile(oldText);
283
345
  const actualProfile = buildIndentProfile(actualText);
284
346
  const newProfile = buildIndentProfile(newText);
285
347
 
286
- if (newProfile.nonEmptyCount === 0 || oldProfile.nonEmptyCount === 0 || actualProfile.nonEmptyCount === 0) {
348
+ if (!hasNonEmptyIndentProfiles(oldProfile, actualProfile, newProfile)) {
287
349
  return newText;
288
350
  }
289
351
 
290
- if (oldProfile.mixed || actualProfile.mixed || newProfile.mixed) {
352
+ if (hasMixedIndentation(oldProfile, actualProfile, newProfile)) {
291
353
  return newText;
292
354
  }
293
355
 
294
356
  if (oldProfile.char && actualProfile.char && oldProfile.char !== actualProfile.char) {
295
- if (actualProfile.spaceOnly && oldProfile.tabOnly && newProfile.tabOnly && actualProfile.unit > 0) {
296
- let consistent = true;
297
- const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
298
- for (let i = 0; i < lineCount; i++) {
299
- const oldLine = oldProfile.lines[i];
300
- const actualLine = actualProfile.lines[i];
301
- if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
302
- const oldIndent = getLeadingWhitespace(oldLine);
303
- const actualIndent = getLeadingWhitespace(actualLine);
304
- if (oldIndent.length === 0) continue;
305
- if (actualIndent.length !== oldIndent.length * actualProfile.unit) {
306
- consistent = false;
307
- break;
308
- }
309
- }
310
- return consistent ? convertLeadingTabsToSpaces(newText, actualProfile.unit) : newText;
357
+ const converted = maybeConvertTabIndentation(oldProfile, actualProfile, newProfile, newText);
358
+ if (converted !== undefined) {
359
+ return converted;
311
360
  }
312
361
  return newText;
313
362
  }
314
363
 
315
- const lineCount = Math.min(oldProfile.lines.length, actualProfile.lines.length);
316
- const deltas: number[] = [];
317
- for (let i = 0; i < lineCount; i++) {
318
- const oldLine = oldProfile.lines[i];
319
- const actualLine = actualProfile.lines[i];
320
- if (oldLine.trim().length === 0 || actualLine.trim().length === 0) continue;
321
- deltas.push(countLeadingWhitespace(actualLine) - countLeadingWhitespace(oldLine));
322
- }
323
-
324
- if (deltas.length === 0) {
325
- return newText;
326
- }
327
-
328
- const delta = deltas[0];
329
- if (!deltas.every(value => value === delta)) {
330
- return newText;
331
- }
332
-
333
- if (delta === 0) {
364
+ const delta = computeUniformIndentDelta(oldProfile, actualProfile);
365
+ if (delta === undefined || delta === 0) {
334
366
  return newText;
335
367
  }
336
368
 
@@ -339,16 +371,5 @@ export function adjustIndentation(oldText: string, actualText: string, newText:
339
371
  }
340
372
 
341
373
  const indentChar = actualProfile.char ?? oldProfile.char ?? detectIndentChar(actualText);
342
- const adjusted = newText.split("\n").map(line => {
343
- if (line.trim().length === 0) {
344
- return line;
345
- }
346
- if (delta > 0) {
347
- return indentChar.repeat(delta) + line;
348
- }
349
- const toRemove = Math.min(-delta, countLeadingWhitespace(line));
350
- return line.slice(toRemove);
351
- });
352
-
353
- return adjusted.join("\n");
374
+ return applyIndentDelta(newText, delta, indentChar);
354
375
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared utilities for edit tool TUI rendering.
2
+ * Edit tool renderer and LSP batching helpers.
3
3
  */
4
4
  import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -22,8 +22,10 @@ import {
22
22
  truncateDiffByHunk,
23
23
  } from "../tools/render-utils";
24
24
  import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
25
- import type { HashlineToolEdit } from "./index";
26
- import type { DiffError, DiffResult, Operation } from "./types";
25
+ import type { DiffError, DiffResult } from "./diff";
26
+ import type { ChunkToolEdit } from "./modes/chunk";
27
+ import type { HashlineToolEdit } from "./modes/hashline";
28
+ import type { Operation } from "./modes/patch";
27
29
 
28
30
  // ═══════════════════════════════════════════════════════════════════════════
29
31
  // LSP Batching
@@ -31,7 +33,12 @@ import type { DiffError, DiffResult, Operation } from "./types";
31
33
 
32
34
  const LSP_BATCH_TOOLS = new Set(["edit", "write"]);
33
35
 
34
- export function getLspBatchRequest(toolCall: ToolCallContext | undefined): { id: string; flush: boolean } | undefined {
36
+ export interface LspBatchRequest {
37
+ id: string;
38
+ flush: boolean;
39
+ }
40
+
41
+ export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBatchRequest | undefined {
35
42
  if (!toolCall) {
36
43
  return undefined;
37
44
  }
@@ -83,8 +90,8 @@ interface EditRenderArgs {
83
90
  * Computed preview diff (used when tool args don't include a diff, e.g. hashline mode).
84
91
  */
85
92
  previewDiff?: string;
86
- // Hashline mode fields
87
- edits?: Partial<HashlineToolEdit>[];
93
+ // Hashline / chunk mode fields
94
+ edits?: Partial<HashlineToolEdit | ChunkToolEdit>[];
88
95
  }
89
96
 
90
97
  /** Extended context for edit tool rendering */
@@ -96,12 +103,69 @@ export interface EditRenderContext {
96
103
  }
97
104
 
98
105
  const EDIT_STREAMING_PREVIEW_LINES = 12;
106
+ const CALL_TEXT_PREVIEW_LINES = 6;
107
+ const CALL_TEXT_PREVIEW_WIDTH = 80;
108
+ const STREAMING_EDIT_PREVIEW_WIDTH = 120;
109
+ const STREAMING_EDIT_PREVIEW_LIMIT = 4;
110
+ const STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT = 8;
111
+
112
+ interface FormattedStreamingEdit {
113
+ srcLabel: string;
114
+ dst: string;
115
+ }
99
116
 
100
117
  function countLines(text: string): number {
101
118
  if (!text) return 0;
102
119
  return text.split("\n").length;
103
120
  }
104
121
 
122
+ function getOperationTitle(op: Operation | undefined): string {
123
+ return op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
124
+ }
125
+
126
+ function formatEditPathDisplay(
127
+ rawPath: string,
128
+ uiTheme: Theme,
129
+ options?: { rename?: string; firstChangedLine?: number },
130
+ ): string {
131
+ let pathDisplay = rawPath ? uiTheme.fg("accent", shortenPath(rawPath)) : uiTheme.fg("toolOutput", "…");
132
+
133
+ if (options?.firstChangedLine) {
134
+ pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`);
135
+ }
136
+
137
+ if (options?.rename) {
138
+ pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(options.rename))}`;
139
+ }
140
+
141
+ return pathDisplay;
142
+ }
143
+
144
+ function formatEditDescription(
145
+ rawPath: string,
146
+ uiTheme: Theme,
147
+ options?: { rename?: string; firstChangedLine?: number },
148
+ ): { language: string; description: string } {
149
+ const language = getLanguageFromPath(rawPath) ?? "text";
150
+ const icon = uiTheme.fg("muted", uiTheme.getLangIcon(language));
151
+ return {
152
+ language,
153
+ description: `${icon} ${formatEditPathDisplay(rawPath, uiTheme, options)}`,
154
+ };
155
+ }
156
+
157
+ function renderPlainTextPreview(text: string, uiTheme: Theme): string {
158
+ const previewLines = text.split("\n");
159
+ let preview = "\n\n";
160
+ for (const line of previewLines.slice(0, CALL_TEXT_PREVIEW_LINES)) {
161
+ preview += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), CALL_TEXT_PREVIEW_WIDTH))}\n`;
162
+ }
163
+ if (previewLines.length > CALL_TEXT_PREVIEW_LINES) {
164
+ preview += uiTheme.fg("dim", `… ${previewLines.length - CALL_TEXT_PREVIEW_LINES} more lines`);
165
+ }
166
+ return preview.trimEnd();
167
+ }
168
+
105
169
  function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
106
170
  if (!diff) return "";
107
171
  const lines = diff.split("\n");
@@ -117,69 +181,111 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
117
181
  return text;
118
182
  }
119
183
 
120
- function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit>[], uiTheme: Theme): string {
121
- const MAX_EDITS = 4;
122
- const MAX_DST_LINES = 8;
184
+ function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
185
+ return "sel" in edit;
186
+ }
187
+
188
+ function getStreamingEditContent(content: unknown): string {
189
+ if (Array.isArray(content)) {
190
+ return content.join("\n");
191
+ }
192
+ return typeof content === "string" ? content : "";
193
+ }
194
+
195
+ function formatHashlineStreamingEdit(edit: Partial<HashlineToolEdit>): FormattedStreamingEdit {
196
+ if (typeof edit !== "object" || !edit) {
197
+ return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
198
+ }
199
+
200
+ const contentLines = getStreamingEditContent(edit.content);
201
+ const loc = edit.loc;
202
+
203
+ if (loc === "append" || loc === "prepend") {
204
+ return { srcLabel: `\u2022 ${loc} (file-level)`, dst: contentLines };
205
+ }
206
+ if (typeof loc === "object" && loc) {
207
+ if ("range" in loc && typeof loc.range === "object" && loc.range) {
208
+ return { srcLabel: `\u2022 range ${loc.range.pos ?? "?"}\u2026${loc.range.end ?? "?"}`, dst: contentLines };
209
+ }
210
+ if ("line" in loc) {
211
+ return { srcLabel: `\u2022 line ${(loc as { line: string }).line}`, dst: contentLines };
212
+ }
213
+ if ("append" in loc) {
214
+ return { srcLabel: `\u2022 append ${(loc as { append: string }).append}`, dst: contentLines };
215
+ }
216
+ if ("prepend" in loc) {
217
+ return { srcLabel: `\u2022 prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
218
+ }
219
+ }
220
+ return { srcLabel: "\u2022 (unknown edit)", dst: contentLines };
221
+ }
222
+
223
+ function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStreamingEdit {
224
+ if (typeof edit !== "object" || !edit) {
225
+ return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
226
+ }
227
+
228
+ const contentLines = getStreamingEditContent(edit.content);
229
+ const target = edit.sel ?? "?";
230
+ const op = edit.op ?? "replace";
231
+
232
+ switch (op) {
233
+ case "append":
234
+ return { srcLabel: `\u2022 append ${target}`, dst: contentLines };
235
+ case "prepend":
236
+ return { srcLabel: `\u2022 prepend ${target}`, dst: contentLines };
237
+ case "after":
238
+ return { srcLabel: `\u2022 insert after ${target}`, dst: contentLines };
239
+ case "before":
240
+ return { srcLabel: `\u2022 insert before ${target}`, dst: contentLines };
241
+ default:
242
+ return {
243
+ srcLabel: contentLines.length === 0 ? `\u2022 remove ${target}` : `\u2022 replace ${target}`,
244
+ dst: contentLines,
245
+ };
246
+ }
247
+ }
248
+
249
+ function formatStreamingHashlineEdits(edits: Partial<HashlineToolEdit | ChunkToolEdit>[], uiTheme: Theme): string {
123
250
  let text = "\n\n";
124
- text += uiTheme.fg("dim", `[${edits.length} hashline edit${edits.length === 1 ? "" : "s"}]`);
251
+
252
+ // Detect whether these are chunk edits (target field) or hashline edits (loc field)
253
+ const isChunk = edits.length > 0 && isChunkStreamingEdit(edits[0]);
254
+ const label = isChunk ? "chunk edit" : "hashline edit";
255
+ const formatEdit = isChunk ? formatChunkStreamingEdit : formatHashlineStreamingEdit;
256
+ text += uiTheme.fg("dim", `[${edits.length} ${label}${edits.length === 1 ? "" : "s"}]`);
125
257
  text += "\n";
126
258
  let shownEdits = 0;
127
259
  let shownDstLines = 0;
128
260
  for (const edit of edits) {
129
261
  shownEdits++;
130
- if (shownEdits > MAX_EDITS) break;
131
- const formatted = formatHashlineEdit(edit);
132
- text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), 120));
262
+ if (shownEdits > STREAMING_EDIT_PREVIEW_LIMIT) break;
263
+ const formatted = formatEdit(edit as never);
264
+ text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), STREAMING_EDIT_PREVIEW_WIDTH));
133
265
  text += "\n";
134
266
  if (formatted.dst === "") {
135
- text += uiTheme.fg("dim", truncateToWidth(" (delete)", 120));
267
+ text += uiTheme.fg("dim", truncateToWidth(" (delete)", STREAMING_EDIT_PREVIEW_WIDTH));
136
268
  text += "\n";
137
269
  continue;
138
270
  }
139
271
  for (const dstLine of formatted.dst.split("\n")) {
140
272
  shownDstLines++;
141
- if (shownDstLines > MAX_DST_LINES) break;
142
- text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), 120));
273
+ if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
274
+ text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), STREAMING_EDIT_PREVIEW_WIDTH));
143
275
  text += "\n";
144
276
  }
145
- if (shownDstLines > MAX_DST_LINES) break;
277
+ if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
146
278
  }
147
- if (edits.length > MAX_EDITS) {
148
- text += uiTheme.fg("dim", `… (${edits.length - MAX_EDITS} more edits)`);
279
+ if (edits.length > STREAMING_EDIT_PREVIEW_LIMIT) {
280
+ text += uiTheme.fg("dim", `\u2026 (${edits.length - STREAMING_EDIT_PREVIEW_LIMIT} more edits)`);
149
281
  }
150
- if (shownDstLines > MAX_DST_LINES) {
151
- text += uiTheme.fg("dim", `\n (${shownDstLines - MAX_DST_LINES} more dst lines)`);
282
+ if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) {
283
+ text += uiTheme.fg("dim", `\n\u2026 (${shownDstLines - STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT} more dst lines)`);
152
284
  }
153
285
 
154
286
  return text.trimEnd();
155
- function formatHashlineEdit(edit: Partial<HashlineToolEdit>): { srcLabel: string; dst: string } {
156
- if (typeof edit !== "object" || !edit) {
157
- return { srcLabel: "• (incomplete edit)", dst: "" };
158
- }
159
-
160
- const contentLines = Array.isArray(edit.content) ? (edit.content as string[]).join("\n") : "";
161
- const loc = edit.loc;
162
-
163
- if (loc === "append" || loc === "prepend") {
164
- return { srcLabel: `• ${loc} (file-level)`, dst: contentLines };
165
- }
166
- if (typeof loc === "object" && loc) {
167
- if ("range" in loc && typeof loc.range === "object" && loc.range) {
168
- return { srcLabel: `• range ${loc.range.pos ?? "?"}…${loc.range.end ?? "?"}`, dst: contentLines };
169
- }
170
- if ("line" in loc) {
171
- return { srcLabel: `• line ${(loc as { line: string }).line}`, dst: contentLines };
172
- }
173
- if ("append" in loc) {
174
- return { srcLabel: `• append ${(loc as { append: string }).append}`, dst: contentLines };
175
- }
176
- if ("prepend" in loc) {
177
- return { srcLabel: `• prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
178
- }
179
- }
180
- return { srcLabel: "• (unknown edit)", dst: contentLines };
181
- }
182
287
  }
288
+
183
289
  function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
184
290
  const icon = uiTheme.getLangIcon(language);
185
291
  if (lineCount !== null) {
@@ -188,6 +294,25 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin
188
294
  return uiTheme.fg("dim", `${icon}`);
189
295
  }
190
296
 
297
+ function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme): string {
298
+ if (args.previewDiff) {
299
+ return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
300
+ }
301
+ if (args.diff && args.op) {
302
+ return formatStreamingDiff(args.diff, rawPath, uiTheme);
303
+ }
304
+ if (args.edits && args.edits.length > 0) {
305
+ return formatStreamingHashlineEdits(args.edits, uiTheme);
306
+ }
307
+ if (args.diff) {
308
+ return renderPlainTextPreview(args.diff, uiTheme);
309
+ }
310
+ if (args.newText || args.patch) {
311
+ return renderPlainTextPreview(args.newText ?? args.patch ?? "", uiTheme);
312
+ }
313
+ return "";
314
+ }
315
+
191
316
  function renderDiffSection(
192
317
  diff: string,
193
318
  rawPath: string,
@@ -252,50 +377,11 @@ export const editToolRenderer = {
252
377
 
253
378
  renderCall(args: EditRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
254
379
  const rawPath = args.file_path || args.path || "";
255
- const filePath = shortenPath(rawPath);
256
- const editLanguage = getLanguageFromPath(rawPath) ?? "text";
257
- const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
258
- let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
259
-
260
- // Add arrow for move/rename operations
261
- if (args.rename) {
262
- pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(args.rename))}`;
263
- }
264
-
265
- // Show operation type for patch mode
266
- const opTitle = args.op === "create" ? "Create" : args.op === "delete" ? "Delete" : "Edit";
380
+ const { description } = formatEditDescription(rawPath, uiTheme, { rename: args.rename });
267
381
  const spinner =
268
382
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
269
- let text = `${formatTitle(opTitle, uiTheme)} ${spinner ? `${spinner} ` : ""}${editIcon} ${pathDisplay}`;
270
-
271
- // Show streaming preview of diff/content
272
- if (args.previewDiff) {
273
- text += formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
274
- } else if (args.diff && args.op) {
275
- text += formatStreamingDiff(args.diff, rawPath, uiTheme);
276
- } else if (args.edits && args.edits.length > 0) {
277
- text += formatStreamingHashlineEdits(args.edits, uiTheme);
278
- } else if (args.diff) {
279
- const previewLines = args.diff.split("\n");
280
- const maxLines = 6;
281
- text += "\n\n";
282
- for (const line of previewLines.slice(0, maxLines)) {
283
- text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`;
284
- }
285
- if (previewLines.length > maxLines) {
286
- text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`);
287
- }
288
- } else if (args.newText || args.patch) {
289
- const previewLines = (args.newText ?? args.patch ?? "").split("\n");
290
- const maxLines = 6;
291
- text += "\n\n";
292
- for (const line of previewLines.slice(0, maxLines)) {
293
- text += `${uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(line), 80))}\n`;
294
- }
295
- if (previewLines.length > maxLines) {
296
- text += uiTheme.fg("dim", `… ${previewLines.length - maxLines} more lines`);
297
- }
298
- }
383
+ let text = `${formatTitle(getOperationTitle(args.op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
384
+ text += getCallPreview(args, rawPath, uiTheme);
299
385
 
300
386
  return new Text(text, 0, 0);
301
387
  },
@@ -307,18 +393,14 @@ export const editToolRenderer = {
307
393
  args?: EditRenderArgs,
308
394
  ): Component {
309
395
  const rawPath = args?.file_path || args?.path || "";
310
- const filePath = shortenPath(rawPath);
311
- const editLanguage = getLanguageFromPath(rawPath) ?? "text";
312
- const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
313
-
314
396
  const op = args?.op || result.details?.op;
315
397
  const rename = args?.rename || result.details?.move;
316
- const opTitle = op === "create" ? "Create" : op === "delete" ? "Delete" : "Edit";
398
+ const { language } = formatEditDescription(rawPath, uiTheme, { rename });
317
399
 
318
400
  // Pre-compute metadata line (static across renders)
319
401
  const metadataLine =
320
402
  op !== "delete"
321
- ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), editLanguage, uiTheme)}`
403
+ ? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
322
404
  : "";
323
405
 
324
406
  // Pre-compute error text (static)
@@ -334,26 +416,17 @@ export const editToolRenderer = {
334
416
  const key = new Hasher().bool(expanded).u32(width).digest();
335
417
  if (cached?.key === key) return cached.lines;
336
418
 
337
- // Build path display with line number
338
- let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", "…");
339
419
  const firstChangedLine =
340
420
  (editDiffPreview && "firstChangedLine" in editDiffPreview
341
421
  ? editDiffPreview.firstChangedLine
342
422
  : undefined) || (result.details && !result.isError ? result.details.firstChangedLine : undefined);
343
- if (firstChangedLine) {
344
- pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
345
- }
346
-
347
- // Add arrow for rename operations
348
- if (rename) {
349
- pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(rename))}`;
350
- }
423
+ const { description } = formatEditDescription(rawPath, uiTheme, { rename, firstChangedLine });
351
424
 
352
425
  const header = renderStatusLine(
353
426
  {
354
427
  icon: result.isError ? "error" : "success",
355
- title: opTitle,
356
- description: `${editIcon} ${pathDisplay}`,
428
+ title: getOperationTitle(op),
429
+ description,
357
430
  },
358
431
  uiTheme,
359
432
  );
@@ -155,8 +155,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
155
155
  timeoutMs: options?.timeout,
156
156
  signal: runAbortController.signal,
157
157
  },
158
- chunk => {
159
- enqueueChunk(chunk);
158
+ (err, chunk) => {
159
+ if (!err) {
160
+ enqueueChunk(chunk);
161
+ }
160
162
  },
161
163
  );
162
164