@oh-my-pi/pi-tui 18.2.4 → 18.2.5

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 (748) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +43 -0
  3. package/dist/types/app-keybindings.d.ts +395 -0
  4. package/dist/types/apps/autoresearch-dashboard.d.ts +43 -0
  5. package/dist/types/apps/autoresearch-data.d.ts +15 -0
  6. package/dist/types/apps/cleanse-board.d.ts +66 -0
  7. package/dist/types/apps/cleanse-picker.d.ts +23 -0
  8. package/dist/types/apps/debug/log-formatting.d.ts +6 -0
  9. package/dist/types/apps/debug/log-viewer.d.ts +81 -0
  10. package/dist/types/apps/debug/protocol-probe.d.ts +42 -0
  11. package/dist/types/apps/debug/raw-sse-buffer.d.ts +58 -0
  12. package/dist/types/apps/debug/raw-sse.d.ts +26 -0
  13. package/dist/types/apps/debug/terminal-info.d.ts +34 -0
  14. package/dist/types/apps/debug/viewer-frame.d.ts +52 -0
  15. package/dist/types/apps/git/avatar.d.ts +9 -0
  16. package/dist/types/apps/git/colors.d.ts +49 -0
  17. package/dist/types/apps/git/diff-pane.d.ts +188 -0
  18. package/dist/types/apps/git/git-tui.d.ts +66 -0
  19. package/dist/types/apps/git/sidebar.d.ts +149 -0
  20. package/dist/types/apps/git/state.d.ts +117 -0
  21. package/dist/types/apps/if-bench-board.d.ts +75 -0
  22. package/dist/types/apps/live-visualizer.d.ts +33 -0
  23. package/dist/types/apps/ps-data.d.ts +51 -0
  24. package/dist/types/apps/ps-top.d.ts +33 -0
  25. package/dist/types/apps/session-picker.d.ts +26 -0
  26. package/dist/types/apps/setup-model-picker.d.ts +13 -0
  27. package/dist/types/apps/standalone-picker.d.ts +42 -0
  28. package/dist/types/autocomplete.d.ts +4 -0
  29. package/dist/types/chat/advisor-message.d.ts +11 -0
  30. package/dist/types/chat/assistant-message.d.ts +106 -0
  31. package/dist/types/chat/background-tan-message.d.ts +9 -0
  32. package/dist/types/chat/bash-execution.d.ts +47 -0
  33. package/dist/types/chat/cache-invalidation-marker.d.ts +50 -0
  34. package/dist/types/chat/chat-transcript-builder.d.ts +50 -0
  35. package/dist/types/chat/collab-prompt-message.d.ts +10 -0
  36. package/dist/types/chat/compaction-summary-message.d.ts +54 -0
  37. package/dist/types/chat/custom-message.d.ts +10 -0
  38. package/dist/types/chat/display-preferences.d.ts +13 -0
  39. package/dist/types/chat/eval-execution.d.ts +29 -0
  40. package/dist/types/chat/execution-shared.d.ts +51 -0
  41. package/dist/types/chat/extension-types.d.ts +28 -0
  42. package/dist/types/chat/hook-message.d.ts +10 -0
  43. package/dist/types/chat/image-loading.d.ts +46 -0
  44. package/dist/types/chat/late-diagnostics-message.d.ts +21 -0
  45. package/dist/types/chat/messages.d.ts +189 -0
  46. package/dist/types/chat/reaction.d.ts +30 -0
  47. package/dist/types/chat/read-target.d.ts +4 -0
  48. package/dist/types/chat/read-tool-group.d.ts +67 -0
  49. package/dist/types/chat/served-model-marker.d.ts +47 -0
  50. package/dist/types/chat/skill-message.d.ts +33 -0
  51. package/dist/types/chat/skill-title-input.d.ts +14 -0
  52. package/dist/types/chat/stripped-tool-calls-placeholder.d.ts +12 -0
  53. package/dist/types/chat/thinking-display.d.ts +12 -0
  54. package/dist/types/chat/todo-reminder.d.ts +13 -0
  55. package/dist/types/chat/tool-execution.d.ts +138 -0
  56. package/dist/types/chat/transcript-browser.d.ts +93 -0
  57. package/dist/types/chat/transcript-entry.d.ts +45 -0
  58. package/dist/types/chat/transcript-outline.d.ts +86 -0
  59. package/dist/types/chat/transcript-render-helpers.d.ts +92 -0
  60. package/dist/types/chat/ttsr-notification.d.ts +23 -0
  61. package/dist/types/chat/user-message.d.ts +52 -0
  62. package/dist/types/chrome/chat-block.d.ts +64 -0
  63. package/dist/types/chrome/collab-qrcode.d.ts +28 -0
  64. package/dist/types/chrome/context-thresholds.d.ts +10 -0
  65. package/dist/types/chrome/countdown-timer.d.ts +14 -0
  66. package/dist/types/chrome/diff.d.ts +14 -0
  67. package/dist/types/chrome/dynamic-border.d.ts +16 -0
  68. package/dist/types/chrome/error-block.d.ts +17 -0
  69. package/dist/types/chrome/form-theme.d.ts +3 -0
  70. package/dist/types/chrome/format.d.ts +12 -0
  71. package/dist/types/chrome/index.d.ts +20 -0
  72. package/dist/types/chrome/keybinding-hints.d.ts +40 -0
  73. package/dist/types/chrome/live-board.d.ts +23 -0
  74. package/dist/types/chrome/local-date.d.ts +4 -0
  75. package/dist/types/chrome/message-divider.d.ts +22 -0
  76. package/dist/types/chrome/message-frame.d.ts +52 -0
  77. package/dist/types/chrome/message-notice.d.ts +35 -0
  78. package/dist/types/chrome/overlay-box.d.ts +74 -0
  79. package/dist/types/chrome/qrcode.d.ts +48 -0
  80. package/dist/types/chrome/segment-track.d.ts +27 -0
  81. package/dist/types/chrome/select-list-mouse-routing.d.ts +4 -0
  82. package/dist/types/chrome/selector-helpers.d.ts +52 -0
  83. package/dist/types/chrome/shared.d.ts +5 -0
  84. package/dist/types/chrome/streaming-panel.d.ts +19 -0
  85. package/dist/types/chrome/tool-activity.d.ts +17 -0
  86. package/dist/types/chrome/transcript-container.d.ts +129 -0
  87. package/dist/types/chrome/transcript-status.d.ts +12 -0
  88. package/dist/types/chrome/visual-truncate.d.ts +19 -0
  89. package/dist/types/components/disclosure.d.ts +42 -0
  90. package/dist/types/components/form.d.ts +130 -0
  91. package/dist/types/components/key-value-list.d.ts +24 -0
  92. package/dist/types/components/layout/geometry.d.ts +73 -0
  93. package/dist/types/components/layout/row.d.ts +55 -0
  94. package/dist/types/components/layout/split-pane.d.ts +80 -0
  95. package/dist/types/components/layout/stack.d.ts +50 -0
  96. package/dist/types/components/menu-selection.d.ts +71 -0
  97. package/dist/types/components/metric.d.ts +44 -0
  98. package/dist/types/components/progress-bar.d.ts +36 -0
  99. package/dist/types/components/scroll-view.d.ts +53 -19
  100. package/dist/types/components/scroll-viewport.d.ts +46 -0
  101. package/dist/types/components/section.d.ts +19 -0
  102. package/dist/types/components/select-list.d.ts +46 -2
  103. package/dist/types/components/settings-list.d.ts +20 -0
  104. package/dist/types/components/table.d.ts +32 -0
  105. package/dist/types/components/tree-view.d.ts +130 -0
  106. package/dist/types/components/wizard-step.d.ts +60 -0
  107. package/dist/types/hotkeys-markdown.d.ts +7 -0
  108. package/dist/types/index.d.ts +17 -0
  109. package/dist/types/keybinding-matchers.d.ts +35 -0
  110. package/dist/types/keys.d.ts +2 -2
  111. package/dist/types/lang-from-path.d.ts +9 -0
  112. package/dist/types/overlays/advisor-config.d.ts +114 -0
  113. package/dist/types/overlays/agent-activity.d.ts +41 -0
  114. package/dist/types/overlays/agent-hub-projection.d.ts +33 -0
  115. package/dist/types/overlays/agent-hub-renderer.d.ts +53 -0
  116. package/dist/types/overlays/agent-hub-types.d.ts +99 -0
  117. package/dist/types/overlays/agent-hub.d.ts +126 -0
  118. package/dist/types/overlays/agent-transcript-viewer.d.ts +71 -0
  119. package/dist/types/overlays/agents-hub.d.ts +72 -0
  120. package/dist/types/overlays/ask-dialog.d.ts +88 -0
  121. package/dist/types/overlays/bordered-loader.d.ts +11 -0
  122. package/dist/types/overlays/btw-history-panel.d.ts +29 -0
  123. package/dist/types/overlays/btw-history.d.ts +17 -0
  124. package/dist/types/overlays/btw-panel.d.ts +26 -0
  125. package/dist/types/overlays/cleanse-panel.d.ts +36 -0
  126. package/dist/types/overlays/codex-reset-fireworks.d.ts +52 -0
  127. package/dist/types/overlays/composer-shape-preview.d.ts +47 -0
  128. package/dist/types/overlays/composer-shape-registry.d.ts +50 -0
  129. package/dist/types/overlays/copy-selector.d.ts +48 -0
  130. package/dist/types/overlays/copy-targets.d.ts +63 -0
  131. package/dist/types/overlays/error-banner.d.ts +13 -0
  132. package/dist/types/overlays/extensions/display-text.d.ts +10 -0
  133. package/dist/types/overlays/extensions/extension-dashboard.d.ts +63 -0
  134. package/dist/types/overlays/extensions/extension-list.d.ts +54 -0
  135. package/dist/types/overlays/extensions/inspector-model.d.ts +109 -0
  136. package/dist/types/overlays/extensions/inspector-panel.d.ts +17 -0
  137. package/dist/types/overlays/extensions/live-tool-session.d.ts +28 -0
  138. package/dist/types/overlays/extensions/mcp-runtime.d.ts +107 -0
  139. package/dist/types/overlays/extensions/state-manager.d.ts +42 -0
  140. package/dist/types/overlays/extensions/types.d.ts +165 -0
  141. package/dist/types/overlays/history-search.d.ts +16 -0
  142. package/dist/types/overlays/hook-editor.d.ts +44 -0
  143. package/dist/types/overlays/hook-input.d.ts +20 -0
  144. package/dist/types/overlays/hook-selector.d.ts +71 -0
  145. package/dist/types/overlays/hub-frame.d.ts +77 -0
  146. package/dist/types/overlays/login-dialog.d.ts +43 -0
  147. package/dist/types/overlays/logout-account-selector.d.ts +15 -0
  148. package/dist/types/overlays/mcp-add-wizard.d.ts +87 -0
  149. package/dist/types/overlays/model-browser.d.ts +213 -0
  150. package/dist/types/overlays/model-hub.d.ts +72 -0
  151. package/dist/types/overlays/model-picker.d.ts +74 -0
  152. package/dist/types/overlays/model-selector.d.ts +52 -0
  153. package/dist/types/overlays/move-overlay.d.ts +39 -0
  154. package/dist/types/overlays/oauth-selector.d.ts +41 -0
  155. package/dist/types/overlays/omfg-panel.d.ts +20 -0
  156. package/dist/types/overlays/pause-screen.d.ts +43 -0
  157. package/dist/types/overlays/plan-review-overlay.d.ts +89 -0
  158. package/dist/types/overlays/plan-save-overlay.d.ts +19 -0
  159. package/dist/types/overlays/plan-toc.d.ts +41 -0
  160. package/dist/types/overlays/plugin-selector.d.ts +28 -0
  161. package/dist/types/overlays/plugin-settings.d.ts +180 -0
  162. package/dist/types/overlays/queue-mode-selector.d.ts +11 -0
  163. package/dist/types/overlays/reset-usage-selector.d.ts +23 -0
  164. package/dist/types/overlays/rewind-selector.d.ts +61 -0
  165. package/dist/types/overlays/running-subagent-badge.d.ts +6 -0
  166. package/dist/types/overlays/session-account-selector.d.ts +24 -0
  167. package/dist/types/overlays/session-info-overlay.d.ts +17 -0
  168. package/dist/types/overlays/session-observer-registry.d.ts +77 -0
  169. package/dist/types/overlays/session-selector.d.ts +131 -0
  170. package/dist/types/overlays/settings-defs.d.ts +119 -0
  171. package/dist/types/overlays/settings-selector.d.ts +73 -0
  172. package/dist/types/overlays/show-images-selector.d.ts +11 -0
  173. package/dist/types/overlays/snapcompact-shape-preview.d.ts +31 -0
  174. package/dist/types/overlays/theme-selector.d.ts +12 -0
  175. package/dist/types/overlays/thinking-selector.d.ts +12 -0
  176. package/dist/types/overlays/tiny-title-download-progress.d.ts +21 -0
  177. package/dist/types/overlays/tree-selector.d.ts +92 -0
  178. package/dist/types/overlays/usage-dashboard.d.ts +94 -0
  179. package/dist/types/overlays/usage-display.d.ts +5 -0
  180. package/dist/types/overlays/usage-row.d.ts +17 -0
  181. package/dist/types/prompt/attachment-chips.d.ts +19 -0
  182. package/dist/types/prompt/composer-attachments.d.ts +71 -0
  183. package/dist/types/prompt/composer-cache.d.ts +37 -0
  184. package/dist/types/prompt/composer.d.ts +159 -0
  185. package/dist/types/prompt/custom-editor.d.ts +261 -0
  186. package/dist/types/prompt/editor-top-gap.d.ts +18 -0
  187. package/dist/types/prompt/emoji-autocomplete.d.ts +16 -0
  188. package/dist/types/prompt/github-ref-autocomplete.d.ts +35 -0
  189. package/dist/types/prompt/gradient-highlight.d.ts +32 -0
  190. package/dist/types/prompt/image-format.d.ts +4 -0
  191. package/dist/types/prompt/image-references.d.ts +26 -0
  192. package/dist/types/prompt/interactive-context-helpers.d.ts +43 -0
  193. package/dist/types/prompt/internal-url-autocomplete.d.ts +56 -0
  194. package/dist/types/prompt/macos-spelling.d.ts +44 -0
  195. package/dist/types/prompt/magic-keyword-boundary.d.ts +9 -0
  196. package/dist/types/prompt/magic-keywords.d.ts +26 -0
  197. package/dist/types/prompt/markdown-prose.d.ts +15 -0
  198. package/dist/types/prompt/model-mention-autocomplete.d.ts +25 -0
  199. package/dist/types/prompt/model-mention-syntax.d.ts +24 -0
  200. package/dist/types/prompt/orchestrate.d.ts +13 -0
  201. package/dist/types/prompt/prompt-action-autocomplete.d.ts +56 -0
  202. package/dist/types/prompt/queue-input.d.ts +8 -0
  203. package/dist/types/prompt/skill-tokens.d.ts +13 -0
  204. package/dist/types/prompt/tools-markdown.d.ts +12 -0
  205. package/dist/types/prompt/ultrathink.d.ts +13 -0
  206. package/dist/types/prompt/usage-amounts.d.ts +26 -0
  207. package/dist/types/prompt/video.d.ts +18 -0
  208. package/dist/types/prompt/welcome.d.ts +83 -0
  209. package/dist/types/prompt/workflow.d.ts +13 -0
  210. package/dist/types/render/code-cell.d.ts +51 -0
  211. package/dist/types/render/file-list.d.ts +22 -0
  212. package/dist/types/render/hyperlink.d.ts +80 -0
  213. package/dist/types/render/index.d.ts +16 -0
  214. package/dist/types/render/output-block.d.ts +54 -0
  215. package/dist/types/render/output-pane.d.ts +80 -0
  216. package/dist/types/render/render-utils.d.ts +312 -0
  217. package/dist/types/render/sixel.d.ts +21 -0
  218. package/dist/types/render/status-line.d.ts +23 -0
  219. package/dist/types/render/tool-card.d.ts +71 -0
  220. package/dist/types/render/tree-list.d.ts +29 -0
  221. package/dist/types/render/types.d.ts +17 -0
  222. package/dist/types/render/utils.d.ts +43 -0
  223. package/dist/types/render/width-aware-text.d.ts +23 -0
  224. package/dist/types/setup/lazy.d.ts +3 -0
  225. package/dist/types/setup/scenes/composer.d.ts +3 -0
  226. package/dist/types/setup/scenes/glyph.d.ts +3 -0
  227. package/dist/types/setup/scenes/model.d.ts +3 -0
  228. package/dist/types/setup/scenes/outro.d.ts +2 -0
  229. package/dist/types/setup/scenes/providers.d.ts +3 -0
  230. package/dist/types/setup/scenes/sign-in.d.ts +21 -0
  231. package/dist/types/setup/scenes/splash.d.ts +11 -0
  232. package/dist/types/setup/scenes/theme.d.ts +3 -0
  233. package/dist/types/setup/scenes/types.d.ts +110 -0
  234. package/dist/types/setup/scenes/web-search.d.ts +22 -0
  235. package/dist/types/setup/setup-version.d.ts +11 -0
  236. package/dist/types/setup/startup-splash.d.ts +9 -0
  237. package/dist/types/setup/wizard-overlay.d.ts +16 -0
  238. package/dist/types/setup/wizard.d.ts +25 -0
  239. package/dist/types/status-line/component.d.ts +182 -0
  240. package/dist/types/status-line/context-usage.d.ts +156 -0
  241. package/dist/types/status-line/footer.d.ts +30 -0
  242. package/dist/types/status-line/git-utils.d.ts +22 -0
  243. package/dist/types/status-line/host.d.ts +129 -0
  244. package/dist/types/status-line/index.d.ts +6 -0
  245. package/dist/types/status-line/loop.d.ts +18 -0
  246. package/dist/types/status-line/metrics.d.ts +21 -0
  247. package/dist/types/status-line/presets.d.ts +3 -0
  248. package/dist/types/status-line/schema.d.ts +15 -0
  249. package/dist/types/status-line/segments.d.ts +5 -0
  250. package/dist/types/status-line/separators.d.ts +3 -0
  251. package/dist/types/status-line/types.d.ts +236 -0
  252. package/dist/types/stdin-buffer.d.ts +4 -0
  253. package/dist/types/theme/color.d.ts +24 -0
  254. package/dist/types/theme/defaults/index.d.ts +9489 -0
  255. package/dist/types/theme/index.d.ts +4 -0
  256. package/dist/types/theme/loader.d.ts +23 -0
  257. package/dist/types/theme/mermaid-cache.d.ts +26 -0
  258. package/dist/types/theme/schema-validation.d.ts +3 -0
  259. package/dist/types/theme/schema.d.ts +25 -0
  260. package/dist/types/theme/session-color.d.ts +43 -0
  261. package/dist/types/theme/shimmer.d.ts +44 -0
  262. package/dist/types/theme/symbols.d.ts +34 -0
  263. package/dist/types/theme/theme-class.d.ts +299 -0
  264. package/dist/types/theme/theme.d.ts +111 -0
  265. package/dist/types/theme/tui-adapters.d.ts +30 -0
  266. package/dist/types/thinking.d.ts +165 -0
  267. package/dist/types/tools/agent-tree.d.ts +36 -0
  268. package/dist/types/tools/ask.d.ts +67 -0
  269. package/dist/types/tools/ast-edit.d.ts +53 -0
  270. package/dist/types/tools/ast-grep.d.ts +52 -0
  271. package/dist/types/tools/autoresearch.d.ts +156 -0
  272. package/dist/types/tools/bash-interactive.d.ts +28 -0
  273. package/dist/types/tools/bash.d.ts +103 -0
  274. package/dist/types/tools/conflict-detect.d.ts +43 -0
  275. package/dist/types/tools/debug.d.ts +60 -0
  276. package/dist/types/tools/default-renderer.d.ts +24 -0
  277. package/dist/types/tools/edit.d.ts +158 -0
  278. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +5 -0
  279. package/dist/types/tools/eval-format/index.d.ts +5 -0
  280. package/dist/types/tools/eval-format/javascript.d.ts +2 -0
  281. package/dist/types/tools/eval-format/python.d.ts +2 -0
  282. package/dist/types/tools/eval.d.ts +94 -0
  283. package/dist/types/tools/fetch.d.ts +42 -0
  284. package/dist/types/tools/gh-format.d.ts +8 -0
  285. package/dist/types/tools/github.d.ts +144 -0
  286. package/dist/types/tools/glob.d.ts +44 -0
  287. package/dist/types/tools/goal.d.ts +42 -0
  288. package/dist/types/tools/grep.d.ts +64 -0
  289. package/dist/types/tools/grouped-file-output.d.ts +71 -0
  290. package/dist/types/tools/hashline-format.d.ts +28 -0
  291. package/dist/types/tools/hub.d.ts +340 -0
  292. package/dist/types/tools/index.d.ts +4 -0
  293. package/dist/types/tools/json-tree.d.ts +59 -0
  294. package/dist/types/tools/line-ranges.d.ts +29 -0
  295. package/dist/types/tools/list-limit.d.ts +15 -0
  296. package/dist/types/tools/lsp.d.ts +72 -0
  297. package/dist/types/tools/match-line-format.d.ts +11 -0
  298. package/dist/types/tools/mcp.d.ts +81 -0
  299. package/dist/types/tools/memory.d.ts +67 -0
  300. package/dist/types/tools/output-meta.d.ts +151 -0
  301. package/dist/types/tools/read.d.ts +139 -0
  302. package/dist/types/tools/renderer.d.ts +98 -0
  303. package/dist/types/tools/report-tool-issue.d.ts +8 -0
  304. package/dist/types/tools/resolve.d.ts +47 -0
  305. package/dist/types/tools/streaming-output.d.ts +282 -0
  306. package/dist/types/tools/subprocess.d.ts +24 -0
  307. package/dist/types/tools/task-repair-args.d.ts +53 -0
  308. package/dist/types/tools/task-yield-assembly.d.ts +20 -0
  309. package/dist/types/tools/task.d.ts +425 -0
  310. package/dist/types/tools/terminal-output.d.ts +8 -0
  311. package/dist/types/tools/think.d.ts +13 -0
  312. package/dist/types/tools/todo.d.ts +118 -0
  313. package/dist/types/tools/tool-errors.d.ts +7 -0
  314. package/dist/types/tools/vibe.d.ts +120 -0
  315. package/dist/types/tools/web-search.d.ts +195 -0
  316. package/dist/types/tools/write.d.ts +76 -0
  317. package/dist/types/tools/xd-url.d.ts +13 -0
  318. package/dist/types/tools/xdev.d.ts +52 -0
  319. package/dist/types/tui.d.ts +18 -0
  320. package/dist/types/utils.d.ts +2 -0
  321. package/package.json +37 -3
  322. package/src/app-keybindings.ts +760 -0
  323. package/src/apps/autoresearch-dashboard.ts +500 -0
  324. package/src/apps/autoresearch-data.ts +64 -0
  325. package/src/apps/cleanse-board.ts +341 -0
  326. package/src/apps/cleanse-picker.ts +57 -0
  327. package/src/apps/debug/log-formatting.ts +53 -0
  328. package/src/apps/debug/log-viewer.ts +868 -0
  329. package/src/apps/debug/protocol-probe.ts +267 -0
  330. package/src/apps/debug/raw-sse-buffer.ts +449 -0
  331. package/src/apps/debug/raw-sse.ts +282 -0
  332. package/src/apps/debug/terminal-info.ts +115 -0
  333. package/src/apps/debug/viewer-frame.ts +174 -0
  334. package/src/apps/git/avatar.ts +52 -0
  335. package/src/apps/git/colors.ts +109 -0
  336. package/src/apps/git/diff-pane.ts +1512 -0
  337. package/src/apps/git/git-tui.ts +889 -0
  338. package/src/apps/git/sidebar.ts +1204 -0
  339. package/src/apps/git/state.ts +113 -0
  340. package/src/apps/if-bench-board.ts +324 -0
  341. package/src/apps/live-visualizer.ts +238 -0
  342. package/src/apps/ps-data.ts +116 -0
  343. package/src/apps/ps-top.ts +422 -0
  344. package/src/apps/session-picker.ts +95 -0
  345. package/src/apps/setup-model-picker.ts +21 -0
  346. package/src/apps/standalone-picker.ts +111 -0
  347. package/src/autocomplete.ts +9 -13
  348. package/src/chat/advisor-message.ts +175 -0
  349. package/src/chat/assistant-message.ts +1214 -0
  350. package/src/chat/background-tan-message.ts +35 -0
  351. package/src/chat/bash-execution.ts +394 -0
  352. package/src/chat/cache-invalidation-marker.ts +94 -0
  353. package/src/chat/chat-transcript-builder.ts +593 -0
  354. package/src/chat/collab-prompt-message.ts +34 -0
  355. package/src/chat/compaction-summary-message.ts +261 -0
  356. package/src/chat/custom-message.ts +23 -0
  357. package/src/chat/display-preferences.ts +24 -0
  358. package/src/chat/eval-execution.ts +172 -0
  359. package/src/chat/execution-shared.ts +113 -0
  360. package/src/chat/extension-types.ts +46 -0
  361. package/src/chat/hook-message.ts +20 -0
  362. package/src/chat/image-loading.ts +122 -0
  363. package/src/chat/late-diagnostics-message.ts +92 -0
  364. package/src/chat/messages.ts +305 -0
  365. package/src/chat/reaction.ts +65 -0
  366. package/src/chat/read-target.ts +7 -0
  367. package/src/chat/read-tool-group.ts +873 -0
  368. package/src/chat/served-model-marker.ts +91 -0
  369. package/src/chat/skill-message.ts +220 -0
  370. package/src/chat/skill-title-input.ts +41 -0
  371. package/src/chat/stripped-tool-calls-placeholder.ts +35 -0
  372. package/src/chat/thinking-display.ts +312 -0
  373. package/src/chat/todo-reminder.ts +33 -0
  374. package/src/chat/tool-execution.ts +1395 -0
  375. package/src/chat/transcript-browser.ts +268 -0
  376. package/src/chat/transcript-entry.ts +96 -0
  377. package/src/chat/transcript-outline.ts +260 -0
  378. package/src/chat/transcript-render-helpers.ts +315 -0
  379. package/src/chat/ttsr-notification.ts +123 -0
  380. package/src/chat/user-message.ts +287 -0
  381. package/src/chrome/chat-block.ts +110 -0
  382. package/src/chrome/collab-qrcode.ts +74 -0
  383. package/src/chrome/context-thresholds.ts +85 -0
  384. package/src/chrome/countdown-timer.ts +74 -0
  385. package/src/chrome/diff.ts +266 -0
  386. package/src/chrome/dynamic-border.ts +36 -0
  387. package/src/chrome/error-block.ts +63 -0
  388. package/src/chrome/form-theme.ts +10 -0
  389. package/src/chrome/format.ts +66 -0
  390. package/src/chrome/index.ts +21 -0
  391. package/src/chrome/keybinding-hints.ts +55 -0
  392. package/src/chrome/live-board.ts +123 -0
  393. package/src/chrome/local-date.ts +20 -0
  394. package/src/chrome/message-divider.ts +51 -0
  395. package/src/chrome/message-frame.ts +164 -0
  396. package/src/chrome/message-notice.ts +103 -0
  397. package/src/chrome/overlay-box.ts +301 -0
  398. package/src/chrome/qrcode.ts +539 -0
  399. package/src/chrome/segment-track.ts +86 -0
  400. package/src/chrome/select-list-mouse-routing.ts +11 -0
  401. package/src/chrome/selector-helpers.ts +119 -0
  402. package/src/chrome/shared.ts +27 -0
  403. package/src/chrome/streaming-panel.ts +72 -0
  404. package/src/chrome/tool-activity.ts +44 -0
  405. package/src/chrome/transcript-container.ts +902 -0
  406. package/src/chrome/transcript-status.ts +27 -0
  407. package/src/chrome/visual-truncate.ts +70 -0
  408. package/src/components/disclosure.ts +235 -0
  409. package/src/components/editor.ts +10 -12
  410. package/src/components/form.ts +566 -0
  411. package/src/components/input.ts +6 -27
  412. package/src/components/key-value-list.ts +88 -0
  413. package/src/components/layout/geometry.ts +202 -0
  414. package/src/components/layout/row.ts +283 -0
  415. package/src/components/layout/split-pane.ts +349 -0
  416. package/src/components/layout/stack.ts +261 -0
  417. package/src/components/markdown.ts +16 -12
  418. package/src/components/menu-selection.ts +248 -0
  419. package/src/components/metric.ts +132 -0
  420. package/src/components/progress-bar.ts +140 -0
  421. package/src/components/scroll-view.ts +372 -70
  422. package/src/components/scroll-viewport.ts +154 -0
  423. package/src/components/section.ts +77 -0
  424. package/src/components/select-list.ts +238 -159
  425. package/src/components/settings-list.ts +103 -80
  426. package/src/components/table.ts +127 -0
  427. package/src/components/tree-view.ts +481 -0
  428. package/src/components/wizard-step.ts +229 -0
  429. package/src/deccara.ts +1 -3
  430. package/src/hotkeys-markdown.ts +100 -0
  431. package/src/index.ts +18 -0
  432. package/src/keybinding-matchers.ts +99 -0
  433. package/src/keys.ts +2 -2
  434. package/src/lang-from-path.ts +252 -0
  435. package/src/overlays/advisor-config.ts +761 -0
  436. package/src/overlays/agent-activity.ts +113 -0
  437. package/src/overlays/agent-hub-projection.ts +250 -0
  438. package/src/overlays/agent-hub-renderer.ts +281 -0
  439. package/src/overlays/agent-hub-types.ts +74 -0
  440. package/src/overlays/agent-hub.ts +1562 -0
  441. package/src/overlays/agent-transcript-viewer.ts +669 -0
  442. package/src/overlays/agents-hub.ts +1203 -0
  443. package/src/overlays/ask-dialog.ts +1202 -0
  444. package/src/overlays/bordered-loader.ts +41 -0
  445. package/src/overlays/btw-history-panel.ts +591 -0
  446. package/src/overlays/btw-history.ts +33 -0
  447. package/src/overlays/btw-panel.ts +167 -0
  448. package/src/overlays/cleanse-panel.ts +167 -0
  449. package/src/overlays/codex-reset-fireworks.ts +375 -0
  450. package/src/overlays/composer-shape-preview.ts +137 -0
  451. package/src/overlays/composer-shape-registry.ts +82 -0
  452. package/src/overlays/copy-selector.ts +618 -0
  453. package/src/overlays/copy-targets.ts +210 -0
  454. package/src/overlays/error-banner.ts +39 -0
  455. package/src/overlays/extensions/display-text.ts +39 -0
  456. package/src/overlays/extensions/extension-dashboard.ts +645 -0
  457. package/src/overlays/extensions/extension-list.ts +667 -0
  458. package/src/overlays/extensions/inspector-model.ts +484 -0
  459. package/src/overlays/extensions/inspector-panel.ts +669 -0
  460. package/src/overlays/extensions/live-tool-session.ts +67 -0
  461. package/src/overlays/extensions/mcp-runtime.ts +289 -0
  462. package/src/overlays/extensions/state-manager.ts +337 -0
  463. package/src/overlays/extensions/types.ts +208 -0
  464. package/src/overlays/history-search.ts +268 -0
  465. package/src/overlays/hook-editor.ts +272 -0
  466. package/src/overlays/hook-input.ts +84 -0
  467. package/src/overlays/hook-selector.ts +635 -0
  468. package/src/overlays/hub-frame.ts +262 -0
  469. package/src/overlays/login-dialog.ts +231 -0
  470. package/src/overlays/logout-account-selector.ts +115 -0
  471. package/src/overlays/mcp-add-wizard.ts +1436 -0
  472. package/src/overlays/model-browser.ts +1176 -0
  473. package/src/overlays/model-hub.ts +2279 -0
  474. package/src/overlays/model-picker.ts +285 -0
  475. package/src/overlays/model-selector.ts +107 -0
  476. package/src/overlays/move-overlay.ts +197 -0
  477. package/src/overlays/oauth-selector.ts +446 -0
  478. package/src/overlays/omfg-panel.ts +136 -0
  479. package/src/overlays/pause-screen.ts +198 -0
  480. package/src/overlays/plan-review-overlay.ts +1227 -0
  481. package/src/overlays/plan-save-overlay.ts +74 -0
  482. package/src/overlays/plan-toc.ts +139 -0
  483. package/src/overlays/plugin-selector.ts +97 -0
  484. package/src/overlays/plugin-settings.ts +839 -0
  485. package/src/overlays/queue-mode-selector.ts +55 -0
  486. package/src/overlays/reset-usage-selector.ts +170 -0
  487. package/src/overlays/rewind-selector.ts +499 -0
  488. package/src/overlays/running-subagent-badge.ts +19 -0
  489. package/src/overlays/session-account-selector.ts +68 -0
  490. package/src/overlays/session-info-overlay.ts +96 -0
  491. package/src/overlays/session-observer-registry.ts +289 -0
  492. package/src/overlays/session-selector.ts +1137 -0
  493. package/src/overlays/settings-defs.ts +315 -0
  494. package/src/overlays/settings-selector.ts +1415 -0
  495. package/src/overlays/show-images-selector.ts +44 -0
  496. package/src/overlays/snapcompact-shape-preview-doc.md +14 -0
  497. package/src/overlays/snapcompact-shape-preview.ts +192 -0
  498. package/src/overlays/theme-selector.ts +62 -0
  499. package/src/overlays/thinking-selector.ts +51 -0
  500. package/src/overlays/tiny-title-download-progress.ts +102 -0
  501. package/src/overlays/tree-selector.ts +1121 -0
  502. package/src/overlays/usage-dashboard.ts +629 -0
  503. package/src/overlays/usage-display.ts +45 -0
  504. package/src/overlays/usage-row.ts +118 -0
  505. package/src/prompt/attachment-chips.ts +201 -0
  506. package/src/prompt/composer-attachments.ts +280 -0
  507. package/src/prompt/composer-cache.ts +304 -0
  508. package/src/prompt/composer.ts +934 -0
  509. package/src/prompt/custom-editor.ts +1392 -0
  510. package/src/prompt/data/emojis.json +1 -0
  511. package/src/prompt/editor-top-gap.ts +28 -0
  512. package/src/prompt/emoji-autocomplete.ts +285 -0
  513. package/src/prompt/github-ref-autocomplete.ts +75 -0
  514. package/src/prompt/gradient-highlight.ts +101 -0
  515. package/src/prompt/image-format.ts +28 -0
  516. package/src/prompt/image-references.ts +102 -0
  517. package/src/prompt/interactive-context-helpers.ts +126 -0
  518. package/src/prompt/internal-url-autocomplete.ts +155 -0
  519. package/src/prompt/macos-spelling.ts +456 -0
  520. package/src/prompt/magic-keyword-boundary.ts +23 -0
  521. package/src/prompt/magic-keywords.ts +42 -0
  522. package/src/prompt/markdown-prose.ts +245 -0
  523. package/src/prompt/model-mention-autocomplete.ts +94 -0
  524. package/src/prompt/model-mention-syntax.ts +41 -0
  525. package/src/prompt/orchestrate.ts +26 -0
  526. package/src/prompt/prompt-action-autocomplete.ts +337 -0
  527. package/src/prompt/queue-input.ts +132 -0
  528. package/src/prompt/skill-tokens.ts +36 -0
  529. package/src/prompt/tips.txt +27 -0
  530. package/src/prompt/tools-markdown.ts +29 -0
  531. package/src/prompt/ultrathink.ts +26 -0
  532. package/src/prompt/usage-amounts.ts +99 -0
  533. package/src/prompt/video.ts +47 -0
  534. package/src/prompt/welcome.ts +605 -0
  535. package/src/prompt/workflow.ts +26 -0
  536. package/src/render/code-cell.ts +244 -0
  537. package/src/render/file-list.ts +58 -0
  538. package/src/render/hyperlink.ts +192 -0
  539. package/src/render/index.ts +17 -0
  540. package/src/render/output-block.ts +267 -0
  541. package/src/render/output-pane.ts +299 -0
  542. package/src/render/render-utils.ts +1182 -0
  543. package/src/render/sixel.ts +69 -0
  544. package/src/render/status-line.ts +55 -0
  545. package/src/render/tool-card.ts +272 -0
  546. package/src/render/tree-list.ts +188 -0
  547. package/src/render/types.ts +19 -0
  548. package/src/render/utils.ts +109 -0
  549. package/src/render/width-aware-text.ts +60 -0
  550. package/src/setup/lazy.ts +17 -0
  551. package/src/setup/scenes/composer.ts +127 -0
  552. package/src/setup/scenes/glyph.ts +120 -0
  553. package/src/setup/scenes/model.ts +138 -0
  554. package/src/setup/scenes/outro.ts +24 -0
  555. package/src/setup/scenes/providers.ts +107 -0
  556. package/src/setup/scenes/sign-in.ts +373 -0
  557. package/src/setup/scenes/splash.ts +192 -0
  558. package/src/setup/scenes/theme.ts +343 -0
  559. package/src/setup/scenes/types.ts +111 -0
  560. package/src/setup/scenes/web-search.ts +165 -0
  561. package/src/setup/setup-version.ts +11 -0
  562. package/src/setup/startup-splash.ts +107 -0
  563. package/src/setup/wizard-overlay.ts +319 -0
  564. package/src/setup/wizard.ts +101 -0
  565. package/src/status-line/component.ts +3003 -0
  566. package/src/status-line/context-usage.ts +688 -0
  567. package/src/status-line/footer.ts +323 -0
  568. package/src/status-line/git-utils.ts +42 -0
  569. package/src/status-line/host.ts +97 -0
  570. package/src/status-line/index.ts +6 -0
  571. package/src/status-line/loop.ts +28 -0
  572. package/src/status-line/metrics.ts +94 -0
  573. package/src/status-line/presets.ts +107 -0
  574. package/src/status-line/schema.ts +57 -0
  575. package/src/status-line/segments.ts +934 -0
  576. package/src/status-line/separators.ts +55 -0
  577. package/src/status-line/types.ts +227 -0
  578. package/src/stdin-buffer.ts +4 -2
  579. package/src/theme/color.ts +95 -0
  580. package/src/theme/dark.json +95 -0
  581. package/src/theme/defaults/alabaster.json +93 -0
  582. package/src/theme/defaults/amethyst.json +96 -0
  583. package/src/theme/defaults/anthracite.json +93 -0
  584. package/src/theme/defaults/basalt.json +91 -0
  585. package/src/theme/defaults/birch.json +95 -0
  586. package/src/theme/defaults/dark-abyss.json +91 -0
  587. package/src/theme/defaults/dark-arctic.json +104 -0
  588. package/src/theme/defaults/dark-aurora.json +95 -0
  589. package/src/theme/defaults/dark-catppuccin.json +107 -0
  590. package/src/theme/defaults/dark-cavern.json +91 -0
  591. package/src/theme/defaults/dark-celestial.json +106 -0
  592. package/src/theme/defaults/dark-copper.json +95 -0
  593. package/src/theme/defaults/dark-cosmos.json +90 -0
  594. package/src/theme/defaults/dark-cyberpunk.json +102 -0
  595. package/src/theme/defaults/dark-dracula.json +98 -0
  596. package/src/theme/defaults/dark-eclipse.json +91 -0
  597. package/src/theme/defaults/dark-ember.json +95 -0
  598. package/src/theme/defaults/dark-equinox.json +90 -0
  599. package/src/theme/defaults/dark-forest.json +96 -0
  600. package/src/theme/defaults/dark-github.json +105 -0
  601. package/src/theme/defaults/dark-gruvbox.json +112 -0
  602. package/src/theme/defaults/dark-lavender.json +95 -0
  603. package/src/theme/defaults/dark-lunar.json +89 -0
  604. package/src/theme/defaults/dark-midnight.json +95 -0
  605. package/src/theme/defaults/dark-monochrome.json +94 -0
  606. package/src/theme/defaults/dark-monokai.json +98 -0
  607. package/src/theme/defaults/dark-nebula.json +90 -0
  608. package/src/theme/defaults/dark-nord.json +98 -0
  609. package/src/theme/defaults/dark-ocean.json +101 -0
  610. package/src/theme/defaults/dark-one.json +100 -0
  611. package/src/theme/defaults/dark-poimandres.json +115 -0
  612. package/src/theme/defaults/dark-rainforest.json +91 -0
  613. package/src/theme/defaults/dark-reef.json +91 -0
  614. package/src/theme/defaults/dark-retro.json +92 -0
  615. package/src/theme/defaults/dark-rose-pine.json +96 -0
  616. package/src/theme/defaults/dark-sakura.json +95 -0
  617. package/src/theme/defaults/dark-slate.json +95 -0
  618. package/src/theme/defaults/dark-solarized.json +97 -0
  619. package/src/theme/defaults/dark-solstice.json +90 -0
  620. package/src/theme/defaults/dark-starfall.json +91 -0
  621. package/src/theme/defaults/dark-sunset.json +99 -0
  622. package/src/theme/defaults/dark-swamp.json +90 -0
  623. package/src/theme/defaults/dark-synthwave.json +103 -0
  624. package/src/theme/defaults/dark-taiga.json +91 -0
  625. package/src/theme/defaults/dark-terminal.json +95 -0
  626. package/src/theme/defaults/dark-tokyo-night.json +101 -0
  627. package/src/theme/defaults/dark-tundra.json +91 -0
  628. package/src/theme/defaults/dark-twilight.json +91 -0
  629. package/src/theme/defaults/dark-volcanic.json +91 -0
  630. package/src/theme/defaults/graphite.json +92 -0
  631. package/src/theme/defaults/index.ts +201 -0
  632. package/src/theme/defaults/light-arctic.json +107 -0
  633. package/src/theme/defaults/light-aurora-day.json +91 -0
  634. package/src/theme/defaults/light-canyon.json +91 -0
  635. package/src/theme/defaults/light-catppuccin.json +106 -0
  636. package/src/theme/defaults/light-cirrus.json +90 -0
  637. package/src/theme/defaults/light-coral.json +95 -0
  638. package/src/theme/defaults/light-cyberpunk.json +96 -0
  639. package/src/theme/defaults/light-dawn.json +90 -0
  640. package/src/theme/defaults/light-dunes.json +91 -0
  641. package/src/theme/defaults/light-eucalyptus.json +95 -0
  642. package/src/theme/defaults/light-forest.json +100 -0
  643. package/src/theme/defaults/light-frost.json +95 -0
  644. package/src/theme/defaults/light-github.json +115 -0
  645. package/src/theme/defaults/light-glacier.json +91 -0
  646. package/src/theme/defaults/light-gruvbox.json +108 -0
  647. package/src/theme/defaults/light-haze.json +90 -0
  648. package/src/theme/defaults/light-honeycomb.json +95 -0
  649. package/src/theme/defaults/light-lagoon.json +91 -0
  650. package/src/theme/defaults/light-lavender.json +95 -0
  651. package/src/theme/defaults/light-meadow.json +91 -0
  652. package/src/theme/defaults/light-mint.json +95 -0
  653. package/src/theme/defaults/light-monochrome.json +101 -0
  654. package/src/theme/defaults/light-ocean.json +99 -0
  655. package/src/theme/defaults/light-one.json +99 -0
  656. package/src/theme/defaults/light-opal.json +91 -0
  657. package/src/theme/defaults/light-orchard.json +91 -0
  658. package/src/theme/defaults/light-paper.json +95 -0
  659. package/src/theme/defaults/light-poimandres.json +115 -0
  660. package/src/theme/defaults/light-prism.json +90 -0
  661. package/src/theme/defaults/light-retro.json +98 -0
  662. package/src/theme/defaults/light-sand.json +95 -0
  663. package/src/theme/defaults/light-savanna.json +91 -0
  664. package/src/theme/defaults/light-solarized.json +102 -0
  665. package/src/theme/defaults/light-soleil.json +90 -0
  666. package/src/theme/defaults/light-sunset.json +99 -0
  667. package/src/theme/defaults/light-synthwave.json +98 -0
  668. package/src/theme/defaults/light-tokyo-night.json +111 -0
  669. package/src/theme/defaults/light-wetland.json +91 -0
  670. package/src/theme/defaults/light-zenith.json +89 -0
  671. package/src/theme/defaults/limestone.json +94 -0
  672. package/src/theme/defaults/mahogany.json +97 -0
  673. package/src/theme/defaults/marble.json +93 -0
  674. package/src/theme/defaults/obsidian.json +91 -0
  675. package/src/theme/defaults/onyx.json +91 -0
  676. package/src/theme/defaults/pearl.json +93 -0
  677. package/src/theme/defaults/porcelain.json +91 -0
  678. package/src/theme/defaults/quartz.json +96 -0
  679. package/src/theme/defaults/sandstone.json +95 -0
  680. package/src/theme/defaults/titanium.json +90 -0
  681. package/src/theme/index.ts +5 -0
  682. package/src/theme/light.json +93 -0
  683. package/src/theme/loader.ts +200 -0
  684. package/src/theme/mermaid-cache.ts +92 -0
  685. package/src/theme/schema-validation.ts +119 -0
  686. package/src/theme/schema.ts +168 -0
  687. package/src/theme/session-color.ts +253 -0
  688. package/src/theme/shimmer.ts +307 -0
  689. package/src/theme/symbols.ts +1433 -0
  690. package/src/theme/theme-class.ts +787 -0
  691. package/src/theme/theme-schema.json +463 -0
  692. package/src/theme/theme.ts +810 -0
  693. package/src/theme/tui-adapters.ts +339 -0
  694. package/src/thinking.ts +380 -0
  695. package/src/tools/agent-tree.ts +128 -0
  696. package/src/tools/ask.ts +482 -0
  697. package/src/tools/ast-edit.ts +210 -0
  698. package/src/tools/ast-grep.ts +171 -0
  699. package/src/tools/autoresearch.ts +287 -0
  700. package/src/tools/bash-interactive.ts +295 -0
  701. package/src/tools/bash.ts +468 -0
  702. package/src/tools/conflict-detect.ts +77 -0
  703. package/src/tools/debug.ts +167 -0
  704. package/src/tools/default-renderer.ts +172 -0
  705. package/src/tools/edit.ts +1258 -0
  706. package/src/tools/eval-format/code-mode-declarations.ts +61 -0
  707. package/src/tools/eval-format/index.ts +16 -0
  708. package/src/tools/eval-format/javascript.ts +952 -0
  709. package/src/tools/eval-format/python.ts +544 -0
  710. package/src/tools/eval.ts +835 -0
  711. package/src/tools/fetch.ts +226 -0
  712. package/src/tools/gh-format.ts +18 -0
  713. package/src/tools/github.ts +686 -0
  714. package/src/tools/glob.ts +213 -0
  715. package/src/tools/goal.ts +153 -0
  716. package/src/tools/grep.ts +385 -0
  717. package/src/tools/grouped-file-output.ts +211 -0
  718. package/src/tools/hashline-format.ts +59 -0
  719. package/src/tools/hub.ts +1488 -0
  720. package/src/tools/index.ts +71 -0
  721. package/src/tools/json-tree.ts +388 -0
  722. package/src/tools/line-ranges.ts +98 -0
  723. package/src/tools/list-limit.ts +43 -0
  724. package/src/tools/lsp.ts +702 -0
  725. package/src/tools/match-line-format.ts +20 -0
  726. package/src/tools/mcp.ts +309 -0
  727. package/src/tools/memory.ts +220 -0
  728. package/src/tools/output-meta.ts +367 -0
  729. package/src/tools/read.ts +628 -0
  730. package/src/tools/renderer.ts +107 -0
  731. package/src/tools/report-tool-issue.ts +13 -0
  732. package/src/tools/resolve.ts +139 -0
  733. package/src/tools/streaming-output.ts +1576 -0
  734. package/src/tools/subprocess.ts +39 -0
  735. package/src/tools/task-repair-args.ts +118 -0
  736. package/src/tools/task-yield-assembly.ts +147 -0
  737. package/src/tools/task.ts +2110 -0
  738. package/src/tools/terminal-output.ts +153 -0
  739. package/src/tools/think.ts +28 -0
  740. package/src/tools/todo.ts +580 -0
  741. package/src/tools/tool-errors.ts +15 -0
  742. package/src/tools/vibe.ts +420 -0
  743. package/src/tools/web-search.ts +409 -0
  744. package/src/tools/write.ts +511 -0
  745. package/src/tools/xd-url.ts +28 -0
  746. package/src/tools/xdev.ts +201 -0
  747. package/src/tui.ts +57 -4
  748. package/src/utils.ts +8 -0
