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,692 @@
1
+ /**
2
+ * ImagePreviewWidget - Fullscreen image preview with navigation
3
+ *
4
+ * Lightbox-style overlay for viewing images. Collects all images from
5
+ * the current chat DOM and provides prev/next navigation within turns
6
+ * and between turns.
7
+ */
8
+
9
+ import { WidgetManager } from '../widget-system/index.js';
10
+ import S from '../strings.js';
11
+ import { escapeHtml } from '../utils.js';
12
+ import { panzoomToolbarHtml, setupPanZoom, panzoomFitToView } from '../preview-plugins/plugin-helpers.js';
13
+ import { ContextMenu, copyToClipboard, copyImageToClipboard, showToast, filePathFromSrc, lastInputWasTouch } from '../context-menu.js';
14
+ import { basename } from '../path-utils.js';
15
+
16
+ // Shared context menu for the main image + thumb strip
17
+ const contextMenu = new ContextMenu();
18
+
19
+ /** Absolute URL for a (possibly root-relative) src. */
20
+ function absoluteSrc(src) {
21
+ if (!src || src.startsWith('data:')) return src;
22
+ try { return new URL(src, location.origin).href; }
23
+ catch { return src; }
24
+ }
25
+
26
+ /**
27
+ * Copy an image to the clipboard, falling back to its path/link when the
28
+ * platform refuses image bytes (iPad PWA, older Safari).
29
+ * @returns {Promise<boolean>} true if anything at all made it to the clipboard
30
+ */
31
+ async function copyImageWithFallback(src, path, abs) {
32
+ if (await copyImageToClipboard(src)) { showToast(S.toast.image_copied); return true; }
33
+ const fallback = path || (abs && !abs.startsWith('data:') ? abs : null);
34
+ const ok = !!fallback && await copyToClipboard(fallback);
35
+ showToast(ok ? S.toast.image_copy_fell_back : S.toast.image_copy_failed);
36
+ return ok;
37
+ }
38
+
39
+ /** Build context-menu items for a gallery image src. */
40
+ function imageMenuItems(src) {
41
+ if (!src) return [];
42
+ const path = filePathFromSrc(src);
43
+ const abs = absoluteSrc(src);
44
+ const items = [
45
+ {
46
+ label: S.context_menus.image.copy_image,
47
+ action: () => copyImageWithFallback(src, path, abs),
48
+ },
49
+ {
50
+ label: S.context_menus.image.copy_image_close,
51
+ action: async () => {
52
+ await copyImageWithFallback(src, path, abs);
53
+ WidgetManager.close('image-preview');
54
+ },
55
+ },
56
+ ];
57
+ if (path) {
58
+ items.push({
59
+ label: S.context_menus.image.copy_path,
60
+ action: async () => { if (await copyToClipboard(path)) showToast(S.toast.path_copied); },
61
+ });
62
+ }
63
+ if (abs && !abs.startsWith('data:')) {
64
+ items.push({
65
+ label: S.context_menus.image.copy_link,
66
+ action: async () => { if (await copyToClipboard(abs)) showToast(S.toast.link_copied); },
67
+ });
68
+ }
69
+ items.push({ separator: true });
70
+ items.push({ label: S.context_menus.image.annotate, action: () => openInAnnotator(src) });
71
+ if (abs && !abs.startsWith('data:')) {
72
+ items.push({ label: S.context_menus.image.open_new_tab, action: () => window.open(abs, '_blank') });
73
+ }
74
+ return items;
75
+ }
76
+
77
+ /**
78
+ * Open the current gallery image in the annotation editor.
79
+ *
80
+ * The editor was built for the paste flow and takes a Blob, so a gallery src
81
+ * (a server file, a blob: URL, or a data: URI) is fetched into one first.
82
+ * The gallery closes on the way in: both are modals, and the gallery's arrow
83
+ * keys would otherwise swap the image out from under an in-progress drawing.
84
+ *
85
+ * On save the annotated PNG is attached to the chat as a pending image — the
86
+ * same destination as paste-to-annotate. It does NOT overwrite the file on
87
+ * disk (there's no binary write endpoint, and the editor downsamples above
88
+ * 3000px, so an in-place save would silently degrade the original).
89
+ */
90
+ async function openInAnnotator(src) {
91
+ if (!src) return;
92
+ const name = basenameFromSrc(src) || 'image.png';
93
+ try {
94
+ const blob = await fetch(src).then(r => {
95
+ if (!r.ok) throw new Error(`fetch failed: ${r.status}`);
96
+ return r.blob();
97
+ });
98
+ // GIFs would be flattened to a single frame by the canvas export —
99
+ // the paste flow skips them for the same reason.
100
+ if (blob.type === 'image/gif') { showToast(S.toast.annotate_gif_unsupported); return; }
101
+
102
+ const { openImageAnnotator } = await import('./image-annotate-widget.js');
103
+ WidgetManager.close('image-preview');
104
+ openImageAnnotator(new File([blob], name, { type: blob.type || 'image/png' }), {
105
+ onDone: (out, markers) => window.app?.uploadManager?.attachAnnotated(out, name, markers),
106
+ });
107
+ } catch (err) {
108
+ console.error('[ImagePreview] Failed to open annotator:', err);
109
+ showToast(S.toast.annotate_open_failed);
110
+ }
111
+ }
112
+
113
+ /** Show the image context menu at (x, y) for a given src. */
114
+ function showImageMenu(x, y, src) {
115
+ const items = imageMenuItems(src);
116
+ if (items.length) contextMenu.show(x, y, items);
117
+ }
118
+
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+ // STATE
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+
123
+ const state = {
124
+ images: [], // [{src, turnIndex, turnLabel?, label?}] in DOM or directory order
125
+ currentIndex: -1,
126
+ mode: 'chat', // 'chat' = session images grouped by turn, 'dir' = directory siblings
127
+ zoom: { scale: 1, tx: 0, ty: 0 },
128
+ };
129
+
130
+ let _keyHandler = null;
131
+
132
+ // ═══════════════════════════════════════════════════════════════════════════
133
+ // IMAGE COLLECTION
134
+ // ═══════════════════════════════════════════════════════════════════════════
135
+
136
+ const IMAGE_EXT_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i;
137
+
138
+ const IMG_SELECTOR = [
139
+ '.turn-summary-bar',
140
+ '.message-image-thumb',
141
+ '.read-image-content img',
142
+ '.write-image-content img',
143
+ '.tt-image-thumb img',
144
+ '.turn-file-pill[data-file-path]',
145
+ 'a.file-path-link[data-resolved]',
146
+ ].join(', ');
147
+
148
+ /**
149
+ * Scan the messages container for all images, grouped by turn.
150
+ * Includes: user uploads, Read/Write tool images, thinking tool images,
151
+ * file pills / image thumbs in turn bars, and linkified image paths in
152
+ * message text (server-verified file-path-links).
153
+ *
154
+ * Turn attribution: a turn's inline images sit BEFORE its summary bar in the
155
+ * DOM, so inline images belong to the bar that FOLLOWS them, while a bar's
156
+ * own pills belong to that bar itself. `turnIndex` is a monotonic segment
157
+ * counter used for ordering and turn-jump; `turnLabel` is the bar's displayed
158
+ * turn number, used only for labels (turn numbers can restart mid-session
159
+ * when the server restarts, so they aren't safe for ordering).
160
+ *
161
+ * Returns [{src, turnIndex, turnLabel}] in DOM order.
162
+ */
163
+ function collectImages() {
164
+ // Scope to the ACTIVE session's message container — #messages-container
165
+ // holds a pool of .session-messages elements (one per open tab, inactive
166
+ // ones just hidden), so scanning it directly would sweep in every open
167
+ // session's images. (Not getActiveScrollContainer() — that returns the
168
+ // welcome container while the welcome view is up.)
169
+ const container = window.app?.chatCtrl?._containerPool?.getActiveContainer()
170
+ || document.getElementById('messages-container');
171
+ if (!container) return [];
172
+
173
+ const nodes = container.querySelectorAll(IMG_SELECTOR);
174
+ const entries = [];
175
+ const barLabels = []; // barLabels[i] = displayed number of the i-th bar
176
+ const seen = new Set(); // dedupe by src
177
+ let bars = 0;
178
+
179
+ for (const el of nodes) {
180
+ // Turn boundary — record its displayed number (partial bars have none)
181
+ if (el.classList.contains('turn-summary-bar')) {
182
+ barLabels[bars] = parseInt(el.dataset.turnNumber, 10) || null;
183
+ bars++;
184
+ continue;
185
+ }
186
+
187
+ // Extract src + filename label based on element type
188
+ let src, label;
189
+ if (el.classList.contains('message-image-thumb')) {
190
+ // User upload — alt is generic, derive the name from the URL
191
+ src = el.dataset.src || el.querySelector('img')?.src;
192
+ label = basenameFromSrc(src);
193
+ } else if (el.classList.contains('turn-file-pill')) {
194
+ // File pill / image thumb in a turn bar — include if it's an image
195
+ const filePath = el.dataset.filePath;
196
+ if (!filePath || !IMAGE_EXT_RE.test(filePath)) continue;
197
+ src = `/api/file-raw?path=${encodeURIComponent(filePath)}`;
198
+ label = basename(filePath);
199
+ } else if (el.classList.contains('file-path-link')) {
200
+ // Linkified file path in message text — include if resolved to an image
201
+ const filePath = el.dataset.resolved;
202
+ if (!filePath || !IMAGE_EXT_RE.test(filePath)) continue;
203
+ src = `/api/file-raw?path=${encodeURIComponent(filePath)}`;
204
+ label = basename(filePath);
205
+ } else {
206
+ // Read/Write/thinking tool <img> — alt carries the filename
207
+ src = el.src;
208
+ label = el.alt || basenameFromSrc(src);
209
+ }
210
+
211
+ if (!src || seen.has(src)) continue;
212
+ seen.add(src);
213
+ const seg = el.closest('.turn-summary-bar') ? bars - 1 : bars;
214
+ entries.push({ src, seg: Math.max(0, seg), label: label || null });
215
+ }
216
+
217
+ // Resolve display labels per segment. Bars without a number (partial
218
+ // bars) and the trailing in-flight segment synthesize previous + 1.
219
+ const segLabels = [];
220
+ let lastLabel = 0;
221
+ for (let i = 0; i <= bars; i++) {
222
+ segLabels[i] = (i < bars && barLabels[i]) ? barLabels[i] : lastLabel + 1;
223
+ lastLabel = segLabels[i];
224
+ }
225
+
226
+ return entries.map(e => ({ src: e.src, turnIndex: e.seg, turnLabel: segLabels[e.seg], label: e.label }));
227
+ }
228
+
229
+ /**
230
+ * Best-effort filename for an image src — /api/file-raw?path=… URLs use the
231
+ * path param's basename, other URLs their pathname basename (only when it
232
+ * looks like an image file); data: URIs have none.
233
+ */
234
+ function basenameFromSrc(src) {
235
+ if (!src || src.startsWith('data:')) return null;
236
+ try {
237
+ const u = new URL(src, location.origin);
238
+ const p = u.searchParams.get('path');
239
+ if (p) return basename(p);
240
+ const name = decodeURIComponent(u.pathname.split('/').pop() || '');
241
+ return IMAGE_EXT_RE.test(name) ? name : null;
242
+ } catch { return null; }
243
+ }
244
+
245
+ /**
246
+ * Find image index by matching src URL. Handles both exact match
247
+ * and path-only match (ignoring query params) for API URLs.
248
+ */
249
+ function findImageIndex(images, src) {
250
+ // Exact match first
251
+ let idx = images.findIndex(img => img.src === src);
252
+ if (idx >= 0) return idx;
253
+
254
+ // Try matching by pathname (for /api/file-raw?path=... URLs with different params)
255
+ try {
256
+ const srcUrl = new URL(src, location.origin);
257
+ idx = images.findIndex(img => {
258
+ try {
259
+ const u = new URL(img.src, location.origin);
260
+ return u.pathname === srcUrl.pathname && u.searchParams.get('path') === srcUrl.searchParams.get('path');
261
+ } catch { return false; }
262
+ });
263
+ } catch { /* not a URL, skip */ }
264
+
265
+ return idx;
266
+ }
267
+
268
+ // ═══════════════════════════════════════════════════════════════════════════
269
+ // NAVIGATION
270
+ // ═══════════════════════════════════════════════════════════════════════════
271
+
272
+ function navigate(delta) {
273
+ const total = state.images.length;
274
+ if (total <= 1) return;
275
+ // Wrap around: prev at the first image lands on the last and vice versa
276
+ state.currentIndex = (state.currentIndex + delta + total) % total;
277
+ updateDisplay();
278
+ }
279
+
280
+ function navigateTurn(direction) {
281
+ const cur = state.images[state.currentIndex];
282
+ if (!cur) return;
283
+
284
+ if (direction < 0) {
285
+ // Find first image of previous turn
286
+ for (let i = state.currentIndex - 1; i >= 0; i--) {
287
+ if (state.images[i].turnIndex < cur.turnIndex) {
288
+ // Found a different turn — now find its first image
289
+ const targetTurn = state.images[i].turnIndex;
290
+ let first = i;
291
+ while (first > 0 && state.images[first - 1].turnIndex === targetTurn) first--;
292
+ state.currentIndex = first;
293
+ updateDisplay();
294
+ return;
295
+ }
296
+ }
297
+ } else {
298
+ // Find first image of next turn
299
+ for (let i = state.currentIndex + 1; i < state.images.length; i++) {
300
+ if (state.images[i].turnIndex > cur.turnIndex) {
301
+ state.currentIndex = i;
302
+ updateDisplay();
303
+ return;
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ function hasPrevTurn() {
310
+ const cur = state.images[state.currentIndex];
311
+ if (!cur) return false;
312
+ return state.images.some((img, i) => i < state.currentIndex && img.turnIndex < cur.turnIndex);
313
+ }
314
+
315
+ function hasNextTurn() {
316
+ const cur = state.images[state.currentIndex];
317
+ if (!cur) return false;
318
+ return state.images.some((img, i) => i > state.currentIndex && img.turnIndex > cur.turnIndex);
319
+ }
320
+
321
+ // ═══════════════════════════════════════════════════════════════════════════
322
+ // DISPLAY UPDATE
323
+ // ═══════════════════════════════════════════════════════════════════════════
324
+
325
+ /**
326
+ * Display label for an image: dir mode is filename-only (no turns exist);
327
+ * chat mode is "T4 · name.png", degrading to just the turn when no
328
+ * filename could be derived (e.g. data-URI uploads).
329
+ */
330
+ function imageLabel(im) {
331
+ const turn = `T${im.turnLabel || im.turnIndex || 0}`;
332
+ if (state.mode === 'dir') return im.label || turn;
333
+ return im.label ? `${turn} · ${im.label}` : turn;
334
+ }
335
+
336
+ function updateDisplay() {
337
+ const modal = document.querySelector('#widget-image-preview .image-preview-modal');
338
+ if (!modal) return;
339
+
340
+ const img = modal.querySelector('.img-preview-main');
341
+ const counter = modal.querySelector('.img-nav-counter');
342
+ const prevBtn = modal.querySelector('.img-nav-prev');
343
+ const nextBtn = modal.querySelector('.img-nav-next');
344
+ const prevTurnBtn = modal.querySelector('.img-nav-turn-prev');
345
+ const nextTurnBtn = modal.querySelector('.img-nav-turn-next');
346
+
347
+ const cur = state.images[state.currentIndex];
348
+ if (!cur) return;
349
+
350
+ // Reset zoom and refit when switching images so each picture starts fitted.
351
+ if (img && img.src !== cur.src) {
352
+ state.zoom.scale = 1;
353
+ state.zoom.tx = 0;
354
+ state.zoom.ty = 0;
355
+ img.style.transform = '';
356
+ img.src = cur.src;
357
+ const canvas = modal.querySelector('.img-preview-canvas');
358
+ const refit = () => panzoomFitToView(canvas, img, state.zoom);
359
+ if (img.complete && img.naturalWidth > 0) {
360
+ requestAnimationFrame(refit);
361
+ } else {
362
+ img.addEventListener('load', refit, { once: true });
363
+ }
364
+ }
365
+
366
+ const total = state.images.length;
367
+
368
+ if (counter) {
369
+ counter.textContent = total > 1
370
+ ? `${state.currentIndex + 1} / ${total} · ${imageLabel(cur)}`
371
+ : '';
372
+ }
373
+
374
+ if (prevBtn) prevBtn.disabled = total <= 1;
375
+ if (nextBtn) nextBtn.disabled = total <= 1;
376
+ if (prevTurnBtn) prevTurnBtn.disabled = !hasPrevTurn();
377
+ if (nextTurnBtn) nextTurnBtn.disabled = !hasNextTurn();
378
+
379
+ updateThumbActive(modal);
380
+ }
381
+
382
+ /**
383
+ * Build the thumb-strip HTML for the current state.images.
384
+ * Inserts an .img-turn-sep between consecutive thumbs from different turns.
385
+ */
386
+ function renderThumbStripHtml() {
387
+ if (state.images.length <= 1) return '';
388
+ let html = '';
389
+ let prevTurn = null;
390
+ for (let i = 0; i < state.images.length; i++) {
391
+ const im = state.images[i];
392
+ if (prevTurn !== null && im.turnIndex !== prevTurn) {
393
+ html += `<div class="img-turn-sep" data-tooltip="T${im.turnLabel || im.turnIndex}"></div>`;
394
+ }
395
+ const tip = escapeHtml(imageLabel(im));
396
+ html += `<button class="img-thumb" data-index="${i}" data-tooltip="${tip}"><img src="${im.src}" alt="" loading="lazy"></button>`;
397
+ prevTurn = im.turnIndex;
398
+ }
399
+ return html;
400
+ }
401
+
402
+ /**
403
+ * Mark the active thumb and scroll it into view.
404
+ */
405
+ function updateThumbActive(modal) {
406
+ const strip = modal.querySelector('.img-thumb-strip');
407
+ if (!strip) return;
408
+ const thumbs = strip.querySelectorAll('.img-thumb');
409
+ let active = null;
410
+ thumbs.forEach((t) => {
411
+ const isActive = parseInt(t.dataset.index, 10) === state.currentIndex;
412
+ t.classList.toggle('active', isActive);
413
+ if (isActive) active = t;
414
+ });
415
+ if (active) {
416
+ active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
417
+ }
418
+ }
419
+
420
+ // ═══════════════════════════════════════════════════════════════════════════
421
+ // KEYBOARD
422
+ // ═══════════════════════════════════════════════════════════════════════════
423
+
424
+ function attachKeyboard() {
425
+ detachKeyboard();
426
+ _keyHandler = (e) => {
427
+ if (e.key === 'ArrowLeft') {
428
+ e.preventDefault();
429
+ e.shiftKey ? navigateTurn(-1) : navigate(-1);
430
+ } else if (e.key === 'ArrowRight') {
431
+ e.preventDefault();
432
+ e.shiftKey ? navigateTurn(1) : navigate(1);
433
+ }
434
+ };
435
+ document.addEventListener('keydown', _keyHandler);
436
+ }
437
+
438
+ function detachKeyboard() {
439
+ if (_keyHandler) {
440
+ document.removeEventListener('keydown', _keyHandler);
441
+ _keyHandler = null;
442
+ }
443
+ }
444
+
445
+ // ═══════════════════════════════════════════════════════════════════════════
446
+ // SVG ICONS (inline to avoid widget-system dependency for simple arrows)
447
+ // ═══════════════════════════════════════════════════════════════════════════
448
+
449
+ const CHEVRON_LEFT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>';
450
+ const CHEVRON_RIGHT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
451
+ const CHEVRONS_LEFT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>';
452
+ const CHEVRONS_RIGHT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 7 18 12 13 17"/><polyline points="6 7 11 12 6 17"/></svg>';
453
+
454
+ // ═══════════════════════════════════════════════════════════════════════════
455
+ // WIDGET REGISTRATION
456
+ // ═══════════════════════════════════════════════════════════════════════════
457
+
458
+ export function registerImagePreviewWidget() {
459
+ WidgetManager.register('image-preview', {
460
+ title: S.widgets.titles.image_preview,
461
+ icon: 'image',
462
+ type: 'modal',
463
+ hideHeader: true,
464
+ closeOnBackdrop: true,
465
+ closeOnEscape: true,
466
+ maxWidth: '95vw',
467
+ maxHeight: '95vh',
468
+ scope: 'global',
469
+ sessionAware: false,
470
+ hiddenInPicker: true,
471
+
472
+ onClose() {
473
+ detachKeyboard();
474
+ // Return focus to the chat input so typing can resume immediately.
475
+ window.app?.focusInput?.();
476
+ },
477
+
478
+ render(container, ctx) {
479
+ const single = state.images.length <= 1;
480
+ const initialSrc = state.images[state.currentIndex]?.src || '';
481
+
482
+ container.innerHTML = `
483
+ <div class="image-preview-modal${single ? ' single-image' : ''}">
484
+ ${panzoomToolbarHtml(state.zoom, {
485
+ hasDownload: false,
486
+ hasAnnotate: true,
487
+ annotateTooltip: S.context_menus.image.annotate,
488
+ })}
489
+ <div class="img-preview-canvas panzoom-canvas">
490
+ <img class="img-preview-main panzoom-img" src="${initialSrc}" alt="Preview">
491
+ </div>
492
+ <button class="img-nav img-nav-prev" data-tooltip="${S.widgets?.image_preview?.prev || 'Previous image'}">${CHEVRON_LEFT}</button>
493
+ <button class="img-nav img-nav-next" data-tooltip="${S.widgets?.image_preview?.next || 'Next image'}">${CHEVRON_RIGHT}</button>
494
+ <span class="img-nav-counter"></span>
495
+ <div class="img-nav-turn">
496
+ <button class="img-nav-turn-prev" data-tooltip="${S.widgets?.image_preview?.prev_turn || "Previous turn"}">${CHEVRONS_LEFT}</button>
497
+ <button class="img-nav-turn-next" data-tooltip="${S.widgets?.image_preview?.next_turn || "Next turn"}">${CHEVRONS_RIGHT}</button>
498
+ </div>
499
+ <div class="img-thumb-strip">${renderThumbStripHtml()}</div>
500
+ </div>
501
+ `;
502
+
503
+ // Wire up pan/zoom (drag, wheel, pinch, gesture, toolbar buttons, Cmd+/-/0)
504
+ setupPanZoom(container, state.zoom, {
505
+ canvasSelector: '.img-preview-canvas',
506
+ imgSelector: '.img-preview-main',
507
+ stateCheck: () => !!document.querySelector('#widget-image-preview .image-preview-modal'),
508
+ onAnnotate: () => openInAnnotator(state.images[state.currentIndex]?.src),
509
+ });
510
+
511
+ // Navigation button clicks
512
+ container.querySelector('.img-nav-prev').addEventListener('click', (e) => {
513
+ e.stopPropagation();
514
+ navigate(-1);
515
+ });
516
+ container.querySelector('.img-nav-next').addEventListener('click', (e) => {
517
+ e.stopPropagation();
518
+ navigate(1);
519
+ });
520
+ container.querySelector('.img-nav-turn-prev').addEventListener('click', (e) => {
521
+ e.stopPropagation();
522
+ navigateTurn(-1);
523
+ });
524
+ container.querySelector('.img-nav-turn-next').addEventListener('click', (e) => {
525
+ e.stopPropagation();
526
+ navigateTurn(1);
527
+ });
528
+
529
+ // Delegated click on thumb strip — jump to clicked image
530
+ container.querySelector('.img-thumb-strip').addEventListener('click', (e) => {
531
+ const thumb = e.target.closest('.img-thumb');
532
+ if (!thumb) return;
533
+ e.stopPropagation();
534
+ const idx = parseInt(thumb.dataset.index, 10);
535
+ if (Number.isFinite(idx) && idx >= 0 && idx < state.images.length) {
536
+ state.currentIndex = idx;
537
+ updateDisplay();
538
+ }
539
+ });
540
+
541
+ // Right-click on the main image or a thumb → copy image / link / path.
542
+ // srcAt() resolves the src for whichever element was targeted.
543
+ const srcAt = (target) => {
544
+ const thumb = target.closest('.img-thumb');
545
+ if (thumb) {
546
+ const idx = parseInt(thumb.dataset.index, 10);
547
+ return state.images[idx]?.src;
548
+ }
549
+ if (target.closest('.img-preview-main')) {
550
+ return state.images[state.currentIndex]?.src;
551
+ }
552
+ return null;
553
+ };
554
+ // NOTE: capture phase is required. The pan/zoom canvas calls
555
+ // stopPropagation() on touch events (to stop the tab-switch gesture
556
+ // manager), which would otherwise swallow our bubble-phase
557
+ // long-press. Capture runs before the canvas handler, so it still
558
+ // fires. (Mouse right-click works via contextmenu once pan/zoom
559
+ // stops capturing the secondary button — see plugin-helpers.js.)
560
+ container.addEventListener('contextmenu', (e) => {
561
+ // A closed modal is never removed from the DOM — it just goes
562
+ // transparent, still laid out at 90vw x 90vh dead-centre. If an
563
+ // engine leaks a hit-test into it (see the visibility:hidden
564
+ // note on .widget-modal-dialog), this capture-phase listener
565
+ // would answer for the chat BEHIND the gallery: right-clicking
566
+ // a file pill served the image menu for the last-viewed image.
567
+ if (!container.closest('.widget-visible')) return;
568
+ const src = srcAt(e.target);
569
+ if (!src) return;
570
+ // On touch, yield to the native iOS callout. Its "Copy Image"
571
+ // actually works on an iPad PWA, where navigator.clipboard
572
+ // refuses image bytes and our own copy can only fall back to
573
+ // the link. The toolbar pencil + Copy&Close stay reachable.
574
+ if (lastInputWasTouch()) return;
575
+ e.preventDefault();
576
+ e.stopPropagation();
577
+ showImageMenu(e.clientX, e.clientY, src);
578
+ }, true);
579
+
580
+ // Click on dark canvas area (not on the image, not on a drag) closes the widget.
581
+ // Use pointer events to distinguish click from pan-drag via distance threshold.
582
+ const canvasEl = container.querySelector('.img-preview-canvas');
583
+ let downAt = null;
584
+ canvasEl.addEventListener('pointerdown', (e) => {
585
+ downAt = { x: e.clientX, y: e.clientY, target: e.target };
586
+ });
587
+ canvasEl.addEventListener('pointerup', (e) => {
588
+ if (!downAt) return;
589
+ const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);
590
+ const onBackdrop = downAt.target === canvasEl && e.target === canvasEl;
591
+ downAt = null;
592
+ if (moved < 5 && onBackdrop) WidgetManager.close('image-preview');
593
+ });
594
+
595
+ updateDisplay();
596
+ }
597
+ });
598
+ }
599
+
600
+ // ═══════════════════════════════════════════════════════════════════════════
601
+ // PUBLIC API
602
+ // ═══════════════════════════════════════════════════════════════════════════
603
+
604
+ /**
605
+ * Open the widget for the current state.images/currentIndex/mode.
606
+ * Widget render() only runs on first creation — update DOM directly
607
+ * for subsequent opens.
608
+ */
609
+ function openWithState() {
610
+ WidgetManager.open('image-preview');
611
+ attachKeyboard();
612
+
613
+ const modal = document.querySelector('#widget-image-preview .image-preview-modal');
614
+ if (modal) {
615
+ modal.classList.toggle('single-image', state.images.length <= 1);
616
+ modal.classList.toggle('dir-mode', state.mode === 'dir');
617
+ // Rebuild thumb strip — collected images may differ between opens
618
+ const strip = modal.querySelector('.img-thumb-strip');
619
+ if (strip) strip.innerHTML = renderThumbStripHtml();
620
+ updateDisplay();
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Show an image in the preview modal with navigation
626
+ * @param {string} src - Image source (URL or data URI)
627
+ */
628
+ export function showImagePreview(src) {
629
+ // Collect all session images from DOM
630
+ state.mode = 'chat';
631
+ state.images = collectImages();
632
+ state.currentIndex = findImageIndex(state.images, src);
633
+
634
+ // If not found in DOM (e.g., pending upload preview), show as single image
635
+ if (state.currentIndex < 0) {
636
+ state.images = [{ src, turnIndex: 0 }];
637
+ state.currentIndex = 0;
638
+ }
639
+
640
+ openWithState();
641
+ }
642
+
643
+ /**
644
+ * Show an image from the filesystem with its directory siblings as the
645
+ * gallery (file-explorer open path). The thumb strip lists every image in
646
+ * the clicked file's directory instead of the session's chat images.
647
+ * @param {string} path - Absolute path of the clicked image
648
+ */
649
+ export async function showImagePreviewForFile(path) {
650
+ const src = `/api/file-raw?path=${encodeURIComponent(path)}`;
651
+ const dir = path.replace(/\/[^/]*$/, '') || '/';
652
+
653
+ let images = [];
654
+ try {
655
+ const resp = await fetch(`/api/files?path=${encodeURIComponent(dir)}`);
656
+ if (resp.ok) {
657
+ const data = await resp.json();
658
+ images = (data.files || [])
659
+ .filter(f => !f.is_dir && IMAGE_EXT_RE.test(f.name))
660
+ .map(f => ({
661
+ src: `/api/file-raw?path=${encodeURIComponent(f.path)}`,
662
+ turnIndex: 0,
663
+ label: f.name,
664
+ }));
665
+ }
666
+ } catch { /* listing failed — fall through to single image */ }
667
+
668
+ state.mode = 'dir';
669
+ state.currentIndex = findImageIndex(images, src);
670
+ if (state.currentIndex >= 0) {
671
+ state.images = images;
672
+ } else {
673
+ state.images = [{ src, turnIndex: 0, label: basename(path) }];
674
+ state.currentIndex = 0;
675
+ }
676
+
677
+ openWithState();
678
+ }
679
+
680
+ /**
681
+ * Close the image preview modal
682
+ */
683
+ export function closeImagePreview() {
684
+ WidgetManager.close('image-preview');
685
+ }
686
+
687
+ // Export for external access
688
+ export const ImagePreviewWidget = {
689
+ show: showImagePreview,
690
+ showForFile: showImagePreviewForFile,
691
+ close: closeImagePreview
692
+ };