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,293 @@
1
+ """
2
+ Upload API Routes - Image and file upload endpoints
3
+
4
+ These endpoints provide:
5
+ - Image upload with automatic resizing for Claude
6
+ - File upload to session uploads directory
7
+ """
8
+
9
+ import base64
10
+ import io
11
+ import logging
12
+ import secrets
13
+ import time
14
+ import warnings
15
+ from pathlib import Path, PurePosixPath
16
+
17
+ from fastapi import APIRouter, HTTPException, UploadFile, File
18
+ from PIL import Image
19
+
20
+ from painapple_code.session_store import SessionStore
21
+ from painapple_code.paths import DATA_HOME
22
+ from painapple_code.utils.file_paths import is_reserved_dos_name
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ router = APIRouter(tags=["upload"])
27
+
28
+ # Image processing constants
29
+ IMAGE_TYPES = {
30
+ 'image/png': 'png',
31
+ 'image/jpeg': 'jpeg',
32
+ 'image/gif': 'gif',
33
+ 'image/webp': 'webp',
34
+ }
35
+
36
+ MAX_IMAGE_SIZE = 20 * 1024 * 1024 # 20MB upload limit
37
+ MAX_IMAGE_DIMENSION = 1568 # Claude's recommended max
38
+ TARGET_FILE_SIZE = 1 * 1024 * 1024 # Target ~1MB after processing
39
+
40
+ # Decompression-bomb ceiling, in PIXELS — the byte cap above cannot bound this.
41
+ # PNG/WebP compress uniform data extremely well, so a ~100KB file can declare
42
+ # 30000x30000 and decode to gigabytes of RGBA. Pillow's default (~89M px) only
43
+ # *warns* and keeps decoding, and it raises only past 2x that, so relying on the
44
+ # default means the allocation happens first. 50M px (~200MB RGBA) is far above
45
+ # any real screenshot or photo and well under what hurts.
46
+ Image.MAX_IMAGE_PIXELS = 50_000_000
47
+
48
+ # Promote Pillow's DecompressionBombWarning to an exception so the guard is a
49
+ # refusal rather than a log line someone reads afterwards. The upload path
50
+ # already converts exceptions into a 400.
51
+ warnings.simplefilter('error', Image.DecompressionBombWarning)
52
+
53
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB limit for file uploads
54
+
55
+
56
+ def resize_image_for_claude(image_data: bytes, content_type: str) -> tuple[bytes, str, dict]:
57
+ """Resize image if needed for optimal Claude processing."""
58
+ img = Image.open(io.BytesIO(image_data))
59
+ original_size = len(image_data)
60
+ original_dimensions = img.size
61
+
62
+ if img.mode == 'RGBA':
63
+ output_format = 'PNG'
64
+ output_media_type = 'image/png'
65
+ else:
66
+ if img.mode != 'RGB':
67
+ img = img.convert('RGB')
68
+ output_format = 'JPEG'
69
+ output_media_type = 'image/jpeg'
70
+
71
+ width, height = img.size
72
+ if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
73
+ ratio = min(MAX_IMAGE_DIMENSION / width, MAX_IMAGE_DIMENSION / height)
74
+ new_size = (int(width * ratio), int(height * ratio))
75
+ img = img.resize(new_size, Image.Resampling.LANCZOS)
76
+ logger.info(f"Resized image from {original_dimensions} to {new_size}")
77
+
78
+ output = io.BytesIO()
79
+ if output_format == 'JPEG':
80
+ img.save(output, format=output_format, quality=85, optimize=True)
81
+ else:
82
+ img.save(output, format=output_format, optimize=True)
83
+
84
+ processed_data = output.getvalue()
85
+
86
+ stats = {
87
+ "original_size": original_size,
88
+ "processed_size": len(processed_data),
89
+ "original_dimensions": original_dimensions,
90
+ "final_dimensions": img.size,
91
+ "compression_ratio": round(original_size / len(processed_data), 2) if len(processed_data) > 0 else 1
92
+ }
93
+
94
+ return processed_data, output_media_type, stats
95
+
96
+
97
+ @router.post("/api/upload-image")
98
+ async def upload_image(file: UploadFile = File(...), session: str = None):
99
+ """Upload an image and return it as base64 for sending to Claude.
100
+ Also saves the processed image to disk for persistence across page refresh."""
101
+ content_type = file.content_type
102
+ if content_type not in IMAGE_TYPES:
103
+ raise HTTPException(
104
+ status_code=400,
105
+ detail=f"Unsupported image type: {content_type}. Supported: {list(IMAGE_TYPES.keys())}"
106
+ )
107
+
108
+ content = await file.read()
109
+
110
+ if len(content) > MAX_IMAGE_SIZE:
111
+ raise HTTPException(
112
+ status_code=400,
113
+ detail=f"Image too large: {len(content)} bytes. Max: {MAX_IMAGE_SIZE} bytes"
114
+ )
115
+
116
+ try:
117
+ processed_data, media_type, stats = resize_image_for_claude(content, content_type)
118
+ except Exception as e:
119
+ logger.error(f"Image processing failed: {e}")
120
+ raise HTTPException(status_code=400, detail=f"Failed to process image: {str(e)}")
121
+
122
+ b64_data = base64.standard_b64encode(processed_data).decode('utf-8')
123
+
124
+ # Save processed image to disk for persistence across page refresh
125
+ ext = 'png' if media_type == 'image/png' else 'jpg'
126
+ stored_name = f"img_{int(time.time())}_{secrets.token_hex(4)}.{ext}"
127
+
128
+ if session and SessionStore.exists(session):
129
+ uploads_dir = SessionStore.get_uploads_path(session)
130
+ else:
131
+ uploads_dir = DATA_HOME / "uploads" / "tmp"
132
+ uploads_dir.mkdir(parents=True, exist_ok=True)
133
+
134
+ try:
135
+ (uploads_dir / stored_name).write_bytes(processed_data)
136
+ except Exception as e:
137
+ logger.warning(f"Failed to persist uploaded image to disk: {e}")
138
+ # Non-fatal — image still works in-memory, just won't survive refresh
139
+ stored_name = None
140
+
141
+ logger.info(f"Uploaded image: {file.filename}, "
142
+ f"original={stats['original_size']/1024:.1f}KB {stats['original_dimensions']}, "
143
+ f"processed={stats['processed_size']/1024:.1f}KB {stats['final_dimensions']}, "
144
+ f"compression={stats['compression_ratio']}x"
145
+ f"{f', stored={stored_name}' if stored_name else ''}")
146
+
147
+ return {
148
+ "success": True,
149
+ "filename": file.filename,
150
+ "stored_name": stored_name,
151
+ "media_type": media_type,
152
+ "original_size": stats["original_size"],
153
+ "processed_size": stats["processed_size"],
154
+ "dimensions": stats["final_dimensions"],
155
+ "compression_ratio": stats["compression_ratio"],
156
+ "image": {
157
+ "type": "image",
158
+ "source": {
159
+ "type": "base64",
160
+ "media_type": media_type,
161
+ "data": b64_data
162
+ }
163
+ }
164
+ }
165
+
166
+
167
+ # Longest filename we hand to the filesystem. NAME_MAX is 255 on ext4 and
168
+ # NTFS; 200 leaves room for the "-2", "-3" de-duplication suffixes callers
169
+ # append.
170
+ _MAX_NAME_LEN = 200
171
+
172
+
173
+ def _truncate_name(name: str) -> str:
174
+ """Clamp to _MAX_NAME_LEN, keeping the extension when one can fit.
175
+
176
+ PurePosixPath, not Path, for the same reason as the basename split
177
+ below: nothing here should vary with the host OS. (No separators or
178
+ colons survive to this point, so the two flavors agree — but pinning
179
+ it keeps that true if the rules above ever change.)
180
+
181
+ The old form was `name[:200 - len(ext)] + ext`, which for any suffix
182
+ longer than 200 made the slice bound negative — `name[:-51]` chops the
183
+ WRONG end and still returns an over-limit name (a 256-char input came
184
+ back 251 chars). A suffix that alone busts the budget isn't an
185
+ extension in any useful sense, so it gets clamped like any other text.
186
+ """
187
+ if len(name) <= _MAX_NAME_LEN:
188
+ return name
189
+ ext = PurePosixPath(name).suffix
190
+ if len(ext) > _MAX_NAME_LEN:
191
+ return name[:_MAX_NAME_LEN]
192
+ return PurePosixPath(name).stem[:_MAX_NAME_LEN - len(ext)] + ext
193
+
194
+
195
+ def sanitize_filename(filename: str) -> str:
196
+ """Sanitize a filename to prevent path traversal and other issues."""
197
+ if not filename:
198
+ raise ValueError("Empty filename")
199
+
200
+ # Basename under BOTH path flavors, not whichever one the host happens to
201
+ # be. `Path(filename).name` was platform-dependent in a way that changed
202
+ # the result: WindowsPath("a:b.txt").name is "b.txt" — it reads "a:" as a
203
+ # drive — while PosixPath's is "a:b.txt". So the same upload landed under
204
+ # a different name depending on the server's OS, and the colon rule below
205
+ # only ran on one of them. Splitting on both separators here makes every
206
+ # platform agree (and "a:b.txt" reaches the ':' rule everywhere).
207
+ filename = filename.replace('\\', '/').rsplit('/', 1)[-1]
208
+ filename = "".join(c for c in filename if c.isprintable() and c not in '\x00')
209
+
210
+ # NTFS forbids these outright; without stripping them the write fails
211
+ # with an opaque OSError instead of a clean rejection.
212
+ filename = "".join('_' if c in ':*?"<>|' else c for c in filename)
213
+
214
+ # NTFS silently strips trailing dots and spaces, so "report." and
215
+ # "report" become the same file — a quiet overwrite of someone else's
216
+ # upload, and a way to smuggle a second name past a uniqueness check.
217
+ filename = filename.rstrip(". ")
218
+
219
+ # Length first, reserved-device check AFTER it. The other order is what
220
+ # the comment here used to claim was safe ("before length truncation so
221
+ # that truncating can't create one") and it was exactly backwards:
222
+ # truncation is the step that CAN create one. `con` + 10 filler chars +
223
+ # a 197-char extension passed the guard on stem "conxxxxxxxxxx", then
224
+ # truncated to "con.yyy…" — a name Windows resolves to the CON console
225
+ # device, so the upload's write_bytes went to the console.
226
+ filename = _truncate_name(filename)
227
+ filename = filename.rstrip(". ")
228
+
229
+ # An alternate-data-stream suffix is gone with ':' above; device names
230
+ # need their own check. Shared with the read-path screen in
231
+ # utils.file_paths so the two can't disagree about what a device is —
232
+ # this file's private copy had already drifted, accepting "nul .txt"
233
+ # (Windows strips the trailing space and opens the device) that
234
+ # is_path_allowed_for_read denies.
235
+ #
236
+ # Applied on EVERY platform, not just win32: uploads are shared (synced
237
+ # dirs, a repo later cloned on Windows), and a Linux-hosted server
238
+ # shouldn't be able to mint a file its Windows users can't open.
239
+ if is_reserved_dos_name(filename):
240
+ # The prefix can push a just-at-limit name one over, so re-clamp.
241
+ # This can't loop: every truncation of "_…" still starts with "_",
242
+ # which is not a device name.
243
+ filename = _truncate_name("_" + filename).rstrip(". ")
244
+
245
+ if not filename or filename in ('.', '..'):
246
+ raise ValueError("Invalid filename after sanitization")
247
+
248
+ return filename
249
+
250
+
251
+ @router.post("/api/upload-file")
252
+ async def upload_file(file: UploadFile = File(...), session: str = None):
253
+ """Upload a file to the session's uploads directory (or temp dir if no session yet)."""
254
+ content = await file.read()
255
+
256
+ if len(content) > MAX_FILE_SIZE:
257
+ raise HTTPException(
258
+ status_code=400,
259
+ detail=f"File too large: {len(content)} bytes. Max: {MAX_FILE_SIZE} bytes (10MB)"
260
+ )
261
+
262
+ if len(content) == 0:
263
+ raise HTTPException(status_code=400, detail="Empty file")
264
+
265
+ try:
266
+ safe_name = sanitize_filename(file.filename)
267
+ except ValueError as e:
268
+ raise HTTPException(status_code=400, detail=str(e))
269
+
270
+ # Use session uploads dir if available, otherwise a shared temp dir
271
+ if session and SessionStore.exists(session):
272
+ uploads_dir = SessionStore.get_uploads_path(session)
273
+ else:
274
+ uploads_dir = DATA_HOME / "uploads" / "tmp"
275
+ uploads_dir.mkdir(parents=True, exist_ok=True)
276
+
277
+ target_path = uploads_dir / safe_name
278
+
279
+ try:
280
+ target_path.write_bytes(content)
281
+ except Exception as e:
282
+ logger.error(f"Failed to write uploaded file: {e}")
283
+ raise HTTPException(status_code=500, detail="Failed to save file")
284
+
285
+ logger.info(f"Uploaded file: {file.filename} -> {target_path} ({len(content)} bytes)")
286
+
287
+ return {
288
+ "success": True,
289
+ "filename": file.filename,
290
+ "stored_name": safe_name,
291
+ "path": str(target_path.resolve()),
292
+ "size": len(content),
293
+ }
@@ -0,0 +1,229 @@
1
+ """
2
+ File Viewer API Routes - Raw file serving and full-screen file viewer
3
+
4
+ These endpoints provide:
5
+ - Raw file serving with correct MIME types (for images, etc.)
6
+ - Full-screen file viewer page (images, markdown, code, excalidraw, charts)
7
+ - Excalidraw JSON → SVG rendering via Node.js subprocess
8
+ - Vega-Lite chart JSON → SVG rendering via Node.js subprocess
9
+ """
10
+
11
+ import logging
12
+ from typing import NoReturn
13
+
14
+ from fastapi import APIRouter, HTTPException, Request
15
+ from fastapi.responses import HTMLResponse, FileResponse, Response
16
+
17
+ from painapple_code.utils.file_paths import is_path_allowed_for_read, safe_resolve
18
+ from painapple_code.utils.excalidraw import render_excalidraw, render_excalidraw_file, is_excalidraw_file
19
+ from painapple_code.utils.chart import render_chart, render_chart_file, is_chart_file
20
+ from painapple_code.viewer_templates import (
21
+ usage_page, access_denied_page, not_found_page, error_page,
22
+ image_viewer, markdown_viewer, code_viewer, get_language,
23
+ excalidraw_viewer, chart_viewer,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ router = APIRouter(tags=["viewer"])
29
+
30
+
31
+ # MIME types for common file extensions
32
+ MIME_TYPES = {
33
+ '.png': 'image/png',
34
+ '.jpg': 'image/jpeg',
35
+ '.jpeg': 'image/jpeg',
36
+ '.gif': 'image/gif',
37
+ '.webp': 'image/webp',
38
+ '.svg': 'image/svg+xml',
39
+ '.ico': 'image/x-icon',
40
+ '.pdf': 'application/pdf',
41
+ '.md': 'text/markdown',
42
+ '.txt': 'text/plain',
43
+ '.json': 'application/json',
44
+ '.xml': 'application/xml',
45
+ '.py': 'text/x-python',
46
+ '.js': 'text/javascript',
47
+ '.ts': 'text/typescript',
48
+ '.html': 'text/html',
49
+ '.css': 'text/css',
50
+ '.sh': 'text/x-shellscript',
51
+ '.yaml': 'text/yaml',
52
+ '.yml': 'text/yaml',
53
+ '.toml': 'text/toml',
54
+ }
55
+
56
+ IMAGE_SUFFIXES = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
57
+
58
+ # SVG is a scriptable format. These endpoints render SVG from model- or
59
+ # repo-authored specs and serve it same-origin, so a top-level navigation to
60
+ # one of them would otherwise run its script in the server origin. Forcing an
61
+ # opaque origin (sandbox with no allow-same-origin) removes that: the document
62
+ # can neither read the auth cookie nor issue credentialed same-origin calls.
63
+ # Mirrors the policy api_browser.py applies to proxied/local HTML and SVG.
64
+ # Harmless for the normal consumption paths — browsers ignore CSP on image
65
+ # subresources (<img src>), and fetch()+innerHTML never applies it as a policy.
66
+ _SVG_SECURITY_HEADERS = {
67
+ 'Content-Security-Policy': "sandbox allow-scripts allow-forms",
68
+ 'X-Content-Type-Options': 'nosniff',
69
+ }
70
+
71
+
72
+ def _renderers_enabled(request: Request) -> bool:
73
+ """Server-side chart/excalidraw rendering gate. Defaults to False (secure)
74
+ when unset — e.g. under a bare TestClient that never ran server.main()."""
75
+ return bool(getattr(request.app.state, "renderers_enabled", False))
76
+
77
+
78
+ def _reject_renderer_disabled() -> NoReturn:
79
+ """Refuse when rendering is off. Never spawns the Node subprocess, so the
80
+ model-authored-spec SSRF/file-read vector cannot fire. Always raises."""
81
+ raise HTTPException(status_code=503, detail="Server-side rendering is disabled")
82
+
83
+
84
+ @router.get("/api/file-raw")
85
+ async def get_file_raw(request: Request, path: str, dark: bool = False):
86
+ """Serve a raw file with correct MIME type (for images, etc.)."""
87
+ try:
88
+ p = safe_resolve(path)
89
+
90
+ if not is_path_allowed_for_read(p):
91
+ raise HTTPException(status_code=403, detail="Path not allowed")
92
+ if not p.exists():
93
+ raise HTTPException(status_code=404, detail="File not found")
94
+ if not p.is_file():
95
+ raise HTTPException(status_code=400, detail="Not a file")
96
+
97
+ suffix = p.suffix.lower()
98
+
99
+ # Server-side rendering is gated: when disabled, fall through to raw
100
+ # serving so the Node renderer is never invoked (SSRF/file-read guard).
101
+ renderers_on = _renderers_enabled(request)
102
+
103
+ # Excalidraw files: render to SVG on-the-fly (.excalidraw or .excalidraw.md)
104
+ if renderers_on and is_excalidraw_file(p):
105
+ try:
106
+ svg_content = await render_excalidraw_file(p, dark_mode=dark)
107
+ return Response(
108
+ content=svg_content,
109
+ media_type='image/svg+xml',
110
+ headers={'Cache-Control': 'no-cache', **_SVG_SECURITY_HEADERS},
111
+ )
112
+ except Exception as e:
113
+ logger.error("Excalidraw render failed for %s: %s", p, e)
114
+ raise HTTPException(status_code=500, detail=f"Render failed: {e}")
115
+
116
+ # Vega-Lite chart files: render to SVG on-the-fly (.vl.json)
117
+ if renderers_on and is_chart_file(p):
118
+ try:
119
+ svg_content = await render_chart_file(p, dark_mode=dark)
120
+ return Response(
121
+ content=svg_content,
122
+ media_type='image/svg+xml',
123
+ headers={'Cache-Control': 'no-cache', **_SVG_SECURITY_HEADERS},
124
+ )
125
+ except Exception as e:
126
+ logger.error("Chart render failed for %s: %s", p, e)
127
+ raise HTTPException(status_code=500, detail=f"Render failed: {e}")
128
+
129
+ mime_type = MIME_TYPES.get(suffix, 'application/octet-stream')
130
+
131
+ return FileResponse(
132
+ path=str(p),
133
+ media_type=mime_type,
134
+ filename=p.name,
135
+ headers={'Cache-Control': 'no-cache'},
136
+ )
137
+ except HTTPException:
138
+ raise
139
+ except PermissionError:
140
+ raise HTTPException(status_code=403, detail="Permission denied")
141
+ except Exception as e:
142
+ raise HTTPException(status_code=500, detail=str(e))
143
+
144
+
145
+ @router.get("/view", response_class=HTMLResponse)
146
+ async def file_viewer(path: str = None):
147
+ """Full-screen file viewer page (images, markdown, code)."""
148
+ if not path:
149
+ return usage_page()
150
+
151
+ try:
152
+ p = safe_resolve(path)
153
+
154
+ if not is_path_allowed_for_read(p):
155
+ return access_denied_page(path)
156
+
157
+ if not p.exists():
158
+ return not_found_page(path)
159
+
160
+ suffix = p.suffix.lower()
161
+
162
+ # Excalidraw viewer (.excalidraw or .excalidraw.md — must check before .md)
163
+ if is_excalidraw_file(p):
164
+ return excalidraw_viewer(p.name, str(p))
165
+
166
+ # Vega-Lite chart viewer (.vl.json — must check before generic .json)
167
+ if is_chart_file(p):
168
+ return chart_viewer(p.name, str(p))
169
+
170
+ # Image viewer
171
+ if suffix in IMAGE_SUFFIXES:
172
+ return image_viewer(p.name, str(p), str(p))
173
+
174
+ # Markdown viewer
175
+ if suffix == '.md':
176
+ content = p.read_text(encoding="utf-8", errors='replace')
177
+ return markdown_viewer(p.name, str(p), content)
178
+
179
+ # Code/text viewer
180
+ content = p.read_text(encoding="utf-8", errors='replace')
181
+ lang = get_language(suffix)
182
+ return code_viewer(p.name, str(p), content, lang)
183
+
184
+ except Exception as e:
185
+ return error_page(str(e))
186
+
187
+
188
+ @router.post("/api/excalidraw/render")
189
+ async def render_excalidraw_json(request: Request, dark: bool = False):
190
+ """Render excalidraw JSON body to SVG. Used for inline chat rendering."""
191
+ if not _renderers_enabled(request):
192
+ _reject_renderer_disabled()
193
+ try:
194
+ body = await request.body()
195
+ if not body:
196
+ raise HTTPException(status_code=400, detail="Empty body")
197
+ svg_content = await render_excalidraw(body, dark_mode=dark)
198
+ return Response(
199
+ content=svg_content,
200
+ media_type='image/svg+xml',
201
+ headers=_SVG_SECURITY_HEADERS,
202
+ )
203
+ except RuntimeError as e:
204
+ raise HTTPException(status_code=500, detail=str(e))
205
+ except Exception as e:
206
+ logger.error("Excalidraw render failed: %s", e)
207
+ raise HTTPException(status_code=500, detail=str(e))
208
+
209
+
210
+ @router.post("/api/chart/render")
211
+ async def render_chart_json(request: Request, dark: bool = False):
212
+ """Render Vega-Lite JSON body to SVG. Used for inline chat rendering."""
213
+ if not _renderers_enabled(request):
214
+ _reject_renderer_disabled()
215
+ try:
216
+ body = await request.body()
217
+ if not body:
218
+ raise HTTPException(status_code=400, detail="Empty body")
219
+ svg_content = await render_chart(body, dark_mode=dark)
220
+ return Response(
221
+ content=svg_content,
222
+ media_type='image/svg+xml',
223
+ headers=_SVG_SECURITY_HEADERS,
224
+ )
225
+ except RuntimeError as e:
226
+ raise HTTPException(status_code=500, detail=str(e))
227
+ except Exception as e:
228
+ logger.error("Chart render failed: %s", e)
229
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,109 @@
1
+ """Shared FastAPI dependencies for route modules.
2
+
3
+ Pulls repeated "look up session, 404 if missing" boilerplate out of every
4
+ endpoint that takes a {session_id} path parameter.
5
+ """
6
+
7
+ from fastapi import HTTPException
8
+
9
+ from painapple_code import paths
10
+ from painapple_code.session_store import SessionStore
11
+
12
+
13
+ def get_session_store(session_id: str) -> SessionStore:
14
+ """Resolve a {session_id} path param to its SessionStore, or 404."""
15
+ store, _ = SessionStore._find_session(session_id)
16
+ if store is None:
17
+ raise HTTPException(status_code=404, detail="Session not found")
18
+ return store
19
+
20
+
21
+ def effective_default_provider(app):
22
+ """The provider NEW sessions get: the --default-provider server flag wins,
23
+ then the `default_provider` global-config key, then the registry default.
24
+ Mirrors the resolution ws_chat.py uses when creating a session."""
25
+ from painapple_code.providers import get_provider
26
+ agents = getattr(app.state, "agents", None)
27
+ name = (getattr(agents, "default_provider", None)
28
+ or paths.load_global_config().get("default_provider"))
29
+ return get_provider(name)
30
+
31
+
32
+ def provider_enabled_map(app) -> dict:
33
+ """Which engines the picker offers: ``{provider_name: bool}``.
34
+
35
+ Per-engine resolution: `providers_enabled` config override if present,
36
+ else the provider's self-described `default_enabled`. The effective
37
+ default engine is always on — new sessions land on it, so hiding it
38
+ would strand the picker. This gates only UI listing; explicit selection
39
+ (``?provider=``, PUT) and already-bound sessions ignore it.
40
+ """
41
+ from painapple_code.providers import all_providers
42
+ overrides = paths.load_global_config().get("providers_enabled") or {}
43
+ default_name = effective_default_provider(app).name
44
+ return {
45
+ p.name: p.name == default_name or bool(overrides.get(p.name, p.default_enabled))
46
+ for p in all_providers()
47
+ }
48
+
49
+
50
+ def bind_permission_level(stored_level, provider):
51
+ """Permission level to stamp when a session BINDS to `provider` (create
52
+ with an explicit engine, or an in-place switch) — or None to leave the
53
+ meta alone.
54
+
55
+ The effective level (session's stored one, else the app-wide default)
56
+ survives when the new engine speaks it. A cross-vocabulary value (e.g.
57
+ Claude's ``dontAsk`` landing on a Codex session) is re-anchored to the
58
+ engine's own default: launch would otherwise apply a silent back-compat
59
+ mapping the UI can't label, leaving the permission chip showing a mode
60
+ the engine doesn't have. Meta, UI, and launch must agree at bind time.
61
+ """
62
+ from painapple_code.providers import DEFAULT_PROVIDER, get_provider
63
+ if provider is None:
64
+ return None
65
+ effective = stored_level
66
+ if effective is None:
67
+ config = paths.load_global_config()
68
+ effective = (config.get("default_permission_level")
69
+ or get_provider(DEFAULT_PROVIDER).default_permission_mode())
70
+ vocab = {m["value"] for m in provider.permission_modes() if m.get("value")}
71
+ if effective in vocab:
72
+ return None
73
+ return provider.default_permission_mode()
74
+
75
+
76
+ def preferred_model_survives(stored_model, provider) -> bool:
77
+ """Whether a session's stored ``preferred_model`` still means something
78
+ after binding to `provider` (in-place engine switch) — False = clear it.
79
+
80
+ A model pick only makes sense to the engine whose catalog offered it: a
81
+ Claude id pinned on a session switching to Codex would be silently
82
+ dropped at launch (Codex forwards only its own ids) while the model chip
83
+ keeps labeling the stale pick — and the reverse would hand Claude an id
84
+ its API rejects. Clearing resets the session to the new engine's own
85
+ default. An engine with an EMPTY catalog (the app doesn't manage its
86
+ models) keeps the stored value — its launch path decides what to
87
+ forward. Prefix match, same as the UI: engine-reported ids carry date
88
+ suffixes ("claude-opus-4-6-20260105" is catalog id "claude-opus-4-6").
89
+
90
+ Membership is checked against `enabled_models()` — a model the user hid
91
+ in Settings isn't offered anywhere, so it can't survive as a default
92
+ either (the chip would label it "Default" while launch passed the hidden
93
+ id). The emptiness escape stays on the RAW catalog: all-models-hidden
94
+ means "offer nothing" (clear), not "unknown vocabulary" (keep).
95
+ """
96
+ if provider is None or not stored_model:
97
+ return True
98
+ if not any(m.get("id") for m in provider.models()):
99
+ return True
100
+ enabled_ids = [m.get("id") for m in provider.enabled_models() if m.get("id")]
101
+ return any(stored_model.startswith(mid) for mid in enabled_ids)
102
+
103
+
104
+ def provider_is_locked(meta: dict) -> bool:
105
+ """A session's engine is frozen once it has run a turn — the conversation
106
+ lives in that engine's on-disk format (its provider_session_id means
107
+ nothing to another engine), so the provider is only switchable while the
108
+ session is still empty (no turns, no upstream session id)."""
109
+ return bool(meta.get("message_count", 0)) or bool(meta.get("provider_session_id"))