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,1472 @@
1
+ /**
2
+ * Input Handler Module
3
+ * Manages message input, history navigation, drafts, and autocomplete integration
4
+ */
5
+
6
+ import { Storage, escapeHtml, escapeAttr } from './utils.js';
7
+ import { CONFIG } from './config.js';
8
+ import { Stash } from './stash.js';
9
+ import { ShortcutHints } from './shortcut-hints.js';
10
+ import { DraftsPill } from './drafts-pill.js';
11
+ import { showToast } from './context-menu.js';
12
+ import { renderStashRefs } from './stash-refs-view.js';
13
+ import S from './strings.js';
14
+
15
+ // Auto-sync of in-progress input to a server-side draft (/api/drafts):
16
+ // idle time before the input is mirrored, and the minimum length before a
17
+ // draft is created at all (below it, throwaway fragments like "ok" would
18
+ // pollute the Drafts list; the localStorage auto-draft still covers them)
19
+ const AUTO_DRAFT_SYNC_MS = 2500;
20
+ const AUTO_DRAFT_MIN_CHARS = 12;
21
+
22
+ // Cap on session.promptHistory (oldest entries fall off the end)
23
+ const PROMPT_HISTORY_LIMIT = 50;
24
+
25
+ // A prompt-history entry is `{ text, stashRefs }` — the typed text plus the
26
+ // compact stash references that rode along with it, so recall can tell the
27
+ // user what was actually sent. Entries with empty text are legitimate: a
28
+ // stash-only send is a real prompt with no words in it. Plain strings are
29
+ // still accepted on read (older in-memory sessions, forks made before the
30
+ // object shape landed) so navigation never breaks on a legacy array.
31
+ export function historyEntryText(entry) {
32
+ return typeof entry === 'string' ? entry : (entry?.text ?? '');
33
+ }
34
+
35
+ export function historyEntryRefs(entry) {
36
+ const refs = typeof entry === 'string' ? null : entry?.stashRefs;
37
+ return Array.isArray(refs) && refs.length > 0 ? refs : null;
38
+ }
39
+
40
+ /**
41
+ * Push a prompt into a SPECIFIC session's arrow-up recall history.
42
+ *
43
+ * This is the single write path for `session.promptHistory` — the
44
+ * InputHandler's own addToHistory delegates here with the active session,
45
+ * and the `message_stored` broadcast handler calls it directly so prompts
46
+ * sent from ANOTHER tab/device become recallable in this one without a
47
+ * page reload (the send-path write only ever runs on the originating
48
+ * client).
49
+ *
50
+ * @param {Object} session - the Session whose history to write
51
+ * @param {string} content - the text the user typed (may be empty)
52
+ * @param {Array|null} stashRefs - compact stash references sent with it
53
+ */
54
+ export function pushHistoryEntry(session, content, stashRefs = null) {
55
+ if (!session) return;
56
+
57
+ const text = content || '';
58
+ const refs = Array.isArray(stashRefs) && stashRefs.length > 0 ? stashRefs : null;
59
+ // Neither words nor references — nothing was sent, nothing to recall
60
+ if (!text.trim() && !refs) return;
61
+
62
+ // Dedup on the typed text, so re-sending the same prompt moves it to
63
+ // the front instead of stacking (and so the enriched write from the
64
+ // send path replaces the bare one written on keypress). Empty-text
65
+ // entries are exempt: two stash-only sends carry different references
66
+ // and are genuinely different prompts — collapsing them would hide one.
67
+ if (text.trim()) {
68
+ session.promptHistory = session.promptHistory.filter(
69
+ h => historyEntryText(h) !== text
70
+ );
71
+ }
72
+ session.promptHistory.unshift({ text, stashRefs: refs });
73
+ if (session.promptHistory.length > PROMPT_HISTORY_LIMIT) {
74
+ session.promptHistory.length = PROMPT_HISTORY_LIMIT;
75
+ }
76
+ }
77
+
78
+ // Tab-cycle: order matches the input toolbar (#, /, @, $).
79
+ // Tab in the input rotates through these trigger pickers without pre-selecting
80
+ // any entry, so the user can browse what each picker offers and arrow-down to commit.
81
+ const TAB_CYCLE_TRIGGERS = [
82
+ { char: '#', popup: 'snippets', btnId: 'snippets-btn' },
83
+ { char: '/', popup: 'slash', btnId: 'slash-cmd-btn' },
84
+ { char: '@', popup: 'file', btnId: 'file-mention-btn' },
85
+ { char: '$', popup: 'skills', btnId: 'skills-btn' },
86
+ ];
87
+
88
+ /**
89
+ * InputHandler - Manage message input with history and drafts
90
+ */
91
+ export class InputHandler {
92
+ /**
93
+ * @param {Object} elements - DOM elements
94
+ * @param {HTMLTextAreaElement} elements.messageInput - The message input
95
+ * @param {HTMLButtonElement} elements.sendBtn - Send button
96
+ * @param {HTMLButtonElement} elements.followupBtn - Follow-up button (visible while working; mirrors send disabled state)
97
+ * @param {Object} options - Configuration
98
+ * @param {boolean} options.hasPhysicalKeyboard - Whether device has keyboard
99
+ * @param {Object} callbacks - Callbacks
100
+ * @param {Function} callbacks.getSession - Returns active session
101
+ * @param {Function} callbacks.getAutocomplete - Returns autocomplete instance
102
+ * @param {Function} callbacks.getFileAutocomplete - Returns file autocomplete instance
103
+ * @param {Function} callbacks.getSnippetsAutocomplete - Returns snippets autocomplete instance
104
+ * @param {Function} callbacks.getSkillsAutocomplete - Returns skills autocomplete instance ($ trigger)
105
+ * @param {Function} callbacks.getPendingImages - Returns pending images array
106
+ * @param {Function} callbacks.isUploading - True while attachment uploads are in flight
107
+ * @param {Function} callbacks.hasPendingUploads - True if any upload (image or file) completed and is attached
108
+ * @param {Function} callbacks.onSendMessage - Called to send a message
109
+ * @param {Function} callbacks.onSlashCommand - Called for slash commands
110
+ * @param {Function} callbacks.onBangCommand - Called for bang commands
111
+ * @param {Function} callbacks.onConnect - Called to connect session
112
+ * @param {Function} callbacks.onAutoResize - Called to resize input
113
+ * @param {Function} callbacks.onUpdateSendButton - Called to update send button state
114
+ * @param {Function} callbacks.onPreviewFile - Called to preview a file (Cmd+Enter)
115
+ */
116
+ constructor(elements, options = {}, callbacks = {}) {
117
+ this.els = elements;
118
+ this.hasPhysicalKeyboard = options.hasPhysicalKeyboard ?? true;
119
+
120
+ // Callbacks
121
+ this.getSession = callbacks.getSession || (() => null);
122
+ this.getAutocomplete = callbacks.getAutocomplete || (() => null);
123
+ this.getFileAutocomplete = callbacks.getFileAutocomplete || (() => null);
124
+ this.getSnippetsAutocomplete = callbacks.getSnippetsAutocomplete || (() => null);
125
+ this.getSkillsAutocomplete = callbacks.getSkillsAutocomplete || (() => null);
126
+ this.getCwd = callbacks.getCwd || (() => window.app?.activeSession?.cwd || null);
127
+ this.getPendingImages = callbacks.getPendingImages || (() => []);
128
+ this.isUploading = callbacks.isUploading || (() => false);
129
+ this.hasPendingUploads = callbacks.hasPendingUploads || (() => this.getPendingImages().length > 0);
130
+ this.onSendMessage = callbacks.onSendMessage || (() => {});
131
+ this.onSlashCommand = callbacks.onSlashCommand || (() => {});
132
+ this.onBangCommand = callbacks.onBangCommand || (() => {});
133
+ this.onPlanComposeChange = callbacks.onPlanComposeChange || (() => {});
134
+ this.onConnect = callbacks.onConnect || (() => {});
135
+ this.onAutoResize = callbacks.onAutoResize || (() => {});
136
+ this.onUpdateSendButton = callbacks.onUpdateSendButton || (() => {});
137
+ this.onPreviewFile = callbacks.onPreviewFile || (() => {});
138
+ this.onSendInNewSession = callbacks.onSendInNewSession || (() => {});
139
+ this.isWelcomeMode = callbacks.isWelcomeMode || (() => false);
140
+
141
+ // State
142
+ this.historyIndex = -1;
143
+ this.historyDraft = '';
144
+
145
+ // Input mode: null, 'shell' (! bang commands), or 'plan' (/plan prompt)
146
+ this.inputMode = null;
147
+
148
+ // Send pressed while attachment uploads were still in flight. Holds the
149
+ // original handleInput options until the uploads settle, then replays
150
+ // the send so the message goes out WITH its attachments rather than as
151
+ // text-only. Cleared by flushDeferredSend() or cancelDeferredSend().
152
+ this._deferredSend = null;
153
+
154
+ // Id of the server-side draft mirroring the current input — set by
155
+ // the auto-sync once it creates one, or by Prompt Explorer → Drafts
156
+ // → Use. Sending consumes (deletes) it; clearing the input abandons
157
+ // it (draft survives, unlinked).
158
+ this.pendingDraftId = null;
159
+
160
+ // Auto-sync state (see _syncDraftToServer): debounce timer,
161
+ // single-flight promise, re-run flag, and a consume epoch that
162
+ // invalidates in-flight creates when the input gets sent meanwhile
163
+ this._draftSyncTimer = null;
164
+ this._draftSyncInflight = null;
165
+ this._draftSyncQueued = false;
166
+ this._draftConsumeEpoch = 0;
167
+
168
+ // Debounce timers for autocompletes
169
+ this._fileDebounceTimer = null;
170
+ this._snippetsDebounceTimer = null;
171
+ this._skillsDebounceTimer = null;
172
+
173
+ // Tab-cycle state: { trigger, insertStart, insertEnd } or null
174
+ this._tabCycle = null;
175
+
176
+ // Bind event listeners
177
+ this._bindEvents();
178
+ }
179
+
180
+ /**
181
+ * Bind input event listeners
182
+ */
183
+ _bindEvents() {
184
+ this.els.messageInput.addEventListener('input', (e) => this._onInputChange(e));
185
+ this.els.messageInput.addEventListener('keydown', (e) => this._onInputKeydown(e));
186
+ // Seed hint/pill visibility on init (empty textarea → show hints)
187
+ ShortcutHints.updateVisibility(this.els.messageInput.value);
188
+ DraftsPill.updateVisibility(this.els.messageInput.value);
189
+ }
190
+
191
+ /**
192
+ * Handle input changes
193
+ */
194
+ _onInputChange(e) {
195
+ let value = e.target.value;
196
+ const cursorPos = this.els.messageInput.selectionStart;
197
+
198
+ // Sync hint overlay + drafts pill visibility (hidden with any content)
199
+ ShortcutHints.updateVisibility(value);
200
+ DraftsPill.updateVisibility(value);
201
+
202
+ // Emptying the box while a send is parked on an upload backs that send
203
+ // out — otherwise the upload landing later fires a turn the user just
204
+ // deleted. Nothing is lost: the attachment stays pending, so the next
205
+ // send still picks it up.
206
+ if (this._deferredSend && !value.trim()) this.cancelDeferredSend();
207
+
208
+ // Input mode detection: shell (!) and plan (/plan )
209
+ const wasMode = this.inputMode;
210
+ if (value.startsWith('!')) {
211
+ this.inputMode = 'shell';
212
+ value = value.slice(1);
213
+ this.els.messageInput.value = value;
214
+ this.els.messageInput.selectionStart = Math.max(0, cursorPos - 1);
215
+ this.els.messageInput.selectionEnd = Math.max(0, cursorPos - 1);
216
+ } else if (!this.inputMode && value.startsWith('/plan ')) {
217
+ this.inputMode = 'plan';
218
+ value = value.slice(6); // strip '/plan '
219
+ this.els.messageInput.value = value;
220
+ const newCursor = Math.max(0, cursorPos - 6);
221
+ this.els.messageInput.selectionStart = newCursor;
222
+ this.els.messageInput.selectionEnd = newCursor;
223
+ }
224
+
225
+ // Mode exit is intentionally Backspace-on-empty-only (handled in
226
+ // _onInputKeydown). Don't auto-exit just because the value happens to
227
+ // be empty here — Cmd+A→Backspace would clear the value via the input
228
+ // event, and auto-exiting would yank the user out of plan/shell mode
229
+ // after a single edit. The user must press Backspace on already-empty
230
+ // input to exit deliberately.
231
+
232
+ // Notify on entering/leaving the /plan compose box so the permission UI
233
+ // can flip to plan (and back). The subprocess restart is deferred to send.
234
+ this._notifyPlanComposeChange(wasMode);
235
+
236
+ // Apply mode classes and placeholder
237
+ this._applyInputMode();
238
+
239
+ // Exit history mode on any text change (user is editing)
240
+ if (this.historyIndex !== -1) {
241
+ this.historyIndex = -1;
242
+ this.historyDraft = '';
243
+ this._renderHistoryRefsHint(null);
244
+ }
245
+
246
+ // Enable/disable send button (considers images too)
247
+ this.onUpdateSendButton();
248
+
249
+ // Save draft to localStorage (survives page refresh)
250
+ // Don't save bare prefix — mode with no content is not a useful draft
251
+ const draft = this._encodeDraftText();
252
+ this.saveDraft(draft);
253
+
254
+ // Mirror the input to a server-side draft (debounced upsert) so
255
+ // typed-but-unsent prompts survive tab loss and are browsable in
256
+ // Prompt Explorer → Drafts from any device
257
+ if (draft.trim()) this._scheduleDraftSync();
258
+ else this._cancelDraftSyncTimer();
259
+
260
+ // Emptying the input abandons the linked draft — it survives
261
+ // server-side (browsable in Drafts), but a later, unrelated send
262
+ // must not consume (delete) it. clearDraft() above already dropped
263
+ // the persisted link.
264
+ if (this.pendingDraftId && !value.trim()) {
265
+ this.pendingDraftId = null;
266
+ }
267
+
268
+ // Autocomplete for slash and bang commands
269
+ // Slash only triggers at the very start of input (no mid-text).
270
+ // Use the `$` picker for inserting skills mid-prompt.
271
+ const autocomplete = this.getAutocomplete();
272
+ if (autocomplete) {
273
+ if (value.startsWith('/') && !this.inputMode) {
274
+ autocomplete.show('slash', value.slice(1), 0);
275
+ } else if (this.inputMode === 'shell') {
276
+ autocomplete.show('bang', value, 0);
277
+ } else {
278
+ autocomplete.hide();
279
+ }
280
+ }
281
+
282
+ // File autocomplete on @
283
+ this._handleFileAutocomplete(value, cursorPos);
284
+
285
+ // Favorites autocomplete on #
286
+ this._handleSnippetsAutocomplete(value, cursorPos);
287
+
288
+ // Skills autocomplete on $
289
+ this._handleSkillsAutocomplete(value, cursorPos);
290
+ }
291
+
292
+ /**
293
+ * Handle $ skills autocomplete trigger
294
+ */
295
+ _handleSkillsAutocomplete(value, cursorPos) {
296
+ const skillsAutocomplete = this.getSkillsAutocomplete();
297
+ if (!skillsAutocomplete) return;
298
+
299
+ const beforeCursor = value.slice(0, cursorPos);
300
+ const triggerMatch = beforeCursor.match(/(?:^|[\s])\$([^\s$]*)$/);
301
+
302
+ if (triggerMatch) {
303
+ const query = triggerMatch[1];
304
+ const triggerIndex = cursorPos - query.length - 1;
305
+ clearTimeout(this._skillsDebounceTimer);
306
+ this._skillsDebounceTimer = setTimeout(() => {
307
+ skillsAutocomplete.show(query, triggerIndex, this.getCwd());
308
+ }, query ? 50 : 0);
309
+ } else {
310
+ skillsAutocomplete.hide();
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Handle @ file autocomplete trigger
316
+ */
317
+ _handleFileAutocomplete(value, cursorPos) {
318
+ const fileAutocomplete = this.getFileAutocomplete();
319
+ if (!fileAutocomplete) return;
320
+
321
+ // Look for @ before cursor
322
+ const beforeCursor = value.slice(0, cursorPos);
323
+
324
+ // Find the last @ that could be a trigger
325
+ // Valid trigger: @ at start, or @ after whitespace
326
+ // Invalid: email-like patterns (user@example)
327
+ const atMatch = beforeCursor.match(/(?:^|[\s])@([^\s@]*)$/);
328
+
329
+ if (atMatch) {
330
+ const query = atMatch[1];
331
+ const atIndex = beforeCursor.lastIndexOf('@');
332
+
333
+ // Don't trigger if it looks like an email (has text before @)
334
+ if (atIndex > 0) {
335
+ const charBefore = beforeCursor[atIndex - 1];
336
+ if (charBefore && !/\s/.test(charBefore)) {
337
+ // Check if this looks like an email
338
+ const wordBefore = beforeCursor.slice(0, atIndex).match(/\S+$/);
339
+ if (wordBefore && /^[a-zA-Z0-9._-]+$/.test(wordBefore[0])) {
340
+ fileAutocomplete.hide();
341
+ return;
342
+ }
343
+ }
344
+ }
345
+
346
+ // Debounce for performance
347
+ clearTimeout(this._fileDebounceTimer);
348
+ this._fileDebounceTimer = setTimeout(() => {
349
+ fileAutocomplete.show(query, atIndex);
350
+ }, query ? 100 : 0); // Immediate for empty query, debounced for typing
351
+ } else {
352
+ fileAutocomplete.hide();
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Handle # snippets autocomplete trigger
358
+ */
359
+ _handleSnippetsAutocomplete(value, cursorPos) {
360
+ const snippetsAutocomplete = this.getSnippetsAutocomplete();
361
+ if (!snippetsAutocomplete) return;
362
+
363
+ const beforeCursor = value.slice(0, cursorPos);
364
+ const hashMatch = beforeCursor.match(/(?:^|[\s])#([^\s#]*)$/);
365
+
366
+ if (hashMatch) {
367
+ const query = hashMatch[1];
368
+ const hashIndex = beforeCursor.lastIndexOf('#');
369
+
370
+ if (hashIndex === 0 || (hashIndex > 0 && /\s/.test(beforeCursor[hashIndex - 1]))) {
371
+ clearTimeout(this._snippetsDebounceTimer);
372
+ this._snippetsDebounceTimer = setTimeout(() => {
373
+ snippetsAutocomplete.show(query, hashIndex);
374
+ }, query ? 50 : 0);
375
+ } else {
376
+ snippetsAutocomplete.hide();
377
+ }
378
+ } else {
379
+ snippetsAutocomplete.hide();
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Handle keydown events
385
+ */
386
+ _onInputKeydown(e) {
387
+ const autocomplete = this.getAutocomplete();
388
+ const fileAutocomplete = this.getFileAutocomplete();
389
+ const snippetsAutocomplete = this.getSnippetsAutocomplete();
390
+ const skillsAutocomplete = this.getSkillsAutocomplete();
391
+
392
+ // Tab-cycle lifecycle: Esc cancels (removes inserted char + hides popup);
393
+ // any non-navigation key exits the cycle visually but leaves the inserted
394
+ // char in place so the user can keep using it as a real trigger.
395
+ if (this._tabCycle) {
396
+ if (e.key === 'Escape') {
397
+ e.preventDefault();
398
+ this._cancelTabCycle();
399
+ return;
400
+ }
401
+ const cycleNavKeys = ['Tab', 'Shift', 'ArrowUp', 'ArrowDown'];
402
+ if (!cycleNavKeys.includes(e.key)) {
403
+ this._exitTabCycle();
404
+ // fall through — let the key be processed normally
405
+ }
406
+ }
407
+
408
+ // File autocomplete navigation (takes priority)
409
+ if (fileAutocomplete?.visible) {
410
+ if (e.key === 'ArrowUp') {
411
+ e.preventDefault();
412
+ fileAutocomplete.moveSelection(-1);
413
+ return;
414
+ }
415
+ if (e.key === 'ArrowDown') {
416
+ e.preventDefault();
417
+ fileAutocomplete.moveSelection(1);
418
+ return;
419
+ }
420
+ if (e.key === 'Tab' && fileAutocomplete.hasSelection()) {
421
+ e.preventDefault();
422
+ fileAutocomplete.select();
423
+ if (this._tabCycle) this._exitTabCycle();
424
+ return;
425
+ }
426
+ // Alt+Enter or Cmd/Ctrl+Enter: Preview file instead of inserting
427
+ if (e.key === 'Enter' && (e.altKey || e.metaKey || e.ctrlKey) && fileAutocomplete.hasSelection()) {
428
+ e.preventDefault();
429
+ const path = fileAutocomplete.getSelectedPath();
430
+ if (path) {
431
+ fileAutocomplete.hide();
432
+ this.onPreviewFile(path);
433
+ }
434
+ return;
435
+ }
436
+ // Regular Enter: Insert file path
437
+ if (e.key === 'Enter' && fileAutocomplete.hasSelection()) {
438
+ e.preventDefault();
439
+ fileAutocomplete.select();
440
+ return;
441
+ }
442
+ if (e.key === 'Escape') {
443
+ e.preventDefault();
444
+ fileAutocomplete.hide();
445
+ return;
446
+ }
447
+ }
448
+
449
+ // Skills autocomplete navigation ($ trigger)
450
+ if (skillsAutocomplete?.visible) {
451
+ if (e.key === 'ArrowUp') {
452
+ e.preventDefault();
453
+ skillsAutocomplete.moveSelection(-1);
454
+ return;
455
+ }
456
+ if (e.key === 'ArrowDown') {
457
+ e.preventDefault();
458
+ skillsAutocomplete.moveSelection(1);
459
+ return;
460
+ }
461
+ if (e.key === 'Tab' || e.key === 'Enter') {
462
+ if (skillsAutocomplete.hasSelection()) {
463
+ e.preventDefault();
464
+ skillsAutocomplete.select();
465
+ if (this._tabCycle) this._exitTabCycle();
466
+ return;
467
+ }
468
+ }
469
+ if (e.key === 'Escape') {
470
+ e.preventDefault();
471
+ skillsAutocomplete.hide();
472
+ return;
473
+ }
474
+ }
475
+
476
+ // Snippets autocomplete navigation
477
+ if (snippetsAutocomplete?.visible) {
478
+ if (e.key === 'ArrowUp') {
479
+ e.preventDefault();
480
+ snippetsAutocomplete.moveSelection(-1);
481
+ return;
482
+ }
483
+ if (e.key === 'ArrowDown') {
484
+ e.preventDefault();
485
+ snippetsAutocomplete.moveSelection(1);
486
+ return;
487
+ }
488
+ // Ctrl+Enter or Cmd+Enter: Select and send immediately
489
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
490
+ if (snippetsAutocomplete.hasSelection()) {
491
+ e.preventDefault();
492
+ snippetsAutocomplete.selectAndSend();
493
+ return;
494
+ }
495
+ }
496
+ // Alt+Enter: Insert just the name (short form)
497
+ if (e.key === 'Enter' && e.altKey) {
498
+ if (snippetsAutocomplete.hasSelection()) {
499
+ e.preventDefault();
500
+ snippetsAutocomplete.select(undefined, { short: true });
501
+ return;
502
+ }
503
+ }
504
+ if (e.key === 'Tab' || e.key === 'Enter') {
505
+ if (snippetsAutocomplete.hasSelection()) {
506
+ e.preventDefault();
507
+ snippetsAutocomplete.select();
508
+ if (this._tabCycle) this._exitTabCycle();
509
+ return;
510
+ }
511
+ }
512
+ if (e.key === 'Escape') {
513
+ e.preventDefault();
514
+ snippetsAutocomplete.hide();
515
+ return;
516
+ }
517
+ }
518
+
519
+ // Slash/bang command autocomplete navigation
520
+ if (autocomplete?.visible) {
521
+ if (e.key === 'ArrowUp') {
522
+ e.preventDefault();
523
+ autocomplete.moveSelection(-1);
524
+ return;
525
+ }
526
+ if (e.key === 'ArrowDown') {
527
+ e.preventDefault();
528
+ autocomplete.moveSelection(1);
529
+ return;
530
+ }
531
+ if (e.key === 'Tab' && autocomplete.hasSelection()) {
532
+ e.preventDefault();
533
+ autocomplete.select();
534
+ if (this._tabCycle) this._exitTabCycle();
535
+ return;
536
+ }
537
+ if (e.key === 'Enter' && autocomplete.hasSelection()) {
538
+ e.preventDefault();
539
+ autocomplete.select();
540
+ return;
541
+ }
542
+ if (e.key === 'Escape') {
543
+ e.preventDefault();
544
+ autocomplete.hide();
545
+ return;
546
+ }
547
+ }
548
+
549
+ // Tab cycle through trigger pickers (#, /, @, $) — advances when no popup
550
+ // consumed the Tab above. Suppresses the browser's default focus traversal,
551
+ // which otherwise jumps to the nearest <select> outside the input.
552
+ if (e.key === 'Tab') {
553
+ e.preventDefault();
554
+ this._advanceTabCycle(e.shiftKey ? -1 : 1);
555
+ return;
556
+ }
557
+
558
+ // NOTE: Escape is deliberately NOT handled here. ShortcutManager binds
559
+ // document keydown in the CAPTURE phase and calls
560
+ // stopImmediatePropagation() (shortcuts.js), so an Escape branch in this
561
+ // listener is unreachable dead code. Cancelling a parked send lives in
562
+ // app.handleEscape()'s priority chain instead.
563
+
564
+ // Input history navigation (fish-shell style)
565
+ // Up arrow: go back in history (when input empty OR already browsing)
566
+ if (e.key === 'ArrowUp' && !autocomplete?.visible) {
567
+ const history = this.getHistory();
568
+ if (history.length === 0) return;
569
+
570
+ // Can enter history mode if: empty input OR already in history mode
571
+ const isEmpty = this.els.messageInput.value.trim() === '';
572
+ if (!isEmpty && this.historyIndex === -1) return;
573
+
574
+ e.preventDefault();
575
+
576
+ if (this.historyIndex === -1) {
577
+ // Entering history mode - save current text as draft (with prefix if in input mode)
578
+ this.historyDraft = this._prependModePrefix(this.els.messageInput.value);
579
+ this.historyIndex = 0;
580
+ } else if (this.historyIndex < history.length - 1) {
581
+ // Go further back
582
+ this.historyIndex++;
583
+ } else {
584
+ return; // Already at oldest
585
+ }
586
+
587
+ const entry = history[this.historyIndex];
588
+ this.els.messageInput.value = historyEntryText(entry);
589
+ this._updateInputAfterHistoryNav(entry);
590
+ return;
591
+ }
592
+
593
+ // Down arrow: go forward in history (only when browsing)
594
+ if (e.key === 'ArrowDown' && !autocomplete?.visible && this.historyIndex !== -1) {
595
+ e.preventDefault();
596
+
597
+ let entry = null;
598
+ if (this.historyIndex > 0) {
599
+ // Go forward in history
600
+ this.historyIndex--;
601
+ const history = this.getHistory();
602
+ entry = history[this.historyIndex];
603
+ this.els.messageInput.value = historyEntryText(entry);
604
+ } else {
605
+ // Restore draft and exit history mode
606
+ this.els.messageInput.value = this.historyDraft;
607
+ this.historyIndex = -1;
608
+ this.historyDraft = '';
609
+ }
610
+
611
+ this._updateInputAfterHistoryNav(entry);
612
+ return;
613
+ }
614
+
615
+ // Exit history mode on cursor movement (user wants to edit)
616
+ if (this.historyIndex !== -1 &&
617
+ ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
618
+ this.historyIndex = -1;
619
+ this.historyDraft = '';
620
+ this._renderHistoryRefsHint(null);
621
+ // Don't prevent default - let the cursor move
622
+ }
623
+
624
+ // Backspace on empty in input mode exits the mode
625
+ if (e.key === 'Backspace' && this.inputMode && !this.els.messageInput.value) {
626
+ e.preventDefault();
627
+ this._exitInputMode();
628
+ return;
629
+ }
630
+
631
+ // Ctrl+Shift+Enter / Cmd+Shift+Enter: Send in new session
632
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
633
+ e.preventDefault();
634
+ const value = this.els.messageInput.value.trim();
635
+ if (!value) return;
636
+ this.els.messageInput.value = '';
637
+ this.onAutoResize();
638
+ this.els.sendBtn.disabled = true;
639
+ if (this.els.followupBtn) this.els.followupBtn.disabled = true;
640
+ this.clearDraft();
641
+ this._consumePendingDraft();
642
+ this.historyIndex = -1;
643
+ this.historyDraft = '';
644
+ this._renderHistoryRefsHint(null);
645
+ this.onSendInNewSession(value);
646
+ return;
647
+ }
648
+
649
+ // Send behavior varies by device:
650
+ // - iPhone (touch): Cmd+Enter sends, Enter adds newline
651
+ // - iPad/Desktop (keyboard): Enter sends, Shift+Enter adds newline
652
+ // - Welcome screen: Shift+Enter also sends but inverts action (no newlines needed)
653
+ const isWelcome = this.isWelcomeMode();
654
+ const shouldSend = e.key === 'Enter' && (
655
+ (e.ctrlKey || e.metaKey) || // Cmd/Ctrl+Enter always sends
656
+ (this.hasPhysicalKeyboard && !e.shiftKey) || // Enter sends on keyboard devices
657
+ (this.hasPhysicalKeyboard && e.shiftKey && isWelcome) // Shift+Enter on welcome = invert
658
+ );
659
+
660
+ if (shouldSend) {
661
+ e.preventDefault();
662
+ // If disconnected and a project (cwd) is set, connect instead
663
+ const session = this.getSession();
664
+ if (session?.status !== 'connected') {
665
+ if (this.getCwd()) {
666
+ this.onConnect();
667
+ return;
668
+ }
669
+ }
670
+ this.handleInput({ shiftKey: e.shiftKey });
671
+ }
672
+ // Otherwise: Enter adds newline (default behavior on iPhone, or Shift+Enter on keyboard devices)
673
+ }
674
+
675
+ /**
676
+ * Update input field state after history navigation
677
+ */
678
+ _updateInputAfterHistoryNav(entry = null) {
679
+ // Surface the stash references this prompt was sent with. Recall is
680
+ // deliberately display-only — re-arming items that were already sent
681
+ // (and may since have been edited or removed) would be worse than the
682
+ // ambiguity this hint exists to remove.
683
+ this._renderHistoryRefsHint(entry);
684
+
685
+ // Check if navigated to a prefixed command — apply input mode
686
+ const wasMode = this.inputMode;
687
+ const val = this.els.messageInput.value;
688
+ if (val.startsWith('!')) {
689
+ this.inputMode = 'shell';
690
+ this.els.messageInput.value = val.slice(1);
691
+ } else if (val.startsWith('/plan ')) {
692
+ this.inputMode = 'plan';
693
+ this.els.messageInput.value = val.slice(6);
694
+ } else {
695
+ this.inputMode = null;
696
+ }
697
+ this._applyInputMode();
698
+ this._notifyPlanComposeChange(wasMode);
699
+ // Move cursor to end
700
+ const len = this.els.messageInput.value.length;
701
+ this.els.messageInput.selectionStart = len;
702
+ this.els.messageInput.selectionEnd = len;
703
+ this.onAutoResize();
704
+ }
705
+
706
+ /**
707
+ * Render (or hide) the "sent with N references" hint under the input.
708
+ * Pass null to hide — every exit from history mode does.
709
+ */
710
+ _renderHistoryRefsHint(entry) {
711
+ const el = this.els.historyRefsHint;
712
+ if (!el) return;
713
+
714
+ const refs = historyEntryRefs(entry);
715
+ if (!refs) {
716
+ el.classList.remove('visible');
717
+ el.innerHTML = '';
718
+ return;
719
+ }
720
+
721
+ // A stash-only send recalls as an empty input — that is the case this
722
+ // hint exists for, so it gets its own wording rather than looking like
723
+ // the recall silently failed.
724
+ const isEmpty = historyEntryText(entry).trim() === '';
725
+ const strings = S.ui.stash;
726
+ const key = isEmpty
727
+ ? (refs.length === 1 ? 'recall_hint_empty_one' : 'recall_hint_empty_many')
728
+ : (refs.length === 1 ? 'recall_hint_one' : 'recall_hint_many');
729
+
730
+ // Same block the sent-message bubble draws, from the same refs — the
731
+ // count alone left the user knowing a prompt had references without
732
+ // being able to see which. Expanded when there is nothing else to look
733
+ // at (no typed text) or when there is only one; collapsed otherwise so
734
+ // a large stash cannot shove the input off screen.
735
+ // Re-attach is offered only for refs whose ORIGINAL stash items are
736
+ // still around and not already armed. Rebuilding them from the refs
737
+ // themselves would be a trap: `selectedText` is truncated to 300 chars
738
+ // for display, so a rebuilt item would quietly send a clipped snippet.
739
+ const ids = refs.map(r => r.id).filter(Boolean);
740
+ const restorable = Stash.restorableIds(ids);
741
+ const actionHtml = restorable.length > 0
742
+ ? `<button type="button" class="history-refs-reattach" data-action="reattach"
743
+ data-tooltip="${escapeAttr(strings.reattach_tooltip)}">${escapeHtml(strings.reattach)}</button>`
744
+ : '';
745
+
746
+ el.innerHTML = renderStashRefs(refs, {
747
+ label: strings[key].replace('{count}', refs.length),
748
+ open: isEmpty || refs.length === 1,
749
+ }) + actionHtml;
750
+ el.dataset.tooltip = strings.recall_hint_tooltip;
751
+ el.classList.add('visible');
752
+
753
+ el.querySelector('[data-action="reattach"]')
754
+ ?.addEventListener('click', (e) => this._reattachRecalledRefs(e, ids, entry));
755
+ }
756
+
757
+ /**
758
+ * Re-arm the stash items a recalled prompt was sent with.
759
+ * @private
760
+ */
761
+ async _reattachRecalledRefs(e, ids, entry) {
762
+ e.preventDefault();
763
+ e.stopPropagation();
764
+
765
+ const strings = S.ui.stash;
766
+ const { restored, missing } = await Stash.reattach(ids);
767
+
768
+ if (restored === 0 && missing > 0) {
769
+ showToast(strings.reattach_none);
770
+ } else {
771
+ const parts = [
772
+ (restored === 1 ? strings.reattached_one : strings.reattached_many)
773
+ .replace('{count}', restored),
774
+ ];
775
+ if (missing > 0) {
776
+ parts.push(
777
+ (missing === 1 ? strings.reattach_missing_one : strings.reattach_missing_many)
778
+ .replace('{count}', missing)
779
+ );
780
+ }
781
+ showToast(parts.join(' · '));
782
+ }
783
+
784
+ // Re-render: the button drops out now that the items are armed, and
785
+ // the stash preview bar above the input picks them up on its own.
786
+ this._renderHistoryRefsHint(entry);
787
+ this.els.messageInput.focus();
788
+ }
789
+
790
+ /**
791
+ * Fire onPlanComposeChange when this.inputMode transitions into/out of
792
+ * 'plan'. Call after any direct mutation of this.inputMode.
793
+ */
794
+ _notifyPlanComposeChange(wasMode) {
795
+ if (wasMode !== 'plan' && this.inputMode === 'plan') {
796
+ this.onPlanComposeChange(true);
797
+ } else if (wasMode === 'plan' && this.inputMode !== 'plan') {
798
+ this.onPlanComposeChange(false);
799
+ }
800
+ }
801
+
802
+ /**
803
+ * Get the prefix string for the current input mode
804
+ */
805
+ _getModePrefix() {
806
+ if (this.inputMode === 'shell') return '!';
807
+ if (this.inputMode === 'plan') return '/plan ';
808
+ return '';
809
+ }
810
+
811
+ /**
812
+ * Prepend the current mode's prefix to a value
813
+ */
814
+ _prependModePrefix(value) {
815
+ const prefix = this._getModePrefix();
816
+ return prefix && value ? prefix + value : value;
817
+ }
818
+
819
+ /**
820
+ * Apply input mode CSS class and placeholder
821
+ */
822
+ _applyInputMode() {
823
+ const container = this.els.messageInput.closest('#input-container');
824
+ if (container) {
825
+ container.classList.toggle('shell-mode', this.inputMode === 'shell');
826
+ container.classList.toggle('plan-input-mode', this.inputMode === 'plan');
827
+ }
828
+ this.els.messageInput.placeholder = this.inputMode === 'shell' ? 'command...'
829
+ : this.inputMode === 'plan' ? 'Describe what to plan...'
830
+ : S.ui.input.placeholder;
831
+ // Suppress hint overlay + drafts pill in shell/plan modes
832
+ ShortcutHints.setMode(this.inputMode);
833
+ DraftsPill.setMode(this.inputMode);
834
+ }
835
+
836
+ /**
837
+ * Exit input mode, restoring the prefix to the input
838
+ */
839
+ _exitInputMode() {
840
+ if (!this.inputMode) return;
841
+ const wasMode = this.inputMode;
842
+ const prefix = this._getModePrefix();
843
+ this.inputMode = null;
844
+ this._applyInputMode();
845
+ this._notifyPlanComposeChange(wasMode);
846
+ // Hide autocomplete (recent commands popup)
847
+ const autocomplete = this.getAutocomplete();
848
+ if (autocomplete) autocomplete.hide();
849
+ // Prepend prefix back so user sees what they had
850
+ const current = this.els.messageInput.value;
851
+ if (current) {
852
+ this.els.messageInput.value = prefix + current;
853
+ }
854
+ this.onAutoResize();
855
+ }
856
+
857
+ // ─────────────────────────────────────────────────────────────────
858
+ // Tab-cycle through trigger pickers
859
+ // ─────────────────────────────────────────────────────────────────
860
+
861
+ _advanceTabCycle(dir) {
862
+ let nextIdx;
863
+ if (!this._tabCycle) {
864
+ nextIdx = dir > 0 ? 0 : TAB_CYCLE_TRIGGERS.length - 1;
865
+ } else {
866
+ const curIdx = TAB_CYCLE_TRIGGERS.findIndex(t => t.char === this._tabCycle.trigger);
867
+ nextIdx = (curIdx + dir + TAB_CYCLE_TRIGGERS.length) % TAB_CYCLE_TRIGGERS.length;
868
+ }
869
+
870
+ // Clear any stale no-preselect flags from prior advance steps
871
+ // (rapid Tab presses can queue async show()s on multiple popups)
872
+ for (const t of TAB_CYCLE_TRIGGERS) {
873
+ const a = this._getAutocompleteForPopup(t.popup);
874
+ if (a) a.noPreselectOnce = false;
875
+ }
876
+
877
+ // Remove the previously-inserted trigger before inserting the next
878
+ if (this._tabCycle) this._removeTabCycleInsertion();
879
+
880
+ const next = TAB_CYCLE_TRIGGERS[nextIdx];
881
+ const input = this.els.messageInput;
882
+ const pos = input.selectionStart;
883
+ const value = input.value;
884
+ const needsSpace = pos > 0 && !/\s/.test(value[pos - 1]);
885
+ const insertText = needsSpace ? ' ' + next.char : next.char;
886
+
887
+ input.value = value.slice(0, pos) + insertText + value.slice(pos);
888
+ const newCursor = pos + insertText.length;
889
+ input.selectionStart = input.selectionEnd = newCursor;
890
+
891
+ this._tabCycle = {
892
+ trigger: next.char,
893
+ insertStart: pos,
894
+ insertEnd: newCursor,
895
+ };
896
+
897
+ // Tell the popup to skip preselection on this show()
898
+ const ac = this._getAutocompleteForPopup(next.popup);
899
+ if (ac) ac.noPreselectOnce = true;
900
+
901
+ this._highlightTabCycleBtn(next.btnId);
902
+
903
+ // Fire input event so the existing autocomplete plumbing shows the popup
904
+ input.dispatchEvent(new Event('input', { bubbles: true }));
905
+ this.onAutoResize();
906
+
907
+ // Async autocompletes may resolve after the input event — re-apply
908
+ // the no-selection state once they do.
909
+ queueMicrotask(() => this._enforceTabCycleDeselect(next.popup));
910
+ setTimeout(() => this._enforceTabCycleDeselect(next.popup), 120);
911
+ }
912
+
913
+ _enforceTabCycleDeselect(popup) {
914
+ if (!this._tabCycle || this._tabCycle.trigger !== TAB_CYCLE_TRIGGERS.find(t => t.popup === popup)?.char) return;
915
+ const ac = this._getAutocompleteForPopup(popup);
916
+ if (ac && ac.visible && ac.selectedIndex !== -1) {
917
+ ac.selectedIndex = -1;
918
+ if (typeof ac.render === 'function') ac.render();
919
+ }
920
+ }
921
+
922
+ _getAutocompleteForPopup(popup) {
923
+ switch (popup) {
924
+ case 'snippets': return this.getSnippetsAutocomplete();
925
+ case 'slash': return this.getAutocomplete();
926
+ case 'file': return this.getFileAutocomplete();
927
+ case 'skills': return this.getSkillsAutocomplete();
928
+ }
929
+ return null;
930
+ }
931
+
932
+ _removeTabCycleInsertion() {
933
+ if (!this._tabCycle) return;
934
+ const input = this.els.messageInput;
935
+ const value = input.value;
936
+ const { insertStart, insertEnd } = this._tabCycle;
937
+ if (insertEnd > value.length) return; // user edited; bail out safely
938
+ input.value = value.slice(0, insertStart) + value.slice(insertEnd);
939
+ input.selectionStart = input.selectionEnd = insertStart;
940
+ }
941
+
942
+ _cancelTabCycle() {
943
+ this._removeTabCycleInsertion();
944
+ this.getAutocomplete()?.hide();
945
+ this.getFileAutocomplete()?.hide();
946
+ this.getSnippetsAutocomplete()?.hide();
947
+ this.getSkillsAutocomplete()?.hide();
948
+ this._exitTabCycle();
949
+ this.onAutoResize();
950
+ }
951
+
952
+ _exitTabCycle() {
953
+ // Clear any pending no-preselect flag so non-cycle uses behave normally
954
+ for (const t of TAB_CYCLE_TRIGGERS) {
955
+ const a = this._getAutocompleteForPopup(t.popup);
956
+ if (a) a.noPreselectOnce = false;
957
+ }
958
+ this._clearTabCycleHighlights();
959
+ this._tabCycle = null;
960
+ }
961
+
962
+ /** Public: is a Tab-cycle currently in progress? */
963
+ isTabCycleActive() {
964
+ return !!this._tabCycle;
965
+ }
966
+
967
+ /** Public: cancel the Tab-cycle (called by the global Esc handler). */
968
+ cancelTabCycle() {
969
+ this._cancelTabCycle();
970
+ }
971
+
972
+ _highlightTabCycleBtn(btnId) {
973
+ this._clearTabCycleHighlights();
974
+ const btn = document.getElementById(btnId);
975
+ if (btn) btn.classList.add('tab-cycle-active');
976
+ }
977
+
978
+ _clearTabCycleHighlights() {
979
+ for (const t of TAB_CYCLE_TRIGGERS) {
980
+ const btn = document.getElementById(t.btnId);
981
+ if (btn) btn.classList.remove('tab-cycle-active');
982
+ }
983
+ }
984
+
985
+ // ─────────────────────────────────────────────────────────────────
986
+ // Public API
987
+ // ─────────────────────────────────────────────────────────────────
988
+
989
+ /**
990
+ * True while a send is parked waiting on an in-flight upload. Read by
991
+ * app.updateSendButtonState() so the button stays disabled for as long as
992
+ * the park lasts — that state-update runs on every upload progress tick and
993
+ * would otherwise re-enable the button underneath the parked send.
994
+ */
995
+ isAwaitingUpload() {
996
+ return this._deferredSend !== null;
997
+ }
998
+
999
+ /**
1000
+ * Reflect "waiting on upload" on the send controls.
1001
+ */
1002
+ _applyDeferredSendUI(waiting) {
1003
+ const btn = this.els.sendBtn;
1004
+ if (!btn) return;
1005
+ btn.classList.toggle('awaiting-upload', waiting);
1006
+ btn.setAttribute('aria-busy', waiting ? 'true' : 'false');
1007
+ if (waiting) btn.disabled = true;
1008
+ }
1009
+
1010
+ /**
1011
+ * Would a send of `value` carry the session's attachments? Only then is it
1012
+ * worth parking behind an in-flight upload.
1013
+ *
1014
+ * Bang commands run locally in the browser's shell widget and never take an
1015
+ * attachment, so stalling `!ls` behind an image upload buys nothing and
1016
+ * makes the input feel stuck. Slash commands DO park: the dispatch below
1017
+ * routes a slash-prefixed value to onSendMessage() as a normal message once
1018
+ * images are present, so waiting is what keeps those two paths agreeing.
1019
+ */
1020
+ _sendTakesAttachments(value) {
1021
+ if (this.inputMode === 'shell') return false;
1022
+ return !value.startsWith('!');
1023
+ }
1024
+
1025
+ /**
1026
+ * Called when upload state settles. If a send was parked by handleInput()
1027
+ * while attachments were uploading, replay it now that the attachments are
1028
+ * actually available. If every upload failed there is nothing to attach —
1029
+ * drop the deferral and leave the text in the box so the user can retry
1030
+ * rather than silently sending a message stripped of its attachment.
1031
+ */
1032
+ flushDeferredSend() {
1033
+ if (!this._deferredSend || this.isUploading()) return;
1034
+
1035
+ const options = this._deferredSend;
1036
+ this._deferredSend = null;
1037
+ this._applyDeferredSendUI(false);
1038
+
1039
+ // Images AND files — a deferred send may have been waiting on either.
1040
+ if (!this.hasPendingUploads()) {
1041
+ showToast(S.uploads.send_waiting_failed);
1042
+ this.onUpdateSendButton();
1043
+ return;
1044
+ }
1045
+
1046
+ this.handleInput(options);
1047
+ }
1048
+
1049
+ /**
1050
+ * Abandon a parked send (Escape, input cleared, session switch) so it can't
1051
+ * fire unexpectedly once uploads finish.
1052
+ */
1053
+ cancelDeferredSend() {
1054
+ if (!this._deferredSend) return;
1055
+ this._deferredSend = null;
1056
+ this._applyDeferredSendUI(false);
1057
+ this.onUpdateSendButton();
1058
+ }
1059
+
1060
+ /**
1061
+ * Process input and send message or command
1062
+ * @param {Object} options - Options
1063
+ * @param {boolean} options.shiftKey - Whether Shift was held (for welcome screen invert)
1064
+ */
1065
+ handleInput(options = {}) {
1066
+ let value = this.els.messageInput.value.trim();
1067
+ const images = this.getPendingImages();
1068
+ const hasImages = images.length > 0;
1069
+ const hasStash = Stash.hasEnabled();
1070
+
1071
+ // Attachments still uploading — park the send instead of firing it now.
1072
+ // getPendingImages() only reports COMPLETED uploads, so sending here
1073
+ // would silently drop the in-flight attachment and deliver text only.
1074
+ // flushDeferredSend() replays this once the uploads settle.
1075
+ if (this.isUploading() && this._sendTakesAttachments(value)) {
1076
+ const alreadyArmed = this._deferredSend !== null;
1077
+ this._deferredSend = options;
1078
+ this._applyDeferredSendUI(true);
1079
+ if (!alreadyArmed) showToast(S.uploads.send_waiting);
1080
+ return;
1081
+ }
1082
+
1083
+ // Reached a real send — make sure no stale "waiting" state lingers.
1084
+ this._deferredSend = null;
1085
+ this._applyDeferredSendUI(false);
1086
+
1087
+ // In input mode, prepend prefix back for dispatch. Plan mode dispatches
1088
+ // even when empty — a bare /plan just switches to plan permission.
1089
+ if (this.inputMode === 'plan') {
1090
+ value = this._getModePrefix() + value;
1091
+ } else if (this.inputMode && value) {
1092
+ value = this._getModePrefix() + value;
1093
+ }
1094
+
1095
+ // Need either text, images, or stash items to send
1096
+ if (!value && !hasImages && !hasStash) return;
1097
+
1098
+ const autocomplete = this.getAutocomplete();
1099
+ autocomplete?.hide();
1100
+
1101
+ // Reset history navigation state
1102
+ this.historyIndex = -1;
1103
+ this.historyDraft = '';
1104
+ this._renderHistoryRefsHint(null);
1105
+
1106
+ // Exit input mode on send
1107
+ this.inputMode = null;
1108
+ this._applyInputMode();
1109
+
1110
+ // Add all input to session history (slash commands, bang commands, messages)
1111
+ if (value) {
1112
+ this.addToHistory(value);
1113
+ }
1114
+
1115
+ // Parse command (only if no images - images go with regular messages)
1116
+ if (value.startsWith('/') && !hasImages) {
1117
+ this.onSlashCommand(value);
1118
+ } else if (value.startsWith('!') && !hasImages) {
1119
+ this.onBangCommand(value);
1120
+ } else {
1121
+ this.onSendMessage(value, { shiftKey: options.shiftKey || false });
1122
+ }
1123
+
1124
+ this.els.messageInput.value = '';
1125
+ this.onAutoResize();
1126
+ this.els.sendBtn.disabled = true;
1127
+ if (this.els.followupBtn) this.els.followupBtn.disabled = true;
1128
+
1129
+ // Clear saved draft after sending
1130
+ this.clearDraft();
1131
+ // Sending a loaded saved-draft consumes it (server-side delete)
1132
+ this._consumePendingDraft();
1133
+ }
1134
+
1135
+ /**
1136
+ * Get user message history (most recent first)
1137
+ * Returns per-session history (in-memory) for up/down navigation
1138
+ */
1139
+ getHistory() {
1140
+ const session = this.getSession();
1141
+ return session?.promptHistory || [];
1142
+ }
1143
+
1144
+ /**
1145
+ * Add a message to session history (in-memory) for up/down navigation.
1146
+ *
1147
+ * @param {string} content - the text the user typed (may be empty)
1148
+ * @param {Array|null} stashRefs - compact stash references sent with it
1149
+ */
1150
+ addToHistory(content, stashRefs = null) {
1151
+ pushHistoryEntry(this.getSession(), content, stashRefs);
1152
+ }
1153
+
1154
+ /**
1155
+ * Get draft storage key. Prefer storeId (server-side, stable across
1156
+ * SessionManager.reconcileWithServer rebuilds) over client-side id, which
1157
+ * is regenerated when reconcile constructs fresh Session stubs.
1158
+ */
1159
+ getDraftKey() {
1160
+ const session = this.getSession();
1161
+ const key = session?.storeId || session?.id || 'default';
1162
+ return `claude-draft:${key}`;
1163
+ }
1164
+
1165
+ /**
1166
+ * Storage key for the id of the server-side draft mirroring this
1167
+ * session's input (written by the auto-sync). Persisting it means a
1168
+ * refresh keeps updating the same draft instead of duplicating it.
1169
+ */
1170
+ getDraftLinkKey() {
1171
+ const session = this.getSession();
1172
+ const key = session?.storeId || session?.id || 'default';
1173
+ return `claude-draft-link:${key}`;
1174
+ }
1175
+
1176
+ /**
1177
+ * Save draft
1178
+ */
1179
+ saveDraft(text) {
1180
+ if (!text || !text.trim()) {
1181
+ this.clearDraft();
1182
+ return;
1183
+ }
1184
+ Storage.set(this.getDraftKey(), text);
1185
+ }
1186
+
1187
+ /**
1188
+ * Load draft
1189
+ */
1190
+ loadDraft() {
1191
+ return Storage.get(this.getDraftKey(), '');
1192
+ }
1193
+
1194
+ /**
1195
+ * Clear draft (text + the link to its server-side mirror)
1196
+ */
1197
+ clearDraft() {
1198
+ Storage.remove(this.getDraftKey());
1199
+ Storage.remove(this.getDraftLinkKey());
1200
+ }
1201
+
1202
+ /**
1203
+ * Restore draft to input
1204
+ */
1205
+ restoreDraft() {
1206
+ // Session switch/startup: a pending auto-sync belongs to the previous
1207
+ // session's input — cancel it (its localStorage draft keeps the tail)
1208
+ this._cancelDraftSyncTimer();
1209
+ const draft = this.loadDraft();
1210
+ // Re-link the server-side draft mirroring this session's input (see
1211
+ // _syncDraftToServer) so edits keep updating it and sending consumes
1212
+ // it — instead of duplicating a draft after every refresh/switch
1213
+ this.pendingDraftId = draft ? (Storage.get(this.getDraftLinkKey(), null) || null) : null;
1214
+ if (draft) {
1215
+ // Detect mode from saved draft prefix
1216
+ if (draft.startsWith('!')) {
1217
+ this.inputMode = 'shell';
1218
+ this.els.messageInput.value = draft.slice(1);
1219
+ } else if (draft.startsWith('/plan ')) {
1220
+ this.inputMode = 'plan';
1221
+ this.els.messageInput.value = draft.slice(6);
1222
+ } else {
1223
+ this.els.messageInput.value = draft;
1224
+ }
1225
+ this._applyInputMode();
1226
+ this.onAutoResize();
1227
+ this.onUpdateSendButton();
1228
+ }
1229
+ }
1230
+
1231
+ /**
1232
+ * Reset history navigation state
1233
+ */
1234
+ resetHistoryState() {
1235
+ this.historyIndex = -1;
1236
+ this.historyDraft = '';
1237
+ this._renderHistoryRefsHint(null);
1238
+ }
1239
+
1240
+ // ─────────────────────────────────────────────────────────────────
1241
+ // Saved drafts (server-side, cross-session — /api/drafts)
1242
+ // ─────────────────────────────────────────────────────────────────
1243
+
1244
+ /**
1245
+ * Current input encoded with its mode prefix — the canonical draft text
1246
+ * (same scheme for the localStorage auto-draft and server-side drafts).
1247
+ * A bare prefix with no content encodes to '' (not a useful draft).
1248
+ */
1249
+ _encodeDraftText() {
1250
+ const raw = this.els.messageInput.value;
1251
+ return this.inputMode === 'shell' && raw ? '!' + raw
1252
+ : this.inputMode === 'plan' && raw ? '/plan ' + raw
1253
+ : !this.inputMode ? raw : '';
1254
+ }
1255
+
1256
+ /**
1257
+ * Debounced trigger for the auto-sync (called on every input change).
1258
+ */
1259
+ _scheduleDraftSync() {
1260
+ if (this._draftSyncTimer) clearTimeout(this._draftSyncTimer);
1261
+ this._draftSyncTimer = setTimeout(() => {
1262
+ this._draftSyncTimer = null;
1263
+ this._syncDraftToServer();
1264
+ }, AUTO_DRAFT_SYNC_MS);
1265
+ }
1266
+
1267
+ _cancelDraftSyncTimer() {
1268
+ if (this._draftSyncTimer) {
1269
+ clearTimeout(this._draftSyncTimer);
1270
+ this._draftSyncTimer = null;
1271
+ }
1272
+ }
1273
+
1274
+ /**
1275
+ * Auto-bank the in-progress input as a server-side draft: create one
1276
+ * once the text passes AUTO_DRAFT_MIN_CHARS, then keep updating that
1277
+ * same draft (linked via pendingDraftId + persisted link key). Sending
1278
+ * consumes it; clearing the input abandons it into the Drafts list.
1279
+ * Single-flight: a tick that fires mid-request re-runs afterwards.
1280
+ */
1281
+ _syncDraftToServer() {
1282
+ if (this._draftSyncInflight) {
1283
+ this._draftSyncQueued = true;
1284
+ return;
1285
+ }
1286
+
1287
+ const text = this._encodeDraftText();
1288
+ if (!text.trim()) return;
1289
+ const idAtStart = this.pendingDraftId;
1290
+ if (!idAtStart && text.trim().length < AUTO_DRAFT_MIN_CHARS) return;
1291
+
1292
+ const keyAtStart = this.getDraftKey();
1293
+ const linkKeyAtStart = this.getDraftLinkKey();
1294
+ const epochAtStart = this._draftConsumeEpoch;
1295
+ const session = this.getSession();
1296
+ const headers = { 'Content-Type': 'application/json' };
1297
+ const body = JSON.stringify({
1298
+ text,
1299
+ cwd: this.getCwd(),
1300
+ session_id: session?.storeId || session?.id || null,
1301
+ });
1302
+
1303
+ this._draftSyncInflight = (async () => {
1304
+ try {
1305
+ if (idAtStart) {
1306
+ const res = await fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(idAtStart)}`, {
1307
+ method: 'PUT', headers, body,
1308
+ });
1309
+ if (res.ok) return;
1310
+ if (res.status !== 404) throw new Error(`HTTP ${res.status}`);
1311
+ // 404 → deleted elsewhere (Drafts tab, other device);
1312
+ // fall through and recreate
1313
+ }
1314
+
1315
+ // Input got sent while we waited — nothing left to bank
1316
+ if (this._draftConsumeEpoch !== epochAtStart) return;
1317
+
1318
+ const res = await fetch(`${CONFIG.API_BASE}/api/drafts`, { method: 'POST', headers, body });
1319
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1320
+ const created = (await res.json())?.draft;
1321
+ if (!created?.id) return;
1322
+
1323
+ if (this._draftConsumeEpoch !== epochAtStart) {
1324
+ // Sent while the create was in flight — the fresh draft is
1325
+ // a ghost of a sent message, delete it right back
1326
+ fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(created.id)}`, { method: 'DELETE' })
1327
+ .catch(() => {});
1328
+ return;
1329
+ }
1330
+
1331
+ // Persist the link against the input this text belonged to,
1332
+ // so a refresh (or returning to that session) resumes
1333
+ // updating this draft instead of duplicating it
1334
+ Storage.set(linkKeyAtStart, created.id);
1335
+ if (this.getDraftKey() === keyAtStart && this.els.messageInput.value.trim()) {
1336
+ this.pendingDraftId = created.id;
1337
+ }
1338
+ // else: session switched / input emptied meanwhile → the
1339
+ // draft stays as an unlinked "abandoned" entry in the list
1340
+ window.dispatchEvent(new CustomEvent('drafts-changed'));
1341
+ } catch (err) {
1342
+ console.warn('Draft auto-sync failed:', err);
1343
+ } finally {
1344
+ this._draftSyncInflight = null;
1345
+ if (this._draftSyncQueued) {
1346
+ this._draftSyncQueued = false;
1347
+ this._scheduleDraftSync();
1348
+ }
1349
+ }
1350
+ })();
1351
+ }
1352
+
1353
+ /**
1354
+ * Bank the current input as a server-side draft (retrieve via Prompt
1355
+ * Explorer → Drafts, from any session). Clears the input on success.
1356
+ * If the auto-sync already mirrors this input, that draft is finalized
1357
+ * in place — no duplicate.
1358
+ */
1359
+ async saveAsDraft() {
1360
+ // Encode active input mode as a prefix (same scheme as the auto-draft)
1361
+ const text = this._encodeDraftText();
1362
+ if (!text.trim()) return;
1363
+
1364
+ // Don't race the auto-sync: cancel the debounce and wait out any
1365
+ // in-flight upsert so the explicit save reuses its draft id
1366
+ this._cancelDraftSyncTimer();
1367
+ if (this._draftSyncInflight) {
1368
+ try { await this._draftSyncInflight; } catch { /* already logged */ }
1369
+ }
1370
+
1371
+ const session = this.getSession();
1372
+ const headers = { 'Content-Type': 'application/json' };
1373
+ const body = JSON.stringify({
1374
+ text,
1375
+ cwd: this.getCwd(),
1376
+ session_id: session?.storeId || session?.id || null,
1377
+ });
1378
+
1379
+ try {
1380
+ let saved = false;
1381
+ if (this.pendingDraftId) {
1382
+ const res = await fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(this.pendingDraftId)}`, {
1383
+ method: 'PUT', headers, body,
1384
+ });
1385
+ if (res.ok) saved = true;
1386
+ else if (res.status !== 404) throw new Error(`HTTP ${res.status}`);
1387
+ }
1388
+ if (!saved) {
1389
+ const res = await fetch(`${CONFIG.API_BASE}/api/drafts`, { method: 'POST', headers, body });
1390
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1391
+ }
1392
+
1393
+ // Prompt is banked — clear input, mode, and the auto-draft
1394
+ this.els.messageInput.value = '';
1395
+ this.inputMode = null;
1396
+ this._applyInputMode();
1397
+ this.pendingDraftId = null;
1398
+ this.clearDraft();
1399
+ this.onAutoResize();
1400
+ this.onUpdateSendButton();
1401
+ ShortcutHints.updateVisibility('');
1402
+ DraftsPill.updateVisibility('');
1403
+ window.dispatchEvent(new CustomEvent('drafts-changed'));
1404
+ showToast(S.toast.draft_saved);
1405
+ } catch (err) {
1406
+ console.error('Failed to save draft:', err);
1407
+ showToast(S.toast.draft_save_failed);
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * Load a saved draft into the input (replaces current content) and
1413
+ * remember its id so sending consumes it.
1414
+ * @param {Object} draft - {id, text, ...} from /api/drafts
1415
+ */
1416
+ insertSavedDraft(draft) {
1417
+ const text = draft?.text || '';
1418
+
1419
+ // Mode prefixes are encoded in the text (same scheme as auto-draft)
1420
+ if (text.startsWith('!')) {
1421
+ this.inputMode = 'shell';
1422
+ this.els.messageInput.value = text.slice(1);
1423
+ } else if (text.startsWith('/plan ')) {
1424
+ this.inputMode = 'plan';
1425
+ this.els.messageInput.value = text.slice(6);
1426
+ } else {
1427
+ this.inputMode = null;
1428
+ this.els.messageInput.value = text;
1429
+ }
1430
+ this._applyInputMode();
1431
+ this.onAutoResize();
1432
+ this.onUpdateSendButton();
1433
+ ShortcutHints.updateVisibility(this.els.messageInput.value);
1434
+
1435
+ // Keep the per-session auto-draft (and its persisted link) in sync
1436
+ // so a refresh preserves the loaded text and keeps updating the
1437
+ // same server-side draft
1438
+ this.saveDraft(text);
1439
+ this.pendingDraftId = draft?.id || null;
1440
+ if (draft?.id) Storage.set(this.getDraftLinkKey(), draft.id);
1441
+ DraftsPill.updateVisibility(this.els.messageInput.value);
1442
+ this.els.messageInput.focus();
1443
+ }
1444
+
1445
+ /**
1446
+ * Drop the link between the input and its server-side draft (e.g. the
1447
+ * draft was deleted from the Drafts tab) without touching the input.
1448
+ * The next auto-sync tick then creates a fresh draft if warranted.
1449
+ */
1450
+ unlinkSavedDraft() {
1451
+ this.pendingDraftId = null;
1452
+ Storage.remove(this.getDraftLinkKey());
1453
+ }
1454
+
1455
+ /**
1456
+ * Delete the draft mirroring the input after it was sent (fire-and-forget).
1457
+ * @private
1458
+ */
1459
+ _consumePendingDraft() {
1460
+ // Bump the epoch first: an auto-sync create may be in flight, and
1461
+ // the epoch tells it the input was sent — the just-created draft is
1462
+ // then a ghost of a sent message and gets deleted instead of linked
1463
+ this._draftConsumeEpoch++;
1464
+ this._cancelDraftSyncTimer();
1465
+ const id = this.pendingDraftId;
1466
+ this.pendingDraftId = null;
1467
+ if (!id) return;
1468
+ fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(id)}`, { method: 'DELETE' })
1469
+ .then(() => window.dispatchEvent(new CustomEvent('drafts-changed')))
1470
+ .catch(err => console.error('Failed to consume sent draft:', err));
1471
+ }
1472
+ }