@oh-my-pi/pi-coding-agent 15.10.9 → 15.10.11

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 (352) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/dist/cli.js +23087 -0
  3. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  4. package/dist/types/async/job-manager.d.ts +18 -0
  5. package/dist/types/cli/args.d.ts +1 -1
  6. package/dist/types/cli/dry-balance-cli.d.ts +1 -1
  7. package/dist/types/cli/gallery-cli.d.ts +1 -1
  8. package/dist/types/cli/gallery-fixtures/types.d.ts +1 -1
  9. package/dist/types/cli/usage-cli.d.ts +72 -0
  10. package/dist/types/commands/launch.d.ts +1 -1
  11. package/dist/types/commands/read.d.ts +1 -1
  12. package/dist/types/commands/usage.d.ts +25 -0
  13. package/dist/types/config/append-only-context-mode.d.ts +2 -1
  14. package/dist/types/config/model-discovery.d.ts +55 -0
  15. package/dist/types/config/model-registry.d.ts +20 -219
  16. package/dist/types/config/model-resolver.d.ts +16 -10
  17. package/dist/types/config/model-roles.d.ts +28 -0
  18. package/dist/types/config/models-config-schema.d.ts +523 -42
  19. package/dist/types/config/models-config.d.ts +385 -0
  20. package/dist/types/config/settings-schema.d.ts +12 -16
  21. package/dist/types/config/settings.d.ts +1 -1
  22. package/dist/types/debug/log-viewer.d.ts +1 -1
  23. package/dist/types/debug/raw-sse.d.ts +1 -1
  24. package/dist/types/debug/terminal-info.d.ts +0 -1
  25. package/dist/types/eval/backend.d.ts +0 -2
  26. package/dist/types/eval/idle-timeout.d.ts +0 -4
  27. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  28. package/dist/types/export/html/template.generated.d.ts +1 -1
  29. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  30. package/dist/types/hindsight/mental-models.d.ts +17 -8
  31. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  32. package/dist/types/internal-urls/types.d.ts +1 -1
  33. package/dist/types/lsp/edits.d.ts +9 -0
  34. package/dist/types/lsp/index.d.ts +2 -2
  35. package/dist/types/lsp/types.d.ts +2 -0
  36. package/dist/types/lsp/utils.d.ts +3 -0
  37. package/dist/types/mcp/json-rpc.d.ts +5 -0
  38. package/dist/types/mnemopi/state.d.ts +11 -1
  39. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  40. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  41. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  42. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  43. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  46. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  47. package/dist/types/modes/components/footer.d.ts +1 -1
  48. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  49. package/dist/types/modes/components/hook-input.d.ts +4 -0
  50. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  51. package/dist/types/modes/components/model-selector.d.ts +1 -1
  52. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  54. package/dist/types/modes/components/session-selector.d.ts +1 -1
  55. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  56. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  57. package/dist/types/modes/components/transcript-container.d.ts +31 -26
  58. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  60. package/dist/types/modes/components/user-message.d.ts +2 -1
  61. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  62. package/dist/types/modes/components/welcome.d.ts +19 -3
  63. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  64. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  65. package/dist/types/modes/interactive-mode.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  69. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  70. package/dist/types/modes/types.d.ts +2 -1
  71. package/dist/types/session/agent-session.d.ts +1 -1
  72. package/dist/types/session/auth-broker-config.d.ts +4 -0
  73. package/dist/types/session/session-manager.d.ts +1 -1
  74. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  75. package/dist/types/ssh/connection-manager.d.ts +8 -0
  76. package/dist/types/task/discovery.d.ts +1 -2
  77. package/dist/types/task/parallel.d.ts +2 -2
  78. package/dist/types/task/worktree.d.ts +2 -0
  79. package/dist/types/tiny/title-client.d.ts +1 -1
  80. package/dist/types/tools/ask.d.ts +4 -0
  81. package/dist/types/tools/conflict-detect.d.ts +16 -0
  82. package/dist/types/tools/github-cache.d.ts +7 -0
  83. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  84. package/dist/types/tools/todo.d.ts +2 -0
  85. package/dist/types/tui/output-block.d.ts +3 -3
  86. package/dist/types/utils/changelog.d.ts +8 -0
  87. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  88. package/dist/types/web/scrapers/types.d.ts +12 -0
  89. package/dist/types/web/search/providers/codex.d.ts +1 -1
  90. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  91. package/examples/extensions/tools.ts +5 -4
  92. package/package.json +14 -11
  93. package/scripts/build-binary.ts +18 -23
  94. package/scripts/bundle-dist.ts +81 -0
  95. package/scripts/{dev-launch → omp} +1 -1
  96. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  97. package/src/async/job-manager.ts +57 -3
  98. package/src/autoresearch/dashboard.ts +1 -1
  99. package/src/autoresearch/prompt-setup.md +6 -6
  100. package/src/autoresearch/prompt.md +6 -6
  101. package/src/capability/fs.ts +10 -0
  102. package/src/cli/args.ts +1 -1
  103. package/src/cli/auth-gateway-cli.ts +1 -3
  104. package/src/cli/dry-balance-cli.ts +1 -1
  105. package/src/cli/gallery-cli.ts +1 -1
  106. package/src/cli/gallery-fixtures/fs.ts +1 -1
  107. package/src/cli/gallery-fixtures/types.ts +5 -1
  108. package/src/cli/list-models.ts +7 -12
  109. package/src/cli/usage-cli.ts +603 -0
  110. package/src/cli-commands.ts +1 -0
  111. package/src/cli.ts +69 -5
  112. package/src/commands/complete.ts +1 -1
  113. package/src/commands/launch.ts +1 -1
  114. package/src/commands/read.ts +6 -3
  115. package/src/commands/usage.ts +35 -0
  116. package/src/commit/agentic/agent.ts +1 -1
  117. package/src/commit/model-selection.ts +1 -1
  118. package/src/config/append-only-context-mode.ts +6 -12
  119. package/src/config/model-discovery.ts +554 -0
  120. package/src/config/model-registry.ts +308 -1025
  121. package/src/config/model-resolver.ts +113 -156
  122. package/src/config/model-roles.ts +74 -0
  123. package/src/config/models-config-schema.ts +57 -8
  124. package/src/config/models-config.ts +129 -0
  125. package/src/config/settings-schema.ts +18 -14
  126. package/src/config/settings.ts +37 -1
  127. package/src/dap/client.ts +124 -37
  128. package/src/dap/session.ts +259 -158
  129. package/src/debug/log-viewer.ts +1 -1
  130. package/src/debug/raw-sse.ts +1 -1
  131. package/src/debug/terminal-info.ts +0 -3
  132. package/src/edit/diff.ts +95 -18
  133. package/src/edit/hashline/block-resolver.ts +20 -1
  134. package/src/edit/hashline/diff.ts +36 -1
  135. package/src/edit/hashline/execute.ts +8 -2
  136. package/src/edit/index.ts +16 -1
  137. package/src/edit/modes/patch.ts +52 -0
  138. package/src/edit/modes/replace.ts +56 -22
  139. package/src/edit/notebook.ts +22 -2
  140. package/src/edit/renderer.ts +36 -10
  141. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  142. package/src/eval/backend.ts +0 -2
  143. package/src/eval/completion-bridge.ts +2 -1
  144. package/src/eval/idle-timeout.ts +2 -9
  145. package/src/eval/js/context-manager.ts +6 -8
  146. package/src/eval/js/executor.ts +6 -2
  147. package/src/eval/js/index.ts +0 -2
  148. package/src/eval/js/shared/helpers.ts +5 -6
  149. package/src/eval/js/shared/local-module-loader.ts +1 -1
  150. package/src/eval/js/shared/prelude.txt +62 -1
  151. package/src/eval/js/shared/rewrite-imports.ts +49 -23
  152. package/src/eval/js/shared/runtime.ts +1 -1
  153. package/src/eval/py/index.ts +0 -2
  154. package/src/eval/py/kernel.ts +19 -0
  155. package/src/eval/py/runner.py +107 -3
  156. package/src/exec/bash-executor.ts +3 -1
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/export/html/template.js +3 -1
  159. package/src/extensibility/extensions/types.ts +3 -2
  160. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  161. package/src/hindsight/mental-models.ts +59 -12
  162. package/src/hindsight/state.ts +6 -1
  163. package/src/internal-urls/artifact-protocol.ts +11 -2
  164. package/src/internal-urls/docs-index.generated.ts +10 -10
  165. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  166. package/src/internal-urls/router.ts +1 -1
  167. package/src/internal-urls/types.ts +1 -1
  168. package/src/lib/xai-http.ts +1 -1
  169. package/src/lsp/client.ts +118 -38
  170. package/src/lsp/clients/biome-client.ts +101 -39
  171. package/src/lsp/edits.ts +143 -95
  172. package/src/lsp/index.ts +31 -22
  173. package/src/lsp/render.ts +1 -1
  174. package/src/lsp/types.ts +2 -0
  175. package/src/lsp/utils.ts +28 -10
  176. package/src/main.ts +165 -17
  177. package/src/mcp/json-rpc.ts +35 -5
  178. package/src/mcp/transports/stdio.ts +7 -1
  179. package/src/memories/index.ts +2 -1
  180. package/src/mnemopi/backend.ts +25 -3
  181. package/src/mnemopi/state.ts +38 -2
  182. package/src/modes/components/agent-dashboard.ts +10 -7
  183. package/src/modes/components/assistant-message.ts +19 -13
  184. package/src/modes/components/bash-execution.ts +1 -1
  185. package/src/modes/components/copy-selector.ts +1 -1
  186. package/src/modes/components/diff.ts +13 -2
  187. package/src/modes/components/dynamic-border.ts +12 -3
  188. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  189. package/src/modes/components/extensions/extension-list.ts +1 -1
  190. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  191. package/src/modes/components/footer.ts +1 -1
  192. package/src/modes/components/history-search.ts +1 -1
  193. package/src/modes/components/hook-editor.ts +8 -0
  194. package/src/modes/components/hook-input.ts +8 -0
  195. package/src/modes/components/hook-selector.ts +2 -2
  196. package/src/modes/components/model-selector.ts +66 -54
  197. package/src/modes/components/plan-review-overlay.ts +1 -1
  198. package/src/modes/components/session-observer-overlay.ts +2 -2
  199. package/src/modes/components/session-selector.ts +1 -1
  200. package/src/modes/components/settings-selector.ts +5 -1
  201. package/src/modes/components/status-line/component.ts +1 -1
  202. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  203. package/src/modes/components/transcript-container.ts +373 -141
  204. package/src/modes/components/tree-selector.ts +3 -3
  205. package/src/modes/components/user-message-selector.ts +1 -1
  206. package/src/modes/components/user-message.ts +17 -5
  207. package/src/modes/components/visual-truncate.ts +1 -1
  208. package/src/modes/components/welcome.ts +108 -26
  209. package/src/modes/controllers/command-controller.ts +10 -3
  210. package/src/modes/controllers/event-controller.ts +73 -49
  211. package/src/modes/controllers/input-controller.ts +5 -5
  212. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  213. package/src/modes/controllers/selector-controller.ts +1 -5
  214. package/src/modes/controllers/streaming-reveal.ts +85 -18
  215. package/src/modes/interactive-mode.ts +5 -19
  216. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  217. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  218. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  219. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  220. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  221. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  222. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  223. package/src/modes/types.ts +2 -1
  224. package/src/prompts/agents/explore.md +2 -2
  225. package/src/prompts/agents/librarian.md +1 -2
  226. package/src/prompts/agents/oracle.md +1 -1
  227. package/src/prompts/agents/plan.md +5 -5
  228. package/src/prompts/agents/task.md +5 -5
  229. package/src/prompts/ci-green-request.md +5 -7
  230. package/src/prompts/goals/goal-budget-limit.md +2 -2
  231. package/src/prompts/goals/goal-continuation.md +4 -4
  232. package/src/prompts/goals/goal-mode-active.md +1 -1
  233. package/src/prompts/memories/read-path.md +1 -1
  234. package/src/prompts/memories/stage_one_system.md +2 -2
  235. package/src/prompts/review-custom-request.md +1 -1
  236. package/src/prompts/system/agent-creation-architect.md +2 -2
  237. package/src/prompts/system/auto-continue.md +1 -1
  238. package/src/prompts/system/background-tan-dispatch.md +1 -1
  239. package/src/prompts/system/btw-user.md +2 -2
  240. package/src/prompts/system/commit-message-system.md +13 -1
  241. package/src/prompts/system/custom-system-prompt.md +1 -1
  242. package/src/prompts/system/eager-todo.md +2 -2
  243. package/src/prompts/system/irc-incoming.md +1 -1
  244. package/src/prompts/system/manual-continue.md +1 -1
  245. package/src/prompts/system/omfg-user.md +3 -4
  246. package/src/prompts/system/orchestrate-notice.md +9 -9
  247. package/src/prompts/system/plan-mode-active.md +4 -4
  248. package/src/prompts/system/plan-mode-subagent.md +4 -5
  249. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  250. package/src/prompts/system/project-prompt.md +2 -2
  251. package/src/prompts/system/subagent-system-prompt.md +4 -4
  252. package/src/prompts/system/system-prompt.md +15 -26
  253. package/src/prompts/system/title-system.md +2 -2
  254. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  255. package/src/prompts/system/workflow-notice.md +1 -1
  256. package/src/prompts/tools/ast-edit.md +1 -1
  257. package/src/prompts/tools/ast-grep.md +2 -2
  258. package/src/prompts/tools/bash.md +8 -10
  259. package/src/prompts/tools/browser.md +7 -7
  260. package/src/prompts/tools/debug.md +1 -1
  261. package/src/prompts/tools/eval.md +3 -3
  262. package/src/prompts/tools/find.md +0 -1
  263. package/src/prompts/tools/github.md +8 -7
  264. package/src/prompts/tools/goal.md +1 -1
  265. package/src/prompts/tools/image-gen.md +1 -1
  266. package/src/prompts/tools/inspect-image-system.md +1 -1
  267. package/src/prompts/tools/irc.md +15 -15
  268. package/src/prompts/tools/lsp.md +2 -2
  269. package/src/prompts/tools/patch.md +2 -2
  270. package/src/prompts/tools/read.md +3 -4
  271. package/src/prompts/tools/recall.md +1 -1
  272. package/src/prompts/tools/reflect.md +1 -1
  273. package/src/prompts/tools/render-mermaid.md +2 -2
  274. package/src/prompts/tools/replace.md +4 -10
  275. package/src/prompts/tools/rewind.md +2 -2
  276. package/src/prompts/tools/search-tool-bm25.md +1 -9
  277. package/src/prompts/tools/search.md +0 -1
  278. package/src/prompts/tools/ssh.md +0 -4
  279. package/src/prompts/tools/task.md +2 -3
  280. package/src/prompts/tools/todo.md +6 -2
  281. package/src/sdk.ts +23 -10
  282. package/src/session/agent-session.ts +44 -10
  283. package/src/session/auth-broker-config.ts +30 -1
  284. package/src/session/session-manager.ts +2 -2
  285. package/src/session/streaming-output.ts +23 -2
  286. package/src/slash-commands/builtin-registry.ts +20 -0
  287. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  288. package/src/ssh/connection-manager.ts +27 -0
  289. package/src/task/commands.ts +2 -1
  290. package/src/task/discovery.ts +17 -24
  291. package/src/task/executor.ts +61 -53
  292. package/src/task/index.ts +137 -60
  293. package/src/task/parallel.ts +3 -3
  294. package/src/task/render.ts +2 -2
  295. package/src/task/worktree.ts +64 -56
  296. package/src/thinking.ts +2 -1
  297. package/src/tiny/title-client.ts +32 -14
  298. package/src/tools/archive-reader.ts +30 -2
  299. package/src/tools/ask.ts +104 -21
  300. package/src/tools/ast-edit.ts +25 -5
  301. package/src/tools/auto-generated-guard.ts +20 -3
  302. package/src/tools/bash-interactive.ts +27 -7
  303. package/src/tools/bash.ts +54 -13
  304. package/src/tools/browser/launch.ts +11 -2
  305. package/src/tools/browser/readable.ts +19 -2
  306. package/src/tools/browser/registry.ts +4 -1
  307. package/src/tools/browser/render.ts +2 -2
  308. package/src/tools/browser/tab-supervisor.ts +55 -16
  309. package/src/tools/conflict-detect.ts +50 -4
  310. package/src/tools/debug.ts +1 -1
  311. package/src/tools/eval-render.ts +5 -5
  312. package/src/tools/eval.ts +0 -2
  313. package/src/tools/fetch.ts +33 -10
  314. package/src/tools/gh-cache-invalidation.ts +63 -8
  315. package/src/tools/gh-renderer.ts +1 -1
  316. package/src/tools/gh.ts +172 -29
  317. package/src/tools/github-cache.ts +70 -6
  318. package/src/tools/image-gen.ts +3 -9
  319. package/src/tools/irc.ts +5 -1
  320. package/src/tools/job.ts +1 -1
  321. package/src/tools/read.ts +202 -61
  322. package/src/tools/render-utils.ts +3 -3
  323. package/src/tools/resolve.ts +1 -1
  324. package/src/tools/search.ts +92 -29
  325. package/src/tools/sqlite-reader.ts +17 -5
  326. package/src/tools/ssh.ts +8 -8
  327. package/src/tools/todo.ts +51 -12
  328. package/src/tools/write.ts +118 -18
  329. package/src/tui/output-block.ts +4 -4
  330. package/src/utils/changelog.ts +27 -1
  331. package/src/utils/file-mentions.ts +2 -1
  332. package/src/web/scrapers/arxiv.ts +1 -1
  333. package/src/web/scrapers/go-pkg.ts +1 -1
  334. package/src/web/scrapers/iacr.ts +1 -1
  335. package/src/web/scrapers/readthedocs.ts +1 -1
  336. package/src/web/scrapers/twitter.ts +2 -1
  337. package/src/web/scrapers/types.ts +87 -8
  338. package/src/web/scrapers/wikipedia.ts +1 -1
  339. package/src/web/scrapers/youtube.ts +6 -1
  340. package/src/web/search/index.ts +1 -1
  341. package/src/web/search/providers/anthropic.ts +8 -2
  342. package/src/web/search/providers/codex.ts +2 -1
  343. package/src/web/search/providers/gemini.ts +2 -3
  344. package/src/web/search/render.ts +8 -6
  345. package/dist/types/config/model-equivalence.d.ts +0 -24
  346. package/dist/types/config/model-id-affixes.d.ts +0 -12
  347. package/dist/types/config/model-provider-priority.d.ts +0 -1
  348. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  349. package/src/config/model-equivalence.ts +0 -875
  350. package/src/config/model-id-affixes.ts +0 -81
  351. package/src/config/model-provider-priority.ts +0 -56
  352. package/src/exec/idle-timeout-watchdog.ts +0 -126
