@oh-my-pi/pi-coding-agent 15.10.10 → 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 (345) hide show
  1. package/CHANGELOG.md +95 -4
  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 +7 -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 -7
  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/eval/backend.d.ts +0 -2
  25. package/dist/types/eval/idle-timeout.d.ts +0 -4
  26. package/dist/types/eval/js/shared/rewrite-imports.d.ts +6 -6
  27. package/dist/types/export/html/template.generated.d.ts +1 -1
  28. package/dist/types/extensibility/extensions/types.d.ts +3 -3
  29. package/dist/types/hindsight/mental-models.d.ts +17 -8
  30. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  31. package/dist/types/internal-urls/types.d.ts +1 -1
  32. package/dist/types/lsp/edits.d.ts +9 -0
  33. package/dist/types/lsp/index.d.ts +2 -2
  34. package/dist/types/lsp/types.d.ts +2 -0
  35. package/dist/types/lsp/utils.d.ts +3 -0
  36. package/dist/types/mcp/json-rpc.d.ts +5 -0
  37. package/dist/types/mnemopi/state.d.ts +11 -1
  38. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  39. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  40. package/dist/types/modes/components/bash-execution.d.ts +1 -1
  41. package/dist/types/modes/components/copy-selector.d.ts +1 -1
  42. package/dist/types/modes/components/dynamic-border.d.ts +1 -1
  43. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -1
  44. package/dist/types/modes/components/extensions/extension-list.d.ts +1 -1
  45. package/dist/types/modes/components/extensions/inspector-panel.d.ts +1 -1
  46. package/dist/types/modes/components/footer.d.ts +1 -1
  47. package/dist/types/modes/components/hook-editor.d.ts +5 -0
  48. package/dist/types/modes/components/hook-input.d.ts +4 -0
  49. package/dist/types/modes/components/hook-selector.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +1 -1
  51. package/dist/types/modes/components/plan-review-overlay.d.ts +1 -1
  52. package/dist/types/modes/components/session-observer-overlay.d.ts +1 -1
  53. package/dist/types/modes/components/session-selector.d.ts +1 -1
  54. package/dist/types/modes/components/status-line/component.d.ts +1 -1
  55. package/dist/types/modes/components/tiny-title-download-progress.d.ts +1 -1
  56. package/dist/types/modes/components/transcript-container.d.ts +25 -6
  57. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  58. package/dist/types/modes/components/user-message-selector.d.ts +1 -1
  59. package/dist/types/modes/components/user-message.d.ts +2 -1
  60. package/dist/types/modes/components/visual-truncate.d.ts +1 -1
  61. package/dist/types/modes/components/welcome.d.ts +19 -3
  62. package/dist/types/modes/controllers/mcp-command-controller.d.ts +1 -1
  63. package/dist/types/modes/controllers/streaming-reveal.d.ts +1 -1
  64. package/dist/types/modes/interactive-mode.d.ts +1 -1
  65. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  66. package/dist/types/modes/setup-wizard/scenes/types.d.ts +1 -1
  67. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  68. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +1 -1
  69. package/dist/types/modes/types.d.ts +2 -1
  70. package/dist/types/session/agent-session.d.ts +1 -1
  71. package/dist/types/session/auth-broker-config.d.ts +4 -0
  72. package/dist/types/session/session-manager.d.ts +1 -1
  73. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +13 -0
  74. package/dist/types/ssh/connection-manager.d.ts +8 -0
  75. package/dist/types/task/parallel.d.ts +2 -2
  76. package/dist/types/task/worktree.d.ts +2 -0
  77. package/dist/types/tools/ask.d.ts +4 -0
  78. package/dist/types/tools/conflict-detect.d.ts +16 -0
  79. package/dist/types/tools/github-cache.d.ts +7 -0
  80. package/dist/types/tools/sqlite-reader.d.ts +3 -0
  81. package/dist/types/tui/output-block.d.ts +3 -3
  82. package/dist/types/utils/changelog.d.ts +8 -0
  83. package/dist/types/web/scrapers/readthedocs.d.ts +3 -0
  84. package/dist/types/web/scrapers/types.d.ts +12 -0
  85. package/dist/types/web/search/providers/codex.d.ts +1 -1
  86. package/dist/types/web/search/providers/gemini.d.ts +1 -1
  87. package/examples/extensions/tools.ts +5 -4
  88. package/package.json +14 -11
  89. package/scripts/build-binary.ts +18 -23
  90. package/scripts/bundle-dist.ts +81 -0
  91. package/scripts/{dev-launch → omp} +1 -1
  92. package/scripts/{dev-launch-preload.ts → omp.ts} +1 -1
  93. package/src/async/job-manager.ts +57 -3
  94. package/src/autoresearch/dashboard.ts +1 -1
  95. package/src/autoresearch/prompt-setup.md +6 -6
  96. package/src/autoresearch/prompt.md +6 -6
  97. package/src/capability/fs.ts +10 -0
  98. package/src/cli/args.ts +1 -1
  99. package/src/cli/auth-gateway-cli.ts +1 -3
  100. package/src/cli/dry-balance-cli.ts +1 -1
  101. package/src/cli/gallery-cli.ts +1 -1
  102. package/src/cli/gallery-fixtures/fs.ts +1 -1
  103. package/src/cli/gallery-fixtures/types.ts +5 -1
  104. package/src/cli/list-models.ts +2 -1
  105. package/src/cli/usage-cli.ts +603 -0
  106. package/src/cli-commands.ts +1 -0
  107. package/src/cli.ts +69 -5
  108. package/src/commands/complete.ts +1 -1
  109. package/src/commands/launch.ts +1 -1
  110. package/src/commands/read.ts +6 -3
  111. package/src/commands/usage.ts +35 -0
  112. package/src/commit/agentic/agent.ts +1 -1
  113. package/src/commit/model-selection.ts +1 -1
  114. package/src/config/append-only-context-mode.ts +6 -12
  115. package/src/config/model-discovery.ts +554 -0
  116. package/src/config/model-registry.ts +231 -1019
  117. package/src/config/model-resolver.ts +113 -156
  118. package/src/config/model-roles.ts +74 -0
  119. package/src/config/models-config-schema.ts +57 -8
  120. package/src/config/models-config.ts +129 -0
  121. package/src/config/settings-schema.ts +18 -4
  122. package/src/config/settings.ts +37 -1
  123. package/src/dap/client.ts +124 -37
  124. package/src/dap/session.ts +259 -158
  125. package/src/debug/log-viewer.ts +1 -1
  126. package/src/debug/raw-sse.ts +1 -1
  127. package/src/edit/diff.ts +47 -3
  128. package/src/edit/hashline/block-resolver.ts +20 -1
  129. package/src/edit/hashline/diff.ts +36 -1
  130. package/src/edit/hashline/execute.ts +8 -2
  131. package/src/edit/index.ts +16 -1
  132. package/src/edit/modes/patch.ts +52 -0
  133. package/src/edit/modes/replace.ts +56 -22
  134. package/src/edit/notebook.ts +22 -2
  135. package/src/edit/renderer.ts +36 -10
  136. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  137. package/src/eval/backend.ts +0 -2
  138. package/src/eval/completion-bridge.ts +2 -1
  139. package/src/eval/idle-timeout.ts +2 -9
  140. package/src/eval/js/context-manager.ts +6 -8
  141. package/src/eval/js/executor.ts +6 -2
  142. package/src/eval/js/index.ts +0 -2
  143. package/src/eval/js/shared/helpers.ts +5 -6
  144. package/src/eval/js/shared/local-module-loader.ts +1 -1
  145. package/src/eval/js/shared/prelude.txt +62 -1
  146. package/src/eval/js/shared/rewrite-imports.ts +40 -22
  147. package/src/eval/js/shared/runtime.ts +1 -1
  148. package/src/eval/py/index.ts +0 -2
  149. package/src/eval/py/kernel.ts +19 -0
  150. package/src/eval/py/runner.py +107 -3
  151. package/src/exec/bash-executor.ts +3 -1
  152. package/src/export/html/template.generated.ts +1 -1
  153. package/src/export/html/template.js +3 -1
  154. package/src/extensibility/extensions/types.ts +3 -2
  155. package/src/extensibility/plugins/legacy-pi-compat.ts +20 -3
  156. package/src/hindsight/mental-models.ts +59 -12
  157. package/src/hindsight/state.ts +6 -1
  158. package/src/internal-urls/artifact-protocol.ts +11 -2
  159. package/src/internal-urls/docs-index.generated.ts +8 -8
  160. package/src/internal-urls/issue-pr-protocol.ts +12 -5
  161. package/src/internal-urls/router.ts +1 -1
  162. package/src/internal-urls/types.ts +1 -1
  163. package/src/lib/xai-http.ts +1 -1
  164. package/src/lsp/client.ts +118 -38
  165. package/src/lsp/clients/biome-client.ts +101 -39
  166. package/src/lsp/edits.ts +143 -95
  167. package/src/lsp/index.ts +31 -22
  168. package/src/lsp/render.ts +1 -1
  169. package/src/lsp/types.ts +2 -0
  170. package/src/lsp/utils.ts +28 -10
  171. package/src/main.ts +165 -17
  172. package/src/mcp/json-rpc.ts +35 -5
  173. package/src/mcp/transports/stdio.ts +7 -1
  174. package/src/memories/index.ts +2 -1
  175. package/src/mnemopi/backend.ts +25 -3
  176. package/src/mnemopi/state.ts +38 -2
  177. package/src/modes/components/agent-dashboard.ts +10 -7
  178. package/src/modes/components/assistant-message.ts +19 -13
  179. package/src/modes/components/bash-execution.ts +1 -1
  180. package/src/modes/components/copy-selector.ts +1 -1
  181. package/src/modes/components/diff.ts +13 -2
  182. package/src/modes/components/dynamic-border.ts +12 -3
  183. package/src/modes/components/extensions/extension-dashboard.ts +8 -5
  184. package/src/modes/components/extensions/extension-list.ts +1 -1
  185. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  186. package/src/modes/components/footer.ts +1 -1
  187. package/src/modes/components/history-search.ts +1 -1
  188. package/src/modes/components/hook-editor.ts +8 -0
  189. package/src/modes/components/hook-input.ts +8 -0
  190. package/src/modes/components/hook-selector.ts +2 -2
  191. package/src/modes/components/model-selector.ts +4 -2
  192. package/src/modes/components/plan-review-overlay.ts +1 -1
  193. package/src/modes/components/session-observer-overlay.ts +2 -2
  194. package/src/modes/components/session-selector.ts +1 -1
  195. package/src/modes/components/settings-selector.ts +5 -1
  196. package/src/modes/components/status-line/component.ts +1 -1
  197. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  198. package/src/modes/components/transcript-container.ts +258 -53
  199. package/src/modes/components/tree-selector.ts +3 -3
  200. package/src/modes/components/user-message-selector.ts +1 -1
  201. package/src/modes/components/user-message.ts +17 -5
  202. package/src/modes/components/visual-truncate.ts +1 -1
  203. package/src/modes/components/welcome.ts +108 -26
  204. package/src/modes/controllers/command-controller.ts +10 -3
  205. package/src/modes/controllers/event-controller.ts +73 -4
  206. package/src/modes/controllers/input-controller.ts +1 -1
  207. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  208. package/src/modes/controllers/selector-controller.ts +1 -1
  209. package/src/modes/controllers/streaming-reveal.ts +85 -18
  210. package/src/modes/interactive-mode.ts +3 -9
  211. package/src/modes/setup-wizard/scenes/glyph.ts +1 -1
  212. package/src/modes/setup-wizard/scenes/providers.ts +1 -1
  213. package/src/modes/setup-wizard/scenes/sign-in.ts +1 -1
  214. package/src/modes/setup-wizard/scenes/theme.ts +1 -1
  215. package/src/modes/setup-wizard/scenes/types.ts +1 -1
  216. package/src/modes/setup-wizard/scenes/web-search.ts +1 -1
  217. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  218. package/src/modes/types.ts +2 -1
  219. package/src/prompts/agents/explore.md +2 -2
  220. package/src/prompts/agents/librarian.md +1 -2
  221. package/src/prompts/agents/oracle.md +1 -1
  222. package/src/prompts/agents/plan.md +5 -5
  223. package/src/prompts/agents/task.md +5 -5
  224. package/src/prompts/ci-green-request.md +5 -7
  225. package/src/prompts/goals/goal-budget-limit.md +2 -2
  226. package/src/prompts/goals/goal-continuation.md +4 -4
  227. package/src/prompts/goals/goal-mode-active.md +1 -1
  228. package/src/prompts/memories/read-path.md +1 -1
  229. package/src/prompts/memories/stage_one_system.md +2 -2
  230. package/src/prompts/review-custom-request.md +1 -1
  231. package/src/prompts/system/agent-creation-architect.md +2 -2
  232. package/src/prompts/system/auto-continue.md +1 -1
  233. package/src/prompts/system/background-tan-dispatch.md +1 -1
  234. package/src/prompts/system/btw-user.md +2 -2
  235. package/src/prompts/system/commit-message-system.md +13 -1
  236. package/src/prompts/system/custom-system-prompt.md +1 -1
  237. package/src/prompts/system/eager-todo.md +2 -2
  238. package/src/prompts/system/irc-incoming.md +1 -1
  239. package/src/prompts/system/manual-continue.md +1 -1
  240. package/src/prompts/system/omfg-user.md +3 -4
  241. package/src/prompts/system/orchestrate-notice.md +9 -9
  242. package/src/prompts/system/plan-mode-active.md +4 -4
  243. package/src/prompts/system/plan-mode-subagent.md +4 -5
  244. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  245. package/src/prompts/system/project-prompt.md +2 -2
  246. package/src/prompts/system/subagent-system-prompt.md +4 -4
  247. package/src/prompts/system/system-prompt.md +13 -24
  248. package/src/prompts/system/title-system.md +2 -2
  249. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  250. package/src/prompts/system/workflow-notice.md +1 -1
  251. package/src/prompts/tools/ast-edit.md +1 -1
  252. package/src/prompts/tools/ast-grep.md +2 -2
  253. package/src/prompts/tools/bash.md +5 -7
  254. package/src/prompts/tools/browser.md +7 -7
  255. package/src/prompts/tools/debug.md +1 -1
  256. package/src/prompts/tools/eval.md +3 -3
  257. package/src/prompts/tools/find.md +0 -1
  258. package/src/prompts/tools/github.md +8 -7
  259. package/src/prompts/tools/goal.md +1 -1
  260. package/src/prompts/tools/image-gen.md +1 -1
  261. package/src/prompts/tools/inspect-image-system.md +1 -1
  262. package/src/prompts/tools/irc.md +15 -15
  263. package/src/prompts/tools/lsp.md +2 -2
  264. package/src/prompts/tools/patch.md +2 -2
  265. package/src/prompts/tools/read.md +3 -4
  266. package/src/prompts/tools/recall.md +1 -1
  267. package/src/prompts/tools/reflect.md +1 -1
  268. package/src/prompts/tools/render-mermaid.md +2 -2
  269. package/src/prompts/tools/replace.md +4 -10
  270. package/src/prompts/tools/rewind.md +2 -2
  271. package/src/prompts/tools/search-tool-bm25.md +1 -9
  272. package/src/prompts/tools/search.md +0 -1
  273. package/src/prompts/tools/ssh.md +0 -4
  274. package/src/prompts/tools/task.md +2 -3
  275. package/src/prompts/tools/todo.md +1 -1
  276. package/src/sdk.ts +23 -10
  277. package/src/session/agent-session.ts +44 -10
  278. package/src/session/auth-broker-config.ts +30 -1
  279. package/src/session/session-manager.ts +2 -2
  280. package/src/session/streaming-output.ts +23 -2
  281. package/src/slash-commands/builtin-registry.ts +20 -0
  282. package/src/slash-commands/helpers/stats-dashboard.ts +85 -0
  283. package/src/ssh/connection-manager.ts +27 -0
  284. package/src/task/commands.ts +2 -1
  285. package/src/task/executor.ts +61 -53
  286. package/src/task/index.ts +137 -60
  287. package/src/task/parallel.ts +3 -3
  288. package/src/task/render.ts +2 -2
  289. package/src/task/worktree.ts +64 -56
  290. package/src/thinking.ts +2 -1
  291. package/src/tiny/title-client.ts +26 -11
  292. package/src/tools/archive-reader.ts +30 -2
  293. package/src/tools/ask.ts +104 -21
  294. package/src/tools/ast-edit.ts +25 -5
  295. package/src/tools/auto-generated-guard.ts +20 -3
  296. package/src/tools/bash-interactive.ts +27 -7
  297. package/src/tools/bash.ts +54 -13
  298. package/src/tools/browser/launch.ts +11 -2
  299. package/src/tools/browser/readable.ts +19 -2
  300. package/src/tools/browser/registry.ts +4 -1
  301. package/src/tools/browser/render.ts +2 -2
  302. package/src/tools/browser/tab-supervisor.ts +55 -16
  303. package/src/tools/conflict-detect.ts +50 -4
  304. package/src/tools/debug.ts +1 -1
  305. package/src/tools/eval-render.ts +5 -5
  306. package/src/tools/eval.ts +0 -2
  307. package/src/tools/fetch.ts +33 -10
  308. package/src/tools/gh-cache-invalidation.ts +63 -8
  309. package/src/tools/gh-renderer.ts +1 -1
  310. package/src/tools/gh.ts +172 -29
  311. package/src/tools/github-cache.ts +70 -6
  312. package/src/tools/image-gen.ts +3 -9
  313. package/src/tools/irc.ts +5 -1
  314. package/src/tools/job.ts +1 -1
  315. package/src/tools/read.ts +202 -61
  316. package/src/tools/render-utils.ts +3 -3
  317. package/src/tools/resolve.ts +1 -1
  318. package/src/tools/search.ts +92 -29
  319. package/src/tools/sqlite-reader.ts +17 -5
  320. package/src/tools/ssh.ts +8 -8
  321. package/src/tools/todo.ts +38 -8
  322. package/src/tools/write.ts +118 -18
  323. package/src/tui/output-block.ts +4 -4
  324. package/src/utils/changelog.ts +27 -1
  325. package/src/utils/file-mentions.ts +2 -1
  326. package/src/web/scrapers/arxiv.ts +1 -1
  327. package/src/web/scrapers/go-pkg.ts +1 -1
  328. package/src/web/scrapers/iacr.ts +1 -1
  329. package/src/web/scrapers/readthedocs.ts +1 -1
  330. package/src/web/scrapers/twitter.ts +2 -1
  331. package/src/web/scrapers/types.ts +87 -8
  332. package/src/web/scrapers/wikipedia.ts +1 -1
  333. package/src/web/scrapers/youtube.ts +6 -1
  334. package/src/web/search/index.ts +1 -1
  335. package/src/web/search/providers/codex.ts +2 -1
  336. package/src/web/search/providers/gemini.ts +2 -3
  337. package/src/web/search/render.ts +8 -6
  338. package/dist/types/config/model-equivalence.d.ts +0 -24
  339. package/dist/types/config/model-id-affixes.d.ts +0 -12
  340. package/dist/types/config/model-provider-priority.d.ts +0 -1
  341. package/dist/types/exec/idle-timeout-watchdog.d.ts +0 -18
  342. package/src/config/model-equivalence.ts +0 -875
  343. package/src/config/model-id-affixes.ts +0 -81
  344. package/src/config/model-provider-priority.ts +0 -56
  345. package/src/exec/idle-timeout-watchdog.ts +0 -126
