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.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- 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
|
+
}
|