painapple-code 1.0.0__py3-none-any.whl

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 (436) hide show
  1. painapple_code/__init__.py +30 -0
  2. painapple_code/__main__.py +18 -0
  3. painapple_code/_version.py +24 -0
  4. painapple_code/auth_middleware.py +930 -0
  5. painapple_code/cli/__init__.py +204 -0
  6. painapple_code/cli/deploy/__init__.py +15 -0
  7. painapple_code/cli/deploy/claude_seed.py +41 -0
  8. painapple_code/cli/deploy/config.py +260 -0
  9. painapple_code/cli/deploy/container.py +715 -0
  10. painapple_code/cli/deploy/launch.py +163 -0
  11. painapple_code/cli/deploy/runtime.py +223 -0
  12. painapple_code/cli/lifecycle_cmd.py +443 -0
  13. painapple_code/cli/list_cmd.py +572 -0
  14. painapple_code/cli/manage_cmd.py +670 -0
  15. painapple_code/cli/netinfo.py +125 -0
  16. painapple_code/cli/profiles.py +390 -0
  17. painapple_code/cli/serve_args.py +163 -0
  18. painapple_code/cli/serve_config.py +256 -0
  19. painapple_code/cli/setup_cmd.py +664 -0
  20. painapple_code/cli/ui.py +438 -0
  21. painapple_code/cost_analytics.py +879 -0
  22. painapple_code/data/models.defaults.yaml +18 -0
  23. painapple_code/data/models.yaml +18 -0
  24. painapple_code/data/presets.defaults.json +32 -0
  25. painapple_code/data/strings.yaml +2269 -0
  26. painapple_code/helpers.py +406 -0
  27. painapple_code/paths.py +1514 -0
  28. painapple_code/prompt_explorer.py +984 -0
  29. painapple_code/providers/__init__.py +224 -0
  30. painapple_code/providers/base.py +710 -0
  31. painapple_code/providers/claude/__init__.py +74 -0
  32. painapple_code/providers/claude/capabilities.py +218 -0
  33. painapple_code/providers/claude/errors.py +79 -0
  34. painapple_code/providers/claude/launch.py +50 -0
  35. painapple_code/providers/claude/summary.py +89 -0
  36. painapple_code/providers/claude/translate.py +74 -0
  37. painapple_code/providers/claude_sdk/__init__.py +139 -0
  38. painapple_code/providers/claude_sdk/driver.py +412 -0
  39. painapple_code/providers/codex/__init__.py +87 -0
  40. painapple_code/providers/codex/capabilities.py +321 -0
  41. painapple_code/providers/codex/errors.py +70 -0
  42. painapple_code/providers/codex/launch.py +142 -0
  43. painapple_code/providers/codex/summary.py +196 -0
  44. painapple_code/providers/codex/translate.py +204 -0
  45. painapple_code/providers/codex_app_server/__init__.py +100 -0
  46. painapple_code/providers/codex_app_server/capabilities.py +41 -0
  47. painapple_code/providers/codex_app_server/launch.py +202 -0
  48. painapple_code/providers/codex_app_server/summary.py +291 -0
  49. painapple_code/providers/codex_app_server/translate.py +263 -0
  50. painapple_code/providers/codex_app_server/transport.py +238 -0
  51. painapple_code/routes/__init__.py +0 -0
  52. painapple_code/routes/api_agents.py +441 -0
  53. painapple_code/routes/api_app.py +368 -0
  54. painapple_code/routes/api_app_commit_sections.py +127 -0
  55. painapple_code/routes/api_app_config.py +997 -0
  56. painapple_code/routes/api_app_session_prefs.py +217 -0
  57. painapple_code/routes/api_browser.py +690 -0
  58. painapple_code/routes/api_commands.py +374 -0
  59. painapple_code/routes/api_costs.py +128 -0
  60. painapple_code/routes/api_drafts.py +139 -0
  61. painapple_code/routes/api_exec.py +149 -0
  62. painapple_code/routes/api_files.py +542 -0
  63. painapple_code/routes/api_git.py +657 -0
  64. painapple_code/routes/api_logs.py +649 -0
  65. painapple_code/routes/api_plugins.py +275 -0
  66. painapple_code/routes/api_project_config.py +418 -0
  67. painapple_code/routes/api_prompts.py +231 -0
  68. painapple_code/routes/api_providers.py +53 -0
  69. painapple_code/routes/api_search.py +411 -0
  70. painapple_code/routes/api_session_stash.py +234 -0
  71. painapple_code/routes/api_session_welcome.py +151 -0
  72. painapple_code/routes/api_sessions.py +742 -0
  73. painapple_code/routes/api_shadow.py +323 -0
  74. painapple_code/routes/api_shadow_db.py +512 -0
  75. painapple_code/routes/api_shadow_files.py +332 -0
  76. painapple_code/routes/api_shadow_search.py +476 -0
  77. painapple_code/routes/api_skills.py +610 -0
  78. painapple_code/routes/api_tasks.py +147 -0
  79. painapple_code/routes/api_terminal.py +540 -0
  80. painapple_code/routes/api_upload.py +293 -0
  81. painapple_code/routes/api_viewer.py +229 -0
  82. painapple_code/routes/dependencies.py +109 -0
  83. painapple_code/routes/ws_chat.py +837 -0
  84. painapple_code/server.py +2208 -0
  85. painapple_code/server_logging.py +263 -0
  86. painapple_code/services/__init__.py +0 -0
  87. painapple_code/services/agent_session.py +2738 -0
  88. painapple_code/session_store.py +253 -0
  89. painapple_code/session_store_core.py +879 -0
  90. painapple_code/shadow_db.py +830 -0
  91. painapple_code/shadow_db_plans.py +369 -0
  92. painapple_code/shadow_db_queries.py +595 -0
  93. painapple_code/shadow_db_schema.py +676 -0
  94. painapple_code/shadow_git.py +1296 -0
  95. painapple_code/shadow_git_frontmatter.py +314 -0
  96. painapple_code/shadow_git_sections.py +520 -0
  97. painapple_code/shadow_git_summary.py +462 -0
  98. painapple_code/shadow_parser.py +560 -0
  99. painapple_code/static/css/00-variables.css +276 -0
  100. painapple_code/static/css/01-base.css +245 -0
  101. painapple_code/static/css/02-tooltips.css +36 -0
  102. painapple_code/static/css/09-left-rail.css +448 -0
  103. painapple_code/static/css/10-header.css +777 -0
  104. painapple_code/static/css/11-connection-bar.css +120 -0
  105. painapple_code/static/css/20-messages.css +750 -0
  106. painapple_code/static/css/21-tool-blocks.css +2340 -0
  107. painapple_code/static/css/22-edit-diff.css +619 -0
  108. painapple_code/static/css/23-thinking.css +1584 -0
  109. painapple_code/static/css/24-background-tasks.css +359 -0
  110. painapple_code/static/css/25-context-block.css +252 -0
  111. painapple_code/static/css/26-session-setup.css +144 -0
  112. painapple_code/static/css/30-markdown.css +854 -0
  113. painapple_code/static/css/40-input.css +2418 -0
  114. painapple_code/static/css/41-autocomplete.css +127 -0
  115. painapple_code/static/css/42-activity-strip.css +97 -0
  116. painapple_code/static/css/43-file-autocomplete.css +121 -0
  117. painapple_code/static/css/44-snippets-autocomplete.css +224 -0
  118. painapple_code/static/css/45-selection-bar.css +433 -0
  119. painapple_code/static/css/46-keyboard-bar.css +119 -0
  120. painapple_code/static/css/47-keyboard-longpress.css +116 -0
  121. painapple_code/static/css/47-token-profile.css +106 -0
  122. painapple_code/static/css/50-welcome.css +3095 -0
  123. painapple_code/static/css/51-modals.css +301 -0
  124. painapple_code/static/css/52-utilities.css +77 -0
  125. painapple_code/static/css/53-image-upload.css +456 -0
  126. painapple_code/static/css/54-todo-list.css +102 -0
  127. painapple_code/static/css/55-file-upload.css +113 -0
  128. painapple_code/static/css/56-chat-navigator.css +218 -0
  129. painapple_code/static/css/56-permission-card.css +332 -0
  130. painapple_code/static/css/56-plan-approval.css +204 -0
  131. painapple_code/static/css/56-question-form.css +602 -0
  132. painapple_code/static/css/57-swipe-indicator.css +95 -0
  133. painapple_code/static/css/58-session-families.css +627 -0
  134. painapple_code/static/css/59-about.css +196 -0
  135. painapple_code/static/css/61-log-explorer.css +723 -0
  136. painapple_code/static/css/62-json-tree.css +489 -0
  137. painapple_code/static/css/62-skills-widget.css +650 -0
  138. painapple_code/static/css/63-commands-widget.css +30 -0
  139. painapple_code/static/css/63-plugins-widget.css +174 -0
  140. painapple_code/static/css/63-terminal.css +516 -0
  141. painapple_code/static/css/64-search-files-widget.css +448 -0
  142. painapple_code/static/css/65-snippets-widget.css +426 -0
  143. painapple_code/static/css/66-active-sessions.css +979 -0
  144. painapple_code/static/css/66-agents-widget.css +265 -0
  145. painapple_code/static/css/66-file-explorer-widget.css +855 -0
  146. painapple_code/static/css/66-git-panel.css +791 -0
  147. painapple_code/static/css/66-history-explorer.css +1496 -0
  148. painapple_code/static/css/67-diff-viewer.css +602 -0
  149. painapple_code/static/css/67-editor-view.css +748 -0
  150. painapple_code/static/css/67-file-preview-widget.css +1873 -0
  151. painapple_code/static/css/68-browser-widget.css +337 -0
  152. painapple_code/static/css/68-compare-wizard.css +355 -0
  153. painapple_code/static/css/68-csv-preview.css +106 -0
  154. painapple_code/static/css/68-diff-preview.css +103 -0
  155. painapple_code/static/css/68-helpers-install-widget.css +491 -0
  156. painapple_code/static/css/68-image-annotate.css +346 -0
  157. painapple_code/static/css/68-terminal-view.css +98 -0
  158. painapple_code/static/css/68-uploads-widget.css +229 -0
  159. painapple_code/static/css/69-cost-analytics.css +537 -0
  160. painapple_code/static/css/70-config-panel.css +3736 -0
  161. painapple_code/static/css/70-excalidraw.css +136 -0
  162. painapple_code/static/css/70-prompt-explorer.css +890 -0
  163. painapple_code/static/css/71-chart.css +111 -0
  164. painapple_code/static/css/72-discussion-sidebar.css +678 -0
  165. painapple_code/static/css/74-debug-logs.css +656 -0
  166. painapple_code/static/css/80-open-dialog.css +146 -0
  167. painapple_code/static/css/80-quick-switcher.css +383 -0
  168. painapple_code/static/css/80-widget-system.css +1763 -0
  169. painapple_code/static/css/81-quick-actions.css +1020 -0
  170. painapple_code/static/css/82-context-popover.css +1233 -0
  171. painapple_code/static/css/83-context-menu.css +339 -0
  172. painapple_code/static/css/85-lazy-loading.css +102 -0
  173. painapple_code/static/css/86-zen-mode.css +708 -0
  174. painapple_code/static/css/87-grid-switcher.css +346 -0
  175. painapple_code/static/css/login.css +198 -0
  176. painapple_code/static/dist/app.bundle.js +7177 -0
  177. painapple_code/static/feature-triage.html +446 -0
  178. painapple_code/static/icons/apple-touch-icon.png +0 -0
  179. painapple_code/static/icons/favicon-32.png +0 -0
  180. painapple_code/static/icons/favicon.ico +0 -0
  181. painapple_code/static/icons/icon-128.png +0 -0
  182. painapple_code/static/icons/icon-144.png +0 -0
  183. painapple_code/static/icons/icon-152.png +0 -0
  184. painapple_code/static/icons/icon-180.png +0 -0
  185. painapple_code/static/icons/icon-192.png +0 -0
  186. painapple_code/static/icons/icon-384.png +0 -0
  187. painapple_code/static/icons/icon-512.png +0 -0
  188. painapple_code/static/icons/icon-72.png +0 -0
  189. painapple_code/static/icons/icon-96.png +0 -0
  190. painapple_code/static/js/action-delegate.js +187 -0
  191. painapple_code/static/js/activity-strip.js +193 -0
  192. painapple_code/static/js/app/context-menus.js +619 -0
  193. painapple_code/static/js/app/file-preview.js +163 -0
  194. painapple_code/static/js/app/init-ui.js +574 -0
  195. painapple_code/static/js/app/input-events.js +561 -0
  196. painapple_code/static/js/app/input-resize.js +313 -0
  197. painapple_code/static/js/app/message-actions.js +419 -0
  198. painapple_code/static/js/app/panels.js +210 -0
  199. painapple_code/static/js/app/rendering-delegators.js +246 -0
  200. painapple_code/static/js/app/server-loading.js +365 -0
  201. painapple_code/static/js/app/session-ops.js +287 -0
  202. painapple_code/static/js/app/session-switch.js +443 -0
  203. painapple_code/static/js/app/settings-helpers.js +280 -0
  204. painapple_code/static/js/app/startup-welcome.js +290 -0
  205. painapple_code/static/js/app-context.js +157 -0
  206. painapple_code/static/js/app.js +2134 -0
  207. painapple_code/static/js/auth-fetch.js +46 -0
  208. painapple_code/static/js/background-tasks.js +274 -0
  209. painapple_code/static/js/boot.js +73 -0
  210. painapple_code/static/js/caret-position.js +171 -0
  211. painapple_code/static/js/chat-navigator.js +710 -0
  212. painapple_code/static/js/chat-search.js +323 -0
  213. painapple_code/static/js/command-executor.js +176 -0
  214. painapple_code/static/js/command-store.js +497 -0
  215. painapple_code/static/js/components.js +1414 -0
  216. painapple_code/static/js/config.js +218 -0
  217. painapple_code/static/js/context-menu.js +740 -0
  218. painapple_code/static/js/controllers/chat-controller.js +4252 -0
  219. painapple_code/static/js/controllers/dialog-controller.js +217 -0
  220. painapple_code/static/js/controllers/tab-controller.js +1828 -0
  221. painapple_code/static/js/controllers/thinking-controller.js +915 -0
  222. painapple_code/static/js/diff-utils.js +486 -0
  223. painapple_code/static/js/drafts-pill.js +95 -0
  224. painapple_code/static/js/editor-view.js +815 -0
  225. painapple_code/static/js/effort-settings.js +430 -0
  226. painapple_code/static/js/feature-triage.js +811 -0
  227. painapple_code/static/js/file-autocomplete.js +516 -0
  228. painapple_code/static/js/file-tabs.js +123 -0
  229. painapple_code/static/js/gestures.js +448 -0
  230. painapple_code/static/js/grid-switcher.js +334 -0
  231. painapple_code/static/js/input-handler.js +1472 -0
  232. painapple_code/static/js/keyboard-bar.js +495 -0
  233. painapple_code/static/js/layout-switcher.js +109 -0
  234. painapple_code/static/js/linkify-utils.js +200 -0
  235. painapple_code/static/js/login.js +141 -0
  236. painapple_code/static/js/open-dialog.js +879 -0
  237. painapple_code/static/js/orphan-terminals.js +373 -0
  238. painapple_code/static/js/path-utils.js +179 -0
  239. painapple_code/static/js/perf-marks.js +102 -0
  240. painapple_code/static/js/permission-settings.js +475 -0
  241. painapple_code/static/js/pointer-input.js +59 -0
  242. painapple_code/static/js/preview/json-tree-menu.js +226 -0
  243. painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
  244. painapple_code/static/js/preview/json-tree.js +457 -0
  245. painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
  246. painapple_code/static/js/preview/preview-edit.js +367 -0
  247. painapple_code/static/js/preview/preview-events.js +361 -0
  248. painapple_code/static/js/preview/preview-history.js +567 -0
  249. painapple_code/static/js/preview/preview-inline-edit.js +869 -0
  250. painapple_code/static/js/preview/preview-poll.js +108 -0
  251. painapple_code/static/js/preview/preview-render.js +572 -0
  252. painapple_code/static/js/preview/preview-search.js +421 -0
  253. painapple_code/static/js/preview/preview-state.js +256 -0
  254. painapple_code/static/js/preview/preview-utils.js +251 -0
  255. painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
  256. painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
  257. painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
  258. painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
  259. painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
  260. painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
  261. painapple_code/static/js/preview-plugins/index.js +34 -0
  262. painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
  263. painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
  264. painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
  265. painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
  266. painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
  267. painapple_code/static/js/project-color-picker.js +110 -0
  268. painapple_code/static/js/project-colors.js +163 -0
  269. painapple_code/static/js/prompt-favorites.js +187 -0
  270. painapple_code/static/js/quick-actions-menu.js +1736 -0
  271. painapple_code/static/js/quick-actions-registry.js +1239 -0
  272. painapple_code/static/js/quick-switcher/controller.js +294 -0
  273. painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
  274. painapple_code/static/js/quick-switcher/index.js +30 -0
  275. painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
  276. painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
  277. painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
  278. painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
  279. painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
  280. painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
  281. painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
  282. painapple_code/static/js/quick-switcher/registry.js +69 -0
  283. painapple_code/static/js/quick-switcher/ui/item.js +54 -0
  284. painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
  285. painapple_code/static/js/recent-opens.js +40 -0
  286. painapple_code/static/js/scroll-manager.js +530 -0
  287. painapple_code/static/js/scroll-state-machine.js +359 -0
  288. painapple_code/static/js/selection/action-bar.js +793 -0
  289. painapple_code/static/js/selection/index.js +15 -0
  290. painapple_code/static/js/selection/selection-handler.js +877 -0
  291. painapple_code/static/js/selection/state.js +181 -0
  292. painapple_code/static/js/session/agent-progress.js +202 -0
  293. painapple_code/static/js/session/handle-agent-message.js +481 -0
  294. painapple_code/static/js/session/handle-message.js +550 -0
  295. painapple_code/static/js/session/interactive.js +587 -0
  296. painapple_code/static/js/session/message-store.js +271 -0
  297. painapple_code/static/js/session/messages.js +253 -0
  298. painapple_code/static/js/session/persistence.js +179 -0
  299. painapple_code/static/js/session/restore.js +136 -0
  300. painapple_code/static/js/session/sync.js +333 -0
  301. painapple_code/static/js/session-container-pool.js +371 -0
  302. painapple_code/static/js/session-setup-panel.js +274 -0
  303. painapple_code/static/js/session.js +1047 -0
  304. painapple_code/static/js/shortcut-hints.js +272 -0
  305. painapple_code/static/js/shortcuts.js +1146 -0
  306. painapple_code/static/js/skills-autocomplete.js +262 -0
  307. painapple_code/static/js/snippets-autocomplete.js +738 -0
  308. painapple_code/static/js/stash-refs-view.js +83 -0
  309. painapple_code/static/js/stash-ui.js +768 -0
  310. painapple_code/static/js/stash.js +785 -0
  311. painapple_code/static/js/status-bar.js +1184 -0
  312. painapple_code/static/js/token-profile.js +274 -0
  313. painapple_code/static/js/tool-renderer-blocks.js +1626 -0
  314. painapple_code/static/js/tool-renderer-thinking.js +813 -0
  315. painapple_code/static/js/tool-renderer.js +1500 -0
  316. painapple_code/static/js/tooltips.js +197 -0
  317. painapple_code/static/js/upload-manager.js +1019 -0
  318. painapple_code/static/js/utils.js +521 -0
  319. painapple_code/static/js/welcome/api.js +363 -0
  320. painapple_code/static/js/welcome/cards.js +626 -0
  321. painapple_code/static/js/welcome/context-menu.js +585 -0
  322. painapple_code/static/js/welcome/families.js +801 -0
  323. painapple_code/static/js/welcome/preview.js +234 -0
  324. painapple_code/static/js/welcome/state.js +129 -0
  325. painapple_code/static/js/welcome.js +1874 -0
  326. painapple_code/static/js/widget-system/base-widget.js +717 -0
  327. painapple_code/static/js/widget-system/device-manager.js +178 -0
  328. painapple_code/static/js/widget-system/event-bus.js +152 -0
  329. painapple_code/static/js/widget-system/icons.js +120 -0
  330. painapple_code/static/js/widget-system/index.js +49 -0
  331. painapple_code/static/js/widget-system/init.js +54 -0
  332. painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
  333. painapple_code/static/js/widget-system/types/floating.js +915 -0
  334. painapple_code/static/js/widget-system/types/index.js +40 -0
  335. painapple_code/static/js/widget-system/types/modal.js +147 -0
  336. painapple_code/static/js/widget-system/types/sidebar.js +158 -0
  337. painapple_code/static/js/widget-system/types/tab.js +146 -0
  338. painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
  339. painapple_code/static/js/widget-system/widget-manager.js +1187 -0
  340. painapple_code/static/js/widgets/about-widget.js +260 -0
  341. painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
  342. painapple_code/static/js/widgets/agents-widget.js +897 -0
  343. painapple_code/static/js/widgets/browser-widget.js +579 -0
  344. painapple_code/static/js/widgets/commands-widget.js +694 -0
  345. painapple_code/static/js/widgets/config/commit-sections.js +449 -0
  346. painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
  347. painapple_code/static/js/widgets/config/gestures.js +161 -0
  348. painapple_code/static/js/widgets/config/models-tab.js +988 -0
  349. painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
  350. painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
  351. painapple_code/static/js/widgets/config/state.js +517 -0
  352. painapple_code/static/js/widgets/config/system-controls.js +90 -0
  353. painapple_code/static/js/widgets/config-widget.js +1535 -0
  354. painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
  355. painapple_code/static/js/widgets/debug-widget.js +693 -0
  356. painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
  357. painapple_code/static/js/widgets/discussion-widget.js +1107 -0
  358. painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
  359. painapple_code/static/js/widgets/file-preview-widget.js +861 -0
  360. painapple_code/static/js/widgets/git-widget.js +794 -0
  361. painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
  362. painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
  363. painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
  364. painapple_code/static/js/widgets/image-preview-widget.js +692 -0
  365. painapple_code/static/js/widgets/index.js +176 -0
  366. painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
  367. painapple_code/static/js/widgets/plugins-widget.js +305 -0
  368. painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
  369. painapple_code/static/js/widgets/search-files-widget.js +529 -0
  370. painapple_code/static/js/widgets/skills-widget.js +843 -0
  371. painapple_code/static/js/widgets/snippets-widget.js +479 -0
  372. painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
  373. painapple_code/static/js/widgets/tasks-widget.js +379 -0
  374. painapple_code/static/js/widgets/terminal/connection.js +283 -0
  375. painapple_code/static/js/widgets/terminal/gestures.js +357 -0
  376. painapple_code/static/js/widgets/terminal/init.js +642 -0
  377. painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
  378. painapple_code/static/js/widgets/terminal/osc52.js +213 -0
  379. painapple_code/static/js/widgets/terminal/render.js +159 -0
  380. painapple_code/static/js/widgets/terminal/size.js +54 -0
  381. painapple_code/static/js/widgets/terminal/state.js +211 -0
  382. painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
  383. painapple_code/static/js/widgets/terminal-widget.js +793 -0
  384. painapple_code/static/js/widgets/uploads-widget.js +461 -0
  385. painapple_code/static/js/widgets/zen-widget.js +1001 -0
  386. painapple_code/static/login.html +51 -0
  387. painapple_code/static/manifest.json +73 -0
  388. painapple_code/static/sw.js +293 -0
  389. painapple_code/static/vendor/LICENSES.txt +769 -0
  390. painapple_code/static/vendor/README.md +103 -0
  391. painapple_code/static/vendor/codemirror.js +35 -0
  392. painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
  393. painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
  394. painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
  395. painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
  396. painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
  397. painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
  398. painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
  399. painapple_code/static/vendor/highlight.min.js +1213 -0
  400. painapple_code/static/vendor/marked.min.js +6 -0
  401. painapple_code/static/vendor/purify.min.js +3 -0
  402. painapple_code/static/vendor/xterm-addon-fit.js +2 -0
  403. painapple_code/static/vendor/xterm.css +209 -0
  404. painapple_code/static/vendor/xterm.js +2 -0
  405. painapple_code/static/web-client.html +635 -0
  406. painapple_code/subprocess_registry.py +535 -0
  407. painapple_code/tls_cert.py +79 -0
  408. painapple_code/tools/agents/shadow-git-helper.md +400 -0
  409. painapple_code/tools/excalidraw-to-svg.js +143 -0
  410. painapple_code/tools/install-helpers.sh +148 -0
  411. painapple_code/tools/shadow-git +320 -0
  412. painapple_code/tools/shadow-query +120 -0
  413. painapple_code/tools/vegalite-to-svg.js +79 -0
  414. painapple_code/turn_query.py +766 -0
  415. painapple_code/turn_tracker.py +221 -0
  416. painapple_code/utils/__init__.py +0 -0
  417. painapple_code/utils/agent_cli.py +306 -0
  418. painapple_code/utils/chart.py +103 -0
  419. painapple_code/utils/excalidraw.py +107 -0
  420. painapple_code/utils/file_lock.py +162 -0
  421. painapple_code/utils/file_paths.py +648 -0
  422. painapple_code/utils/generate_icons.py +251 -0
  423. painapple_code/utils/proc.py +205 -0
  424. painapple_code/utils/pty_backend.py +59 -0
  425. painapple_code/utils/pty_posix.py +160 -0
  426. painapple_code/utils/pty_windows.py +245 -0
  427. painapple_code/utils/token_profiles.py +91 -0
  428. painapple_code/viewer_templates.py +657 -0
  429. painapple_code/welcome_search.py +879 -0
  430. painapple_code-1.0.0.dist-info/METADATA +292 -0
  431. painapple_code-1.0.0.dist-info/RECORD +436 -0
  432. painapple_code-1.0.0.dist-info/WHEEL +5 -0
  433. painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
  434. painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
  435. painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
  436. painapple_code-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1420 @@
