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,984 @@
1
+ """
2
+ Prompt Explorer - Extract and search user prompts across all sessions
3
+
4
+ Provides efficient access to user prompts with rich metadata:
5
+ - Content and timestamp
6
+ - Session context (name, project)
7
+ - Response preview
8
+ - Word/character counts
9
+
10
+ Features:
11
+ - Full-text search across prompts
12
+ - Filter by project, date range, session
13
+ - Recent prompts for quick access (Ctrl+R)
14
+ - Prompt statistics and patterns
15
+
16
+ Search Syntax:
17
+ - `word1 word2` → AND (both required)
18
+ - `"exact phrase"` → Exact phrase match
19
+ - `-exclude` → Exclude term
20
+ - `word1 OR word2` → Either term
21
+ - `in:response` → Search in Claude's responses
22
+ - `project:name` → Filter by project name (partial match)
23
+ - `after:2026-01-15` → After date
24
+ - `before:2026-01-10`→ Before date
25
+ - `long:` → Long prompts (>500 chars)
26
+ - `short:` → Short prompts (<100 chars)
27
+ - `has:image` → Prompts with images
28
+ - `session:id` → Filter by session
29
+ """
30
+
31
+ import json
32
+ import logging
33
+ import re
34
+ from datetime import datetime, timedelta
35
+ from pathlib import Path
36
+ from typing import Optional, List, Dict, Tuple, Set
37
+ from dataclasses import dataclass, asdict, field
38
+
39
+ from painapple_code import paths
40
+
41
+ logger = logging.getLogger("painapple-code.prompts")
42
+
43
+
44
+ # ═══════════════════════════════════════════════════════════════════════
45
+ # QUERY PARSER - Advanced search syntax
46
+ # ═══════════════════════════════════════════════════════════════════════
47
+
48
+ @dataclass
49
+ class ParsedQuery:
50
+ """Parsed search query with structured components"""
51
+ # Terms that MUST be present
52
+ required_terms: List[str] = field(default_factory=list)
53
+ # Terms that MUST NOT be present
54
+ excluded_terms: List[str] = field(default_factory=list)
55
+ # Groups of terms where at least one must match (OR groups)
56
+ or_groups: List[List[str]] = field(default_factory=list)
57
+ # Exact phrases that must appear
58
+ exact_phrases: List[str] = field(default_factory=list)
59
+ # Search in responses too
60
+ search_response: bool = False
61
+ # Project filter (partial match)
62
+ project_filter: Optional[str] = None
63
+ # Session filter
64
+ session_filter: Optional[str] = None
65
+ # Date filters
66
+ after_date: Optional[datetime] = None
67
+ before_date: Optional[datetime] = None
68
+ # Length filters
69
+ min_length: Optional[int] = None
70
+ max_length: Optional[int] = None
71
+ # Has image filter
72
+ has_image: Optional[bool] = None
73
+ # Has stash filter
74
+ has_stash: Optional[bool] = None
75
+ # Favorites filter
76
+ favorites_only: bool = False
77
+ # Original query for display
78
+ original_query: str = ""
79
+ # Active filters for UI display
80
+ active_filters: List[Dict] = field(default_factory=list)
81
+
82
+ def is_empty(self) -> bool:
83
+ """Check if query has any search criteria"""
84
+ return (
85
+ not self.required_terms and
86
+ not self.excluded_terms and
87
+ not self.or_groups and
88
+ not self.exact_phrases and
89
+ not self.search_response and
90
+ not self.project_filter and
91
+ not self.session_filter and
92
+ not self.after_date and
93
+ not self.before_date and
94
+ not self.min_length and
95
+ not self.max_length and
96
+ self.has_image is None and
97
+ self.has_stash is None and
98
+ not self.favorites_only
99
+ )
100
+
101
+
102
+ class QueryParser:
103
+ """
104
+ Parse search queries into structured components.
105
+
106
+ Supports:
107
+ - Basic terms (AND by default): `word1 word2`
108
+ - Exact phrases: `"exact phrase"`
109
+ - Exclusion: `-word` or `NOT word`
110
+ - OR groups: `word1 OR word2`
111
+ - Field filters: `in:response`, `project:name`, `after:date`
112
+ - Special filters: `long:`, `short:`, `has:image`
113
+ """
114
+
115
+ # Regex patterns
116
+ QUOTED_PHRASE = re.compile(r'"([^"]+)"')
117
+ FILTER_PATTERN = re.compile(r'(\w+):(\S+)')
118
+
119
+ def parse(self, query: str) -> ParsedQuery:
120
+ """Parse a search query string into structured components"""
121
+ result = ParsedQuery(original_query=query)
122
+
123
+ if not query or not query.strip():
124
+ return result
125
+
126
+ # Extract quoted phrases first
127
+ phrases = self.QUOTED_PHRASE.findall(query)
128
+ for phrase in phrases:
129
+ result.exact_phrases.append(phrase)
130
+ result.active_filters.append({
131
+ "type": "phrase",
132
+ "label": f'"{phrase}"',
133
+ "value": phrase
134
+ })
135
+
136
+ # Remove quoted phrases from query
137
+ query = self.QUOTED_PHRASE.sub('', query)
138
+
139
+ # Extract field filters
140
+ filters = self.FILTER_PATTERN.findall(query)
141
+ for key, value in filters:
142
+ self._apply_filter(result, key.lower(), value)
143
+
144
+ # Remove filters from query
145
+ query = self.FILTER_PATTERN.sub('', query)
146
+
147
+ # Handle special keywords
148
+ query = self._handle_special_keywords(query, result)
149
+
150
+ # Split remaining query into tokens
151
+ tokens = query.split()
152
+
153
+ i = 0
154
+ while i < len(tokens):
155
+ token = tokens[i]
156
+
157
+ # Handle exclusion (-term or NOT term)
158
+ if token.startswith('-') and len(token) > 1:
159
+ result.excluded_terms.append(token[1:].lower())
160
+ result.active_filters.append({
161
+ "type": "exclude",
162
+ "label": f"-{token[1:]}",
163
+ "value": token[1:]
164
+ })
165
+ elif token.upper() == 'NOT' and i + 1 < len(tokens):
166
+ result.excluded_terms.append(tokens[i + 1].lower())
167
+ result.active_filters.append({
168
+ "type": "exclude",
169
+ "label": f"-{tokens[i + 1]}",
170
+ "value": tokens[i + 1]
171
+ })
172
+ i += 1
173
+ # Handle OR
174
+ elif token.upper() == 'OR' and i > 0 and i + 1 < len(tokens):
175
+ # Look back for the OR group start
176
+ prev_term = None
177
+ if result.required_terms:
178
+ prev_term = result.required_terms.pop()
179
+ # Remove from active filters too
180
+ result.active_filters = [
181
+ f for f in result.active_filters
182
+ if not (f.get("type") == "term" and f.get("value") == prev_term)
183
+ ]
184
+
185
+ # Collect OR group
186
+ or_group = []
187
+ if prev_term:
188
+ or_group.append(prev_term.lower())
189
+
190
+ # Add next term
191
+ i += 1
192
+ if i < len(tokens):
193
+ next_term = tokens[i]
194
+ if not next_term.startswith('-') and next_term.upper() not in ('OR', 'NOT'):
195
+ or_group.append(next_term.lower())
196
+
197
+ # Continue collecting OR terms
198
+ while i + 2 < len(tokens) and tokens[i + 1].upper() == 'OR':
199
+ or_group.append(tokens[i + 2].lower())
200
+ i += 2
201
+
202
+ if or_group:
203
+ result.or_groups.append(or_group)
204
+ result.active_filters.append({
205
+ "type": "or",
206
+ "label": " OR ".join(or_group),
207
+ "value": or_group
208
+ })
209
+ # Regular term
210
+ elif token and not token.startswith(':'):
211
+ result.required_terms.append(token.lower())
212
+ result.active_filters.append({
213
+ "type": "term",
214
+ "label": token,
215
+ "value": token.lower()
216
+ })
217
+
218
+ i += 1
219
+
220
+ return result
221
+
222
+ def _apply_filter(self, result: ParsedQuery, key: str, value: str):
223
+ """Apply a field:value filter"""
224
+ if key == 'in' and value.lower() == 'response':
225
+ result.search_response = True
226
+ result.active_filters.append({
227
+ "type": "field",
228
+ "label": "in:response",
229
+ "value": "response"
230
+ })
231
+
232
+ elif key == 'project':
233
+ result.project_filter = value
234
+ result.active_filters.append({
235
+ "type": "project",
236
+ "label": f"project:{value}",
237
+ "value": value
238
+ })
239
+
240
+ elif key == 'session':
241
+ result.session_filter = value
242
+ result.active_filters.append({
243
+ "type": "session",
244
+ "label": f"session:{value}",
245
+ "value": value
246
+ })
247
+
248
+ elif key in ('after', 'since', 'from'):
249
+ dt = self._parse_date_value(value)
250
+ if dt:
251
+ result.after_date = dt
252
+ result.active_filters.append({
253
+ "type": "date",
254
+ "label": f"after:{value}",
255
+ "value": value
256
+ })
257
+
258
+ elif key in ('before', 'until', 'to'):
259
+ dt = self._parse_date_value(value)
260
+ if dt:
261
+ result.before_date = dt
262
+ result.active_filters.append({
263
+ "type": "date",
264
+ "label": f"before:{value}",
265
+ "value": value
266
+ })
267
+
268
+ elif key == 'has' and value.lower() in ('image', 'images', 'img'):
269
+ result.has_image = True
270
+ result.active_filters.append({
271
+ "type": "field",
272
+ "label": "has:image",
273
+ "value": "image"
274
+ })
275
+
276
+ elif key == 'has' and value.lower() in ('stash', 'refs', 'context', 'comments'):
277
+ result.has_stash = True
278
+ result.active_filters.append({
279
+ "type": "field",
280
+ "label": "has:stash",
281
+ "value": "stash"
282
+ })
283
+
284
+ elif key in ('chars', 'length', 'len'):
285
+ # Handle chars>N, chars<N, chars:N
286
+ if value.startswith('>'):
287
+ try:
288
+ result.min_length = int(value[1:])
289
+ result.active_filters.append({
290
+ "type": "length",
291
+ "label": f"chars>{value[1:]}",
292
+ "value": result.min_length
293
+ })
294
+ except ValueError:
295
+ pass
296
+ elif value.startswith('<'):
297
+ try:
298
+ result.max_length = int(value[1:])
299
+ result.active_filters.append({
300
+ "type": "length",
301
+ "label": f"chars<{value[1:]}",
302
+ "value": result.max_length
303
+ })
304
+ except ValueError:
305
+ pass
306
+
307
+ def _handle_special_keywords(self, query: str, result: ParsedQuery) -> str:
308
+ """Handle special keywords like long:, short:"""
309
+ from datetime import timezone
310
+ now = datetime.now(timezone.utc).replace(tzinfo=None) # Naive UTC
311
+
312
+ # long: → chars > 500
313
+ if 'long:' in query.lower():
314
+ result.min_length = 500
315
+ result.active_filters.append({
316
+ "type": "length",
317
+ "label": "long (>500 chars)",
318
+ "value": 500
319
+ })
320
+ query = re.sub(r'\blong:\s*', '', query, flags=re.IGNORECASE)
321
+
322
+ # short: → chars < 100
323
+ if 'short:' in query.lower():
324
+ result.max_length = 100
325
+ result.active_filters.append({
326
+ "type": "length",
327
+ "label": "short (<100 chars)",
328
+ "value": 100
329
+ })
330
+ query = re.sub(r'\bshort:\s*', '', query, flags=re.IGNORECASE)
331
+
332
+ # today: → after today
333
+ if 'today:' in query.lower():
334
+ result.after_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
335
+ result.active_filters.append({
336
+ "type": "date",
337
+ "label": "today",
338
+ "value": "today"
339
+ })
340
+ query = re.sub(r'\btoday:\s*', '', query, flags=re.IGNORECASE)
341
+
342
+ # yesterday: → specific day
343
+ if 'yesterday:' in query.lower():
344
+ yesterday = now - timedelta(days=1)
345
+ result.after_date = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
346
+ result.before_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
347
+ result.active_filters.append({
348
+ "type": "date",
349
+ "label": "yesterday",
350
+ "value": "yesterday"
351
+ })
352
+ query = re.sub(r'\byesterday:\s*', '', query, flags=re.IGNORECASE)
353
+
354
+ # week: → last 7 days
355
+ if 'week:' in query.lower():
356
+ result.after_date = now - timedelta(days=7)
357
+ result.active_filters.append({
358
+ "type": "date",
359
+ "label": "this week",
360
+ "value": "week"
361
+ })
362
+ query = re.sub(r'\bweek:\s*', '', query, flags=re.IGNORECASE)
363
+
364
+ # fav: or starred: or favorites: → favorites only
365
+ fav_patterns = [r'\bfav:\s*', r'\bstarred:\s*', r'\bfavorites:\s*', r'\bfavourites:\s*']
366
+ for pattern in fav_patterns:
367
+ if re.search(pattern, query, re.IGNORECASE):
368
+ result.favorites_only = True
369
+ result.active_filters.append({
370
+ "type": "favorites",
371
+ "label": "favorites",
372
+ "value": True
373
+ })
374
+ query = re.sub(pattern, '', query, flags=re.IGNORECASE)
375
+ break # Only add filter once
376
+
377
+ return query
378
+
379
+ def _parse_date_value(self, value: str) -> Optional[datetime]:
380
+ """Parse date value from filter - returns naive UTC datetime"""
381
+ from datetime import timezone
382
+ now = datetime.now(timezone.utc).replace(tzinfo=None) # Naive UTC
383
+
384
+ # Handle relative dates
385
+ if value.lower() == 'today':
386
+ return now.replace(hour=0, minute=0, second=0, microsecond=0)
387
+ elif value.lower() == 'yesterday':
388
+ return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
389
+ elif value.lower() == 'week':
390
+ return now - timedelta(days=7)
391
+ elif value.lower() == 'month':
392
+ return now - timedelta(days=30)
393
+
394
+ # Handle YYYY-MM-DD (treat as UTC start of day)
395
+ try:
396
+ return datetime.strptime(value, "%Y-%m-%d")
397
+ except ValueError:
398
+ pass
399
+
400
+ # Handle days ago: 3d, 7d
401
+ match = re.match(r'^(\d+)d$', value)
402
+ if match:
403
+ days = int(match.group(1))
404
+ return now - timedelta(days=days)
405
+
406
+ return None
407
+
408
+
409
+ # Singleton query parser
410
+ _query_parser = QueryParser()
411
+
412
+
413
+ @dataclass
414
+ class Prompt:
415
+ """A user prompt with metadata"""
416
+ id: str # Unique ID: {session_id}:{line_number}
417
+ content: str # Prompt text
418
+ timestamp: str # ISO timestamp
419
+ session_id: str # Session ID
420
+ session_name: str # Session name/description
421
+ project_path: str # Project CWD
422
+ project_hash: str # Project hash
423
+ response_preview: str # First ~150 chars of Claude's response
424
+ word_count: int # Word count
425
+ char_count: int # Character count
426
+ has_images: bool # Whether prompt included images
427
+ line_number: int # Line number in messages.jsonl
428
+ is_favorite: bool = False # Whether prompt is favorited
429
+ stash_refs: Optional[list] = None # Stash items attached to this prompt
430
+
431
+ def to_dict(self):
432
+ d = asdict(self)
433
+ # Only include stash_refs when present to keep responses lean
434
+ if not d.get('stash_refs'):
435
+ d.pop('stash_refs', None)
436
+ return d
437
+
438
+
439
+ class PromptExtractor:
440
+ """
441
+ Extracts and indexes user prompts from session logs.
442
+
443
+ Reads messages.jsonl files directly for efficiency.
444
+ """
445
+
446
+ def __init__(self):
447
+ self._cache = {} # session_id -> list of prompts
448
+ self._cache_timestamps = {} # session_id -> last_activity
449
+
450
+ def get_all_prompts(
451
+ self,
452
+ project_hash: str = None,
453
+ since: str = None,
454
+ until: str = None,
455
+ search_query: str = None,
456
+ limit: int = 100,
457
+ offset: int = 0,
458
+ ) -> Tuple[List[Prompt], int, Optional[ParsedQuery]]:
459
+ """
460
+ Get all prompts with filtering and pagination.
461
+
462
+ Args:
463
+ project_hash: Filter by project
464
+ since: Start date (ISO or YYYY-MM-DD)
465
+ until: End date (ISO or YYYY-MM-DD)
466
+ search_query: Full-text search with advanced syntax
467
+ limit: Max prompts to return
468
+ offset: Skip N prompts
469
+
470
+ Returns:
471
+ Tuple of (prompts, total_count, parsed_query)
472
+ """
473
+ # Parse the search query
474
+ parsed_query = None
475
+ if search_query:
476
+ parsed_query = _query_parser.parse(search_query)
477
+
478
+ # Merge explicit date params with query-parsed dates
479
+ since_dt = self._parse_date(since) if since else None
480
+ until_dt = self._parse_date(until) if until else None
481
+
482
+ if parsed_query:
483
+ if parsed_query.after_date and not since_dt:
484
+ since_dt = parsed_query.after_date
485
+ if parsed_query.before_date and not until_dt:
486
+ until_dt = parsed_query.before_date
487
+
488
+ # Merge project filter
489
+ effective_project = project_hash
490
+ if parsed_query and parsed_query.project_filter:
491
+ effective_project = parsed_query.project_filter
492
+
493
+ # Load favorites for marking
494
+ favorite_ids = set(paths.get_all_prompt_favorites().keys())
495
+
496
+ # Collect all prompts from all sessions
497
+ all_prompts = []
498
+
499
+ # Iterate all projects
500
+ projects_dir = paths.DATA_HOME / "projects"
501
+ if not projects_dir.exists():
502
+ return [], 0, parsed_query
503
+
504
+ for project_dir in projects_dir.iterdir():
505
+ if not project_dir.is_dir():
506
+ continue
507
+
508
+ hash_name = project_dir.name
509
+
510
+ # Get project path for display
511
+ path_file = project_dir / "path"
512
+ project_path = path_file.read_text(encoding="utf-8").strip() if path_file.exists() else ""
513
+
514
+ # Filter by project hash OR project path (partial match)
515
+ if effective_project:
516
+ hash_match = hash_name == effective_project or hash_name.startswith(effective_project)
517
+ path_match = effective_project.lower() in project_path.lower()
518
+ if not (hash_match or path_match):
519
+ continue
520
+
521
+ sessions_dir = project_dir / "sessions"
522
+ if not sessions_dir.exists():
523
+ continue
524
+
525
+ for session_dir in sessions_dir.iterdir():
526
+ if not session_dir.is_dir():
527
+ continue
528
+
529
+ session_id = session_dir.name
530
+
531
+ # Filter by session if specified
532
+ if parsed_query and parsed_query.session_filter:
533
+ if not session_id.startswith(parsed_query.session_filter):
534
+ continue
535
+
536
+ # Load session meta
537
+ meta_file = session_dir / "meta.json"
538
+ if not meta_file.exists():
539
+ continue
540
+
541
+ try:
542
+ meta = json.loads(meta_file.read_text(encoding="utf-8"))
543
+ except (ValueError, OSError) as exc:
544
+ logger.debug("prompt search: skipping %s with unreadable meta: %s", session_id, exc)
545
+ continue
546
+
547
+ session_name = meta.get("name") or meta.get("description") or ""
548
+
549
+ # Extract prompts from this session
550
+ prompts = self._extract_prompts_from_session(
551
+ session_dir=session_dir,
552
+ session_id=session_id,
553
+ session_name=session_name,
554
+ project_path=project_path,
555
+ project_hash=hash_name,
556
+ since_dt=since_dt,
557
+ until_dt=until_dt,
558
+ parsed_query=parsed_query,
559
+ favorite_ids=favorite_ids,
560
+ )
561
+
562
+ all_prompts.extend(prompts)
563
+
564
+ # Filter by favorites if requested
565
+ if parsed_query and parsed_query.favorites_only:
566
+ all_prompts = [p for p in all_prompts if p.is_favorite]
567
+
568
+ # Sort by timestamp (newest first)
569
+ all_prompts.sort(key=lambda p: p.timestamp, reverse=True)
570
+
571
+ total = len(all_prompts)
572
+
573
+ # Apply pagination
574
+ paginated = all_prompts[offset:offset + limit]
575
+
576
+ return paginated, total, parsed_query
577
+
578
+ def get_recent_prompts(
579
+ self,
580
+ limit: int = 20,
581
+ project_hash: str = None,
582
+ session_id: str = None,
583
+ ) -> List[Prompt]:
584
+ """
585
+ Get most recent prompts (optimized for Ctrl+R quick access).
586
+
587
+ Args:
588
+ limit: Max prompts
589
+ project_hash: Filter by project
590
+ session_id: Filter by specific session
591
+
592
+ Returns:
593
+ List of recent prompts
594
+ """
595
+ prompts, _, _ = self.get_all_prompts(
596
+ project_hash=project_hash,
597
+ limit=limit,
598
+ offset=0,
599
+ )
600
+
601
+ # Filter by session if specified
602
+ if session_id:
603
+ prompts = [p for p in prompts if p.session_id == session_id]
604
+
605
+ return prompts[:limit]
606
+
607
+ def get_frequent_prompts(
608
+ self,
609
+ limit: int = 10,
610
+ min_similarity: float = 0.8,
611
+ ) -> List[Dict]:
612
+ """
613
+ Get frequently used prompts (similar prompts grouped).
614
+
615
+ Returns prompts that appear multiple times with slight variations.
616
+ Uses simple word-based similarity.
617
+ """
618
+ prompts, _, _ = self.get_all_prompts(limit=500) # Analyze recent prompts
619
+
620
+ # Group similar prompts
621
+ groups = []
622
+ used = set()
623
+
624
+ for i, prompt in enumerate(prompts):
625
+ if i in used:
626
+ continue
627
+
628
+ similar = [prompt]
629
+ words1 = set(prompt.content.lower().split())
630
+
631
+ for j, other in enumerate(prompts[i+1:], i+1):
632
+ if j in used:
633
+ continue
634
+
635
+ words2 = set(other.content.lower().split())
636
+
637
+ # Jaccard similarity
638
+ if words1 and words2:
639
+ intersection = len(words1 & words2)
640
+ union = len(words1 | words2)
641
+ similarity = intersection / union if union > 0 else 0
642
+
643
+ if similarity >= min_similarity:
644
+ similar.append(other)
645
+ used.add(j)
646
+
647
+ if len(similar) >= 2:
648
+ # Return the shortest (most canonical) version
649
+ canonical = min(similar, key=lambda p: len(p.content))
650
+ groups.append({
651
+ "prompt": canonical.to_dict(),
652
+ "count": len(similar),
653
+ "variations": [p.to_dict() for p in similar[:5]] # Limit variations
654
+ })
655
+
656
+ # Sort by count
657
+ groups.sort(key=lambda g: g["count"], reverse=True)
658
+ return groups[:limit]
659
+
660
+ def get_prompt_stats(self, project_hash: str = None) -> Dict:
661
+ """
662
+ Get statistics about prompts.
663
+
664
+ Returns:
665
+ Dict with counts, averages, time distribution, etc.
666
+ """
667
+ prompts, total, _ = self.get_all_prompts(
668
+ project_hash=project_hash,
669
+ limit=10000, # Get all for stats
670
+ )
671
+
672
+ if not prompts:
673
+ return {
674
+ "total_prompts": 0,
675
+ "avg_word_count": 0,
676
+ "avg_char_count": 0,
677
+ "prompts_with_images": 0,
678
+ "unique_sessions": 0,
679
+ "unique_projects": 0,
680
+ "by_day": {},
681
+ "by_hour": {},
682
+ }
683
+
684
+ # Calculate stats
685
+ total_words = sum(p.word_count for p in prompts)
686
+ total_chars = sum(p.char_count for p in prompts)
687
+ with_images = sum(1 for p in prompts if p.has_images)
688
+ with_stash = sum(1 for p in prompts if p.stash_refs)
689
+ unique_sessions = len(set(p.session_id for p in prompts))
690
+ unique_projects = len(set(p.project_hash for p in prompts))
691
+
692
+ # Distribution by day of week
693
+ by_day = {}
694
+ by_hour = {}
695
+ for p in prompts:
696
+ try:
697
+ dt = datetime.fromisoformat(p.timestamp.replace("Z", "+00:00"))
698
+ day_name = dt.strftime("%A")
699
+ hour = dt.hour
700
+
701
+ by_day[day_name] = by_day.get(day_name, 0) + 1
702
+ by_hour[hour] = by_hour.get(hour, 0) + 1
703
+ except (ValueError, AttributeError) as exc:
704
+ logger.debug("prompt stats: skipping malformed timestamp %r: %s", p.timestamp, exc)
705
+
706
+ return {
707
+ "total_prompts": total,
708
+ "avg_word_count": round(total_words / len(prompts), 1) if prompts else 0,
709
+ "avg_char_count": round(total_chars / len(prompts), 1) if prompts else 0,
710
+ "prompts_with_images": with_images,
711
+ "prompts_with_stash": with_stash,
712
+ "unique_sessions": unique_sessions,
713
+ "unique_projects": unique_projects,
714
+ "by_day": by_day,
715
+ "by_hour": by_hour,
716
+ }
717
+
718
+ def search_prompts(
719
+ self,
720
+ query: str,
721
+ project_hash: str = None,
722
+ limit: int = 50,
723
+ ) -> Tuple[List[Prompt], Optional[ParsedQuery]]:
724
+ """
725
+ Search prompts by content with advanced query syntax.
726
+
727
+ Args:
728
+ query: Search string with advanced syntax
729
+ project_hash: Filter by project
730
+ limit: Max results
731
+
732
+ Returns:
733
+ Tuple of (matching prompts, parsed query)
734
+ """
735
+ prompts, _, parsed = self.get_all_prompts(
736
+ project_hash=project_hash,
737
+ search_query=query,
738
+ limit=limit,
739
+ )
740
+ return prompts, parsed
741
+
742
+ def get_full_response(self, prompt_id: str) -> Optional[str]:
743
+ """Get the full Claude response for a given prompt_id.
744
+
745
+ prompt_id format: '{session_id}:{line_number}'
746
+
747
+ Returns concatenated text of all string-content assistant messages
748
+ between this user message and the next user message in messages.jsonl.
749
+ Returns None if the session/prompt cannot be found.
750
+ """
751
+ try:
752
+ session_id, line_str = prompt_id.rsplit(":", 1)
753
+ line_number = int(line_str)
754
+ except (ValueError, AttributeError):
755
+ return None
756
+
757
+ projects_dir = paths.DATA_HOME / "projects"
758
+ if not projects_dir.exists():
759
+ return None
760
+
761
+ messages_file = None
762
+ for project_dir in projects_dir.iterdir():
763
+ if not project_dir.is_dir():
764
+ continue
765
+ candidate = project_dir / "sessions" / session_id / "messages.jsonl"
766
+ if candidate.exists():
767
+ messages_file = candidate
768
+ break
769
+
770
+ if not messages_file:
771
+ return None
772
+
773
+ parts: List[str] = []
774
+ try:
775
+ with open(messages_file, "r", encoding="utf-8") as f:
776
+ for ln, line in enumerate(f, 1):
777
+ if ln <= line_number:
778
+ continue
779
+ line = line.strip()
780
+ if not line:
781
+ continue
782
+ try:
783
+ msg = json.loads(line)
784
+ except ValueError:
785
+ continue
786
+ role = msg.get("role")
787
+ if role == "user":
788
+ break
789
+ if role == "assistant":
790
+ content = msg.get("content", "")
791
+ if isinstance(content, str) and content:
792
+ parts.append(content)
793
+ except OSError as exc:
794
+ logger.debug("get_full_response: %s unreadable: %s", messages_file, exc)
795
+ return None
796
+
797
+ return "\n\n".join(parts) if parts else None
798
+
799
+ def _extract_prompts_from_session(
800
+ self,
801
+ session_dir: Path,
802
+ session_id: str,
803
+ session_name: str,
804
+ project_path: str,
805
+ project_hash: str,
806
+ since_dt: datetime = None,
807
+ until_dt: datetime = None,
808
+ parsed_query: ParsedQuery = None,
809
+ favorite_ids: Set[str] = None,
810
+ ) -> List[Prompt]:
811
+ """Extract user prompts from a session's messages.jsonl"""
812
+
813
+ messages_file = session_dir / "messages.jsonl"
814
+ if not messages_file.exists():
815
+ return []
816
+
817
+ prompts = []
818
+
819
+ try:
820
+ with open(messages_file, 'r', encoding="utf-8") as f:
821
+ lines = f.readlines()
822
+
823
+ # First pass: collect user messages
824
+ user_messages = []
825
+ for line_num, line in enumerate(lines, 1):
826
+ line = line.strip()
827
+ if not line:
828
+ continue
829
+
830
+ try:
831
+ msg = json.loads(line)
832
+ except ValueError:
833
+ continue
834
+
835
+ role = msg.get("role")
836
+ if role == "user":
837
+ user_messages.append((line_num, msg))
838
+ elif role == "assistant" and user_messages:
839
+ # Attach response preview to last user message
840
+ last_line, last_msg = user_messages[-1]
841
+ content = msg.get("content", "")
842
+ if isinstance(content, str) and content:
843
+ last_msg["_response_preview"] = content[:150] + ("..." if len(content) > 150 else "")
844
+
845
+ # Second pass: convert to Prompt objects
846
+ for line_num, msg in user_messages:
847
+ content = msg.get("content", "")
848
+ if not isinstance(content, str):
849
+ continue
850
+
851
+ timestamp = msg.get("timestamp", "")
852
+ response_preview = msg.get("_response_preview", "")
853
+ has_images = msg.get("has_images", False) or msg.get("image_count", 0) > 0
854
+ stash_refs = msg.get("stashRefs") or None
855
+ char_count = len(content)
856
+
857
+ # Apply date filters
858
+ if since_dt or until_dt:
859
+ try:
860
+ # Parse timestamp and convert to naive UTC for comparison
861
+ msg_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
862
+ msg_dt_naive = msg_dt.replace(tzinfo=None) # Strip timezone
863
+ if since_dt and msg_dt_naive < since_dt:
864
+ continue
865
+ if until_dt and msg_dt_naive > until_dt:
866
+ continue
867
+ except (ValueError, AttributeError):
868
+ pass
869
+
870
+ # Apply parsed query filters
871
+ if parsed_query and not self._matches_query(
872
+ content, response_preview, char_count, has_images, bool(stash_refs), parsed_query
873
+ ):
874
+ continue
875
+
876
+ # Create Prompt object
877
+ prompt_id = f"{session_id}:{line_num}"
878
+ prompt = Prompt(
879
+ id=prompt_id,
880
+ content=content,
881
+ timestamp=timestamp,
882
+ session_id=session_id,
883
+ session_name=session_name,
884
+ project_path=project_path,
885
+ project_hash=project_hash,
886
+ response_preview=response_preview,
887
+ word_count=len(content.split()),
888
+ char_count=char_count,
889
+ has_images=has_images,
890
+ line_number=line_num,
891
+ is_favorite=prompt_id in (favorite_ids or set()),
892
+ stash_refs=stash_refs,
893
+ )
894
+ prompts.append(prompt)
895
+
896
+ except Exception as e:
897
+ logger.error(f"Error extracting prompts from {session_id}: {e}")
898
+
899
+ return prompts
900
+
901
+ def _matches_query(
902
+ self,
903
+ content: str,
904
+ response: str,
905
+ char_count: int,
906
+ has_images: bool,
907
+ has_stash: bool,
908
+ query: ParsedQuery,
909
+ ) -> bool:
910
+ """Check if content matches the parsed query"""
911
+
912
+ if query.is_empty():
913
+ return True
914
+
915
+ content_lower = content.lower()
916
+ response_lower = response.lower() if response else ""
917
+
918
+ # Combine search targets based on in:response flag
919
+ search_text = content_lower
920
+ if query.search_response:
921
+ search_text = content_lower + " " + response_lower
922
+
923
+ # Check required terms (AND)
924
+ for term in query.required_terms:
925
+ if term not in search_text:
926
+ return False
927
+
928
+ # Check excluded terms (NOT)
929
+ for term in query.excluded_terms:
930
+ if term in search_text:
931
+ return False
932
+
933
+ # Check OR groups (at least one must match)
934
+ for or_group in query.or_groups:
935
+ if not any(term in search_text for term in or_group):
936
+ return False
937
+
938
+ # Check exact phrases
939
+ for phrase in query.exact_phrases:
940
+ if phrase.lower() not in search_text:
941
+ return False
942
+
943
+ # Check length filters
944
+ if query.min_length is not None and char_count < query.min_length:
945
+ return False
946
+ if query.max_length is not None and char_count > query.max_length:
947
+ return False
948
+
949
+ # Check has_image filter
950
+ if query.has_image is not None:
951
+ if query.has_image and not has_images:
952
+ return False
953
+
954
+ # Check has_stash filter
955
+ if query.has_stash is not None:
956
+ if query.has_stash and not has_stash:
957
+ return False
958
+
959
+ return True
960
+
961
+ def _parse_date(self, date_str: str) -> Optional[datetime]:
962
+ """Parse date string (ISO or YYYY-MM-DD)"""
963
+ if not date_str:
964
+ return None
965
+ try:
966
+ # Try ISO format first
967
+ if "T" in date_str:
968
+ return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
969
+ # Try YYYY-MM-DD
970
+ return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=None)
971
+ except ValueError:
972
+ return None
973
+
974
+
975
+ # Singleton instance
976
+ _extractor = None
977
+
978
+
979
+ def get_prompt_extractor() -> PromptExtractor:
980
+ """Get or create the singleton prompt extractor"""
981
+ global _extractor
982
+ if _extractor is None:
983
+ _extractor = PromptExtractor()
984
+ return _extractor