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,1019 @@
1
+ /**
2
+ * Upload Manager Module
3
+ * Handles file and image uploads with preview, drag-drop, and paste support
4
+ */
5
+
6
+ import { CONFIG } from './config.js';
7
+ import { genId, escapeHtml, $ } from './utils.js';
8
+ import { ImagePreviewWidget, openImageAnnotator, isImageAnnotatorOpen, isAnnotateOnPasteEnabled } from './widgets/index.js';
9
+ import { ContextMenu, copyToClipboard, showToast } from './context-menu.js';
10
+ import { WidgetBus } from './widget-system/event-bus.js';
11
+ import S from './strings.js';
12
+
13
+ // Media types Claude accepts verbatim as a base64 image block. Anything else
14
+ // (svg, bmp, …) has to go back through /api/upload-image, which re-encodes it.
15
+ const CLAUDE_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
16
+
17
+ const EXT_MIME = {
18
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
19
+ webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
20
+ };
21
+
22
+ /** Best-effort image mime from a filename — File.type drives handleImages(). */
23
+ function guessImageMime(name) {
24
+ const ext = (name || '').split('.').pop().toLowerCase();
25
+ return EXT_MIME[ext] || 'image/png';
26
+ }
27
+
28
+ /**
29
+ * UploadManager - Handle file and image uploads
30
+ */
31
+ export class UploadManager {
32
+ /**
33
+ * @param {Object} elements - DOM elements
34
+ * @param {HTMLElement} elements.inputContainer - Container for input area
35
+ * @param {HTMLTextAreaElement} elements.messageInput - The message input
36
+ * @param {HTMLElement} elements.messagesContainer - For drag-drop zone
37
+ * @param {HTMLButtonElement} elements.sendBtn - Send button (to update state)
38
+ * @param {Object} options - Configuration
39
+ * @param {string} options.apiBase - API base URL
40
+ * @param {Object} callbacks - Callbacks
41
+ * @param {Function} callbacks.getSessionId - Returns current session storeId
42
+ * @param {Function} callbacks.isConnected - Returns true if session connected
43
+ * @param {Function} callbacks.onError - Called on upload error
44
+ * @param {Function} callbacks.onStateChange - Called when uploads change
45
+ */
46
+ constructor(elements, options = {}, callbacks = {}) {
47
+ this.els = elements;
48
+ this.apiBase = options.apiBase || CONFIG.API_BASE;
49
+
50
+ // Callbacks
51
+ this.getSessionId = callbacks.getSessionId || (() => null);
52
+ this.isConnected = callbacks.isConnected || (() => false);
53
+ this.onError = callbacks.onError || ((msg) => console.error(msg));
54
+ this.onStateChange = callbacks.onStateChange || (() => {});
55
+
56
+ // State
57
+ this.pendingImages = [];
58
+ this.uploadingImages = [];
59
+ this.pendingFiles = [];
60
+ this.uploadingFiles = [];
61
+
62
+ // Session ids of upload batches (a multi-file paste/drop) still being
63
+ // walked. handleImages()/_uploadFiles() upload sequentially, so between
64
+ // one file finishing and the next being pushed, uploading* is
65
+ // momentarily empty — without this, isUploading() would blink false
66
+ // mid-batch and a send parked on the batch would fire with only the
67
+ // first file.
68
+ //
69
+ // Keyed by session because this manager is a singleton shared by every
70
+ // tab: a batch started in session A keeps running after a switch to B,
71
+ // and counting it as "B is uploading" would park B's send behind an
72
+ // upload that will never belong to it.
73
+ this._activeBatchSessions = [];
74
+
75
+ // Context menu for file chips
76
+ this._contextMenu = new ContextMenu();
77
+ this._longPressTimer = null;
78
+
79
+ // Create hidden file inputs
80
+ this._createInputs();
81
+
82
+ // Bind event listeners
83
+ this._bindEvents();
84
+ }
85
+
86
+ /**
87
+ * Show image in preview modal (uses widget system)
88
+ */
89
+ showImagePreview(src) {
90
+ ImagePreviewWidget.show(src);
91
+ }
92
+
93
+ /**
94
+ * Create hidden file inputs
95
+ */
96
+ _createInputs() {
97
+ // Image input
98
+ this.imageInput = document.createElement('input');
99
+ this.imageInput.type = 'file';
100
+ this.imageInput.accept = 'image/png,image/jpeg,image/gif,image/webp';
101
+ this.imageInput.multiple = true;
102
+ this.imageInput.style.display = 'none';
103
+ document.body.appendChild(this.imageInput);
104
+
105
+ this.imageInput.addEventListener('change', (e) => {
106
+ this.handleImages(e.target.files);
107
+ this.imageInput.value = '';
108
+ });
109
+
110
+ // General file input
111
+ this.fileInput = document.createElement('input');
112
+ this.fileInput.type = 'file';
113
+ this.fileInput.multiple = true;
114
+ this.fileInput.style.display = 'none';
115
+ document.body.appendChild(this.fileInput);
116
+
117
+ this.fileInput.addEventListener('change', (e) => {
118
+ this.handleFiles(e.target.files);
119
+ this.fileInput.value = '';
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Bind paste and drag-drop events
125
+ */
126
+ _bindEvents() {
127
+ // Track Shift on the paste chord — ClipboardEvent carries no modifier
128
+ // state, so remember it from the keydown that triggers the paste.
129
+ // Shift inverts the annotate-on-paste setting: with auto-annotate on,
130
+ // Cmd/Ctrl+Shift+V uploads plain; with it off (default), Shift+V is
131
+ // the way to open the annotation editor from a paste.
132
+ this.els.messageInput.addEventListener('keydown', (e) => {
133
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'v') {
134
+ this._pasteShift = e.shiftKey;
135
+ }
136
+ });
137
+
138
+ // Paste on message input
139
+ this.els.messageInput.addEventListener('paste', (e) => {
140
+ const items = e.clipboardData?.items;
141
+ if (!items) return;
142
+
143
+ const shift = this._pasteShift === true;
144
+ this._pasteShift = false;
145
+
146
+ for (const item of items) {
147
+ if (item.type.startsWith('image/')) {
148
+ e.preventDefault();
149
+ const file = item.getAsFile();
150
+ if (file) {
151
+ const annotate = isAnnotateOnPasteEnabled() !== shift;
152
+ // GIFs would be flattened by the canvas export; the
153
+ // editor is also skipped while one is already open.
154
+ if (annotate && file.type !== 'image/gif' && !isImageAnnotatorOpen()) {
155
+ this._openAnnotator(file);
156
+ } else {
157
+ this.handleImages([file]);
158
+ }
159
+ }
160
+ break;
161
+ }
162
+ }
163
+ });
164
+
165
+ // Drag and drop on messages area
166
+ this.els.messagesContainer.addEventListener('dragover', (e) => {
167
+ e.preventDefault();
168
+ this.els.messagesContainer.classList.add('drag-over');
169
+ });
170
+
171
+ this.els.messagesContainer.addEventListener('dragleave', (e) => {
172
+ e.preventDefault();
173
+ this.els.messagesContainer.classList.remove('drag-over');
174
+ });
175
+
176
+ this.els.messagesContainer.addEventListener('drop', (e) => {
177
+ e.preventDefault();
178
+ this.els.messagesContainer.classList.remove('drag-over');
179
+ this.handleFiles(e.dataTransfer.files);
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Handle file uploads - routes images to image handler, others to file upload
185
+ */
186
+ handleFiles(files) {
187
+ const fileArray = Array.from(files);
188
+ const imageFiles = fileArray.filter(f => f.type.startsWith('image/'));
189
+ const otherFiles = fileArray.filter(f => !f.type.startsWith('image/'));
190
+
191
+ if (imageFiles.length > 0) {
192
+ this.handleImages(imageFiles);
193
+ }
194
+ if (otherFiles.length > 0) {
195
+ this._uploadFiles(otherFiles);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Handle image uploads
201
+ */
202
+ async handleImages(files) {
203
+ const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
204
+ if (!imageFiles.length) return;
205
+
206
+ const batch = this._beginBatch();
207
+ try {
208
+ await this._handleImagesBatch(imageFiles);
209
+ } finally {
210
+ this._endBatch(batch);
211
+ // Batch fully settled — let listeners (e.g. a send parked on this
212
+ // batch) act now that isUploading() is honestly false.
213
+ this._notifyChange();
214
+ }
215
+ }
216
+
217
+ async _handleImagesBatch(imageFiles) {
218
+ for (const file of imageFiles) {
219
+ const preview = URL.createObjectURL(file);
220
+ const uploadId = genId();
221
+
222
+ // Show loading state
223
+ this.uploadingImages.push({ id: uploadId, preview });
224
+ this._renderImagePreviews();
225
+
226
+ try {
227
+ const formData = new FormData();
228
+ formData.append('file', file);
229
+
230
+ const sessionId = this.getSessionId();
231
+ let uploadUrl = `${this.apiBase}/api/upload-image`;
232
+ if (sessionId) uploadUrl += `?session=${encodeURIComponent(sessionId)}`;
233
+
234
+ const response = await fetch(uploadUrl, {
235
+ method: 'POST',
236
+ body: formData
237
+ });
238
+
239
+ this.uploadingImages = this.uploadingImages.filter(img => img.id !== uploadId);
240
+
241
+ if (!response.ok) {
242
+ const error = await response.json();
243
+ this.onError(`Upload failed: ${error.detail}`);
244
+ this._renderImagePreviews();
245
+ this._notifyChange();
246
+ continue;
247
+ }
248
+
249
+ const data = await response.json();
250
+
251
+ // The user switched tabs while this was in flight. pendingImages
252
+ // now belongs to a different session (switchSession saved the
253
+ // outgoing arrays off before we resolved), so pushing here would
254
+ // silently attach this image to someone else's next message.
255
+ if (this._isForeignSession(sessionId)) {
256
+ URL.revokeObjectURL(preview);
257
+ this._renderImagePreviews();
258
+ this._notifyChange();
259
+ continue;
260
+ }
261
+
262
+ this.pendingImages.push({
263
+ file,
264
+ preview,
265
+ imageData: data.image,
266
+ storedName: data.stored_name || null,
267
+ mediaType: data.media_type || null,
268
+ });
269
+
270
+ this._renderImagePreviews();
271
+ this._notifyChange();
272
+ } catch (error) {
273
+ this.uploadingImages = this.uploadingImages.filter(img => img.id !== uploadId);
274
+ this.onError(`Upload error: ${error.message}`);
275
+ this._renderImagePreviews();
276
+ this._notifyChange();
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Open the annotation editor for a pasted image. Done → upload the
283
+ * annotated PNG; skip → upload the original; cancel → drop the paste.
284
+ */
285
+ _openAnnotator(file) {
286
+ openImageAnnotator(file, {
287
+ onDone: (blob, markers) => {
288
+ const annotated = this._annotatedFile(blob, file.name);
289
+ this.handleImages([annotated]);
290
+ this._stashMarkers(annotated.name, markers);
291
+ },
292
+ onSkip: () => this.handleImages([file]),
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Re-open a pending image in the annotation editor; on done the
298
+ * annotated PNG replaces the original pending upload.
299
+ */
300
+ async editImage(index) {
301
+ const img = this.pendingImages[index];
302
+ if (!img) return;
303
+ let blob = img.file;
304
+ if (!blob) {
305
+ // Restored from server — the preview blob URL is the only copy
306
+ try { blob = await fetch(img.preview).then(r => r.blob()); }
307
+ catch { return; }
308
+ }
309
+ openImageAnnotator(blob, {
310
+ onDone: (out, markers) => {
311
+ const name = img.file?.name || img.storedName;
312
+ this.removeImage(index);
313
+ const annotated = this._annotatedFile(out, name);
314
+ this.handleImages([annotated]);
315
+ this._stashMarkers(annotated.name, markers);
316
+ },
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Attach an annotated PNG produced outside the paste flow — currently the
322
+ * image gallery's "Open in Annotation Editor" action, which annotates a
323
+ * file that already exists on disk. The result lands in the same place as
324
+ * paste-to-annotate: a pending chat attachment, with marker notes stashed.
325
+ * @param {Blob} blob - PNG exported by the annotation editor
326
+ * @param {string} originalName - name of the source image
327
+ * @param {Array} markers - [{n, note}] from the editor
328
+ */
329
+ attachAnnotated(blob, originalName, markers) {
330
+ const annotated = this._annotatedFile(blob, originalName);
331
+ this.handleImages([annotated]);
332
+ this._stashMarkers(annotated.name, markers);
333
+ }
334
+
335
+ /**
336
+ * Re-attach an upload that already exists on disk as a pending attachment
337
+ * on the current input — the Uploads widget's "Attach to Chat" action.
338
+ *
339
+ * Non-image files need no copy: the prompt only ever carries the absolute
340
+ * path, so an upload from any session in the project attaches as-is.
341
+ *
342
+ * Images DO travel as base64, so the bytes are fetched back. Same session →
343
+ * reuse the stored name (no duplicate on disk, and the pending image
344
+ * survives a page refresh). Different session → re-upload into THIS
345
+ * session, because restoreFromServer() resolves storedName against the
346
+ * current session's uploads dir and would 404 otherwise.
347
+ *
348
+ * @param {Object} entry - {name, session_id, path, size, is_image} as
349
+ * served by GET /api/sessions/{id}/uploads
350
+ * @returns {Promise<boolean>} true when something is now pending
351
+ */
352
+ async attachStored(entry) {
353
+ if (!entry?.name) return false;
354
+
355
+ if (!entry.is_image) {
356
+ if (!entry.path) {
357
+ this.onError(S.uploads.attach_no_path);
358
+ return false;
359
+ }
360
+ const fileEntry = {
361
+ name: entry.name,
362
+ path: entry.path,
363
+ size: entry.size || 0,
364
+ originalName: entry.name,
365
+ };
366
+ const existingIdx = this.pendingFiles.findIndex(f => f.path === entry.path);
367
+ if (existingIdx >= 0) this.pendingFiles[existingIdx] = fileEntry;
368
+ else this.pendingFiles.push(fileEntry);
369
+ this._renderFilePreviews();
370
+ this._notifyChange();
371
+ return true;
372
+ }
373
+
374
+ const ownerId = entry.session_id;
375
+ const currentId = this.getSessionId();
376
+ if (!ownerId) return false;
377
+
378
+ // Already pending from this same session — don't stack a duplicate
379
+ if (ownerId === currentId && this.pendingImages.some(i => i.storedName === entry.name)) {
380
+ return true;
381
+ }
382
+
383
+ const uploadId = genId();
384
+ this.uploadingImages.push({
385
+ id: uploadId,
386
+ preview: `${this.apiBase}/api/sessions/${encodeURIComponent(ownerId)}/uploads/${encodeURIComponent(entry.name)}`,
387
+ });
388
+ this._renderImagePreviews();
389
+
390
+ try {
391
+ const resp = await fetch(
392
+ `${this.apiBase}/api/sessions/${encodeURIComponent(ownerId)}/uploads/${encodeURIComponent(entry.name)}?base64_encode=true`
393
+ );
394
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
395
+ const data = await resp.json();
396
+ const blob = this._base64Blob(data.data, data.media_type);
397
+
398
+ this.uploadingImages = this.uploadingImages.filter(i => i.id !== uploadId);
399
+
400
+ // Cross-session, or a format Claude can't take as-is (svg/bmp land
401
+ // on 'application/octet-stream') — round-trip through the upload
402
+ // endpoint so the server normalises AND files it under this session.
403
+ const claudeSafe = CLAUDE_IMAGE_TYPES.has(data.media_type);
404
+ if (ownerId !== currentId || !claudeSafe) {
405
+ const type = claudeSafe ? data.media_type : guessImageMime(entry.name);
406
+ await this.handleImages([new File([blob], entry.name, { type })]);
407
+ return true;
408
+ }
409
+
410
+ this.pendingImages.push({
411
+ file: null,
412
+ preview: URL.createObjectURL(blob),
413
+ imageData: {
414
+ type: 'image',
415
+ source: { type: 'base64', media_type: data.media_type, data: data.data },
416
+ },
417
+ storedName: entry.name,
418
+ mediaType: data.media_type,
419
+ });
420
+ this._renderImagePreviews();
421
+ this._notifyChange();
422
+ return true;
423
+ } catch (err) {
424
+ this.uploadingImages = this.uploadingImages.filter(i => i.id !== uploadId);
425
+ this._renderImagePreviews();
426
+ this._notifyChange();
427
+ this.onError(S.uploads.attach_failed.replace('{name}', entry.name));
428
+ return false;
429
+ }
430
+ }
431
+
432
+ /** Decode a base64 payload into a Blob (preview + re-upload source). */
433
+ _base64Blob(base64, mediaType) {
434
+ const binary = atob(base64);
435
+ const bytes = new Uint8Array(binary.length);
436
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
437
+ return new Blob([bytes], { type: mediaType });
438
+ }
439
+
440
+ /**
441
+ * Turn numbered-marker comments from the annotation editor into Comments
442
+ * Stash items. The stash block and the annotated image travel in the same
443
+ * user message, so "marker N on <file>" is enough for Claude to connect
444
+ * the comment to the badge on the picture.
445
+ */
446
+ async _stashMarkers(fileName, markers) {
447
+ if (!markers?.length) return;
448
+ try {
449
+ const { Stash } = await import('./stash.js');
450
+ // Stash.add unshifts — insert in reverse so the list reads 1, 2, 3
451
+ for (const m of [...markers].reverse()) {
452
+ await Stash.add({
453
+ type: 'image',
454
+ filePath: fileName,
455
+ markerIndex: m.n,
456
+ }, m.note);
457
+ }
458
+ showToast(S.toast.markers_stashed.replace('{count}', markers.length));
459
+ } catch (err) {
460
+ console.error('[Upload] Failed to stash marker comments:', err);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Wrap an exported annotation blob as a File named after the original.
466
+ */
467
+ _annotatedFile(blob, originalName) {
468
+ const base = (originalName || 'pasted-image').replace(/\.[^.]+$/, '');
469
+ return new File([blob], `${base}-annotated.png`, { type: 'image/png' });
470
+ }
471
+
472
+ /**
473
+ * Handle non-image file uploads
474
+ */
475
+ async _uploadFiles(files) {
476
+ if (!files.length) return;
477
+
478
+ const batch = this._beginBatch();
479
+ try {
480
+ await this._uploadFilesBatch(files);
481
+ } finally {
482
+ this._endBatch(batch);
483
+ this._notifyChange();
484
+ }
485
+ }
486
+
487
+ async _uploadFilesBatch(files) {
488
+ const sessionId = this.getSessionId();
489
+
490
+ for (const file of files) {
491
+ const uploadId = genId();
492
+
493
+ this.uploadingFiles.push({ id: uploadId, name: file.name });
494
+ this._renderFilePreviews();
495
+
496
+ try {
497
+ const formData = new FormData();
498
+ formData.append('file', file);
499
+
500
+ let url = `${this.apiBase}/api/upload-file`;
501
+ if (sessionId) url += `?session=${encodeURIComponent(sessionId)}`;
502
+
503
+ const response = await fetch(url, { method: 'POST', body: formData });
504
+
505
+ this.uploadingFiles = this.uploadingFiles.filter(f => f.id !== uploadId);
506
+
507
+ if (!response.ok) {
508
+ const error = await response.json();
509
+ this.onError(`File upload failed: ${error.detail}`);
510
+ this._renderFilePreviews();
511
+ this._notifyChange();
512
+ continue;
513
+ }
514
+
515
+ const data = await response.json();
516
+ const fileEntry = {
517
+ name: data.stored_name,
518
+ path: data.path,
519
+ size: data.size,
520
+ originalName: data.filename,
521
+ };
522
+
523
+ // Switched tabs mid-flight — pendingFiles is the new session's
524
+ // list now, so don't attach this file to it. The upload itself
525
+ // still succeeded under the original session (it's on disk
526
+ // there), which the WidgetBus event below reports.
527
+ if (this._isForeignSession(sessionId)) {
528
+ this._renderFilePreviews();
529
+ this._notifyChange();
530
+ WidgetBus.emit('uploads:changed', { sessionId });
531
+ continue;
532
+ }
533
+
534
+ // Replace existing entry with same name, or append
535
+ const existingIdx = this.pendingFiles.findIndex(f => f.name === data.stored_name);
536
+ if (existingIdx >= 0) {
537
+ this.pendingFiles[existingIdx] = fileEntry;
538
+ } else {
539
+ this.pendingFiles.push(fileEntry);
540
+ }
541
+
542
+ this._renderFilePreviews();
543
+ this._notifyChange();
544
+
545
+ // Notify uploads widget that files changed (file is already on disk)
546
+ if (sessionId) {
547
+ WidgetBus.emit('uploads:changed', { sessionId });
548
+ }
549
+ } catch (error) {
550
+ this.uploadingFiles = this.uploadingFiles.filter(f => f.id !== uploadId);
551
+ this.onError(`File upload error: ${error.message}`);
552
+ this._renderFilePreviews();
553
+ this._notifyChange();
554
+ }
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Render file previews
560
+ */
561
+ _renderFilePreviews() {
562
+ let container = $('#file-previews');
563
+
564
+ // Only create container if we have files to show
565
+ const hasFiles = this.uploadingFiles.length > 0 || this.pendingFiles.length > 0;
566
+
567
+ if (!container && hasFiles) {
568
+ container = document.createElement('div');
569
+ container.id = 'file-previews';
570
+ // Insert before image-previews if it exists, otherwise before textarea wrapper
571
+ const imagePreviews = $('#image-previews');
572
+ if (imagePreviews && imagePreviews.parentNode === this.els.inputContainer) {
573
+ this.els.inputContainer.insertBefore(container, imagePreviews);
574
+ } else {
575
+ const textareaWrapper = this.els.inputContainer.querySelector('.input-textarea-wrapper');
576
+ if (textareaWrapper) {
577
+ this.els.inputContainer.insertBefore(container, textareaWrapper);
578
+ } else {
579
+ this.els.inputContainer.appendChild(container);
580
+ }
581
+ }
582
+ }
583
+
584
+ // If no container and no files, nothing to do
585
+ if (!container) return;
586
+
587
+ const formatSize = (bytes) => {
588
+ if (bytes < 1024) return `${bytes} B`;
589
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
590
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
591
+ };
592
+
593
+ const getFileIcon = (name) => {
594
+ const ext = name.split('.').pop()?.toLowerCase();
595
+ const codeExts = ['js', 'ts', 'jsx', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'vue', 'svelte'];
596
+ const dataExts = ['json', 'yaml', 'yml', 'xml', 'csv', 'sql'];
597
+ const docExts = ['md', 'txt', 'rst', 'doc', 'docx', 'pdf'];
598
+ if (codeExts.includes(ext)) return '📝';
599
+ if (dataExts.includes(ext)) return '📊';
600
+ if (docExts.includes(ext)) return '📄';
601
+ return '📎';
602
+ };
603
+
604
+ const uploadingHtml = this.uploadingFiles.map(f => `
605
+ <div class="file-preview uploading" data-upload-id="${f.id}">
606
+ <span class="file-icon">⏳</span>
607
+ <span class="file-name">${escapeHtml(f.name)}</span>
608
+ <span class="file-spinner"></span>
609
+ </div>
610
+ `).join('');
611
+
612
+ const pendingHtml = this.pendingFiles.map((f, idx) => `
613
+ <div class="file-preview" data-index="${idx}">
614
+ <span class="file-icon">${getFileIcon(f.name)}</span>
615
+ <span class="file-name">${escapeHtml(f.name)}</span>
616
+ <span class="file-size">${formatSize(f.size)}</span>
617
+ <button class="file-remove" data-index="${idx}" data-tooltip="${S.upload_manager.remove_file}">
618
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
619
+ <path d="M18 6L6 18M6 6l12 12"/>
620
+ </svg>
621
+ </button>
622
+ </div>
623
+ `).join('');
624
+
625
+ container.innerHTML = uploadingHtml + pendingHtml;
626
+
627
+ container.querySelectorAll('.file-remove').forEach(btn => {
628
+ btn.addEventListener('click', (e) => {
629
+ e.preventDefault();
630
+ e.stopPropagation();
631
+ this.removeFile(parseInt(btn.dataset.index));
632
+ });
633
+ });
634
+
635
+ // Click to preview, context menu for options
636
+ container.querySelectorAll('.file-preview:not(.uploading)').forEach(chip => {
637
+ const idx = parseInt(chip.dataset.index);
638
+ const file = this.pendingFiles[idx];
639
+ if (!file) return;
640
+
641
+ // Click → preview
642
+ chip.addEventListener('click', (e) => {
643
+ if (e.target.closest('.file-remove')) return;
644
+ e.preventDefault();
645
+ this._openFilePreview(file.path);
646
+ });
647
+
648
+ // Right-click → context menu
649
+ chip.addEventListener('contextmenu', (e) => {
650
+ e.preventDefault();
651
+ e.stopPropagation();
652
+ this._showFileContextMenu(e.clientX, e.clientY, file, idx);
653
+ });
654
+
655
+ // Long-press for touch → context menu
656
+ chip.addEventListener('touchstart', (e) => {
657
+ if (e.target.closest('.file-remove')) return;
658
+ this._longPressTimer = setTimeout(() => {
659
+ this._longPressTimer = null;
660
+ const touch = e.touches[0];
661
+ this._showFileContextMenu(touch.clientX, touch.clientY, file, idx);
662
+ }, 500);
663
+ }, { passive: true });
664
+ chip.addEventListener('touchend', () => {
665
+ if (this._longPressTimer) {
666
+ clearTimeout(this._longPressTimer);
667
+ this._longPressTimer = null;
668
+ }
669
+ });
670
+ chip.addEventListener('touchmove', () => {
671
+ if (this._longPressTimer) {
672
+ clearTimeout(this._longPressTimer);
673
+ this._longPressTimer = null;
674
+ }
675
+ });
676
+ });
677
+
678
+ container.classList.toggle('visible', hasFiles);
679
+ }
680
+
681
+ /**
682
+ * Render image previews
683
+ */
684
+ _renderImagePreviews() {
685
+ let container = $('#image-previews');
686
+
687
+ // Only create container if we have images to show
688
+ const hasImages = this.uploadingImages.length > 0 || this.pendingImages.length > 0;
689
+
690
+ if (!container && hasImages) {
691
+ container = document.createElement('div');
692
+ container.id = 'image-previews';
693
+ // Insert before the textarea wrapper (a direct child of inputContainer)
694
+ const textareaWrapper = this.els.inputContainer.querySelector('.input-textarea-wrapper');
695
+ if (textareaWrapper) {
696
+ this.els.inputContainer.insertBefore(container, textareaWrapper);
697
+ } else {
698
+ this.els.inputContainer.appendChild(container);
699
+ }
700
+ }
701
+
702
+ // If no container and no images, nothing to do
703
+ if (!container) return;
704
+
705
+ const uploadingHtml = this.uploadingImages.map(img => `
706
+ <div class="image-preview uploading" data-upload-id="${img.id}">
707
+ <img src="${img.preview}" alt="Uploading...">
708
+ <div class="upload-spinner">
709
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
710
+ <circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="8"/>
711
+ </svg>
712
+ </div>
713
+ </div>
714
+ `).join('');
715
+
716
+ const pendingHtml = this.pendingImages.map((img, idx) => `
717
+ <div class="image-preview" data-index="${idx}">
718
+ <img src="${img.preview}" alt="Upload preview">
719
+ <button class="image-annotate" data-index="${idx}" data-tooltip="${S.upload_manager.annotate_image}">
720
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
721
+ <path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
722
+ </svg>
723
+ </button>
724
+ <button class="image-remove" data-index="${idx}" data-tooltip="${S.upload_manager.remove_image}">
725
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
726
+ <path d="M18 6L6 18M6 6l12 12"/>
727
+ </svg>
728
+ </button>
729
+ </div>
730
+ `).join('');
731
+
732
+ container.innerHTML = uploadingHtml + pendingHtml;
733
+
734
+ container.querySelectorAll('.image-remove').forEach(btn => {
735
+ btn.addEventListener('click', (e) => {
736
+ e.preventDefault();
737
+ e.stopPropagation();
738
+ this.removeImage(parseInt(btn.dataset.index));
739
+ });
740
+ });
741
+
742
+ container.querySelectorAll('.image-annotate').forEach(btn => {
743
+ btn.addEventListener('click', (e) => {
744
+ e.preventDefault();
745
+ e.stopPropagation();
746
+ this.editImage(parseInt(btn.dataset.index));
747
+ });
748
+ });
749
+
750
+ // Click a pending thumbnail → open the annotation editor directly
751
+ // (annotating is the primary intent; the pencil button is the same
752
+ // action). Uploading thumbnails have no pending index — skip them.
753
+ container.querySelectorAll('.image-preview:not(.uploading) img').forEach(img => {
754
+ img.addEventListener('click', (e) => {
755
+ e.preventDefault();
756
+ e.stopPropagation();
757
+ const wrap = img.closest('.image-preview[data-index]');
758
+ if (wrap) this.editImage(parseInt(wrap.dataset.index));
759
+ });
760
+ });
761
+
762
+ container.classList.toggle('visible', hasImages);
763
+ }
764
+
765
+ /**
766
+ * Notify that upload state changed
767
+ */
768
+ _notifyChange() {
769
+ this.onStateChange();
770
+ }
771
+
772
+ /** Register an in-flight batch against the session that started it. */
773
+ _beginBatch() {
774
+ const batch = { session: this.getSessionId() };
775
+ this._activeBatchSessions.push(batch);
776
+ return batch;
777
+ }
778
+
779
+ _endBatch(batch) {
780
+ const i = this._activeBatchSessions.indexOf(batch);
781
+ if (i !== -1) this._activeBatchSessions.splice(i, 1);
782
+ }
783
+
784
+ /**
785
+ * Does `session` belong to a different session than the one on screen?
786
+ *
787
+ * Deliberately false when either id is missing: a fresh tab has no storeId
788
+ * until it connects, so an image pasted into it starts a batch with a null
789
+ * session and would otherwise be judged "foreign" the moment the id is
790
+ * assigned mid-upload — dropping a perfectly good attachment.
791
+ */
792
+ _isForeignSession(session) {
793
+ const current = this.getSessionId();
794
+ return !!(session && current && session !== current);
795
+ }
796
+
797
+ // ─────────────────────────────────────────────────────────────────
798
+ // File preview & context menu
799
+ // ─────────────────────────────────────────────────────────────────
800
+
801
+ /**
802
+ * Open file in the inline preview widget
803
+ */
804
+ _openFilePreview(filePath) {
805
+ window.app?.previewFile(filePath);
806
+ }
807
+
808
+ /**
809
+ * Show context menu for a file chip
810
+ */
811
+ _showFileContextMenu(x, y, file, index) {
812
+ this._contextMenu.show(x, y, [
813
+ {
814
+ label: S.context_menus.file.preview,
815
+ action: () => this._openFilePreview(file.path),
816
+ },
817
+ {
818
+ label: S.widgets.header_actions.copy_path,
819
+ action: async () => {
820
+ await copyToClipboard(file.path);
821
+ showToast(S.toast.path_copied);
822
+ },
823
+ },
824
+ { separator: true },
825
+ {
826
+ label: S.common.delete,
827
+ action: () => this.removeFile(index),
828
+ },
829
+ ]);
830
+ }
831
+
832
+ // ─────────────────────────────────────────────────────────────────
833
+ // Public API
834
+ // ─────────────────────────────────────────────────────────────────
835
+
836
+ /**
837
+ * Open image file picker
838
+ */
839
+ openImagePicker() {
840
+ this.imageInput.click();
841
+ }
842
+
843
+ /**
844
+ * Open general file picker
845
+ */
846
+ openFilePicker() {
847
+ this.fileInput.click();
848
+ }
849
+
850
+ /**
851
+ * Remove a pending file
852
+ */
853
+ removeFile(index) {
854
+ if (index >= 0 && index < this.pendingFiles.length) {
855
+ this.pendingFiles.splice(index, 1);
856
+ this._renderFilePreviews();
857
+ this._notifyChange();
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Remove a pending image
863
+ */
864
+ removeImage(index) {
865
+ if (index >= 0 && index < this.pendingImages.length) {
866
+ URL.revokeObjectURL(this.pendingImages[index].preview);
867
+ this.pendingImages.splice(index, 1);
868
+ this._renderImagePreviews();
869
+ this._notifyChange();
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Clear all pending files
875
+ */
876
+ clearFiles() {
877
+ this.pendingFiles = [];
878
+ this._renderFilePreviews();
879
+ }
880
+
881
+ /**
882
+ * Clear all pending images
883
+ */
884
+ clearImages() {
885
+ this.pendingImages.forEach(img => URL.revokeObjectURL(img.preview));
886
+ this.pendingImages = [];
887
+ this._renderImagePreviews();
888
+ }
889
+
890
+ /**
891
+ * Clear all pending uploads
892
+ */
893
+ clear() {
894
+ this.clearFiles();
895
+ this.clearImages();
896
+ }
897
+
898
+ /**
899
+ * Save current upload state (for session switching).
900
+ * Returns arrays that can be stored on a Session object.
901
+ * Does NOT revoke blob URLs — the session still owns them.
902
+ */
903
+ saveState() {
904
+ return {
905
+ pendingImages: this.pendingImages.slice(),
906
+ pendingFiles: this.pendingFiles.slice()
907
+ };
908
+ }
909
+
910
+ /**
911
+ * Restore upload state (for session switching).
912
+ * Replaces current state and re-renders previews.
913
+ */
914
+ restoreState(state) {
915
+ // Clear current without revoking URLs (they belong to the outgoing session)
916
+ this.pendingImages = state?.pendingImages || [];
917
+ this.pendingFiles = state?.pendingFiles || [];
918
+ this.uploadingImages = [];
919
+ this.uploadingFiles = [];
920
+ this._renderImagePreviews();
921
+ this._renderFilePreviews();
922
+ this._notifyChange();
923
+ }
924
+
925
+ /**
926
+ * Restore uploads from server after page refresh.
927
+ * Fetches images from server to reconstruct imageData and blob URLs.
928
+ * Files are metadata-only (already on disk server-side).
929
+ *
930
+ * @param {Object} metadata - {images: [{storedName, mediaType}], files: [{name, path, size, originalName}]}
931
+ * @param {string} sessionId - The server-side session storeId
932
+ */
933
+ async restoreFromServer(metadata, sessionId) {
934
+ if (!metadata || !sessionId) return;
935
+ const { images = [], files = [] } = metadata;
936
+
937
+ // Restore files (metadata-only, no server fetch needed)
938
+ if (files.length > 0) {
939
+ this.pendingFiles = files.slice();
940
+ this._renderFilePreviews();
941
+ }
942
+
943
+ // Restore images (fetch from server for base64 + preview)
944
+ if (images.length > 0) {
945
+ for (const imgMeta of images) {
946
+ try {
947
+ const resp = await fetch(
948
+ `${this.apiBase}/api/sessions/${encodeURIComponent(sessionId)}/uploads/${encodeURIComponent(imgMeta.storedName)}?base64_encode=true`
949
+ );
950
+ if (!resp.ok) {
951
+ console.warn(`[UploadManager] Failed to restore image ${imgMeta.storedName}: ${resp.status}`);
952
+ continue;
953
+ }
954
+ const data = await resp.json();
955
+
956
+ // Reconstruct Claude API imageData
957
+ const imageData = {
958
+ type: 'image',
959
+ source: {
960
+ type: 'base64',
961
+ media_type: data.media_type,
962
+ data: data.data
963
+ }
964
+ };
965
+
966
+ // Create blob URL for preview
967
+ const preview = URL.createObjectURL(this._base64Blob(data.data, data.media_type));
968
+
969
+ this.pendingImages.push({
970
+ file: null,
971
+ preview,
972
+ imageData,
973
+ storedName: imgMeta.storedName,
974
+ mediaType: data.media_type,
975
+ });
976
+ } catch (err) {
977
+ console.warn(`[UploadManager] Error restoring image ${imgMeta.storedName}:`, err);
978
+ }
979
+ }
980
+ this._renderImagePreviews();
981
+ }
982
+
983
+ if (images.length > 0 || files.length > 0) {
984
+ this._notifyChange();
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Get pending images for sending
990
+ */
991
+ getPendingImages() {
992
+ return this.pendingImages.map(img => img.imageData);
993
+ }
994
+
995
+ /**
996
+ * Get pending files for message text
997
+ */
998
+ getPendingFiles() {
999
+ return this.pendingFiles.slice();
1000
+ }
1001
+
1002
+ /**
1003
+ * Check if there are pending uploads
1004
+ */
1005
+ get hasPending() {
1006
+ return this.pendingImages.length > 0 || this.pendingFiles.length > 0;
1007
+ }
1008
+
1009
+ /**
1010
+ * Check if there are active uploads
1011
+ */
1012
+ get isUploading() {
1013
+ // Only batches belonging to the session on screen count — see
1014
+ // _activeBatchSessions in the constructor.
1015
+ return this._activeBatchSessions.some(b => !this._isForeignSession(b.session)) ||
1016
+ this.uploadingImages.length > 0 ||
1017
+ this.uploadingFiles.length > 0;
1018
+ }
1019
+ }