package/src/edit/diff.ts CHANGED
@@ -55,13 +55,10 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
55
55
  return `${prefix}${lineNum}|${content}`;
56
56
  }
57
57
 
58
- type DiffSource = "old" | "new";
59
-
60
58
  interface ParsedNumberedDiffRow {
61
59
  prefix: "+" | "-" | " ";
62
60
  lineNumber: number;
63
61
  content: string;
64
- source: DiffSource;
65
62
  }
66
63
 
67
64
  function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined {
@@ -70,18 +67,56 @@ function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined {
70
67
  const prefix = match[1] as "+" | "-" | " ";
71
68
  const lineNumber = Number.parseInt(match[2], 10);
72
69
  if (!Number.isFinite(lineNumber)) return undefined;
73
- return {
74
- prefix,
75
- lineNumber,
76
- content: match[3] ?? "",
77
- source: prefix === "+" ? "new" : "old",
78
- };
70
+ return { prefix, lineNumber, content: match[3] ?? "" };
79
71
  }
80
72
 
81
73
  function isDiffChangeRow(row: string | undefined): boolean {
82
74
  return row !== undefined && (row.startsWith("+") || row.startsWith("-"));
83
75
  }
84
76
 
77
+ /** Blank row separating non-contiguous regions of a numbered diff. */
78
+ const DIFF_GAP_ROW = "";
79
+
80
+ /** Old-file line number of a source-visible row (`-` or context); `+`/gap/other rows yield undefined. */
81
+ function parseSourceRowLineNumber(row: string): number | undefined {
82
+ const parsed = parseNumberedDiffRow(row);
83
+ return parsed === undefined || parsed.prefix === "+" ? undefined : parsed.lineNumber;
84
+ }
85
+
86
+ /**
87
+ * Drop gap rows that no longer separate anything. Context rows are inserted
88
+ * one at a time, each adding its own gap rows from a snapshot of the diff, so
89
+ * the raw result can contain adjacent gap rows, gap rows whose neighbors
90
+ * became contiguous after a later insert filled the hole, and gap rows at the
91
+ * diff edges. The sweep keeps a gap row only when it sits between two
92
+ * source-numbered rows (old-file coordinates — the same numbering the
93
+ * insertion gap test uses) that are actually non-contiguous, and never keeps
94
+ * two in a row.
95
+ */
96
+ function normalizeDiffGapRows(rows: string[]): void {
97
+ const kept: string[] = [];
98
+ for (let i = 0; i < rows.length; i++) {
99
+ const row = rows[i];
100
+ if (row !== DIFF_GAP_ROW) {
101
+ kept.push(row);
102
+ continue;
103
+ }
104
+ if (kept.length === 0 || kept[kept.length - 1] === DIFF_GAP_ROW) continue;
105
+ let before: number | undefined;
106
+ for (let j = kept.length - 1; j >= 0 && before === undefined; j--) {
107
+ before = parseSourceRowLineNumber(kept[j]);
108
+ }
109
+ let after: number | undefined;
110
+ for (let j = i + 1; j < rows.length && after === undefined; j++) {
111
+ if (rows[j] === DIFF_GAP_ROW) continue;
112
+ after = parseSourceRowLineNumber(rows[j]);
113
+ }
114
+ if (before === undefined || after === undefined || after <= before + 1) continue;
115
+ kept.push(row);
116
+ }
117
+ if (kept.length !== rows.length) rows.splice(0, rows.length, ...kept);
118
+ }
119
+
85
120
  function adjustedContextInsertIndex(rows: readonly string[], index: number): number {
86
121
  let start = index;
87
122
  while (start > 0 && isDiffChangeRow(rows[start - 1])) start--;
@@ -92,7 +127,6 @@ function adjustedContextInsertIndex(rows: readonly string[], index: number): num
92
127
 
93
128
  function insertBracketContextRows(
94
129
  rows: string[],
95
- source: DiffSource,
96
130
  contextLines: ReadonlyMap<number, string>,
97
131
  seenRows: Set<string>,
98
132
  ): void {
@@ -106,7 +140,7 @@ function insertBracketContextRows(
106
140
  let nextSourceLine: number | undefined;
107
141
  for (let i = 0; i < rows.length; i++) {
108
142
  const parsed = parseNumberedDiffRow(rows[i]);
109
- if (!parsed || parsed.source !== source) continue;
143
+ if (!parsed || parsed.prefix === "+") continue;
110
144
  if (parsed.lineNumber < lineNumber) {
111
145
  previousSourceLine = parsed.lineNumber;
112
146
  continue;
@@ -117,16 +151,26 @@ function insertBracketContextRows(
117
151
  }
118
152
 
119
153
  const chunk: string[] = [];
120
- if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push("...");
154
+ if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push(DIFF_GAP_ROW);
121
155
  chunk.push(row);
122
- if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push("...");
156
+ if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push(DIFF_GAP_ROW);
123
157
 
124
158
  const adjustedIndex = adjustedContextInsertIndex(rows, insertIndex);
125
159
  rows.splice(adjustedIndex, 0, ...chunk);
126
- for (const inserted of chunk) seenRows.add(inserted);
160
+ seenRows.add(row);
127
161
  }
128
162
  }
129
163
 
164
+ /**
165
+ * Insert off-window block-boundary rows (enclosing header, matching closing
166
+ * bracket, …) into a numbered diff. Context rows carry pre-edit line numbers —
167
+ * the renumbering contract of `buildCompactDiffPreview` — so boundary lines
168
+ * discovered in the new file are translated back to their pre-edit numbers
169
+ * and merged with the old-file pass before a single insertion sweep. Without
170
+ * the translation, a context line sitting in a net-offset region would be
171
+ * re-inserted under its post-edit number: duplicated, out of order, and
172
+ * renumbered incorrectly by the preview.
173
+ */
130
174
  function addMatchingBracketContextRows(
131
175
  rows: string[],
132
176
  oldLines: readonly string[],
@@ -136,16 +180,49 @@ function addMatchingBracketContextRows(
136
180
  const oldVisible: number[] = [];
137
181
  const newVisible: number[] = [];
138
182
  const seenRows = new Set(rows);
183
+ // Change positions in new-file coordinates, used to translate an unchanged
184
+ // new-file line number back to its pre-edit equivalent.
185
+ const changes: { newPos: number; delta: 1 | -1 }[] = [];
186
+ let offset = 0;
139
187
 
140
188
  for (const row of rows) {
141
189
  const parsed = parseNumberedDiffRow(row);
142
190
  if (!parsed) continue;
143
- if (parsed.source === "old") oldVisible.push(parsed.lineNumber);
144
- else newVisible.push(parsed.lineNumber);
191
+ switch (parsed.prefix) {
192
+ case "-":
193
+ oldVisible.push(parsed.lineNumber);
194
+ changes.push({ newPos: parsed.lineNumber + offset, delta: -1 });
195
+ offset--;
196
+ break;
197
+ case "+":
198
+ newVisible.push(parsed.lineNumber);
199
+ changes.push({ newPos: parsed.lineNumber, delta: 1 });
200
+ offset++;
201
+ break;
202
+ default:
203
+ // Context rows are visible in BOTH files: pre-edit number as
204
+ // written, post-edit number shifted by the net change so far.
205
+ oldVisible.push(parsed.lineNumber);
206
+ newVisible.push(parsed.lineNumber + offset);
207
+ break;
208
+ }
145
209
  }
146
210
 
147
- insertBracketContextRows(rows, "old", findBlockContextLines(oldLines, oldVisible, source), seenRows);
148
- insertBracketContextRows(rows, "new", findBlockContextLines(newLines, newVisible, source), seenRows);
211
+ const toOldLineNumber = (newLineNumber: number): number => {
212
+ let shift = 0;
213
+ for (const change of changes) {
214
+ if (change.newPos <= newLineNumber) shift += change.delta;
215
+ }
216
+ return newLineNumber - shift;
217
+ };
218
+
219
+ const contextRows = findBlockContextLines(oldLines, oldVisible, source);
220
+ for (const [lineNumber, text] of findBlockContextLines(newLines, newVisible, source)) {
221
+ const oldLineNumber = toOldLineNumber(lineNumber);
222
+ if (!contextRows.has(oldLineNumber)) contextRows.set(oldLineNumber, text);
223
+ }
224
+ insertBracketContextRows(rows, contextRows, seenRows);
225
+ normalizeDiffGapRows(rows);
149
226
  }
150
227
 
151
228
  /**
@@ -8,7 +8,26 @@
8
8
  import type { BlockResolver } from "@oh-my-pi/hashline";
9
9
  import { blockRangeAt } from "@oh-my-pi/pi-natives";
10
10
 
11
+ /**
12
+ * `blockRangeAt` runs a full synchronous tree-sitter parse of `text` per
13
+ * call, and streaming previews re-resolve the same (text, line) every
14
+ * streamed chunk. Memoize by content: identical text + line always yields the
15
+ * same span. FIFO-bounded; hashing the text is orders of magnitude cheaper
16
+ * than re-parsing it.
17
+ */
18
+ const resolutionCache = new Map<string, { start: number; end: number } | null>();
19
+ const RESOLUTION_CACHE_MAX = 512;
20
+
11
21
  export const nativeBlockResolver: BlockResolver = ({ path, text, line }) => {
22
+ const key = `${Bun.hash(text).toString(36)}:${text.length}:${line}:${path}`;
23
+ const cached = resolutionCache.get(key);
24
+ if (cached !== undefined) return cached;
12
25
  const range = blockRangeAt({ code: text, path, line });
13
- return range ? { start: range.startLine, end: range.endLine } : null;
26
+ const result = range ? { start: range.startLine, end: range.endLine } : null;
27
+ if (resolutionCache.size >= RESOLUTION_CACHE_MAX) {
28
+ const oldest = resolutionCache.keys().next().value;
29
+ if (oldest !== undefined) resolutionCache.delete(oldest);
30
+ }
31
+ resolutionCache.set(key, result);
32
+ return result;
14
33
  };
@@ -57,6 +57,39 @@ async function readSectionText(absolutePath: string, sectionPath: string): Promi
57
57
  }
58
58
  }
59
59
 
60
+ /**
61
+ * Streaming previews recompute on every streamed chunk; re-reading the target
62
+ * file from disk each tick dominates the cost on large files. Cache the raw
63
+ * section text keyed by mtime+size so any on-disk change invalidates
64
+ * naturally. Used by the streaming path only — the args-complete pass always
65
+ * reads fresh.
66
+ */
67
+ const streamingTextCache = new Map<string, { mtimeMs: number; size: number; rawContent: string }>();
68
+ const STREAMING_TEXT_CACHE_MAX = 8;
69
+
70
+ async function readSectionTextCached(absolutePath: string, sectionPath: string): Promise<string> {
71
+ let stamp: { mtimeMs: number; size: number } | undefined;
72
+ try {
73
+ const stat = await Bun.file(absolutePath).stat();
74
+ stamp = { mtimeMs: stat.mtimeMs, size: stat.size };
75
+ } catch {
76
+ stamp = undefined;
77
+ }
78
+ if (stamp) {
79
+ const cached = streamingTextCache.get(absolutePath);
80
+ if (cached && cached.mtimeMs === stamp.mtimeMs && cached.size === stamp.size) return cached.rawContent;
81
+ }
82
+ const rawContent = await readSectionText(absolutePath, sectionPath);
83
+ if (stamp) {
84
+ if (streamingTextCache.size >= STREAMING_TEXT_CACHE_MAX && !streamingTextCache.has(absolutePath)) {
85
+ const oldest = streamingTextCache.keys().next().value;
86
+ if (oldest !== undefined) streamingTextCache.delete(oldest);
87
+ }
88
+ streamingTextCache.set(absolutePath, { mtimeMs: stamp.mtimeMs, size: stamp.size, rawContent });
89
+ }
90
+ return rawContent;
91
+ }
92
+
60
93
  function hasAnchorScopedEdit(edits: readonly Edit[]): boolean {
61
94
  return edits.some(edit => {
62
95
  if (edit.kind === "delete") return true;
@@ -220,7 +253,9 @@ export async function computeHashlineSectionDiff(
220
253
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
221
254
  try {
222
255
  const absolutePath = resolveToCwd(section.path, cwd);
223
- const rawContent = await readSectionText(absolutePath, section.path);
256
+ const rawContent = options.streaming
257
+ ? await readSectionTextCached(absolutePath, section.path)
258
+ : await readSectionText(absolutePath, section.path);
224
259
  const { text: content } = stripBom(rawContent);
225
260
  const normalized = normalizeToLF(content);
226
261
  // Streaming favors a stable, monotonic preview over an exact unified
@@ -78,11 +78,17 @@ interface RenderedSection {
78
78
  }
79
79
 
80
80
  function formatBlockResolution(resolution: BlockResolution): string {
81
- const op = resolution.isDelete ? "delete block" : "replace block";
81
+ const op =
82
+ resolution.op === "delete"
83
+ ? "delete block"
84
+ : resolution.op === "insert_after"
85
+ ? "insert after block"
86
+ : "replace block";
82
87
  const lines = resolution.end - resolution.start + 1;
83
88
  const span =
84
89
  resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
85
- return `${op} ${resolution.anchorLine} resolved ${span} (${lines} line${lines === 1 ? "" : "s"})`;
90
+ const suffix = resolution.op === "insert_after" ? `; body lands after line ${resolution.end}` : "";
91
+ return `${op} ${resolution.anchorLine} → resolved ${span} (${lines} line${lines === 1 ? "" : "s"})${suffix}`;
86
92
  }
87
93
 
88
94
  function renderSection(result: PatchSectionResult, diagnostics: FileDiagnosticsResult | undefined): RenderedSection {
package/src/edit/index.ts CHANGED
@@ -238,8 +238,23 @@ async function executeSinglePathEntries(
238
238
  if (text) contentTexts.push(text);
239
239
  } catch (err) {
240
240
  const errorText = err instanceof Error ? err.message : String(err);
241
- contentTexts.push(`Error editing ${path}: ${errorText}`);
241
+ contentTexts.push(`Error editing ${path} (entry ${i + 1} of ${runs.length}): ${errorText}`);
242
+ if (i > 0) {
243
+ contentTexts.push(i === 1 ? `Entry 1 was already applied.` : `Entries 1-${i} were already applied.`);
244
+ }
245
+ if (i + 1 < runs.length) {
246
+ contentTexts.push(
247
+ (i + 2 === runs.length
248
+ ? `Entry ${runs.length} was NOT applied`
249
+ : `Entries ${i + 2}-${runs.length} were NOT applied`) +
250
+ `; re-read the file and re-issue only the failed and unapplied entries.`,
251
+ );
252
+ }
242
253
  errorCount++;
254
+ // Stop at the first failure: later entries were authored against
255
+ // line numbers/content that assumed this entry succeeded, and
256
+ // applying them after a failure compounds the damage.
257
+ break;
243
258
  }
244
259
 
245
260
  if (!isLast && onUpdate) {
@@ -40,6 +40,7 @@ import {
40
40
  countLeadingWhitespace,
41
41
  detectLineEnding,
42
42
  getLeadingWhitespace,
43
+ normalizeForFuzzy,
43
44
  normalizeToLF,
44
45
  restoreLineEndings,
45
46
  stripBom,
@@ -1007,6 +1008,41 @@ async function readExistingPatchFile(fileSystem: FileSystem, absolutePath: strin
1007
1008
  }
1008
1009
  }
1009
1010
 
1011
+ /**
1012
+ * A prefix/substring strategy matched pattern lines that cover only part of
1013
+ * the corresponding file lines; replacing whole lines would silently drop the
1014
+ * uncovered text the model never saw. Allow the replacement only when every
1015
+ * discarded piece (normalized) survives somewhere in the hunk's new lines.
1016
+ */
1017
+ function assertPartialMatchPreservesDiscardedText(
1018
+ path: string,
1019
+ pattern: string[],
1020
+ matchedLines: string[],
1021
+ newLines: string[],
1022
+ matchStartIndex: number,
1023
+ ): void {
1024
+ let newLinesNorm: string | undefined;
1025
+ for (let j = 0; j < pattern.length; j++) {
1026
+ const lineNorm = normalizeForFuzzy(matchedLines[j]);
1027
+ const patternNorm = normalizeForFuzzy(pattern[j]);
1028
+ if (lineNorm === patternNorm) continue;
1029
+ const at = lineNorm.indexOf(patternNorm);
1030
+ if (at === -1) continue;
1031
+ const discardedParts = [lineNorm.slice(0, at).trim(), lineNorm.slice(at + patternNorm.length).trim()];
1032
+ for (const part of discardedParts) {
1033
+ if (part.length === 0) continue;
1034
+ newLinesNorm ??= newLines.map(normalizeForFuzzy).join("\n");
1035
+ if (!newLinesNorm.includes(part)) {
1036
+ throw new ApplyPatchError(
1037
+ `Refusing partial-line match in ${path} at line ${matchStartIndex + j + 1}: ` +
1038
+ `the file line also contains ${JSON.stringify(part)}, which the replacement would silently drop. ` +
1039
+ `Provide the complete line in the hunk.`,
1040
+ );
1041
+ }
1042
+ }
1043
+ }
1044
+ }
1045
+
1010
1046
  /**
1011
1047
  * Compute replacements needed to transform originalLines using the diff hunks.
1012
1048
  */
@@ -1253,6 +1289,18 @@ function computeReplacements(
1253
1289
  if (searchResult.strategy === "fuzzy-dominant") {
1254
1290
  const similarity = Math.round(searchResult.confidence * 100);
1255
1291
  warnings.push(`Dominant fuzzy match selected in ${path} near line ${found + 1} (${similarity}% similar).`);
1292
+ } else if (
1293
+ searchResult.strategy === "comment-prefix" ||
1294
+ searchResult.strategy === "prefix" ||
1295
+ searchResult.strategy === "substring" ||
1296
+ searchResult.strategy === "fuzzy" ||
1297
+ searchResult.strategy === "character"
1298
+ ) {
1299
+ const similarity = Math.round(searchResult.confidence * 100);
1300
+ warnings.push(
1301
+ `Inexact match in ${path} near line ${found + 1}: matched via ${searchResult.strategy} strategy ` +
1302
+ `(${similarity}% similar). Re-read the file if the result is not what you intended.`,
1303
+ );
1256
1304
  }
1257
1305
 
1258
1306
  // Reject if match is ambiguous (prefix/substring matching found multiple matches)
@@ -1305,6 +1353,10 @@ function computeReplacements(
1305
1353
  continue;
1306
1354
  }
1307
1355
 
1356
+ if (searchResult.strategy === "prefix" || searchResult.strategy === "substring") {
1357
+ assertPartialMatchPreservesDiscardedText(path, pattern, actualMatchedLines, newSlice, found);
1358
+ }
1359
+
1308
1360
  const adjustedNewLines = adjustLinesIndentation(pattern, actualMatchedLines, newSlice);
1309
1361
  replacements.push({ startIndex: found, oldLen: pattern.length, newLines: adjustedNewLines });
1310
1362
  lineIndex = found + pattern.length;
@@ -525,29 +525,45 @@ function matchesAt(lines: string[], pattern: string[], i: number, compare: (a: s
525
525
  return true;
526
526
  }
527
527
 
528
- /** Compute average similarity score for pattern at position */
529
- function fuzzyScoreAt(lines: string[], pattern: string[], i: number): number {
528
+ /**
529
+ * Compute average similarity score for pre-normalized pattern lines at
530
+ * position `i` of pre-normalized file lines.
531
+ *
532
+ * `minScore` is a bail threshold: when even perfect similarity on the
533
+ * remaining lines cannot lift the average to `minScore`, returns the partial
534
+ * average early (always ≤ the true score). The length-difference lower bound
535
+ * on Levenshtein distance is used to skip the DP entirely for line pairs the
536
+ * bail test already rules out.
537
+ */
538
+ function fuzzyScoreAt(linesNorm: string[], patternNorm: string[], i: number, minScore = 0): number {
539
+ const count = patternNorm.length;
530
540
  let totalScore = 0;
531
- for (let j = 0; j < pattern.length; j++) {
532
- const lineNorm = normalizeForFuzzy(lines[i + j]);
533
- const patternNorm = normalizeForFuzzy(pattern[j]);
534
- totalScore += similarity(lineNorm, patternNorm);
541
+ for (let j = 0; j < count; j++) {
542
+ const lineNorm = linesNorm[i + j];
543
+ const patNorm = patternNorm[j];
544
+ if (lineNorm === patNorm) {
545
+ totalScore += 1;
546
+ continue;
547
+ }
548
+ const remaining = count - j - 1;
549
+ const maxLen = Math.max(lineNorm.length, patNorm.length);
550
+ // similarity ≤ 1 − |lenA−lenB|/maxLen: test the bound before the DP.
551
+ const upperBound = 1 - Math.abs(lineNorm.length - patNorm.length) / maxLen;
552
+ if ((totalScore + upperBound + remaining) / count < minScore) return totalScore / count;
553
+ if (upperBound > 0) totalScore += similarity(lineNorm, patNorm);
554
+ if ((totalScore + remaining) / count < minScore) return totalScore / count;
535
555
  }
536
- return totalScore / pattern.length;
556
+ return totalScore / count;
537
557
  }
538
558
 
539
- /** Check if line starts with pattern (normalized) */
540
- function lineStartsWithPattern(line: string, pattern: string): boolean {
541
- const lineNorm = normalizeForFuzzy(line);
542
- const patternNorm = normalizeForFuzzy(pattern);
559
+ /** Check if pre-normalized line starts with pre-normalized pattern */
560
+ function normStartsWith(lineNorm: string, patternNorm: string): boolean {
543
561
  if (patternNorm.length === 0) return lineNorm.length === 0;
544
562
  return lineNorm.startsWith(patternNorm);
545
563
  }
546
564
 
547
- /** Check if line contains pattern as significant substring */
548
- function lineIncludesPattern(line: string, pattern: string): boolean {
549
- const lineNorm = normalizeForFuzzy(line);
550
- const patternNorm = normalizeForFuzzy(pattern);
565
+ /** Check if pre-normalized line contains pre-normalized pattern as significant substring */
566
+ function normIncludes(lineNorm: string, patternNorm: string): boolean {
551
567
  if (patternNorm.length === 0) return lineNorm.length === 0;
552
568
  if (patternNorm.length < PARTIAL_MATCH_MIN_LENGTH) return false;
553
569
  if (!lineNorm.includes(patternNorm)) return false;
@@ -613,6 +629,13 @@ export function seekSequence(
613
629
  const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
614
630
  const maxStart = lines.length - pattern.length;
615
631
 
632
+ // Fuzzy and partial passes compare normalizeForFuzzy forms; normalize the
633
+ // file and pattern once per call instead of once per candidate position.
634
+ let linesNormCache: string[] | undefined;
635
+ let patternNormCache: string[] | undefined;
636
+ const getLinesNorm = () => (linesNormCache ??= lines.map(normalizeForFuzzy));
637
+ const getPatternNorm = () => (patternNormCache ??= pattern.map(normalizeForFuzzy));
638
+
616
639
  const runExactPasses = (from: number, to: number): SequenceSearchResult | undefined => {
617
640
  const comparisonPasses: Array<{
618
641
  compare: (a: string, b: string) => boolean;
@@ -646,17 +669,19 @@ export function seekSequence(
646
669
  return undefined;
647
670
  }
648
671
 
672
+ const linesNorm = getLinesNorm();
673
+ const patternNorm = getPatternNorm();
649
674
  const partialPasses: Array<{
650
- compare: (line: string, patternLine: string) => boolean;
675
+ compare: (lineNorm: string, patternLineNorm: string) => boolean;
651
676
  confidence: number;
652
677
  strategy: SequenceMatchStrategy;
653
678
  }> = [
654
- { compare: lineStartsWithPattern, confidence: 0.965, strategy: "prefix" },
655
- { compare: lineIncludesPattern, confidence: 0.94, strategy: "substring" },
679
+ { compare: normStartsWith, confidence: 0.965, strategy: "prefix" },
680
+ { compare: normIncludes, confidence: 0.94, strategy: "substring" },
656
681
  ];
657
682
 
658
683
  for (const pass of partialPasses) {
659
- const matches = collectIndexedMatches(from, to, i => matchesAt(lines, pattern, i, pass.compare));
684
+ const matches = collectIndexedMatches(from, to, i => matchesAt(linesNorm, patternNorm, i, pass.compare));
660
685
  const result = toAmbiguousMatchResult(matches, pass.confidence, pass.strategy);
661
686
  if (result) {
662
687
  return result;
@@ -692,9 +717,14 @@ export function seekSequence(
692
717
  matchIndices: [],
693
718
  };
694
719
 
720
+ const fuzzyLinesNorm = getLinesNorm();
721
+ const fuzzyPatternNorm = getPatternNorm();
722
+ // Positions scoring below this can neither become a fuzzy match nor affect
723
+ // the dominant-fuzzy gap test; let fuzzyScoreAt bail early on them.
724
+ const fuzzyBail = SEQUENCE_FUZZY_THRESHOLD - DOMINANT_FUZZY_DELTA;
695
725
  const scoreFuzzyRange = (from: number, to: number): void => {
696
726
  for (let i = from; i <= to; i++) {
697
- const score = fuzzyScoreAt(lines, pattern, i);
727
+ const score = fuzzyScoreAt(fuzzyLinesNorm, fuzzyPatternNorm, i, fuzzyBail);
698
728
  if (score >= SEQUENCE_FUZZY_THRESHOLD) {
699
729
  if (fuzzyMatches.firstMatch === undefined) {
700
730
  fuzzyMatches.firstMatch = i;
@@ -787,12 +817,16 @@ export function findClosestSequenceMatch(
787
817
  const eof = options?.eof ?? false;
788
818
  const maxStart = lines.length - pattern.length;
789
819
  const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
820
+ const linesNorm = lines.map(normalizeForFuzzy);
821
+ const patternNorm = pattern.map(normalizeForFuzzy);
790
822
 
791
823
  let bestIndex: number | undefined;
792
824
  let bestScore = 0;
793
825
 
826
+ // Passing the running best as the bail threshold is exact: a bailed
827
+ // position returns a value strictly below it, so it can never win.
794
828
  for (let i = searchStart; i <= maxStart; i++) {
795
- const score = fuzzyScoreAt(lines, pattern, i);
829
+ const score = fuzzyScoreAt(linesNorm, patternNorm, i, bestScore);
796
830
  if (score > bestScore) {
797
831
  bestScore = score;
798
832
  bestIndex = i;
@@ -801,7 +835,7 @@ export function findClosestSequenceMatch(
801
835
 
802
836
  if (eof && searchStart > start) {
803
837
  for (let i = start; i < searchStart; i++) {
804
- const score = fuzzyScoreAt(lines, pattern, i);
838
+ const score = fuzzyScoreAt(linesNorm, patternNorm, i, bestScore);
805
839
  if (score > bestScore) {
806
840
  bestScore = score;
807
841
  bestIndex = i;
@@ -21,6 +21,26 @@ export interface NotebookDocument {
21
21
  }
22
22
 
23
23
  const CELL_MARKER_RE = /^# %% \[(code|markdown|raw)\](?: cell:(\d+))?$/;
24
+ /**
25
+ * Cell source lines that would themselves parse as (possibly already-escaped)
26
+ * cell markers gain one extra `%` on render and lose it on parse, so a
27
+ * notebook that *contains* the literal text `# %% [markdown] cell:3` survives
28
+ * the editable-text round trip instead of being split into extra cells.
29
+ */
30
+ const ESCAPABLE_MARKER_RE = /^# %%+ \[(?:code|markdown|raw)\](?: cell:\d+)?$/;
31
+ const ESCAPED_MARKER_RE = /^# %%%+ \[(?:code|markdown|raw)\](?: cell:\d+)?$/;
32
+
33
+ function escapeMarkerLikeSourceLines(source: string): string {
34
+ if (!source.includes("# %%")) return source;
35
+ return source
36
+ .split("\n")
37
+ .map(line => (ESCAPABLE_MARKER_RE.test(line) ? line.replace("# %", "# %%") : line))
38
+ .join("\n");
39
+ }
40
+
41
+ function unescapeMarkerLikeLine(line: string): string {
42
+ return ESCAPED_MARKER_RE.test(line) ? line.replace("# %%", "# %") : line;
43
+ }
24
44
 
25
45
  export function isNotebookPath(filePath: string): boolean {
26
46
  return path.extname(filePath).toLowerCase() === ".ipynb";
@@ -100,7 +120,7 @@ export async function readNotebookDocument(absolutePath: string, displayPath: st
100
120
  export function notebookToEditableText(notebook: NotebookDocument): string {
101
121
  return notebook.cells
102
122
  .map((cell, index) => {
103
- const source = sourceToText(cell.source);
123
+ const source = escapeMarkerLikeSourceLines(sourceToText(cell.source));
104
124
  return source.length > 0
105
125
  ? `# %% [${cell.cell_type}] cell:${index}\n${source}`
106
126
  : `# %% [${cell.cell_type}] cell:${index}`;
@@ -156,7 +176,7 @@ function parseNotebookEditableText(text: string, displayPath: string): ParsedVir
156
176
  `Invalid notebook editable representation for ${displayPath}: expected first line to be "# %% [code] cell:0", "# %% [markdown] cell:0", or "# %% [raw] cell:0".`,
157
177
  );
158
178
  }
159
- current.lines.push(line);
179
+ current.lines.push(unescapeMarkerLikeLine(line));
160
180
  }
161
181
  flush();
162
182
  return cells;