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,951 @@
1
+ /**
2
+ * ImageAnnotateWidget - Draw on an image before uploading
3
+ *
4
+ * Paint-style editor opened when the user pastes an image (Cmd/Ctrl+V).
5
+ * Tools: numbered comment markers, freehand pen, arrow, rectangle, ellipse,
6
+ * text; color swatches, three stroke sizes, undo/redo. "Attach annotated"
7
+ * exports the canvas as PNG and hands it back; "Attach original" skips
8
+ * editing. Enter accepts (attach annotated); Escape cancels — but once
9
+ * anything has been drawn, Escape/Cancel arm an inline "Discard changes?"
10
+ * confirm and a second press within 4s actually discards.
11
+ *
12
+ * Marker tool (default): click drops a numbered yellow badge and opens a
13
+ * floating comment box. The comment is NOT drawn on the image — onDone
14
+ * receives it as {n, note} so the caller can attach it to the Comments
15
+ * Stash, keeping prose as prompt text while the badge anchors the spot.
16
+ * Committed comments stay visible as a DOM bubble beside the badge
17
+ * (never rasterized into the exported PNG); clicking the bubble or the
18
+ * badge re-opens the comment for editing.
19
+ *
20
+ * Vector model: the base image plus a shapes array, fully redrawn on every
21
+ * change — undo/redo are just stack pops, no pixel snapshots.
22
+ */
23
+
24
+ import { WidgetManager } from '../widget-system/index.js';
25
+ import { isAnnotateOnPasteEnabled, state as configState } from './config/state.js';
26
+ import { withChords } from '../shortcuts.js';
27
+ import S from '../strings.js';
28
+
29
+ // ═══════════════════════════════════════════════════════════════════════════
30
+ // STATE
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+
33
+ // Server resizes uploads to 1568px anyway — cap the canvas so huge retina
34
+ // screenshots don't allocate hundreds of MB of pixel buffers.
35
+ const MAX_CANVAS_DIM = 3000;
36
+
37
+ const state = {
38
+ img: null, // loaded HTMLImageElement (base picture)
39
+ objectUrl: null, // blob URL backing img (revoked on close)
40
+ fileName: null, // original file name, for the exported file
41
+ shapes: [],
42
+ redoStack: [],
43
+ tool: 'marker', // 'marker' | 'pen' | 'arrow' | 'rect' | 'ellipse' | 'text'
44
+ color: '#ff3b30',
45
+ stroke: 'm', // 's' | 'm' | 'l'
46
+ callbacks: null, // {onDone, onSkip, onCancel}
47
+ settled: false, // a callback has fired — ignore further outcomes
48
+ };
49
+
50
+ const COLORS = ['#ff3b30', '#ffcc00', '#34c759', '#0a84ff', '#ffffff', '#111111'];
51
+
52
+ let canvas = null;
53
+ let ctx2d = null;
54
+ let drawing = null; // in-progress shape during a pointer drag
55
+ let markerDrag = null; // {shape, startX, startY, moved} while dragging a badge
56
+ let hoverPt = null; // cursor position for the marker ghost preview
57
+ let _keyHandler = null;
58
+ let _discardArmed = false; // ESC/Cancel while dirty arms a confirm; second one discards
59
+ let _discardTimer = null; // auto-disarm timeout
60
+
61
+ // ═══════════════════════════════════════════════════════════════════════════
62
+ // GEOMETRY / DRAWING
63
+ // ═══════════════════════════════════════════════════════════════════════════
64
+
65
+ /** Scale factor so strokes stay visible on large images. */
66
+ function sizeK() {
67
+ return Math.max(1, (canvas?.width || 1000) / 1000);
68
+ }
69
+
70
+ function strokeWidth(choice = state.stroke) {
71
+ const k = sizeK();
72
+ return { s: 2.5, m: 5, l: 9 }[choice] * k;
73
+ }
74
+
75
+ function fontSize(choice = state.stroke) {
76
+ const k = sizeK();
77
+ return Math.round({ s: 18, m: 28, l: 42 }[choice] * k);
78
+ }
79
+
80
+ function markerRadius(choice = state.stroke) {
81
+ const k = sizeK();
82
+ return Math.round({ s: 11, m: 15, l: 21 }[choice] * k);
83
+ }
84
+
85
+ function markerShapes() {
86
+ return state.shapes.filter(s => s.type === 'marker');
87
+ }
88
+
89
+ /** Map a pointer event to canvas-internal coordinates. */
90
+ function canvasPoint(e) {
91
+ const r = canvas.getBoundingClientRect();
92
+ return {
93
+ x: (e.clientX - r.left) * canvas.width / r.width,
94
+ y: (e.clientY - r.top) * canvas.height / r.height,
95
+ };
96
+ }
97
+
98
+ function drawShape(s) {
99
+ const c = ctx2d;
100
+ c.strokeStyle = s.color;
101
+ c.fillStyle = s.color;
102
+ c.lineWidth = s.w;
103
+ c.lineCap = 'round';
104
+ c.lineJoin = 'round';
105
+
106
+ if (s.type === 'pen') {
107
+ if (s.points.length < 2) {
108
+ c.beginPath();
109
+ c.arc(s.points[0].x, s.points[0].y, s.w / 2, 0, Math.PI * 2);
110
+ c.fill();
111
+ return;
112
+ }
113
+ c.beginPath();
114
+ c.moveTo(s.points[0].x, s.points[0].y);
115
+ for (let i = 1; i < s.points.length; i++) c.lineTo(s.points[i].x, s.points[i].y);
116
+ c.stroke();
117
+ } else if (s.type === 'rect') {
118
+ c.strokeRect(Math.min(s.x1, s.x2), Math.min(s.y1, s.y2),
119
+ Math.abs(s.x2 - s.x1), Math.abs(s.y2 - s.y1));
120
+ } else if (s.type === 'ellipse') {
121
+ c.beginPath();
122
+ c.ellipse((s.x1 + s.x2) / 2, (s.y1 + s.y2) / 2,
123
+ Math.abs(s.x2 - s.x1) / 2, Math.abs(s.y2 - s.y1) / 2, 0, 0, Math.PI * 2);
124
+ c.stroke();
125
+ } else if (s.type === 'arrow') {
126
+ const angle = Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
127
+ const head = Math.max(12, s.w * 3.5);
128
+ // Stop the shaft short of the tip so it doesn't poke through the head
129
+ const shaftX = s.x2 - head * 0.6 * Math.cos(angle);
130
+ const shaftY = s.y2 - head * 0.6 * Math.sin(angle);
131
+ c.beginPath();
132
+ c.moveTo(s.x1, s.y1);
133
+ c.lineTo(shaftX, shaftY);
134
+ c.stroke();
135
+ c.beginPath();
136
+ c.moveTo(s.x2, s.y2);
137
+ c.lineTo(s.x2 - head * Math.cos(angle - Math.PI / 7), s.y2 - head * Math.sin(angle - Math.PI / 7));
138
+ c.lineTo(s.x2 - head * Math.cos(angle + Math.PI / 7), s.y2 - head * Math.sin(angle + Math.PI / 7));
139
+ c.closePath();
140
+ c.fill();
141
+ } else if (s.type === 'text') {
142
+ c.font = `600 ${s.size}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;
143
+ c.textBaseline = 'top';
144
+ c.shadowColor = 'rgba(0, 0, 0, 0.55)';
145
+ c.shadowBlur = Math.max(2, s.size / 8);
146
+ c.fillText(s.text, s.x, s.y);
147
+ c.shadowColor = 'transparent';
148
+ c.shadowBlur = 0;
149
+ } else if (s.type === 'marker') {
150
+ // Numbered comment badge — fixed yellow so markers read as one
151
+ // family regardless of the drawing color
152
+ c.beginPath();
153
+ c.arc(s.x, s.y, s.r, 0, Math.PI * 2);
154
+ c.fillStyle = '#ffcc00';
155
+ c.shadowColor = 'rgba(0, 0, 0, 0.45)';
156
+ c.shadowBlur = Math.max(3, s.r * 0.4);
157
+ c.fill();
158
+ c.shadowColor = 'transparent';
159
+ c.shadowBlur = 0;
160
+ c.lineWidth = Math.max(1.5, s.r * 0.11);
161
+ c.strokeStyle = 'rgba(0, 0, 0, 0.55)';
162
+ c.stroke();
163
+ c.fillStyle = '#111111';
164
+ c.font = `700 ${Math.round(s.r * 1.2)}px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`;
165
+ c.textAlign = 'center';
166
+ c.textBaseline = 'middle';
167
+ c.fillText(String(s.n), s.x, s.y + s.r * 0.06);
168
+ c.textAlign = 'start';
169
+ c.textBaseline = 'alphabetic';
170
+ }
171
+ }
172
+
173
+ /** Translucent preview of the next badge, following the cursor. */
174
+ function drawGhostMarker(pt) {
175
+ const c = ctx2d;
176
+ c.save();
177
+ c.globalAlpha = 0.5;
178
+ drawShape({ type: 'marker', x: pt.x, y: pt.y, r: markerRadius(), n: markerShapes().length + 1 });
179
+ c.restore();
180
+ }
181
+
182
+ function redraw() {
183
+ if (!ctx2d || !state.img) return;
184
+ ctx2d.clearRect(0, 0, canvas.width, canvas.height);
185
+ ctx2d.drawImage(state.img, 0, 0, canvas.width, canvas.height);
186
+ for (const s of state.shapes) drawShape(s);
187
+ if (drawing) drawShape(drawing);
188
+ syncMarkerNotes();
189
+ // Ghost of the next marker under the cursor — never while dragging,
190
+ // editing a comment, or hovering an existing badge (that click edits)
191
+ if (state.tool === 'marker' && hoverPt && !drawing && !markerDrag && !_markerEditing) {
192
+ const overBadge = markerShapes().some(m =>
193
+ Math.hypot(hoverPt.x - m.x, hoverPt.y - m.y) <= m.r * 1.3);
194
+ if (!overBadge) drawGhostMarker(hoverPt);
195
+ }
196
+ }
197
+
198
+ // ═══════════════════════════════════════════════════════════════════════════
199
+ // POINTER INPUT
200
+ // ═══════════════════════════════════════════════════════════════════════════
201
+
202
+ function onPointerDown(e) {
203
+ if (e.button !== undefined && e.button !== 0) return;
204
+ disarmDiscard(); // drawing again cancels a pending discard confirm
205
+ commitTextInput();
206
+ commitMarkerInput();
207
+
208
+ const pt = canvasPoint(e);
209
+ if (state.tool === 'marker') {
210
+ // Same focus-steal cancel as the text tool — the comment input must
211
+ // survive the trusted click's default focus change
212
+ e.preventDefault();
213
+ const hit = [...markerShapes()].reverse().find(m =>
214
+ Math.hypot(pt.x - m.x, pt.y - m.y) <= m.r * 1.3);
215
+ if (hit) {
216
+ // Begin a potential drag; a click (no movement) opens the comment
217
+ // box on pointerup, a drag repositions the badge instead
218
+ markerDrag = { shape: hit, startX: pt.x, startY: pt.y, moved: false };
219
+ try { canvas.setPointerCapture(e.pointerId); } catch { /* synthetic */ }
220
+ return;
221
+ }
222
+ const marker = {
223
+ type: 'marker',
224
+ x: pt.x,
225
+ y: pt.y,
226
+ r: markerRadius(),
227
+ n: markerShapes().length + 1,
228
+ note: '',
229
+ };
230
+ state.shapes.push(marker);
231
+ state.redoStack = [];
232
+ hoverPt = null; // the badge now sits here; drop the ghost
233
+ redraw();
234
+ updateToolbar();
235
+ openMarkerInput(marker, true);
236
+ return;
237
+ }
238
+ if (state.tool === 'text') {
239
+ // Cancel the click's default focus change — it lands right after we
240
+ // focus the floating input and would blur-commit it empty (real
241
+ // clicks only; synthetic test events have no default actions)
242
+ e.preventDefault();
243
+ openTextInput(pt);
244
+ return;
245
+ }
246
+
247
+ e.preventDefault();
248
+ // Capture can throw if the pointer is already gone (or synthetic in tests)
249
+ try { canvas.setPointerCapture(e.pointerId); } catch { /* draw uncaptured */ }
250
+ const w = strokeWidth();
251
+ drawing = state.tool === 'pen'
252
+ ? { type: 'pen', points: [pt], color: state.color, w }
253
+ : { type: state.tool, x1: pt.x, y1: pt.y, x2: pt.x, y2: pt.y, color: state.color, w };
254
+ redraw();
255
+ }
256
+
257
+ function onPointerMove(e) {
258
+ if (markerDrag) {
259
+ e.preventDefault();
260
+ const pt = canvasPoint(e);
261
+ if (!markerDrag.moved
262
+ && Math.hypot(pt.x - markerDrag.startX, pt.y - markerDrag.startY) > 3) {
263
+ markerDrag.moved = true;
264
+ }
265
+ if (markerDrag.moved) {
266
+ markerDrag.shape.x = Math.max(0, Math.min(canvas.width, pt.x));
267
+ markerDrag.shape.y = Math.max(0, Math.min(canvas.height, pt.y));
268
+ redraw();
269
+ }
270
+ return;
271
+ }
272
+ // Marker ghost preview — track the cursor when nothing is in progress
273
+ if (state.tool === 'marker' && !drawing) {
274
+ hoverPt = canvasPoint(e);
275
+ redraw();
276
+ return;
277
+ }
278
+ if (!drawing) return;
279
+ e.preventDefault();
280
+ const pt = canvasPoint(e);
281
+ if (drawing.type === 'pen') {
282
+ const last = drawing.points[drawing.points.length - 1];
283
+ if (Math.hypot(pt.x - last.x, pt.y - last.y) > 1.5) drawing.points.push(pt);
284
+ } else {
285
+ drawing.x2 = pt.x;
286
+ drawing.y2 = pt.y;
287
+ }
288
+ redraw();
289
+ }
290
+
291
+ function onPointerLeave() {
292
+ if (hoverPt) { hoverPt = null; redraw(); }
293
+ }
294
+
295
+ function onPointerUp(e) {
296
+ if (markerDrag) {
297
+ const { shape, moved } = markerDrag;
298
+ markerDrag = null;
299
+ try { canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
300
+ if (moved) redraw(); // repositioned — note preserved
301
+ else openMarkerInput(shape, false); // a plain click edits the comment
302
+ return;
303
+ }
304
+ if (!drawing) return;
305
+ // Discard zero-size accidental taps for shape tools (pen keeps its dot)
306
+ const degenerate = drawing.type !== 'pen'
307
+ && Math.hypot(drawing.x2 - drawing.x1, drawing.y2 - drawing.y1) < 3;
308
+ if (!degenerate) {
309
+ state.shapes.push(drawing);
310
+ state.redoStack = [];
311
+ }
312
+ drawing = null;
313
+ redraw();
314
+ updateToolbar();
315
+ }
316
+
317
+ // ═══════════════════════════════════════════════════════════════════════════
318
+ // TEXT TOOL
319
+ // ═══════════════════════════════════════════════════════════════════════════
320
+
321
+ function currentTextInput() {
322
+ return document.querySelector('#widget-image-annotate .ia-text-input');
323
+ }
324
+
325
+ /** Commit the floating text input (if any) as a text shape. */
326
+ function commitTextInput() {
327
+ const input = currentTextInput();
328
+ // The closing guard breaks blur re-entrancy: input.remove() fires blur,
329
+ // whose handler calls commit again while the node is still attached —
330
+ // without the guard that double-commits the shape and the second
331
+ // remove() throws NotFoundError
332
+ if (!input || input.dataset.closing) return;
333
+ input.dataset.closing = '1';
334
+ const text = input.value.trim();
335
+ if (text) {
336
+ state.shapes.push({
337
+ type: 'text',
338
+ x: parseFloat(input.dataset.x),
339
+ y: parseFloat(input.dataset.y),
340
+ text,
341
+ color: input.dataset.color,
342
+ size: parseFloat(input.dataset.size),
343
+ });
344
+ state.redoStack = [];
345
+ }
346
+ input.remove();
347
+ redraw();
348
+ updateToolbar();
349
+ }
350
+
351
+ function cancelTextInput() {
352
+ const input = currentTextInput();
353
+ if (!input || input.dataset.closing) return;
354
+ input.dataset.closing = '1';
355
+ input.remove();
356
+ }
357
+
358
+ function openTextInput(pt) {
359
+ const wrap = canvas.closest('.ia-canvas-wrap');
360
+ const r = canvas.getBoundingClientRect();
361
+ const wr = wrap.getBoundingClientRect();
362
+ const scale = r.width / canvas.width;
363
+ const size = fontSize();
364
+
365
+ const input = document.createElement('input');
366
+ input.type = 'text';
367
+ input.className = 'ia-text-input';
368
+ // Claim Enter/Escape so the app's global Escape shortcut doesn't close
369
+ // the whole editor out from under the input (shortcuts.js honors this)
370
+ input.dataset.shortcutsDisabled = 'enter,escape';
371
+ input.placeholder = S.widgets.image_annotate.text_placeholder;
372
+ input.style.left = `${r.left - wr.left + pt.x * scale}px`;
373
+ input.style.top = `${r.top - wr.top + pt.y * scale}px`;
374
+ input.style.fontSize = `${Math.max(12, size * scale)}px`;
375
+ input.style.color = state.color;
376
+ input.dataset.x = pt.x;
377
+ input.dataset.y = pt.y;
378
+ input.dataset.color = state.color;
379
+ input.dataset.size = size;
380
+
381
+ input.addEventListener('keydown', (e) => {
382
+ e.stopPropagation();
383
+ if (e.key === 'Enter') commitTextInput();
384
+ else if (e.key === 'Escape') cancelTextInput();
385
+ });
386
+ input.addEventListener('blur', () => commitTextInput());
387
+
388
+ wrap.appendChild(input);
389
+ input.focus();
390
+ }
391
+
392
+ // ═══════════════════════════════════════════════════════════════════════════
393
+ // MARKER TOOL — numbered badges whose comments go to the stash, not the image
394
+ // ═══════════════════════════════════════════════════════════════════════════
395
+
396
+ let _markerEditing = null; // {shape, isNew} while the comment box is open
397
+ let _markerIdSeq = 0; // stable DOM keys for the comment bubbles
398
+
399
+ function currentMarkerInput() {
400
+ return document.querySelector('#widget-image-annotate .ia-marker-input');
401
+ }
402
+
403
+ /** Place a floating element beside a badge; flip left when it would overflow. */
404
+ function positionBesideMarker(el, shape, wrap) {
405
+ const r = canvas.getBoundingClientRect();
406
+ const wr = wrap.getBoundingClientRect();
407
+ const scale = r.width / canvas.width;
408
+ const bx = r.left - wr.left + shape.x * scale;
409
+ const by = r.top - wr.top + shape.y * scale;
410
+ const pad = shape.r * scale + 8;
411
+ if (bx + pad + 240 > wr.width) {
412
+ el.style.left = 'auto';
413
+ el.style.right = `${wr.width - bx + pad}px`;
414
+ } else {
415
+ el.style.right = 'auto';
416
+ el.style.left = `${bx + pad}px`;
417
+ }
418
+ el.style.top = `${by}px`;
419
+ el.style.transform = 'translateY(-50%)';
420
+ }
421
+
422
+ /**
423
+ * Keep a read-only comment bubble beside every badge that has a note, so
424
+ * committed comments stay visible and clickable (click re-opens the edit
425
+ * box). DOM overlay only — never rasterized into the exported PNG. Runs on
426
+ * every redraw; nodes are keyed by a per-shape id and updated in place.
427
+ */
428
+ function syncMarkerNotes() {
429
+ const wrap = canvas?.closest('.ia-canvas-wrap');
430
+ if (!wrap) return;
431
+ const live = new Set();
432
+ for (const m of markerShapes()) {
433
+ // No bubble while empty or while its edit box replaces it
434
+ if (!m.note || _markerEditing?.shape === m) continue;
435
+ if (!m.id) m.id = String(++_markerIdSeq);
436
+ live.add(m.id);
437
+ let el = wrap.querySelector(`.ia-marker-note[data-marker-id="${m.id}"]`);
438
+ if (!el) {
439
+ el = document.createElement('div');
440
+ el.className = 'ia-marker-note';
441
+ el.dataset.markerId = m.id;
442
+ el.setAttribute('data-tooltip', S.widgets.image_annotate.marker_note_edit);
443
+ // Keep focus where it is so a peer comment box's blur→commit
444
+ // runs before our click (same trick as the delete button)
445
+ el.addEventListener('pointerdown', (e) => e.preventDefault());
446
+ el.addEventListener('mousedown', (e) => e.preventDefault());
447
+ el.addEventListener('click', (e) => {
448
+ e.stopPropagation();
449
+ openMarkerInput(m, false);
450
+ });
451
+ wrap.appendChild(el);
452
+ }
453
+ if (el.textContent !== m.note) el.textContent = m.note;
454
+ positionBesideMarker(el, m, wrap);
455
+ }
456
+ wrap.querySelectorAll('.ia-marker-note').forEach(el => {
457
+ if (!live.has(el.dataset.markerId)) el.remove();
458
+ });
459
+ }
460
+
461
+ /** Remove the whole comment box (input + delete button), not just the input. */
462
+ function removeMarkerBox(input) {
463
+ (input.closest('.ia-marker-box') || input).remove();
464
+ }
465
+
466
+ /** Renumber remaining markers so badges always read 1, 2, 3… with no gaps. */
467
+ function renumberMarkers() {
468
+ markerShapes().forEach((m, i) => { m.n = i + 1; });
469
+ }
470
+
471
+ /** Save the open comment box (if any) onto its marker shape. */
472
+ function commitMarkerInput() {
473
+ const input = currentMarkerInput();
474
+ // Same blur re-entrancy guard as commitTextInput
475
+ if (!input || input.dataset.closing) return;
476
+ input.dataset.closing = '1';
477
+ if (_markerEditing) _markerEditing.shape.note = input.value.trim();
478
+ _markerEditing = null;
479
+ removeMarkerBox(input);
480
+ syncMarkerNotes(); // the committed comment reappears as a bubble
481
+ }
482
+
483
+ /** Close the comment box; a brand-new marker with no comment is removed. */
484
+ function cancelMarkerInput() {
485
+ const input = currentMarkerInput();
486
+ if (!input || input.dataset.closing) return;
487
+ input.dataset.closing = '1';
488
+ if (_markerEditing?.isNew && !_markerEditing.shape.note) {
489
+ const idx = state.shapes.indexOf(_markerEditing.shape);
490
+ if (idx !== -1) state.shapes.splice(idx, 1);
491
+ renumberMarkers();
492
+ redraw();
493
+ updateToolbar();
494
+ }
495
+ _markerEditing = null;
496
+ removeMarkerBox(input);
497
+ syncMarkerNotes(); // an untouched existing note gets its bubble back
498
+ }
499
+
500
+ /** Delete the marker whose comment box is open, then renumber + redraw. */
501
+ function deleteMarkerInput() {
502
+ const input = currentMarkerInput();
503
+ if (!input || input.dataset.closing) return;
504
+ input.dataset.closing = '1';
505
+ if (_markerEditing) {
506
+ const idx = state.shapes.indexOf(_markerEditing.shape);
507
+ if (idx !== -1) state.shapes.splice(idx, 1);
508
+ renumberMarkers();
509
+ redraw();
510
+ updateToolbar();
511
+ }
512
+ _markerEditing = null;
513
+ removeMarkerBox(input);
514
+ syncMarkerNotes();
515
+ }
516
+
517
+ function openMarkerInput(shape, isNew) {
518
+ commitMarkerInput();
519
+ _markerEditing = { shape, isNew };
520
+ syncMarkerNotes(); // hide this marker's bubble — the edit box replaces it
521
+
522
+ const wrap = canvas.closest('.ia-canvas-wrap');
523
+ const box = document.createElement('div');
524
+ box.className = 'ia-marker-box';
525
+
526
+ // Textarea, not <input> — longer descriptions are normal here since the
527
+ // comment travels as prompt text. Enter commits, Shift+Enter adds a line.
528
+ const input = document.createElement('textarea');
529
+ input.rows = 1;
530
+ input.className = 'ia-marker-input';
531
+ // Claim Enter/Escape from the app's global shortcut handler (see above)
532
+ input.dataset.shortcutsDisabled = 'enter,escape';
533
+ input.placeholder = S.widgets.image_annotate.marker_placeholder.replace('{n}', shape.n);
534
+ input.value = shape.note || '';
535
+ // Auto-grow with content up to the CSS max-height, then scroll
536
+ const autogrow = () => {
537
+ input.style.height = 'auto';
538
+ input.style.height = `${Math.min(input.scrollHeight, 132)}px`;
539
+ };
540
+ input.addEventListener('input', autogrow);
541
+
542
+ const del = document.createElement('button');
543
+ del.type = 'button';
544
+ del.className = 'ia-marker-delete';
545
+ del.innerHTML = TRASH_ICON;
546
+ del.setAttribute('data-tooltip', S.widgets.image_annotate.marker_delete);
547
+ del.setAttribute('aria-label', S.widgets.image_annotate.marker_delete);
548
+ // Keep focus on the input so its blur→commit doesn't fire before the click
549
+ del.addEventListener('pointerdown', (e) => e.preventDefault());
550
+ del.addEventListener('mousedown', (e) => e.preventDefault());
551
+ del.addEventListener('click', (e) => { e.stopPropagation(); deleteMarkerInput(); });
552
+
553
+ box.appendChild(input);
554
+ box.appendChild(del);
555
+
556
+ // Sit beside the badge; flip to the left when it would overflow
557
+ positionBesideMarker(box, shape, wrap);
558
+
559
+ input.addEventListener('keydown', (e) => {
560
+ e.stopPropagation();
561
+ if (e.key === 'Enter' && !e.shiftKey) {
562
+ e.preventDefault(); // don't insert a newline into the dying textarea
563
+ commitMarkerInput();
564
+ } else if (e.key === 'Escape') cancelMarkerInput();
565
+ });
566
+ input.addEventListener('blur', () => commitMarkerInput());
567
+
568
+ wrap.appendChild(box);
569
+ autogrow(); // size to any existing note (needs the DOM for scrollHeight)
570
+ input.focus();
571
+ // Caret at the end — re-editing usually means appending
572
+ input.setSelectionRange(input.value.length, input.value.length);
573
+ }
574
+
575
+ /** Markers that carry a comment, for the onDone callback. */
576
+ function collectMarkers() {
577
+ return markerShapes()
578
+ .filter(m => m.note)
579
+ .map(m => ({ n: m.n, note: m.note }));
580
+ }
581
+
582
+ // ═══════════════════════════════════════════════════════════════════════════
583
+ // UNDO / REDO
584
+ // ═══════════════════════════════════════════════════════════════════════════
585
+
586
+ function undoOp() {
587
+ if (!state.shapes.length) return;
588
+ state.redoStack.push(state.shapes.pop());
589
+ redraw();
590
+ updateToolbar();
591
+ }
592
+
593
+ function redoOp() {
594
+ if (!state.redoStack.length) return;
595
+ state.shapes.push(state.redoStack.pop());
596
+ redraw();
597
+ updateToolbar();
598
+ }
599
+
600
+ // ═══════════════════════════════════════════════════════════════════════════
601
+ // OUTCOMES
602
+ // ═══════════════════════════════════════════════════════════════════════════
603
+
604
+ /** Fire one of onSkip/onCancel exactly once and close. */
605
+ function settle(kind) {
606
+ if (state.settled) return;
607
+ state.settled = true;
608
+ const cbs = state.callbacks || {};
609
+ if (kind === 'skip') cbs.onSkip?.();
610
+ else cbs.onCancel?.();
611
+ WidgetManager.close('image-annotate');
612
+ }
613
+
614
+ /** True once the user has drawn/placed anything — closing would lose work. */
615
+ function isDirty() {
616
+ return state.shapes.length > 0;
617
+ }
618
+
619
+ /** Clear the armed-discard confirmation and restore the Cancel button label. */
620
+ function disarmDiscard() {
621
+ if (_discardTimer) { clearTimeout(_discardTimer); _discardTimer = null; }
622
+ if (!_discardArmed) return;
623
+ _discardArmed = false;
624
+ const btn = document.querySelector('#widget-image-annotate .ia-cancel');
625
+ if (btn) { btn.classList.remove('armed'); btn.textContent = S.widgets.image_annotate.cancel; }
626
+ }
627
+
628
+ /**
629
+ * Cancel intent from ESC or the Cancel button. Clean (no drawings) → discard
630
+ * immediately. Dirty → first press arms an inline "Discard changes?" confirm
631
+ * (window.confirm() silently no-ops in the iPad PWA — use the two-step
632
+ * armed-button pattern instead); a second press within the window discards.
633
+ */
634
+ function requestCancel() {
635
+ if (!isDirty()) { settle('cancel'); return; }
636
+ if (_discardArmed) { disarmDiscard(); settle('cancel'); return; }
637
+ _discardArmed = true;
638
+ const btn = document.querySelector('#widget-image-annotate .ia-cancel');
639
+ if (btn) { btn.classList.add('armed'); btn.textContent = S.widgets.image_annotate.confirm_discard; }
640
+ _discardTimer = setTimeout(disarmDiscard, 4000);
641
+ }
642
+
643
+ /** Export the annotated canvas as PNG, fire onDone, close. */
644
+ function finishDone() {
645
+ if (state.settled) return;
646
+ commitTextInput();
647
+ commitMarkerInput();
648
+ state.settled = true;
649
+ drawing = null;
650
+ markerDrag = null;
651
+ hoverPt = null; // never burn the ghost preview into the exported PNG
652
+ redraw();
653
+ const cbs = state.callbacks || {};
654
+ const markers = collectMarkers();
655
+ canvas.toBlob((blob) => {
656
+ if (blob) cbs.onDone?.(blob, markers);
657
+ else cbs.onSkip?.(); // export failed — fall back to the original
658
+ WidgetManager.close('image-annotate');
659
+ }, 'image/png');
660
+ }
661
+
662
+ // ═══════════════════════════════════════════════════════════════════════════
663
+ // KEYBOARD
664
+ // ═══════════════════════════════════════════════════════════════════════════
665
+
666
+ function attachKeyboard() {
667
+ detachKeyboard();
668
+ _keyHandler = (e) => {
669
+ if (!WidgetManager.isOpen('image-annotate')) return;
670
+ const inText = e.target.classList?.contains('ia-text-input');
671
+ const inMarker = e.target.classList?.contains('ia-marker-input');
672
+ if (e.key === 'Escape') {
673
+ e.preventDefault();
674
+ e.stopPropagation();
675
+ if (inText) cancelTextInput();
676
+ else if (inMarker) cancelMarkerInput();
677
+ else requestCancel();
678
+ return;
679
+ }
680
+ if (inText || inMarker) return;
681
+ const mod = e.metaKey || e.ctrlKey;
682
+ if (mod && e.key.toLowerCase() === 'z') {
683
+ e.preventDefault();
684
+ e.stopPropagation();
685
+ if (e.shiftKey) redoOp(); else undoOp();
686
+ } else if (e.key === 'Enter') {
687
+ // Plain Enter accepts (attach annotated) — Cmd/Ctrl+Enter too.
688
+ // The floating text/marker inputs claim Enter themselves and
689
+ // stopPropagation, so this only fires with no input focused.
690
+ e.preventDefault();
691
+ e.stopPropagation();
692
+ finishDone();
693
+ }
694
+ };
695
+ // Capture phase so app-level shortcuts don't fire while the editor is up
696
+ document.addEventListener('keydown', _keyHandler, true);
697
+ }
698
+
699
+ function detachKeyboard() {
700
+ if (_keyHandler) {
701
+ document.removeEventListener('keydown', _keyHandler, true);
702
+ _keyHandler = null;
703
+ }
704
+ }
705
+
706
+ // ═══════════════════════════════════════════════════════════════════════════
707
+ // TOOLBAR
708
+ // ═══════════════════════════════════════════════════════════════════════════
709
+
710
+ const TOOL_ICONS = {
711
+ marker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M10 9.5l2.5-2v9"/></svg>',
712
+ pen: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>',
713
+ arrow: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="19" x2="19" y2="5"/><polyline points="9 5 19 5 19 15"/></svg>',
714
+ rect: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="6" width="16" height="12" rx="1"/></svg>',
715
+ ellipse: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="8" ry="6"/></svg>',
716
+ text: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 7 5 4 19 4 19 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="9" y1="20" x2="15" y2="20"/></svg>',
717
+ };
718
+
719
+ // Not a tool — used inside the marker comment box's delete button
720
+ const TRASH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
721
+
722
+ const UNDO_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>';
723
+ const REDO_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 15-6.7L21 13"/></svg>';
724
+
725
+ function toolbarHtml() {
726
+ const T = S.widgets.image_annotate;
727
+ const toolTips = { marker: T.tool_marker, pen: T.tool_pen, arrow: T.tool_arrow, rect: T.tool_rect, ellipse: T.tool_ellipse, text: T.tool_text };
728
+ const tools = Object.keys(TOOL_ICONS).map(t =>
729
+ `<button class="ia-tool-btn" data-tool="${t}" data-tooltip="${toolTips[t]}">${TOOL_ICONS[t]}</button>`
730
+ ).join('');
731
+ const colors = COLORS.map(c =>
732
+ `<button class="ia-color-btn" data-color="${c}" style="--ia-swatch: ${c}" data-tooltip="${T.color}"></button>`
733
+ ).join('');
734
+ const strokeTips = { s: T.stroke_small, m: T.stroke_medium, l: T.stroke_large };
735
+ const strokes = ['s', 'm', 'l'].map(w =>
736
+ `<button class="ia-stroke-btn" data-stroke="${w}" data-tooltip="${strokeTips[w]}"><span class="ia-stroke-dot ia-stroke-${w}"></span></button>`
737
+ ).join('');
738
+
739
+ return `
740
+ <div class="ia-toolbar">
741
+ <div class="ia-tool-group">${tools}</div>
742
+ <span class="ia-sep"></span>
743
+ <div class="ia-tool-group">${colors}</div>
744
+ <span class="ia-sep"></span>
745
+ <div class="ia-tool-group">${strokes}</div>
746
+ <span class="ia-sep"></span>
747
+ <div class="ia-tool-group">
748
+ <button class="ia-tool-btn ia-undo" data-tooltip="${withChords(T.undo)}">${UNDO_ICON}</button>
749
+ <button class="ia-tool-btn ia-redo" data-tooltip="${withChords(T.redo)}">${REDO_ICON}</button>
750
+ </div>
751
+ <span class="ia-spacer"></span>
752
+ <label class="ia-paste-toggle" data-tooltip="${T.paste_toggle_tip}">
753
+ <input type="checkbox" class="ia-paste-cb"${isAnnotateOnPasteEnabled() ? ' checked' : ''}>
754
+ <span>${T.paste_toggle}</span>
755
+ </label>
756
+ <div class="ia-tool-group ia-actions">
757
+ <button class="ia-btn ia-cancel">${T.cancel}</button>
758
+ <button class="ia-btn ia-skip">${T.skip}</button>
759
+ <button class="ia-btn ia-btn-primary ia-done">${T.done}</button>
760
+ </div>
761
+ </div>
762
+ `;
763
+ }
764
+
765
+ function updateToolbar() {
766
+ const root = document.querySelector('#widget-image-annotate .image-annotate-modal');
767
+ if (!root) return;
768
+ root.querySelectorAll('.ia-tool-btn[data-tool]').forEach(b =>
769
+ b.classList.toggle('active', b.dataset.tool === state.tool));
770
+ root.querySelectorAll('.ia-color-btn').forEach(b =>
771
+ b.classList.toggle('active', b.dataset.color === state.color));
772
+ root.querySelectorAll('.ia-stroke-btn').forEach(b =>
773
+ b.classList.toggle('active', b.dataset.stroke === state.stroke));
774
+ const undoBtn = root.querySelector('.ia-undo');
775
+ const redoBtn = root.querySelector('.ia-redo');
776
+ if (undoBtn) undoBtn.disabled = state.shapes.length === 0;
777
+ if (redoBtn) redoBtn.disabled = state.redoStack.length === 0;
778
+ }
779
+
780
+ // ═══════════════════════════════════════════════════════════════════════════
781
+ // WIDGET REGISTRATION
782
+ // ═══════════════════════════════════════════════════════════════════════════
783
+
784
+ export function registerImageAnnotateWidget() {
785
+ WidgetManager.register('image-annotate', {
786
+ title: S.widgets.titles.image_annotate,
787
+ icon: 'image',
788
+ type: 'modal',
789
+ hideHeader: true,
790
+ closeOnBackdrop: false, // a stray click must not discard drawings
791
+ closeOnEscape: false, // own Escape handling (text input vs cancel)
792
+ maxWidth: '96vw',
793
+ maxHeight: '96vh',
794
+ scope: 'global',
795
+ sessionAware: false,
796
+ hiddenInPicker: true,
797
+
798
+ onClose() {
799
+ // Closed without an explicit outcome (safety net) → cancel
800
+ if (!state.settled) {
801
+ state.settled = true;
802
+ state.callbacks?.onCancel?.();
803
+ }
804
+ cancelTextInput();
805
+ _markerEditing = null;
806
+ const mi = currentMarkerInput();
807
+ if (mi) removeMarkerBox(mi);
808
+ if (_discardTimer) { clearTimeout(_discardTimer); _discardTimer = null; }
809
+ _discardArmed = false;
810
+ detachKeyboard();
811
+ drawing = null;
812
+ markerDrag = null;
813
+ hoverPt = null;
814
+ if (state.objectUrl) {
815
+ URL.revokeObjectURL(state.objectUrl);
816
+ state.objectUrl = null;
817
+ }
818
+ // Return focus to the chat input so typing can resume immediately.
819
+ window.app?.focusInput?.();
820
+ },
821
+
822
+ render(container) {
823
+ if (!state.img) return;
824
+
825
+ container.innerHTML = `
826
+ <div class="image-annotate-modal">
827
+ ${toolbarHtml()}
828
+ <div class="ia-canvas-wrap">
829
+ <canvas class="ia-canvas"></canvas>
830
+ </div>
831
+ </div>
832
+ `;
833
+
834
+ canvas = container.querySelector('.ia-canvas');
835
+ const scale = Math.min(1, MAX_CANVAS_DIM / Math.max(state.img.naturalWidth, state.img.naturalHeight));
836
+ canvas.width = Math.max(1, Math.round(state.img.naturalWidth * scale));
837
+ canvas.height = Math.max(1, Math.round(state.img.naturalHeight * scale));
838
+ ctx2d = canvas.getContext('2d');
839
+
840
+ canvas.addEventListener('pointerdown', onPointerDown);
841
+ canvas.addEventListener('pointermove', onPointerMove);
842
+ canvas.addEventListener('pointerup', onPointerUp);
843
+ canvas.addEventListener('pointercancel', onPointerUp);
844
+ canvas.addEventListener('pointerleave', onPointerLeave);
845
+ // Canceling pointerdown should suppress mousedown's focus-steal per
846
+ // spec, but not every browser links them — block it directly too so
847
+ // the text tool's floating input keeps focus
848
+ canvas.addEventListener('mousedown', (e) => e.preventDefault());
849
+
850
+ const root = container.querySelector('.image-annotate-modal');
851
+ root.querySelector('.ia-toolbar').addEventListener('click', (e) => {
852
+ const btn = e.target.closest('button');
853
+ if (!btn) return;
854
+ // Any action other than confirming the discard resumes editing
855
+ if (!btn.classList.contains('ia-cancel')) disarmDiscard();
856
+ if (btn.dataset.tool) { state.tool = btn.dataset.tool; hoverPt = null; redraw(); }
857
+ else if (btn.dataset.color) state.color = btn.dataset.color;
858
+ else if (btn.dataset.stroke) state.stroke = btn.dataset.stroke;
859
+ else if (btn.classList.contains('ia-undo')) return undoOp();
860
+ else if (btn.classList.contains('ia-redo')) return redoOp();
861
+ else if (btn.classList.contains('ia-cancel')) return requestCancel();
862
+ else if (btn.classList.contains('ia-skip')) return settle('skip');
863
+ else if (btn.classList.contains('ia-done')) return finishDone();
864
+ updateToolbar();
865
+ });
866
+
867
+ // Persist the "open editor on paste" setting straight from the toolbar
868
+ root.querySelector('.ia-paste-cb')?.addEventListener('change', (e) => {
869
+ configState.setAnnotateOnPaste(e.target.checked);
870
+ });
871
+
872
+ redraw();
873
+ updateToolbar();
874
+ }
875
+ });
876
+ }
877
+
878
+ // ═══════════════════════════════════════════════════════════════════════════
879
+ // PUBLIC API
880
+ // ═══════════════════════════════════════════════════════════════════════════
881
+
882
+ /**
883
+ * Open the annotation editor for an image blob/file.
884
+ *
885
+ * @param {Blob|File} blob - The image to annotate
886
+ * @param {Object} callbacks
887
+ * @param {Function} callbacks.onDone - (pngBlob, markers) → user finished
888
+ * annotating; markers is [{n, note}] for badges with a comment, meant
889
+ * for the Comments Stash
890
+ * @param {Function} callbacks.onSkip - () → attach the original untouched
891
+ * @param {Function} callbacks.onCancel - () → discard entirely
892
+ */
893
+ export async function openImageAnnotator(blob, callbacks = {}) {
894
+ const url = URL.createObjectURL(blob);
895
+ const img = new Image();
896
+ try {
897
+ await new Promise((resolve, reject) => {
898
+ img.onload = resolve;
899
+ img.onerror = reject;
900
+ img.src = url;
901
+ });
902
+ } catch {
903
+ // Undecodable image — let the normal upload path deal with it
904
+ URL.revokeObjectURL(url);
905
+ callbacks.onSkip?.();
906
+ return;
907
+ }
908
+
909
+ state.img = img;
910
+ state.objectUrl = url;
911
+ state.fileName = blob.name || null;
912
+ state.shapes = [];
913
+ state.redoStack = [];
914
+ state.callbacks = callbacks;
915
+ state.settled = false;
916
+ _markerEditing = null;
917
+ markerDrag = null;
918
+ hoverPt = null;
919
+ _discardArmed = false;
920
+ if (_discardTimer) { clearTimeout(_discardTimer); _discardTimer = null; }
921
+
922
+ WidgetManager.open('image-annotate');
923
+ // render() only runs on first creation — force a rebuild for this image
924
+ WidgetManager.update('image-annotate');
925
+ attachKeyboard();
926
+ }
927
+
928
+ export function isImageAnnotatorOpen() {
929
+ return WidgetManager.isOpen('image-annotate');
930
+ }
931
+
932
+ /**
933
+ * Handle ESC while the annotator is open. The app's global shortcut handler
934
+ * claims Escape (stopImmediatePropagation) before this widget's own capture
935
+ * listener can run, so app.handleEscape() routes here to keep the
936
+ * dirty-aware discard confirm working. Cancels an open text/marker input
937
+ * first; otherwise runs the armed-cancel. Returns true if it handled the key.
938
+ */
939
+ export function handleAnnotatorEscape() {
940
+ if (!isImageAnnotatorOpen()) return false;
941
+ const el = document.activeElement;
942
+ if (el?.classList?.contains('ia-text-input')) { cancelTextInput(); return true; }
943
+ if (el?.classList?.contains('ia-marker-input')) { cancelMarkerInput(); return true; }
944
+ requestCancel();
945
+ return true;
946
+ }
947
+
948
+ export const ImageAnnotateWidget = {
949
+ open: openImageAnnotator,
950
+ isOpen: isImageAnnotatorOpen,
951
+ };