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,231 @@
1
+ """
2
+ Prompt Explorer API Routes - Search and browse user prompts across sessions
3
+
4
+ These endpoints provide access to prompt history, search functionality,
5
+ and prompt favorites management.
6
+ """
7
+
8
+ import logging
9
+
10
+ from fastapi import APIRouter, HTTPException, Request
11
+
12
+ from painapple_code.prompt_explorer import get_prompt_extractor
13
+ from painapple_code import paths
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ router = APIRouter(prefix="/api/prompts", tags=["prompts"])
18
+
19
+
20
+ # ═══════════════════════════════════════════════════════════════════════
21
+ # Prompt Search & List
22
+ # ═══════════════════════════════════════════════════════════════════════
23
+
24
+ @router.get("")
25
+ async def list_prompts(
26
+ q: str = None,
27
+ project: str = None,
28
+ session: str = None,
29
+ since: str = None,
30
+ until: str = None,
31
+ limit: int = 50,
32
+ offset: int = 0,
33
+ ):
34
+ """
35
+ List user prompts with search and filtering.
36
+
37
+ Query params:
38
+ - q: Search query with advanced syntax:
39
+ - word1 word2 → AND (both required)
40
+ - "exact phrase" → Exact phrase match
41
+ - -exclude → Exclude term
42
+ - word1 OR word2 → Either term
43
+ - in:response → Search in Claude's responses
44
+ - project:name → Filter by project name
45
+ - after:YYYY-MM-DD → After date
46
+ - before:YYYY-MM-DD → Before date
47
+ - long: → Long prompts (>500 chars)
48
+ - short: → Short prompts (<100 chars)
49
+ - has:image → Prompts with images
50
+ - today: yesterday: week: → Quick date filters
51
+ - project: Filter by project hash
52
+ - session: Filter by session ID
53
+ - since: Start date (ISO or YYYY-MM-DD)
54
+ - until: End date (ISO or YYYY-MM-DD)
55
+ - limit: Max results (default 50)
56
+ - offset: Skip N results for pagination
57
+ """
58
+ extractor = get_prompt_extractor()
59
+
60
+ prompts, total, parsed_query = extractor.get_all_prompts(
61
+ project_hash=project,
62
+ since=since,
63
+ until=until,
64
+ search_query=q,
65
+ limit=limit,
66
+ offset=offset,
67
+ )
68
+
69
+ # Filter by session if specified
70
+ if session:
71
+ prompts = [p for p in prompts if p.session_id == session]
72
+ total = len(prompts)
73
+
74
+ response = {
75
+ "prompts": [p.to_dict() for p in prompts],
76
+ "total": total,
77
+ "limit": limit,
78
+ "offset": offset,
79
+ "has_more": offset + len(prompts) < total,
80
+ }
81
+
82
+ # Include query parsing info for UI
83
+ if parsed_query:
84
+ response["query"] = {
85
+ "original": parsed_query.original_query,
86
+ "filters": parsed_query.active_filters,
87
+ "search_response": parsed_query.search_response,
88
+ }
89
+ # Extract all terms for highlighting
90
+ highlight_terms = list(parsed_query.required_terms)
91
+ for phrase in parsed_query.exact_phrases:
92
+ highlight_terms.append(phrase)
93
+ for or_group in parsed_query.or_groups:
94
+ highlight_terms.extend(or_group)
95
+ response["query"]["highlight_terms"] = highlight_terms
96
+
97
+ return response
98
+
99
+
100
+ @router.get("/recent")
101
+ async def get_recent_prompts(
102
+ limit: int = 20,
103
+ project: str = None,
104
+ session: str = None,
105
+ ):
106
+ """
107
+ Get most recent prompts (optimized for Ctrl+R quick access).
108
+
109
+ Query params:
110
+ - limit: Max prompts (default 20)
111
+ - project: Filter by project hash
112
+ - session: Filter by specific session only
113
+ """
114
+ extractor = get_prompt_extractor()
115
+
116
+ prompts = extractor.get_recent_prompts(
117
+ limit=limit,
118
+ project_hash=project,
119
+ session_id=session,
120
+ )
121
+
122
+ return {
123
+ "prompts": [p.to_dict() for p in prompts],
124
+ "count": len(prompts),
125
+ }
126
+
127
+
128
+ @router.get("/frequent")
129
+ async def get_frequent_prompts(
130
+ limit: int = 10,
131
+ ):
132
+ """
133
+ Get frequently used prompts (similar prompts grouped).
134
+
135
+ Finds prompts that appear multiple times with slight variations.
136
+ Useful for discovering common patterns.
137
+ """
138
+ extractor = get_prompt_extractor()
139
+ groups = extractor.get_frequent_prompts(limit=limit)
140
+
141
+ return {
142
+ "groups": groups,
143
+ "count": len(groups),
144
+ }
145
+
146
+
147
+ @router.get("/stats")
148
+ async def get_prompt_stats(
149
+ project: str = None,
150
+ ):
151
+ """
152
+ Get statistics about prompts.
153
+
154
+ Returns counts, averages, and time distribution.
155
+ """
156
+ extractor = get_prompt_extractor()
157
+ stats = extractor.get_prompt_stats(project_hash=project)
158
+ return stats
159
+
160
+
161
+ @router.get("/response/{prompt_id:path}")
162
+ async def get_prompt_response(prompt_id: str):
163
+ """
164
+ Get the full Claude response for a prompt.
165
+
166
+ prompt_id is `{session_id}:{line_number}` (URL-encoded colon is fine).
167
+ Returns concatenated text of all assistant messages between this user
168
+ message and the next one.
169
+ """
170
+ extractor = get_prompt_extractor()
171
+ response = extractor.get_full_response(prompt_id)
172
+ if response is None:
173
+ raise HTTPException(status_code=404, detail="Response not found")
174
+ return {"prompt_id": prompt_id, "response": response}
175
+
176
+
177
+ # ═══════════════════════════════════════════════════════════════════════════
178
+ # Prompt Favorites
179
+ # ═══════════════════════════════════════════════════════════════════════════
180
+
181
+ @router.get("/favorites")
182
+ async def get_prompt_favorites():
183
+ """
184
+ Get all favorited prompts.
185
+
186
+ Returns dict of prompt_id -> metadata (content_preview, note, added_at).
187
+ """
188
+ favorites = paths.get_all_prompt_favorites()
189
+ return {"favorites": favorites, "count": len(favorites)}
190
+
191
+
192
+ @router.post("/favorites")
193
+ async def add_prompt_favorite(request: Request):
194
+ """
195
+ Add a prompt to favorites.
196
+
197
+ Body: { "prompt_id": "session:line", "content_preview": "...", "note": "..." }
198
+ """
199
+ body = await request.json()
200
+ prompt_id = body.get("prompt_id")
201
+ content_preview = body.get("content_preview", "")
202
+ note = body.get("note", "")
203
+
204
+ if not prompt_id:
205
+ raise HTTPException(status_code=400, detail="prompt_id required")
206
+
207
+ added = paths.add_prompt_favorite(prompt_id, content_preview, note)
208
+ return {"success": added, "prompt_id": prompt_id}
209
+
210
+
211
+ @router.delete("/favorites/{prompt_id:path}")
212
+ async def remove_prompt_favorite(prompt_id: str):
213
+ """
214
+ Remove a prompt from favorites.
215
+
216
+ Path param prompt_id is URL-encoded (contains colon).
217
+ """
218
+ removed = paths.remove_prompt_favorite(prompt_id)
219
+ if not removed:
220
+ raise HTTPException(status_code=404, detail="Prompt not in favorites")
221
+ return {"success": True, "prompt_id": prompt_id}
222
+
223
+
224
+ @router.get("/favorites/{prompt_id:path}")
225
+ async def check_prompt_favorite(prompt_id: str):
226
+ """
227
+ Check if a specific prompt is favorited.
228
+ """
229
+ is_fav = paths.is_prompt_favorite(prompt_id)
230
+ metadata = paths.get_prompt_favorite(prompt_id) if is_fav else None
231
+ return {"is_favorite": is_fav, "metadata": metadata}
@@ -0,0 +1,53 @@
1
+ """
2
+ Providers API — the engine catalog behind the per-session provider picker.
3
+
4
+ One read endpoint: every registered provider's `describe()` (display name,
5
+ capabilities, availability + fix hint, models, efforts, permission modes)
6
+ plus which engine new sessions currently default to. The registry is the
7
+ single source of truth — a drop-in provider (module `PROVIDERS` export or
8
+ `painapple_code.providers` entry point) appears here automatically; nothing
9
+ is hardcoded per engine.
10
+ """
11
+
12
+ import logging
13
+
14
+ from fastapi import APIRouter, Request
15
+
16
+ from painapple_code.providers import all_providers
17
+ from painapple_code.routes.dependencies import (
18
+ effective_default_provider,
19
+ provider_enabled_map,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ router = APIRouter(tags=["providers"])
25
+
26
+
27
+ @router.get("/api/providers")
28
+ async def list_providers(request: Request):
29
+ """Engine catalog for the picker UI.
30
+
31
+ Returns each provider's full `describe()` dict — the UI greys out
32
+ unavailable engines (showing `unavailable_reason` as the fix hint) and
33
+ uses `capabilities` to gate per-engine chrome (model chip, fork/Discuss,
34
+ cost display, /context). `default` is the engine a new session gets when
35
+ the picker isn't touched (--default-provider flag → `default_provider`
36
+ config key → registry default).
37
+ """
38
+ agents = getattr(request.app.state, "agents", None)
39
+ enabled = provider_enabled_map(request.app)
40
+ return {
41
+ # `enabled` (Settings toggles, default engine always on) gates which
42
+ # rows the picker offers; a session already bound to a disabled engine
43
+ # still renders it. `default_enabled` is the provider's own pre-
44
+ # override default, shown in Settings as "what Reset returns to".
45
+ "providers": [
46
+ {**p.describe(), "enabled": enabled.get(p.name, True)}
47
+ for p in all_providers()
48
+ ],
49
+ "default": effective_default_provider(request.app).name,
50
+ # A --default-provider server flag outranks the config key — the UI
51
+ # disables its "set as default" affordance while pinned.
52
+ "default_pinned_by_flag": bool(getattr(agents, "default_provider", None)),
53
+ }
@@ -0,0 +1,411 @@
1
+ """
2
+ Search API Routes - Project-wide content search ("Search in Files" widget)
3
+
4
+ GET /api/search runs ripgrep against a project directory and returns matches
5
+ grouped per file, with char-offset highlight spans for the client to render.
6
+ Falls back to a pure-Python walker when ripgrep isn't installed (degraded:
7
+ .gitignore not respected — the response says so via `engine`/`gitignore_respected`).
8
+
9
+ Design notes (session fP--zppLO6s plan, 2026-05-21):
10
+ - subprocess via argv-list `asyncio.create_subprocess_exec` (never shell=True —
11
+ the search term is an argv element, so no injection surface)
12
+ - rg `--json` NDJSON stream is read incrementally so we can stop at `limit`
13
+ and kill the process instead of buffering unbounded output
14
+ - byte offsets from rg submatches are converted to char offsets server-side
15
+ so the client can slice JS strings directly
16
+ """
17
+
18
+ import asyncio
19
+ import base64
20
+ import fnmatch
21
+ import json
22
+ import logging
23
+ import os
24
+ import re
25
+ import time
26
+ from pathlib import Path
27
+
28
+ from fastapi import APIRouter, HTTPException
29
+
30
+ from painapple_code.utils.file_paths import is_path_allowed_for_read, safe_resolve
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ router = APIRouter(tags=["search"])
35
+
36
+ # Directories never searched, regardless of ignore settings — mirrors the
37
+ # hardcoded exclude list api_files._enumerate_files uses for file listing.
38
+ EXCLUDED_DIRS = (
39
+ "node_modules", ".git", "__pycache__", "venv", ".venv",
40
+ "dist", "build", ".next", "coverage",
41
+ )
42
+
43
+ MAX_FILESIZE = "1M" # rg --max-filesize (skip huge/minified files)
44
+ MAX_FILESIZE_BYTES = 1_000_000 # same cap for the python fallback
45
+ MAX_PER_FILE = 100 # per-file match ceiling (rg --max-count)
46
+ DEFAULT_LIMIT = 500 # total match cap unless the client asks lower
47
+ MAX_LIMIT = 2000
48
+ MAX_QUERY_LEN = 1000
49
+ SEARCH_TIMEOUT = 20.0 # overall wall-clock budget for rg (seconds)
50
+ FALLBACK_TIMEOUT = 10.0 # python fallback budget
51
+ MAX_LINE_CHARS = 400 # long lines clipped to a window around the match
52
+ # rg emits one JSON line per match incl. the full matched line; --max-filesize
53
+ # bounds lines at ~1MB, JSON-escaping can inflate ~6x worst case.
54
+ STREAM_LIMIT = 8 * 1024 * 1024
55
+
56
+
57
+ def _event_text(obj) -> str:
58
+ """Extract text from an rg JSON data object ({"text": ...} or {"bytes": b64})."""
59
+ if not obj:
60
+ return ""
61
+ if "text" in obj:
62
+ return obj["text"]
63
+ try:
64
+ return base64.b64decode(obj.get("bytes", "")).decode("utf-8", errors="replace")
65
+ except Exception:
66
+ return ""
67
+
68
+
69
+ def _clip_line(text: str, spans: list) -> tuple[str, list, bool, bool]:
70
+ """Clip very long lines to a window around the first match.
71
+
72
+ Returns (text, adjusted_spans, clipped_start, clipped_end).
73
+ Spans are [start, end] char offsets into the returned text.
74
+ """
75
+ if len(text) <= MAX_LINE_CHARS:
76
+ return text, spans, False, False
77
+ first = spans[0][0] if spans else 0
78
+ start = max(0, first - 80)
79
+ end = min(len(text), start + MAX_LINE_CHARS)
80
+ clipped = text[start:end]
81
+ out_spans = []
82
+ for s, e in spans:
83
+ s2, e2 = max(s - start, 0), min(e - start, len(clipped))
84
+ if e2 > s2 and s2 < len(clipped):
85
+ out_spans.append([s2, e2])
86
+ return clipped, out_spans, start > 0, end < len(text)
87
+
88
+
89
+ def _parse_globs(raw: str) -> list[str]:
90
+ """Split a comma-separated glob string into a clean list."""
91
+ return [g.strip() for g in (raw or "").split(",") if g.strip()]
92
+
93
+
94
+ # ═══════════════════════════════════════════════════════════════════════
95
+ # ripgrep engine
96
+ # ═══════════════════════════════════════════════════════════════════════
97
+
98
+ def _build_rg_args(q, regex, case_sensitive, whole_word,
99
+ include, exclude, include_ignored) -> list[str]:
100
+ args = [
101
+ "rg", "--json", "--no-config",
102
+ f"--max-count={MAX_PER_FILE}",
103
+ f"--max-filesize={MAX_FILESIZE}",
104
+ ]
105
+ if not case_sensitive:
106
+ args.append("-i")
107
+ if whole_word:
108
+ args.append("-w")
109
+ if not regex:
110
+ args.append("-F")
111
+ if include_ignored:
112
+ args += ["--no-ignore", "--hidden"]
113
+ # Hardcoded excludes always apply (gitignore-style: bare name = any depth)
114
+ for d in EXCLUDED_DIRS:
115
+ args += ["-g", f"!{d}"]
116
+ for g in include:
117
+ args += ["-g", g]
118
+ for g in exclude:
119
+ args += ["-g", f"!{g}"]
120
+ args += ["-e", q]
121
+ return args
122
+
123
+
124
+ async def _search_ripgrep(work_dir: Path, q, regex, case_sensitive, whole_word,
125
+ include, exclude, include_ignored, limit):
126
+ """Run rg --json, streaming events so we can stop at `limit`.
127
+
128
+ Returns (matches_by_file, order, total, truncated).
129
+ Raises FileNotFoundError if rg isn't installed, HTTPException(400) on
130
+ a bad pattern.
131
+ """
132
+ args = _build_rg_args(q, regex, case_sensitive, whole_word,
133
+ include, exclude, include_ignored)
134
+ proc = await asyncio.create_subprocess_exec(
135
+ *args, cwd=str(work_dir),
136
+ stdout=asyncio.subprocess.PIPE,
137
+ stderr=asyncio.subprocess.PIPE,
138
+ limit=STREAM_LIMIT,
139
+ )
140
+
141
+ matches_by_file: dict[str, list] = {}
142
+ order: list[str] = []
143
+ total = 0
144
+ truncated = False
145
+ loop = asyncio.get_running_loop()
146
+ deadline = loop.time() + SEARCH_TIMEOUT
147
+
148
+ try:
149
+ while True:
150
+ remaining = deadline - loop.time()
151
+ if remaining <= 0:
152
+ truncated = True
153
+ break
154
+ try:
155
+ line = await asyncio.wait_for(proc.stdout.readline(), timeout=remaining)
156
+ except asyncio.TimeoutError:
157
+ truncated = True
158
+ break
159
+ except (ValueError, asyncio.LimitOverrunError):
160
+ # Single event line exceeded STREAM_LIMIT — skip the rest of it
161
+ continue
162
+ if not line:
163
+ break
164
+ try:
165
+ event = json.loads(line)
166
+ except ValueError:
167
+ continue
168
+ if event.get("type") != "match":
169
+ continue
170
+
171
+ data = event.get("data", {})
172
+ # ripgrep emits OS-native separators; normalize so the client
173
+ # and the Python fallback engine agree on the shape.
174
+ #
175
+ # Keyed off os.sep, NOT an unconditional '\\' -> '/'. On
176
+ # Linux/macOS a backslash is an ordinary filename byte, so the
177
+ # blanket rewrite turned a real `src/weird\name.txt` into
178
+ # `src/weird/name.txt` — a path that doesn't exist, 404ing in
179
+ # the preview — while _search_python's relative_to().as_posix()
180
+ # preserved it. Same query, two answers, depending only on
181
+ # whether rg happened to be installed.
182
+ path = _event_text(data.get("path")).replace(os.sep, "/")
183
+ lines_obj = data.get("lines", {})
184
+ raw = _event_text(lines_obj).rstrip("\r\n")
185
+
186
+ spans = []
187
+ if "text" in lines_obj:
188
+ # Submatch offsets are bytes into the utf-8 line; convert to chars
189
+ lb = lines_obj["text"].encode("utf-8")
190
+ for sm in data.get("submatches", []):
191
+ try:
192
+ s = len(lb[:sm["start"]].decode("utf-8"))
193
+ e = len(lb[:sm["end"]].decode("utf-8"))
194
+ except (UnicodeDecodeError, KeyError, TypeError):
195
+ continue
196
+ s, e = min(s, len(raw)), min(e, len(raw))
197
+ if e > s:
198
+ spans.append([s, e])
199
+
200
+ text, spans, clipped_start, clipped_end = _clip_line(raw, spans)
201
+ entry = {"line": data.get("line_number"), "text": text, "spans": spans}
202
+ if clipped_start:
203
+ entry["clipped_start"] = True
204
+ if clipped_end:
205
+ entry["clipped_end"] = True
206
+
207
+ if path not in matches_by_file:
208
+ matches_by_file[path] = []
209
+ order.append(path)
210
+ matches_by_file[path].append(entry)
211
+
212
+ total += 1
213
+ if total >= limit:
214
+ truncated = True
215
+ break
216
+ finally:
217
+ if proc.returncode is None:
218
+ try:
219
+ proc.kill()
220
+ except ProcessLookupError:
221
+ pass
222
+
223
+ # Reap the process and collect stderr (for pattern errors)
224
+ try:
225
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=5.0)
226
+ except asyncio.TimeoutError:
227
+ stderr = b""
228
+
229
+ # Exit 2 = rg error. Only surface it when we got nothing back — rg also
230
+ # exits 2 on partial per-file errors (e.g. one unreadable file) with
231
+ # perfectly good matches alongside.
232
+ if total == 0 and not truncated and proc.returncode == 2:
233
+ # rg's parse errors span multiple lines; the human-useful part is the
234
+ # last "error: …" line (e.g. "error: unclosed character class").
235
+ lines = [l.strip() for l in stderr.decode(errors="replace").splitlines() if l.strip()]
236
+ detail = lines[0] if lines else "Search failed"
237
+ for l in reversed(lines):
238
+ if l.startswith("error:"):
239
+ detail = f"Invalid pattern: {l[len('error:'):].strip()}"
240
+ break
241
+ raise HTTPException(status_code=400, detail=detail)
242
+
243
+ return matches_by_file, order, total, truncated
244
+
245
+
246
+ # ═══════════════════════════════════════════════════════════════════════
247
+ # Python fallback engine (no ripgrep installed)
248
+ # ═══════════════════════════════════════════════════════════════════════
249
+
250
+ def _glob_match(rel: str, name: str, glob: str) -> bool:
251
+ """gitignore-lite: glob with '/' matches the relative path, else the basename."""
252
+ if "/" in glob:
253
+ return fnmatch.fnmatch(rel, glob.lstrip("/"))
254
+ return fnmatch.fnmatch(name, glob)
255
+
256
+
257
+ def _search_python(work_dir: Path, q, regex, case_sensitive, whole_word,
258
+ include, exclude, include_ignored, limit):
259
+ """os.walk + re fallback. Runs in a thread. No .gitignore awareness."""
260
+ flags = 0 if case_sensitive else re.IGNORECASE
261
+ pattern = q if regex else re.escape(q)
262
+ if whole_word:
263
+ pattern = r"\b(?:%s)\b" % pattern
264
+ try:
265
+ rx = re.compile(pattern, flags)
266
+ except re.error as e:
267
+ raise HTTPException(status_code=400, detail=f"Invalid regex: {e}")
268
+
269
+ matches_by_file: dict[str, list] = {}
270
+ order: list[str] = []
271
+ total = 0
272
+ truncated = False
273
+ deadline = time.monotonic() + FALLBACK_TIMEOUT
274
+
275
+ for root, dirs, files in os.walk(work_dir):
276
+ dirs[:] = sorted(
277
+ d for d in dirs
278
+ if d not in EXCLUDED_DIRS and (include_ignored or not d.startswith("."))
279
+ )
280
+ if time.monotonic() > deadline:
281
+ truncated = True
282
+ break
283
+ for fname in sorted(files):
284
+ if time.monotonic() > deadline or total >= limit:
285
+ truncated = True
286
+ break
287
+ if not include_ignored and fname.startswith("."):
288
+ continue
289
+ fpath = Path(root) / fname
290
+ # as_posix, not str: on Windows str() yields "sub\\dir\\f.py",
291
+ # which (a) reaches the client with backslashes and (b) can never
292
+ # match an include/exclude glob like "src/**" — so user filters
293
+ # were silently ignored rather than failing loudly.
294
+ rel = fpath.relative_to(work_dir).as_posix()
295
+ if include and not any(_glob_match(rel, fname, g) for g in include):
296
+ continue
297
+ if any(_glob_match(rel, fname, g) for g in exclude):
298
+ continue
299
+ try:
300
+ if fpath.stat().st_size > MAX_FILESIZE_BYTES:
301
+ continue
302
+ with open(fpath, "rb") as fh:
303
+ head = fh.read(8192)
304
+ if b"\0" in head: # binary sniff
305
+ continue
306
+ blob = head + fh.read()
307
+ except OSError:
308
+ continue
309
+
310
+ content = blob.decode("utf-8", errors="replace")
311
+ file_matches = []
312
+ for ln, line in enumerate(content.splitlines(), 1):
313
+ spans = [[m.start(), m.end()] for m in rx.finditer(line) if m.end() > m.start()]
314
+ if not spans:
315
+ continue
316
+ text, spans, clipped_start, clipped_end = _clip_line(line, spans)
317
+ entry = {"line": ln, "text": text, "spans": spans}
318
+ if clipped_start:
319
+ entry["clipped_start"] = True
320
+ if clipped_end:
321
+ entry["clipped_end"] = True
322
+ file_matches.append(entry)
323
+ total += 1
324
+ if len(file_matches) >= MAX_PER_FILE or total >= limit:
325
+ break
326
+ if file_matches:
327
+ matches_by_file[rel] = file_matches
328
+ order.append(rel)
329
+ if truncated:
330
+ break
331
+
332
+ return matches_by_file, order, total, truncated
333
+
334
+
335
+ # ═══════════════════════════════════════════════════════════════════════
336
+ # Endpoint
337
+ # ═══════════════════════════════════════════════════════════════════════
338
+
339
+ @router.get("/api/search")
340
+ async def search_in_files(
341
+ cwd: str,
342
+ q: str,
343
+ regex: bool = False,
344
+ case_sensitive: bool = False,
345
+ whole_word: bool = False,
346
+ include: str = "",
347
+ exclude: str = "",
348
+ include_ignored: bool = False,
349
+ limit: int = DEFAULT_LIMIT,
350
+ ):
351
+ """
352
+ Project-wide content search.
353
+
354
+ Args:
355
+ cwd: Directory to search (usually the session's project root)
356
+ q: Search text (fixed string unless regex=true)
357
+ regex: Treat q as a regular expression
358
+ case_sensitive: Match case exactly (default: insensitive)
359
+ whole_word: Match whole words only
360
+ include: Comma-separated globs to include (e.g. "*.js, src/**")
361
+ exclude: Comma-separated globs to exclude
362
+ include_ignored: Also search .gitignore'd and hidden files
363
+ limit: Total match cap (default 500, max 2000)
364
+
365
+ Returns matches grouped per file with char-offset highlight spans:
366
+ {cwd, query, engine, gitignore_respected,
367
+ files: [{path, matches: [{line, text, spans: [[s,e],…]}]}],
368
+ total_matches, total_files, truncated, elapsed_ms}
369
+ """
370
+ if not q:
371
+ raise HTTPException(status_code=400, detail="Empty search query")
372
+ if len(q) > MAX_QUERY_LEN:
373
+ raise HTTPException(status_code=400, detail="Search query too long")
374
+ limit = max(1, min(limit, MAX_LIMIT))
375
+
376
+ work_dir = safe_resolve(cwd)
377
+ if not is_path_allowed_for_read(work_dir):
378
+ raise HTTPException(status_code=403, detail="Path not allowed")
379
+ if not work_dir.exists() or not work_dir.is_dir():
380
+ raise HTTPException(status_code=404, detail="Directory not found")
381
+
382
+ include_globs = _parse_globs(include)
383
+ exclude_globs = _parse_globs(exclude)
384
+
385
+ t0 = time.monotonic()
386
+ engine = "ripgrep"
387
+ try:
388
+ matches_by_file, order, total, truncated = await _search_ripgrep(
389
+ work_dir, q, regex, case_sensitive, whole_word,
390
+ include_globs, exclude_globs, include_ignored, limit)
391
+ except FileNotFoundError:
392
+ engine = "python"
393
+ matches_by_file, order, total, truncated = await asyncio.to_thread(
394
+ _search_python, work_dir, q, regex, case_sensitive, whole_word,
395
+ include_globs, exclude_globs, include_ignored, limit)
396
+
397
+ # rg's parallel walk yields nondeterministic file order — sort for a
398
+ # stable UI (matches within a file keep line order either way).
399
+ order.sort()
400
+
401
+ return {
402
+ "cwd": str(work_dir),
403
+ "query": q,
404
+ "engine": engine,
405
+ "gitignore_respected": engine == "ripgrep" and not include_ignored,
406
+ "files": [{"path": p, "matches": matches_by_file[p]} for p in order],
407
+ "total_matches": total,
408
+ "total_files": len(order),
409
+ "truncated": truncated,
410
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
411
+ }