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,374 @@
1
+ """Commands Manager API.
2
+
3
+ Endpoints for discovering, viewing, editing, and upgrading legacy `.md`
4
+ command files. Built-in Claude CLI commands are surfaced as read-only
5
+ entries for reference.
6
+
7
+ Sources:
8
+ builtin: Claude CLI binary (extracted via `strings`, ~77 names)
9
+ project: <cwd>/.claude/commands/<name>.md (editable)
10
+ personal: ~/.claude/commands/<name>.md (editable)
11
+ plugin: ~/.claude/plugins/marketplaces/*/plugins/*/commands/<name>.md (read-only)
12
+
13
+ Folder-form skills (`skills/<name>/SKILL.md`) live in `routes/api_skills.py`.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import shutil
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ import yaml
24
+ from fastapi import APIRouter, HTTPException
25
+ from pydantic import BaseModel
26
+ from painapple_code.utils.file_paths import safe_resolve
27
+
28
+ router = APIRouter(prefix="/api/commands", tags=["commands"])
29
+
30
+ NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
31
+ FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.DOTALL)
32
+
33
+
34
+ # ---------- helpers ---------------------------------------------------------
35
+
36
+
37
+ def _validate_name(name: str) -> None:
38
+ if not NAME_RE.match(name):
39
+ raise HTTPException(
40
+ status_code=400,
41
+ detail=f"Invalid command name {name!r} — must match {NAME_RE.pattern}",
42
+ )
43
+
44
+
45
+ def _safe_under(base: Path, candidate: Path) -> bool:
46
+ try:
47
+ candidate.resolve().relative_to(base.resolve())
48
+ return True
49
+ except (ValueError, OSError):
50
+ return False
51
+
52
+
53
+ def _parse_frontmatter(text: str) -> tuple[dict[str, Any], str]:
54
+ m = FRONTMATTER_RE.match(text)
55
+ if not m:
56
+ return {}, text
57
+ try:
58
+ data = yaml.safe_load(m.group(1)) or {}
59
+ if not isinstance(data, dict):
60
+ data = {"_parse_error": "frontmatter is not a mapping"}
61
+ return data, m.group(2)
62
+ except yaml.YAMLError as e:
63
+ return {"_parse_error": str(e)}, m.group(2)
64
+
65
+
66
+ def _serialize(frontmatter: dict[str, Any], body: str) -> str:
67
+ if not frontmatter:
68
+ return body
69
+ clean = {k: v for k, v in frontmatter.items() if not k.startswith("_")}
70
+ if not clean:
71
+ return body
72
+ yaml_text = yaml.safe_dump(
73
+ clean, sort_keys=False, default_flow_style=False, allow_unicode=True
74
+ ).rstrip()
75
+ body_part = body if body.startswith("\n") else "\n" + body
76
+ return f"---\n{yaml_text}\n---{body_part}"
77
+
78
+
79
+ def _body_preview(body: str, n: int = 280) -> str:
80
+ body = body.strip()
81
+ if len(body) <= n:
82
+ return body
83
+ return body[:n].rstrip() + "…"
84
+
85
+
86
+ def _iter_legacy_commands(cwd: str):
87
+ """Yield legacy `.md` command files across project/personal/plugin scopes."""
88
+ home = Path.home()
89
+ cwd_path = Path(cwd)
90
+
91
+ for scope, base in (("project", cwd_path), ("personal", home)):
92
+ cmd_dir = base / ".claude" / "commands"
93
+ if cmd_dir.is_dir():
94
+ for f in cmd_dir.iterdir():
95
+ if f.is_file() and f.suffix == ".md":
96
+ yield {
97
+ "scope": scope,
98
+ "name": f.stem,
99
+ "path": f,
100
+ "editable": True,
101
+ "plugin_label": None,
102
+ }
103
+
104
+ plugins_root = home / ".claude" / "plugins" / "marketplaces"
105
+ if plugins_root.is_dir():
106
+ for marketplace in plugins_root.iterdir():
107
+ plugins_dir = marketplace / "plugins"
108
+ if not plugins_dir.is_dir():
109
+ continue
110
+ for plugin in plugins_dir.iterdir():
111
+ cmd_dir = plugin / "commands"
112
+ if cmd_dir.is_dir():
113
+ for f in cmd_dir.iterdir():
114
+ if f.is_file() and f.suffix == ".md":
115
+ yield {
116
+ "scope": "plugin",
117
+ "name": f.stem,
118
+ "path": f,
119
+ "editable": False,
120
+ "plugin_label": f"{plugin.name} ({marketplace.name})",
121
+ }
122
+
123
+
124
+ def _resolve_path(scope: str, name: str, cwd: str, allow_plugin: bool = False) -> dict[str, Any]:
125
+ """Locate a legacy `.md` command on disk."""
126
+ _validate_name(name)
127
+ if scope == "plugin":
128
+ if not allow_plugin:
129
+ raise HTTPException(status_code=405, detail="Plugin commands are read-only")
130
+ for rec in _iter_legacy_commands(cwd):
131
+ if rec["scope"] == "plugin" and rec["name"] == name:
132
+ return rec
133
+ raise HTTPException(status_code=404, detail=f"Plugin command {name!r} not found")
134
+
135
+ if scope not in ("project", "personal"):
136
+ raise HTTPException(status_code=400, detail=f"Unknown scope {scope!r}")
137
+
138
+ base = (Path(cwd) if scope == "project" else Path.home()) / ".claude"
139
+ cmd_file = base / "commands" / f"{name}.md"
140
+ if cmd_file.is_file() and _safe_under(base, cmd_file):
141
+ return {
142
+ "scope": scope,
143
+ "name": name,
144
+ "path": cmd_file,
145
+ "editable": True,
146
+ "plugin_label": None,
147
+ }
148
+ raise HTTPException(status_code=404, detail=f"Command {name!r} not found in {scope}")
149
+
150
+
151
+ # ---------- endpoints -------------------------------------------------------
152
+
153
+
154
+ @router.get("")
155
+ async def list_commands(cwd: str):
156
+ """List built-in CLI commands and legacy `.md` files."""
157
+ # Lazy import to avoid load-order issues
158
+ from painapple_code.routes.api_project_config import cli_command_descriptions, _get_command_descriptions
159
+
160
+ resolved_cwd = str(safe_resolve(cwd))
161
+ # Off the event loop — scanning the CLI bundle takes ~2s on a cold
162
+ # cache. Every lookup after this hits the memo, including the sync
163
+ # `_get_command_descriptions` below.
164
+ builtin_descriptions = await cli_command_descriptions()
165
+ descriptions = _get_command_descriptions(resolved_cwd)
166
+ commands: list[dict[str, Any]] = []
167
+ counts = {"builtin": 0, "project": 0, "personal": 0, "plugin": 0}
168
+
169
+ # Built-in CLI commands (read-only — extracted from binary)
170
+ for name, desc in sorted(builtin_descriptions.items()):
171
+ commands.append({
172
+ "id": f"builtin:{name}",
173
+ "name": name,
174
+ "scope": "builtin",
175
+ "scope_label": "Built-in",
176
+ "kind": "builtin",
177
+ "path": None,
178
+ "description": desc,
179
+ "editable": False,
180
+ "frontmatter": {},
181
+ "body_preview": "",
182
+ "body_length": 0,
183
+ })
184
+ counts["builtin"] += 1
185
+
186
+ # Legacy `.md` files
187
+ for rec in _iter_legacy_commands(resolved_cwd):
188
+ try:
189
+ text = rec["path"].read_text(encoding="utf-8", errors="replace")
190
+ except OSError:
191
+ continue
192
+ fm, body = _parse_frontmatter(text)
193
+ desc = (
194
+ fm.get("description")
195
+ or descriptions.get(rec["name"])
196
+ or _body_preview(body, 80)
197
+ )
198
+ commands.append({
199
+ "id": f"{rec['scope']}:{rec['name']}",
200
+ "name": rec["name"],
201
+ "scope": rec["scope"],
202
+ "scope_label": rec["plugin_label"] or rec["scope"].capitalize(),
203
+ "kind": "file",
204
+ "path": str(rec["path"]),
205
+ "description": desc,
206
+ "editable": rec["editable"],
207
+ "frontmatter": fm,
208
+ "body_preview": _body_preview(body),
209
+ "body_length": len(body),
210
+ })
211
+ counts[rec["scope"]] += 1
212
+
213
+ return {
214
+ "cwd": resolved_cwd,
215
+ "commands": commands,
216
+ "counts": counts,
217
+ "total": len(commands),
218
+ }
219
+
220
+
221
+ @router.get("/{scope}/{name}")
222
+ async def get_command(scope: str, name: str, cwd: str):
223
+ """Return full command metadata + body. Builtins return description only."""
224
+ if scope == "builtin":
225
+ from painapple_code.routes.api_project_config import cli_command_descriptions
226
+ cli_descriptions = await cli_command_descriptions()
227
+ if name not in cli_descriptions:
228
+ raise HTTPException(status_code=404, detail=f"Built-in command {name!r} not found")
229
+ return {
230
+ "id": f"builtin:{name}",
231
+ "name": name,
232
+ "scope": "builtin",
233
+ "scope_label": "Built-in",
234
+ "kind": "builtin",
235
+ "path": None,
236
+ "description": cli_descriptions[name],
237
+ "editable": False,
238
+ "frontmatter": {},
239
+ "body": "",
240
+ "raw": "",
241
+ "mtime": 0,
242
+ }
243
+
244
+ resolved_cwd = str(safe_resolve(cwd))
245
+ rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=True)
246
+ text = rec["path"].read_text(encoding="utf-8", errors="replace")
247
+ fm, body = _parse_frontmatter(text)
248
+ return {
249
+ "id": f"{scope}:{name}",
250
+ "name": name,
251
+ "scope": scope,
252
+ "scope_label": rec["plugin_label"] or scope.capitalize(),
253
+ "kind": "file",
254
+ "path": str(rec["path"]),
255
+ "editable": rec["editable"],
256
+ "frontmatter": fm,
257
+ "body": body,
258
+ "raw": text,
259
+ "description": fm.get("description") or "",
260
+ "mtime": rec["path"].stat().st_mtime,
261
+ }
262
+
263
+
264
+ class UpdateCommandBody(BaseModel):
265
+ frontmatter: Optional[dict[str, Any]] = None
266
+ body: Optional[str] = None
267
+ raw: Optional[str] = None
268
+ expected_mtime: Optional[float] = None
269
+
270
+
271
+ @router.put("/{scope}/{name}")
272
+ async def update_command(scope: str, name: str, cwd: str, payload: UpdateCommandBody):
273
+ """Update a legacy `.md` command file (project or personal scope only)."""
274
+ if scope == "builtin":
275
+ raise HTTPException(status_code=405, detail="Built-in commands are not editable")
276
+ resolved_cwd = str(safe_resolve(cwd))
277
+ rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
278
+ path: Path = rec["path"]
279
+
280
+ if payload.expected_mtime is not None:
281
+ try:
282
+ current = path.stat().st_mtime
283
+ if abs(current - payload.expected_mtime) > 0.5:
284
+ raise HTTPException(
285
+ status_code=409,
286
+ detail="File changed on disk since you started editing",
287
+ )
288
+ except OSError:
289
+ pass
290
+
291
+ if payload.raw is not None:
292
+ new_text = payload.raw
293
+ elif payload.frontmatter is not None or payload.body is not None:
294
+ existing = path.read_text(encoding="utf-8", errors="replace")
295
+ ex_fm, ex_body = _parse_frontmatter(existing)
296
+ fm = payload.frontmatter if payload.frontmatter is not None else ex_fm
297
+ body = payload.body if payload.body is not None else ex_body
298
+ new_text = _serialize(fm, body)
299
+ else:
300
+ raise HTTPException(status_code=400, detail="No content provided")
301
+
302
+ tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
303
+ tmp.write_text(new_text, encoding="utf-8", newline="")
304
+ os.replace(tmp, path)
305
+
306
+ return {
307
+ "ok": True,
308
+ "path": str(path),
309
+ "mtime": path.stat().st_mtime,
310
+ "bytes": len(new_text.encode("utf-8")),
311
+ }
312
+
313
+
314
+ @router.delete("/{scope}/{name}")
315
+ async def delete_command(scope: str, name: str, cwd: str):
316
+ """Delete a legacy `.md` command file."""
317
+ if scope == "builtin":
318
+ raise HTTPException(status_code=405, detail="Built-in commands cannot be deleted")
319
+ resolved_cwd = str(safe_resolve(cwd))
320
+ rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
321
+ path: Path = rec["path"]
322
+
323
+ parts = path.resolve().parts
324
+ if ".claude" not in parts:
325
+ raise HTTPException(status_code=400, detail="Refusing to delete outside .claude/")
326
+
327
+ try:
328
+ path.unlink()
329
+ except OSError as e:
330
+ raise HTTPException(status_code=500, detail=str(e))
331
+ return {"ok": True, "removed": str(path), "kind": "file"}
332
+
333
+
334
+ @router.post("/{scope}/{name}/upgrade")
335
+ async def upgrade_to_skill(scope: str, name: str, cwd: str):
336
+ """Convert a legacy `commands/<name>.md` to `skills/<name>/SKILL.md` folder.
337
+
338
+ The original flat file is removed after the new directory is in place.
339
+ """
340
+ if scope == "builtin":
341
+ raise HTTPException(status_code=405, detail="Built-in commands cannot be upgraded")
342
+ resolved_cwd = str(safe_resolve(cwd))
343
+ rec = _resolve_path(scope, name, resolved_cwd, allow_plugin=False)
344
+ src_file: Path = rec["path"]
345
+
346
+ scope_base = Path(resolved_cwd) / ".claude" if scope == "project" else Path.home() / ".claude"
347
+ target_dir = scope_base / "skills" / name
348
+ if target_dir.exists():
349
+ raise HTTPException(status_code=409, detail=f"Skill folder already exists: {target_dir}")
350
+
351
+ text = src_file.read_text(encoding="utf-8", errors="replace")
352
+ fm, body = _parse_frontmatter(text)
353
+ fm = dict(fm or {})
354
+ fm = {k: v for k, v in fm.items() if not k.startswith("_")}
355
+ fm.setdefault("name", name)
356
+
357
+ target_dir.mkdir(parents=True, exist_ok=False)
358
+ (target_dir / "SKILL.md").write_text(_serialize(fm, body), encoding="utf-8", newline="")
359
+
360
+ try:
361
+ src_file.unlink()
362
+ except OSError as e:
363
+ shutil.rmtree(target_dir, ignore_errors=True)
364
+ raise HTTPException(status_code=500, detail=f"Could not remove legacy file: {e}")
365
+
366
+ return {
367
+ "ok": True,
368
+ "id": f"skill:{scope}:{name}",
369
+ "scope": scope,
370
+ "name": name,
371
+ "path": str(target_dir / "SKILL.md"),
372
+ "dir": str(target_dir),
373
+ "removed_legacy": str(src_file),
374
+ }
@@ -0,0 +1,128 @@
1
+ """
2
+ Cost Analytics API Routes - Cost tracking and analysis across sessions
3
+
4
+ These endpoints provide comprehensive cost tracking including summaries,
5
+ session rankings, tool attribution, trends, and efficiency metrics.
6
+ """
7
+
8
+ import logging
9
+
10
+ from fastapi import APIRouter
11
+
12
+ from painapple_code.cost_analytics import CostAnalyzer
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ router = APIRouter(prefix="/api/costs", tags=["costs"])
17
+
18
+
19
+ # ═══════════════════════════════════════════════════════════════════════
20
+ # Cost Analyzer Singleton
21
+ # ═══════════════════════════════════════════════════════════════════════
22
+
23
+ _cost_analyzer = None
24
+
25
+
26
+ def get_cost_analyzer() -> CostAnalyzer:
27
+ """Get or create the cost analyzer singleton."""
28
+ global _cost_analyzer
29
+ if _cost_analyzer is None:
30
+ _cost_analyzer = CostAnalyzer()
31
+ return _cost_analyzer
32
+
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════
35
+ # Cost Endpoints
36
+ # ═══════════════════════════════════════════════════════════════════════
37
+
38
+ @router.get("")
39
+ async def get_costs_summary(
40
+ project: str = None,
41
+ session: str = None,
42
+ since: str = None,
43
+ until: str = None,
44
+ ):
45
+ """
46
+ Get comprehensive cost summary.
47
+
48
+ Query params:
49
+ - project: Filter by project hash
50
+ - session: Filter by session ID
51
+ - since: Start date (ISO format, e.g., 2025-12-01)
52
+ - until: End date (ISO format, e.g., 2025-12-31)
53
+ """
54
+ analyzer = get_cost_analyzer()
55
+ return analyzer.get_summary(
56
+ project_hash=project,
57
+ session_id=session,
58
+ since=since,
59
+ until=until,
60
+ )
61
+
62
+
63
+ @router.get("/sessions")
64
+ async def get_costs_sessions(
65
+ limit: int = 20,
66
+ project: str = None,
67
+ ):
68
+ """
69
+ Get sessions ranked by cost.
70
+
71
+ Query params:
72
+ - limit: Max sessions to return (default 20)
73
+ - project: Filter by project hash
74
+ """
75
+ analyzer = get_cost_analyzer()
76
+ return {"sessions": analyzer.get_sessions_ranked(limit=limit, project_hash=project)}
77
+
78
+
79
+ @router.get("/tools")
80
+ async def get_costs_tools(
81
+ project: str = None,
82
+ session: str = None,
83
+ ):
84
+ """
85
+ Get tool cost attribution (estimated).
86
+
87
+ Attributes turn cost proportionally to tools used in that turn.
88
+
89
+ Query params:
90
+ - project: Filter by project hash
91
+ - session: Filter by session ID
92
+ """
93
+ analyzer = get_cost_analyzer()
94
+ return analyzer.get_tool_attribution(project_hash=project, session_id=session)
95
+
96
+
97
+ @router.get("/trends")
98
+ async def get_costs_trends(
99
+ period: str = "daily",
100
+ project: str = None,
101
+ days: int = 30,
102
+ ):
103
+ """
104
+ Get cost trends over time.
105
+
106
+ Query params:
107
+ - period: Aggregation period (daily, weekly, monthly)
108
+ - project: Filter by project hash
109
+ - days: Number of periods to return (default 30)
110
+ """
111
+ analyzer = get_cost_analyzer()
112
+ return analyzer.get_trends(period=period, project_hash=project, days=days)
113
+
114
+
115
+ @router.get("/efficiency")
116
+ async def get_costs_efficiency(
117
+ project: str = None,
118
+ ):
119
+ """
120
+ Get efficiency metrics.
121
+
122
+ Returns cost per turn, tokens per dollar, cache efficiency, etc.
123
+
124
+ Query params:
125
+ - project: Filter by project hash
126
+ """
127
+ analyzer = get_cost_analyzer()
128
+ return analyzer.get_efficiency_metrics(project_hash=project)
@@ -0,0 +1,139 @@
1
+ """
2
+ Prompt Drafts API Routes
3
+
4
+ Explicit "save this prompt for later" drafts — global (cross-session,
5
+ cross-project), stored server-side at ~/.painapple-code/drafts.json so
6
+ they survive the iPad PWA's flaky localStorage persistence.
7
+
8
+ Distinct from the per-session auto-draft (client-side, one slot per
9
+ session, restored on refresh) — these are deliberately banked prompts.
10
+ """
11
+
12
+ import logging
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+ from typing import Optional
16
+
17
+ from fastapi import APIRouter, HTTPException
18
+ from pydantic import BaseModel
19
+
20
+ from painapple_code.paths import load_drafts, save_drafts
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ router = APIRouter(tags=["drafts"])
25
+
26
+ # Safety cap — trim oldest on write so drafts.json can't grow unbounded
27
+ MAX_DRAFTS = 200
28
+ TITLE_MAX_LEN = 60
29
+
30
+
31
+ class DraftCreate(BaseModel):
32
+ text: str
33
+ cwd: Optional[str] = None
34
+ session_id: Optional[str] = None
35
+
36
+
37
+ class DraftUpdate(BaseModel):
38
+ text: str
39
+ cwd: Optional[str] = None
40
+ session_id: Optional[str] = None
41
+
42
+
43
+ def _title_from_text(text: str) -> str:
44
+ """Auto title: first non-empty line, truncated to ~60 chars."""
45
+ for line in text.splitlines():
46
+ line = line.strip()
47
+ if line:
48
+ return line[:TITLE_MAX_LEN] + ("…" if len(line) > TITLE_MAX_LEN else "")
49
+ return text.strip()[:TITLE_MAX_LEN] or "Draft"
50
+
51
+
52
+ @router.get("/api/drafts")
53
+ async def list_drafts():
54
+ """List all saved drafts, newest first."""
55
+ data = load_drafts()
56
+ drafts = data.get("drafts", [])
57
+ drafts.sort(key=lambda d: d.get("updatedAt") or "", reverse=True)
58
+ return {"drafts": drafts, "count": len(drafts)}
59
+
60
+
61
+ @router.post("/api/drafts")
62
+ async def create_draft(req: DraftCreate):
63
+ """Save a new draft. Title is derived from the first line of text."""
64
+ if not (req.text or "").strip():
65
+ raise HTTPException(status_code=400, detail="Draft text is empty")
66
+
67
+ now = datetime.now(timezone.utc).isoformat()
68
+ draft = {
69
+ "id": uuid.uuid4().hex[:12],
70
+ "text": req.text,
71
+ "title": _title_from_text(req.text),
72
+ "cwd": req.cwd,
73
+ "sessionId": req.session_id,
74
+ "createdAt": now,
75
+ "updatedAt": now,
76
+ }
77
+
78
+ data = load_drafts()
79
+ data.setdefault("drafts", []).insert(0, draft)
80
+ # Trim oldest beyond the cap (list is kept newest-first)
81
+ if len(data["drafts"]) > MAX_DRAFTS:
82
+ data["drafts"] = data["drafts"][:MAX_DRAFTS]
83
+
84
+ if not save_drafts(data):
85
+ raise HTTPException(status_code=500, detail="Failed to save draft")
86
+ return {"success": True, "draft": draft}
87
+
88
+
89
+ @router.put("/api/drafts/{draft_id}")
90
+ async def update_draft(draft_id: str, req: DraftUpdate):
91
+ """Update a draft in place (auto-sync keeps the live input's draft fresh).
92
+
93
+ Title is re-derived from the new text; updatedAt bumps so the draft
94
+ floats to the top of the newest-first list.
95
+ """
96
+ if not (req.text or "").strip():
97
+ raise HTTPException(status_code=400, detail="Draft text is empty")
98
+
99
+ data = load_drafts()
100
+ for draft in data.get("drafts", []):
101
+ if draft.get("id") == draft_id:
102
+ draft["text"] = req.text
103
+ draft["title"] = _title_from_text(req.text)
104
+ if req.cwd is not None:
105
+ draft["cwd"] = req.cwd
106
+ if req.session_id is not None:
107
+ draft["sessionId"] = req.session_id
108
+ draft["updatedAt"] = datetime.now(timezone.utc).isoformat()
109
+ if not save_drafts(data):
110
+ raise HTTPException(status_code=500, detail="Failed to save draft")
111
+ return {"success": True, "draft": draft}
112
+
113
+ raise HTTPException(status_code=404, detail="Draft not found")
114
+
115
+
116
+ @router.delete("/api/drafts")
117
+ async def clear_drafts():
118
+ """Wipe all saved drafts at once (Clear All)."""
119
+ data = load_drafts()
120
+ count = len(data.get("drafts", []))
121
+ data["drafts"] = []
122
+ if not save_drafts(data):
123
+ raise HTTPException(status_code=500, detail="Failed to clear drafts")
124
+ return {"success": True, "cleared": count}
125
+
126
+
127
+ @router.delete("/api/drafts/{draft_id}")
128
+ async def delete_draft(draft_id: str):
129
+ """Delete a draft (explicit delete, or consumed on send)."""
130
+ data = load_drafts()
131
+ drafts = data.get("drafts", [])
132
+ remaining = [d for d in drafts if d.get("id") != draft_id]
133
+ if len(remaining) == len(drafts):
134
+ raise HTTPException(status_code=404, detail="Draft not found")
135
+
136
+ data["drafts"] = remaining
137
+ if not save_drafts(data):
138
+ raise HTTPException(status_code=500, detail="Failed to save drafts")
139
+ return {"success": True}