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,1472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Handler Module
|
|
3
|
+
* Manages message input, history navigation, drafts, and autocomplete integration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Storage, escapeHtml, escapeAttr } from './utils.js';
|
|
7
|
+
import { CONFIG } from './config.js';
|
|
8
|
+
import { Stash } from './stash.js';
|
|
9
|
+
import { ShortcutHints } from './shortcut-hints.js';
|
|
10
|
+
import { DraftsPill } from './drafts-pill.js';
|
|
11
|
+
import { showToast } from './context-menu.js';
|
|
12
|
+
import { renderStashRefs } from './stash-refs-view.js';
|
|
13
|
+
import S from './strings.js';
|
|
14
|
+
|
|
15
|
+
// Auto-sync of in-progress input to a server-side draft (/api/drafts):
|
|
16
|
+
// idle time before the input is mirrored, and the minimum length before a
|
|
17
|
+
// draft is created at all (below it, throwaway fragments like "ok" would
|
|
18
|
+
// pollute the Drafts list; the localStorage auto-draft still covers them)
|
|
19
|
+
const AUTO_DRAFT_SYNC_MS = 2500;
|
|
20
|
+
const AUTO_DRAFT_MIN_CHARS = 12;
|
|
21
|
+
|
|
22
|
+
// Cap on session.promptHistory (oldest entries fall off the end)
|
|
23
|
+
const PROMPT_HISTORY_LIMIT = 50;
|
|
24
|
+
|
|
25
|
+
// A prompt-history entry is `{ text, stashRefs }` — the typed text plus the
|
|
26
|
+
// compact stash references that rode along with it, so recall can tell the
|
|
27
|
+
// user what was actually sent. Entries with empty text are legitimate: a
|
|
28
|
+
// stash-only send is a real prompt with no words in it. Plain strings are
|
|
29
|
+
// still accepted on read (older in-memory sessions, forks made before the
|
|
30
|
+
// object shape landed) so navigation never breaks on a legacy array.
|
|
31
|
+
export function historyEntryText(entry) {
|
|
32
|
+
return typeof entry === 'string' ? entry : (entry?.text ?? '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function historyEntryRefs(entry) {
|
|
36
|
+
const refs = typeof entry === 'string' ? null : entry?.stashRefs;
|
|
37
|
+
return Array.isArray(refs) && refs.length > 0 ? refs : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Push a prompt into a SPECIFIC session's arrow-up recall history.
|
|
42
|
+
*
|
|
43
|
+
* This is the single write path for `session.promptHistory` — the
|
|
44
|
+
* InputHandler's own addToHistory delegates here with the active session,
|
|
45
|
+
* and the `message_stored` broadcast handler calls it directly so prompts
|
|
46
|
+
* sent from ANOTHER tab/device become recallable in this one without a
|
|
47
|
+
* page reload (the send-path write only ever runs on the originating
|
|
48
|
+
* client).
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} session - the Session whose history to write
|
|
51
|
+
* @param {string} content - the text the user typed (may be empty)
|
|
52
|
+
* @param {Array|null} stashRefs - compact stash references sent with it
|
|
53
|
+
*/
|
|
54
|
+
export function pushHistoryEntry(session, content, stashRefs = null) {
|
|
55
|
+
if (!session) return;
|
|
56
|
+
|
|
57
|
+
const text = content || '';
|
|
58
|
+
const refs = Array.isArray(stashRefs) && stashRefs.length > 0 ? stashRefs : null;
|
|
59
|
+
// Neither words nor references — nothing was sent, nothing to recall
|
|
60
|
+
if (!text.trim() && !refs) return;
|
|
61
|
+
|
|
62
|
+
// Dedup on the typed text, so re-sending the same prompt moves it to
|
|
63
|
+
// the front instead of stacking (and so the enriched write from the
|
|
64
|
+
// send path replaces the bare one written on keypress). Empty-text
|
|
65
|
+
// entries are exempt: two stash-only sends carry different references
|
|
66
|
+
// and are genuinely different prompts — collapsing them would hide one.
|
|
67
|
+
if (text.trim()) {
|
|
68
|
+
session.promptHistory = session.promptHistory.filter(
|
|
69
|
+
h => historyEntryText(h) !== text
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
session.promptHistory.unshift({ text, stashRefs: refs });
|
|
73
|
+
if (session.promptHistory.length > PROMPT_HISTORY_LIMIT) {
|
|
74
|
+
session.promptHistory.length = PROMPT_HISTORY_LIMIT;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Tab-cycle: order matches the input toolbar (#, /, @, $).
|
|
79
|
+
// Tab in the input rotates through these trigger pickers without pre-selecting
|
|
80
|
+
// any entry, so the user can browse what each picker offers and arrow-down to commit.
|
|
81
|
+
const TAB_CYCLE_TRIGGERS = [
|
|
82
|
+
{ char: '#', popup: 'snippets', btnId: 'snippets-btn' },
|
|
83
|
+
{ char: '/', popup: 'slash', btnId: 'slash-cmd-btn' },
|
|
84
|
+
{ char: '@', popup: 'file', btnId: 'file-mention-btn' },
|
|
85
|
+
{ char: '$', popup: 'skills', btnId: 'skills-btn' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* InputHandler - Manage message input with history and drafts
|
|
90
|
+
*/
|
|
91
|
+
export class InputHandler {
|
|
92
|
+
/**
|
|
93
|
+
* @param {Object} elements - DOM elements
|
|
94
|
+
* @param {HTMLTextAreaElement} elements.messageInput - The message input
|
|
95
|
+
* @param {HTMLButtonElement} elements.sendBtn - Send button
|
|
96
|
+
* @param {HTMLButtonElement} elements.followupBtn - Follow-up button (visible while working; mirrors send disabled state)
|
|
97
|
+
* @param {Object} options - Configuration
|
|
98
|
+
* @param {boolean} options.hasPhysicalKeyboard - Whether device has keyboard
|
|
99
|
+
* @param {Object} callbacks - Callbacks
|
|
100
|
+
* @param {Function} callbacks.getSession - Returns active session
|
|
101
|
+
* @param {Function} callbacks.getAutocomplete - Returns autocomplete instance
|
|
102
|
+
* @param {Function} callbacks.getFileAutocomplete - Returns file autocomplete instance
|
|
103
|
+
* @param {Function} callbacks.getSnippetsAutocomplete - Returns snippets autocomplete instance
|
|
104
|
+
* @param {Function} callbacks.getSkillsAutocomplete - Returns skills autocomplete instance ($ trigger)
|
|
105
|
+
* @param {Function} callbacks.getPendingImages - Returns pending images array
|
|
106
|
+
* @param {Function} callbacks.isUploading - True while attachment uploads are in flight
|
|
107
|
+
* @param {Function} callbacks.hasPendingUploads - True if any upload (image or file) completed and is attached
|
|
108
|
+
* @param {Function} callbacks.onSendMessage - Called to send a message
|
|
109
|
+
* @param {Function} callbacks.onSlashCommand - Called for slash commands
|
|
110
|
+
* @param {Function} callbacks.onBangCommand - Called for bang commands
|
|
111
|
+
* @param {Function} callbacks.onConnect - Called to connect session
|
|
112
|
+
* @param {Function} callbacks.onAutoResize - Called to resize input
|
|
113
|
+
* @param {Function} callbacks.onUpdateSendButton - Called to update send button state
|
|
114
|
+
* @param {Function} callbacks.onPreviewFile - Called to preview a file (Cmd+Enter)
|
|
115
|
+
*/
|
|
116
|
+
constructor(elements, options = {}, callbacks = {}) {
|
|
117
|
+
this.els = elements;
|
|
118
|
+
this.hasPhysicalKeyboard = options.hasPhysicalKeyboard ?? true;
|
|
119
|
+
|
|
120
|
+
// Callbacks
|
|
121
|
+
this.getSession = callbacks.getSession || (() => null);
|
|
122
|
+
this.getAutocomplete = callbacks.getAutocomplete || (() => null);
|
|
123
|
+
this.getFileAutocomplete = callbacks.getFileAutocomplete || (() => null);
|
|
124
|
+
this.getSnippetsAutocomplete = callbacks.getSnippetsAutocomplete || (() => null);
|
|
125
|
+
this.getSkillsAutocomplete = callbacks.getSkillsAutocomplete || (() => null);
|
|
126
|
+
this.getCwd = callbacks.getCwd || (() => window.app?.activeSession?.cwd || null);
|
|
127
|
+
this.getPendingImages = callbacks.getPendingImages || (() => []);
|
|
128
|
+
this.isUploading = callbacks.isUploading || (() => false);
|
|
129
|
+
this.hasPendingUploads = callbacks.hasPendingUploads || (() => this.getPendingImages().length > 0);
|
|
130
|
+
this.onSendMessage = callbacks.onSendMessage || (() => {});
|
|
131
|
+
this.onSlashCommand = callbacks.onSlashCommand || (() => {});
|
|
132
|
+
this.onBangCommand = callbacks.onBangCommand || (() => {});
|
|
133
|
+
this.onPlanComposeChange = callbacks.onPlanComposeChange || (() => {});
|
|
134
|
+
this.onConnect = callbacks.onConnect || (() => {});
|
|
135
|
+
this.onAutoResize = callbacks.onAutoResize || (() => {});
|
|
136
|
+
this.onUpdateSendButton = callbacks.onUpdateSendButton || (() => {});
|
|
137
|
+
this.onPreviewFile = callbacks.onPreviewFile || (() => {});
|
|
138
|
+
this.onSendInNewSession = callbacks.onSendInNewSession || (() => {});
|
|
139
|
+
this.isWelcomeMode = callbacks.isWelcomeMode || (() => false);
|
|
140
|
+
|
|
141
|
+
// State
|
|
142
|
+
this.historyIndex = -1;
|
|
143
|
+
this.historyDraft = '';
|
|
144
|
+
|
|
145
|
+
// Input mode: null, 'shell' (! bang commands), or 'plan' (/plan prompt)
|
|
146
|
+
this.inputMode = null;
|
|
147
|
+
|
|
148
|
+
// Send pressed while attachment uploads were still in flight. Holds the
|
|
149
|
+
// original handleInput options until the uploads settle, then replays
|
|
150
|
+
// the send so the message goes out WITH its attachments rather than as
|
|
151
|
+
// text-only. Cleared by flushDeferredSend() or cancelDeferredSend().
|
|
152
|
+
this._deferredSend = null;
|
|
153
|
+
|
|
154
|
+
// Id of the server-side draft mirroring the current input — set by
|
|
155
|
+
// the auto-sync once it creates one, or by Prompt Explorer → Drafts
|
|
156
|
+
// → Use. Sending consumes (deletes) it; clearing the input abandons
|
|
157
|
+
// it (draft survives, unlinked).
|
|
158
|
+
this.pendingDraftId = null;
|
|
159
|
+
|
|
160
|
+
// Auto-sync state (see _syncDraftToServer): debounce timer,
|
|
161
|
+
// single-flight promise, re-run flag, and a consume epoch that
|
|
162
|
+
// invalidates in-flight creates when the input gets sent meanwhile
|
|
163
|
+
this._draftSyncTimer = null;
|
|
164
|
+
this._draftSyncInflight = null;
|
|
165
|
+
this._draftSyncQueued = false;
|
|
166
|
+
this._draftConsumeEpoch = 0;
|
|
167
|
+
|
|
168
|
+
// Debounce timers for autocompletes
|
|
169
|
+
this._fileDebounceTimer = null;
|
|
170
|
+
this._snippetsDebounceTimer = null;
|
|
171
|
+
this._skillsDebounceTimer = null;
|
|
172
|
+
|
|
173
|
+
// Tab-cycle state: { trigger, insertStart, insertEnd } or null
|
|
174
|
+
this._tabCycle = null;
|
|
175
|
+
|
|
176
|
+
// Bind event listeners
|
|
177
|
+
this._bindEvents();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Bind input event listeners
|
|
182
|
+
*/
|
|
183
|
+
_bindEvents() {
|
|
184
|
+
this.els.messageInput.addEventListener('input', (e) => this._onInputChange(e));
|
|
185
|
+
this.els.messageInput.addEventListener('keydown', (e) => this._onInputKeydown(e));
|
|
186
|
+
// Seed hint/pill visibility on init (empty textarea → show hints)
|
|
187
|
+
ShortcutHints.updateVisibility(this.els.messageInput.value);
|
|
188
|
+
DraftsPill.updateVisibility(this.els.messageInput.value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Handle input changes
|
|
193
|
+
*/
|
|
194
|
+
_onInputChange(e) {
|
|
195
|
+
let value = e.target.value;
|
|
196
|
+
const cursorPos = this.els.messageInput.selectionStart;
|
|
197
|
+
|
|
198
|
+
// Sync hint overlay + drafts pill visibility (hidden with any content)
|
|
199
|
+
ShortcutHints.updateVisibility(value);
|
|
200
|
+
DraftsPill.updateVisibility(value);
|
|
201
|
+
|
|
202
|
+
// Emptying the box while a send is parked on an upload backs that send
|
|
203
|
+
// out — otherwise the upload landing later fires a turn the user just
|
|
204
|
+
// deleted. Nothing is lost: the attachment stays pending, so the next
|
|
205
|
+
// send still picks it up.
|
|
206
|
+
if (this._deferredSend && !value.trim()) this.cancelDeferredSend();
|
|
207
|
+
|
|
208
|
+
// Input mode detection: shell (!) and plan (/plan )
|
|
209
|
+
const wasMode = this.inputMode;
|
|
210
|
+
if (value.startsWith('!')) {
|
|
211
|
+
this.inputMode = 'shell';
|
|
212
|
+
value = value.slice(1);
|
|
213
|
+
this.els.messageInput.value = value;
|
|
214
|
+
this.els.messageInput.selectionStart = Math.max(0, cursorPos - 1);
|
|
215
|
+
this.els.messageInput.selectionEnd = Math.max(0, cursorPos - 1);
|
|
216
|
+
} else if (!this.inputMode && value.startsWith('/plan ')) {
|
|
217
|
+
this.inputMode = 'plan';
|
|
218
|
+
value = value.slice(6); // strip '/plan '
|
|
219
|
+
this.els.messageInput.value = value;
|
|
220
|
+
const newCursor = Math.max(0, cursorPos - 6);
|
|
221
|
+
this.els.messageInput.selectionStart = newCursor;
|
|
222
|
+
this.els.messageInput.selectionEnd = newCursor;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Mode exit is intentionally Backspace-on-empty-only (handled in
|
|
226
|
+
// _onInputKeydown). Don't auto-exit just because the value happens to
|
|
227
|
+
// be empty here — Cmd+A→Backspace would clear the value via the input
|
|
228
|
+
// event, and auto-exiting would yank the user out of plan/shell mode
|
|
229
|
+
// after a single edit. The user must press Backspace on already-empty
|
|
230
|
+
// input to exit deliberately.
|
|
231
|
+
|
|
232
|
+
// Notify on entering/leaving the /plan compose box so the permission UI
|
|
233
|
+
// can flip to plan (and back). The subprocess restart is deferred to send.
|
|
234
|
+
this._notifyPlanComposeChange(wasMode);
|
|
235
|
+
|
|
236
|
+
// Apply mode classes and placeholder
|
|
237
|
+
this._applyInputMode();
|
|
238
|
+
|
|
239
|
+
// Exit history mode on any text change (user is editing)
|
|
240
|
+
if (this.historyIndex !== -1) {
|
|
241
|
+
this.historyIndex = -1;
|
|
242
|
+
this.historyDraft = '';
|
|
243
|
+
this._renderHistoryRefsHint(null);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Enable/disable send button (considers images too)
|
|
247
|
+
this.onUpdateSendButton();
|
|
248
|
+
|
|
249
|
+
// Save draft to localStorage (survives page refresh)
|
|
250
|
+
// Don't save bare prefix — mode with no content is not a useful draft
|
|
251
|
+
const draft = this._encodeDraftText();
|
|
252
|
+
this.saveDraft(draft);
|
|
253
|
+
|
|
254
|
+
// Mirror the input to a server-side draft (debounced upsert) so
|
|
255
|
+
// typed-but-unsent prompts survive tab loss and are browsable in
|
|
256
|
+
// Prompt Explorer → Drafts from any device
|
|
257
|
+
if (draft.trim()) this._scheduleDraftSync();
|
|
258
|
+
else this._cancelDraftSyncTimer();
|
|
259
|
+
|
|
260
|
+
// Emptying the input abandons the linked draft — it survives
|
|
261
|
+
// server-side (browsable in Drafts), but a later, unrelated send
|
|
262
|
+
// must not consume (delete) it. clearDraft() above already dropped
|
|
263
|
+
// the persisted link.
|
|
264
|
+
if (this.pendingDraftId && !value.trim()) {
|
|
265
|
+
this.pendingDraftId = null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Autocomplete for slash and bang commands
|
|
269
|
+
// Slash only triggers at the very start of input (no mid-text).
|
|
270
|
+
// Use the `$` picker for inserting skills mid-prompt.
|
|
271
|
+
const autocomplete = this.getAutocomplete();
|
|
272
|
+
if (autocomplete) {
|
|
273
|
+
if (value.startsWith('/') && !this.inputMode) {
|
|
274
|
+
autocomplete.show('slash', value.slice(1), 0);
|
|
275
|
+
} else if (this.inputMode === 'shell') {
|
|
276
|
+
autocomplete.show('bang', value, 0);
|
|
277
|
+
} else {
|
|
278
|
+
autocomplete.hide();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// File autocomplete on @
|
|
283
|
+
this._handleFileAutocomplete(value, cursorPos);
|
|
284
|
+
|
|
285
|
+
// Favorites autocomplete on #
|
|
286
|
+
this._handleSnippetsAutocomplete(value, cursorPos);
|
|
287
|
+
|
|
288
|
+
// Skills autocomplete on $
|
|
289
|
+
this._handleSkillsAutocomplete(value, cursorPos);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Handle $ skills autocomplete trigger
|
|
294
|
+
*/
|
|
295
|
+
_handleSkillsAutocomplete(value, cursorPos) {
|
|
296
|
+
const skillsAutocomplete = this.getSkillsAutocomplete();
|
|
297
|
+
if (!skillsAutocomplete) return;
|
|
298
|
+
|
|
299
|
+
const beforeCursor = value.slice(0, cursorPos);
|
|
300
|
+
const triggerMatch = beforeCursor.match(/(?:^|[\s])\$([^\s$]*)$/);
|
|
301
|
+
|
|
302
|
+
if (triggerMatch) {
|
|
303
|
+
const query = triggerMatch[1];
|
|
304
|
+
const triggerIndex = cursorPos - query.length - 1;
|
|
305
|
+
clearTimeout(this._skillsDebounceTimer);
|
|
306
|
+
this._skillsDebounceTimer = setTimeout(() => {
|
|
307
|
+
skillsAutocomplete.show(query, triggerIndex, this.getCwd());
|
|
308
|
+
}, query ? 50 : 0);
|
|
309
|
+
} else {
|
|
310
|
+
skillsAutocomplete.hide();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Handle @ file autocomplete trigger
|
|
316
|
+
*/
|
|
317
|
+
_handleFileAutocomplete(value, cursorPos) {
|
|
318
|
+
const fileAutocomplete = this.getFileAutocomplete();
|
|
319
|
+
if (!fileAutocomplete) return;
|
|
320
|
+
|
|
321
|
+
// Look for @ before cursor
|
|
322
|
+
const beforeCursor = value.slice(0, cursorPos);
|
|
323
|
+
|
|
324
|
+
// Find the last @ that could be a trigger
|
|
325
|
+
// Valid trigger: @ at start, or @ after whitespace
|
|
326
|
+
// Invalid: email-like patterns (user@example)
|
|
327
|
+
const atMatch = beforeCursor.match(/(?:^|[\s])@([^\s@]*)$/);
|
|
328
|
+
|
|
329
|
+
if (atMatch) {
|
|
330
|
+
const query = atMatch[1];
|
|
331
|
+
const atIndex = beforeCursor.lastIndexOf('@');
|
|
332
|
+
|
|
333
|
+
// Don't trigger if it looks like an email (has text before @)
|
|
334
|
+
if (atIndex > 0) {
|
|
335
|
+
const charBefore = beforeCursor[atIndex - 1];
|
|
336
|
+
if (charBefore && !/\s/.test(charBefore)) {
|
|
337
|
+
// Check if this looks like an email
|
|
338
|
+
const wordBefore = beforeCursor.slice(0, atIndex).match(/\S+$/);
|
|
339
|
+
if (wordBefore && /^[a-zA-Z0-9._-]+$/.test(wordBefore[0])) {
|
|
340
|
+
fileAutocomplete.hide();
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Debounce for performance
|
|
347
|
+
clearTimeout(this._fileDebounceTimer);
|
|
348
|
+
this._fileDebounceTimer = setTimeout(() => {
|
|
349
|
+
fileAutocomplete.show(query, atIndex);
|
|
350
|
+
}, query ? 100 : 0); // Immediate for empty query, debounced for typing
|
|
351
|
+
} else {
|
|
352
|
+
fileAutocomplete.hide();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Handle # snippets autocomplete trigger
|
|
358
|
+
*/
|
|
359
|
+
_handleSnippetsAutocomplete(value, cursorPos) {
|
|
360
|
+
const snippetsAutocomplete = this.getSnippetsAutocomplete();
|
|
361
|
+
if (!snippetsAutocomplete) return;
|
|
362
|
+
|
|
363
|
+
const beforeCursor = value.slice(0, cursorPos);
|
|
364
|
+
const hashMatch = beforeCursor.match(/(?:^|[\s])#([^\s#]*)$/);
|
|
365
|
+
|
|
366
|
+
if (hashMatch) {
|
|
367
|
+
const query = hashMatch[1];
|
|
368
|
+
const hashIndex = beforeCursor.lastIndexOf('#');
|
|
369
|
+
|
|
370
|
+
if (hashIndex === 0 || (hashIndex > 0 && /\s/.test(beforeCursor[hashIndex - 1]))) {
|
|
371
|
+
clearTimeout(this._snippetsDebounceTimer);
|
|
372
|
+
this._snippetsDebounceTimer = setTimeout(() => {
|
|
373
|
+
snippetsAutocomplete.show(query, hashIndex);
|
|
374
|
+
}, query ? 50 : 0);
|
|
375
|
+
} else {
|
|
376
|
+
snippetsAutocomplete.hide();
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
snippetsAutocomplete.hide();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Handle keydown events
|
|
385
|
+
*/
|
|
386
|
+
_onInputKeydown(e) {
|
|
387
|
+
const autocomplete = this.getAutocomplete();
|
|
388
|
+
const fileAutocomplete = this.getFileAutocomplete();
|
|
389
|
+
const snippetsAutocomplete = this.getSnippetsAutocomplete();
|
|
390
|
+
const skillsAutocomplete = this.getSkillsAutocomplete();
|
|
391
|
+
|
|
392
|
+
// Tab-cycle lifecycle: Esc cancels (removes inserted char + hides popup);
|
|
393
|
+
// any non-navigation key exits the cycle visually but leaves the inserted
|
|
394
|
+
// char in place so the user can keep using it as a real trigger.
|
|
395
|
+
if (this._tabCycle) {
|
|
396
|
+
if (e.key === 'Escape') {
|
|
397
|
+
e.preventDefault();
|
|
398
|
+
this._cancelTabCycle();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const cycleNavKeys = ['Tab', 'Shift', 'ArrowUp', 'ArrowDown'];
|
|
402
|
+
if (!cycleNavKeys.includes(e.key)) {
|
|
403
|
+
this._exitTabCycle();
|
|
404
|
+
// fall through — let the key be processed normally
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// File autocomplete navigation (takes priority)
|
|
409
|
+
if (fileAutocomplete?.visible) {
|
|
410
|
+
if (e.key === 'ArrowUp') {
|
|
411
|
+
e.preventDefault();
|
|
412
|
+
fileAutocomplete.moveSelection(-1);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
if (e.key === 'ArrowDown') {
|
|
416
|
+
e.preventDefault();
|
|
417
|
+
fileAutocomplete.moveSelection(1);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (e.key === 'Tab' && fileAutocomplete.hasSelection()) {
|
|
421
|
+
e.preventDefault();
|
|
422
|
+
fileAutocomplete.select();
|
|
423
|
+
if (this._tabCycle) this._exitTabCycle();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Alt+Enter or Cmd/Ctrl+Enter: Preview file instead of inserting
|
|
427
|
+
if (e.key === 'Enter' && (e.altKey || e.metaKey || e.ctrlKey) && fileAutocomplete.hasSelection()) {
|
|
428
|
+
e.preventDefault();
|
|
429
|
+
const path = fileAutocomplete.getSelectedPath();
|
|
430
|
+
if (path) {
|
|
431
|
+
fileAutocomplete.hide();
|
|
432
|
+
this.onPreviewFile(path);
|
|
433
|
+
}
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
// Regular Enter: Insert file path
|
|
437
|
+
if (e.key === 'Enter' && fileAutocomplete.hasSelection()) {
|
|
438
|
+
e.preventDefault();
|
|
439
|
+
fileAutocomplete.select();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (e.key === 'Escape') {
|
|
443
|
+
e.preventDefault();
|
|
444
|
+
fileAutocomplete.hide();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Skills autocomplete navigation ($ trigger)
|
|
450
|
+
if (skillsAutocomplete?.visible) {
|
|
451
|
+
if (e.key === 'ArrowUp') {
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
skillsAutocomplete.moveSelection(-1);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (e.key === 'ArrowDown') {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
skillsAutocomplete.moveSelection(1);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
462
|
+
if (skillsAutocomplete.hasSelection()) {
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
skillsAutocomplete.select();
|
|
465
|
+
if (this._tabCycle) this._exitTabCycle();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (e.key === 'Escape') {
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
skillsAutocomplete.hide();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Snippets autocomplete navigation
|
|
477
|
+
if (snippetsAutocomplete?.visible) {
|
|
478
|
+
if (e.key === 'ArrowUp') {
|
|
479
|
+
e.preventDefault();
|
|
480
|
+
snippetsAutocomplete.moveSelection(-1);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (e.key === 'ArrowDown') {
|
|
484
|
+
e.preventDefault();
|
|
485
|
+
snippetsAutocomplete.moveSelection(1);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// Ctrl+Enter or Cmd+Enter: Select and send immediately
|
|
489
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
490
|
+
if (snippetsAutocomplete.hasSelection()) {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
snippetsAutocomplete.selectAndSend();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Alt+Enter: Insert just the name (short form)
|
|
497
|
+
if (e.key === 'Enter' && e.altKey) {
|
|
498
|
+
if (snippetsAutocomplete.hasSelection()) {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
snippetsAutocomplete.select(undefined, { short: true });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
505
|
+
if (snippetsAutocomplete.hasSelection()) {
|
|
506
|
+
e.preventDefault();
|
|
507
|
+
snippetsAutocomplete.select();
|
|
508
|
+
if (this._tabCycle) this._exitTabCycle();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (e.key === 'Escape') {
|
|
513
|
+
e.preventDefault();
|
|
514
|
+
snippetsAutocomplete.hide();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Slash/bang command autocomplete navigation
|
|
520
|
+
if (autocomplete?.visible) {
|
|
521
|
+
if (e.key === 'ArrowUp') {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
autocomplete.moveSelection(-1);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (e.key === 'ArrowDown') {
|
|
527
|
+
e.preventDefault();
|
|
528
|
+
autocomplete.moveSelection(1);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (e.key === 'Tab' && autocomplete.hasSelection()) {
|
|
532
|
+
e.preventDefault();
|
|
533
|
+
autocomplete.select();
|
|
534
|
+
if (this._tabCycle) this._exitTabCycle();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (e.key === 'Enter' && autocomplete.hasSelection()) {
|
|
538
|
+
e.preventDefault();
|
|
539
|
+
autocomplete.select();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (e.key === 'Escape') {
|
|
543
|
+
e.preventDefault();
|
|
544
|
+
autocomplete.hide();
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Tab cycle through trigger pickers (#, /, @, $) — advances when no popup
|
|
550
|
+
// consumed the Tab above. Suppresses the browser's default focus traversal,
|
|
551
|
+
// which otherwise jumps to the nearest <select> outside the input.
|
|
552
|
+
if (e.key === 'Tab') {
|
|
553
|
+
e.preventDefault();
|
|
554
|
+
this._advanceTabCycle(e.shiftKey ? -1 : 1);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// NOTE: Escape is deliberately NOT handled here. ShortcutManager binds
|
|
559
|
+
// document keydown in the CAPTURE phase and calls
|
|
560
|
+
// stopImmediatePropagation() (shortcuts.js), so an Escape branch in this
|
|
561
|
+
// listener is unreachable dead code. Cancelling a parked send lives in
|
|
562
|
+
// app.handleEscape()'s priority chain instead.
|
|
563
|
+
|
|
564
|
+
// Input history navigation (fish-shell style)
|
|
565
|
+
// Up arrow: go back in history (when input empty OR already browsing)
|
|
566
|
+
if (e.key === 'ArrowUp' && !autocomplete?.visible) {
|
|
567
|
+
const history = this.getHistory();
|
|
568
|
+
if (history.length === 0) return;
|
|
569
|
+
|
|
570
|
+
// Can enter history mode if: empty input OR already in history mode
|
|
571
|
+
const isEmpty = this.els.messageInput.value.trim() === '';
|
|
572
|
+
if (!isEmpty && this.historyIndex === -1) return;
|
|
573
|
+
|
|
574
|
+
e.preventDefault();
|
|
575
|
+
|
|
576
|
+
if (this.historyIndex === -1) {
|
|
577
|
+
// Entering history mode - save current text as draft (with prefix if in input mode)
|
|
578
|
+
this.historyDraft = this._prependModePrefix(this.els.messageInput.value);
|
|
579
|
+
this.historyIndex = 0;
|
|
580
|
+
} else if (this.historyIndex < history.length - 1) {
|
|
581
|
+
// Go further back
|
|
582
|
+
this.historyIndex++;
|
|
583
|
+
} else {
|
|
584
|
+
return; // Already at oldest
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const entry = history[this.historyIndex];
|
|
588
|
+
this.els.messageInput.value = historyEntryText(entry);
|
|
589
|
+
this._updateInputAfterHistoryNav(entry);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Down arrow: go forward in history (only when browsing)
|
|
594
|
+
if (e.key === 'ArrowDown' && !autocomplete?.visible && this.historyIndex !== -1) {
|
|
595
|
+
e.preventDefault();
|
|
596
|
+
|
|
597
|
+
let entry = null;
|
|
598
|
+
if (this.historyIndex > 0) {
|
|
599
|
+
// Go forward in history
|
|
600
|
+
this.historyIndex--;
|
|
601
|
+
const history = this.getHistory();
|
|
602
|
+
entry = history[this.historyIndex];
|
|
603
|
+
this.els.messageInput.value = historyEntryText(entry);
|
|
604
|
+
} else {
|
|
605
|
+
// Restore draft and exit history mode
|
|
606
|
+
this.els.messageInput.value = this.historyDraft;
|
|
607
|
+
this.historyIndex = -1;
|
|
608
|
+
this.historyDraft = '';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
this._updateInputAfterHistoryNav(entry);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Exit history mode on cursor movement (user wants to edit)
|
|
616
|
+
if (this.historyIndex !== -1 &&
|
|
617
|
+
['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
|
|
618
|
+
this.historyIndex = -1;
|
|
619
|
+
this.historyDraft = '';
|
|
620
|
+
this._renderHistoryRefsHint(null);
|
|
621
|
+
// Don't prevent default - let the cursor move
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Backspace on empty in input mode exits the mode
|
|
625
|
+
if (e.key === 'Backspace' && this.inputMode && !this.els.messageInput.value) {
|
|
626
|
+
e.preventDefault();
|
|
627
|
+
this._exitInputMode();
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Ctrl+Shift+Enter / Cmd+Shift+Enter: Send in new session
|
|
632
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
|
|
633
|
+
e.preventDefault();
|
|
634
|
+
const value = this.els.messageInput.value.trim();
|
|
635
|
+
if (!value) return;
|
|
636
|
+
this.els.messageInput.value = '';
|
|
637
|
+
this.onAutoResize();
|
|
638
|
+
this.els.sendBtn.disabled = true;
|
|
639
|
+
if (this.els.followupBtn) this.els.followupBtn.disabled = true;
|
|
640
|
+
this.clearDraft();
|
|
641
|
+
this._consumePendingDraft();
|
|
642
|
+
this.historyIndex = -1;
|
|
643
|
+
this.historyDraft = '';
|
|
644
|
+
this._renderHistoryRefsHint(null);
|
|
645
|
+
this.onSendInNewSession(value);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Send behavior varies by device:
|
|
650
|
+
// - iPhone (touch): Cmd+Enter sends, Enter adds newline
|
|
651
|
+
// - iPad/Desktop (keyboard): Enter sends, Shift+Enter adds newline
|
|
652
|
+
// - Welcome screen: Shift+Enter also sends but inverts action (no newlines needed)
|
|
653
|
+
const isWelcome = this.isWelcomeMode();
|
|
654
|
+
const shouldSend = e.key === 'Enter' && (
|
|
655
|
+
(e.ctrlKey || e.metaKey) || // Cmd/Ctrl+Enter always sends
|
|
656
|
+
(this.hasPhysicalKeyboard && !e.shiftKey) || // Enter sends on keyboard devices
|
|
657
|
+
(this.hasPhysicalKeyboard && e.shiftKey && isWelcome) // Shift+Enter on welcome = invert
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
if (shouldSend) {
|
|
661
|
+
e.preventDefault();
|
|
662
|
+
// If disconnected and a project (cwd) is set, connect instead
|
|
663
|
+
const session = this.getSession();
|
|
664
|
+
if (session?.status !== 'connected') {
|
|
665
|
+
if (this.getCwd()) {
|
|
666
|
+
this.onConnect();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
this.handleInput({ shiftKey: e.shiftKey });
|
|
671
|
+
}
|
|
672
|
+
// Otherwise: Enter adds newline (default behavior on iPhone, or Shift+Enter on keyboard devices)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Update input field state after history navigation
|
|
677
|
+
*/
|
|
678
|
+
_updateInputAfterHistoryNav(entry = null) {
|
|
679
|
+
// Surface the stash references this prompt was sent with. Recall is
|
|
680
|
+
// deliberately display-only — re-arming items that were already sent
|
|
681
|
+
// (and may since have been edited or removed) would be worse than the
|
|
682
|
+
// ambiguity this hint exists to remove.
|
|
683
|
+
this._renderHistoryRefsHint(entry);
|
|
684
|
+
|
|
685
|
+
// Check if navigated to a prefixed command — apply input mode
|
|
686
|
+
const wasMode = this.inputMode;
|
|
687
|
+
const val = this.els.messageInput.value;
|
|
688
|
+
if (val.startsWith('!')) {
|
|
689
|
+
this.inputMode = 'shell';
|
|
690
|
+
this.els.messageInput.value = val.slice(1);
|
|
691
|
+
} else if (val.startsWith('/plan ')) {
|
|
692
|
+
this.inputMode = 'plan';
|
|
693
|
+
this.els.messageInput.value = val.slice(6);
|
|
694
|
+
} else {
|
|
695
|
+
this.inputMode = null;
|
|
696
|
+
}
|
|
697
|
+
this._applyInputMode();
|
|
698
|
+
this._notifyPlanComposeChange(wasMode);
|
|
699
|
+
// Move cursor to end
|
|
700
|
+
const len = this.els.messageInput.value.length;
|
|
701
|
+
this.els.messageInput.selectionStart = len;
|
|
702
|
+
this.els.messageInput.selectionEnd = len;
|
|
703
|
+
this.onAutoResize();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Render (or hide) the "sent with N references" hint under the input.
|
|
708
|
+
* Pass null to hide — every exit from history mode does.
|
|
709
|
+
*/
|
|
710
|
+
_renderHistoryRefsHint(entry) {
|
|
711
|
+
const el = this.els.historyRefsHint;
|
|
712
|
+
if (!el) return;
|
|
713
|
+
|
|
714
|
+
const refs = historyEntryRefs(entry);
|
|
715
|
+
if (!refs) {
|
|
716
|
+
el.classList.remove('visible');
|
|
717
|
+
el.innerHTML = '';
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// A stash-only send recalls as an empty input — that is the case this
|
|
722
|
+
// hint exists for, so it gets its own wording rather than looking like
|
|
723
|
+
// the recall silently failed.
|
|
724
|
+
const isEmpty = historyEntryText(entry).trim() === '';
|
|
725
|
+
const strings = S.ui.stash;
|
|
726
|
+
const key = isEmpty
|
|
727
|
+
? (refs.length === 1 ? 'recall_hint_empty_one' : 'recall_hint_empty_many')
|
|
728
|
+
: (refs.length === 1 ? 'recall_hint_one' : 'recall_hint_many');
|
|
729
|
+
|
|
730
|
+
// Same block the sent-message bubble draws, from the same refs — the
|
|
731
|
+
// count alone left the user knowing a prompt had references without
|
|
732
|
+
// being able to see which. Expanded when there is nothing else to look
|
|
733
|
+
// at (no typed text) or when there is only one; collapsed otherwise so
|
|
734
|
+
// a large stash cannot shove the input off screen.
|
|
735
|
+
// Re-attach is offered only for refs whose ORIGINAL stash items are
|
|
736
|
+
// still around and not already armed. Rebuilding them from the refs
|
|
737
|
+
// themselves would be a trap: `selectedText` is truncated to 300 chars
|
|
738
|
+
// for display, so a rebuilt item would quietly send a clipped snippet.
|
|
739
|
+
const ids = refs.map(r => r.id).filter(Boolean);
|
|
740
|
+
const restorable = Stash.restorableIds(ids);
|
|
741
|
+
const actionHtml = restorable.length > 0
|
|
742
|
+
? `<button type="button" class="history-refs-reattach" data-action="reattach"
|
|
743
|
+
data-tooltip="${escapeAttr(strings.reattach_tooltip)}">${escapeHtml(strings.reattach)}</button>`
|
|
744
|
+
: '';
|
|
745
|
+
|
|
746
|
+
el.innerHTML = renderStashRefs(refs, {
|
|
747
|
+
label: strings[key].replace('{count}', refs.length),
|
|
748
|
+
open: isEmpty || refs.length === 1,
|
|
749
|
+
}) + actionHtml;
|
|
750
|
+
el.dataset.tooltip = strings.recall_hint_tooltip;
|
|
751
|
+
el.classList.add('visible');
|
|
752
|
+
|
|
753
|
+
el.querySelector('[data-action="reattach"]')
|
|
754
|
+
?.addEventListener('click', (e) => this._reattachRecalledRefs(e, ids, entry));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Re-arm the stash items a recalled prompt was sent with.
|
|
759
|
+
* @private
|
|
760
|
+
*/
|
|
761
|
+
async _reattachRecalledRefs(e, ids, entry) {
|
|
762
|
+
e.preventDefault();
|
|
763
|
+
e.stopPropagation();
|
|
764
|
+
|
|
765
|
+
const strings = S.ui.stash;
|
|
766
|
+
const { restored, missing } = await Stash.reattach(ids);
|
|
767
|
+
|
|
768
|
+
if (restored === 0 && missing > 0) {
|
|
769
|
+
showToast(strings.reattach_none);
|
|
770
|
+
} else {
|
|
771
|
+
const parts = [
|
|
772
|
+
(restored === 1 ? strings.reattached_one : strings.reattached_many)
|
|
773
|
+
.replace('{count}', restored),
|
|
774
|
+
];
|
|
775
|
+
if (missing > 0) {
|
|
776
|
+
parts.push(
|
|
777
|
+
(missing === 1 ? strings.reattach_missing_one : strings.reattach_missing_many)
|
|
778
|
+
.replace('{count}', missing)
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
showToast(parts.join(' · '));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// Re-render: the button drops out now that the items are armed, and
|
|
785
|
+
// the stash preview bar above the input picks them up on its own.
|
|
786
|
+
this._renderHistoryRefsHint(entry);
|
|
787
|
+
this.els.messageInput.focus();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Fire onPlanComposeChange when this.inputMode transitions into/out of
|
|
792
|
+
* 'plan'. Call after any direct mutation of this.inputMode.
|
|
793
|
+
*/
|
|
794
|
+
_notifyPlanComposeChange(wasMode) {
|
|
795
|
+
if (wasMode !== 'plan' && this.inputMode === 'plan') {
|
|
796
|
+
this.onPlanComposeChange(true);
|
|
797
|
+
} else if (wasMode === 'plan' && this.inputMode !== 'plan') {
|
|
798
|
+
this.onPlanComposeChange(false);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Get the prefix string for the current input mode
|
|
804
|
+
*/
|
|
805
|
+
_getModePrefix() {
|
|
806
|
+
if (this.inputMode === 'shell') return '!';
|
|
807
|
+
if (this.inputMode === 'plan') return '/plan ';
|
|
808
|
+
return '';
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Prepend the current mode's prefix to a value
|
|
813
|
+
*/
|
|
814
|
+
_prependModePrefix(value) {
|
|
815
|
+
const prefix = this._getModePrefix();
|
|
816
|
+
return prefix && value ? prefix + value : value;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Apply input mode CSS class and placeholder
|
|
821
|
+
*/
|
|
822
|
+
_applyInputMode() {
|
|
823
|
+
const container = this.els.messageInput.closest('#input-container');
|
|
824
|
+
if (container) {
|
|
825
|
+
container.classList.toggle('shell-mode', this.inputMode === 'shell');
|
|
826
|
+
container.classList.toggle('plan-input-mode', this.inputMode === 'plan');
|
|
827
|
+
}
|
|
828
|
+
this.els.messageInput.placeholder = this.inputMode === 'shell' ? 'command...'
|
|
829
|
+
: this.inputMode === 'plan' ? 'Describe what to plan...'
|
|
830
|
+
: S.ui.input.placeholder;
|
|
831
|
+
// Suppress hint overlay + drafts pill in shell/plan modes
|
|
832
|
+
ShortcutHints.setMode(this.inputMode);
|
|
833
|
+
DraftsPill.setMode(this.inputMode);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Exit input mode, restoring the prefix to the input
|
|
838
|
+
*/
|
|
839
|
+
_exitInputMode() {
|
|
840
|
+
if (!this.inputMode) return;
|
|
841
|
+
const wasMode = this.inputMode;
|
|
842
|
+
const prefix = this._getModePrefix();
|
|
843
|
+
this.inputMode = null;
|
|
844
|
+
this._applyInputMode();
|
|
845
|
+
this._notifyPlanComposeChange(wasMode);
|
|
846
|
+
// Hide autocomplete (recent commands popup)
|
|
847
|
+
const autocomplete = this.getAutocomplete();
|
|
848
|
+
if (autocomplete) autocomplete.hide();
|
|
849
|
+
// Prepend prefix back so user sees what they had
|
|
850
|
+
const current = this.els.messageInput.value;
|
|
851
|
+
if (current) {
|
|
852
|
+
this.els.messageInput.value = prefix + current;
|
|
853
|
+
}
|
|
854
|
+
this.onAutoResize();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ─────────────────────────────────────────────────────────────────
|
|
858
|
+
// Tab-cycle through trigger pickers
|
|
859
|
+
// ─────────────────────────────────────────────────────────────────
|
|
860
|
+
|
|
861
|
+
_advanceTabCycle(dir) {
|
|
862
|
+
let nextIdx;
|
|
863
|
+
if (!this._tabCycle) {
|
|
864
|
+
nextIdx = dir > 0 ? 0 : TAB_CYCLE_TRIGGERS.length - 1;
|
|
865
|
+
} else {
|
|
866
|
+
const curIdx = TAB_CYCLE_TRIGGERS.findIndex(t => t.char === this._tabCycle.trigger);
|
|
867
|
+
nextIdx = (curIdx + dir + TAB_CYCLE_TRIGGERS.length) % TAB_CYCLE_TRIGGERS.length;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Clear any stale no-preselect flags from prior advance steps
|
|
871
|
+
// (rapid Tab presses can queue async show()s on multiple popups)
|
|
872
|
+
for (const t of TAB_CYCLE_TRIGGERS) {
|
|
873
|
+
const a = this._getAutocompleteForPopup(t.popup);
|
|
874
|
+
if (a) a.noPreselectOnce = false;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Remove the previously-inserted trigger before inserting the next
|
|
878
|
+
if (this._tabCycle) this._removeTabCycleInsertion();
|
|
879
|
+
|
|
880
|
+
const next = TAB_CYCLE_TRIGGERS[nextIdx];
|
|
881
|
+
const input = this.els.messageInput;
|
|
882
|
+
const pos = input.selectionStart;
|
|
883
|
+
const value = input.value;
|
|
884
|
+
const needsSpace = pos > 0 && !/\s/.test(value[pos - 1]);
|
|
885
|
+
const insertText = needsSpace ? ' ' + next.char : next.char;
|
|
886
|
+
|
|
887
|
+
input.value = value.slice(0, pos) + insertText + value.slice(pos);
|
|
888
|
+
const newCursor = pos + insertText.length;
|
|
889
|
+
input.selectionStart = input.selectionEnd = newCursor;
|
|
890
|
+
|
|
891
|
+
this._tabCycle = {
|
|
892
|
+
trigger: next.char,
|
|
893
|
+
insertStart: pos,
|
|
894
|
+
insertEnd: newCursor,
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
// Tell the popup to skip preselection on this show()
|
|
898
|
+
const ac = this._getAutocompleteForPopup(next.popup);
|
|
899
|
+
if (ac) ac.noPreselectOnce = true;
|
|
900
|
+
|
|
901
|
+
this._highlightTabCycleBtn(next.btnId);
|
|
902
|
+
|
|
903
|
+
// Fire input event so the existing autocomplete plumbing shows the popup
|
|
904
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
905
|
+
this.onAutoResize();
|
|
906
|
+
|
|
907
|
+
// Async autocompletes may resolve after the input event — re-apply
|
|
908
|
+
// the no-selection state once they do.
|
|
909
|
+
queueMicrotask(() => this._enforceTabCycleDeselect(next.popup));
|
|
910
|
+
setTimeout(() => this._enforceTabCycleDeselect(next.popup), 120);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
_enforceTabCycleDeselect(popup) {
|
|
914
|
+
if (!this._tabCycle || this._tabCycle.trigger !== TAB_CYCLE_TRIGGERS.find(t => t.popup === popup)?.char) return;
|
|
915
|
+
const ac = this._getAutocompleteForPopup(popup);
|
|
916
|
+
if (ac && ac.visible && ac.selectedIndex !== -1) {
|
|
917
|
+
ac.selectedIndex = -1;
|
|
918
|
+
if (typeof ac.render === 'function') ac.render();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
_getAutocompleteForPopup(popup) {
|
|
923
|
+
switch (popup) {
|
|
924
|
+
case 'snippets': return this.getSnippetsAutocomplete();
|
|
925
|
+
case 'slash': return this.getAutocomplete();
|
|
926
|
+
case 'file': return this.getFileAutocomplete();
|
|
927
|
+
case 'skills': return this.getSkillsAutocomplete();
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
_removeTabCycleInsertion() {
|
|
933
|
+
if (!this._tabCycle) return;
|
|
934
|
+
const input = this.els.messageInput;
|
|
935
|
+
const value = input.value;
|
|
936
|
+
const { insertStart, insertEnd } = this._tabCycle;
|
|
937
|
+
if (insertEnd > value.length) return; // user edited; bail out safely
|
|
938
|
+
input.value = value.slice(0, insertStart) + value.slice(insertEnd);
|
|
939
|
+
input.selectionStart = input.selectionEnd = insertStart;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
_cancelTabCycle() {
|
|
943
|
+
this._removeTabCycleInsertion();
|
|
944
|
+
this.getAutocomplete()?.hide();
|
|
945
|
+
this.getFileAutocomplete()?.hide();
|
|
946
|
+
this.getSnippetsAutocomplete()?.hide();
|
|
947
|
+
this.getSkillsAutocomplete()?.hide();
|
|
948
|
+
this._exitTabCycle();
|
|
949
|
+
this.onAutoResize();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
_exitTabCycle() {
|
|
953
|
+
// Clear any pending no-preselect flag so non-cycle uses behave normally
|
|
954
|
+
for (const t of TAB_CYCLE_TRIGGERS) {
|
|
955
|
+
const a = this._getAutocompleteForPopup(t.popup);
|
|
956
|
+
if (a) a.noPreselectOnce = false;
|
|
957
|
+
}
|
|
958
|
+
this._clearTabCycleHighlights();
|
|
959
|
+
this._tabCycle = null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Public: is a Tab-cycle currently in progress? */
|
|
963
|
+
isTabCycleActive() {
|
|
964
|
+
return !!this._tabCycle;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Public: cancel the Tab-cycle (called by the global Esc handler). */
|
|
968
|
+
cancelTabCycle() {
|
|
969
|
+
this._cancelTabCycle();
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
_highlightTabCycleBtn(btnId) {
|
|
973
|
+
this._clearTabCycleHighlights();
|
|
974
|
+
const btn = document.getElementById(btnId);
|
|
975
|
+
if (btn) btn.classList.add('tab-cycle-active');
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
_clearTabCycleHighlights() {
|
|
979
|
+
for (const t of TAB_CYCLE_TRIGGERS) {
|
|
980
|
+
const btn = document.getElementById(t.btnId);
|
|
981
|
+
if (btn) btn.classList.remove('tab-cycle-active');
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// ─────────────────────────────────────────────────────────────────
|
|
986
|
+
// Public API
|
|
987
|
+
// ─────────────────────────────────────────────────────────────────
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* True while a send is parked waiting on an in-flight upload. Read by
|
|
991
|
+
* app.updateSendButtonState() so the button stays disabled for as long as
|
|
992
|
+
* the park lasts — that state-update runs on every upload progress tick and
|
|
993
|
+
* would otherwise re-enable the button underneath the parked send.
|
|
994
|
+
*/
|
|
995
|
+
isAwaitingUpload() {
|
|
996
|
+
return this._deferredSend !== null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Reflect "waiting on upload" on the send controls.
|
|
1001
|
+
*/
|
|
1002
|
+
_applyDeferredSendUI(waiting) {
|
|
1003
|
+
const btn = this.els.sendBtn;
|
|
1004
|
+
if (!btn) return;
|
|
1005
|
+
btn.classList.toggle('awaiting-upload', waiting);
|
|
1006
|
+
btn.setAttribute('aria-busy', waiting ? 'true' : 'false');
|
|
1007
|
+
if (waiting) btn.disabled = true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Would a send of `value` carry the session's attachments? Only then is it
|
|
1012
|
+
* worth parking behind an in-flight upload.
|
|
1013
|
+
*
|
|
1014
|
+
* Bang commands run locally in the browser's shell widget and never take an
|
|
1015
|
+
* attachment, so stalling `!ls` behind an image upload buys nothing and
|
|
1016
|
+
* makes the input feel stuck. Slash commands DO park: the dispatch below
|
|
1017
|
+
* routes a slash-prefixed value to onSendMessage() as a normal message once
|
|
1018
|
+
* images are present, so waiting is what keeps those two paths agreeing.
|
|
1019
|
+
*/
|
|
1020
|
+
_sendTakesAttachments(value) {
|
|
1021
|
+
if (this.inputMode === 'shell') return false;
|
|
1022
|
+
return !value.startsWith('!');
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Called when upload state settles. If a send was parked by handleInput()
|
|
1027
|
+
* while attachments were uploading, replay it now that the attachments are
|
|
1028
|
+
* actually available. If every upload failed there is nothing to attach —
|
|
1029
|
+
* drop the deferral and leave the text in the box so the user can retry
|
|
1030
|
+
* rather than silently sending a message stripped of its attachment.
|
|
1031
|
+
*/
|
|
1032
|
+
flushDeferredSend() {
|
|
1033
|
+
if (!this._deferredSend || this.isUploading()) return;
|
|
1034
|
+
|
|
1035
|
+
const options = this._deferredSend;
|
|
1036
|
+
this._deferredSend = null;
|
|
1037
|
+
this._applyDeferredSendUI(false);
|
|
1038
|
+
|
|
1039
|
+
// Images AND files — a deferred send may have been waiting on either.
|
|
1040
|
+
if (!this.hasPendingUploads()) {
|
|
1041
|
+
showToast(S.uploads.send_waiting_failed);
|
|
1042
|
+
this.onUpdateSendButton();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
this.handleInput(options);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Abandon a parked send (Escape, input cleared, session switch) so it can't
|
|
1051
|
+
* fire unexpectedly once uploads finish.
|
|
1052
|
+
*/
|
|
1053
|
+
cancelDeferredSend() {
|
|
1054
|
+
if (!this._deferredSend) return;
|
|
1055
|
+
this._deferredSend = null;
|
|
1056
|
+
this._applyDeferredSendUI(false);
|
|
1057
|
+
this.onUpdateSendButton();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Process input and send message or command
|
|
1062
|
+
* @param {Object} options - Options
|
|
1063
|
+
* @param {boolean} options.shiftKey - Whether Shift was held (for welcome screen invert)
|
|
1064
|
+
*/
|
|
1065
|
+
handleInput(options = {}) {
|
|
1066
|
+
let value = this.els.messageInput.value.trim();
|
|
1067
|
+
const images = this.getPendingImages();
|
|
1068
|
+
const hasImages = images.length > 0;
|
|
1069
|
+
const hasStash = Stash.hasEnabled();
|
|
1070
|
+
|
|
1071
|
+
// Attachments still uploading — park the send instead of firing it now.
|
|
1072
|
+
// getPendingImages() only reports COMPLETED uploads, so sending here
|
|
1073
|
+
// would silently drop the in-flight attachment and deliver text only.
|
|
1074
|
+
// flushDeferredSend() replays this once the uploads settle.
|
|
1075
|
+
if (this.isUploading() && this._sendTakesAttachments(value)) {
|
|
1076
|
+
const alreadyArmed = this._deferredSend !== null;
|
|
1077
|
+
this._deferredSend = options;
|
|
1078
|
+
this._applyDeferredSendUI(true);
|
|
1079
|
+
if (!alreadyArmed) showToast(S.uploads.send_waiting);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Reached a real send — make sure no stale "waiting" state lingers.
|
|
1084
|
+
this._deferredSend = null;
|
|
1085
|
+
this._applyDeferredSendUI(false);
|
|
1086
|
+
|
|
1087
|
+
// In input mode, prepend prefix back for dispatch. Plan mode dispatches
|
|
1088
|
+
// even when empty — a bare /plan just switches to plan permission.
|
|
1089
|
+
if (this.inputMode === 'plan') {
|
|
1090
|
+
value = this._getModePrefix() + value;
|
|
1091
|
+
} else if (this.inputMode && value) {
|
|
1092
|
+
value = this._getModePrefix() + value;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Need either text, images, or stash items to send
|
|
1096
|
+
if (!value && !hasImages && !hasStash) return;
|
|
1097
|
+
|
|
1098
|
+
const autocomplete = this.getAutocomplete();
|
|
1099
|
+
autocomplete?.hide();
|
|
1100
|
+
|
|
1101
|
+
// Reset history navigation state
|
|
1102
|
+
this.historyIndex = -1;
|
|
1103
|
+
this.historyDraft = '';
|
|
1104
|
+
this._renderHistoryRefsHint(null);
|
|
1105
|
+
|
|
1106
|
+
// Exit input mode on send
|
|
1107
|
+
this.inputMode = null;
|
|
1108
|
+
this._applyInputMode();
|
|
1109
|
+
|
|
1110
|
+
// Add all input to session history (slash commands, bang commands, messages)
|
|
1111
|
+
if (value) {
|
|
1112
|
+
this.addToHistory(value);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Parse command (only if no images - images go with regular messages)
|
|
1116
|
+
if (value.startsWith('/') && !hasImages) {
|
|
1117
|
+
this.onSlashCommand(value);
|
|
1118
|
+
} else if (value.startsWith('!') && !hasImages) {
|
|
1119
|
+
this.onBangCommand(value);
|
|
1120
|
+
} else {
|
|
1121
|
+
this.onSendMessage(value, { shiftKey: options.shiftKey || false });
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
this.els.messageInput.value = '';
|
|
1125
|
+
this.onAutoResize();
|
|
1126
|
+
this.els.sendBtn.disabled = true;
|
|
1127
|
+
if (this.els.followupBtn) this.els.followupBtn.disabled = true;
|
|
1128
|
+
|
|
1129
|
+
// Clear saved draft after sending
|
|
1130
|
+
this.clearDraft();
|
|
1131
|
+
// Sending a loaded saved-draft consumes it (server-side delete)
|
|
1132
|
+
this._consumePendingDraft();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Get user message history (most recent first)
|
|
1137
|
+
* Returns per-session history (in-memory) for up/down navigation
|
|
1138
|
+
*/
|
|
1139
|
+
getHistory() {
|
|
1140
|
+
const session = this.getSession();
|
|
1141
|
+
return session?.promptHistory || [];
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Add a message to session history (in-memory) for up/down navigation.
|
|
1146
|
+
*
|
|
1147
|
+
* @param {string} content - the text the user typed (may be empty)
|
|
1148
|
+
* @param {Array|null} stashRefs - compact stash references sent with it
|
|
1149
|
+
*/
|
|
1150
|
+
addToHistory(content, stashRefs = null) {
|
|
1151
|
+
pushHistoryEntry(this.getSession(), content, stashRefs);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Get draft storage key. Prefer storeId (server-side, stable across
|
|
1156
|
+
* SessionManager.reconcileWithServer rebuilds) over client-side id, which
|
|
1157
|
+
* is regenerated when reconcile constructs fresh Session stubs.
|
|
1158
|
+
*/
|
|
1159
|
+
getDraftKey() {
|
|
1160
|
+
const session = this.getSession();
|
|
1161
|
+
const key = session?.storeId || session?.id || 'default';
|
|
1162
|
+
return `claude-draft:${key}`;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Storage key for the id of the server-side draft mirroring this
|
|
1167
|
+
* session's input (written by the auto-sync). Persisting it means a
|
|
1168
|
+
* refresh keeps updating the same draft instead of duplicating it.
|
|
1169
|
+
*/
|
|
1170
|
+
getDraftLinkKey() {
|
|
1171
|
+
const session = this.getSession();
|
|
1172
|
+
const key = session?.storeId || session?.id || 'default';
|
|
1173
|
+
return `claude-draft-link:${key}`;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Save draft
|
|
1178
|
+
*/
|
|
1179
|
+
saveDraft(text) {
|
|
1180
|
+
if (!text || !text.trim()) {
|
|
1181
|
+
this.clearDraft();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
Storage.set(this.getDraftKey(), text);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Load draft
|
|
1189
|
+
*/
|
|
1190
|
+
loadDraft() {
|
|
1191
|
+
return Storage.get(this.getDraftKey(), '');
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Clear draft (text + the link to its server-side mirror)
|
|
1196
|
+
*/
|
|
1197
|
+
clearDraft() {
|
|
1198
|
+
Storage.remove(this.getDraftKey());
|
|
1199
|
+
Storage.remove(this.getDraftLinkKey());
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Restore draft to input
|
|
1204
|
+
*/
|
|
1205
|
+
restoreDraft() {
|
|
1206
|
+
// Session switch/startup: a pending auto-sync belongs to the previous
|
|
1207
|
+
// session's input — cancel it (its localStorage draft keeps the tail)
|
|
1208
|
+
this._cancelDraftSyncTimer();
|
|
1209
|
+
const draft = this.loadDraft();
|
|
1210
|
+
// Re-link the server-side draft mirroring this session's input (see
|
|
1211
|
+
// _syncDraftToServer) so edits keep updating it and sending consumes
|
|
1212
|
+
// it — instead of duplicating a draft after every refresh/switch
|
|
1213
|
+
this.pendingDraftId = draft ? (Storage.get(this.getDraftLinkKey(), null) || null) : null;
|
|
1214
|
+
if (draft) {
|
|
1215
|
+
// Detect mode from saved draft prefix
|
|
1216
|
+
if (draft.startsWith('!')) {
|
|
1217
|
+
this.inputMode = 'shell';
|
|
1218
|
+
this.els.messageInput.value = draft.slice(1);
|
|
1219
|
+
} else if (draft.startsWith('/plan ')) {
|
|
1220
|
+
this.inputMode = 'plan';
|
|
1221
|
+
this.els.messageInput.value = draft.slice(6);
|
|
1222
|
+
} else {
|
|
1223
|
+
this.els.messageInput.value = draft;
|
|
1224
|
+
}
|
|
1225
|
+
this._applyInputMode();
|
|
1226
|
+
this.onAutoResize();
|
|
1227
|
+
this.onUpdateSendButton();
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Reset history navigation state
|
|
1233
|
+
*/
|
|
1234
|
+
resetHistoryState() {
|
|
1235
|
+
this.historyIndex = -1;
|
|
1236
|
+
this.historyDraft = '';
|
|
1237
|
+
this._renderHistoryRefsHint(null);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1241
|
+
// Saved drafts (server-side, cross-session — /api/drafts)
|
|
1242
|
+
// ─────────────────────────────────────────────────────────────────
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Current input encoded with its mode prefix — the canonical draft text
|
|
1246
|
+
* (same scheme for the localStorage auto-draft and server-side drafts).
|
|
1247
|
+
* A bare prefix with no content encodes to '' (not a useful draft).
|
|
1248
|
+
*/
|
|
1249
|
+
_encodeDraftText() {
|
|
1250
|
+
const raw = this.els.messageInput.value;
|
|
1251
|
+
return this.inputMode === 'shell' && raw ? '!' + raw
|
|
1252
|
+
: this.inputMode === 'plan' && raw ? '/plan ' + raw
|
|
1253
|
+
: !this.inputMode ? raw : '';
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Debounced trigger for the auto-sync (called on every input change).
|
|
1258
|
+
*/
|
|
1259
|
+
_scheduleDraftSync() {
|
|
1260
|
+
if (this._draftSyncTimer) clearTimeout(this._draftSyncTimer);
|
|
1261
|
+
this._draftSyncTimer = setTimeout(() => {
|
|
1262
|
+
this._draftSyncTimer = null;
|
|
1263
|
+
this._syncDraftToServer();
|
|
1264
|
+
}, AUTO_DRAFT_SYNC_MS);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
_cancelDraftSyncTimer() {
|
|
1268
|
+
if (this._draftSyncTimer) {
|
|
1269
|
+
clearTimeout(this._draftSyncTimer);
|
|
1270
|
+
this._draftSyncTimer = null;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Auto-bank the in-progress input as a server-side draft: create one
|
|
1276
|
+
* once the text passes AUTO_DRAFT_MIN_CHARS, then keep updating that
|
|
1277
|
+
* same draft (linked via pendingDraftId + persisted link key). Sending
|
|
1278
|
+
* consumes it; clearing the input abandons it into the Drafts list.
|
|
1279
|
+
* Single-flight: a tick that fires mid-request re-runs afterwards.
|
|
1280
|
+
*/
|
|
1281
|
+
_syncDraftToServer() {
|
|
1282
|
+
if (this._draftSyncInflight) {
|
|
1283
|
+
this._draftSyncQueued = true;
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const text = this._encodeDraftText();
|
|
1288
|
+
if (!text.trim()) return;
|
|
1289
|
+
const idAtStart = this.pendingDraftId;
|
|
1290
|
+
if (!idAtStart && text.trim().length < AUTO_DRAFT_MIN_CHARS) return;
|
|
1291
|
+
|
|
1292
|
+
const keyAtStart = this.getDraftKey();
|
|
1293
|
+
const linkKeyAtStart = this.getDraftLinkKey();
|
|
1294
|
+
const epochAtStart = this._draftConsumeEpoch;
|
|
1295
|
+
const session = this.getSession();
|
|
1296
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1297
|
+
const body = JSON.stringify({
|
|
1298
|
+
text,
|
|
1299
|
+
cwd: this.getCwd(),
|
|
1300
|
+
session_id: session?.storeId || session?.id || null,
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
this._draftSyncInflight = (async () => {
|
|
1304
|
+
try {
|
|
1305
|
+
if (idAtStart) {
|
|
1306
|
+
const res = await fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(idAtStart)}`, {
|
|
1307
|
+
method: 'PUT', headers, body,
|
|
1308
|
+
});
|
|
1309
|
+
if (res.ok) return;
|
|
1310
|
+
if (res.status !== 404) throw new Error(`HTTP ${res.status}`);
|
|
1311
|
+
// 404 → deleted elsewhere (Drafts tab, other device);
|
|
1312
|
+
// fall through and recreate
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Input got sent while we waited — nothing left to bank
|
|
1316
|
+
if (this._draftConsumeEpoch !== epochAtStart) return;
|
|
1317
|
+
|
|
1318
|
+
const res = await fetch(`${CONFIG.API_BASE}/api/drafts`, { method: 'POST', headers, body });
|
|
1319
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1320
|
+
const created = (await res.json())?.draft;
|
|
1321
|
+
if (!created?.id) return;
|
|
1322
|
+
|
|
1323
|
+
if (this._draftConsumeEpoch !== epochAtStart) {
|
|
1324
|
+
// Sent while the create was in flight — the fresh draft is
|
|
1325
|
+
// a ghost of a sent message, delete it right back
|
|
1326
|
+
fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(created.id)}`, { method: 'DELETE' })
|
|
1327
|
+
.catch(() => {});
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Persist the link against the input this text belonged to,
|
|
1332
|
+
// so a refresh (or returning to that session) resumes
|
|
1333
|
+
// updating this draft instead of duplicating it
|
|
1334
|
+
Storage.set(linkKeyAtStart, created.id);
|
|
1335
|
+
if (this.getDraftKey() === keyAtStart && this.els.messageInput.value.trim()) {
|
|
1336
|
+
this.pendingDraftId = created.id;
|
|
1337
|
+
}
|
|
1338
|
+
// else: session switched / input emptied meanwhile → the
|
|
1339
|
+
// draft stays as an unlinked "abandoned" entry in the list
|
|
1340
|
+
window.dispatchEvent(new CustomEvent('drafts-changed'));
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
console.warn('Draft auto-sync failed:', err);
|
|
1343
|
+
} finally {
|
|
1344
|
+
this._draftSyncInflight = null;
|
|
1345
|
+
if (this._draftSyncQueued) {
|
|
1346
|
+
this._draftSyncQueued = false;
|
|
1347
|
+
this._scheduleDraftSync();
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
})();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* Bank the current input as a server-side draft (retrieve via Prompt
|
|
1355
|
+
* Explorer → Drafts, from any session). Clears the input on success.
|
|
1356
|
+
* If the auto-sync already mirrors this input, that draft is finalized
|
|
1357
|
+
* in place — no duplicate.
|
|
1358
|
+
*/
|
|
1359
|
+
async saveAsDraft() {
|
|
1360
|
+
// Encode active input mode as a prefix (same scheme as the auto-draft)
|
|
1361
|
+
const text = this._encodeDraftText();
|
|
1362
|
+
if (!text.trim()) return;
|
|
1363
|
+
|
|
1364
|
+
// Don't race the auto-sync: cancel the debounce and wait out any
|
|
1365
|
+
// in-flight upsert so the explicit save reuses its draft id
|
|
1366
|
+
this._cancelDraftSyncTimer();
|
|
1367
|
+
if (this._draftSyncInflight) {
|
|
1368
|
+
try { await this._draftSyncInflight; } catch { /* already logged */ }
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const session = this.getSession();
|
|
1372
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1373
|
+
const body = JSON.stringify({
|
|
1374
|
+
text,
|
|
1375
|
+
cwd: this.getCwd(),
|
|
1376
|
+
session_id: session?.storeId || session?.id || null,
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
try {
|
|
1380
|
+
let saved = false;
|
|
1381
|
+
if (this.pendingDraftId) {
|
|
1382
|
+
const res = await fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(this.pendingDraftId)}`, {
|
|
1383
|
+
method: 'PUT', headers, body,
|
|
1384
|
+
});
|
|
1385
|
+
if (res.ok) saved = true;
|
|
1386
|
+
else if (res.status !== 404) throw new Error(`HTTP ${res.status}`);
|
|
1387
|
+
}
|
|
1388
|
+
if (!saved) {
|
|
1389
|
+
const res = await fetch(`${CONFIG.API_BASE}/api/drafts`, { method: 'POST', headers, body });
|
|
1390
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Prompt is banked — clear input, mode, and the auto-draft
|
|
1394
|
+
this.els.messageInput.value = '';
|
|
1395
|
+
this.inputMode = null;
|
|
1396
|
+
this._applyInputMode();
|
|
1397
|
+
this.pendingDraftId = null;
|
|
1398
|
+
this.clearDraft();
|
|
1399
|
+
this.onAutoResize();
|
|
1400
|
+
this.onUpdateSendButton();
|
|
1401
|
+
ShortcutHints.updateVisibility('');
|
|
1402
|
+
DraftsPill.updateVisibility('');
|
|
1403
|
+
window.dispatchEvent(new CustomEvent('drafts-changed'));
|
|
1404
|
+
showToast(S.toast.draft_saved);
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
console.error('Failed to save draft:', err);
|
|
1407
|
+
showToast(S.toast.draft_save_failed);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Load a saved draft into the input (replaces current content) and
|
|
1413
|
+
* remember its id so sending consumes it.
|
|
1414
|
+
* @param {Object} draft - {id, text, ...} from /api/drafts
|
|
1415
|
+
*/
|
|
1416
|
+
insertSavedDraft(draft) {
|
|
1417
|
+
const text = draft?.text || '';
|
|
1418
|
+
|
|
1419
|
+
// Mode prefixes are encoded in the text (same scheme as auto-draft)
|
|
1420
|
+
if (text.startsWith('!')) {
|
|
1421
|
+
this.inputMode = 'shell';
|
|
1422
|
+
this.els.messageInput.value = text.slice(1);
|
|
1423
|
+
} else if (text.startsWith('/plan ')) {
|
|
1424
|
+
this.inputMode = 'plan';
|
|
1425
|
+
this.els.messageInput.value = text.slice(6);
|
|
1426
|
+
} else {
|
|
1427
|
+
this.inputMode = null;
|
|
1428
|
+
this.els.messageInput.value = text;
|
|
1429
|
+
}
|
|
1430
|
+
this._applyInputMode();
|
|
1431
|
+
this.onAutoResize();
|
|
1432
|
+
this.onUpdateSendButton();
|
|
1433
|
+
ShortcutHints.updateVisibility(this.els.messageInput.value);
|
|
1434
|
+
|
|
1435
|
+
// Keep the per-session auto-draft (and its persisted link) in sync
|
|
1436
|
+
// so a refresh preserves the loaded text and keeps updating the
|
|
1437
|
+
// same server-side draft
|
|
1438
|
+
this.saveDraft(text);
|
|
1439
|
+
this.pendingDraftId = draft?.id || null;
|
|
1440
|
+
if (draft?.id) Storage.set(this.getDraftLinkKey(), draft.id);
|
|
1441
|
+
DraftsPill.updateVisibility(this.els.messageInput.value);
|
|
1442
|
+
this.els.messageInput.focus();
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Drop the link between the input and its server-side draft (e.g. the
|
|
1447
|
+
* draft was deleted from the Drafts tab) without touching the input.
|
|
1448
|
+
* The next auto-sync tick then creates a fresh draft if warranted.
|
|
1449
|
+
*/
|
|
1450
|
+
unlinkSavedDraft() {
|
|
1451
|
+
this.pendingDraftId = null;
|
|
1452
|
+
Storage.remove(this.getDraftLinkKey());
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Delete the draft mirroring the input after it was sent (fire-and-forget).
|
|
1457
|
+
* @private
|
|
1458
|
+
*/
|
|
1459
|
+
_consumePendingDraft() {
|
|
1460
|
+
// Bump the epoch first: an auto-sync create may be in flight, and
|
|
1461
|
+
// the epoch tells it the input was sent — the just-created draft is
|
|
1462
|
+
// then a ghost of a sent message and gets deleted instead of linked
|
|
1463
|
+
this._draftConsumeEpoch++;
|
|
1464
|
+
this._cancelDraftSyncTimer();
|
|
1465
|
+
const id = this.pendingDraftId;
|
|
1466
|
+
this.pendingDraftId = null;
|
|
1467
|
+
if (!id) return;
|
|
1468
|
+
fetch(`${CONFIG.API_BASE}/api/drafts/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
1469
|
+
.then(() => window.dispatchEvent(new CustomEvent('drafts-changed')))
|
|
1470
|
+
.catch(err => console.error('Failed to consume sent draft:', err));
|
|
1471
|
+
}
|
|
1472
|
+
}
|