package/src/tools/read.ts CHANGED
@@ -87,6 +87,7 @@ import {
87
87
  getTableSchema,
88
88
  isSqliteFile,
89
89
  listTables,
90
+ MAX_RAW_QUERY_ROWS,
90
91
  parseSqlitePathCandidates,
91
92
  parseSqliteSelector,
92
93
  queryRows,
@@ -334,6 +335,7 @@ async function streamLinesFromFile(
334
335
  maxBytes: number,
335
336
  selectedLineLimit: number | null,
336
337
  signal?: AbortSignal,
338
+ stopScanAfterCollect = false,
337
339
  ): Promise<{
338
340
  lines: string[];
339
341
  totalFileLines: number;
@@ -342,6 +344,8 @@ async function streamLinesFromFile(
342
344
  firstLinePreview?: { text: string; bytes: number };
343
345
  firstLineByteLength?: number;
344
346
  selectedBytesTotal: number;
347
+ /** False when `stopScanAfterCollect` cut the scan short — `totalFileLines` is then a lower bound. */
348
+ reachedEof: boolean;
345
349
  }> {
346
350
  const bufferChunk = Buffer.allocUnsafe(READ_CHUNK_SIZE);
347
351
  const collectedLines: string[] = [];
@@ -349,6 +353,7 @@ async function streamLinesFromFile(
349
353
  let collectedBytes = 0;
350
354
  let stoppedByByteLimit = false;
351
355
  let doneCollecting = false;
356
+ let reachedEof = true;
352
357
  let fileHandle: fs.FileHandle | null = null;
353
358
  let currentLineLength = 0;
354
359
  let currentLineChunks: Buffer[] = [];
@@ -463,6 +468,30 @@ async function streamLinesFromFile(
463
468
  const chunk = bufferChunk.subarray(0, bytesRead);
464
469
  endedWithNewline = chunk[bytesRead - 1] === 0x0a;
465
470
 
471
+ // Once collection and selected-line accounting are both finished, the
472
+ // remaining scan only computes `totalFileLines` — count newlines with
473
+ // native indexOf instead of the per-byte JS loop (a multi-GB tail
474
+ // otherwise stalls the read for seconds to minutes).
475
+ if (doneCollecting && selectedLineLimit !== null && selectedLinesSeen >= selectedLineLimit) {
476
+ if (stopScanAfterCollect) {
477
+ reachedEof = false;
478
+ break;
479
+ }
480
+ let searchFrom = 0;
481
+ let newlineAt = chunk.indexOf(0x0a);
482
+ while (newlineAt !== -1) {
483
+ lineIndex++;
484
+ searchFrom = newlineAt + 1;
485
+ newlineAt = chunk.indexOf(0x0a, searchFrom);
486
+ }
487
+ if (searchFrom === 0) {
488
+ currentLineLength += chunk.length;
489
+ } else {
490
+ currentLineLength = chunk.length - searchFrom;
491
+ }
492
+ continue;
493
+ }
494
+
466
495
  let start = 0;
467
496
  for (let i = 0; i < chunk.length; i++) {
468
497
  if (chunk[i] === 0x0a) {
@@ -485,7 +514,7 @@ async function streamLinesFromFile(
485
514
  }
486
515
  }
487
516
 
488
- if (endedWithNewline || currentLineLength > 0 || !sawAnyByte) {
517
+ if (reachedEof && (endedWithNewline || currentLineLength > 0 || !sawAnyByte)) {
489
518
  finalizeLine();
490
519
  }
491
520
 
@@ -503,6 +532,7 @@ async function streamLinesFromFile(
503
532
  firstLinePreview,
504
533
  firstLineByteLength,
505
534
  selectedBytesTotal,
535
+ reachedEof,
506
536
  };
507
537
  }
508
538
 
@@ -516,6 +546,17 @@ function isNotFoundError(error: unknown): boolean {
516
546
  return code === "ENOENT" || code === "ENOTDIR";
517
547
  }
518
548
 
549
+ /**
550
+ * Escape glob metacharacters so a literal path (e.g. `foo[1].ts`) interpolated
551
+ * into a suffix-glob pattern matches itself. Each metachar is wrapped in a
552
+ * character class (the native glob engine rewrites `\` to `/`, so backslash
553
+ * escaping is unavailable). `]`/`}` need no escaping once their openers are
554
+ * neutralized — unmatched closers are literal.
555
+ */
556
+ function escapeGlobMetachars(value: string): string {
557
+ return value.replace(/[*?[{]/g, "[$&]");
558
+ }
559
+
519
560
  /**
520
561
  * Attempt to resolve a non-existent path by finding a unique suffix match within the workspace.
521
562
  * Uses a glob suffix pattern so the native engine handles matching directly.
@@ -528,6 +569,7 @@ async function findUniqueSuffixMatch(
528
569
  ): Promise<{ absolutePath: string; displayPath: string } | null> {
529
570
  const normalized = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
530
571
  if (!normalized) return null;
572
+ const pattern = `**/${escapeGlobMetachars(normalized)}`;
531
573
 
532
574
  const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
533
575
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
@@ -536,7 +578,7 @@ async function findUniqueSuffixMatch(
536
578
  try {
537
579
  const result = await untilAborted(combinedSignal, () =>
538
580
  glob({
539
- pattern: `**/${normalized}`,
581
+ pattern,
540
582
  path: cwd,
541
583
  // No fileType filter: matches both files and directories
542
584
  hidden: true,
@@ -560,9 +602,7 @@ async function findUniqueSuffixMatch(
560
602
  }
561
603
 
562
604
  function decodeUtf8Text(bytes: Uint8Array): string | null {
563
- for (const byte of bytes) {
564
- if (byte === 0) return null;
565
- }
605
+ if (bytes.indexOf(0) !== -1) return null;
566
606
 
567
607
  try {
568
608
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
@@ -580,7 +620,11 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
580
620
 
581
621
  const readSchema = z
582
622
  .object({
583
- path: z.string().describe('path or url; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")'),
623
+ path: z
624
+ .string()
625
+ .describe(
626
+ 'Local path, internal URI (e.g. "omp://", "issue://123", "pr://123"), or URL; append :<sel> for line ranges or raw mode (e.g. "src/foo.ts:50-100")',
627
+ ),
584
628
  })
585
629
  .strict();
586
630
 
@@ -689,6 +733,9 @@ interface ResolvedSqliteReadPath {
689
733
  suffixResolution?: { from: string; to: string };
690
734
  }
691
735
 
736
+ /** Per-execute memo of suffix-glob lookups; `null` records a confirmed miss. */
737
+ type SuffixMatchCache = Map<string, { absolutePath: string; displayPath: string } | null>;
738
+
692
739
  /**
693
740
  * Read tool implementation.
694
741
  *
@@ -772,7 +819,30 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
772
819
  return toolResult<ReadToolDetails>({ notes, displayReadTargets }).content(content).done();
773
820
  }
774
821
 
775
- async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
822
+ /**
823
+ * Memoized {@link findUniqueSuffixMatch} for a single read call. A missing
824
+ * path with archive/sqlite extensions probes the workspace once per stage
825
+ * (archive candidates, sqlite candidates, plain path) — each glob carries a
826
+ * 5s timeout, so repeated lookups of the same string stack into a long
827
+ * stall before erroring. The cache collapses repeats within one execute().
828
+ */
829
+ async #findSuffixMatchCached(
830
+ cache: SuffixMatchCache,
831
+ rawPath: string,
832
+ signal?: AbortSignal,
833
+ ): Promise<{ absolutePath: string; displayPath: string } | null> {
834
+ const hit = cache.get(rawPath);
835
+ if (hit !== undefined) return hit;
836
+ const result = await findUniqueSuffixMatch(rawPath, this.session.cwd, signal);
837
+ cache.set(rawPath, result);
838
+ return result;
839
+ }
840
+
841
+ async #resolveArchiveReadPath(
842
+ readPath: string,
843
+ suffixCache: SuffixMatchCache,
844
+ signal?: AbortSignal,
845
+ ): Promise<ResolvedArchiveReadPath | null> {
776
846
  const candidates = parseArchivePathCandidates(readPath);
777
847
  for (const candidate of candidates) {
778
848
  let absolutePath = resolveReadPath(candidate.archivePath, this.session.cwd);
@@ -789,7 +859,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
789
859
  } catch (error) {
790
860
  if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) continue;
791
861
 
792
- const suffixMatch = await findUniqueSuffixMatch(candidate.archivePath, this.session.cwd, signal);
862
+ const suffixMatch = await this.#findSuffixMatchCached(suffixCache, candidate.archivePath, signal);
793
863
  if (!suffixMatch) continue;
794
864
 
795
865
  try {
@@ -814,7 +884,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
814
884
  return null;
815
885
  }
816
886
 
817
- async #resolveSqliteReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedSqliteReadPath | null> {
887
+ async #resolveSqliteReadPath(
888
+ readPath: string,
889
+ suffixCache: SuffixMatchCache,
890
+ signal?: AbortSignal,
891
+ ): Promise<ResolvedSqliteReadPath | null> {
818
892
  const candidates = parseSqlitePathCandidates(readPath);
819
893
  for (const candidate of candidates) {
820
894
  let absolutePath = resolveReadPath(candidate.sqlitePath, this.session.cwd);
@@ -834,7 +908,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
834
908
  } catch (error) {
835
909
  if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) continue;
836
910
 
837
- const suffixMatch = await findUniqueSuffixMatch(candidate.sqlitePath, this.session.cwd, signal);
911
+ const suffixMatch = await this.#findSuffixMatchCached(suffixCache, candidate.sqlitePath, signal);
838
912
  if (!suffixMatch) continue;
839
913
 
840
914
  try {
@@ -1169,17 +1243,29 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1169
1243
  const rangeStart = range.startLine - 1; // 0-indexed
1170
1244
  const requestedLength = range.endLine !== undefined ? range.endLine - range.startLine + 1 : this.#defaultLimit;
1171
1245
  const maxLines = Math.min(requestedLength, DEFAULT_MAX_LINES);
1172
- const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512);
1173
1246
 
1174
- const streamResult = await streamLinesFromFile(
1175
- absolutePath,
1176
- rangeStart,
1177
- maxLines,
1178
- maxBytesForRead,
1179
- maxLines,
1180
- signal,
1181
- );
1182
- const totalFileLines = streamResult.totalFileLines;
1247
+ // When the full file is already in memory (the common case for files
1248
+ // within the snapshot byte cap), slice ranges from it instead of
1249
+ // re-streaming the file once per range.
1250
+ let collectedLines: string[];
1251
+ let totalFileLines: number;
1252
+ if (fullLines) {
1253
+ totalFileLines = fullLines.length;
1254
+ collectedLines = fullLines.slice(rangeStart, rangeStart + maxLines);
1255
+ } else {
1256
+ const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLines * 512);
1257
+ const streamResult = await streamLinesFromFile(
1258
+ absolutePath,
1259
+ rangeStart,
1260
+ maxLines,
1261
+ maxBytesForRead,
1262
+ maxLines,
1263
+ signal,
1264
+ fileSize > SNAPSHOT_MAX_BYTES, // giant file: collected ranges don't need an exact EOF line count
1265
+ );
1266
+ totalFileLines = streamResult.totalFileLines;
1267
+ collectedLines = streamResult.lines;
1268
+ }
1183
1269
 
1184
1270
  if (rangeStart >= totalFileLines) {
1185
1271
  const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
@@ -1187,7 +1273,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1187
1273
  continue;
1188
1274
  }
1189
1275
 
1190
- const collectedLines = streamResult.lines;
1191
1276
  // Column truncation is display-only; clone before stamping ellipsis so
1192
1277
  // the original on-disk lines stay intact for display reconstruction.
1193
1278
  let displayLines: string[] = collectedLines;
@@ -1256,13 +1341,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1256
1341
  archive: ArchiveReader,
1257
1342
  archivePath: string,
1258
1343
  subPath: string,
1344
+ offset: number | undefined,
1259
1345
  limit: number | undefined,
1260
1346
  details: ReadToolDetails,
1261
1347
  signal?: AbortSignal,
1262
1348
  ): Promise<AgentToolResult<ReadToolDetails>> {
1263
1349
  const DEFAULT_LIMIT = 500;
1264
1350
  const effectiveLimit = limit ?? DEFAULT_LIMIT;
1265
- const entries = archive.listDirectory(subPath);
1351
+ const allEntries = archive.listDirectory(subPath);
1352
+ // `offset` is 1-indexed (line-selector semantics): `a.zip:dir:50` starts
1353
+ // the listing at the 50th entry instead of being silently ignored.
1354
+ const entries = offset !== undefined && offset > 1 ? allEntries.slice(offset - 1) : allEntries;
1266
1355
 
1267
1356
  const listLimit = applyListLimit(entries, { limit: effectiveLimit });
1268
1357
  const limitedEntries = listLimit.items;
@@ -1301,27 +1390,41 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1301
1390
  suffixResolution: resolvedArchivePath.suffixResolution,
1302
1391
  };
1303
1392
 
1304
- const node = archive.getNode(resolvedArchivePath.archiveSubPath);
1393
+ let archiveSubPath = resolvedArchivePath.archiveSubPath;
1394
+ let sel = parsedSel;
1395
+ let node = archive.getNode(archiveSubPath);
1396
+ if (!node && archiveSubPath) {
1397
+ // `archive.zip:500` / `archive.zip:raw`: the whole subPath is a
1398
+ // selector on the archive root, not a member name. Member names take
1399
+ // precedence (getNode above); fall back to root + selector.
1400
+ const wholeSel = parseSel(archiveSubPath);
1401
+ if (wholeSel.kind !== "none") {
1402
+ node = archive.getNode("");
1403
+ archiveSubPath = "";
1404
+ sel = wholeSel;
1405
+ }
1406
+ }
1305
1407
  if (!node) {
1306
1408
  throw new ToolError(`Path '${readPath}' not found inside archive`);
1307
1409
  }
1308
1410
 
1309
1411
  if (node.isDirectory) {
1310
- if (isMultiRange(parsedSel)) {
1412
+ if (isMultiRange(sel)) {
1311
1413
  throw new ToolError("Multi-range line selectors are not supported for archive directory listings.");
1312
1414
  }
1313
- const { limit } = selToOffsetLimit(parsedSel);
1415
+ const { offset, limit } = selToOffsetLimit(sel);
1314
1416
  return this.#readArchiveDirectory(
1315
1417
  archive,
1316
1418
  resolvedArchivePath.absolutePath,
1317
- resolvedArchivePath.archiveSubPath,
1419
+ archiveSubPath,
1420
+ offset,
1318
1421
  limit,
1319
1422
  details,
1320
1423
  signal,
1321
1424
  );
1322
1425
  }
1323
1426
 
1324
- const entry = await archive.readFile(resolvedArchivePath.archiveSubPath);
1427
+ const entry = await archive.readFile(archiveSubPath);
1325
1428
  const text = decodeUtf8Text(entry.bytes);
1326
1429
  if (text === null) {
1327
1430
  return toolResult<ReadToolDetails>(details)
@@ -1335,26 +1438,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1335
1438
  .done();
1336
1439
  }
1337
1440
 
1338
- const raw = isRawSelector(parsedSel);
1441
+ // Archive members are immutable: there is no edit path for bytes inside
1442
+ // an archive, and a hashline tag keyed to the archive file would invite
1443
+ // (and fail) edits while clobbering sibling members' snapshots.
1444
+ const raw = isRawSelector(sel);
1339
1445
  const result =
1340
- isMultiRange(parsedSel) && parsedSel.kind === "lines"
1341
- ? this.#buildInMemoryMultiRangeResult(text, parsedSel.ranges, {
1446
+ isMultiRange(sel) && sel.kind === "lines"
1447
+ ? this.#buildInMemoryMultiRangeResult(text, sel.ranges, {
1342
1448
  details,
1343
1449
  sourcePath: resolvedArchivePath.absolutePath,
1344
1450
  entityLabel: "archive entry",
1345
1451
  raw,
1452
+ immutable: true,
1346
1453
  })
1347
- : this.#buildInMemoryTextResult(
1348
- text,
1349
- selToOffsetLimit(parsedSel).offset,
1350
- selToOffsetLimit(parsedSel).limit,
1351
- {
1352
- details,
1353
- sourcePath: resolvedArchivePath.absolutePath,
1354
- entityLabel: "archive entry",
1355
- raw,
1356
- },
1357
- );
1454
+ : this.#buildInMemoryTextResult(text, selToOffsetLimit(sel).offset, selToOffsetLimit(sel).limit, {
1455
+ details,
1456
+ sourcePath: resolvedArchivePath.absolutePath,
1457
+ entityLabel: "archive entry",
1458
+ raw,
1459
+ immutable: true,
1460
+ });
1358
1461
  const firstText = result.content.find((content): content is TextContent => content.type === "text");
1359
1462
  if (firstText) {
1360
1463
  firstText.text = prependSuffixResolutionNotice(firstText.text, resolvedArchivePath.suffixResolution);
@@ -1459,19 +1562,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1459
1562
  }
1460
1563
  case "raw": {
1461
1564
  const result = executeReadQuery(db, selector.sql);
1565
+ let output = renderTable(result.columns, result.rows, {
1566
+ totalCount: result.rows.length,
1567
+ offset: 0,
1568
+ limit: result.rows.length || DEFAULT_MAX_LINES,
1569
+ table: "query",
1570
+ dbPath: resolvedSqlitePath.absolutePath,
1571
+ });
1572
+ if (result.truncated) {
1573
+ output += `\n[Output capped at ${MAX_RAW_QUERY_ROWS} rows; add a LIMIT/OFFSET clause to the query to page through more]`;
1574
+ }
1462
1575
  return toolResult<ReadToolDetails>(details)
1463
- .text(
1464
- prependSuffixResolutionNotice(
1465
- renderTable(result.columns, result.rows, {
1466
- totalCount: result.rows.length,
1467
- offset: 0,
1468
- limit: result.rows.length || DEFAULT_MAX_LINES,
1469
- table: "query",
1470
- dbPath: resolvedSqlitePath.absolutePath,
1471
- }),
1472
- resolvedSqlitePath.suffixResolution,
1473
- ),
1474
- )
1576
+ .text(prependSuffixResolutionNotice(output, resolvedSqlitePath.suffixResolution))
1475
1577
  .sourcePath(resolvedSqlitePath.absolutePath)
1476
1578
  .done();
1477
1579
  }
@@ -1696,10 +1798,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1696
1798
  if (internalRouter.canHandle(readPath)) {
1697
1799
  const internalTarget = splitInternalUrlSel(readPath);
1698
1800
  const parsed = parseSel(internalTarget.sel);
1801
+ if (internalTarget.sel !== undefined && parsed.kind === "none") {
1802
+ throw new ToolError(
1803
+ `Invalid selector ':${internalTarget.sel}' on '${internalTarget.path}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
1804
+ );
1805
+ }
1699
1806
  return this.#handleInternalUrl(internalTarget.path, parsed, signal);
1700
1807
  }
1701
1808
 
1702
- const archivePath = await this.#resolveArchiveReadPath(readPath, signal);
1809
+ // One suffix-glob memo per read call — archive, sqlite, and plain-path
1810
+ // resolution share misses instead of re-globbing the workspace.
1811
+ const suffixCache: SuffixMatchCache = new Map();
1812
+
1813
+ const archivePath = await this.#resolveArchiveReadPath(readPath, suffixCache, signal);
1703
1814
  if (archivePath) {
1704
1815
  const archiveSubPath = splitPathAndSel(archivePath.archiveSubPath);
1705
1816
  const archiveParsed = parseSel(archiveSubPath.sel);
@@ -1711,7 +1822,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1711
1822
  );
1712
1823
  }
1713
1824
 
1714
- const sqlitePath = await this.#resolveSqliteReadPath(readPath, signal);
1825
+ const sqlitePath = await this.#resolveSqliteReadPath(readPath, suffixCache, signal);
1715
1826
  if (sqlitePath) {
1716
1827
  return this.#readSqlite(sqlitePath, signal);
1717
1828
  }
@@ -1733,7 +1844,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1733
1844
  if (isNotFoundError(error)) {
1734
1845
  // Attempt unique suffix resolution before falling back to fuzzy suggestions
1735
1846
  if (!isRemoteMountPath(absolutePath)) {
1736
- const suffixMatch = await findUniqueSuffixMatch(localReadPath, this.session.cwd, signal);
1847
+ const suffixMatch = await this.#findSuffixMatchCached(suffixCache, localReadPath, signal);
1737
1848
  if (suffixMatch) {
1738
1849
  try {
1739
1850
  const retryStat = await Bun.file(suffixMatch.absolutePath).stat();
@@ -1992,6 +2103,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1992
2103
  maxBytesForRead,
1993
2104
  selectedLineLimit,
1994
2105
  undefined, // plain-file read: deterministic and fast, never abort mid-read
2106
+ fileSize > SNAPSHOT_MAX_BYTES, // giant file: don't scan to EOF just for an exact line count
1995
2107
  );
1996
2108
 
1997
2109
  const {
@@ -2001,6 +2113,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2001
2113
  stoppedByByteLimit,
2002
2114
  firstLinePreview,
2003
2115
  firstLineByteLength,
2116
+ reachedEof,
2004
2117
  } = streamResult;
2005
2118
 
2006
2119
  // Check if offset is out of bounds - return graceful message instead of throwing
@@ -2021,6 +2134,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2021
2134
  // counts in `truncation` keep reflecting the source, not the trimmed
2022
2135
  // view — column truncation surfaces separately via `.limits()`.
2023
2136
  const rawSelector = isRawSelector(parsed);
2137
+ // Binary sniff: NUL bytes in the collected window mean the file is
2138
+ // not displayable text (binary, or UTF-16 which has NULs in the
2139
+ // ASCII range) — emit a notice instead of mojibake filling the
2140
+ // line budget. `:raw` stays an explicit escape hatch.
2141
+ if (!rawSelector) {
2142
+ for (const line of collectedLines) {
2143
+ if (line.includes("\u0000")) {
2144
+ return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
2145
+ .text(
2146
+ prependSuffixResolutionNotice(
2147
+ `[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); content contains NUL bytes (binary or UTF-16 encoded)]`,
2148
+ suffixResolution,
2149
+ ),
2150
+ )
2151
+ .sourcePath(absolutePath)
2152
+ .done();
2153
+ }
2154
+ }
2155
+ }
2024
2156
  const maxColumns = resolveOutputMaxColumns(this.session.settings);
2025
2157
  // Column truncation is display-only. `collectedLines` MUST stay
2026
2158
  // byte-for-byte with the on-disk content so the snapshot recorded
@@ -2149,7 +2281,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2149
2281
  sourcePath = absolutePath;
2150
2282
  truncationInfo = {
2151
2283
  result: truncation,
2152
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
2284
+ options: {
2285
+ direction: "head",
2286
+ startLine: startLineDisplay,
2287
+ totalFileLines: reachedEof ? totalFileLines : undefined,
2288
+ },
2153
2289
  };
2154
2290
  } else if (truncation.truncated) {
2155
2291
  outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
@@ -2157,14 +2293,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2157
2293
  sourcePath = absolutePath;
2158
2294
  truncationInfo = {
2159
2295
  result: truncation,
2160
- options: { direction: "head", startLine: startLineDisplay, totalFileLines },
2296
+ options: {
2297
+ direction: "head",
2298
+ startLine: startLineDisplay,
2299
+ totalFileLines: reachedEof ? totalFileLines : undefined,
2300
+ },
2161
2301
  };
2162
- } else if (startLine + userLimitedLines < totalFileLines) {
2163
- const remaining = totalFileLines - (startLine + userLimitedLines);
2302
+ } else if (startLine + userLimitedLines < totalFileLines || !reachedEof) {
2164
2303
  const nextOffset = startLine + userLimitedLines + 1;
2165
2304
 
2166
2305
  outputText = formatBracketAwareText() ?? formatText(truncation.content, startLineDisplay);
2167
- outputText += `\n\n[${remaining} more lines in file. Use :${nextOffset} to continue]`;
2306
+ outputText += reachedEof
2307
+ ? `\n\n[${totalFileLines - (startLine + userLimitedLines)} more lines in file. Use :${nextOffset} to continue]`
2308
+ : `\n\n[More lines in file (${formatBytes(fileSize)} total; not scanned to EOF). Use :${nextOffset} to continue]`;
2168
2309
  details = {};
2169
2310
  sourcePath = absolutePath;
2170
2311
  } else {
@@ -521,7 +521,7 @@ function parseDiffSegments(lines: string[]): DiffSegment[] {
521
521
 
522
522
  for (const line of lines) {
523
523
  const isChange = line.startsWith("+") || line.startsWith("-");
524
- const isEllipsis = line.trimStart().startsWith("...");
524
+ const isEllipsis = line.trimStart().startsWith("...") || line.trim().length === 0;
525
525
 
526
526
  if (isEllipsis) {
527
527
  if (current) segments.push(current);
@@ -628,7 +628,7 @@ export function truncateDiffByHunk(
628
628
  const half = Math.ceil(allowedLines / 2);
629
629
  if (seg.lines.length > allowedLines) {
630
630
  kept.push(...seg.lines.slice(0, half));
631
- kept.push(seg.lines[0].replace(/^(\s*\d*\s*).*/, "$1..."));
631
+ kept.push("");
632
632
  kept.push(...seg.lines.slice(-half));
633
633
  } else {
634
634
  kept.push(...seg.lines);
@@ -761,7 +761,7 @@ export function createCachedComponent(
761
761
  ): Component {
762
762
  let cached: { key: bigint; lines: string[] } | undefined;
763
763
  return {
764
- render(width: number): string[] {
764
+ render(width: number): readonly string[] {
765
765
  const expanded = getExpanded();
766
766
  const key = new Hasher().bool(expanded).u32(width).digest();
767
767
  if (cached?.key === key) return cached.lines;
@@ -254,7 +254,7 @@ export const resolveToolRenderer = {
254
254
  const lines = ["", headerLine, "", uiTheme.italic(reason), ""];
255
255
 
256
256
  return {
257
- render(width: number) {
257
+ render(width: number): readonly string[] {
258
258
  const lineWidth = Math.max(3, width);
259
259
  const innerWidth = Math.max(1, lineWidth - 2);
260
260
  return lines.map(line => {