@@ -0,0 +1,1512 @@
1
+ /**
2
+ * Diff pane of the git TUI, with four view modes:
3
+ *
4
+ * - `split` (default): old file left, new file right, aligned line-by-line
5
+ * with word-level intraline emphasis and faint fillers for one-sided rows.
6
+ * - `inline`: the full file with deletions/additions stacked in place.
7
+ * - `hunk`: only the changed regions (3 context lines), one block per hunk
8
+ * with `@@` headers and per-hunk stage/unstage/discard buttons.
9
+ * - `file`: the current (new) side only, plain syntax-highlighted view.
10
+ *
11
+ * The right edge carries a minimap-style scrollbar encoding change density
12
+ * (deletions > additions > changes > context, hunk headers in accent) with
13
+ * the visible viewport brightened; clicking it seeks. Long lines either pan
14
+ * horizontally (`←`/`→`) or soft-wrap when word wrap is enabled.
15
+ */
16
+ import type { DiffStreamResult, HighlightStream } from "@oh-my-pi/pi-natives";
17
+ import { diffWords, structuredPatchHunks } from "@oh-my-pi/pi-natives";
18
+ import { Image, type ImageBudget } from "../../components/image";
19
+ import { clampScrollOffset, scrollOffsetForRow, viewportRange } from "../../components/scroll-viewport";
20
+ import { centerLine, sliceWithWidth, truncateToWidth, visibleWidth } from "../../utils";
21
+ import { formatBytes } from "@oh-my-pi/pi-utils";
22
+ import { sanitizeDisplayText } from "../../overlays/extensions/display-text";
23
+ import { getLanguageFromPath } from "../../lang-from-path";
24
+ import { createHighlightStream, theme } from "../../theme/theme";
25
+ import { bgAnsiHex, canvasHex, fgAnsiHex, mixHex, pill, selectionBgAnsi, textHex, withBg } from "./colors";
26
+ import { DIFF_CONTEXT_LINES, type FileAssetSide, type FileStreamUpdate } from "./state";
27
+
28
+ /** Column ranges (inclusive start, exclusive end) carrying intraline emphasis. */
29
+ type MarkRanges = readonly (readonly [number, number])[];
30
+
31
+ type RowKind = "context" | "change" | "add" | "del";
32
+
33
+ interface DiffRow {
34
+ readonly kind: RowKind;
35
+ readonly oldNum?: number;
36
+ readonly newNum?: number;
37
+ /** Tab-expanded line for each side ("" when absent). */
38
+ readonly oldText: string;
39
+ readonly newText: string;
40
+ readonly oldWidth: number;
41
+ readonly newWidth: number;
42
+ readonly oldMarks?: MarkRanges;
43
+ readonly newMarks?: MarkRanges;
44
+ /** Raw (untab-expanded) source lines, for patch construction. */
45
+ readonly oldRaw?: string;
46
+ readonly newRaw?: string;
47
+ }
48
+
49
+ /** One changed region with its applyable patch for hunk-level staging. */
50
+ export interface HunkBlock {
51
+ /** `@@ -a,b +c,d @@` header shown above the block. */
52
+ readonly header: string;
53
+ /** Standalone patch text for this hunk, or "" when not applyable. */
54
+ readonly patch: string;
55
+ readonly rows: readonly DiffRow[];
56
+ }
57
+
58
+ /** Aligned diff document produced by {@link buildDiffDocument}. */
59
+ export interface DiffDocument {
60
+ readonly filePath: string;
61
+ readonly rows: readonly DiffRow[];
62
+ readonly hunks: readonly HunkBlock[];
63
+ /** Tab-expanded new-side lines for the `file` view. */
64
+ readonly fileLines: readonly { text: string; width: number }[];
65
+ /** Tab-expanded source lines, retained for progressive syntax highlighting. */
66
+ readonly oldDisplayLines: readonly string[];
67
+ /** Tab-expanded source lines, retained for progressive syntax highlighting. */
68
+ readonly newDisplayLines: readonly string[];
69
+ readonly additions: number;
70
+ readonly deletions: number;
71
+ readonly gutterWidth: number;
72
+ readonly maxLineWidth: number;
73
+ /** False when built with whitespace-ignore: hunk patches would not apply. */
74
+ readonly canPatch: boolean;
75
+ /** Raw input texts and newline state, for line-selection patches. */
76
+ readonly rawOld: string;
77
+ readonly rawNew: string;
78
+ readonly oldEndsNewline: boolean;
79
+ readonly newEndsNewline: boolean;
80
+ /** Index into {@link rows} for each 1-based new-file line number. */
81
+ readonly rowIndexByNewLine: readonly number[];
82
+ }
83
+
84
+ /** How the pane presents the document. */
85
+ export type ViewMode = "split" | "inline" | "hunk" | "file";
86
+
87
+ /** Hunk-button actions raised to the root component. */
88
+ export type HunkAction = "stage" | "unstage" | "discard";
89
+
90
+ /** Maximum source lines highlighted before yielding control back to the TUI. */
91
+ const HIGHLIGHT_BATCH_LINES = 32;
92
+ /** Cap on intraline word-diff pairs per document. */
93
+ const INTRALINE_PAIR_LIMIT = 1_500;
94
+
95
+ function intralineMarks(oldLine: string, newLine: string): { old: MarkRanges; new: MarkRanges } {
96
+ const oldRanges: [number, number][] = [];
97
+ const newRanges: [number, number][] = [];
98
+ let oldCol = 0;
99
+ let newCol = 0;
100
+ for (const change of diffWords(oldLine, newLine)) {
101
+ const width = visibleWidth(change.value);
102
+ if (change.removed) {
103
+ pushRange(oldRanges, oldCol, oldCol + width);
104
+ oldCol += width;
105
+ } else if (change.added) {
106
+ pushRange(newRanges, newCol, newCol + width);
107
+ newCol += width;
108
+ } else {
109
+ oldCol += width;
110
+ newCol += width;
111
+ }
112
+ }
113
+ return { old: oldRanges, new: newRanges };
114
+ }
115
+
116
+ function pushRange(ranges: [number, number][], start: number, end: number): void {
117
+ if (end <= start) return;
118
+ const last = ranges[ranges.length - 1];
119
+ if (last && start <= last[1]) last[1] = Math.max(last[1], end);
120
+ else ranges.push([start, end]);
121
+ }
122
+
123
+ /**
124
+ * Whitespace handling for {@link buildDiffDocument}:
125
+ *
126
+ * - `off`: exact, byte-level alignment.
127
+ * - `whitespace`: align ignoring leading/trailing whitespace (disables hunk
128
+ * patches — the alignment no longer matches git's view of the file).
129
+ * - `formatting`: exact alignment, but changed blocks that only move
130
+ * whitespace around (indentation, line splits/joins, blank lines) or only
131
+ * touch import statements (ts/js, rust, go) are demoted to context.
132
+ */
133
+ export type WhitespaceMode = "off" | "whitespace" | "formatting";
134
+
135
+ /** Languages with import-statement demotion under formatting-ignore. */
136
+ type ImportLang = "ts" | "rust" | "go";
137
+
138
+ const IMPORT_LANG_BY_EXT: Record<string, ImportLang> = {
139
+ ts: "ts",
140
+ tsx: "ts",
141
+ mts: "ts",
142
+ cts: "ts",
143
+ js: "ts",
144
+ jsx: "ts",
145
+ mjs: "ts",
146
+ cjs: "ts",
147
+ rs: "rust",
148
+ go: "go",
149
+ };
150
+
151
+ /**
152
+ * Per-language import recognition. `starter` marks a line that begins an
153
+ * import statement; `continuation` admits inner/closing lines of multi-line
154
+ * import blocks. A changed block is import-only when every changed line
155
+ * matches either pattern and at least one matches `starter`.
156
+ * `removable` matches self-contained import statement lines that may be
157
+ * dropped before re-comparing a mixed block as a pure reflow.
158
+ */
159
+ const IMPORT_LINES: Record<ImportLang, { starter: RegExp; continuation: RegExp; removable: RegExp }> = {
160
+ ts: {
161
+ starter: /^import\b|^export\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*["']/,
162
+ continuation:
163
+ /^(?:type\s+)?[\w$]+(?:\s+as\s+[\w$]+)?\s*,?$|^\}\s*from\s*["'][^"']*["'](?:\s*with\s*\{[^}]*\})?\s*;?$/,
164
+ removable:
165
+ /^import\b[^"']*["'][^"']*["'][^"']*$|^export\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*["'][^"']*["']\s*;?$|^\}\s*from\s*["'][^"']*["'](?:\s*with\s*\{[^}]*\})?\s*;?$/,
166
+ },
167
+ rust: {
168
+ starter: /^(?:pub(?:\([^)]*\))?\s+)?use\b|^extern\s+crate\b/,
169
+ continuation: /^[\w:*{},\s]+;?$/,
170
+ removable: /^(?:pub(?:\([^)]*\))?\s+)?use\b[^;]*;$|^extern\s+crate\s+[^;]+;$/,
171
+ },
172
+ go: {
173
+ starter: /^import\b/,
174
+ continuation: /^(?:[\w.]+\s+)?"[^"]+"\s*,?$|^[()]$/,
175
+ removable: /^import\b.*$|^(?:[\w.]+\s+)?"[^"]+"$|^\)$/,
176
+ },
177
+ };
178
+
179
+ /** Options for {@link buildDiffDocument}. */
180
+ export interface DiffBuildOptions {
181
+ /** Whitespace handling; defaults to `off`. */
182
+ whitespace?: WhitespaceMode;
183
+ /** Exact runs/hunks already computed by the native streaming differ. */
184
+ streamResult?: DiffStreamResult;
185
+ }
186
+
187
+ /** Build the aligned document for one file from its raw old/new texts. */
188
+ export function buildDiffDocument(
189
+ oldRaw: string,
190
+ newRaw: string,
191
+ filePath: string,
192
+ options: DiffBuildOptions = {},
193
+ ): DiffDocument {
194
+ const mode = options.whitespace ?? "off";
195
+ const ignoreWs = mode === "whitespace";
196
+ const ignoreFormatting = mode === "formatting";
197
+ const dot = filePath.lastIndexOf(".");
198
+ const importLang =
199
+ ignoreFormatting && dot >= 0 ? (IMPORT_LANG_BY_EXT[filePath.slice(dot + 1).toLowerCase()] ?? null) : null;
200
+ const oldLines = oldRaw.length === 0 ? [] : oldRaw.replace(/\n$/, "").split("\n");
201
+ const newLines = newRaw.length === 0 ? [] : newRaw.replace(/\n$/, "").split("\n");
202
+ const oldPlain = oldLines.map(sanitizeDisplayText);
203
+ const newPlain = newLines.map(sanitizeDisplayText);
204
+
205
+ // Alignment basis: raw lines, or trimmed lines when ignoring whitespace.
206
+ // Line numbers stay 1:1 with the raw text either way.
207
+ // The raw text is passed through untouched so trailing-newline state is
208
+ // preserved — hunk patches must match `git apply`'s view of the file.
209
+ const oldBasis = ignoreWs
210
+ ? oldLines.map(line => line.trim()).join("\n") + (oldRaw.endsWith("\n") ? "\n" : "")
211
+ : oldRaw;
212
+ const newBasis = ignoreWs
213
+ ? newLines.map(line => line.trim()).join("\n") + (newRaw.endsWith("\n") ? "\n" : "")
214
+ : newRaw;
215
+
216
+ let additions = 0;
217
+ let deletions = 0;
218
+ let intralinePairs = 0;
219
+ let maxLineWidth = 0;
220
+ const touch = (line: string | undefined): void => {
221
+ if (line !== undefined) maxLineWidth = Math.max(maxLineWidth, line.length);
222
+ };
223
+
224
+ const makeRow = (kind: RowKind, oldIdx: number | undefined, newIdx: number | undefined): DiffRow => {
225
+ let marks: { old: MarkRanges; new: MarkRanges } | undefined;
226
+ if (kind === "change" && oldIdx !== undefined && newIdx !== undefined && intralinePairs < INTRALINE_PAIR_LIMIT) {
227
+ intralinePairs++;
228
+ marks = intralineMarks(oldPlain[oldIdx] ?? "", newPlain[newIdx] ?? "");
229
+ }
230
+ return {
231
+ kind,
232
+ oldNum: oldIdx === undefined ? undefined : oldIdx + 1,
233
+ newNum: newIdx === undefined ? undefined : newIdx + 1,
234
+ oldText: oldIdx === undefined ? "" : (oldPlain[oldIdx] ?? ""),
235
+ newText: newIdx === undefined ? "" : (newPlain[newIdx] ?? ""),
236
+ oldWidth: oldIdx === undefined ? 0 : visibleWidth(oldPlain[oldIdx] ?? ""),
237
+ newWidth: newIdx === undefined ? 0 : visibleWidth(newPlain[newIdx] ?? ""),
238
+ oldMarks: marks?.old,
239
+ newMarks: marks?.new,
240
+ oldRaw: oldIdx === undefined ? undefined : oldLines[oldIdx],
241
+ newRaw: newIdx === undefined ? undefined : newLines[newIdx],
242
+ };
243
+ };
244
+ /** True when a changed block should demote to context under formatting-ignore. */
245
+ const demoteBlock = (dels: number[], adds: number[]): boolean => {
246
+ if (!ignoreFormatting) return false;
247
+ const stripBlock = (lines: string[], indices: number[], removable: RegExp | null): string => {
248
+ let out = "";
249
+ for (const idx of indices) {
250
+ const raw = lines[idx] ?? "";
251
+ if (removable?.test(raw.trim())) continue;
252
+ out += raw.replace(/\s+/g, "");
253
+ }
254
+ return out;
255
+ };
256
+ if (stripBlock(oldLines, dels, null) === stripBlock(newLines, adds, null)) return true;
257
+ if (!importLang) return false;
258
+ const { starter, continuation, removable } = IMPORT_LINES[importLang];
259
+ let sawImport = false;
260
+ const importish = (lines: string[], indices: number[]): boolean => {
261
+ for (const idx of indices) {
262
+ const line = (lines[idx] ?? "").trim();
263
+ if (line.length === 0) continue;
264
+ if (starter.test(line)) sawImport = true;
265
+ else if (!continuation.test(line)) return false;
266
+ }
267
+ return true;
268
+ };
269
+ if (importish(oldLines, dels) && importish(newLines, adds) && sawImport) return true;
270
+ // Mixed block: whole-line import statements dropped, the remainder must
271
+ // be a pure whitespace reflow for the block to stay hidden.
272
+ return stripBlock(oldLines, dels, removable) === stripBlock(newLines, adds, removable);
273
+ };
274
+
275
+ /** Pair a pending del/add block into rows; ignored blocks become context. */
276
+ const flushBlock = (rows: DiffRow[], dels: number[], adds: number[], count: boolean): void => {
277
+ if (dels.length === 0 && adds.length === 0) return;
278
+ const paired = Math.min(dels.length, adds.length);
279
+ if (demoteBlock(dels, adds)) {
280
+ for (let i = 0; i < paired; i++) rows.push(makeRow("context", dels[i], adds[i]));
281
+ for (let i = paired; i < dels.length; i++) rows.push(makeRow("context", dels[i], undefined));
282
+ for (let i = paired; i < adds.length; i++) rows.push(makeRow("context", undefined, adds[i]));
283
+ return;
284
+ }
285
+ if (count) {
286
+ deletions += dels.length;
287
+ additions += adds.length;
288
+ }
289
+ for (let i = 0; i < paired; i++) rows.push(makeRow("change", dels[i], adds[i]));
290
+ for (let i = paired; i < dels.length; i++) rows.push(makeRow("del", dels[i], undefined));
291
+ for (let i = paired; i < adds.length; i++) rows.push(makeRow("add", undefined, adds[i]));
292
+ };
293
+
294
+ /** Walk one structured hunk into aligned rows (shared by both passes). */
295
+ const walkHunk = (hunk: { oldStart: number; newStart: number; lines: string[] }, count: boolean): DiffRow[] => {
296
+ const rows: DiffRow[] = [];
297
+ let oldNum = hunk.oldStart;
298
+ let newNum = hunk.newStart;
299
+ let pendingDel: number[] = [];
300
+ let pendingAdd: number[] = [];
301
+ const flush = (): void => {
302
+ flushBlock(rows, pendingDel, pendingAdd, count);
303
+ pendingDel = [];
304
+ pendingAdd = [];
305
+ };
306
+ for (const line of hunk.lines) {
307
+ const tag = line[0];
308
+ if (tag === "\\") continue;
309
+ if (tag === "-") {
310
+ touch(oldPlain[oldNum - 1]);
311
+ pendingDel.push(oldNum - 1);
312
+ oldNum++;
313
+ } else if (tag === "+") {
314
+ touch(newPlain[newNum - 1]);
315
+ pendingAdd.push(newNum - 1);
316
+ newNum++;
317
+ } else {
318
+ flush();
319
+ touch(oldPlain[oldNum - 1]);
320
+ touch(newPlain[newNum - 1]);
321
+ rows.push(makeRow("context", oldNum - 1, newNum - 1));
322
+ oldNum++;
323
+ newNum++;
324
+ }
325
+ }
326
+ flush();
327
+ return rows;
328
+ };
329
+
330
+ const streamed = ignoreWs ? undefined : options.streamResult;
331
+ const rows: DiffRow[] = [];
332
+ if (streamed) {
333
+ let oldIndex = 0;
334
+ let newIndex = 0;
335
+ let pendingDel: number[] = [];
336
+ let pendingAdd: number[] = [];
337
+ const flush = (): void => {
338
+ flushBlock(rows, pendingDel, pendingAdd, true);
339
+ pendingDel = [];
340
+ pendingAdd = [];
341
+ };
342
+ for (const run of streamed.runs) {
343
+ if (run.removed) {
344
+ for (let index = 0; index < run.count; index++) {
345
+ touch(oldPlain[oldIndex]);
346
+ pendingDel.push(oldIndex++);
347
+ }
348
+ } else if (run.added) {
349
+ for (let index = 0; index < run.count; index++) {
350
+ touch(newPlain[newIndex]);
351
+ pendingAdd.push(newIndex++);
352
+ }
353
+ } else {
354
+ flush();
355
+ for (let index = 0; index < run.count; index++) {
356
+ touch(oldPlain[oldIndex]);
357
+ touch(newPlain[newIndex]);
358
+ rows.push(makeRow("context", oldIndex++, newIndex++));
359
+ }
360
+ }
361
+ }
362
+ flush();
363
+ } else {
364
+ // The synchronous path remains for direct callers and whitespace-ignore.
365
+ const megaHunks = structuredPatchHunks(oldBasis, newBasis, oldLines.length + newLines.length + 1);
366
+ for (const hunk of megaHunks) rows.push(...walkHunk(hunk, true));
367
+ if (megaHunks.length === 0) {
368
+ for (let index = 0; index < oldLines.length; index++) {
369
+ touch(oldPlain[index]);
370
+ rows.push(makeRow("context", index, index));
371
+ }
372
+ }
373
+ }
374
+
375
+ // Tight hunks drive the hunk view and patch actions. Whitespace-ignore
376
+ // cannot use the raw streamed result because its equality basis differs.
377
+ const canPatch = !ignoreWs;
378
+ const tightHunks = streamed?.hunks ?? structuredPatchHunks(oldBasis, newBasis, DIFF_CONTEXT_LINES);
379
+ const allHunks: HunkBlock[] = tightHunks.map(hunk => ({
380
+ header: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
381
+ patch: canPatch
382
+ ? `--- a/${filePath}\n+++ b/${filePath}\n@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}\n`
383
+ : "",
384
+ rows: walkHunk(hunk, false),
385
+ }));
386
+ // Formatting-ignore: hunks whose every changed block was demoted vanish
387
+ // from the hunk view; mixed hunks stay (their patches include the
388
+ // formatting noise — staging follows git's real content).
389
+ const hunks = ignoreFormatting ? allHunks.filter(hunk => hunk.rows.some(row => row.kind !== "context")) : allHunks;
390
+
391
+ const fileLines = newPlain.map(line => ({ text: line, width: visibleWidth(line) }));
392
+ const gutterWidth = Math.max(3, String(Math.max(oldLines.length, newLines.length)).length);
393
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
394
+ const rowIndexByNewLine: number[] = new Array(newLines.length + 1).fill(-1);
395
+ rows.forEach((row, index) => {
396
+ if (row.newNum !== undefined && rowIndexByNewLine[row.newNum] === -1) rowIndexByNewLine[row.newNum] = index;
397
+ });
398
+ return {
399
+ filePath,
400
+ rows,
401
+ hunks,
402
+ fileLines,
403
+ oldDisplayLines: oldPlain,
404
+ newDisplayLines: newPlain,
405
+ additions,
406
+ deletions,
407
+ gutterWidth,
408
+ maxLineWidth,
409
+ canPatch,
410
+ rawOld: oldRaw,
411
+ rawNew: newRaw,
412
+ oldEndsNewline: oldRaw.endsWith("\n"),
413
+ newEndsNewline: newRaw.endsWith("\n"),
414
+ rowIndexByNewLine,
415
+ };
416
+ }
417
+ /**
418
+ * Build a patch covering only the changed rows inside `[from, to]` (indices
419
+ * into `doc.rows`).
420
+ *
421
+ * - `apply`: base is the old side; the target adopts only the selected
422
+ * changes. Staging selected lines = apply this to the index.
423
+ * - `revert`: base is the new side; the target undoes only the selected
424
+ * changes. Unstaging selected lines = apply `--cached`; discarding
425
+ * selected lines = apply to the worktree.
426
+ *
427
+ * Returns `null` when the selection touches no changes or the document
428
+ * cannot produce patches (whitespace-ignore alignment).
429
+ */
430
+ export function buildLineSelectionPatch(
431
+ doc: DiffDocument,
432
+ from: number,
433
+ to: number,
434
+ intent: "apply" | "revert",
435
+ ): string | null {
436
+ if (!doc.canPatch) return null;
437
+ const target: string[] = [];
438
+ let touched = false;
439
+ for (let i = 0; i < doc.rows.length; i++) {
440
+ const row = doc.rows[i];
441
+ const selected = i >= from && i <= to;
442
+ if (selected && row.kind !== "context") touched = true;
443
+ const useNew = intent === "apply" ? selected : !selected;
444
+ switch (row.kind) {
445
+ case "context": {
446
+ // Formatting-demoted rows can be one-sided or differ across
447
+ // sides; the target must mirror the patch base outside changes.
448
+ const line = intent === "apply" ? row.oldRaw : row.newRaw;
449
+ if (line !== undefined) target.push(line);
450
+ break;
451
+ }
452
+ case "change":
453
+ target.push((useNew ? row.newRaw : row.oldRaw) ?? "");
454
+ break;
455
+ case "del":
456
+ if (!useNew) target.push(row.oldRaw ?? "");
457
+ break;
458
+ case "add":
459
+ if (useNew) target.push(row.newRaw ?? "");
460
+ break;
461
+ }
462
+ }
463
+ if (!touched) return null;
464
+ const base = intent === "apply" ? doc.rawOld : doc.rawNew;
465
+ const baseEndsNL = intent === "apply" ? doc.oldEndsNewline : doc.newEndsNewline;
466
+ const otherEndsNL = intent === "apply" ? doc.newEndsNewline : doc.oldEndsNewline;
467
+ const lastRow = doc.rows[doc.rows.length - 1];
468
+ const lastSelected = lastRow !== undefined && to >= doc.rows.length - 1 && lastRow.kind !== "context";
469
+ const endsNL = lastSelected ? otherEndsNL : baseEndsNL;
470
+ const targetText = target.join("\n") + (endsNL && target.length > 0 ? "\n" : "");
471
+ const hunks = structuredPatchHunks(base, targetText, DIFF_CONTEXT_LINES);
472
+ if (hunks.length === 0) return null;
473
+ const body = hunks
474
+ .map(
475
+ hunk =>
476
+ `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}`,
477
+ )
478
+ .join("\n");
479
+ return `--- a/${doc.filePath}\n+++ b/${doc.filePath}\n${body}\n`;
480
+ }
481
+
482
+ // ── palette ──────────────────────────────────────────────────────────────────
483
+
484
+ interface DiffPalette {
485
+ addSoft: string;
486
+ addStrong: string;
487
+ delSoft: string;
488
+ delStrong: string;
489
+ fillAdd: string;
490
+ fillDel: string;
491
+ mapAdd: string;
492
+ mapDel: string;
493
+ mapChange: string;
494
+ mapContext: string;
495
+ mapHunk: string;
496
+ gutterAdd: string;
497
+ gutterDel: string;
498
+ }
499
+
500
+ let paletteCache: { key: string; palette: DiffPalette } | undefined;
501
+
502
+ function palette(): DiffPalette {
503
+ const added = theme.getColorHex("toolDiffAdded");
504
+ const removed = theme.getColorHex("toolDiffRemoved");
505
+ const accent = theme.getColorHex("accent");
506
+ const luminance = theme.statusLineLuminance;
507
+ const dark = luminance === undefined || luminance <= 0.5;
508
+ const canvas = canvasHex();
509
+ const text = textHex();
510
+ const key = `${added}\u0000${removed}\u0000${accent}\u0000${dark}\u0000${canvas}\u0000${text}`;
511
+ if (paletteCache?.key === key) return paletteCache.palette;
512
+ const built: DiffPalette = {
513
+ addSoft: bgAnsiHex(mixHex(canvas, added, dark ? 0.18 : 0.24)),
514
+ addStrong: bgAnsiHex(mixHex(canvas, added, dark ? 0.42 : 0.48)),
515
+ delSoft: bgAnsiHex(mixHex(canvas, removed, dark ? 0.18 : 0.24)),
516
+ delStrong: bgAnsiHex(mixHex(canvas, removed, dark ? 0.42 : 0.48)),
517
+ fillAdd: bgAnsiHex(mixHex(canvas, added, 0.07)),
518
+ fillDel: bgAnsiHex(mixHex(canvas, removed, 0.07)),
519
+ mapAdd: added,
520
+ mapDel: removed,
521
+ mapChange: mixHex(added, removed, 0.5),
522
+ mapContext: mixHex(canvas, text, 0.2),
523
+ mapHunk: accent,
524
+ gutterAdd: added,
525
+ gutterDel: removed,
526
+ };
527
+ paletteCache = { key, palette: built };
528
+ return built;
529
+ }
530
+
531
+ // ── pane ─────────────────────────────────────────────────────────────────────
532
+
533
+ /** Placeholder states shown instead of a document. */
534
+ export type DiffPaneState = "empty" | "loading" | "streaming" | "asset" | "ready";
535
+
536
+ /** One rendered line of the current view. */
537
+ type Visual =
538
+ | { t: "split"; row: DiffRow; seg: number; rowIndex: number }
539
+ | { t: "line"; row: DiffRow; side: "old" | "new" | "both"; seg: number; rowIndex: number }
540
+ | { t: "file"; index: number; seg: number; rowIndex: number }
541
+ | { t: "header"; hunk: number }
542
+ | { t: "blank" };
543
+
544
+ /** Incremental syntax-coloring state layered over an immutable document. */
545
+ interface SyntaxHighlights {
546
+ readonly old: (string | undefined)[];
547
+ readonly new: (string | undefined)[];
548
+ }
549
+
550
+ /** Provisional complete lines exposed while native ingestion is active. */
551
+ interface StreamingDocument {
552
+ readonly filePath: string;
553
+ readonly oldLines: string[];
554
+ readonly newLines: string[];
555
+ stableCommonLines: number;
556
+ maxLineWidth: number;
557
+ }
558
+ interface AssetDocument {
559
+ readonly filePath: string;
560
+ readonly old: FileAssetSide;
561
+ readonly new: FileAssetSide;
562
+ }
563
+
564
+ /** Result of a left click inside the pane. */
565
+ export type PaneClick = { type: "hunk-action"; hunk: HunkBlock; action: HunkAction } | { type: "handled" } | null;
566
+
567
+ /**
568
+ * Scrollable diff viewport. The root component feeds it key/mouse input and
569
+ * composes its rendered lines into the frame.
570
+ */
571
+ export class DiffPane {
572
+ readonly #imageBudget: ImageBudget | undefined;
573
+ #doc: DiffDocument | null = null;
574
+ #docVersion = 0;
575
+ #highlights: SyntaxHighlights | null = null;
576
+ #streaming: StreamingDocument | null = null;
577
+ #asset: AssetDocument | null = null;
578
+ state: DiffPaneState = "empty";
579
+ /** Message shown in the empty state. */
580
+ emptyMessage = "No changes";
581
+ mode: ViewMode = "split";
582
+ wrap = false;
583
+ /** Which hunk buttons apply: staging (unstaged), unstaging (staged), or none. */
584
+ patchTarget: "stage" | "unstage" | null = null;
585
+ selectedHunk = 0;
586
+ scrollTop = 0;
587
+ scrollLeft = 0;
588
+ /** Pane holds keyboard focus: the cursor band renders at full strength (dimmed otherwise). */
589
+ focused = false;
590
+ /** Cursor as a visual-row index; navigation keys move it. */
591
+ cursor = 0;
592
+ /** Shift-selection anchor (visual-row index), or null when no selection. */
593
+ anchor: number | null = null;
594
+ #lastHeight = 1;
595
+ #lastWidth = 0;
596
+ #layoutCache: { key: string; visuals: Visual[] } | undefined;
597
+ /** Per visible row: clickable hunk-button ranges recorded during render. */
598
+ #hits: ({ hunk: number; primary?: [number, number]; discard?: [number, number] } | undefined)[] = [];
599
+ constructor(imageBudget?: ImageBudget) {
600
+ this.#imageBudget = imageBudget;
601
+ }
602
+
603
+ get doc(): DiffDocument | null {
604
+ return this.#doc;
605
+ }
606
+
607
+ setDocument(doc: DiffDocument | null, state: DiffPaneState): void {
608
+ this.#doc = doc;
609
+ this.#docVersion++;
610
+ this.#highlights = null;
611
+ this.#streaming = null;
612
+ this.#asset = null;
613
+ this.state = state;
614
+ this.scrollTop = 0;
615
+ this.scrollLeft = 0;
616
+ this.selectedHunk = 0;
617
+ this.cursor = 0;
618
+ this.anchor = null;
619
+ if (doc && this.mode !== "file" && this.mode !== "hunk") {
620
+ const visuals = this.#layout(this.#lastWidth || 80);
621
+ const first = visuals.findIndex(visual => visualKind(visual) !== "context");
622
+ if (first > 0) {
623
+ this.cursor = first;
624
+ this.scrollTop = clampScrollOffset(
625
+ first - Math.floor(this.#lastHeight / 3),
626
+ visuals.length,
627
+ this.#lastHeight,
628
+ );
629
+ }
630
+ }
631
+ }
632
+ /** Show media previews and safe placeholders for non-text Git objects. */
633
+ setAsset(filePath: string, old: FileAssetSide, next: FileAssetSide): void {
634
+ this.setDocument(null, "asset");
635
+ this.#asset = { filePath, old, new: next };
636
+ }
637
+
638
+ /** Start a provisional file view while the native stream ingests both sides. */
639
+ startStream(filePath: string): void {
640
+ this.setDocument(null, "streaming");
641
+ this.#streaming = { filePath, oldLines: [], newLines: [], stableCommonLines: 0, maxLineWidth: 0 };
642
+ }
643
+
644
+ /** Merge newly completed native-stream lines into the provisional viewport. */
645
+ updateStream(update: FileStreamUpdate): void {
646
+ const streaming = this.#streaming;
647
+ if (!streaming) return;
648
+ const oldLines = update.oldLines.map(sanitizeDisplayText);
649
+ const newLines = update.newLines.map(sanitizeDisplayText);
650
+ streaming.oldLines.splice(update.oldLineOffset, streaming.oldLines.length - update.oldLineOffset, ...oldLines);
651
+ streaming.newLines.splice(update.newLineOffset, streaming.newLines.length - update.newLineOffset, ...newLines);
652
+ for (const line of oldLines) streaming.maxLineWidth = Math.max(streaming.maxLineWidth, line.length);
653
+ for (const line of newLines) streaming.maxLineWidth = Math.max(streaming.maxLineWidth, line.length);
654
+ streaming.stableCommonLines = update.progress.stableCommonLines;
655
+ this.#clampScroll();
656
+ }
657
+
658
+ /** Incrementally syntax-highlight the active document without blocking input. */
659
+ async highlightAsync(signal: AbortSignal, requestRender: () => void): Promise<void> {
660
+ const doc = this.#doc;
661
+ if (!doc || signal.aborted) return;
662
+ const language = getLanguageFromPath(doc.filePath);
663
+ const oldStream = createHighlightStream(language);
664
+ const newStream = createHighlightStream(language);
665
+ if (!oldStream && !newStream) return;
666
+
667
+ const highlights: SyntaxHighlights = {
668
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
669
+ old: new Array(doc.oldDisplayLines.length),
670
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
671
+ new: new Array(doc.newDisplayLines.length),
672
+ };
673
+ this.#highlights = highlights;
674
+ let oldOffset = oldStream ? 0 : doc.oldDisplayLines.length;
675
+ let newOffset = newStream ? 0 : doc.newDisplayLines.length;
676
+ while (oldOffset < doc.oldDisplayLines.length || newOffset < doc.newDisplayLines.length) {
677
+ if (signal.aborted || this.#doc !== doc) return;
678
+ if (oldStream && oldOffset < doc.oldDisplayLines.length) {
679
+ oldOffset = this.#highlightChunk(
680
+ oldStream,
681
+ doc.oldDisplayLines,
682
+ doc.oldEndsNewline,
683
+ highlights.old,
684
+ oldOffset,
685
+ );
686
+ }
687
+ if (newStream && newOffset < doc.newDisplayLines.length) {
688
+ newOffset = this.#highlightChunk(
689
+ newStream,
690
+ doc.newDisplayLines,
691
+ doc.newEndsNewline,
692
+ highlights.new,
693
+ newOffset,
694
+ );
695
+ }
696
+ requestRender();
697
+ if (oldOffset < doc.oldDisplayLines.length || newOffset < doc.newDisplayLines.length) await Bun.sleep(0);
698
+ }
699
+ }
700
+
701
+ #highlightChunk(
702
+ stream: HighlightStream,
703
+ lines: readonly string[],
704
+ endsNewline: boolean,
705
+ highlights: (string | undefined)[],
706
+ offset: number,
707
+ ): number {
708
+ const end = Math.min(lines.length, offset + HIGHLIGHT_BATCH_LINES);
709
+ const final = end === lines.length;
710
+ const chunk = `${lines.slice(offset, end).join("\n")}${!final || endsNewline ? "\n" : ""}`;
711
+ const rendered = stream.push(chunk).split("\n");
712
+ if (chunk.endsWith("\n")) rendered.pop();
713
+ for (let index = offset; index < end; index++) highlights[index] = rendered[index - offset] ?? lines[index];
714
+ return end;
715
+ }
716
+
717
+ setMode(mode: ViewMode): void {
718
+ if (this.mode === mode) return;
719
+ this.mode = mode;
720
+ this.scrollTop = 0;
721
+ this.scrollLeft = 0;
722
+ this.cursor = 0;
723
+ this.anchor = null;
724
+ }
725
+
726
+ cycleMode(): void {
727
+ const order: ViewMode[] = ["split", "inline", "hunk", "file"];
728
+ this.setMode(order[(order.indexOf(this.mode) + 1) % order.length]);
729
+ }
730
+
731
+ toggleWrap(): void {
732
+ this.wrap = !this.wrap;
733
+ this.scrollLeft = 0;
734
+ this.#layoutCache = undefined;
735
+ }
736
+
737
+ // ── scrolling ──────────────────────────────────────────────────────────
738
+
739
+ #total(): number {
740
+ if (this.#doc) return this.#layout(this.#lastWidth || 80).length;
741
+ return this.#streaming ? Math.max(this.#streaming.oldLines.length, this.#streaming.newLines.length) : 0;
742
+ }
743
+
744
+ #clampScroll(): void {
745
+ this.scrollTop = clampScrollOffset(this.scrollTop, this.#total(), this.#lastHeight);
746
+ const maxLineWidth = this.#doc?.maxLineWidth ?? this.#streaming?.maxLineWidth ?? 0;
747
+ const maxLeft = this.wrap ? 0 : Math.max(0, maxLineWidth - 8);
748
+ this.scrollLeft = Math.max(0, Math.min(this.scrollLeft, maxLeft));
749
+ }
750
+
751
+ scrollBy(delta: number): void {
752
+ this.scrollTop += delta;
753
+ this.#clampScroll();
754
+ }
755
+
756
+ pageBy(direction: 1 | -1): void {
757
+ this.scrollBy(direction * Math.max(1, this.#lastHeight - 2));
758
+ }
759
+
760
+ scrollLeftBy(delta: number): void {
761
+ this.scrollLeft += delta;
762
+ this.#clampScroll();
763
+ }
764
+ /** Move the read-only cursor; `extend` grows the shift selection. */
765
+ moveCursor(delta: number, extend: boolean): void {
766
+ const total = this.#total();
767
+ if (total === 0) return;
768
+ if (extend) {
769
+ if (this.anchor === null) this.anchor = this.cursor;
770
+ } else {
771
+ this.anchor = null;
772
+ }
773
+ this.cursor = Math.max(0, Math.min(total - 1, this.cursor + delta));
774
+ this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
775
+ this.#clampScroll();
776
+ }
777
+
778
+ /** Drop the shift selection. True when there was one. */
779
+ clearSelection(): boolean {
780
+ if (this.anchor === null) return false;
781
+ this.anchor = null;
782
+ return true;
783
+ }
784
+
785
+ /**
786
+ * Selected span as indices into `doc.rows` (shift selection, or the cursor
787
+ * row alone). Null when the cursor sits on rows that cannot map back to the
788
+ * document (hunk-view rows, headers).
789
+ */
790
+ get selection(): { from: number; to: number; explicit: boolean } | null {
791
+ if (!this.#doc) return null;
792
+ const visuals = this.#layout(this.#lastWidth || 80);
793
+ const a = this.anchor === null ? this.cursor : Math.min(this.anchor, this.cursor);
794
+ const b = this.anchor === null ? this.cursor : Math.max(this.anchor, this.cursor);
795
+ let from = Number.POSITIVE_INFINITY;
796
+ let to = -1;
797
+ for (let i = a; i <= b; i++) {
798
+ const index = visualRowIndex(visuals[i]);
799
+ if (index >= 0) {
800
+ from = Math.min(from, index);
801
+ to = Math.max(to, index);
802
+ }
803
+ }
804
+ return to >= 0 ? { from, to, explicit: this.anchor !== null } : null;
805
+ }
806
+
807
+ /** Center the viewport on a visual row (minimap seek, hunk jump). */
808
+ seekTo(row: number): void {
809
+ const total = this.#total();
810
+ this.scrollTop = scrollOffsetForRow(this.scrollTop, row, total, this.#lastHeight, "center");
811
+ this.#clampScroll();
812
+ }
813
+
814
+ /** Jump to the next/previous hunk. False when already at the boundary. */
815
+ jumpHunk(direction: 1 | -1): boolean {
816
+ const doc = this.#doc;
817
+ if (!doc || doc.hunks.length === 0) return false;
818
+ const visuals = this.#layout(this.#lastWidth || 80);
819
+ if (this.mode === "hunk") {
820
+ const next = this.selectedHunk + direction;
821
+ if (next < 0 || next >= doc.hunks.length) return false;
822
+ this.#focusHunkHeader(next, visuals);
823
+ return true;
824
+ }
825
+ // Other modes: jump between change blocks.
826
+ const starts = this.#changeStarts(visuals);
827
+ const reference = this.cursor;
828
+ const next =
829
+ direction > 0
830
+ ? starts.find(start => start > reference)
831
+ : [...starts].reverse().find(start => start < reference);
832
+ if (next === undefined) return false;
833
+ this.#focusChange(next);
834
+ return true;
835
+ }
836
+
837
+ /** Snap to the first/last hunk — the landing spot when hunk nav crosses files. */
838
+ seekHunk(edge: "first" | "last"): void {
839
+ const doc = this.#doc;
840
+ if (!doc || doc.hunks.length === 0) return;
841
+ const visuals = this.#layout(this.#lastWidth || 80);
842
+ if (this.mode === "hunk") {
843
+ this.#focusHunkHeader(edge === "first" ? 0 : doc.hunks.length - 1, visuals);
844
+ return;
845
+ }
846
+ const starts = this.#changeStarts(visuals);
847
+ if (starts.length === 0) return;
848
+ this.#focusChange(edge === "first" ? starts[0] : starts[starts.length - 1]);
849
+ }
850
+
851
+ /** Move the cursor to the first/last visual row (home/end, `g`/`G`). */
852
+ cursorToEdge(edge: "start" | "end"): void {
853
+ const total = this.#total();
854
+ if (total === 0) return;
855
+ this.anchor = null;
856
+ this.cursor = edge === "start" ? 0 : total - 1;
857
+ this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
858
+ this.#clampScroll();
859
+ }
860
+
861
+ /** Select a hunk in hunk view and scroll its header into view. */
862
+ #focusHunkHeader(index: number, visuals: Visual[]): void {
863
+ this.selectedHunk = index;
864
+ const header = visuals.findIndex(visual => visual.t === "header" && visual.hunk === index);
865
+ if (header >= 0) {
866
+ this.cursor = header;
867
+ this.anchor = null;
868
+ this.scrollTop = clampScrollOffset(header - 1, visuals.length, this.#lastHeight);
869
+ this.#clampScroll();
870
+ }
871
+ }
872
+
873
+ /** First visual row of every change block, in document order. */
874
+ #changeStarts(visuals: Visual[]): number[] {
875
+ const starts: number[] = [];
876
+ let inChange = false;
877
+ for (let i = 0; i < visuals.length; i++) {
878
+ const kind = this.#changeKind(visuals[i]);
879
+ const changed = kind !== "context" && kind !== null;
880
+ if (changed && !inChange) starts.push(i);
881
+ inChange = changed;
882
+ }
883
+ return starts;
884
+ }
885
+
886
+ /** Like {@link visualKind}, but resolves file-view rows through the document. */
887
+ #changeKind(visual: Visual): RowKind | "hunk" | null {
888
+ if (visual.t === "file") {
889
+ const row = visual.rowIndex >= 0 ? this.#doc?.rows[visual.rowIndex] : undefined;
890
+ return row?.kind ?? "context";
891
+ }
892
+ return visualKind(visual);
893
+ }
894
+
895
+ /** Put the cursor on a change block and scroll it into the upper third. */
896
+ #focusChange(row: number): void {
897
+ this.cursor = row;
898
+ this.anchor = null;
899
+ this.scrollTop = clampScrollOffset(row - Math.floor(this.#lastHeight / 3), this.#total(), this.#lastHeight);
900
+ this.#clampScroll();
901
+ }
902
+
903
+ /** Currently selected hunk (hunk view), if any. */
904
+ get currentHunk(): HunkBlock | null {
905
+ return this.#doc?.hunks[this.selectedHunk] ?? null;
906
+ }
907
+
908
+ // ── mouse ──────────────────────────────────────────────────────────────
909
+
910
+ /** Handle a left click at pane-local coordinates. */
911
+ clickAt(col: number, row: number, shift = false): PaneClick {
912
+ if (this.#lastWidth > 0 && col >= this.#lastWidth - 2) {
913
+ const total = this.#total();
914
+ if (total > 0 && this.#lastHeight > 0) this.seekTo(Math.floor(((row + 0.5) / this.#lastHeight) * total));
915
+ return { type: "handled" };
916
+ }
917
+ const hit = this.#hits[row];
918
+ if (hit && this.#doc) {
919
+ this.selectedHunk = hit.hunk;
920
+ if (hit.primary && col >= hit.primary[0] && col < hit.primary[1] && this.patchTarget) {
921
+ return { type: "hunk-action", hunk: this.#doc.hunks[hit.hunk], action: this.patchTarget };
922
+ }
923
+ if (hit.discard && col >= hit.discard[0] && col < hit.discard[1] && this.patchTarget === "stage") {
924
+ return { type: "hunk-action", hunk: this.#doc.hunks[hit.hunk], action: "discard" };
925
+ }
926
+ return { type: "handled" };
927
+ }
928
+ const visual = this.scrollTop + row;
929
+ if (visual < this.#total()) {
930
+ if (shift) {
931
+ if (this.anchor === null) this.anchor = this.cursor;
932
+ } else {
933
+ this.anchor = null;
934
+ }
935
+ this.cursor = visual;
936
+ return { type: "handled" };
937
+ }
938
+ return null;
939
+ }
940
+
941
+ // ── layout ─────────────────────────────────────────────────────────────
942
+
943
+ #splitTextWidth(width: number): number {
944
+ const gutter = this.#doc?.gutterWidth ?? 4;
945
+ // [gutterL][ textL ][│][gutterR][ textR ][minimap(2)]
946
+ return Math.max(8, Math.floor((width - 2 * (gutter + 2) - 1 - 2) / 2));
947
+ }
948
+
949
+ #lineTextWidth(width: number): number {
950
+ const gutter = this.#doc?.gutterWidth ?? 4;
951
+ // [oldNum][newNum][ text ][minimap(2)]
952
+ return Math.max(8, width - 2 * (gutter + 1) - 3);
953
+ }
954
+
955
+ #layout(width: number): Visual[] {
956
+ const doc = this.#doc;
957
+ if (!doc) return [];
958
+ const key = `${this.#docVersion}\u0000${this.mode}\u0000${this.wrap}\u0000${width}`;
959
+ if (this.#layoutCache?.key === key) return this.#layoutCache.visuals;
960
+ const visuals: Visual[] = [];
961
+ const segsFor = (textWidth: number, ...widths: number[]): number =>
962
+ this.wrap ? Math.max(1, Math.ceil(Math.max(...widths, 1) / textWidth)) : 1;
963
+ switch (this.mode) {
964
+ case "split": {
965
+ const textWidth = this.#splitTextWidth(width);
966
+ doc.rows.forEach((row, rowIndex) => {
967
+ const segs = segsFor(textWidth, row.oldWidth, row.newWidth);
968
+ for (let seg = 0; seg < segs; seg++) visuals.push({ t: "split", row, seg, rowIndex });
969
+ });
970
+ break;
971
+ }
972
+ case "inline": {
973
+ const textWidth = this.#lineTextWidth(width);
974
+ doc.rows.forEach((row, rowIndex) => {
975
+ if (row.kind === "change") {
976
+ for (let seg = 0; seg < segsFor(textWidth, row.oldWidth); seg++)
977
+ visuals.push({ t: "line", row, side: "old", seg, rowIndex });
978
+ for (let seg = 0; seg < segsFor(textWidth, row.newWidth); seg++)
979
+ visuals.push({ t: "line", row, side: "new", seg, rowIndex });
980
+ } else {
981
+ const side = row.kind === "del" ? "old" : row.kind === "add" ? "new" : "both";
982
+ const rowWidth = side === "old" ? row.oldWidth : row.newWidth;
983
+ for (let seg = 0; seg < segsFor(textWidth, rowWidth); seg++)
984
+ visuals.push({ t: "line", row, side, seg, rowIndex });
985
+ }
986
+ });
987
+ break;
988
+ }
989
+ case "hunk": {
990
+ const textWidth = this.#lineTextWidth(width);
991
+ doc.hunks.forEach((hunk, index) => {
992
+ visuals.push({ t: "header", hunk: index });
993
+ for (const row of hunk.rows) {
994
+ if (row.kind === "change") {
995
+ for (let seg = 0; seg < segsFor(textWidth, row.oldWidth); seg++)
996
+ visuals.push({ t: "line", row, side: "old", seg, rowIndex: -1 });
997
+ for (let seg = 0; seg < segsFor(textWidth, row.newWidth); seg++)
998
+ visuals.push({ t: "line", row, side: "new", seg, rowIndex: -1 });
999
+ } else {
1000
+ const side = row.kind === "del" ? "old" : row.kind === "add" ? "new" : "both";
1001
+ const rowWidth = side === "old" ? row.oldWidth : row.newWidth;
1002
+ for (let seg = 0; seg < segsFor(textWidth, rowWidth); seg++)
1003
+ visuals.push({ t: "line", row, side, seg, rowIndex: -1 });
1004
+ }
1005
+ }
1006
+ visuals.push({ t: "blank" });
1007
+ });
1008
+ break;
1009
+ }
1010
+ case "file": {
1011
+ const gutter = doc.gutterWidth;
1012
+ const textWidth = Math.max(8, width - gutter - 1 - 2 - 2);
1013
+ doc.fileLines.forEach((line, index) => {
1014
+ const rowIndex = doc.rowIndexByNewLine[index + 1] ?? -1;
1015
+ for (let seg = 0; seg < segsFor(textWidth, line.width); seg++)
1016
+ visuals.push({ t: "file", index, seg, rowIndex });
1017
+ });
1018
+ break;
1019
+ }
1020
+ }
1021
+ this.#layoutCache = { key, visuals };
1022
+ return visuals;
1023
+ }
1024
+
1025
+ // ── render ─────────────────────────────────────────────────────────────
1026
+
1027
+ render(width: number, height: number): string[] {
1028
+ this.#lastWidth = width;
1029
+ this.#lastHeight = height;
1030
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
1031
+ this.#hits = new Array(height);
1032
+ const doc = this.#doc;
1033
+ if (this.state === "streaming" && this.#streaming) return this.#renderStreaming(width, height);
1034
+ if (this.state === "asset" && this.#asset) return this.#renderAsset(width, height);
1035
+ if (!doc || this.state !== "ready") {
1036
+ const message = this.state === "loading" ? "Loading diff…" : this.emptyMessage;
1037
+ const lines: string[] = [];
1038
+ for (let i = 0; i < height; i++) {
1039
+ lines.push(
1040
+ i === Math.floor(height / 2)
1041
+ ? truncateToWidth(centerLine(theme.fg("dim", message), width).trimEnd(), width)
1042
+ : "",
1043
+ );
1044
+ }
1045
+ return lines;
1046
+ }
1047
+
1048
+ const visuals = this.#layout(width);
1049
+ this.#clampScroll();
1050
+ const colors = palette();
1051
+ const minimap = this.#renderMinimap(visuals, height, colors);
1052
+ const selectFrom = this.anchor === null ? this.cursor : Math.min(this.anchor, this.cursor);
1053
+ const selectTo = this.anchor === null ? this.cursor : Math.max(this.anchor, this.cursor);
1054
+ const selectionBg = selectionBgAnsi(!this.focused);
1055
+ const { start } = viewportRange(visuals.length, height, this.scrollTop);
1056
+ const lines: string[] = [];
1057
+ for (let i = 0; i < height; i++) {
1058
+ const visual = visuals[start + i];
1059
+ if (!visual) {
1060
+ lines.push(`${" ".repeat(Math.max(0, width - 1))}${minimap[i]}`);
1061
+ continue;
1062
+ }
1063
+ // Pad every body to exactly width-2 so the minimap column never drifts.
1064
+ const body = this.#renderVisual(visual, doc, width, colors, i);
1065
+ let padded = `${body}${" ".repeat(Math.max(0, width - 2 - visibleWidth(body)))}`;
1066
+ const visualIndex = start + i;
1067
+ if (visualIndex >= selectFrom && visualIndex <= selectTo) {
1068
+ padded = `${withBg(padded, selectionBg)}\x1b[0m`;
1069
+ }
1070
+ lines.push(`${padded} ${minimap[i]}`);
1071
+ }
1072
+ return lines;
1073
+ }
1074
+ #renderAsset(width: number, height: number): string[] {
1075
+ const asset = this.#asset;
1076
+ if (!asset || height <= 0) return [];
1077
+ const leftWidth = Math.max(1, Math.floor((width - 1) / 2));
1078
+ const rightWidth = Math.max(1, width - leftWidth - 1);
1079
+ const bodyHeight = Math.max(0, height - 1);
1080
+ const oldLines = this.#renderAssetSide(asset.old, leftWidth, bodyHeight, `old:${asset.filePath}`);
1081
+ const newLines = this.#renderAssetSide(asset.new, rightWidth, bodyHeight, `new:${asset.filePath}`);
1082
+ const border = theme.fg("borderMuted", "│");
1083
+ const lines: string[] = [];
1084
+ for (let index = 0; index < height; index++) {
1085
+ const leftSource =
1086
+ index === 0
1087
+ ? centerLine(theme.bold(this.#assetTitle("Before", asset.old)), leftWidth).trimEnd()
1088
+ : (oldLines[index - 1] ?? "");
1089
+ const rightSource =
1090
+ index === 0
1091
+ ? centerLine(theme.bold(this.#assetTitle("After", asset.new)), rightWidth).trimEnd()
1092
+ : (newLines[index - 1] ?? "");
1093
+ const left = truncateToWidth(leftSource, leftWidth);
1094
+ const right = truncateToWidth(rightSource, rightWidth);
1095
+ lines.push(
1096
+ `${left}${" ".repeat(Math.max(0, leftWidth - visibleWidth(left)))}${border}${right}${" ".repeat(Math.max(0, rightWidth - visibleWidth(right)))}`,
1097
+ );
1098
+ }
1099
+ return lines;
1100
+ }
1101
+
1102
+ #renderAssetSide(side: FileAssetSide, width: number, height: number, placementKey: string): readonly string[] {
1103
+ if (height <= 0) return [];
1104
+ let content: readonly string[];
1105
+ if (side.kind === "image") {
1106
+ const image = side.image;
1107
+ content = new Image(
1108
+ image.data,
1109
+ image.mimeType,
1110
+ { fallbackColor: text => theme.fg("dim", text) },
1111
+ {
1112
+ maxWidthCells: Math.max(1, width - 2),
1113
+ maxHeightCells: height,
1114
+ filename: this.#asset?.filePath,
1115
+ budget: this.#imageBudget,
1116
+ imageKey: `git-review:${placementKey}:${image.key}`,
1117
+ },
1118
+ { widthPx: image.widthPx, heightPx: image.heightPx },
1119
+ ).render(width);
1120
+ } else {
1121
+ let details: string[];
1122
+ switch (side.kind) {
1123
+ case "empty":
1124
+ details = ["No file"];
1125
+ break;
1126
+ case "text":
1127
+ details = ["Text object", formatBytes(side.byteLength)];
1128
+ break;
1129
+ case "binary":
1130
+ details = [
1131
+ "Binary object",
1132
+ side.byteLength === undefined ? "Size unavailable" : formatBytes(side.byteLength),
1133
+ ];
1134
+ break;
1135
+ case "tooLarge":
1136
+ details = [
1137
+ "Object too large to preview",
1138
+ side.byteLength === undefined ? "Exceeds preview limit" : formatBytes(side.byteLength),
1139
+ ];
1140
+ break;
1141
+ case "lfsMissing":
1142
+ details = [
1143
+ "Git LFS object unavailable",
1144
+ `sha256:${side.oid.slice(0, 12)}… · ${formatBytes(side.byteLength)}`,
1145
+ ];
1146
+ break;
1147
+ }
1148
+ content = details.map(detail => centerLine(theme.fg("dim", detail), width).trimEnd());
1149
+ }
1150
+ const top = Math.max(0, Math.floor((height - content.length) / 2));
1151
+ return Array.from({ length: height }, (_, index) => content[index - top] ?? "");
1152
+ }
1153
+
1154
+ #assetTitle(label: string, side: FileAssetSide): string {
1155
+ let kind: string;
1156
+ let lfs = false;
1157
+ switch (side.kind) {
1158
+ case "empty":
1159
+ return label;
1160
+ case "image":
1161
+ kind =
1162
+ side.image.sourceMimeType === "image/svg+xml"
1163
+ ? "SVG"
1164
+ : side.image.sourceMimeType.replace(/^image\//, "").toUpperCase();
1165
+ lfs = side.image.lfsOid !== undefined;
1166
+ break;
1167
+ case "text":
1168
+ kind = "Text";
1169
+ lfs = side.lfsOid !== undefined;
1170
+ break;
1171
+ case "binary":
1172
+ kind = "Binary";
1173
+ lfs = side.lfsOid !== undefined;
1174
+ break;
1175
+ case "tooLarge":
1176
+ kind = "Too large";
1177
+ lfs = side.lfsOid !== undefined;
1178
+ break;
1179
+ case "lfsMissing":
1180
+ kind = "LFS missing";
1181
+ lfs = true;
1182
+ break;
1183
+ }
1184
+ return `${label} · ${kind}${lfs ? " · Git LFS" : ""}`;
1185
+ }
1186
+
1187
+ #renderStreaming(width: number, height: number): string[] {
1188
+ const streaming = this.#streaming;
1189
+ if (!streaming) return [];
1190
+ const total = this.#total();
1191
+ if (total === 0) {
1192
+ return Array.from({ length: height }, (_, index) =>
1193
+ index === Math.floor(height / 2) ? centerLine(theme.fg("dim", "Streaming file…"), width).trimEnd() : "",
1194
+ );
1195
+ }
1196
+ this.#clampScroll();
1197
+ const gutter = Math.max(3, String(total).length);
1198
+ const selectionBg = selectionBgAnsi(!this.focused);
1199
+ const { start } = viewportRange(total, height, this.scrollTop);
1200
+ const lines: string[] = [];
1201
+ for (let screenRow = 0; screenRow < height; screenRow++) {
1202
+ const index = start + screenRow;
1203
+ if (index >= total) {
1204
+ lines.push("");
1205
+ continue;
1206
+ }
1207
+ let line: string;
1208
+ if (this.mode === "file") {
1209
+ const textWidth = Math.max(8, width - gutter - 1);
1210
+ const text = streaming.newLines[index];
1211
+ if (text === undefined) {
1212
+ line = "";
1213
+ } else {
1214
+ const slice = sliceWithWidth(text, this.scrollLeft, textWidth);
1215
+ line = `${theme.fg("dim", String(index + 1).padStart(gutter))} ${slice.text}${" ".repeat(Math.max(0, textWidth - slice.width))}`;
1216
+ }
1217
+ } else {
1218
+ const textWidth = Math.max(8, Math.floor((width - 2 * (gutter + 1) - 1) / 2));
1219
+ const renderSide = (text: string | undefined): string => {
1220
+ if (text === undefined) return " ".repeat(gutter + 1 + textWidth);
1221
+ const slice = sliceWithWidth(text, this.scrollLeft, textWidth);
1222
+ return `${theme.fg("dim", String(index + 1).padStart(gutter))} ${slice.text}${" ".repeat(Math.max(0, textWidth - slice.width))}`;
1223
+ };
1224
+ line = `${renderSide(streaming.oldLines[index])}${theme.fg("borderMuted", "│")}${renderSide(streaming.newLines[index])}`;
1225
+ }
1226
+ line = truncateToWidth(line, width);
1227
+ if (index === this.cursor) {
1228
+ line = `${withBg(`${line}${" ".repeat(Math.max(0, width - visibleWidth(line)))}`, selectionBg)}\x1b[0m`;
1229
+ }
1230
+ lines.push(line);
1231
+ }
1232
+ return lines;
1233
+ }
1234
+
1235
+ #displayText(row: DiffRow, side: "old" | "new" | "both"): string {
1236
+ // "both" prefers the new side; formatting-demoted one-sided context
1237
+ // rows may only carry an old side.
1238
+ const actualSide = side === "old" || (side === "both" && row.newNum === undefined) ? "old" : "new";
1239
+ const number = actualSide === "old" ? row.oldNum : row.newNum;
1240
+ const plain = actualSide === "old" ? row.oldText : row.newText;
1241
+ return number === undefined ? plain : (this.#highlights?.[actualSide][number - 1] ?? plain);
1242
+ }
1243
+
1244
+ #renderVisual(visual: Visual, doc: DiffDocument, width: number, colors: DiffPalette, screenRow: number): string {
1245
+ switch (visual.t) {
1246
+ case "blank":
1247
+ return " ".repeat(Math.max(0, width - 2));
1248
+ case "header":
1249
+ return this.#renderHeader(visual.hunk, doc, width, screenRow);
1250
+ case "split": {
1251
+ const textWidth = this.#splitTextWidth(width);
1252
+ const startCol = this.wrap ? visual.seg * textWidth : this.scrollLeft;
1253
+ const left = this.#renderSide(
1254
+ visual.row,
1255
+ "old",
1256
+ doc.gutterWidth,
1257
+ textWidth,
1258
+ colors,
1259
+ startCol,
1260
+ visual.seg === 0,
1261
+ );
1262
+ const right = this.#renderSide(
1263
+ visual.row,
1264
+ "new",
1265
+ doc.gutterWidth,
1266
+ textWidth,
1267
+ colors,
1268
+ startCol,
1269
+ visual.seg === 0,
1270
+ );
1271
+ return `${left}${theme.fg("borderMuted", "│")}${right}`;
1272
+ }
1273
+ case "line":
1274
+ return this.#renderLine(visual, doc, width, colors);
1275
+ case "file": {
1276
+ const gutter = doc.gutterWidth;
1277
+ const textWidth = Math.max(8, width - gutter - 1 - 2 - 2);
1278
+ const startCol = this.wrap ? visual.seg * textWidth : this.scrollLeft;
1279
+ const text = this.#highlights?.new[visual.index] ?? doc.fileLines[visual.index]?.text ?? "";
1280
+ const slice = sliceWithWidth(text, startCol, textWidth);
1281
+ const gutterText =
1282
+ visual.seg === 0 ? theme.fg("dim", String(visual.index + 1).padStart(gutter)) : " ".repeat(gutter);
1283
+ return `${gutterText} ${slice.text}${" ".repeat(Math.max(0, textWidth - slice.width))}`;
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ #renderHeader(hunkIndex: number, doc: DiffDocument, width: number, screenRow: number): string {
1289
+ const hunk = doc.hunks[hunkIndex];
1290
+ const selected = this.mode === "hunk" && hunkIndex === this.selectedHunk;
1291
+ const headerText = selected ? theme.fg("accent", theme.bold(hunk.header)) : theme.fg("accent", hunk.header);
1292
+ let buttons = "";
1293
+ let primary: [number, number] | undefined;
1294
+ let discard: [number, number] | undefined;
1295
+ if (this.patchTarget && doc.canPatch) {
1296
+ const primaryLabel =
1297
+ this.patchTarget === "stage"
1298
+ ? pill(" Stage Hunk ", theme.getColorHex("toolDiffAdded"))
1299
+ : pill(" Unstage Hunk ", theme.getColorHex("warning"));
1300
+ const discardLabel =
1301
+ this.patchTarget === "stage" ? pill(" Discard Hunk ", theme.getColorHex("toolDiffRemoved")) : "";
1302
+ const total = visibleWidth(primaryLabel) + (discardLabel ? visibleWidth(discardLabel) + 1 : 0);
1303
+ const from = Math.max(0, width - 2 - total);
1304
+ let cursor = from;
1305
+ if (discardLabel) {
1306
+ discard = [cursor, cursor + visibleWidth(discardLabel)];
1307
+ buttons += `${discardLabel} `;
1308
+ cursor += visibleWidth(discardLabel) + 1;
1309
+ }
1310
+ primary = [cursor, cursor + visibleWidth(primaryLabel)];
1311
+ buttons += primaryLabel;
1312
+ this.#hits[screenRow] = { hunk: hunkIndex, primary, discard };
1313
+ const pad = Math.max(1, from - visibleWidth(hunk.header) - (selected ? 2 : 0));
1314
+ return truncateToWidth(
1315
+ `${selected ? theme.fg("accent", "▶ ") : ""}${headerText}${" ".repeat(pad)}${buttons}`,
1316
+ width - 2,
1317
+ );
1318
+ }
1319
+ this.#hits[screenRow] = { hunk: hunkIndex };
1320
+ return truncateToWidth(`${selected ? theme.fg("accent", "▶ ") : ""}${headerText}`, width - 2);
1321
+ }
1322
+
1323
+ #renderLine(visual: Visual & { t: "line" }, doc: DiffDocument, width: number, colors: DiffPalette): string {
1324
+ const { row, side, seg } = visual;
1325
+ const gutter = doc.gutterWidth;
1326
+ const textWidth = this.#lineTextWidth(width);
1327
+ const startCol = this.wrap ? seg * textWidth : this.scrollLeft;
1328
+ const first = seg === 0;
1329
+ const oldLabel =
1330
+ first && row.oldNum !== undefined && side !== "new" ? String(row.oldNum).padStart(gutter) : " ".repeat(gutter);
1331
+ const newLabel =
1332
+ first && row.newNum !== undefined && side !== "old" ? String(row.newNum).padStart(gutter) : " ".repeat(gutter);
1333
+ const isDel = side === "old" && row.kind !== "context";
1334
+ const isAdd = side === "new" && row.kind !== "context";
1335
+ const gutterText = isDel
1336
+ ? `${fgAnsiHex(colors.gutterDel) + oldLabel + " ".repeat(gutter + 1)}\x1b[0m`
1337
+ : isAdd
1338
+ ? `${" ".repeat(gutter)}${fgAnsiHex(colors.gutterAdd)}${newLabel}\x1b[0m `
1339
+ : theme.fg("dim", `${oldLabel}${newLabel} `);
1340
+ const text = this.#displayText(row, side);
1341
+ const marks = side === "old" ? row.oldMarks : row.newMarks;
1342
+ let body: string;
1343
+ if (!isDel && !isAdd) {
1344
+ const slice = sliceWithWidth(text, startCol, textWidth);
1345
+ body = ` ${slice.text}${" ".repeat(Math.max(0, textWidth - slice.width))} `;
1346
+ } else {
1347
+ const soft = isDel ? colors.delSoft : colors.addSoft;
1348
+ const strong = isDel ? colors.delStrong : colors.addStrong;
1349
+ if (marks && marks.length > 0 && row.kind === "change") {
1350
+ body = `${withBg(` ${this.#renderMarked(text, marks, textWidth, soft, strong, startCol)} `, soft)}\x1b[0m`;
1351
+ } else {
1352
+ const slice = sliceWithWidth(text, startCol, textWidth);
1353
+ body = `${withBg(` ${slice.text}${" ".repeat(Math.max(0, textWidth - slice.width))} `, soft)}\x1b[0m`;
1354
+ }
1355
+ }
1356
+ return `${gutterText}${body}`;
1357
+ }
1358
+
1359
+ #renderSide(
1360
+ row: DiffRow,
1361
+ side: "old" | "new",
1362
+ gutter: number,
1363
+ textWidth: number,
1364
+ colors: DiffPalette,
1365
+ startCol: number,
1366
+ first: boolean,
1367
+ ): string {
1368
+ const num = side === "old" ? row.oldNum : row.newNum;
1369
+ const text = this.#displayText(row, side);
1370
+ const marks = side === "old" ? row.oldMarks : row.newMarks;
1371
+ const present = num !== undefined;
1372
+ const changed = row.kind === "change" || (side === "old" ? row.kind === "del" : row.kind === "add");
1373
+
1374
+ let gutterText: string;
1375
+ if (present && first) {
1376
+ const label = String(num).padStart(gutter);
1377
+ gutterText = changed
1378
+ ? `${fgAnsiHex(side === "old" ? colors.gutterDel : colors.gutterAdd) + label}\x1b[0m`
1379
+ : theme.fg("dim", label);
1380
+ } else {
1381
+ gutterText = " ".repeat(gutter);
1382
+ }
1383
+
1384
+ if (!present) {
1385
+ // Filler side of a one-sided row: faint tint, no text.
1386
+ const fill = side === "old" ? colors.fillDel : colors.fillAdd;
1387
+ const tinted = row.kind === "add" || row.kind === "del";
1388
+ const body = tinted ? `${fill}${" ".repeat(textWidth + 2)}\x1b[0m` : " ".repeat(textWidth + 2);
1389
+ return `${gutterText}${body}`;
1390
+ }
1391
+
1392
+ const soft = side === "old" ? colors.delSoft : colors.addSoft;
1393
+ const strong = side === "old" ? colors.delStrong : colors.addStrong;
1394
+ let body: string;
1395
+ if (!changed) {
1396
+ const slice = sliceWithWidth(text, startCol, textWidth);
1397
+ body = ` ${slice.text}${" ".repeat(Math.max(0, textWidth - slice.width))} `;
1398
+ } else if (marks && marks.length > 0) {
1399
+ body = `${withBg(` ${this.#renderMarked(text, marks, textWidth, soft, strong, startCol)} `, soft)}\x1b[0m`;
1400
+ } else {
1401
+ const slice = sliceWithWidth(text, startCol, textWidth);
1402
+ body = `${withBg(` ${slice.text}${" ".repeat(Math.max(0, textWidth - slice.width))} `, soft)}\x1b[0m`;
1403
+ }
1404
+ return `${gutterText}${body}`;
1405
+ }
1406
+
1407
+ /** Compose a changed line from soft/strong background segments along mark ranges. */
1408
+ #renderMarked(
1409
+ text: string,
1410
+ marks: MarkRanges,
1411
+ textWidth: number,
1412
+ soft: string,
1413
+ strong: string,
1414
+ startCol: number,
1415
+ ): string {
1416
+ const end = startCol + textWidth;
1417
+ let cursor = startCol;
1418
+ let out = "";
1419
+ let used = 0;
1420
+ const emit = (from: number, to: number, bg: string): void => {
1421
+ if (to <= from) return;
1422
+ const slice = sliceWithWidth(text, from, to - from);
1423
+ out += `${withBg(slice.text, bg)}\x1b[0m`;
1424
+ used += slice.width;
1425
+ };
1426
+ for (const [markStart, markEnd] of marks) {
1427
+ if (markEnd <= cursor) continue;
1428
+ if (markStart >= end) break;
1429
+ emit(cursor, Math.min(markStart, end), soft);
1430
+ emit(Math.max(markStart, cursor), Math.min(markEnd, end), strong);
1431
+ cursor = Math.min(markEnd, end);
1432
+ }
1433
+ emit(cursor, end, soft);
1434
+ if (used < textWidth) out += `${soft}${" ".repeat(textWidth - used)}\x1b[0m`;
1435
+ return out;
1436
+ }
1437
+
1438
+ #renderMinimap(visuals: Visual[], height: number, colors: DiffPalette): string[] {
1439
+ const total = visuals.length;
1440
+ const lines: string[] = [];
1441
+ const bandKind = (band: number): RowKind | "hunk" | null => {
1442
+ const from = Math.floor((band * total) / (height * 2));
1443
+ const to = Math.max(from + 1, Math.floor(((band + 1) * total) / (height * 2)));
1444
+ if (from >= total) return null;
1445
+ let best: RowKind | "hunk" = "context";
1446
+ for (let i = from; i < Math.min(to, total); i++) {
1447
+ const kind = visualKind(visuals[i]);
1448
+ if (kind === "del") return "del";
1449
+ if (kind === "add") best = "add";
1450
+ else if (kind === "change" && best !== "add") best = "change";
1451
+ else if (kind === "hunk" && best === "context") best = "hunk";
1452
+ }
1453
+ return best;
1454
+ };
1455
+ const kindColor = (kind: RowKind | "hunk" | null, band: number): string | null => {
1456
+ if (kind === null) return null;
1457
+ const base =
1458
+ kind === "del"
1459
+ ? colors.mapDel
1460
+ : kind === "add"
1461
+ ? colors.mapAdd
1462
+ : kind === "change"
1463
+ ? colors.mapChange
1464
+ : kind === "hunk"
1465
+ ? colors.mapHunk
1466
+ : colors.mapContext;
1467
+ const docRow = Math.floor((band * total) / (height * 2));
1468
+ const inViewport = docRow >= this.scrollTop && docRow < this.scrollTop + height;
1469
+ return inViewport ? mixHex(base, textHex(), 0.25) : base;
1470
+ };
1471
+ for (let i = 0; i < height; i++) {
1472
+ const topColor = kindColor(bandKind(i * 2), i * 2);
1473
+ const bottomColor = kindColor(bandKind(i * 2 + 1), i * 2 + 1);
1474
+ if (topColor === null && bottomColor === null) {
1475
+ lines.push(" ");
1476
+ } else {
1477
+ const fg = fgAnsiHex(topColor ?? bottomColor ?? "#000000");
1478
+ const bg = bottomColor ? bgAnsiHex(bottomColor) : "";
1479
+ lines.push(`${fg}${bg}▀\x1b[0m`);
1480
+ }
1481
+ }
1482
+ return lines;
1483
+ }
1484
+ }
1485
+
1486
+ /** Index into `doc.rows` a visual maps to, or -1 (headers, blanks, hunk rows). */
1487
+ function visualRowIndex(visual: Visual | undefined): number {
1488
+ if (!visual) return -1;
1489
+ switch (visual.t) {
1490
+ case "split":
1491
+ case "line":
1492
+ case "file":
1493
+ return visual.rowIndex;
1494
+ default:
1495
+ return -1;
1496
+ }
1497
+ }
1498
+
1499
+ function visualKind(visual: Visual): RowKind | "hunk" | null {
1500
+ switch (visual.t) {
1501
+ case "split":
1502
+ return visual.row.kind;
1503
+ case "line":
1504
+ return visual.row.kind === "change" ? (visual.side === "old" ? "del" : "add") : visual.row.kind;
1505
+ case "header":
1506
+ return "hunk";
1507
+ case "file":
1508
+ return "context";
1509
+ default:
1510
+ return null;
1511
+ }
1512
+ }