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,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImagePreviewWidget - Fullscreen image preview with navigation
|
|
3
|
+
*
|
|
4
|
+
* Lightbox-style overlay for viewing images. Collects all images from
|
|
5
|
+
* the current chat DOM and provides prev/next navigation within turns
|
|
6
|
+
* and between turns.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { WidgetManager } from '../widget-system/index.js';
|
|
10
|
+
import S from '../strings.js';
|
|
11
|
+
import { escapeHtml } from '../utils.js';
|
|
12
|
+
import { panzoomToolbarHtml, setupPanZoom, panzoomFitToView } from '../preview-plugins/plugin-helpers.js';
|
|
13
|
+
import { ContextMenu, copyToClipboard, copyImageToClipboard, showToast, filePathFromSrc, lastInputWasTouch } from '../context-menu.js';
|
|
14
|
+
import { basename } from '../path-utils.js';
|
|
15
|
+
|
|
16
|
+
// Shared context menu for the main image + thumb strip
|
|
17
|
+
const contextMenu = new ContextMenu();
|
|
18
|
+
|
|
19
|
+
/** Absolute URL for a (possibly root-relative) src. */
|
|
20
|
+
function absoluteSrc(src) {
|
|
21
|
+
if (!src || src.startsWith('data:')) return src;
|
|
22
|
+
try { return new URL(src, location.origin).href; }
|
|
23
|
+
catch { return src; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Copy an image to the clipboard, falling back to its path/link when the
|
|
28
|
+
* platform refuses image bytes (iPad PWA, older Safari).
|
|
29
|
+
* @returns {Promise<boolean>} true if anything at all made it to the clipboard
|
|
30
|
+
*/
|
|
31
|
+
async function copyImageWithFallback(src, path, abs) {
|
|
32
|
+
if (await copyImageToClipboard(src)) { showToast(S.toast.image_copied); return true; }
|
|
33
|
+
const fallback = path || (abs && !abs.startsWith('data:') ? abs : null);
|
|
34
|
+
const ok = !!fallback && await copyToClipboard(fallback);
|
|
35
|
+
showToast(ok ? S.toast.image_copy_fell_back : S.toast.image_copy_failed);
|
|
36
|
+
return ok;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build context-menu items for a gallery image src. */
|
|
40
|
+
function imageMenuItems(src) {
|
|
41
|
+
if (!src) return [];
|
|
42
|
+
const path = filePathFromSrc(src);
|
|
43
|
+
const abs = absoluteSrc(src);
|
|
44
|
+
const items = [
|
|
45
|
+
{
|
|
46
|
+
label: S.context_menus.image.copy_image,
|
|
47
|
+
action: () => copyImageWithFallback(src, path, abs),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
label: S.context_menus.image.copy_image_close,
|
|
51
|
+
action: async () => {
|
|
52
|
+
await copyImageWithFallback(src, path, abs);
|
|
53
|
+
WidgetManager.close('image-preview');
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
if (path) {
|
|
58
|
+
items.push({
|
|
59
|
+
label: S.context_menus.image.copy_path,
|
|
60
|
+
action: async () => { if (await copyToClipboard(path)) showToast(S.toast.path_copied); },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (abs && !abs.startsWith('data:')) {
|
|
64
|
+
items.push({
|
|
65
|
+
label: S.context_menus.image.copy_link,
|
|
66
|
+
action: async () => { if (await copyToClipboard(abs)) showToast(S.toast.link_copied); },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
items.push({ separator: true });
|
|
70
|
+
items.push({ label: S.context_menus.image.annotate, action: () => openInAnnotator(src) });
|
|
71
|
+
if (abs && !abs.startsWith('data:')) {
|
|
72
|
+
items.push({ label: S.context_menus.image.open_new_tab, action: () => window.open(abs, '_blank') });
|
|
73
|
+
}
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Open the current gallery image in the annotation editor.
|
|
79
|
+
*
|
|
80
|
+
* The editor was built for the paste flow and takes a Blob, so a gallery src
|
|
81
|
+
* (a server file, a blob: URL, or a data: URI) is fetched into one first.
|
|
82
|
+
* The gallery closes on the way in: both are modals, and the gallery's arrow
|
|
83
|
+
* keys would otherwise swap the image out from under an in-progress drawing.
|
|
84
|
+
*
|
|
85
|
+
* On save the annotated PNG is attached to the chat as a pending image — the
|
|
86
|
+
* same destination as paste-to-annotate. It does NOT overwrite the file on
|
|
87
|
+
* disk (there's no binary write endpoint, and the editor downsamples above
|
|
88
|
+
* 3000px, so an in-place save would silently degrade the original).
|
|
89
|
+
*/
|
|
90
|
+
async function openInAnnotator(src) {
|
|
91
|
+
if (!src) return;
|
|
92
|
+
const name = basenameFromSrc(src) || 'image.png';
|
|
93
|
+
try {
|
|
94
|
+
const blob = await fetch(src).then(r => {
|
|
95
|
+
if (!r.ok) throw new Error(`fetch failed: ${r.status}`);
|
|
96
|
+
return r.blob();
|
|
97
|
+
});
|
|
98
|
+
// GIFs would be flattened to a single frame by the canvas export —
|
|
99
|
+
// the paste flow skips them for the same reason.
|
|
100
|
+
if (blob.type === 'image/gif') { showToast(S.toast.annotate_gif_unsupported); return; }
|
|
101
|
+
|
|
102
|
+
const { openImageAnnotator } = await import('./image-annotate-widget.js');
|
|
103
|
+
WidgetManager.close('image-preview');
|
|
104
|
+
openImageAnnotator(new File([blob], name, { type: blob.type || 'image/png' }), {
|
|
105
|
+
onDone: (out, markers) => window.app?.uploadManager?.attachAnnotated(out, name, markers),
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('[ImagePreview] Failed to open annotator:', err);
|
|
109
|
+
showToast(S.toast.annotate_open_failed);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Show the image context menu at (x, y) for a given src. */
|
|
114
|
+
function showImageMenu(x, y, src) {
|
|
115
|
+
const items = imageMenuItems(src);
|
|
116
|
+
if (items.length) contextMenu.show(x, y, items);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
120
|
+
// STATE
|
|
121
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
122
|
+
|
|
123
|
+
const state = {
|
|
124
|
+
images: [], // [{src, turnIndex, turnLabel?, label?}] in DOM or directory order
|
|
125
|
+
currentIndex: -1,
|
|
126
|
+
mode: 'chat', // 'chat' = session images grouped by turn, 'dir' = directory siblings
|
|
127
|
+
zoom: { scale: 1, tx: 0, ty: 0 },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let _keyHandler = null;
|
|
131
|
+
|
|
132
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
133
|
+
// IMAGE COLLECTION
|
|
134
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
135
|
+
|
|
136
|
+
const IMAGE_EXT_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i;
|
|
137
|
+
|
|
138
|
+
const IMG_SELECTOR = [
|
|
139
|
+
'.turn-summary-bar',
|
|
140
|
+
'.message-image-thumb',
|
|
141
|
+
'.read-image-content img',
|
|
142
|
+
'.write-image-content img',
|
|
143
|
+
'.tt-image-thumb img',
|
|
144
|
+
'.turn-file-pill[data-file-path]',
|
|
145
|
+
'a.file-path-link[data-resolved]',
|
|
146
|
+
].join(', ');
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Scan the messages container for all images, grouped by turn.
|
|
150
|
+
* Includes: user uploads, Read/Write tool images, thinking tool images,
|
|
151
|
+
* file pills / image thumbs in turn bars, and linkified image paths in
|
|
152
|
+
* message text (server-verified file-path-links).
|
|
153
|
+
*
|
|
154
|
+
* Turn attribution: a turn's inline images sit BEFORE its summary bar in the
|
|
155
|
+
* DOM, so inline images belong to the bar that FOLLOWS them, while a bar's
|
|
156
|
+
* own pills belong to that bar itself. `turnIndex` is a monotonic segment
|
|
157
|
+
* counter used for ordering and turn-jump; `turnLabel` is the bar's displayed
|
|
158
|
+
* turn number, used only for labels (turn numbers can restart mid-session
|
|
159
|
+
* when the server restarts, so they aren't safe for ordering).
|
|
160
|
+
*
|
|
161
|
+
* Returns [{src, turnIndex, turnLabel}] in DOM order.
|
|
162
|
+
*/
|
|
163
|
+
function collectImages() {
|
|
164
|
+
// Scope to the ACTIVE session's message container — #messages-container
|
|
165
|
+
// holds a pool of .session-messages elements (one per open tab, inactive
|
|
166
|
+
// ones just hidden), so scanning it directly would sweep in every open
|
|
167
|
+
// session's images. (Not getActiveScrollContainer() — that returns the
|
|
168
|
+
// welcome container while the welcome view is up.)
|
|
169
|
+
const container = window.app?.chatCtrl?._containerPool?.getActiveContainer()
|
|
170
|
+
|| document.getElementById('messages-container');
|
|
171
|
+
if (!container) return [];
|
|
172
|
+
|
|
173
|
+
const nodes = container.querySelectorAll(IMG_SELECTOR);
|
|
174
|
+
const entries = [];
|
|
175
|
+
const barLabels = []; // barLabels[i] = displayed number of the i-th bar
|
|
176
|
+
const seen = new Set(); // dedupe by src
|
|
177
|
+
let bars = 0;
|
|
178
|
+
|
|
179
|
+
for (const el of nodes) {
|
|
180
|
+
// Turn boundary — record its displayed number (partial bars have none)
|
|
181
|
+
if (el.classList.contains('turn-summary-bar')) {
|
|
182
|
+
barLabels[bars] = parseInt(el.dataset.turnNumber, 10) || null;
|
|
183
|
+
bars++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Extract src + filename label based on element type
|
|
188
|
+
let src, label;
|
|
189
|
+
if (el.classList.contains('message-image-thumb')) {
|
|
190
|
+
// User upload — alt is generic, derive the name from the URL
|
|
191
|
+
src = el.dataset.src || el.querySelector('img')?.src;
|
|
192
|
+
label = basenameFromSrc(src);
|
|
193
|
+
} else if (el.classList.contains('turn-file-pill')) {
|
|
194
|
+
// File pill / image thumb in a turn bar — include if it's an image
|
|
195
|
+
const filePath = el.dataset.filePath;
|
|
196
|
+
if (!filePath || !IMAGE_EXT_RE.test(filePath)) continue;
|
|
197
|
+
src = `/api/file-raw?path=${encodeURIComponent(filePath)}`;
|
|
198
|
+
label = basename(filePath);
|
|
199
|
+
} else if (el.classList.contains('file-path-link')) {
|
|
200
|
+
// Linkified file path in message text — include if resolved to an image
|
|
201
|
+
const filePath = el.dataset.resolved;
|
|
202
|
+
if (!filePath || !IMAGE_EXT_RE.test(filePath)) continue;
|
|
203
|
+
src = `/api/file-raw?path=${encodeURIComponent(filePath)}`;
|
|
204
|
+
label = basename(filePath);
|
|
205
|
+
} else {
|
|
206
|
+
// Read/Write/thinking tool <img> — alt carries the filename
|
|
207
|
+
src = el.src;
|
|
208
|
+
label = el.alt || basenameFromSrc(src);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!src || seen.has(src)) continue;
|
|
212
|
+
seen.add(src);
|
|
213
|
+
const seg = el.closest('.turn-summary-bar') ? bars - 1 : bars;
|
|
214
|
+
entries.push({ src, seg: Math.max(0, seg), label: label || null });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Resolve display labels per segment. Bars without a number (partial
|
|
218
|
+
// bars) and the trailing in-flight segment synthesize previous + 1.
|
|
219
|
+
const segLabels = [];
|
|
220
|
+
let lastLabel = 0;
|
|
221
|
+
for (let i = 0; i <= bars; i++) {
|
|
222
|
+
segLabels[i] = (i < bars && barLabels[i]) ? barLabels[i] : lastLabel + 1;
|
|
223
|
+
lastLabel = segLabels[i];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return entries.map(e => ({ src: e.src, turnIndex: e.seg, turnLabel: segLabels[e.seg], label: e.label }));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Best-effort filename for an image src — /api/file-raw?path=… URLs use the
|
|
231
|
+
* path param's basename, other URLs their pathname basename (only when it
|
|
232
|
+
* looks like an image file); data: URIs have none.
|
|
233
|
+
*/
|
|
234
|
+
function basenameFromSrc(src) {
|
|
235
|
+
if (!src || src.startsWith('data:')) return null;
|
|
236
|
+
try {
|
|
237
|
+
const u = new URL(src, location.origin);
|
|
238
|
+
const p = u.searchParams.get('path');
|
|
239
|
+
if (p) return basename(p);
|
|
240
|
+
const name = decodeURIComponent(u.pathname.split('/').pop() || '');
|
|
241
|
+
return IMAGE_EXT_RE.test(name) ? name : null;
|
|
242
|
+
} catch { return null; }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Find image index by matching src URL. Handles both exact match
|
|
247
|
+
* and path-only match (ignoring query params) for API URLs.
|
|
248
|
+
*/
|
|
249
|
+
function findImageIndex(images, src) {
|
|
250
|
+
// Exact match first
|
|
251
|
+
let idx = images.findIndex(img => img.src === src);
|
|
252
|
+
if (idx >= 0) return idx;
|
|
253
|
+
|
|
254
|
+
// Try matching by pathname (for /api/file-raw?path=... URLs with different params)
|
|
255
|
+
try {
|
|
256
|
+
const srcUrl = new URL(src, location.origin);
|
|
257
|
+
idx = images.findIndex(img => {
|
|
258
|
+
try {
|
|
259
|
+
const u = new URL(img.src, location.origin);
|
|
260
|
+
return u.pathname === srcUrl.pathname && u.searchParams.get('path') === srcUrl.searchParams.get('path');
|
|
261
|
+
} catch { return false; }
|
|
262
|
+
});
|
|
263
|
+
} catch { /* not a URL, skip */ }
|
|
264
|
+
|
|
265
|
+
return idx;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
269
|
+
// NAVIGATION
|
|
270
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
271
|
+
|
|
272
|
+
function navigate(delta) {
|
|
273
|
+
const total = state.images.length;
|
|
274
|
+
if (total <= 1) return;
|
|
275
|
+
// Wrap around: prev at the first image lands on the last and vice versa
|
|
276
|
+
state.currentIndex = (state.currentIndex + delta + total) % total;
|
|
277
|
+
updateDisplay();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function navigateTurn(direction) {
|
|
281
|
+
const cur = state.images[state.currentIndex];
|
|
282
|
+
if (!cur) return;
|
|
283
|
+
|
|
284
|
+
if (direction < 0) {
|
|
285
|
+
// Find first image of previous turn
|
|
286
|
+
for (let i = state.currentIndex - 1; i >= 0; i--) {
|
|
287
|
+
if (state.images[i].turnIndex < cur.turnIndex) {
|
|
288
|
+
// Found a different turn — now find its first image
|
|
289
|
+
const targetTurn = state.images[i].turnIndex;
|
|
290
|
+
let first = i;
|
|
291
|
+
while (first > 0 && state.images[first - 1].turnIndex === targetTurn) first--;
|
|
292
|
+
state.currentIndex = first;
|
|
293
|
+
updateDisplay();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
// Find first image of next turn
|
|
299
|
+
for (let i = state.currentIndex + 1; i < state.images.length; i++) {
|
|
300
|
+
if (state.images[i].turnIndex > cur.turnIndex) {
|
|
301
|
+
state.currentIndex = i;
|
|
302
|
+
updateDisplay();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function hasPrevTurn() {
|
|
310
|
+
const cur = state.images[state.currentIndex];
|
|
311
|
+
if (!cur) return false;
|
|
312
|
+
return state.images.some((img, i) => i < state.currentIndex && img.turnIndex < cur.turnIndex);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function hasNextTurn() {
|
|
316
|
+
const cur = state.images[state.currentIndex];
|
|
317
|
+
if (!cur) return false;
|
|
318
|
+
return state.images.some((img, i) => i > state.currentIndex && img.turnIndex > cur.turnIndex);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
322
|
+
// DISPLAY UPDATE
|
|
323
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Display label for an image: dir mode is filename-only (no turns exist);
|
|
327
|
+
* chat mode is "T4 · name.png", degrading to just the turn when no
|
|
328
|
+
* filename could be derived (e.g. data-URI uploads).
|
|
329
|
+
*/
|
|
330
|
+
function imageLabel(im) {
|
|
331
|
+
const turn = `T${im.turnLabel || im.turnIndex || 0}`;
|
|
332
|
+
if (state.mode === 'dir') return im.label || turn;
|
|
333
|
+
return im.label ? `${turn} · ${im.label}` : turn;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function updateDisplay() {
|
|
337
|
+
const modal = document.querySelector('#widget-image-preview .image-preview-modal');
|
|
338
|
+
if (!modal) return;
|
|
339
|
+
|
|
340
|
+
const img = modal.querySelector('.img-preview-main');
|
|
341
|
+
const counter = modal.querySelector('.img-nav-counter');
|
|
342
|
+
const prevBtn = modal.querySelector('.img-nav-prev');
|
|
343
|
+
const nextBtn = modal.querySelector('.img-nav-next');
|
|
344
|
+
const prevTurnBtn = modal.querySelector('.img-nav-turn-prev');
|
|
345
|
+
const nextTurnBtn = modal.querySelector('.img-nav-turn-next');
|
|
346
|
+
|
|
347
|
+
const cur = state.images[state.currentIndex];
|
|
348
|
+
if (!cur) return;
|
|
349
|
+
|
|
350
|
+
// Reset zoom and refit when switching images so each picture starts fitted.
|
|
351
|
+
if (img && img.src !== cur.src) {
|
|
352
|
+
state.zoom.scale = 1;
|
|
353
|
+
state.zoom.tx = 0;
|
|
354
|
+
state.zoom.ty = 0;
|
|
355
|
+
img.style.transform = '';
|
|
356
|
+
img.src = cur.src;
|
|
357
|
+
const canvas = modal.querySelector('.img-preview-canvas');
|
|
358
|
+
const refit = () => panzoomFitToView(canvas, img, state.zoom);
|
|
359
|
+
if (img.complete && img.naturalWidth > 0) {
|
|
360
|
+
requestAnimationFrame(refit);
|
|
361
|
+
} else {
|
|
362
|
+
img.addEventListener('load', refit, { once: true });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const total = state.images.length;
|
|
367
|
+
|
|
368
|
+
if (counter) {
|
|
369
|
+
counter.textContent = total > 1
|
|
370
|
+
? `${state.currentIndex + 1} / ${total} · ${imageLabel(cur)}`
|
|
371
|
+
: '';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (prevBtn) prevBtn.disabled = total <= 1;
|
|
375
|
+
if (nextBtn) nextBtn.disabled = total <= 1;
|
|
376
|
+
if (prevTurnBtn) prevTurnBtn.disabled = !hasPrevTurn();
|
|
377
|
+
if (nextTurnBtn) nextTurnBtn.disabled = !hasNextTurn();
|
|
378
|
+
|
|
379
|
+
updateThumbActive(modal);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Build the thumb-strip HTML for the current state.images.
|
|
384
|
+
* Inserts an .img-turn-sep between consecutive thumbs from different turns.
|
|
385
|
+
*/
|
|
386
|
+
function renderThumbStripHtml() {
|
|
387
|
+
if (state.images.length <= 1) return '';
|
|
388
|
+
let html = '';
|
|
389
|
+
let prevTurn = null;
|
|
390
|
+
for (let i = 0; i < state.images.length; i++) {
|
|
391
|
+
const im = state.images[i];
|
|
392
|
+
if (prevTurn !== null && im.turnIndex !== prevTurn) {
|
|
393
|
+
html += `<div class="img-turn-sep" data-tooltip="T${im.turnLabel || im.turnIndex}"></div>`;
|
|
394
|
+
}
|
|
395
|
+
const tip = escapeHtml(imageLabel(im));
|
|
396
|
+
html += `<button class="img-thumb" data-index="${i}" data-tooltip="${tip}"><img src="${im.src}" alt="" loading="lazy"></button>`;
|
|
397
|
+
prevTurn = im.turnIndex;
|
|
398
|
+
}
|
|
399
|
+
return html;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Mark the active thumb and scroll it into view.
|
|
404
|
+
*/
|
|
405
|
+
function updateThumbActive(modal) {
|
|
406
|
+
const strip = modal.querySelector('.img-thumb-strip');
|
|
407
|
+
if (!strip) return;
|
|
408
|
+
const thumbs = strip.querySelectorAll('.img-thumb');
|
|
409
|
+
let active = null;
|
|
410
|
+
thumbs.forEach((t) => {
|
|
411
|
+
const isActive = parseInt(t.dataset.index, 10) === state.currentIndex;
|
|
412
|
+
t.classList.toggle('active', isActive);
|
|
413
|
+
if (isActive) active = t;
|
|
414
|
+
});
|
|
415
|
+
if (active) {
|
|
416
|
+
active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
421
|
+
// KEYBOARD
|
|
422
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
423
|
+
|
|
424
|
+
function attachKeyboard() {
|
|
425
|
+
detachKeyboard();
|
|
426
|
+
_keyHandler = (e) => {
|
|
427
|
+
if (e.key === 'ArrowLeft') {
|
|
428
|
+
e.preventDefault();
|
|
429
|
+
e.shiftKey ? navigateTurn(-1) : navigate(-1);
|
|
430
|
+
} else if (e.key === 'ArrowRight') {
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
e.shiftKey ? navigateTurn(1) : navigate(1);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
document.addEventListener('keydown', _keyHandler);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function detachKeyboard() {
|
|
439
|
+
if (_keyHandler) {
|
|
440
|
+
document.removeEventListener('keydown', _keyHandler);
|
|
441
|
+
_keyHandler = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
446
|
+
// SVG ICONS (inline to avoid widget-system dependency for simple arrows)
|
|
447
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
448
|
+
|
|
449
|
+
const CHEVRON_LEFT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>';
|
|
450
|
+
const CHEVRON_RIGHT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>';
|
|
451
|
+
const CHEVRONS_LEFT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>';
|
|
452
|
+
const CHEVRONS_RIGHT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 7 18 12 13 17"/><polyline points="6 7 11 12 6 17"/></svg>';
|
|
453
|
+
|
|
454
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
455
|
+
// WIDGET REGISTRATION
|
|
456
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
457
|
+
|
|
458
|
+
export function registerImagePreviewWidget() {
|
|
459
|
+
WidgetManager.register('image-preview', {
|
|
460
|
+
title: S.widgets.titles.image_preview,
|
|
461
|
+
icon: 'image',
|
|
462
|
+
type: 'modal',
|
|
463
|
+
hideHeader: true,
|
|
464
|
+
closeOnBackdrop: true,
|
|
465
|
+
closeOnEscape: true,
|
|
466
|
+
maxWidth: '95vw',
|
|
467
|
+
maxHeight: '95vh',
|
|
468
|
+
scope: 'global',
|
|
469
|
+
sessionAware: false,
|
|
470
|
+
hiddenInPicker: true,
|
|
471
|
+
|
|
472
|
+
onClose() {
|
|
473
|
+
detachKeyboard();
|
|
474
|
+
// Return focus to the chat input so typing can resume immediately.
|
|
475
|
+
window.app?.focusInput?.();
|
|
476
|
+
},
|
|
477
|
+
|
|
478
|
+
render(container, ctx) {
|
|
479
|
+
const single = state.images.length <= 1;
|
|
480
|
+
const initialSrc = state.images[state.currentIndex]?.src || '';
|
|
481
|
+
|
|
482
|
+
container.innerHTML = `
|
|
483
|
+
<div class="image-preview-modal${single ? ' single-image' : ''}">
|
|
484
|
+
${panzoomToolbarHtml(state.zoom, {
|
|
485
|
+
hasDownload: false,
|
|
486
|
+
hasAnnotate: true,
|
|
487
|
+
annotateTooltip: S.context_menus.image.annotate,
|
|
488
|
+
})}
|
|
489
|
+
<div class="img-preview-canvas panzoom-canvas">
|
|
490
|
+
<img class="img-preview-main panzoom-img" src="${initialSrc}" alt="Preview">
|
|
491
|
+
</div>
|
|
492
|
+
<button class="img-nav img-nav-prev" data-tooltip="${S.widgets?.image_preview?.prev || 'Previous image'}">${CHEVRON_LEFT}</button>
|
|
493
|
+
<button class="img-nav img-nav-next" data-tooltip="${S.widgets?.image_preview?.next || 'Next image'}">${CHEVRON_RIGHT}</button>
|
|
494
|
+
<span class="img-nav-counter"></span>
|
|
495
|
+
<div class="img-nav-turn">
|
|
496
|
+
<button class="img-nav-turn-prev" data-tooltip="${S.widgets?.image_preview?.prev_turn || "Previous turn"}">${CHEVRONS_LEFT}</button>
|
|
497
|
+
<button class="img-nav-turn-next" data-tooltip="${S.widgets?.image_preview?.next_turn || "Next turn"}">${CHEVRONS_RIGHT}</button>
|
|
498
|
+
</div>
|
|
499
|
+
<div class="img-thumb-strip">${renderThumbStripHtml()}</div>
|
|
500
|
+
</div>
|
|
501
|
+
`;
|
|
502
|
+
|
|
503
|
+
// Wire up pan/zoom (drag, wheel, pinch, gesture, toolbar buttons, Cmd+/-/0)
|
|
504
|
+
setupPanZoom(container, state.zoom, {
|
|
505
|
+
canvasSelector: '.img-preview-canvas',
|
|
506
|
+
imgSelector: '.img-preview-main',
|
|
507
|
+
stateCheck: () => !!document.querySelector('#widget-image-preview .image-preview-modal'),
|
|
508
|
+
onAnnotate: () => openInAnnotator(state.images[state.currentIndex]?.src),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Navigation button clicks
|
|
512
|
+
container.querySelector('.img-nav-prev').addEventListener('click', (e) => {
|
|
513
|
+
e.stopPropagation();
|
|
514
|
+
navigate(-1);
|
|
515
|
+
});
|
|
516
|
+
container.querySelector('.img-nav-next').addEventListener('click', (e) => {
|
|
517
|
+
e.stopPropagation();
|
|
518
|
+
navigate(1);
|
|
519
|
+
});
|
|
520
|
+
container.querySelector('.img-nav-turn-prev').addEventListener('click', (e) => {
|
|
521
|
+
e.stopPropagation();
|
|
522
|
+
navigateTurn(-1);
|
|
523
|
+
});
|
|
524
|
+
container.querySelector('.img-nav-turn-next').addEventListener('click', (e) => {
|
|
525
|
+
e.stopPropagation();
|
|
526
|
+
navigateTurn(1);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Delegated click on thumb strip — jump to clicked image
|
|
530
|
+
container.querySelector('.img-thumb-strip').addEventListener('click', (e) => {
|
|
531
|
+
const thumb = e.target.closest('.img-thumb');
|
|
532
|
+
if (!thumb) return;
|
|
533
|
+
e.stopPropagation();
|
|
534
|
+
const idx = parseInt(thumb.dataset.index, 10);
|
|
535
|
+
if (Number.isFinite(idx) && idx >= 0 && idx < state.images.length) {
|
|
536
|
+
state.currentIndex = idx;
|
|
537
|
+
updateDisplay();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Right-click on the main image or a thumb → copy image / link / path.
|
|
542
|
+
// srcAt() resolves the src for whichever element was targeted.
|
|
543
|
+
const srcAt = (target) => {
|
|
544
|
+
const thumb = target.closest('.img-thumb');
|
|
545
|
+
if (thumb) {
|
|
546
|
+
const idx = parseInt(thumb.dataset.index, 10);
|
|
547
|
+
return state.images[idx]?.src;
|
|
548
|
+
}
|
|
549
|
+
if (target.closest('.img-preview-main')) {
|
|
550
|
+
return state.images[state.currentIndex]?.src;
|
|
551
|
+
}
|
|
552
|
+
return null;
|
|
553
|
+
};
|
|
554
|
+
// NOTE: capture phase is required. The pan/zoom canvas calls
|
|
555
|
+
// stopPropagation() on touch events (to stop the tab-switch gesture
|
|
556
|
+
// manager), which would otherwise swallow our bubble-phase
|
|
557
|
+
// long-press. Capture runs before the canvas handler, so it still
|
|
558
|
+
// fires. (Mouse right-click works via contextmenu once pan/zoom
|
|
559
|
+
// stops capturing the secondary button — see plugin-helpers.js.)
|
|
560
|
+
container.addEventListener('contextmenu', (e) => {
|
|
561
|
+
// A closed modal is never removed from the DOM — it just goes
|
|
562
|
+
// transparent, still laid out at 90vw x 90vh dead-centre. If an
|
|
563
|
+
// engine leaks a hit-test into it (see the visibility:hidden
|
|
564
|
+
// note on .widget-modal-dialog), this capture-phase listener
|
|
565
|
+
// would answer for the chat BEHIND the gallery: right-clicking
|
|
566
|
+
// a file pill served the image menu for the last-viewed image.
|
|
567
|
+
if (!container.closest('.widget-visible')) return;
|
|
568
|
+
const src = srcAt(e.target);
|
|
569
|
+
if (!src) return;
|
|
570
|
+
// On touch, yield to the native iOS callout. Its "Copy Image"
|
|
571
|
+
// actually works on an iPad PWA, where navigator.clipboard
|
|
572
|
+
// refuses image bytes and our own copy can only fall back to
|
|
573
|
+
// the link. The toolbar pencil + Copy&Close stay reachable.
|
|
574
|
+
if (lastInputWasTouch()) return;
|
|
575
|
+
e.preventDefault();
|
|
576
|
+
e.stopPropagation();
|
|
577
|
+
showImageMenu(e.clientX, e.clientY, src);
|
|
578
|
+
}, true);
|
|
579
|
+
|
|
580
|
+
// Click on dark canvas area (not on the image, not on a drag) closes the widget.
|
|
581
|
+
// Use pointer events to distinguish click from pan-drag via distance threshold.
|
|
582
|
+
const canvasEl = container.querySelector('.img-preview-canvas');
|
|
583
|
+
let downAt = null;
|
|
584
|
+
canvasEl.addEventListener('pointerdown', (e) => {
|
|
585
|
+
downAt = { x: e.clientX, y: e.clientY, target: e.target };
|
|
586
|
+
});
|
|
587
|
+
canvasEl.addEventListener('pointerup', (e) => {
|
|
588
|
+
if (!downAt) return;
|
|
589
|
+
const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);
|
|
590
|
+
const onBackdrop = downAt.target === canvasEl && e.target === canvasEl;
|
|
591
|
+
downAt = null;
|
|
592
|
+
if (moved < 5 && onBackdrop) WidgetManager.close('image-preview');
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
updateDisplay();
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
601
|
+
// PUBLIC API
|
|
602
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Open the widget for the current state.images/currentIndex/mode.
|
|
606
|
+
* Widget render() only runs on first creation — update DOM directly
|
|
607
|
+
* for subsequent opens.
|
|
608
|
+
*/
|
|
609
|
+
function openWithState() {
|
|
610
|
+
WidgetManager.open('image-preview');
|
|
611
|
+
attachKeyboard();
|
|
612
|
+
|
|
613
|
+
const modal = document.querySelector('#widget-image-preview .image-preview-modal');
|
|
614
|
+
if (modal) {
|
|
615
|
+
modal.classList.toggle('single-image', state.images.length <= 1);
|
|
616
|
+
modal.classList.toggle('dir-mode', state.mode === 'dir');
|
|
617
|
+
// Rebuild thumb strip — collected images may differ between opens
|
|
618
|
+
const strip = modal.querySelector('.img-thumb-strip');
|
|
619
|
+
if (strip) strip.innerHTML = renderThumbStripHtml();
|
|
620
|
+
updateDisplay();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Show an image in the preview modal with navigation
|
|
626
|
+
* @param {string} src - Image source (URL or data URI)
|
|
627
|
+
*/
|
|
628
|
+
export function showImagePreview(src) {
|
|
629
|
+
// Collect all session images from DOM
|
|
630
|
+
state.mode = 'chat';
|
|
631
|
+
state.images = collectImages();
|
|
632
|
+
state.currentIndex = findImageIndex(state.images, src);
|
|
633
|
+
|
|
634
|
+
// If not found in DOM (e.g., pending upload preview), show as single image
|
|
635
|
+
if (state.currentIndex < 0) {
|
|
636
|
+
state.images = [{ src, turnIndex: 0 }];
|
|
637
|
+
state.currentIndex = 0;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
openWithState();
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Show an image from the filesystem with its directory siblings as the
|
|
645
|
+
* gallery (file-explorer open path). The thumb strip lists every image in
|
|
646
|
+
* the clicked file's directory instead of the session's chat images.
|
|
647
|
+
* @param {string} path - Absolute path of the clicked image
|
|
648
|
+
*/
|
|
649
|
+
export async function showImagePreviewForFile(path) {
|
|
650
|
+
const src = `/api/file-raw?path=${encodeURIComponent(path)}`;
|
|
651
|
+
const dir = path.replace(/\/[^/]*$/, '') || '/';
|
|
652
|
+
|
|
653
|
+
let images = [];
|
|
654
|
+
try {
|
|
655
|
+
const resp = await fetch(`/api/files?path=${encodeURIComponent(dir)}`);
|
|
656
|
+
if (resp.ok) {
|
|
657
|
+
const data = await resp.json();
|
|
658
|
+
images = (data.files || [])
|
|
659
|
+
.filter(f => !f.is_dir && IMAGE_EXT_RE.test(f.name))
|
|
660
|
+
.map(f => ({
|
|
661
|
+
src: `/api/file-raw?path=${encodeURIComponent(f.path)}`,
|
|
662
|
+
turnIndex: 0,
|
|
663
|
+
label: f.name,
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
} catch { /* listing failed — fall through to single image */ }
|
|
667
|
+
|
|
668
|
+
state.mode = 'dir';
|
|
669
|
+
state.currentIndex = findImageIndex(images, src);
|
|
670
|
+
if (state.currentIndex >= 0) {
|
|
671
|
+
state.images = images;
|
|
672
|
+
} else {
|
|
673
|
+
state.images = [{ src, turnIndex: 0, label: basename(path) }];
|
|
674
|
+
state.currentIndex = 0;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
openWithState();
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Close the image preview modal
|
|
682
|
+
*/
|
|
683
|
+
export function closeImagePreview() {
|
|
684
|
+
WidgetManager.close('image-preview');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Export for external access
|
|
688
|
+
export const ImagePreviewWidget = {
|
|
689
|
+
show: showImagePreview,
|
|
690
|
+
showForFile: showImagePreviewForFile,
|
|
691
|
+
close: closeImagePreview
|
|
692
|
+
};
|