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,149 @@
1
+ """
2
+ Exec API Route - Shell command execution endpoint
3
+
4
+ Provides a POST endpoint for executing shell commands
5
+ with output capture and timeout handling.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ import signal
12
+ import sys
13
+
14
+ from fastapi import APIRouter, HTTPException
15
+ from pydantic import BaseModel
16
+ from painapple_code.utils.file_paths import safe_resolve
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter(tags=["exec"])
21
+
22
+ # Combined stdout+stderr ceiling. `communicate()` buffers without limit, so a
23
+ # `cat` of a huge file or a runaway `yes` grows the server's heap until the OOM
24
+ # killer settles it. Generous enough that no realistic bang-command output is
25
+ # clipped; the truncation is reported, never silent.
26
+ MAX_OUTPUT_BYTES = 1 * 1024 * 1024
27
+
28
+
29
+ def _kill_tree(process) -> None:
30
+ """Kill the command AND anything it spawned.
31
+
32
+ `process.kill()` signals only the shell. A pipeline or a backgrounded child
33
+ outlives it and keeps holding the pipes, so the timeout fires and the
34
+ orphans keep running. POSIX commands are spawned with `start_new_session`
35
+ so the whole group can be signalled at once; Windows has no equivalent here
36
+ and gets the single-process kill.
37
+ """
38
+ try:
39
+ if sys.platform != "win32":
40
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
41
+ else:
42
+ process.kill()
43
+ except (ProcessLookupError, PermissionError):
44
+ pass # already gone
45
+
46
+
47
+ async def _read_capped(stream, limit: int, on_overflow) -> tuple[bytes, bool]:
48
+ """Read a pipe, keeping at most `limit` bytes.
49
+
50
+ Keeps draining past the cap rather than just stopping: an unread pipe fills
51
+ up and the child blocks forever on write, converting an over-limit command
52
+ into a hang. `on_overflow` kills the process group the first time the cap is
53
+ passed, which is what actually ends the read.
54
+ """
55
+ buf = bytearray()
56
+ truncated = False
57
+ while True:
58
+ chunk = await stream.read(65536)
59
+ if not chunk:
60
+ return bytes(buf), truncated
61
+ if not truncated:
62
+ buf.extend(chunk[: limit - len(buf)])
63
+ if len(buf) >= limit:
64
+ truncated = True
65
+ on_overflow()
66
+
67
+
68
+ class ExecRequest(BaseModel):
69
+ command: str
70
+ cwd: str = "."
71
+
72
+
73
+ @router.post("/api/exec")
74
+ async def execute_command(req: ExecRequest):
75
+ """Execute a shell command directly and return the output.
76
+
77
+ Inputs ride in a JSON body (not query params): this forces a
78
+ non-simple cross-origin request (CORS-preflighted), so a hostile
79
+ page can't fire ``POST /api/exec?command=...`` with the user's
80
+ ambient cookie, and the command never lands in an access-log query
81
+ string.
82
+ """
83
+ command = req.command
84
+ cwd = req.cwd
85
+ try:
86
+ p = safe_resolve(cwd)
87
+ if not p.exists() or not p.is_dir():
88
+ raise HTTPException(status_code=400, detail="Invalid working directory")
89
+
90
+ if sys.platform == "win32":
91
+ # create_subprocess_shell would use COMSPEC (cmd.exe), whose
92
+ # dialect shares almost nothing with the POSIX-shaped commands
93
+ # every doc and quick-action here assumes. PowerShell is both
94
+ # closer and what native Claude Code itself defaults to on
95
+ # Windows. Note for docs: bang commands are PowerShell-flavored
96
+ # on Windows.
97
+ process = await asyncio.create_subprocess_exec(
98
+ "powershell.exe", "-NoProfile", "-NonInteractive",
99
+ "-Command", command,
100
+ stdout=asyncio.subprocess.PIPE,
101
+ stderr=asyncio.subprocess.PIPE,
102
+ cwd=str(p),
103
+ )
104
+ else:
105
+ process = await asyncio.create_subprocess_shell(
106
+ command,
107
+ stdout=asyncio.subprocess.PIPE,
108
+ stderr=asyncio.subprocess.PIPE,
109
+ cwd=str(p),
110
+ start_new_session=True, # own process group, so _kill_tree gets the children too
111
+ )
112
+
113
+ killed = False
114
+
115
+ def _overflow():
116
+ nonlocal killed
117
+ if not killed:
118
+ killed = True
119
+ logger.warning(f"exec output exceeded {MAX_OUTPUT_BYTES} bytes, killing: {command[:80]}")
120
+ _kill_tree(process)
121
+
122
+ try:
123
+ (stdout, out_trunc), (stderr, err_trunc) = await asyncio.wait_for(
124
+ asyncio.gather(
125
+ _read_capped(process.stdout, MAX_OUTPUT_BYTES, _overflow),
126
+ _read_capped(process.stderr, MAX_OUTPUT_BYTES, _overflow),
127
+ ),
128
+ timeout=30.0,
129
+ )
130
+ await process.wait()
131
+ except asyncio.TimeoutError:
132
+ _kill_tree(process)
133
+ raise HTTPException(status_code=408, detail="Command timed out after 30 seconds")
134
+
135
+ return {
136
+ "command": command,
137
+ "cwd": str(p),
138
+ "exit_code": process.returncode,
139
+ "stdout": stdout.decode('utf-8', errors='replace'),
140
+ "stderr": stderr.decode('utf-8', errors='replace'),
141
+ # Reported, not silent: a clipped result must not read as a complete
142
+ # one. Callers that ignore this see less output, never wrong output.
143
+ "truncated": out_trunc or err_trunc,
144
+ "output_limit": MAX_OUTPUT_BYTES,
145
+ }
146
+ except HTTPException:
147
+ raise
148
+ except Exception as e:
149
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,542 @@
1
+ """
2
+ Files API Routes - File operations for the file browser
3
+
4
+ These endpoints provide file listing, reading, writing, and search
5
+ functionality for the web client's file browser and autocomplete.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from fastapi import APIRouter, HTTPException, Query, Request
16
+ from pydantic import BaseModel
17
+
18
+ from painapple_code import paths
19
+ from painapple_code.utils.file_paths import (
20
+ PATH_DENIED_DETAIL,
21
+ is_path_allowed,
22
+ is_path_allowed_for_read,
23
+ resolve_project_dir,
24
+ resolve_project_files,
25
+ safe_resolve,
26
+ verify_file_paths,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ router = APIRouter(tags=["files"])
32
+
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════
35
+ # File Listing
36
+ # ═══════════════════════════════════════════════════════════════════════
37
+
38
+ @router.get("/api/files")
39
+ async def list_files(path: str = "."):
40
+ """List files in a directory (for file browser)."""
41
+ try:
42
+ p = safe_resolve(path)
43
+ if not is_path_allowed_for_read(p):
44
+ raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
45
+ if not p.exists():
46
+ raise HTTPException(status_code=404, detail="Path not found")
47
+ if not p.is_dir():
48
+ raise HTTPException(status_code=400, detail="Not a directory")
49
+
50
+ files = []
51
+ for item in sorted(p.iterdir()):
52
+ try:
53
+ st = item.stat()
54
+ mtime = st.st_mtime
55
+ size = st.st_size if item.is_file() else None
56
+ except (OSError, PermissionError):
57
+ mtime = 0
58
+ size = None
59
+ files.append({
60
+ "name": item.name,
61
+ "path": str(item),
62
+ "is_dir": item.is_dir(),
63
+ "size": size,
64
+ "mtime": mtime,
65
+ })
66
+
67
+ return {"path": str(p), "files": files}
68
+ except PermissionError:
69
+ raise HTTPException(status_code=403, detail="Permission denied")
70
+
71
+
72
+ # Mirrors the fd --exclude list below (and the old find -not -path set).
73
+ _WALK_SKIP_DIRS = {'node_modules', '.git', '__pycache__', 'venv', '.venv',
74
+ 'dist', 'build', '.next', 'coverage'}
75
+ _WALK_SKIP_NAMES = {'.DS_Store'}
76
+ _WALK_MAX_FILES = 50000 # same order as fd's practical ceiling; keeps a
77
+ # runaway tree from pinning a worker thread
78
+ _WALK_TIMEOUT = 15.0 # matches the `find` budget this path replaced
79
+ # (5s spawn + 10s communicate)
80
+
81
+
82
+ def _walk_files(directory: Path, deadline: float | None = None) -> tuple[list[str], bool]:
83
+ """Relative file paths under `directory`, POSIX-separated.
84
+
85
+ Returns (paths, truncated) — truncated is True when the file cap or the
86
+ deadline cut the walk short, so the caller can tell the client its list
87
+ is partial instead of silently serving a prefix of the project.
88
+
89
+ Runs in a worker thread (os.walk is blocking). Separators are
90
+ normalized so the client gets the same shape fd produces, and so
91
+ Windows results don't arrive with backslashes the @-autocomplete
92
+ can't match.
93
+
94
+ `deadline` (a time.monotonic value) is checked once per directory. It
95
+ bounds the WORKER; the caller separately bounds the REQUEST, because a
96
+ single scandir stuck on a dead NFS/sshfs mount is uninterruptible and
97
+ no in-loop check can preempt it.
98
+ """
99
+ out = []
100
+ root = str(directory)
101
+ for dirpath, dirnames, filenames in os.walk(root):
102
+ if deadline is not None and time.monotonic() > deadline:
103
+ return out, True
104
+ dirnames[:] = [d for d in dirnames if d not in _WALK_SKIP_DIRS]
105
+ # Once per directory, not once per file: os.walk already knows
106
+ # dirpath, and relpath abspath-normalizes and splits BOTH arguments
107
+ # on every call — 50k of them at the cap, for a prefix that changes
108
+ # only when the directory does.
109
+ rel_dir = os.path.relpath(dirpath, root)
110
+ prefix = '' if rel_dir == os.curdir else rel_dir.replace(os.sep, '/') + '/'
111
+ for name in filenames:
112
+ if name in _WALK_SKIP_NAMES or name.endswith('.pyc'):
113
+ continue
114
+ out.append(prefix + name)
115
+ if len(out) >= _WALK_MAX_FILES:
116
+ return out, True
117
+ return out, False
118
+
119
+
120
+ async def _enumerate_files(directory: Path, include_ignored: bool = False) -> tuple[list[str], bool]:
121
+ """
122
+ Enumerate files in a directory using fd (falls back to a Python walk).
123
+ Returns (relative paths within the directory, truncated).
124
+
125
+ When `include_ignored` is True, .gitignore'd and hidden files are included
126
+ (fd's --no-ignore --hidden). The hardcoded `--exclude` list still applies
127
+ so node_modules/.git etc. are never returned regardless.
128
+ """
129
+ # Try fd first (respects .gitignore by default)
130
+ try:
131
+ fd_args = [
132
+ 'fd', '--type', 'f', '--relative-path',
133
+ '--exclude', 'node_modules',
134
+ '--exclude', '.git',
135
+ '--exclude', '__pycache__',
136
+ '--exclude', 'venv',
137
+ '--exclude', '.venv',
138
+ '--exclude', '*.pyc',
139
+ '--exclude', '.DS_Store',
140
+ '--exclude', 'dist',
141
+ '--exclude', 'build',
142
+ '--exclude', '.next',
143
+ '--exclude', 'coverage',
144
+ ]
145
+ if include_ignored:
146
+ fd_args += ['--no-ignore', '--hidden']
147
+ result = await asyncio.wait_for(
148
+ asyncio.create_subprocess_exec(
149
+ *fd_args,
150
+ cwd=str(directory),
151
+ stdout=asyncio.subprocess.PIPE,
152
+ stderr=asyncio.subprocess.PIPE
153
+ ),
154
+ timeout=5.0
155
+ )
156
+ stdout, stderr = await asyncio.wait_for(result.communicate(), timeout=10.0)
157
+
158
+ if result.returncode == 0:
159
+ # `.replace(os.sep, '/')`, matching _walk_files: fd emits
160
+ # OS-native separators, so on Windows this branch returned
161
+ # `src\app.js` while the fallback returned `src/app.js` — one
162
+ # endpoint with two shapes depending on whether fd happened to
163
+ # be installed. The client scores the user's typed `src/app`
164
+ # against that string and checks `changedSet.has(path)` against
165
+ # shadow-git paths that are always forward-slashed, so both
166
+ # silently missed on the fd machine. Keyed off os.sep rather
167
+ # than replacing '\\' unconditionally, because on POSIX a
168
+ # backslash is an ordinary filename byte.
169
+ return [f.replace(os.sep, '/') for f in stdout.decode().strip().split('\n') if f], False
170
+ raise FileNotFoundError("fd failed")
171
+
172
+ except (FileNotFoundError, asyncio.TimeoutError):
173
+ # Fallback: pure-Python walk. This used to shell out to `find`,
174
+ # which on Windows resolves to find.exe — a COMPLETELY different
175
+ # tool (it greps for a string in files). It doesn't error on this
176
+ # argv, it just prints something else, so the @-autocomplete file
177
+ # list would have been silently wrong rather than empty. os.walk
178
+ # also drops the last non-Python dependency of this endpoint.
179
+ #
180
+ # Like `find`, this doesn't read .gitignore, so include_ignored
181
+ # stays a no-op on the fallback path.
182
+ #
183
+ # Bounded on both sides. The `find` path it replaced had two
184
+ # timeouts (5s spawn + 10s communicate) and the executor handoff
185
+ # had none, so one directory on a hung autofs/NFS/sshfs mount held
186
+ # a shared ThreadPoolExecutor worker forever and the request never
187
+ # returned. wait_for can't kill the thread — nothing can — but it
188
+ # returns the request, and the in-walk deadline lets the thread
189
+ # retire as soon as it comes back from the stuck syscall.
190
+ loop = asyncio.get_running_loop()
191
+ deadline = time.monotonic() + _WALK_TIMEOUT
192
+ return await asyncio.wait_for(
193
+ loop.run_in_executor(None, _walk_files, directory, deadline),
194
+ timeout=_WALK_TIMEOUT + 1.0,
195
+ )
196
+
197
+
198
+ @router.get("/api/files/list")
199
+ async def list_project_files(cwd: str, refresh: bool = False, include_ignored: bool = False):
200
+ """
201
+ List all files in a project for @ autocomplete.
202
+ Uses 'fd' command for fast enumeration, falls back to find.
203
+ Also includes files from extra_dirs in project config.
204
+
205
+ `include_ignored=true` returns .gitignore'd and hidden files as well
206
+ (used by the file explorer's "Include ignored" search toggle).
207
+ """
208
+ try:
209
+ p = safe_resolve(cwd)
210
+ if not p.exists() or not p.is_dir():
211
+ raise HTTPException(status_code=404, detail="Directory not found")
212
+
213
+ # Enumerate project files (relative paths)
214
+ files, walk_truncated = await _enumerate_files(p, include_ignored=include_ignored)
215
+
216
+ # Load extra_dirs: merge global (all projects) + project-specific
217
+ try:
218
+ global_config = paths.load_global_config()
219
+ global_extra = global_config.get("extra_dirs", [])
220
+ except Exception as e:
221
+ logger.warning(f"Failed to load global config for extra_dirs: {e}")
222
+ global_extra = []
223
+ try:
224
+ project_config_path = paths.get_project_config_path(str(p))
225
+ if project_config_path.exists():
226
+ project_config = json.loads(project_config_path.read_text(encoding="utf-8"))
227
+ project_extra = project_config.get("extra_dirs", [])
228
+ else:
229
+ project_extra = []
230
+ except Exception as e:
231
+ logger.warning(f"Failed to load project config for extra_dirs: {e}")
232
+ project_extra = []
233
+ # Combine both, deduplicate, preserve order
234
+ seen = set()
235
+ extra_dirs = []
236
+ for d in global_extra + project_extra:
237
+ resolved = str(safe_resolve(d))
238
+ if resolved not in seen:
239
+ seen.add(resolved)
240
+ extra_dirs.append(d)
241
+
242
+ # Enumerate files from each extra directory (absolute paths)
243
+ for extra_dir in extra_dirs:
244
+ extra_path = safe_resolve(extra_dir)
245
+ if not extra_path.is_dir():
246
+ logger.warning(f"Extra dir not found: {extra_dir}")
247
+ continue
248
+ try:
249
+ extra_files, extra_truncated = await _enumerate_files(
250
+ extra_path, include_ignored=include_ignored
251
+ )
252
+ walk_truncated = walk_truncated or extra_truncated
253
+ for f in extra_files:
254
+ files.append(str(extra_path / f))
255
+ except Exception as e:
256
+ logger.warning(f"Failed to enumerate extra dir {extra_dir}: {e}")
257
+
258
+ # Also get directories (for drilling down)
259
+ dirs = []
260
+ try:
261
+ for item in p.iterdir():
262
+ if item.is_dir() and not item.name.startswith('.') and item.name not in (
263
+ 'node_modules', '__pycache__', 'venv', '.venv', 'dist', 'build', '.next', 'coverage'
264
+ ):
265
+ dirs.append(item.name + '/')
266
+ except PermissionError:
267
+ pass
268
+
269
+ if walk_truncated:
270
+ logger.warning(
271
+ f"File enumeration hit its cap under {p} — returning a partial list"
272
+ )
273
+
274
+ return {
275
+ "cwd": str(p),
276
+ "files": sorted(files)[:20000], # 20k files ≈ 1MB JSON
277
+ "directories": sorted(dirs),
278
+ "total": len(files),
279
+ # Either cap counts: the 20k response budget, or the walk's own
280
+ # 50k/15s ceiling. The latter used to cut the list off with no
281
+ # signal at all, so the client rendered a partial project as a
282
+ # complete one and an @-mention that found nothing looked like
283
+ # a missing file.
284
+ "truncated": len(files) > 20000 or walk_truncated,
285
+ }
286
+
287
+ except PermissionError:
288
+ raise HTTPException(status_code=403, detail="Permission denied")
289
+ except asyncio.TimeoutError:
290
+ raise HTTPException(status_code=504, detail="File listing timed out")
291
+ except Exception as e:
292
+ logger.error(f"Error listing files: {e}")
293
+ raise HTTPException(status_code=500, detail=str(e))
294
+
295
+
296
+ # ═══════════════════════════════════════════════════════════════════════
297
+ # File Read/Write
298
+ # ═══════════════════════════════════════════════════════════════════════
299
+
300
+ @router.get("/api/file")
301
+ async def read_file(path: str):
302
+ """Read a file's contents (for file viewer)."""
303
+ try:
304
+ p = safe_resolve(path)
305
+ if not is_path_allowed_for_read(p):
306
+ raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
307
+ if not p.exists():
308
+ raise HTTPException(status_code=404, detail="File not found")
309
+ if not p.is_file():
310
+ raise HTTPException(status_code=400, detail="Not a file")
311
+ if p.stat().st_size > 1_000_000: # 1MB limit
312
+ raise HTTPException(status_code=400, detail="File too large")
313
+
314
+ return {"path": str(p), "content": p.read_text(encoding="utf-8", errors='replace'), "mtime": p.stat().st_mtime}
315
+ except PermissionError:
316
+ raise HTTPException(status_code=403, detail="Permission denied")
317
+
318
+
319
+ @router.get("/api/file/stat")
320
+ async def file_stat(path: str):
321
+ """Lightweight file stat (mtime + size) without reading content."""
322
+ try:
323
+ p = safe_resolve(path)
324
+ if not is_path_allowed_for_read(p):
325
+ raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
326
+ if not p.exists():
327
+ return {"path": str(p), "exists": False, "mtime": 0, "size": 0}
328
+ st = p.stat()
329
+ return {"path": str(p), "exists": True, "mtime": st.st_mtime, "size": st.st_size}
330
+ except PermissionError:
331
+ raise HTTPException(status_code=403, detail="Permission denied")
332
+
333
+
334
+ class WriteFileRequest(BaseModel):
335
+ path: str
336
+ content: str
337
+
338
+
339
+ # How much of an existing file to sniff for its line-ending style. A file's
340
+ # convention is established in its first few hundred lines or not at all.
341
+ _EOL_SNIFF_BYTES = 65536
342
+
343
+
344
+ def _file_is_crlf(p: Path) -> bool:
345
+ """Is this existing file written entirely with CRLF line endings?
346
+
347
+ True only when the sniffed prefix has at least one `\\r\\n` and no bare
348
+ `\\n` at all. Deliberately strict: see `write_file`.
349
+ """
350
+ try:
351
+ with open(p, "rb") as fh:
352
+ head = fh.read(_EOL_SNIFF_BYTES)
353
+ except OSError:
354
+ return False
355
+ # A cut exactly between CR and LF would otherwise read as a bare CR
356
+ # followed (in the next chunk we never see) by a bare LF.
357
+ if head.endswith(b"\r"):
358
+ head = head[:-1]
359
+ lf = head.count(b"\n")
360
+ return lf > 0 and head.count(b"\r\n") == lf
361
+
362
+
363
+ @router.post("/api/file/write")
364
+ async def write_file(request: WriteFileRequest):
365
+ """Write content to a file (for scratch Save As, and every editor save).
366
+
367
+ Line endings: GET /api/file reads with universal newlines, which turns
368
+ a CRLF file into `\\n` before the client ever sees it, and this write
369
+ used `newline=""`, which suppresses the translation that would put them
370
+ back. So on a Windows server every save silently rewrote a CRLF file to
371
+ LF — and with `core.autocrlf=true`, git then reported the whole file as
372
+ modified after a one-character edit.
373
+
374
+ `newline=""` is still right (the content must go out byte-for-byte, and
375
+ on a Linux server editing a checked-out CRLF file the platform default
376
+ wouldn't help anyway); what was missing is that the endpoint has to
377
+ supply the endings itself. It takes them from the file already on disk,
378
+ which is the only place the information survives — the client's editor
379
+ normalizes to `\\n` in its document model regardless of what we send it.
380
+
381
+ A file that is *purely* CRLF gets CRLF back. Everything else — LF,
382
+ genuinely mixed, lone-CR, and any new file — is written verbatim. Mixed
383
+ is the interesting case and it is left alone on purpose: there is no
384
+ ending that is "the file's", so either choice rewrites lines the user
385
+ never touched, and silently normalizing a mixed file is a bigger
386
+ surprise (a whole-file diff) than leaving it as the editor produced it.
387
+ """
388
+ try:
389
+ p = safe_resolve(request.path)
390
+ if not is_path_allowed(p):
391
+ raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
392
+
393
+ content = request.content
394
+ if p.is_file() and _file_is_crlf(p):
395
+ # Normalize first so a client that already sent CRLF doesn't
396
+ # come out with CRCRLF.
397
+ content = content.replace("\r\n", "\n").replace("\n", "\r\n")
398
+
399
+ # Create parent directories if needed
400
+ p.parent.mkdir(parents=True, exist_ok=True)
401
+
402
+ # Write the file
403
+ p.write_text(content, encoding="utf-8", newline="")
404
+
405
+ return {
406
+ "path": str(p),
407
+ "size": p.stat().st_size,
408
+ "mtime": p.stat().st_mtime
409
+ }
410
+ except HTTPException:
411
+ raise
412
+ except PermissionError:
413
+ raise HTTPException(status_code=403, detail="Permission denied")
414
+ except Exception as e:
415
+ raise HTTPException(status_code=500, detail=str(e))
416
+
417
+
418
+ # ═══════════════════════════════════════════════════════════════════════
419
+ # Directory Operations
420
+ # ═══════════════════════════════════════════════════════════════════════
421
+
422
+ @router.get("/api/check-dir")
423
+ async def check_directory(path: str):
424
+ """Check if a directory exists."""
425
+ try:
426
+ p = safe_resolve(path)
427
+ return {
428
+ "path": str(p),
429
+ "exists": p.exists(),
430
+ "is_dir": p.is_dir() if p.exists() else False
431
+ }
432
+ except Exception as e:
433
+ return {"path": path, "exists": False, "is_dir": False, "error": str(e)}
434
+
435
+
436
+ class MkdirRequest(BaseModel):
437
+ path: str
438
+
439
+
440
+ @router.post("/api/mkdir")
441
+ async def create_directory(req: MkdirRequest):
442
+ """Create a directory if it doesn't exist.
443
+
444
+ Path rides in a JSON body (not a query param) so the request is
445
+ CORS-preflighted — a hostile page can't create dirs with the user's
446
+ ambient cookie via a simple cross-origin POST.
447
+ """
448
+ path = req.path
449
+ try:
450
+ p = safe_resolve(path)
451
+ if not is_path_allowed(p):
452
+ raise HTTPException(status_code=403, detail=PATH_DENIED_DETAIL)
453
+ if p.exists():
454
+ if p.is_dir():
455
+ return {"path": str(p), "created": False, "message": "Directory already exists"}
456
+ else:
457
+ raise HTTPException(status_code=400, detail="Path exists but is not a directory")
458
+
459
+ p.mkdir(parents=True, exist_ok=True)
460
+ return {"path": str(p), "created": True, "message": "Directory created"}
461
+ except HTTPException:
462
+ raise
463
+ except PermissionError:
464
+ raise HTTPException(status_code=403, detail="Permission denied")
465
+ except Exception as e:
466
+ raise HTTPException(status_code=500, detail=str(e))
467
+
468
+
469
+ # ═══════════════════════════════════════════════════════════════════════
470
+ # File Search
471
+ # ═══════════════════════════════════════════════════════════════════════
472
+
473
+ @router.get("/api/find-file")
474
+ async def find_file(name: str, cwd: str = None, hint: list[str] = Query(default=None)):
475
+ """
476
+ Find a file by name within the project directory.
477
+
478
+ Resolution is context-aware: repeated `hint` query params carry
479
+ directory candidates from the caller's context (terminal lines around
480
+ the clicked filename, the shell's live cwd) and are tried before any
481
+ heuristic search. The fallback search ranks matches shallowest-first
482
+ (mtime breaks ties) and skips dependency/build trees like node_modules.
483
+
484
+ Args:
485
+ name: Filename to search for (e.g., "git-widget.js")
486
+ cwd: Base directory to search from
487
+ hint: Optional directory hints, most-relevant first
488
+
489
+ Returns:
490
+ - {found: true, path: "/full/path", is_dir: bool} if found
491
+ - {found: false} if not found
492
+ """
493
+ try:
494
+ base_cwd = cwd or str(Path.cwd())
495
+ resolved = resolve_project_files([name], base_cwd, hint or ()).get(name)
496
+ if resolved:
497
+ return {"found": True, "path": resolved, "is_dir": False}
498
+ # Not a file anywhere — maybe it's a directory (e.g. a clicked
499
+ # `docs-ai/readme` in terminal output). Direct tiers only, no walk.
500
+ resolved_dir = resolve_project_dir(name, base_cwd, hint or ())
501
+ if resolved_dir:
502
+ return {"found": True, "path": resolved_dir, "is_dir": True}
503
+ return {"found": False}
504
+
505
+ except Exception as e:
506
+ return {"found": False, "error": str(e)}
507
+
508
+
509
+ @router.post("/api/verify-files")
510
+ async def verify_files(request: Request):
511
+ """
512
+ Batch verify multiple file paths exist.
513
+ Used by frontend to validate file links after rendering.
514
+
515
+ Request body:
516
+ {
517
+ "files": ["git-panel.c", "server.py", "static/js/app.js"],
518
+ "cwd": "/home/user/dev/project"
519
+ }
520
+
521
+ Returns:
522
+ {
523
+ "results": {
524
+ "git-panel.c": null, // Not found
525
+ "server.py": "/full/path/server.py", // Found
526
+ "static/js/app.js": "/full/path/static/js/app.js" // Found
527
+ }
528
+ }
529
+ """
530
+ try:
531
+ body = await request.json()
532
+ files = body.get("files", [])
533
+ cwd = body.get("cwd")
534
+
535
+ if not files:
536
+ return {"results": {}}
537
+
538
+ results = verify_file_paths(set(files), cwd or str(Path.cwd()))
539
+ return {"results": results}
540
+
541
+ except Exception as e:
542
+ return {"error": str(e), "results": {}}