1
+ /**
2
+ * Diff Viewer Widget - Side-by-side file comparison
3
+ *
4
+ * Provides a two-column diff view using CSS Grid (single scroll container,
5
+ * perfect line alignment). Supports multiple entry points:
6
+ * - Edit tool blocks (automatic old/new)
7
+ * - Changes widget (session file changes)
8
+ * - Git widget (HEAD vs working tree)
9
+ * - Compare Wizard (shadow history, git history, branches, working changes, file picker)
10
+ *
11
+ * Views: split (side-by-side) | unified (classic)
12
+ */
13
+
14
+ import S from '../strings.js';
15
+ import { escapeHtml } from '../utils.js';
16
+ import { CONFIG } from '../config.js';
17
+ import { WidgetManager, ICONS } from '../widget-system/index.js';
18
+ import { generateSmartDiff, renderSmartDiff, renderSideBySideDiff } from '../diff-utils.js';
19
+ import { showToast, ContextMenu } from '../context-menu.js';
20
+ import { basename, isAbsolutePath, joinPath, relativeTo } from '../path-utils.js';
21
+
22
+ const PREF_KEY_VIEW_MODE = 'diff-viewer-view-mode';
23
+ const PREF_KEY_WRAP_LINES = 'diff-viewer-wrap-lines';
24
+
25
+ function loadViewMode() {
26
+ try {
27
+ const v = localStorage.getItem(PREF_KEY_VIEW_MODE);
28
+ return v === 'unified' || v === 'split' ? v : null;
29
+ } catch { return null; }
30
+ }
31
+ function saveViewMode(v) {
32
+ try { localStorage.setItem(PREF_KEY_VIEW_MODE, v); } catch {}
33
+ }
34
+ function loadWrapLines() {
35
+ try { return localStorage.getItem(PREF_KEY_WRAP_LINES) === '1'; } catch { return false; }
36
+ }
37
+ function saveWrapLines(v) {
38
+ try { localStorage.setItem(PREF_KEY_WRAP_LINES, v ? '1' : '0'); } catch {}
39
+ }
40
+
41
+ // ═══════════════════════════════════════════════════════════════
42
+ // State
43
+ // ═══════════════════════════════════════════════════════════════
44
+
45
+ class DiffViewerState {
46
+ constructor() { this.reset(); }
47
+ reset() {
48
+ this.files = []; // [{path, oldContent, newContent, oldLabel, newLabel, additions, deletions}]
49
+ this.currentFileIndex = 0;
50
+ this.viewMode = loadViewMode() || 'split'; // 'split' | 'unified'
51
+ this.wrapLines = loadWrapLines();
52
+ this.source = null; // 'edit-tool' | 'git' | 'diff' | 'compare' | 'wizard'
53
+ this.loading = false;
54
+ this.error = null;
55
+ this.currentContainer = null;
56
+ this.hunkElements = []; // cached hunk start elements for keyboard nav
57
+ this.currentHunkIndex = -1;
58
+
59
+ // History bar state
60
+ this.historyCommits = null; // null = not loaded; [] = no history; [...] = loaded
61
+ this.historyLoading = false;
62
+ this.historyKey = null; // `${cwd}:${path}` of last loaded history
63
+ this.historyIndex = -1; // -1 = not aligned to a shadow commit; else index into historyCommits
64
+
65
+ // Wizard state
66
+ this.wizardActive = false;
67
+ this.wizardStep = 'source'; // 'source' | 'shadow' | 'git' | 'branch' | 'working' | 'file'
68
+ this.wizardFilePath = null;
69
+ this.wizardFileContent = null;
70
+ this.wizardCwd = null;
71
+ this.wizardFocusIndex = -1; // keyboard nav focus within lists/cards
72
+ this.wizardPickerData = []; // loaded commits/branches/files
73
+ this.wizardFileQuery = ''; // search filter for file picker
74
+ this.wizardAllFiles = null; // cached full file list
75
+ }
76
+ }
77
+
78
+ const states = new Map();
79
+
80
+ function getState(sessionId) {
81
+ sessionId = sessionId || WidgetManager.currentSessionId || '_global';
82
+ if (!states.has(sessionId)) states.set(sessionId, new DiffViewerState());
83
+ return states.get(sessionId);
84
+ }
85
+
86
+ function destroyState(sessionId) {
87
+ states.delete(sessionId);
88
+ }
89
+
90
+ // ═══════════════════════════════════════════════════════════════
91
+ // Edit data cache (for tool-renderer onclick passing)
92
+ // ═══════════════════════════════════════════════════════════════
93
+
94
+ const editDataCache = new Map();
95
+ let editDataCounter = 0;
96
+
97
+ function cacheEditData(filePath, oldContent, newContent, startLine) {
98
+ const id = `edit-${++editDataCounter}`;
99
+ editDataCache.set(id, { filePath, oldContent, newContent, startLine });
100
+ // Limit cache size
101
+ if (editDataCache.size > 200) {
102
+ const first = editDataCache.keys().next().value;
103
+ editDataCache.delete(first);
104
+ }
105
+ return id;
106
+ }
107
+
108
+ function openFromCache(cacheId) {
109
+ const data = editDataCache.get(cacheId);
110
+ if (!data) return;
111
+ DiffViewerWidget.openWithContent(data.filePath, data.oldContent, data.newContent, {
112
+ source: 'edit-tool',
113
+ startLine: data.startLine
114
+ });
115
+ }
116
+
117
+ // ═══════════════════════════════════════════════════════════════
118
+ // Compare Wizard
119
+ // ═══════════════════════════════════════════════════════════════
120
+
121
+ const WIZARD_SOURCES = [
122
+ { id: 'shadow', icon: 'history', titleKey: 'source_shadow', descKey: 'source_shadow_desc' },
123
+ { id: 'git', icon: 'commit', titleKey: 'source_git', descKey: 'source_git_desc' },
124
+ { id: 'branch', icon: 'branch', titleKey: 'source_branch', descKey: 'source_branch_desc' },
125
+ { id: 'working',icon: 'edit', titleKey: 'source_working', descKey: 'source_working_desc' },
126
+ { id: 'file', icon: 'file', titleKey: 'source_file', descKey: 'source_file_desc' },
127
+ ];
128
+
129
+ function renderWizard(sessionId) {
130
+ const state = getState(sessionId);
131
+ const container = state.currentContainer;
132
+ if (!container) return;
133
+
134
+ if (state.wizardStep === 'source') {
135
+ renderWizardSourcePicker(state, container, sessionId);
136
+ } else {
137
+ renderWizardPicker(state, container, sessionId);
138
+ }
139
+
140
+ // Update widget summary
141
+ const widget = WidgetManager.get('diff-viewer');
142
+ if (widget) {
143
+ const W = S.widgets.diff_viewer.wizard;
144
+ widget.setSummary?.(W.choose_source);
145
+ }
146
+
147
+ container.tabIndex = -1;
148
+ container.focus();
149
+ }
150
+
151
+ function renderWizardSourcePicker(state, container, sessionId) {
152
+ const W = S.widgets.diff_viewer.wizard;
153
+ const fileName = basename(state.wizardFilePath);
154
+
155
+ let cardsHtml = '';
156
+ WIZARD_SOURCES.forEach((src, i) => {
157
+ const focused = i === state.wizardFocusIndex ? ' cw-focused' : '';
158
+ cardsHtml += `
159
+ <button class="cw-source-card${focused}" data-source="${src.id}" data-idx="${i}">
160
+ <span class="cw-source-icon">${ICONS[src.icon] || ''}<span class="cw-source-title">${W[src.titleKey]}</span></span>
161
+ <span class="cw-source-desc">${W[src.descKey]}</span>
162
+ </button>`;
163
+ });
164
+
165
+ container.innerHTML = `
166
+ <div class="cw-wizard">
167
+ <div class="cw-file-header">
168
+ <span class="cw-file-name">${escapeHtml(fileName)}</span>
169
+ <span class="cw-file-hint">${W.choose_source}</span>
170
+ </div>
171
+ <div class="cw-sources">${cardsHtml}</div>
172
+ </div>`;
173
+
174
+ container.querySelectorAll('.cw-source-card').forEach(btn => {
175
+ btn.addEventListener('click', () => wizardSelectSource(state, btn.dataset.source, sessionId));
176
+ });
177
+ }
178
+
179
+ async function wizardSelectSource(state, sourceId, sessionId) {
180
+ state.wizardStep = sourceId;
181
+ state.wizardFocusIndex = -1;
182
+ state.wizardPickerData = [];
183
+
184
+ // For working changes, render immediately (no data to fetch)
185
+ if (sourceId === 'working') {
186
+ renderWizard(sessionId);
187
+ return;
188
+ }
189
+
190
+ // Show loading
191
+ const container = state.currentContainer;
192
+ if (container) {
193
+ container.innerHTML = `<div class="cw-wizard"><div class="cw-loading">${ICONS.loading} ${S.widgets.diff_viewer.wizard.loading_commits}</div></div>`;
194
+ }
195
+
196
+ const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
197
+ const filePath = state.wizardFilePath;
198
+ // Relative path for API calls
199
+ const relPath = relativeTo(filePath, cwd);
200
+
201
+ try {
202
+ if (sourceId === 'shadow') {
203
+ const resp = await fetch(`${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/history?cwd=${encodeURIComponent(cwd)}&limit=50`);
204
+ if (resp.ok) {
205
+ const data = await resp.json();
206
+ state.wizardPickerData = data.commits || [];
207
+ }
208
+ } else if (sourceId === 'git') {
209
+ const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-log?file=${encodeURIComponent(relPath)}&cwd=${encodeURIComponent(cwd)}&limit=30`);
210
+ if (resp.ok) {
211
+ const data = await resp.json();
212
+ state.wizardPickerData = data.commits || [];
213
+ }
214
+ } else if (sourceId === 'branch') {
215
+ const resp = await fetch(`${CONFIG.API_BASE}/api/git/branches?cwd=${encodeURIComponent(cwd)}`);
216
+ if (resp.ok) {
217
+ const data = await resp.json();
218
+ state.wizardPickerData = (data.branches || []).filter(b => !b.isCurrent);
219
+ }
220
+ } else if (sourceId === 'file') {
221
+ if (!state.wizardAllFiles) {
222
+ const resp = await fetch(`${CONFIG.API_BASE}/api/files/list?cwd=${encodeURIComponent(cwd)}`);
223
+ if (resp.ok) {
224
+ const data = await resp.json();
225
+ state.wizardAllFiles = (data.files || []).filter(f => f !== relPath);
226
+ }
227
+ }
228
+ state.wizardPickerData = state.wizardAllFiles || [];
229
+ }
230
+ } catch (err) {
231
+ console.error('[DiffViewer] Wizard fetch error:', err);
232
+ }
233
+
234
+ renderWizard(sessionId);
235
+ }
236
+
237
+ function renderWizardPicker(state, container, sessionId) {
238
+ const W = S.widgets.diff_viewer.wizard;
239
+ const fileName = basename(state.wizardFilePath);
240
+ const step = state.wizardStep;
241
+
242
+ // Build picker header with back button
243
+ const backHtml = `
244
+ <div class="cw-picker-header">
245
+ <button class="cw-back-btn" data-action="wizard-back">${ICONS.back} ${W.back}</button>
246
+ <span class="cw-picker-title">${getPickerTitle(step, fileName)}</span>
247
+ </div>`;
248
+
249
+ let bodyHtml = '';
250
+
251
+ if (step === 'working') {
252
+ bodyHtml = renderWorkingPicker(state);
253
+ } else if (step === 'file') {
254
+ bodyHtml = renderFilePicker(state);
255
+ } else {
256
+ bodyHtml = renderCommitList(state, step);
257
+ }
258
+
259
+ container.innerHTML = `<div class="cw-wizard">${backHtml}${bodyHtml}</div>`;
260
+
261
+ // Wire up back button
262
+ container.querySelector('[data-action="wizard-back"]')?.addEventListener('click', () => {
263
+ state.wizardStep = 'source';
264
+ state.wizardFocusIndex = -1;
265
+ renderWizard(sessionId);
266
+ });
267
+
268
+ // Wire up commit/branch clicks
269
+ container.querySelectorAll('.cw-item').forEach(el => {
270
+ el.addEventListener('click', () => {
271
+ wizardPickItem(state, sessionId, el.dataset);
272
+ });
273
+ });
274
+
275
+ // Wire up working change buttons
276
+ container.querySelectorAll('.cw-working-btn').forEach(btn => {
277
+ btn.addEventListener('click', () => {
278
+ wizardPickWorking(state, sessionId, btn.dataset.staged === 'true');
279
+ });
280
+ });
281
+
282
+ // Wire up file picker search
283
+ const searchInput = container.querySelector('.cw-file-search');
284
+ if (searchInput) {
285
+ searchInput.value = state.wizardFileQuery;
286
+ searchInput.addEventListener('input', (e) => {
287
+ state.wizardFileQuery = e.target.value;
288
+ // Re-render just the file list
289
+ const listEl = container.querySelector('.cw-list');
290
+ if (listEl) listEl.innerHTML = renderFileListItems(state);
291
+ // Re-wire file clicks
292
+ container.querySelectorAll('.cw-file-item').forEach(el => {
293
+ el.addEventListener('click', () => {
294
+ wizardPickFile(state, sessionId, el.dataset.path);
295
+ });
296
+ });
297
+ });
298
+ searchInput.focus();
299
+ }
300
+
301
+ // Wire up file item clicks
302
+ container.querySelectorAll('.cw-file-item').forEach(el => {
303
+ el.addEventListener('click', () => {
304
+ wizardPickFile(state, sessionId, el.dataset.path);
305
+ });
306
+ });
307
+ }
308
+
309
+ function getPickerTitle(step, fileName) {
310
+ const W = S.widgets.diff_viewer.wizard;
311
+ switch (step) {
312
+ case 'shadow': return W.picker_shadow.replace('{file}', fileName);
313
+ case 'git': return W.picker_git.replace('{file}', fileName);
314
+ case 'branch': return W.picker_branch;
315
+ case 'file': return W.picker_file;
316
+ case 'working': return W.source_working;
317
+ default: return '';
318
+ }
319
+ }
320
+
321
+ function renderCommitList(state, step) {
322
+ const W = S.widgets.diff_viewer.wizard;
323
+ const items = state.wizardPickerData;
324
+
325
+ if (!items.length) {
326
+ const emptyMsg = step === 'shadow' ? W.no_shadow_history :
327
+ step === 'branch' ? W.no_branches : W.no_git_history;
328
+ return `<div class="cw-empty">${emptyMsg}</div>`;
329
+ }
330
+
331
+ let html = '<div class="cw-list">';
332
+ items.forEach((item, i) => {
333
+ const focused = i === state.wizardFocusIndex ? ' cw-focused' : '';
334
+ const time = item.timestamp ? timeAgo(item.timestamp) : '';
335
+
336
+ if (step === 'branch') {
337
+ html += `<div class="cw-item${focused}" data-ref="${escapeHtml(item.name)}" data-idx="${i}">
338
+ <span class="cw-item-hash">${escapeHtml(item.name)}</span>
339
+ <span class="cw-item-subject">${escapeHtml(item.subject || '')}</span>
340
+ <span class="cw-item-time">${time}</span>
341
+ </div>`;
342
+ } else {
343
+ const hash = item.hashShort || item.hash?.slice(0, 8) || '';
344
+ const ref = item.hashFull || item.hash || '';
345
+ const statsHtml = (item.additions || item.deletions) ? `
346
+ <span class="cw-item-stats">
347
+ ${item.additions ? `<span class="added">+${item.additions}</span>` : ''}
348
+ ${item.deletions ? `<span class="removed">-${item.deletions}</span>` : ''}
349
+ </span>` : '';
350
+
351
+ html += `<div class="cw-item${focused}" data-ref="${escapeHtml(ref)}" data-idx="${i}">
352
+ <span class="cw-item-hash">${escapeHtml(hash)}</span>
353
+ <span class="cw-item-subject">${escapeHtml(item.subject || '')}</span>
354
+ ${statsHtml}
355
+ ${item.author ? `<span class="cw-item-author">${escapeHtml(item.author)}</span>` : ''}
356
+ <span class="cw-item-time">${time}</span>
357
+ </div>`;
358
+ }
359
+ });
360
+ html += '</div>';
361
+ return html;
362
+ }
363
+
364
+ function renderWorkingPicker(state) {
365
+ const W = S.widgets.diff_viewer.wizard;
366
+ return `
367
+ <div class="cw-working-options">
368
+ <button class="cw-working-btn" data-staged="false">
369
+ ${ICONS.edit}
370
+ <span>${W.working_tree}</span>
371
+ </button>
372
+ <button class="cw-working-btn" data-staged="true">
373
+ ${ICONS.check}
374
+ <span>${W.staged_changes}</span>
375
+ </button>
376
+ </div>`;
377
+ }
378
+
379
+ function renderFilePicker(state) {
380
+ const W = S.widgets.diff_viewer.wizard;
381
+ return `
382
+ <input class="cw-file-search" type="text" placeholder="${W.search_placeholder}" autocomplete="off" spellcheck="false">
383
+ <div class="cw-list">${renderFileListItems(state)}</div>`;
384
+ }
385
+
386
+ function renderFileListItems(state) {
387
+ const query = state.wizardFileQuery.toLowerCase();
388
+ const files = state.wizardPickerData;
389
+ const filtered = query ? files.filter(f => f.toLowerCase().includes(query)) : files;
390
+ const shown = filtered.slice(0, 200); // limit for performance
391
+
392
+ if (!shown.length) return `<div class="cw-empty">${S.widgets.diff_viewer.wizard.no_git_history}</div>`;
393
+
394
+ let html = '';
395
+ shown.forEach((filePath, i) => {
396
+ const name = basename(filePath);
397
+ const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/') + 1) : '';
398
+ const focused = i === state.wizardFocusIndex ? ' cw-focused' : '';
399
+ html += `<div class="cw-file-item${focused}" data-path="${escapeHtml(filePath)}" data-idx="${i}">
400
+ <span class="cw-file-item-icon">${ICONS.file}</span>
401
+ <span class="cw-file-item-name">${escapeHtml(name)}</span>
402
+ <span class="cw-file-item-dir">${escapeHtml(dir)}</span>
403
+ </div>`;
404
+ });
405
+ return html;
406
+ }
407
+
408
+ // ── Wizard actions ──
409
+
410
+ async function wizardPickItem(state, sessionId, dataset) {
411
+ const ref = dataset.ref;
412
+ if (!ref) return;
413
+
414
+ const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
415
+ const filePath = state.wizardFilePath;
416
+ const relPath = relativeTo(filePath, cwd);
417
+ const step = state.wizardStep;
418
+
419
+ // Show loading in the diff viewer
420
+ state.wizardActive = false;
421
+ state.loading = true;
422
+ state.files = [];
423
+ renderContent(sessionId);
424
+
425
+ try {
426
+ let refContent;
427
+ if (step === 'shadow') {
428
+ const resp = await fetch(`${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/content?ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`);
429
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
430
+ const data = await resp.json();
431
+ refContent = data.content;
432
+ } else if (step === 'git' || step === 'branch') {
433
+ const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-at-ref?file=${encodeURIComponent(relPath)}&ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`);
434
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
435
+ const data = await resp.json();
436
+ refContent = data.content;
437
+ }
438
+
439
+ if (refContent !== undefined) {
440
+ const currentContent = state.wizardFileContent || '';
441
+ const refLabel = step === 'branch' ? ref : ref.slice(0, 8);
442
+ state.loading = false;
443
+ state.files = [{
444
+ path: relPath,
445
+ oldContent: refContent,
446
+ newContent: currentContent,
447
+ oldLabel: refLabel,
448
+ newLabel: 'Current',
449
+ startLine: 1,
450
+ additions: 0,
451
+ deletions: 0
452
+ }];
453
+ state.source = 'wizard';
454
+ state.viewMode = loadViewMode() || ((state.currentContainer?.offsetWidth >= 600) ? 'split' : 'unified');
455
+ state.currentFileIndex = 0;
456
+ state.currentHunkIndex = -1;
457
+ renderContent(sessionId);
458
+ }
459
+ } catch (err) {
460
+ console.error('[DiffViewer] Wizard pick error:', err);
461
+ state.loading = false;
462
+ state.error = err.message;
463
+ renderContent(sessionId);
464
+ }
465
+ }
466
+
467
+ async function wizardPickWorking(state, sessionId, staged) {
468
+ const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
469
+ const filePath = state.wizardFilePath;
470
+ const relPath = relativeTo(filePath, cwd);
471
+
472
+ state.wizardActive = false;
473
+ state.loading = true;
474
+ state.files = [];
475
+ renderContent(sessionId);
476
+
477
+ try {
478
+ const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-content?file=${encodeURIComponent(relPath)}&cwd=${encodeURIComponent(cwd)}&staged=${staged}`);
479
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
480
+ const data = await resp.json();
481
+
482
+ state.loading = false;
483
+ state.files = [{
484
+ path: relPath,
485
+ oldContent: data.old || '',
486
+ newContent: data.new || '',
487
+ oldLabel: data.oldLabel || 'HEAD',
488
+ newLabel: data.newLabel || (staged ? 'Staged' : 'Working Tree'),
489
+ startLine: 1,
490
+ additions: 0,
491
+ deletions: 0
492
+ }];
493
+ state.source = 'wizard';
494
+ state.viewMode = loadViewMode() || ((state.currentContainer?.offsetWidth >= 600) ? 'split' : 'unified');
495
+ state.currentFileIndex = 0;
496
+ state.currentHunkIndex = -1;
497
+ renderContent(sessionId);
498
+ } catch (err) {
499
+ console.error('[DiffViewer] Working changes error:', err);
500
+ state.loading = false;
501
+ state.error = err.message;
502
+ renderContent(sessionId);
503
+ }
504
+ }
505
+
506
+ async function wizardPickFile(state, sessionId, otherPath) {
507
+ const cwd = state.wizardCwd || WidgetManager.currentCwd || '';
508
+
509
+ state.wizardActive = false;
510
+ state.loading = true;
511
+ state.files = [];
512
+ renderContent(sessionId);
513
+
514
+ try {
515
+ const fullOtherPath = isAbsolutePath(otherPath) ? otherPath : joinPath(cwd, otherPath);
516
+ const resp = await fetch(`${CONFIG.API_BASE}/api/file?path=${encodeURIComponent(fullOtherPath)}&cwd=${encodeURIComponent(cwd)}`);
517
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
518
+ const data = await resp.json();
519
+
520
+ const currentContent = state.wizardFileContent || '';
521
+ state.loading = false;
522
+ state.files = [{
523
+ path: state.wizardFilePath,
524
+ oldContent: currentContent,
525
+ newContent: data.content || '',
526
+ oldLabel: basename(state.wizardFilePath),
527
+ newLabel: basename(otherPath),
528
+ startLine: 1,
529
+ additions: 0,
530
+ deletions: 0
531
+ }];
532
+ state.source = 'wizard';
533
+ state.viewMode = loadViewMode() || ((state.currentContainer?.offsetWidth >= 600) ? 'split' : 'unified');
534
+ state.currentFileIndex = 0;
535
+ state.currentHunkIndex = -1;
536
+ renderContent(sessionId);
537
+ } catch (err) {
538
+ console.error('[DiffViewer] File compare error:', err);
539
+ state.loading = false;
540
+ state.error = err.message;
541
+ renderContent(sessionId);
542
+ }
543
+ }
544
+
545
+ // ═══════════════════════════════════════════════════════════════
546
+ // Quick Compare (one-click presets, bypasses wizard)
547
+ // ═══════════════════════════════════════════════════════════════
548
+
549
+ function _relPath(filePath, cwd) {
550
+ return relativeTo(filePath, cwd);
551
+ }
552
+
553
+ async function _shadowContent(relPath, ref, cwd) {
554
+ const url = `${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/content?ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`;
555
+ const resp = await fetch(url);
556
+ if (!resp.ok) return null;
557
+ return (await resp.json()).content;
558
+ }
559
+
560
+ async function _gitContentAtRef(relPath, ref, cwd) {
561
+ const url = `${CONFIG.API_BASE}/api/git/file-at-ref?file=${encodeURIComponent(relPath)}&ref=${encodeURIComponent(ref)}&cwd=${encodeURIComponent(cwd)}`;
562
+ const resp = await fetch(url);
563
+ if (!resp.ok) return null;
564
+ return (await resp.json()).content;
565
+ }
566
+
567
+ async function _currentFileContent(filePath, cwd) {
568
+ const url = `${CONFIG.API_BASE}/api/file?path=${encodeURIComponent(filePath)}&cwd=${encodeURIComponent(cwd)}`;
569
+ const resp = await fetch(url);
570
+ if (!resp.ok) return null;
571
+ return (await resp.json()).content;
572
+ }
573
+
574
+ async function quickCompareGitHead(filePath, cwd) {
575
+ cwd = cwd || WidgetManager.currentCwd || '';
576
+ const relPath = _relPath(filePath, cwd);
577
+ try {
578
+ const resp = await fetch(`${CONFIG.API_BASE}/api/git/file-content?file=${encodeURIComponent(relPath)}&cwd=${encodeURIComponent(cwd)}`);
579
+ if (!resp.ok) throw new Error('Git read failed');
580
+ const data = await resp.json();
581
+ DiffViewerWidget.openWithContent(filePath, data.old || '', data.new || '', {
582
+ source: 'quick-compare',
583
+ oldLabel: data.oldLabel || 'HEAD',
584
+ newLabel: data.newLabel || 'Working Tree',
585
+ });
586
+ } catch (err) {
587
+ console.error('[DiffViewer] quickCompareGitHead failed:', err);
588
+ showToast(`${S.toast.compare_failed}: ${err.message}`);
589
+ }
590
+ }
591
+
592
+ async function quickComparePreviousCommit(filePath, cwd) {
593
+ cwd = cwd || WidgetManager.currentCwd || '';
594
+ const relPath = _relPath(filePath, cwd);
595
+ try {
596
+ const [oldContent, newContent] = await Promise.all([
597
+ _gitContentAtRef(relPath, 'HEAD~1', cwd),
598
+ _gitContentAtRef(relPath, 'HEAD', cwd),
599
+ ]);
600
+ if (oldContent === null && newContent === null) {
601
+ showToast(S.toast.compare_no_git_history);
602
+ return;
603
+ }
604
+ DiffViewerWidget.openWithContent(filePath, oldContent || '', newContent || '', {
605
+ source: 'quick-compare',
606
+ oldLabel: 'HEAD~1',
607
+ newLabel: 'HEAD',
608
+ });
609
+ } catch (err) {
610
+ console.error('[DiffViewer] quickComparePreviousCommit failed:', err);
611
+ showToast(`${S.toast.compare_failed}: ${err.message}`);
612
+ }
613
+ }
614
+
615
+ // ═══════════════════════════════════════════════════════════════
616
+ // History Bar (stepper + picker + pivots)
617
+ // ═══════════════════════════════════════════════════════════════
618
+
619
+ function _stateCwd(state) {
620
+ return state.wizardCwd || WidgetManager.currentCwd || '';
621
+ }
622
+
623
+ async function loadHistoryForCurrentFile(state, sessionId) {
624
+ if (!state.files.length) return;
625
+ const file = state.files[state.currentFileIndex];
626
+ if (!file?.path || file.path === 'untitled') return;
627
+
628
+ const cwd = _stateCwd(state);
629
+ const key = `${cwd}:${file.path}`;
630
+ if (state.historyKey === key && state.historyCommits !== null) return;
631
+
632
+ state.historyKey = key;
633
+ state.historyLoading = true;
634
+ state.historyCommits = null;
635
+
636
+ const relPath = _relPath(file.path, cwd);
637
+ try {
638
+ const url = `${CONFIG.API_BASE}/api/shadow/files/${encodeURIComponent(relPath)}/history?cwd=${encodeURIComponent(cwd)}&limit=20`;
639
+ const resp = await fetch(url);
640
+ if (resp.ok) {
641
+ const data = await resp.json();
642
+ state.historyCommits = data.commits || [];
643
+ } else {
644
+ state.historyCommits = [];
645
+ }
646
+ } catch {
647
+ state.historyCommits = [];
648
+ }
649
+ state.historyLoading = false;
650
+
651
+ // Try to align historyIndex to current new-side label (a hash prefix match)
652
+ state.historyIndex = findHistoryIndex(state, file);
653
+
654
+ // Re-render bar only (cheaper than full content rerender)
655
+ renderHistoryBar(state, sessionId);
656
+ }
657
+
658
+ function findHistoryIndex(state, file) {
659
+ if (!state.historyCommits?.length) return -1;
660
+ const label = (file.newLabel || '').trim();
661
+ if (!label) return -1;
662
+ // Match on hashShort prefix
663
+ return state.historyCommits.findIndex(c =>
664
+ c.hash && (label === c.hash || label.startsWith(c.hash) || c.hash.startsWith(label))
665
+ );
666
+ }
667
+
668
+ function renderHistoryBar(state, sessionId) {
669
+ const container = state.currentContainer;
670
+ if (!container) return;
671
+ const barEl = container.querySelector('.dv-history-bar');
672
+ if (!barEl) return;
673
+
674
+ barEl.innerHTML = buildHistoryBarHtml(state);
675
+ wireHistoryBar(state, sessionId, barEl);
676
+ }
677
+
678
+ function buildHistoryBarHtml(state) {
679
+ const HB = S.widgets.diff_viewer.history_bar;
680
+ const commits = state.historyCommits;
681
+ const i = state.historyIndex;
682
+
683
+ const hasHistory = Array.isArray(commits) && commits.length > 0;
684
+ const atIndex = hasHistory && i >= 0 && i < commits.length;
685
+
686
+ const prevDisabled = !hasHistory || (i >= commits.length - 1);
687
+ const nextDisabled = !hasHistory || i <= 0;
688
+
689
+ let pickerLabel;
690
+ let pickerSub = '';
691
+ if (state.historyLoading) {
692
+ pickerLabel = HB.picker_loading;
693
+ } else if (!hasHistory) {
694
+ pickerLabel = HB.picker_empty;
695
+ } else if (atIndex) {
696
+ const c = commits[i];
697
+ pickerLabel = `${c.hash} · ${c.summary || c.subject || ''}`;
698
+ pickerSub = `${c.additions ? '+' + c.additions : ''}${c.deletions ? ' -' + c.deletions : ''}${c.timestamp ? ' · ' + timeAgo(c.timestamp) : ''}`.trim();
699
+ } else {
700
+ pickerLabel = HB.picker_no_match;
701
+ }
702
+
703
+ const dis = (d) => d ? ' dv-disabled' : '';
704
+ return `
705
+ <div class="dv-stepper">
706
+ <button class="dv-step-btn${dis(prevDisabled)}" data-action="step-prev" data-tooltip="${HB.step_prev}" ${prevDisabled ? 'disabled' : ''}>${ICONS.back}</button>
707
+ <button class="dv-picker" data-action="picker">
708
+ <span class="dv-picker-label">${escapeHtml(pickerLabel)}</span>
709
+ ${pickerSub ? `<span class="dv-picker-sub">${escapeHtml(pickerSub)}</span>` : ''}
710
+ <span class="dv-picker-caret">${ICONS.down}</span>
711
+ </button>
712
+ <button class="dv-step-btn${dis(nextDisabled)}" data-action="step-next" data-tooltip="${HB.step_next}" ${nextDisabled ? 'disabled' : ''}>${ICONS.forward}</button>
713
+ </div>
714
+ <div class="dv-divider"></div>
715
+ <div class="dv-pivots">
716
+ <button class="dv-pivot" data-action="pivot-head" data-tooltip="${HB.pivot_head_tooltip}">${HB.pivot_head}</button>
717
+ <button class="dv-pivot" data-action="pivot-working" data-tooltip="${HB.pivot_working_tooltip}">${HB.pivot_working}</button>
718
+ <button class="dv-pivot" data-action="pivot-pick" data-tooltip="${HB.pivot_pick_tooltip}">${HB.pivot_pick}</button>
719
+ </div>`;
720
+ }
721
+
722
+ function wireHistoryBar(state, sessionId, barEl) {
723
+ barEl.querySelector('[data-action="step-prev"]')?.addEventListener('click', (e) => {
724
+ if (e.currentTarget.disabled) return;
725
+ stepHistory(state, sessionId, +1);
726
+ });
727
+ barEl.querySelector('[data-action="step-next"]')?.addEventListener('click', (e) => {
728
+ if (e.currentTarget.disabled) return;
729
+ stepHistory(state, sessionId, -1);
730
+ });
731
+ barEl.querySelector('[data-action="picker"]')?.addEventListener('click', (e) => {
732
+ showHistoryPicker(state, sessionId, e.currentTarget);
733
+ });
734
+ barEl.querySelector('[data-action="pivot-head"]')?.addEventListener('click', () => {
735
+ pivotVsHead(state);
736
+ });
737
+ barEl.querySelector('[data-action="pivot-working"]')?.addEventListener('click', () => {
738
+ pivotVsWorking(state);
739
+ });
740
+ barEl.querySelector('[data-action="pivot-pick"]')?.addEventListener('click', () => {
741
+ pivotPick(state);
742
+ });
743
+ }
744
+
745
+ function stepHistory(state, sessionId, delta) {
746
+ if (!state.historyCommits?.length) return;
747
+ let target = state.historyIndex;
748
+ if (target < 0) {
749
+ // Not aligned yet — first step jumps to newest (index 0) when going newer,
750
+ // or to oldest visible window (index 0) when going older. Default to newest.
751
+ target = delta > 0 ? 0 : 0;
752
+ } else {
753
+ target = target + delta;
754
+ }
755
+ if (target < 0 || target >= state.historyCommits.length) return;
756
+ state.historyIndex = target;
757
+ loadDiffAtHistoryIndex(state, sessionId);
758
+ }
759
+
760
+ function showHistoryPicker(state, sessionId, anchor) {
761
+ if (!state.historyCommits?.length) return;
762
+ const HB = S.widgets.diff_viewer.history_bar;
763
+ const rect = anchor.getBoundingClientRect();
764
+
765
+ const items = [];
766
+ let lastSession = null;
767
+ state.historyCommits.forEach((c, i) => {
768
+ if (lastSession !== null && c.sessionId && c.sessionId !== lastSession) {
769
+ items.push({ type: 'separator' });
770
+ }
771
+ if (c.sessionId) lastSession = c.sessionId;
772
+
773
+ const stats = `${c.additions ? '+' + c.additions : ''}${c.deletions ? ' -' + c.deletions : ''}`.trim();
774
+ const time = c.timestamp ? timeAgo(c.timestamp) : '';
775
+ const sub = [stats, time].filter(Boolean).join(' · ');
776
+
777
+ items.push({
778
+ label: `${c.hash} · ${(c.summary || c.subject || '').slice(0, 80)}`,
779
+ sublabel: sub,
780
+ action: () => {
781
+ state.historyIndex = i;
782
+ loadDiffAtHistoryIndex(state, sessionId);
783
+ }
784
+ });
785
+ });
786
+
787
+ const menu = window.app?.contextMenu || (window._dvCtxMenu ||= new ContextMenu());
788
+ menu.show(rect.left, rect.bottom + 4, items);
789
+ }
790
+
791
+ async function loadDiffAtHistoryIndex(state, sessionId) {
792
+ const commits = state.historyCommits;
793
+ const i = state.historyIndex;
794
+ if (!commits || i < 0 || i >= commits.length) return;
795
+
796
+ const file = state.files[state.currentFileIndex];
797
+ if (!file) return;
798
+
799
+ const cwd = _stateCwd(state);
800
+ const relPath = _relPath(file.path, cwd);
801
+ const newCommit = commits[i];
802
+ const oldCommit = commits[i + 1]; // next-older on the linear file history
803
+
804
+ state.loading = true;
805
+ renderContent(sessionId);
806
+
807
+ try {
808
+ const newContent = await _shadowContent(relPath, newCommit.hashFull, cwd);
809
+ const oldContent = oldCommit
810
+ ? await _shadowContent(relPath, oldCommit.hashFull, cwd)
811
+ : await _shadowContent(relPath, newCommit.hashFull + '~1', cwd);
812
+
813
+ const HB = S.widgets.diff_viewer.history_bar;
814
+ state.loading = false;
815
+ state.files = [{
816
+ path: file.path,
817
+ oldContent: oldContent || '',
818
+ newContent: newContent || '',
819
+ oldLabel: oldCommit ? oldCommit.hash : HB.picker_initial,
820
+ newLabel: newCommit.hash,
821
+ startLine: 1,
822
+ additions: 0,
823
+ deletions: 0
824
+ }];
825
+ state.source = 'history-bar';
826
+ state.currentFileIndex = 0;
827
+ state.currentHunkIndex = -1;
828
+ renderContent(sessionId);
829
+ } catch (err) {
830
+ console.error('[DiffViewer] loadDiffAtHistoryIndex failed:', err);
831
+ state.loading = false;
832
+ state.error = err.message;
833
+ renderContent(sessionId);
834
+ }
835
+ }
836
+
837
+ async function pivotVsHead(state) {
838
+ const file = state.files[state.currentFileIndex];
839
+ if (!file?.path) return;
840
+ await quickCompareGitHead(file.path, _stateCwd(state));
841
+ }
842
+
843
+ async function pivotVsWorking(state) {
844
+ const file = state.files[state.currentFileIndex];
845
+ if (!file?.path) return;
846
+ const cwd = _stateCwd(state);
847
+ try {
848
+ const workingContent = await _currentFileContent(file.path, cwd);
849
+ if (workingContent === null) {
850
+ showToast(S.toast.compare_failed);
851
+ return;
852
+ }
853
+ DiffViewerWidget.openWithContent(file.path, file.newContent || '', workingContent, {
854
+ source: 'history-bar',
855
+ oldLabel: file.newLabel || S.widgets.diff_viewer.before,
856
+ newLabel: 'Working Tree'
857
+ });
858
+ } catch (err) {
859
+ console.error('[DiffViewer] pivotVsWorking failed:', err);
860
+ showToast(`${S.toast.compare_failed}: ${err.message}`);
861
+ }
862
+ }
863
+
864
+ function pivotPick(state) {
865
+ const file = state.files[state.currentFileIndex];
866
+ if (!file?.path) return;
867
+ DiffViewerWidget.openCompareWizard(file.path, _stateCwd(state));
868
+ }
869
+
870
+ function shouldShowHistoryBar(state) {
871
+ if (state.wizardActive) return false;
872
+ if (state.loading || state.error) return false;
873
+ if (state.files.length !== 1) return false;
874
+ const f = state.files[0];
875
+ if (!f?.path || f.path === 'untitled') return false;
876
+ return true;
877
+ }
878
+
879
+ // ═══════════════════════════════════════════════════════════════
880
+ // Rendering
881
+ // ═══════════════════════════════════════════════════════════════
882
+
883
+ function renderContent(sessionId) {
884
+ const state = getState(sessionId);
885
+ const container = state.currentContainer;
886
+ if (!container) return;
887
+
888
+ // Wizard mode takes priority
889
+ if (state.wizardActive) {
890
+ renderWizard(sessionId);
891
+ return;
892
+ }
893
+
894
+ if (state.loading) {
895
+ container.innerHTML = `<div class="diff-viewer-loading">${S.widgets.diff_viewer.loading}</div>`;
896
+ return;
897
+ }
898
+
899
+ if (state.error) {
900
+ container.innerHTML = `<div class="diff-viewer-empty">${escapeHtml(state.error)}</div>`;
901
+ return;
902
+ }
903
+
904
+ if (!state.files.length) {
905
+ container.innerHTML = `<div class="diff-viewer-empty">${S.widgets.diff_viewer.no_files}</div>`;
906
+ return;
907
+ }
908
+
909
+ const multiFile = state.files.length > 1;
910
+ const file = state.files[state.currentFileIndex];
911
+
912
+ // Determine view mode: force unified if container is narrow
913
+ const effectiveMode = (container.offsetWidth < 600) ? 'unified' : state.viewMode;
914
+
915
+ // Build diff entries
916
+ const oldLines = (file.oldContent || '').split('\n');
917
+ const newLines = (file.newContent || '').split('\n');
918
+ const startLine = file.startLine || 1;
919
+
920
+ const diffEntries = generateSmartDiff(oldLines, newLines, startLine, escapeHtml, {
921
+ contextLines: 3,
922
+ collapseThreshold: 6
923
+ });
924
+
925
+ // Calculate stats from entries if not provided
926
+ if (!file.additions && !file.deletions) {
927
+ file.additions = diffEntries.filter(e => e.type === 'added' || e.type === 'modified').length;
928
+ file.deletions = diffEntries.filter(e => e.type === 'removed' || e.type === 'modified').length;
929
+ }
930
+
931
+ // Render diff HTML
932
+ const diffHtml = effectiveMode === 'split'
933
+ ? renderSideBySideDiff(diffEntries, {
934
+ collapseLabel: (count) => S.widgets.diff_viewer.unchanged_lines.replace('{count}', count)
935
+ })
936
+ : `<div class="changes-diff">${renderSmartDiff(diffEntries)}</div>`;
937
+
938
+ // Build column header for split mode
939
+ const headerHtml = effectiveMode === 'split' ? `
940
+ <div class="sbs-header">
941
+ <div class="sbs-header-label left">${escapeHtml(file.oldLabel || S.widgets.diff_viewer.before)}</div>
942
+ <div class="sbs-header-gutter"></div>
943
+ <div class="sbs-header-label right">${escapeHtml(file.newLabel || S.widgets.diff_viewer.after)}</div>
944
+ </div>
945
+ ` : '';
946
+
947
+ // Multi-file: sidebar + tab bar
948
+ let fileListHtml = '';
949
+ let fileTabsHtml = '';
950
+ if (multiFile) {
951
+ fileListHtml = '<div class="diff-viewer-file-list">';
952
+ fileTabsHtml = '<div class="diff-viewer-file-tabs">';
953
+ state.files.forEach((f, i) => {
954
+ const name = basename(f.path);
955
+ const active = i === state.currentFileIndex ? ' active' : '';
956
+ const add = f.additions || 0;
957
+ const del = f.deletions || 0;
958
+ const stats = `${add ? `<span class="added">+${add}</span>` : ''}${del ? `<span class="removed">-${del}</span>` : ''}`;
959
+
960
+ fileListHtml += `<div class="diff-viewer-file-item${active}" data-file-idx="${i}">
961
+ <span class="diff-viewer-file-name">${escapeHtml(name)}</span>
962
+ <span class="diff-viewer-file-stats">${stats}</span>
963
+ </div>`;
964
+
965
+ fileTabsHtml += `<div class="diff-viewer-file-tab${active}" data-file-idx="${i}">${escapeHtml(name)}</div>`;
966
+ });
967
+ fileListHtml += '</div>';
968
+ fileTabsHtml += '</div>';
969
+ }
970
+
971
+ const showBar = shouldShowHistoryBar(state);
972
+ if (showBar) {
973
+ const expectedKey = `${_stateCwd(state)}:${file.path}`;
974
+ if (state.historyKey !== expectedKey) {
975
+ state.historyCommits = null;
976
+ state.historyLoading = false;
977
+ state.historyIndex = -1;
978
+ }
979
+ }
980
+ const barHtml = showBar ? `<div class="dv-history-bar">${buildHistoryBarHtml(state)}</div>` : '';
981
+
982
+ const wrapClass = state.wrapLines ? ' wrap-lines' : '';
983
+ container.innerHTML = `
984
+ ${barHtml}
985
+ ${multiFile ? fileTabsHtml : ''}
986
+ <div class="diff-viewer-layout">
987
+ ${multiFile ? fileListHtml : ''}
988
+ <div class="diff-viewer-content${wrapClass}">
989
+ ${headerHtml}
990
+ ${diffHtml}
991
+ </div>
992
+ </div>
993
+ `;
994
+
995
+ if (showBar) {
996
+ const barEl = container.querySelector('.dv-history-bar');
997
+ if (barEl) wireHistoryBar(state, sessionId, barEl);
998
+ // Trigger async load if needed; result re-renders the bar in place
999
+ if (state.historyCommits === null && !state.historyLoading) {
1000
+ loadHistoryForCurrentFile(state, sessionId);
1001
+ } else {
1002
+ // Re-align historyIndex if labels changed (e.g. after a pivot)
1003
+ const newIdx = findHistoryIndex(state, file);
1004
+ if (newIdx !== state.historyIndex) {
1005
+ state.historyIndex = newIdx;
1006
+ renderHistoryBar(state, sessionId);
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ // Wire up file navigation clicks
1012
+ container.querySelectorAll('[data-file-idx]').forEach(el => {
1013
+ el.addEventListener('click', () => {
1014
+ state.currentFileIndex = parseInt(el.dataset.fileIdx, 10);
1015
+ state.currentHunkIndex = -1;
1016
+ renderContent(sessionId);
1017
+ });
1018
+ });
1019
+
1020
+ // Wire up collapse expand
1021
+ container.querySelectorAll('.sbs-collapse, .diff-collapse').forEach(el => {
1022
+ el.addEventListener('click', () => {
1023
+ // Re-render with higher context to show collapsed lines
1024
+ // For simplicity, remove collapse and show all context
1025
+ el.style.display = 'none';
1026
+ });
1027
+ });
1028
+
1029
+ // Cache hunk elements for keyboard navigation
1030
+ state.hunkElements = Array.from(container.querySelectorAll('[data-hunk]'));
1031
+ state.currentHunkIndex = -1;
1032
+
1033
+ // Update widget header summary
1034
+ const widget = WidgetManager.get('diff-viewer');
1035
+ if (widget) {
1036
+ const totalFiles = state.files.length;
1037
+ const fileLabel = totalFiles > 1 ? `${state.currentFileIndex + 1}/${totalFiles}` : basename(file.path);
1038
+ const modeLabel = effectiveMode === 'split' ? S.widgets.diff_viewer.split_view : S.widgets.diff_viewer.unified_view;
1039
+ widget.setSummary?.(`${fileLabel} · ${modeLabel}`);
1040
+ }
1041
+
1042
+ // Focus for keyboard nav
1043
+ container.tabIndex = -1;
1044
+ container.focus();
1045
+ }
1046
+
1047
+ // ═══════════════════════════════════════════════════════════════
1048
+ // Keyboard navigation
1049
+ // ═══════════════════════════════════════════════════════════════
1050
+
1051
+ function handleKeyDown(e, sessionId) {
1052
+ const state = getState(sessionId);
1053
+
1054
+ // Wizard keyboard nav
1055
+ if (state.wizardActive) {
1056
+ handleWizardKeyDown(e, state, sessionId);
1057
+ return;
1058
+ }
1059
+
1060
+ if (!state.files.length) return;
1061
+
1062
+ switch (e.key) {
1063
+ case 'j':
1064
+ case 'n':
1065
+ e.preventDefault();
1066
+ navigateHunk(1, state);
1067
+ break;
1068
+ case 'k':
1069
+ case 'p':
1070
+ e.preventDefault();
1071
+ navigateHunk(-1, state);
1072
+ break;
1073
+ case ']':
1074
+ e.preventDefault();
1075
+ navigateFile(1, state, sessionId);
1076
+ break;
1077
+ case '[':
1078
+ e.preventDefault();
1079
+ navigateFile(-1, state, sessionId);
1080
+ break;
1081
+ case 'u':
1082
+ e.preventDefault();
1083
+ toggleViewMode(state, sessionId);
1084
+ break;
1085
+ case 'Escape':
1086
+ e.preventDefault();
1087
+ WidgetManager.close('diff-viewer');
1088
+ break;
1089
+ }
1090
+ }
1091
+
1092
+ function handleWizardKeyDown(e, state, sessionId) {
1093
+ if (state.wizardStep === 'source') {
1094
+ // Source picker: arrow keys, Enter, number keys, Escape
1095
+ const totalCards = WIZARD_SOURCES.length;
1096
+ switch (e.key) {
1097
+ case 'ArrowDown':
1098
+ e.preventDefault();
1099
+ state.wizardFocusIndex = Math.min(state.wizardFocusIndex + 2, totalCards - 1);
1100
+ renderWizard(sessionId);
1101
+ break;
1102
+ case 'ArrowUp':
1103
+ e.preventDefault();
1104
+ state.wizardFocusIndex = Math.max(state.wizardFocusIndex - 2, 0);
1105
+ renderWizard(sessionId);
1106
+ break;
1107
+ case 'ArrowRight':
1108
+ e.preventDefault();
1109
+ if (state.wizardFocusIndex < totalCards - 1) state.wizardFocusIndex++;
1110
+ renderWizard(sessionId);
1111
+ break;
1112
+ case 'ArrowLeft':
1113
+ e.preventDefault();
1114
+ if (state.wizardFocusIndex > 0) state.wizardFocusIndex--;
1115
+ renderWizard(sessionId);
1116
+ break;
1117
+ case 'Enter':
1118
+ e.preventDefault();
1119
+ if (state.wizardFocusIndex >= 0 && state.wizardFocusIndex < totalCards) {
1120
+ wizardSelectSource(state, WIZARD_SOURCES[state.wizardFocusIndex].id, sessionId);
1121
+ }
1122
+ break;
1123
+ case '1': case '2': case '3': case '4': case '5': {
1124
+ e.preventDefault();
1125
+ const idx = parseInt(e.key) - 1;
1126
+ if (idx < totalCards) wizardSelectSource(state, WIZARD_SOURCES[idx].id, sessionId);
1127
+ break;
1128
+ }
1129
+ case 'Escape':
1130
+ e.preventDefault();
1131
+ WidgetManager.close('diff-viewer');
1132
+ break;
1133
+ }
1134
+ } else {
1135
+ // Picker: up/down to navigate, Enter to select, Backspace/Escape to go back
1136
+ // Skip keyboard list nav if file search input is focused
1137
+ const isSearchFocused = state.currentContainer?.querySelector('.cw-file-search') === document.activeElement;
1138
+
1139
+ switch (e.key) {
1140
+ case 'ArrowDown':
1141
+ if (isSearchFocused) return; // let input handle cursor
1142
+ e.preventDefault();
1143
+ state.wizardFocusIndex = Math.min(state.wizardFocusIndex + 1, state.wizardPickerData.length - 1);
1144
+ updateFocusHighlight(state);
1145
+ break;
1146
+ case 'ArrowUp':
1147
+ if (isSearchFocused) return;
1148
+ e.preventDefault();
1149
+ state.wizardFocusIndex = Math.max(state.wizardFocusIndex - 1, 0);
1150
+ updateFocusHighlight(state);
1151
+ break;
1152
+ case 'Enter':
1153
+ e.preventDefault();
1154
+ if (state.wizardStep === 'file' && state.wizardFocusIndex >= 0) {
1155
+ const items = state.currentContainer?.querySelectorAll('.cw-file-item');
1156
+ const item = items?.[state.wizardFocusIndex];
1157
+ if (item) wizardPickFile(state, sessionId, item.dataset.path);
1158
+ } else if (state.wizardFocusIndex >= 0) {
1159
+ const items = state.currentContainer?.querySelectorAll('.cw-item');
1160
+ const item = items?.[state.wizardFocusIndex];
1161
+ if (item) wizardPickItem(state, sessionId, item.dataset);
1162
+ }
1163
+ break;
1164
+ case 'Backspace':
1165
+ if (isSearchFocused && state.wizardFileQuery) return; // let input handle delete
1166
+ e.preventDefault();
1167
+ state.wizardStep = 'source';
1168
+ state.wizardFocusIndex = -1;
1169
+ renderWizard(sessionId);
1170
+ break;
1171
+ case 'Escape':
1172
+ e.preventDefault();
1173
+ state.wizardStep = 'source';
1174
+ state.wizardFocusIndex = -1;
1175
+ renderWizard(sessionId);
1176
+ break;
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ function updateFocusHighlight(state) {
1182
+ const container = state.currentContainer;
1183
+ if (!container) return;
1184
+ container.querySelectorAll('.cw-focused').forEach(el => el.classList.remove('cw-focused'));
1185
+ const items = container.querySelectorAll('.cw-item, .cw-file-item');
1186
+ if (state.wizardFocusIndex >= 0 && state.wizardFocusIndex < items.length) {
1187
+ items[state.wizardFocusIndex].classList.add('cw-focused');
1188
+ items[state.wizardFocusIndex].scrollIntoView({ block: 'nearest' });
1189
+ }
1190
+ }
1191
+
1192
+ function navigateHunk(direction, state) {
1193
+ if (!state.hunkElements.length) return;
1194
+
1195
+ // Find unique hunk indices
1196
+ const hunkIds = [...new Set(state.hunkElements.map(el => el.dataset.hunk))];
1197
+ if (!hunkIds.length) return;
1198
+
1199
+ // Move to next/prev hunk
1200
+ state.currentHunkIndex += direction;
1201
+ if (state.currentHunkIndex < 0) state.currentHunkIndex = 0;
1202
+ if (state.currentHunkIndex >= hunkIds.length) state.currentHunkIndex = hunkIds.length - 1;
1203
+
1204
+ const targetHunkId = hunkIds[state.currentHunkIndex];
1205
+ const targetEl = state.hunkElements.find(el => el.dataset.hunk === targetHunkId);
1206
+ if (targetEl) {
1207
+ // Clear old highlight
1208
+ state.currentContainer?.querySelectorAll('.sbs-hunk-active').forEach(el => el.classList.remove('sbs-hunk-active'));
1209
+
1210
+ // Highlight all elements in this hunk
1211
+ state.hunkElements
1212
+ .filter(el => el.dataset.hunk === targetHunkId)
1213
+ .forEach(el => el.classList.add('sbs-hunk-active'));
1214
+
1215
+ targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
1216
+ }
1217
+ }
1218
+
1219
+ function navigateFile(direction, state, sessionId) {
1220
+ if (state.files.length <= 1) return;
1221
+ const newIdx = state.currentFileIndex + direction;
1222
+ if (newIdx < 0 || newIdx >= state.files.length) return;
1223
+ state.currentFileIndex = newIdx;
1224
+ state.currentHunkIndex = -1;
1225
+ renderContent(sessionId);
1226
+ }
1227
+
1228
+ function toggleViewMode(state, sessionId) {
1229
+ state.viewMode = state.viewMode === 'split' ? 'unified' : 'split';
1230
+ saveViewMode(state.viewMode);
1231
+ renderContent(sessionId);
1232
+ }
1233
+
1234
+ function toggleWrapLines(state, btn) {
1235
+ state.wrapLines = !state.wrapLines;
1236
+ saveWrapLines(state.wrapLines);
1237
+ const content = state.currentContainer?.querySelector('.diff-viewer-content');
1238
+ if (content) content.classList.toggle('wrap-lines', state.wrapLines);
1239
+ btn?.classList.toggle('is-active', state.wrapLines);
1240
+ }
1241
+
1242
+ // ═══════════════════════════════════════════════════════════════
1243
+ // Helpers
1244
+ // ═══════════════════════════════════════════════════════════════
1245
+
1246
+ function timeAgo(timestamp) {
1247
+ const now = Math.floor(Date.now() / 1000);
1248
+ const diff = now - timestamp;
1249
+ if (diff < 60) return '<1m';
1250
+ if (diff < 3600) return `${Math.floor(diff / 60)}m`;
1251
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
1252
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
1253
+ return `${Math.floor(diff / 604800)}w`;
1254
+ }
1255
+
1256
+ // ═══════════════════════════════════════════════════════════════
1257
+ // Widget registration
1258
+ // ═══════════════════════════════════════════════════════════════
1259
+
1260
+ export function registerDiffViewerWidget() {
1261
+ WidgetManager.register('diff-viewer', {
1262
+ type: 'floating',
1263
+ title: S.widgets.titles.diff_viewer,
1264
+ icon: 'columns',
1265
+ scope: 'session',
1266
+ hiddenInPicker: true,
1267
+
1268
+ deviceTypes: {
1269
+ default: 'floating',
1270
+ phone: 'bottom-sheet',
1271
+ tablet: 'floating',
1272
+ desktop: 'floating'
1273
+ },
1274
+
1275
+ allowedTypes: ['floating', 'bottom-sheet', 'tab'],
1276
+
1277
+ heights: { half: '55vh', full: '90vh' },
1278
+
1279
+ headerActions: [
1280
+ {
1281
+ icon: 'wrapText',
1282
+ title: S.widgets.diff_viewer.toggle_wrap,
1283
+ onClick: (e) => {
1284
+ const state = getState(WidgetManager.currentSessionId);
1285
+ toggleWrapLines(state, e.currentTarget);
1286
+ }
1287
+ },
1288
+ {
1289
+ icon: 'columns',
1290
+ title: S.widgets.diff_viewer.toggle_view,
1291
+ onClick: () => {
1292
+ const sessionId = WidgetManager.currentSessionId;
1293
+ const state = getState(sessionId);
1294
+ toggleViewMode(state, sessionId);
1295
+ }
1296
+ }
1297
+ ],
1298
+
1299
+ render: (container, ctx) => {
1300
+ const sessionId = ctx.sessionId || WidgetManager.currentSessionId;
1301
+ const state = getState(sessionId);
1302
+ state.currentContainer = container;
1303
+
1304
+ // Wizard mode: open from openCompareWizard()
1305
+ if (ctx.wizardMode) {
1306
+ state.wizardActive = true;
1307
+ state.wizardStep = 'source';
1308
+ state.wizardFilePath = ctx.wizardFilePath;
1309
+ state.wizardFileContent = ctx.wizardFileContent;
1310
+ state.wizardCwd = ctx.wizardCwd;
1311
+ state.wizardFocusIndex = -1;
1312
+ state.wizardPickerData = [];
1313
+ state.wizardFileQuery = '';
1314
+ state.wizardAllFiles = null;
1315
+ state.files = [];
1316
+ state.loading = false;
1317
+ state.error = null;
1318
+ }
1319
+
1320
+ // Normal mode: files passed directly
1321
+ if (ctx.files) {
1322
+ state.wizardActive = false;
1323
+ state.files = ctx.files;
1324
+ state.source = ctx.source || 'unknown';
1325
+ state.currentFileIndex = ctx.fileIndex || 0;
1326
+ state.viewMode = loadViewMode() || ((container.offsetWidth >= 600) ? 'split' : 'unified');
1327
+ state.currentHunkIndex = -1;
1328
+ }
1329
+
1330
+ renderContent(sessionId);
1331
+
1332
+ // Mark the wrap toggle as active if wrap mode is on.
1333
+ const wrapBtn = container.closest('.widget')
1334
+ ?.querySelector(`.widget-actions [data-tooltip="${S.widgets.diff_viewer.toggle_wrap}"]`);
1335
+ if (wrapBtn && state.wrapLines) wrapBtn.classList.add('is-active');
1336
+
1337
+ // Keyboard handler
1338
+ container.onkeydown = (e) => handleKeyDown(e, sessionId);
1339
+ },
1340
+
1341
+ onDestroy: (sessionId) => {
1342
+ destroyState(sessionId);
1343
+ }
1344
+ });
1345
+ }
1346
+
1347
+ // ═══════════════════════════════════════════════════════════════
1348
+ // Public API
1349
+ // ═══════════════════════════════════════════════════════════════
1350
+
1351
+ export const DiffViewerWidget = {
1352
+ /**
1353
+ * Open with explicit old/new content (Edit tool blocks, Changes widget)
1354
+ */
1355
+ openWithContent(filePath, oldContent, newContent, options = {}) {
1356
+ const file = {
1357
+ path: filePath || 'untitled',
1358
+ oldContent: oldContent || '',
1359
+ newContent: newContent || '',
1360
+ oldLabel: options.oldLabel || S.widgets.diff_viewer.before,
1361
+ newLabel: options.newLabel || S.widgets.diff_viewer.after,
1362
+ startLine: options.startLine || 1,
1363
+ additions: 0,
1364
+ deletions: 0
1365
+ };
1366
+ WidgetManager.open('diff-viewer', {
1367
+ files: [file],
1368
+ source: options.source || 'edit-tool',
1369
+ fileIndex: 0
1370
+ });
1371
+ },
1372
+
1373
+ /**
1374
+ * Open with multiple files (multi-file git diff)
1375
+ */
1376
+ openWithFiles(files, options = {}) {
1377
+ WidgetManager.open('diff-viewer', {
1378
+ files,
1379
+ source: options.source || 'diff',
1380
+ fileIndex: options.fileIndex || 0,
1381
+ });
1382
+ },
1383
+
1384
+ /**
1385
+ * Open the compare wizard for a file
1386
+ */
1387
+ async openCompareWizard(filePath, cwd) {
1388
+ cwd = cwd || WidgetManager.currentCwd || '';
1389
+ // Load the file's current content
1390
+ let content = '';
1391
+ try {
1392
+ const resp = await fetch(`${CONFIG.API_BASE}/api/file?path=${encodeURIComponent(filePath)}&cwd=${encodeURIComponent(cwd)}`);
1393
+ if (resp.ok) {
1394
+ const data = await resp.json();
1395
+ content = data.content || '';
1396
+ }
1397
+ } catch (err) {
1398
+ console.error('[DiffViewer] Failed to load file for wizard:', err);
1399
+ }
1400
+
1401
+ WidgetManager.open('diff-viewer', {
1402
+ wizardMode: true,
1403
+ wizardFilePath: filePath,
1404
+ wizardFileContent: content,
1405
+ wizardCwd: cwd
1406
+ });
1407
+ },
1408
+
1409
+ // Edit data cache for tool-renderer onclick
1410
+ cacheEditData,
1411
+ openFromCache,
1412
+
1413
+ // Quick compare presets (bypass wizard)
1414
+ quickCompareGitHead,
1415
+ quickComparePreviousCommit,
1416
+
1417
+ // State access
1418
+ getState,
1419
+ destroyState
1420
+ };