pythinker-code 0.8.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.
- pythinker_code/CHANGELOG.md +60 -0
- pythinker_code/__init__.py +0 -0
- pythinker_code/__main__.py +97 -0
- pythinker_code/acp/AGENTS.md +93 -0
- pythinker_code/acp/__init__.py +13 -0
- pythinker_code/acp/convert.py +128 -0
- pythinker_code/acp/host.py +301 -0
- pythinker_code/acp/mcp.py +46 -0
- pythinker_code/acp/server.py +497 -0
- pythinker_code/acp/session.py +502 -0
- pythinker_code/acp/tools.py +174 -0
- pythinker_code/acp/types.py +13 -0
- pythinker_code/acp/version.py +45 -0
- pythinker_code/agents/default/agent.yaml +55 -0
- pythinker_code/agents/default/code_reviewer.yaml +47 -0
- pythinker_code/agents/default/coder.yaml +42 -0
- pythinker_code/agents/default/debugger.yaml +35 -0
- pythinker_code/agents/default/explore.yaml +59 -0
- pythinker_code/agents/default/implementer.yaml +46 -0
- pythinker_code/agents/default/plan.yaml +42 -0
- pythinker_code/agents/default/review.yaml +47 -0
- pythinker_code/agents/default/security_reviewer.yaml +37 -0
- pythinker_code/agents/default/system.md +192 -0
- pythinker_code/agents/default/verifier.yaml +46 -0
- pythinker_code/agents/okabe/agent.yaml +22 -0
- pythinker_code/agentspec.py +163 -0
- pythinker_code/app.py +847 -0
- pythinker_code/approval_runtime/__init__.py +29 -0
- pythinker_code/approval_runtime/models.py +42 -0
- pythinker_code/approval_runtime/runtime.py +235 -0
- pythinker_code/auth/__init__.py +25 -0
- pythinker_code/auth/anthropic_direct.py +207 -0
- pythinker_code/auth/deepseek.py +192 -0
- pythinker_code/auth/github_feedback.py +228 -0
- pythinker_code/auth/lm_studio.py +418 -0
- pythinker_code/auth/minimax.py +203 -0
- pythinker_code/auth/oauth.py +1145 -0
- pythinker_code/auth/ollama.py +293 -0
- pythinker_code/auth/openai.py +783 -0
- pythinker_code/auth/opencode_go.py +203 -0
- pythinker_code/auth/openrouter.py +225 -0
- pythinker_code/auth/platforms.py +475 -0
- pythinker_code/background/__init__.py +36 -0
- pythinker_code/background/agent_runner.py +231 -0
- pythinker_code/background/ids.py +19 -0
- pythinker_code/background/manager.py +668 -0
- pythinker_code/background/models.py +118 -0
- pythinker_code/background/store.py +243 -0
- pythinker_code/background/summary.py +66 -0
- pythinker_code/background/worker.py +209 -0
- pythinker_code/cli/__init__.py +1326 -0
- pythinker_code/cli/__main__.py +19 -0
- pythinker_code/cli/_lazy_group.py +268 -0
- pythinker_code/cli/debug.py +11 -0
- pythinker_code/cli/export.py +322 -0
- pythinker_code/cli/info.py +62 -0
- pythinker_code/cli/mcp.py +362 -0
- pythinker_code/cli/plugin.py +351 -0
- pythinker_code/cli/review.py +74 -0
- pythinker_code/cli/secscan.py +11 -0
- pythinker_code/cli/security_scan.py +35 -0
- pythinker_code/cli/toad.py +74 -0
- pythinker_code/cli/update.py +26 -0
- pythinker_code/cli/vis.py +38 -0
- pythinker_code/cli/web.py +80 -0
- pythinker_code/config.py +511 -0
- pythinker_code/constant.py +33 -0
- pythinker_code/events.py +106 -0
- pythinker_code/exception.py +43 -0
- pythinker_code/extensions.py +151 -0
- pythinker_code/hooks/__init__.py +4 -0
- pythinker_code/hooks/config.py +34 -0
- pythinker_code/hooks/engine.py +383 -0
- pythinker_code/hooks/events.py +190 -0
- pythinker_code/hooks/runner.py +92 -0
- pythinker_code/llm.py +441 -0
- pythinker_code/metadata.py +79 -0
- pythinker_code/notifications/__init__.py +33 -0
- pythinker_code/notifications/llm.py +77 -0
- pythinker_code/notifications/manager.py +145 -0
- pythinker_code/notifications/models.py +50 -0
- pythinker_code/notifications/notifier.py +41 -0
- pythinker_code/notifications/store.py +118 -0
- pythinker_code/notifications/wire.py +21 -0
- pythinker_code/plugin/__init__.py +124 -0
- pythinker_code/plugin/manager.py +166 -0
- pythinker_code/plugin/tool.py +173 -0
- pythinker_code/prompt_templates.py +181 -0
- pythinker_code/prompts/__init__.py +6 -0
- pythinker_code/prompts/compact.md +73 -0
- pythinker_code/prompts/init.md +21 -0
- pythinker_code/py.typed +0 -0
- pythinker_code/session.py +319 -0
- pythinker_code/session_fork.py +325 -0
- pythinker_code/session_state.py +132 -0
- pythinker_code/share.py +14 -0
- pythinker_code/skill/__init__.py +727 -0
- pythinker_code/skill/flow/__init__.py +99 -0
- pythinker_code/skill/flow/d2.py +482 -0
- pythinker_code/skill/flow/mermaid.py +266 -0
- pythinker_code/skills/pythinker-code-help/SKILL.md +54 -0
- pythinker_code/skills/skill-creator/SKILL.md +367 -0
- pythinker_code/soul/__init__.py +304 -0
- pythinker_code/soul/agent.py +552 -0
- pythinker_code/soul/approval.py +267 -0
- pythinker_code/soul/btw.py +220 -0
- pythinker_code/soul/compaction.py +189 -0
- pythinker_code/soul/context.py +339 -0
- pythinker_code/soul/denwarenji.py +39 -0
- pythinker_code/soul/dynamic_injection.py +84 -0
- pythinker_code/soul/dynamic_injections/__init__.py +0 -0
- pythinker_code/soul/dynamic_injections/auto_mode.py +72 -0
- pythinker_code/soul/dynamic_injections/plan_mode.py +239 -0
- pythinker_code/soul/message.py +92 -0
- pythinker_code/soul/permission.py +368 -0
- pythinker_code/soul/pythinkersoul.py +1763 -0
- pythinker_code/soul/slash.py +340 -0
- pythinker_code/soul/toolset.py +826 -0
- pythinker_code/subagents/__init__.py +21 -0
- pythinker_code/subagents/builder.py +43 -0
- pythinker_code/subagents/core.py +86 -0
- pythinker_code/subagents/discovery.py +234 -0
- pythinker_code/subagents/git_context.py +172 -0
- pythinker_code/subagents/models.py +56 -0
- pythinker_code/subagents/output.py +71 -0
- pythinker_code/subagents/registry.py +28 -0
- pythinker_code/subagents/runner.py +442 -0
- pythinker_code/subagents/store.py +200 -0
- pythinker_code/telemetry/__init__.py +217 -0
- pythinker_code/telemetry/config.py +113 -0
- pythinker_code/telemetry/crash.py +191 -0
- pythinker_code/telemetry/errors.py +113 -0
- pythinker_code/telemetry/metrics.py +208 -0
- pythinker_code/telemetry/otel.py +303 -0
- pythinker_code/telemetry/sentry.py +212 -0
- pythinker_code/telemetry/sink.py +189 -0
- pythinker_code/tools/AGENTS.md +6 -0
- pythinker_code/tools/__init__.py +105 -0
- pythinker_code/tools/agent/__init__.py +326 -0
- pythinker_code/tools/agent/description.md +65 -0
- pythinker_code/tools/ask_user/__init__.py +162 -0
- pythinker_code/tools/ask_user/description.md +19 -0
- pythinker_code/tools/background/__init__.py +318 -0
- pythinker_code/tools/background/list.md +10 -0
- pythinker_code/tools/background/output.md +11 -0
- pythinker_code/tools/background/stop.md +8 -0
- pythinker_code/tools/display.py +46 -0
- pythinker_code/tools/dmail/__init__.py +38 -0
- pythinker_code/tools/dmail/dmail.md +17 -0
- pythinker_code/tools/file/__init__.py +31 -0
- pythinker_code/tools/file/glob.md +17 -0
- pythinker_code/tools/file/glob.py +163 -0
- pythinker_code/tools/file/grep.md +6 -0
- pythinker_code/tools/file/grep_local.py +904 -0
- pythinker_code/tools/file/plan_mode.py +45 -0
- pythinker_code/tools/file/read.md +16 -0
- pythinker_code/tools/file/read.py +303 -0
- pythinker_code/tools/file/read_media.md +24 -0
- pythinker_code/tools/file/read_media.py +220 -0
- pythinker_code/tools/file/replace.md +7 -0
- pythinker_code/tools/file/replace.py +204 -0
- pythinker_code/tools/file/utils.py +257 -0
- pythinker_code/tools/file/write.md +5 -0
- pythinker_code/tools/file/write.py +186 -0
- pythinker_code/tools/plan/__init__.py +362 -0
- pythinker_code/tools/plan/description.md +29 -0
- pythinker_code/tools/plan/enter.py +193 -0
- pythinker_code/tools/plan/enter_description.md +35 -0
- pythinker_code/tools/plan/handoff.py +69 -0
- pythinker_code/tools/plan/heroes.py +277 -0
- pythinker_code/tools/shell/__init__.py +263 -0
- pythinker_code/tools/shell/bash.md +35 -0
- pythinker_code/tools/shell/powershell.md +30 -0
- pythinker_code/tools/test.py +55 -0
- pythinker_code/tools/think/__init__.py +21 -0
- pythinker_code/tools/think/think.md +1 -0
- pythinker_code/tools/todo/__init__.py +168 -0
- pythinker_code/tools/todo/set_todo_list.md +23 -0
- pythinker_code/tools/utils.py +200 -0
- pythinker_code/tools/web/__init__.py +4 -0
- pythinker_code/tools/web/fetch.md +1 -0
- pythinker_code/tools/web/fetch.py +261 -0
- pythinker_code/tools/web/search.md +1 -0
- pythinker_code/tools/web/search.py +163 -0
- pythinker_code/ui/__init__.py +0 -0
- pythinker_code/ui/acp/__init__.py +99 -0
- pythinker_code/ui/print/__init__.py +474 -0
- pythinker_code/ui/print/visualize.py +185 -0
- pythinker_code/ui/shell/__init__.py +1806 -0
- pythinker_code/ui/shell/components/__init__.py +110 -0
- pythinker_code/ui/shell/components/base.py +25 -0
- pythinker_code/ui/shell/components/bash_execution.py +249 -0
- pythinker_code/ui/shell/components/bordered_loader.py +62 -0
- pythinker_code/ui/shell/components/diff.py +308 -0
- pythinker_code/ui/shell/components/footer.py +231 -0
- pythinker_code/ui/shell/components/key_hints.py +27 -0
- pythinker_code/ui/shell/components/messages.py +152 -0
- pythinker_code/ui/shell/components/render_utils.py +198 -0
- pythinker_code/ui/shell/components/settings_list.py +369 -0
- pythinker_code/ui/shell/components/special_messages.py +125 -0
- pythinker_code/ui/shell/components/tool_execution.py +261 -0
- pythinker_code/ui/shell/console.py +109 -0
- pythinker_code/ui/shell/debug.py +190 -0
- pythinker_code/ui/shell/echo.py +30 -0
- pythinker_code/ui/shell/export_import.py +117 -0
- pythinker_code/ui/shell/keyboard.py +300 -0
- pythinker_code/ui/shell/keymap.py +84 -0
- pythinker_code/ui/shell/mcp_status.py +112 -0
- pythinker_code/ui/shell/model_picker.py +318 -0
- pythinker_code/ui/shell/oauth.py +273 -0
- pythinker_code/ui/shell/placeholders.py +578 -0
- pythinker_code/ui/shell/prompt.py +2888 -0
- pythinker_code/ui/shell/replay.py +215 -0
- pythinker_code/ui/shell/selector.py +364 -0
- pythinker_code/ui/shell/selectors/__init__.py +38 -0
- pythinker_code/ui/shell/selectors/extension.py +37 -0
- pythinker_code/ui/shell/selectors/oauth.py +63 -0
- pythinker_code/ui/shell/selectors/settings.py +349 -0
- pythinker_code/ui/shell/selectors/show_images.py +29 -0
- pythinker_code/ui/shell/selectors/theme.py +28 -0
- pythinker_code/ui/shell/selectors/thinking.py +42 -0
- pythinker_code/ui/shell/session_picker.py +227 -0
- pythinker_code/ui/shell/setup.py +212 -0
- pythinker_code/ui/shell/slash.py +1433 -0
- pythinker_code/ui/shell/spinner_words.py +222 -0
- pythinker_code/ui/shell/startup.py +32 -0
- pythinker_code/ui/shell/task_browser.py +486 -0
- pythinker_code/ui/shell/tool_renderers/__init__.py +197 -0
- pythinker_code/ui/shell/tool_renderers/_render_utils.py +168 -0
- pythinker_code/ui/shell/tool_renderers/agent.py +140 -0
- pythinker_code/ui/shell/tool_renderers/ask_user.py +93 -0
- pythinker_code/ui/shell/tool_renderers/background.py +144 -0
- pythinker_code/ui/shell/tool_renderers/bash.py +103 -0
- pythinker_code/ui/shell/tool_renderers/edit.py +163 -0
- pythinker_code/ui/shell/tool_renderers/find.py +81 -0
- pythinker_code/ui/shell/tool_renderers/generic.py +60 -0
- pythinker_code/ui/shell/tool_renderers/grep.py +98 -0
- pythinker_code/ui/shell/tool_renderers/plan.py +98 -0
- pythinker_code/ui/shell/tool_renderers/read.py +103 -0
- pythinker_code/ui/shell/tool_renderers/think.py +66 -0
- pythinker_code/ui/shell/tool_renderers/todo.py +164 -0
- pythinker_code/ui/shell/tool_renderers/web.py +128 -0
- pythinker_code/ui/shell/tool_renderers/write.py +102 -0
- pythinker_code/ui/shell/update.py +352 -0
- pythinker_code/ui/shell/usage.py +291 -0
- pythinker_code/ui/shell/usage_adapters/__init__.py +50 -0
- pythinker_code/ui/shell/usage_adapters/anthropic_admin.py +233 -0
- pythinker_code/ui/shell/usage_adapters/base.py +72 -0
- pythinker_code/ui/shell/usage_adapters/deepseek.py +137 -0
- pythinker_code/ui/shell/usage_adapters/minimax.py +236 -0
- pythinker_code/ui/shell/usage_adapters/openai_admin.py +225 -0
- pythinker_code/ui/shell/usage_adapters/openai_chatgpt.py +241 -0
- pythinker_code/ui/shell/usage_adapters/opencode_go.py +232 -0
- pythinker_code/ui/shell/usage_adapters/openrouter.py +105 -0
- pythinker_code/ui/shell/usage_adapters/pythinker.py +189 -0
- pythinker_code/ui/shell/usage_adapters/pythinker_ai.py +50 -0
- pythinker_code/ui/shell/usage_render.py +150 -0
- pythinker_code/ui/shell/visualize/__init__.py +165 -0
- pythinker_code/ui/shell/visualize/_approval_panel.py +539 -0
- pythinker_code/ui/shell/visualize/_blocks.py +802 -0
- pythinker_code/ui/shell/visualize/_btw_panel.py +227 -0
- pythinker_code/ui/shell/visualize/_input_router.py +48 -0
- pythinker_code/ui/shell/visualize/_interactive.py +531 -0
- pythinker_code/ui/shell/visualize/_live_view.py +891 -0
- pythinker_code/ui/shell/visualize/_question_panel.py +586 -0
- pythinker_code/ui/shell/visualize/_worklog.py +245 -0
- pythinker_code/ui/theme.py +395 -0
- pythinker_code/ui/tui_config.py +82 -0
- pythinker_code/usage_ratelimit_cache.py +175 -0
- pythinker_code/utils/__init__.py +0 -0
- pythinker_code/utils/aiohttp.py +24 -0
- pythinker_code/utils/aioqueue.py +72 -0
- pythinker_code/utils/broadcast.py +38 -0
- pythinker_code/utils/changelog.py +108 -0
- pythinker_code/utils/clipboard.py +246 -0
- pythinker_code/utils/datetime.py +64 -0
- pythinker_code/utils/diff.py +135 -0
- pythinker_code/utils/editor.py +91 -0
- pythinker_code/utils/environment.py +73 -0
- pythinker_code/utils/envvar.py +22 -0
- pythinker_code/utils/export.py +696 -0
- pythinker_code/utils/file_filter.py +375 -0
- pythinker_code/utils/frontmatter.py +70 -0
- pythinker_code/utils/io.py +27 -0
- pythinker_code/utils/logging.py +146 -0
- pythinker_code/utils/media_tags.py +29 -0
- pythinker_code/utils/message.py +24 -0
- pythinker_code/utils/path.py +199 -0
- pythinker_code/utils/proctitle.py +33 -0
- pythinker_code/utils/proxy.py +31 -0
- pythinker_code/utils/pyinstaller.py +45 -0
- pythinker_code/utils/rich/__init__.py +33 -0
- pythinker_code/utils/rich/columns.py +99 -0
- pythinker_code/utils/rich/diff_render.py +481 -0
- pythinker_code/utils/rich/markdown.py +935 -0
- pythinker_code/utils/rich/markdown_sample.md +108 -0
- pythinker_code/utils/rich/markdown_sample_short.md +2 -0
- pythinker_code/utils/rich/syntax.py +114 -0
- pythinker_code/utils/sensitive.py +54 -0
- pythinker_code/utils/server.py +121 -0
- pythinker_code/utils/signals.py +43 -0
- pythinker_code/utils/slashcmd.py +124 -0
- pythinker_code/utils/string.py +41 -0
- pythinker_code/utils/subprocess_env.py +83 -0
- pythinker_code/utils/term.py +168 -0
- pythinker_code/utils/typing.py +20 -0
- pythinker_code/vis/__init__.py +0 -0
- pythinker_code/vis/api/__init__.py +5 -0
- pythinker_code/vis/api/sessions.py +714 -0
- pythinker_code/vis/api/statistics.py +209 -0
- pythinker_code/vis/api/system.py +19 -0
- pythinker_code/vis/app.py +199 -0
- pythinker_code/vis/static/assets/highlighted-body-B3W2YXNL-CY1rtwrX.js +1 -0
- pythinker_code/vis/static/assets/index-DSRInNnm.css +1 -0
- pythinker_code/vis/static/assets/index-DgmTI2M_.js +185 -0
- pythinker_code/vis/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- pythinker_code/vis/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- pythinker_code/vis/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- pythinker_code/vis/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- pythinker_code/vis/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- pythinker_code/vis/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- pythinker_code/vis/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- pythinker_code/vis/static/index.html +17 -0
- pythinker_code/web/__init__.py +5 -0
- pythinker_code/web/api/__init__.py +15 -0
- pythinker_code/web/api/config.py +217 -0
- pythinker_code/web/api/open_in.py +233 -0
- pythinker_code/web/api/sessions.py +1256 -0
- pythinker_code/web/app.py +449 -0
- pythinker_code/web/auth.py +191 -0
- pythinker_code/web/models.py +98 -0
- pythinker_code/web/runner/__init__.py +5 -0
- pythinker_code/web/runner/messages.py +57 -0
- pythinker_code/web/runner/process.py +754 -0
- pythinker_code/web/runner/worker.py +97 -0
- pythinker_code/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- pythinker_code/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- pythinker_code/web/static/assets/_baseUniq-DpSMr1jx.js +1 -0
- pythinker_code/web/static/assets/abap-BdImnpbu.js +1 -0
- pythinker_code/web/static/assets/actionscript-3-CfeIJUat.js +1 -0
- pythinker_code/web/static/assets/ada-bCR0ucgS.js +1 -0
- pythinker_code/web/static/assets/andromeeda-C-Jbm3Hp.js +1 -0
- pythinker_code/web/static/assets/angular-html-CU67Zn6k.js +1 -0
- pythinker_code/web/static/assets/angular-ts-BwZT4LLn.js +1 -0
- pythinker_code/web/static/assets/apache-Pmp26Uib.js +1 -0
- pythinker_code/web/static/assets/apex-D8_7TLub.js +1 -0
- pythinker_code/web/static/assets/apl-dKokRX4l.js +1 -0
- pythinker_code/web/static/assets/applescript-Co6uUVPk.js +1 -0
- pythinker_code/web/static/assets/ara-BRHolxvo.js +1 -0
- pythinker_code/web/static/assets/arc-DpsahJyV.js +1 -0
- pythinker_code/web/static/assets/architectureDiagram-VXUJARFQ-DqiRv9Eg.js +36 -0
- pythinker_code/web/static/assets/asciidoc-Dv7Oe6Be.js +1 -0
- pythinker_code/web/static/assets/asm-D_Q5rh1f.js +1 -0
- pythinker_code/web/static/assets/astro-CbQHKStN.js +1 -0
- pythinker_code/web/static/assets/aurora-x-D-2ljcwZ.js +1 -0
- pythinker_code/web/static/assets/awk-DMzUqQB5.js +1 -0
- pythinker_code/web/static/assets/ayu-dark-CmMr59Fi.js +1 -0
- pythinker_code/web/static/assets/ballerina-BFfxhgS-.js +1 -0
- pythinker_code/web/static/assets/bat-BkioyH1T.js +1 -0
- pythinker_code/web/static/assets/beancount-k_qm7-4y.js +1 -0
- pythinker_code/web/static/assets/berry-uYugtg8r.js +1 -0
- pythinker_code/web/static/assets/bibtex-CHM0blh-.js +1 -0
- pythinker_code/web/static/assets/bicep-Bmn6On1c.js +1 -0
- pythinker_code/web/static/assets/blade-D4QpJJKB.js +1 -0
- pythinker_code/web/static/assets/blockDiagram-VD42YOAC-WgtUvqbp.js +122 -0
- pythinker_code/web/static/assets/bsl-BO_Y6i37.js +1 -0
- pythinker_code/web/static/assets/c-BIGW1oBm.js +1 -0
- pythinker_code/web/static/assets/c3-VCDPK7BO.js +1 -0
- pythinker_code/web/static/assets/c4Diagram-YG6GDRKO-rK0RPuZd.js +10 -0
- pythinker_code/web/static/assets/cadence-Bv_4Rxtq.js +1 -0
- pythinker_code/web/static/assets/cairo-KRGpt6FW.js +1 -0
- pythinker_code/web/static/assets/catppuccin-frappe-DFWUc33u.js +1 -0
- pythinker_code/web/static/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
- pythinker_code/web/static/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
- pythinker_code/web/static/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
- pythinker_code/web/static/assets/channel-B0rlvkH-.js +1 -0
- pythinker_code/web/static/assets/chunk-4BX2VUAB-DIkMuLV-.js +1 -0
- pythinker_code/web/static/assets/chunk-55IACEB6-CORdm4k4.js +1 -0
- pythinker_code/web/static/assets/chunk-B4BG7PRW-D9xDhwHO.js +165 -0
- pythinker_code/web/static/assets/chunk-DI55MBZ5-BDmF9Bh-.js +220 -0
- pythinker_code/web/static/assets/chunk-FMBD7UC4-BCse_HmM.js +15 -0
- pythinker_code/web/static/assets/chunk-QN33PNHL-DCpBmTzA.js +1 -0
- pythinker_code/web/static/assets/chunk-QZHKN3VN-BqLuqobw.js +1 -0
- pythinker_code/web/static/assets/chunk-TZMSLE5B-8K2ogOKS.js +1 -0
- pythinker_code/web/static/assets/clarity-D53aC0YG.js +1 -0
- pythinker_code/web/static/assets/classDiagram-2ON5EDUG-D_ZHSii2.js +1 -0
- pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-D_ZHSii2.js +1 -0
- pythinker_code/web/static/assets/clojure-P80f7IUj.js +1 -0
- pythinker_code/web/static/assets/clone-GSXejyY1.js +1 -0
- pythinker_code/web/static/assets/cmake-D1j8_8rp.js +1 -0
- pythinker_code/web/static/assets/cobol-nwyudZeR.js +1 -0
- pythinker_code/web/static/assets/code-block-IT6T5CEO-DWTFYA28.js +2 -0
- pythinker_code/web/static/assets/codeowners-Bp6g37R7.js +1 -0
- pythinker_code/web/static/assets/codeql-DsOJ9woJ.js +1 -0
- pythinker_code/web/static/assets/coffee-Ch7k5sss.js +1 -0
- pythinker_code/web/static/assets/common-lisp-Cg-RD9OK.js +1 -0
- pythinker_code/web/static/assets/coq-DkFqJrB1.js +1 -0
- pythinker_code/web/static/assets/cose-bilkent-S5V4N54A-BRI7ES-N.js +1 -0
- pythinker_code/web/static/assets/cpp-CofmeUqb.js +1 -0
- pythinker_code/web/static/assets/crystal-tKQVLTB8.js +1 -0
- pythinker_code/web/static/assets/csharp-K5feNrxe.js +1 -0
- pythinker_code/web/static/assets/css-DPfMkruS.js +1 -0
- pythinker_code/web/static/assets/csv-fuZLfV_i.js +1 -0
- pythinker_code/web/static/assets/cue-D82EKSYY.js +1 -0
- pythinker_code/web/static/assets/cypher-COkxafJQ.js +1 -0
- pythinker_code/web/static/assets/cytoscape.esm-B6BxUuKW.js +321 -0
- pythinker_code/web/static/assets/d-85-TOEBH.js +1 -0
- pythinker_code/web/static/assets/dagre-6UL2VRFP-Ci5GdWfi.js +4 -0
- pythinker_code/web/static/assets/dark-plus-C3mMm8J8.js +1 -0
- pythinker_code/web/static/assets/dart-CF10PKvl.js +1 -0
- pythinker_code/web/static/assets/dax-CEL-wOlO.js +1 -0
- pythinker_code/web/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- pythinker_code/web/static/assets/desktop-BmXAJ9_W.js +1 -0
- pythinker_code/web/static/assets/diagram-PSM6KHXK-0hhAylV4.js +24 -0
- pythinker_code/web/static/assets/diagram-QEK2KX5R-8fxgaW6d.js +43 -0
- pythinker_code/web/static/assets/diagram-S2PKOQOG-FRr0_atE.js +24 -0
- pythinker_code/web/static/assets/diff-D97Zzqfu.js +1 -0
- pythinker_code/web/static/assets/docker-BcOcwvcX.js +1 -0
- pythinker_code/web/static/assets/dotenv-Da5cRb03.js +1 -0
- pythinker_code/web/static/assets/dracula-BzJJZx-M.js +1 -0
- pythinker_code/web/static/assets/dracula-soft-BXkSAIEj.js +1 -0
- pythinker_code/web/static/assets/dream-maker-BtqSS_iP.js +1 -0
- pythinker_code/web/static/assets/edge-BkV0erSs.js +1 -0
- pythinker_code/web/static/assets/elixir-CDX3lj18.js +1 -0
- pythinker_code/web/static/assets/elm-DbKCFpqz.js +1 -0
- pythinker_code/web/static/assets/emacs-lisp-C9XAeP06.js +1 -0
- pythinker_code/web/static/assets/erDiagram-Q2GNP2WA-B3T-hJUM.js +60 -0
- pythinker_code/web/static/assets/erb-BOJIQeun.js +1 -0
- pythinker_code/web/static/assets/erlang-DsQrWhSR.js +1 -0
- pythinker_code/web/static/assets/everforest-dark-BgDCqdQA.js +1 -0
- pythinker_code/web/static/assets/everforest-light-C8M2exoo.js +1 -0
- pythinker_code/web/static/assets/fennel-BYunw83y.js +1 -0
- pythinker_code/web/static/assets/fish-BvzEVeQv.js +1 -0
- pythinker_code/web/static/assets/flowDiagram-NV44I4VS-D0S3u7ot.js +162 -0
- pythinker_code/web/static/assets/fluent-C4IJs8-o.js +1 -0
- pythinker_code/web/static/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
- pythinker_code/web/static/assets/fortran-free-form-BxgE0vQu.js +1 -0
- pythinker_code/web/static/assets/fsharp-CXgrBDvD.js +1 -0
- pythinker_code/web/static/assets/ganttDiagram-JELNMOA3-CHrN2a23.js +267 -0
- pythinker_code/web/static/assets/gdresource-B7Tvp0Sc.js +1 -0
- pythinker_code/web/static/assets/gdscript-DTMYz4Jt.js +1 -0
- pythinker_code/web/static/assets/gdshader-DkwncUOv.js +1 -0
- pythinker_code/web/static/assets/genie-D0YGMca9.js +1 -0
- pythinker_code/web/static/assets/gherkin-DyxjwDmM.js +1 -0
- pythinker_code/web/static/assets/git-commit-F4YmCXRG.js +1 -0
- pythinker_code/web/static/assets/git-rebase-r7XF79zn.js +1 -0
- pythinker_code/web/static/assets/gitGraphDiagram-NY62KEGX-CfcXZWg0.js +65 -0
- pythinker_code/web/static/assets/github-dark-DHJKELXO.js +1 -0
- pythinker_code/web/static/assets/github-dark-default-Cuk6v7N8.js +1 -0
- pythinker_code/web/static/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
- pythinker_code/web/static/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
- pythinker_code/web/static/assets/github-light-DAi9KRSo.js +1 -0
- pythinker_code/web/static/assets/github-light-default-D7oLnXFd.js +1 -0
- pythinker_code/web/static/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
- pythinker_code/web/static/assets/gleam-BspZqrRM.js +1 -0
- pythinker_code/web/static/assets/glimmer-js-Rg0-pVw9.js +1 -0
- pythinker_code/web/static/assets/glimmer-ts-U6CK756n.js +1 -0
- pythinker_code/web/static/assets/glsl-DplSGwfg.js +1 -0
- pythinker_code/web/static/assets/gn-n2N0HUVH.js +1 -0
- pythinker_code/web/static/assets/gnuplot-DdkO51Og.js +1 -0
- pythinker_code/web/static/assets/go-Dn2_MT6a.js +1 -0
- pythinker_code/web/static/assets/graph-8jMJwCqE.js +1 -0
- pythinker_code/web/static/assets/graphql-ChdNCCLP.js +1 -0
- pythinker_code/web/static/assets/groovy-gcz8RCvz.js +1 -0
- pythinker_code/web/static/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
- pythinker_code/web/static/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
- pythinker_code/web/static/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
- pythinker_code/web/static/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
- pythinker_code/web/static/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
- pythinker_code/web/static/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
- pythinker_code/web/static/assets/hack-CaT9iCJl.js +1 -0
- pythinker_code/web/static/assets/haml-B8DHNrY2.js +1 -0
- pythinker_code/web/static/assets/handlebars-BL8al0AC.js +1 -0
- pythinker_code/web/static/assets/haskell-Df6bDoY_.js +1 -0
- pythinker_code/web/static/assets/haxe-CzTSHFRz.js +1 -0
- pythinker_code/web/static/assets/hcl-BWvSN4gD.js +1 -0
- pythinker_code/web/static/assets/hjson-D5-asLiD.js +1 -0
- pythinker_code/web/static/assets/hlsl-D3lLCCz7.js +1 -0
- pythinker_code/web/static/assets/houston-DnULxvSX.js +1 -0
- pythinker_code/web/static/assets/html-GMplVEZG.js +1 -0
- pythinker_code/web/static/assets/html-derivative-BFtXZ54Q.js +1 -0
- pythinker_code/web/static/assets/http-jrhK8wxY.js +1 -0
- pythinker_code/web/static/assets/hurl-irOxFIW8.js +1 -0
- pythinker_code/web/static/assets/hxml-Bvhsp5Yf.js +1 -0
- pythinker_code/web/static/assets/hy-DFXneXwc.js +1 -0
- pythinker_code/web/static/assets/imba-DGztddWO.js +1 -0
- pythinker_code/web/static/assets/index-BXrFnzMy.js +153 -0
- pythinker_code/web/static/assets/index-BpoLgcEt.js +1 -0
- pythinker_code/web/static/assets/index-BrfQJnRD.js +5 -0
- pythinker_code/web/static/assets/index-C4gFzubz.js +2 -0
- pythinker_code/web/static/assets/index-CzV_vCfu.css +1 -0
- pythinker_code/web/static/assets/index-DI2oedCt.js +19 -0
- pythinker_code/web/static/assets/infoDiagram-WHAUD3N6-DdxonBf3.js +2 -0
- pythinker_code/web/static/assets/ini-BEwlwnbL.js +1 -0
- pythinker_code/web/static/assets/init-Gi6I4Gst.js +1 -0
- pythinker_code/web/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- pythinker_code/web/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- pythinker_code/web/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- pythinker_code/web/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- pythinker_code/web/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- pythinker_code/web/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- pythinker_code/web/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- pythinker_code/web/static/assets/java-CylS5w8V.js +1 -0
- pythinker_code/web/static/assets/javascript-wDzz0qaB.js +1 -0
- pythinker_code/web/static/assets/jinja-4LBKfQ-Z.js +1 -0
- pythinker_code/web/static/assets/jison-wvAkD_A8.js +1 -0
- pythinker_code/web/static/assets/journeyDiagram-XKPGCS4Q-BXf4aQei.js +139 -0
- pythinker_code/web/static/assets/json-Cp-IABpG.js +1 -0
- pythinker_code/web/static/assets/json5-C9tS-k6U.js +1 -0
- pythinker_code/web/static/assets/jsonc-Des-eS-w.js +1 -0
- pythinker_code/web/static/assets/jsonl-DcaNXYhu.js +1 -0
- pythinker_code/web/static/assets/jsonnet-DFQXde-d.js +1 -0
- pythinker_code/web/static/assets/jssm-C2t-YnRu.js +1 -0
- pythinker_code/web/static/assets/jsx-g9-lgVsj.js +1 -0
- pythinker_code/web/static/assets/julia-CxzCAyBv.js +1 -0
- pythinker_code/web/static/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
- pythinker_code/web/static/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
- pythinker_code/web/static/assets/kanagawa-wave-DWedfzmr.js +1 -0
- pythinker_code/web/static/assets/kanban-definition-3W4ZIXB7-DLpPPOu8.js +89 -0
- pythinker_code/web/static/assets/katex-D2lIc1rk.css +1 -0
- pythinker_code/web/static/assets/kdl-DV7GczEv.js +1 -0
- pythinker_code/web/static/assets/kotlin-BdnUsdx6.js +1 -0
- pythinker_code/web/static/assets/kusto-DZf3V79B.js +1 -0
- pythinker_code/web/static/assets/laserwave-DUszq2jm.js +1 -0
- pythinker_code/web/static/assets/latex-B4uzh10-.js +1 -0
- pythinker_code/web/static/assets/layout-DH73UoAH.js +1 -0
- pythinker_code/web/static/assets/lean-BZvkOJ9d.js +1 -0
- pythinker_code/web/static/assets/less-B1dDrJ26.js +1 -0
- pythinker_code/web/static/assets/light-plus-B7mTdjB0.js +1 -0
- pythinker_code/web/static/assets/linear-bAer2-sK.js +1 -0
- pythinker_code/web/static/assets/liquid-DYVedYrR.js +1 -0
- pythinker_code/web/static/assets/llvm-BtvRca6l.js +1 -0
- pythinker_code/web/static/assets/log-2UxHyX5q.js +1 -0
- pythinker_code/web/static/assets/logo-BtOb2qkB.js +1 -0
- pythinker_code/web/static/assets/lua-BbnMAYS6.js +1 -0
- pythinker_code/web/static/assets/luau-C-HG3fhB.js +1 -0
- pythinker_code/web/static/assets/make-CHLpvVh8.js +1 -0
- pythinker_code/web/static/assets/markdown-Cvjx9yec.js +1 -0
- pythinker_code/web/static/assets/marko-DZsq8hO1.js +1 -0
- pythinker_code/web/static/assets/material-theme-D5KoaKCx.js +1 -0
- pythinker_code/web/static/assets/material-theme-darker-BfHTSMKl.js +1 -0
- pythinker_code/web/static/assets/material-theme-lighter-B0m2ddpp.js +1 -0
- pythinker_code/web/static/assets/material-theme-ocean-CyktbL80.js +1 -0
- pythinker_code/web/static/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
- pythinker_code/web/static/assets/matlab-D7o27uSR.js +1 -0
- pythinker_code/web/static/assets/mdc-DUICxH0z.js +1 -0
- pythinker_code/web/static/assets/mdx-Cmh6b_Ma.js +1 -0
- pythinker_code/web/static/assets/mermaid-VLURNSYL-B2P5VJ9v.css +1 -0
- pythinker_code/web/static/assets/mermaid-VLURNSYL-CuqbwKXv.js +465 -0
- pythinker_code/web/static/assets/mermaid-mWjccvbQ.js +1 -0
- pythinker_code/web/static/assets/mermaid.core-Nx-rTKiV.js +191 -0
- pythinker_code/web/static/assets/min-DbfD8Ywu.js +1 -0
- pythinker_code/web/static/assets/min-dark-CafNBF8u.js +1 -0
- pythinker_code/web/static/assets/min-light-CTRr51gU.js +1 -0
- pythinker_code/web/static/assets/mindmap-definition-VGOIOE7T-C6l761Ue.js +68 -0
- pythinker_code/web/static/assets/mipsasm-CKIfxQSi.js +1 -0
- pythinker_code/web/static/assets/mojo-B93PlW-d.js +1 -0
- pythinker_code/web/static/assets/monokai-D4h5O-jR.js +1 -0
- pythinker_code/web/static/assets/moonbit-Ba13S78F.js +1 -0
- pythinker_code/web/static/assets/move-Bu9oaDYs.js +1 -0
- pythinker_code/web/static/assets/narrat-DRg8JJMk.js +1 -0
- pythinker_code/web/static/assets/nextflow-BrzmwbiE.js +1 -0
- pythinker_code/web/static/assets/nginx-DknmC5AR.js +1 -0
- pythinker_code/web/static/assets/night-owl-C39BiMTA.js +1 -0
- pythinker_code/web/static/assets/nim-CVrawwO9.js +1 -0
- pythinker_code/web/static/assets/nix-CwoSXNpI.js +1 -0
- pythinker_code/web/static/assets/nord-Ddv68eIx.js +1 -0
- pythinker_code/web/static/assets/nushell-C-sUppwS.js +1 -0
- pythinker_code/web/static/assets/objective-c-DXmwc3jG.js +1 -0
- pythinker_code/web/static/assets/objective-cpp-CLxacb5B.js +1 -0
- pythinker_code/web/static/assets/ocaml-C0hk2d4L.js +1 -0
- pythinker_code/web/static/assets/one-dark-pro-DVMEJ2y_.js +1 -0
- pythinker_code/web/static/assets/one-light-PoHY5YXO.js +1 -0
- pythinker_code/web/static/assets/openscad-C4EeE6gA.js +1 -0
- pythinker_code/web/static/assets/ordinal-Cboi1Yqb.js +1 -0
- pythinker_code/web/static/assets/pascal-D93ZcfNL.js +1 -0
- pythinker_code/web/static/assets/perl-C0TMdlhV.js +1 -0
- pythinker_code/web/static/assets/php-CDn_0X-4.js +1 -0
- pythinker_code/web/static/assets/pieDiagram-ADFJNKIX-fNg41mT9.js +30 -0
- pythinker_code/web/static/assets/pkl-u5AG7uiY.js +1 -0
- pythinker_code/web/static/assets/plastic-3e1v2bzS.js +1 -0
- pythinker_code/web/static/assets/plsql-ChMvpjG-.js +1 -0
- pythinker_code/web/static/assets/po-BTJTHyun.js +1 -0
- pythinker_code/web/static/assets/poimandres-CS3Unz2-.js +1 -0
- pythinker_code/web/static/assets/polar-C0HS_06l.js +1 -0
- pythinker_code/web/static/assets/postcss-CXtECtnM.js +1 -0
- pythinker_code/web/static/assets/powerquery-CEu0bR-o.js +1 -0
- pythinker_code/web/static/assets/powershell-Dpen1YoG.js +1 -0
- pythinker_code/web/static/assets/prisma-Dd19v3D-.js +1 -0
- pythinker_code/web/static/assets/prolog-CbFg5uaA.js +1 -0
- pythinker_code/web/static/assets/proto-C7zT0LnQ.js +1 -0
- pythinker_code/web/static/assets/pug-CGlum2m_.js +1 -0
- pythinker_code/web/static/assets/puppet-BMWR74SV.js +1 -0
- pythinker_code/web/static/assets/purescript-CklMAg4u.js +1 -0
- pythinker_code/web/static/assets/python-B6aJPvgy.js +1 -0
- pythinker_code/web/static/assets/qml-3beO22l8.js +1 -0
- pythinker_code/web/static/assets/qmldir-C8lEn-DE.js +1 -0
- pythinker_code/web/static/assets/qss-IeuSbFQv.js +1 -0
- pythinker_code/web/static/assets/quadrantDiagram-AYHSOK5B-DJz3Kx87.js +7 -0
- pythinker_code/web/static/assets/r-Dspwwk_N.js +1 -0
- pythinker_code/web/static/assets/racket-BqYA7rlc.js +1 -0
- pythinker_code/web/static/assets/raku-DXvB9xmW.js +1 -0
- pythinker_code/web/static/assets/razor-C1TweQQi.js +1 -0
- pythinker_code/web/static/assets/red-bN70gL4F.js +1 -0
- pythinker_code/web/static/assets/reg-C-SQnVFl.js +1 -0
- pythinker_code/web/static/assets/regexp-CDVJQ6XC.js +1 -0
- pythinker_code/web/static/assets/rel-C3B-1QV4.js +1 -0
- pythinker_code/web/static/assets/requirementDiagram-UZGBJVZJ-B4SbrfE9.js +64 -0
- pythinker_code/web/static/assets/riscv-BM1_JUlF.js +1 -0
- pythinker_code/web/static/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
- pythinker_code/web/static/assets/rose-pine-moon-D4_iv3hh.js +1 -0
- pythinker_code/web/static/assets/rose-pine-qdsjHGoJ.js +1 -0
- pythinker_code/web/static/assets/rosmsg-BJDFO7_C.js +1 -0
- pythinker_code/web/static/assets/rst-B0xPkSld.js +1 -0
- pythinker_code/web/static/assets/ruby-BvKwtOVI.js +1 -0
- pythinker_code/web/static/assets/rust-B1yitclQ.js +1 -0
- pythinker_code/web/static/assets/sankeyDiagram-TZEHDZUN-CoSUjLAG.js +10 -0
- pythinker_code/web/static/assets/sas-cz2c8ADy.js +1 -0
- pythinker_code/web/static/assets/sass-Cj5Yp3dK.js +1 -0
- pythinker_code/web/static/assets/scala-C151Ov-r.js +1 -0
- pythinker_code/web/static/assets/scheme-C98Dy4si.js +1 -0
- pythinker_code/web/static/assets/scss-OYdSNvt2.js +1 -0
- pythinker_code/web/static/assets/sdbl-DVxCFoDh.js +1 -0
- pythinker_code/web/static/assets/sequenceDiagram-WL72ISMW-PjhBNHi3.js +145 -0
- pythinker_code/web/static/assets/shaderlab-Dg9Lc6iA.js +1 -0
- pythinker_code/web/static/assets/shellscript-Yzrsuije.js +1 -0
- pythinker_code/web/static/assets/shellsession-BADoaaVG.js +1 -0
- pythinker_code/web/static/assets/slack-dark-BthQWCQV.js +1 -0
- pythinker_code/web/static/assets/slack-ochin-DqwNpetd.js +1 -0
- pythinker_code/web/static/assets/smalltalk-BERRCDM3.js +1 -0
- pythinker_code/web/static/assets/snazzy-light-Bw305WKR.js +1 -0
- pythinker_code/web/static/assets/solarized-dark-DXbdFlpD.js +1 -0
- pythinker_code/web/static/assets/solarized-light-L9t79GZl.js +1 -0
- pythinker_code/web/static/assets/solidity-rGO070M0.js +1 -0
- pythinker_code/web/static/assets/soy-Brmx7dQM.js +1 -0
- pythinker_code/web/static/assets/sparql-rVzFXLq3.js +1 -0
- pythinker_code/web/static/assets/splunk-BtCnVYZw.js +1 -0
- pythinker_code/web/static/assets/sql-BLtJtn59.js +1 -0
- pythinker_code/web/static/assets/ssh-config-_ykCGR6B.js +1 -0
- pythinker_code/web/static/assets/stata-BH5u7GGu.js +1 -0
- pythinker_code/web/static/assets/stateDiagram-FKZM4ZOC-DOwESt8-.js +1 -0
- pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-yl3OHWiP.js +1 -0
- pythinker_code/web/static/assets/stylus-BEDo0Tqx.js +1 -0
- pythinker_code/web/static/assets/svelte-zxCyuUbr.js +1 -0
- pythinker_code/web/static/assets/swift-Dg5xB15N.js +1 -0
- pythinker_code/web/static/assets/synthwave-84-CbfX1IO0.js +1 -0
- pythinker_code/web/static/assets/system-verilog-CnnmHF94.js +1 -0
- pythinker_code/web/static/assets/systemd-4A_iFExJ.js +1 -0
- pythinker_code/web/static/assets/talonscript-CkByrt1z.js +1 -0
- pythinker_code/web/static/assets/tasl-QIJgUcNo.js +1 -0
- pythinker_code/web/static/assets/tcl-dwOrl1Do.js +1 -0
- pythinker_code/web/static/assets/templ-W15q3VgB.js +1 -0
- pythinker_code/web/static/assets/terraform-BETggiCN.js +1 -0
- pythinker_code/web/static/assets/tex-CvyZ59Mk.js +1 -0
- pythinker_code/web/static/assets/timeline-definition-IT6M3QCI-CkCLnAgi.js +61 -0
- pythinker_code/web/static/assets/tokyo-night-hegEt444.js +1 -0
- pythinker_code/web/static/assets/toml-vGWfd6FD.js +1 -0
- pythinker_code/web/static/assets/treemap-KMMF4GRG-CZS5XwTf.js +128 -0
- pythinker_code/web/static/assets/ts-tags-zn1MmPIZ.js +1 -0
- pythinker_code/web/static/assets/tsv-B_m7g4N7.js +1 -0
- pythinker_code/web/static/assets/tsx-COt5Ahok.js +1 -0
- pythinker_code/web/static/assets/turtle-BsS91CYL.js +1 -0
- pythinker_code/web/static/assets/twig-CO9l9SDP.js +1 -0
- pythinker_code/web/static/assets/typescript-BPQ3VLAy.js +1 -0
- pythinker_code/web/static/assets/typespec-BGHnOYBU.js +1 -0
- pythinker_code/web/static/assets/typst-DHCkPAjA.js +1 -0
- pythinker_code/web/static/assets/v-BcVCzyr7.js +1 -0
- pythinker_code/web/static/assets/vala-CsfeWuGM.js +1 -0
- pythinker_code/web/static/assets/vb-D17OF-Vu.js +1 -0
- pythinker_code/web/static/assets/verilog-BQ8w6xss.js +1 -0
- pythinker_code/web/static/assets/vesper-DU1UobuO.js +1 -0
- pythinker_code/web/static/assets/vhdl-CeAyd5Ju.js +1 -0
- pythinker_code/web/static/assets/viml-CJc9bBzg.js +1 -0
- pythinker_code/web/static/assets/vitesse-black-Bkuqu6BP.js +1 -0
- pythinker_code/web/static/assets/vitesse-dark-D0r3Knsf.js +1 -0
- pythinker_code/web/static/assets/vitesse-light-CVO1_9PV.js +1 -0
- pythinker_code/web/static/assets/vue-DN_0RTcg.js +1 -0
- pythinker_code/web/static/assets/vue-html-AaS7Mt5G.js +1 -0
- pythinker_code/web/static/assets/vue-vine-CQOfvN7w.js +1 -0
- pythinker_code/web/static/assets/vyper-CDx5xZoG.js +1 -0
- pythinker_code/web/static/assets/wasm-CG6Dc4jp.js +1 -0
- pythinker_code/web/static/assets/wasm-MzD3tlZU.js +1 -0
- pythinker_code/web/static/assets/wenyan-BV7otONQ.js +1 -0
- pythinker_code/web/static/assets/wgsl-Dx-B1_4e.js +1 -0
- pythinker_code/web/static/assets/wikitext-BhOHFoWU.js +1 -0
- pythinker_code/web/static/assets/wit-5i3qLPDT.js +1 -0
- pythinker_code/web/static/assets/wolfram-lXgVvXCa.js +1 -0
- pythinker_code/web/static/assets/xml-sdJ4AIDG.js +1 -0
- pythinker_code/web/static/assets/xsl-CtQFsRM5.js +1 -0
- pythinker_code/web/static/assets/xychartDiagram-PRI3JC2R-DkqqHNLh.js +7 -0
- pythinker_code/web/static/assets/yaml-Buea-lGh.js +1 -0
- pythinker_code/web/static/assets/zenscript-DVFEvuxE.js +1 -0
- pythinker_code/web/static/assets/zig-VOosw3JB.js +1 -0
- pythinker_code/web/static/brand/apple-touch-icon.png +0 -0
- pythinker_code/web/static/brand/arctecture.webp +0 -0
- pythinker_code/web/static/brand/bimi-logo.svg +46 -0
- pythinker_code/web/static/brand/favicon.ico +0 -0
- pythinker_code/web/static/brand/fonts/dm-sans-latin-ext.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/dm-sans-latin.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/instrument-sans-latin-ext.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/instrument-sans-latin.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/instrument-serif-latin-ext.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/instrument-serif-latin.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin-ext.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/libre-baskerville-latin-ext.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/libre-baskerville-latin.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/roboto-latin-ext.woff2 +0 -0
- pythinker_code/web/static/brand/fonts/roboto-latin.woff2 +0 -0
- pythinker_code/web/static/brand/icon-192.png +0 -0
- pythinker_code/web/static/brand/icon-512.png +0 -0
- pythinker_code/web/static/brand/icon.svg +1 -0
- pythinker_code/web/static/brand/logo.png +0 -0
- pythinker_code/web/static/brand/pythinker_animated.svg +79 -0
- pythinker_code/web/static/brand/robots.txt +4 -0
- pythinker_code/web/static/index.html +15 -0
- pythinker_code/web/static/logo.png +0 -0
- pythinker_code/web/store/__init__.py +1 -0
- pythinker_code/web/store/sessions.py +433 -0
- pythinker_code/wire/__init__.py +148 -0
- pythinker_code/wire/file.py +151 -0
- pythinker_code/wire/jsonrpc.py +263 -0
- pythinker_code/wire/protocol.py +2 -0
- pythinker_code/wire/root_hub.py +27 -0
- pythinker_code/wire/serde.py +26 -0
- pythinker_code/wire/server.py +1072 -0
- pythinker_code/wire/types.py +698 -0
- pythinker_code-0.8.0.dist-info/METADATA +706 -0
- pythinker_code-0.8.0.dist-info/RECORD +790 -0
- pythinker_code-0.8.0.dist-info/WHEEL +4 -0
- pythinker_code-0.8.0.dist-info/entry_points.txt +4 -0
- pythinker_code-0.8.0.dist-info/licenses/LICENSE +202 -0
- pythinker_code-0.8.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,2888 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import warnings
|
|
14
|
+
from collections import deque
|
|
15
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from hashlib import md5
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Literal, Protocol, cast, override, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from prompt_toolkit import PromptSession
|
|
23
|
+
from prompt_toolkit.application.current import get_app_or_none
|
|
24
|
+
from prompt_toolkit.buffer import Buffer
|
|
25
|
+
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
|
|
26
|
+
from prompt_toolkit.completion import (
|
|
27
|
+
CompleteEvent,
|
|
28
|
+
Completer,
|
|
29
|
+
Completion,
|
|
30
|
+
FuzzyCompleter,
|
|
31
|
+
WordCompleter,
|
|
32
|
+
merge_completers,
|
|
33
|
+
)
|
|
34
|
+
from prompt_toolkit.data_structures import Point
|
|
35
|
+
from prompt_toolkit.document import Document
|
|
36
|
+
from prompt_toolkit.filters import Condition, has_completions
|
|
37
|
+
from prompt_toolkit.formatted_text import AnyFormattedText, FormattedText, to_formatted_text
|
|
38
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
39
|
+
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
|
40
|
+
from prompt_toolkit.keys import Keys
|
|
41
|
+
from prompt_toolkit.layout.containers import (
|
|
42
|
+
ConditionalContainer,
|
|
43
|
+
DynamicContainer,
|
|
44
|
+
FloatContainer,
|
|
45
|
+
HSplit,
|
|
46
|
+
Window,
|
|
47
|
+
)
|
|
48
|
+
from prompt_toolkit.layout.controls import BufferControl, UIContent, UIControl
|
|
49
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
50
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
51
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
52
|
+
from prompt_toolkit.utils import get_cwidth
|
|
53
|
+
from pydantic import BaseModel, ValidationError
|
|
54
|
+
from pythinker_host.path import HostPath
|
|
55
|
+
|
|
56
|
+
from pythinker_code.llm import ModelCapability
|
|
57
|
+
from pythinker_code.share import get_share_dir
|
|
58
|
+
from pythinker_code.soul import StatusSnapshot, format_context_status
|
|
59
|
+
from pythinker_code.ui.shell import placeholders as prompt_placeholders
|
|
60
|
+
from pythinker_code.ui.shell.console import console
|
|
61
|
+
from pythinker_code.ui.shell.placeholders import (
|
|
62
|
+
PromptPlaceholderManager,
|
|
63
|
+
normalize_pasted_text,
|
|
64
|
+
sanitize_surrogates,
|
|
65
|
+
)
|
|
66
|
+
from pythinker_code.ui.shell.spinner_words import spinner_message
|
|
67
|
+
from pythinker_code.ui.theme import get_prompt_style, get_toolbar_colors
|
|
68
|
+
from pythinker_code.ui.tui_config import is_card_style
|
|
69
|
+
from pythinker_code.utils.clipboard import (
|
|
70
|
+
grab_media_from_clipboard,
|
|
71
|
+
is_clipboard_available,
|
|
72
|
+
is_media_clipboard_available,
|
|
73
|
+
)
|
|
74
|
+
from pythinker_code.utils.logging import logger
|
|
75
|
+
from pythinker_code.utils.slashcmd import SlashCommand
|
|
76
|
+
from pythinker_code.wire.types import ContentPart
|
|
77
|
+
|
|
78
|
+
AttachmentCache = prompt_placeholders.AttachmentCache
|
|
79
|
+
CachedAttachment = prompt_placeholders.CachedAttachment
|
|
80
|
+
_parse_attachment_kind = prompt_placeholders.parse_attachment_kind
|
|
81
|
+
|
|
82
|
+
PROMPT_SYMBOL = "✨"
|
|
83
|
+
PROMPT_SYMBOL_AGENT_INPUT = "›"
|
|
84
|
+
PROMPT_SYMBOL_SHELL = "$"
|
|
85
|
+
PROMPT_SYMBOL_THINKING = "💫"
|
|
86
|
+
PROMPT_SYMBOL_PLAN = "📋"
|
|
87
|
+
_CARD_SIDE_PADDING = 2
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# prompt_toolkit 3.0.52 can emit these during prompt shutdown on Python 3.14
|
|
91
|
+
# when its internal background tasks are cancelled before first execution.
|
|
92
|
+
# Keep the filter narrow so unrelated RuntimeWarnings still surface.
|
|
93
|
+
warnings.filterwarnings(
|
|
94
|
+
"ignore",
|
|
95
|
+
message=(
|
|
96
|
+
r"coroutine 'Buffer\._create_completer_coroutine\.<locals>\.async_completer"
|
|
97
|
+
r"\.<locals>\.refresh_while_loading' was never awaited"
|
|
98
|
+
),
|
|
99
|
+
category=RuntimeWarning,
|
|
100
|
+
)
|
|
101
|
+
warnings.filterwarnings(
|
|
102
|
+
"ignore",
|
|
103
|
+
message=(
|
|
104
|
+
r"coroutine 'Application\.run_async\.<locals>\._run_async\.<locals>"
|
|
105
|
+
r"\.auto_flush_input' was never awaited"
|
|
106
|
+
),
|
|
107
|
+
category=RuntimeWarning,
|
|
108
|
+
)
|
|
109
|
+
warnings.filterwarnings(
|
|
110
|
+
"ignore",
|
|
111
|
+
message=r"coroutine 'KeyProcessor\._start_timeout\.<locals>\.wait' was never awaited",
|
|
112
|
+
category=RuntimeWarning,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
_ORIGINAL_UNRAISABLE_HOOK = sys.unraisablehook
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_prompt_toolkit_keyprocessor_shutdown_noise(unraisable: Any) -> bool:
|
|
119
|
+
"""Return true for prompt_toolkit's Python 3.14 coroutine-finalizer noise."""
|
|
120
|
+
exc = getattr(unraisable, "exc_value", None)
|
|
121
|
+
obj = getattr(unraisable, "object", None)
|
|
122
|
+
return (
|
|
123
|
+
isinstance(exc, KeyError)
|
|
124
|
+
and exc.args == ("__import__",)
|
|
125
|
+
and "KeyProcessor._start_timeout.<locals>.wait" in repr(obj)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pythinker_unraisable_hook(unraisable: Any) -> None:
|
|
130
|
+
if _is_prompt_toolkit_keyprocessor_shutdown_noise(unraisable):
|
|
131
|
+
return
|
|
132
|
+
_ORIGINAL_UNRAISABLE_HOOK(unraisable)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_prompt_toolkit_empty_exception_context(context: dict[str, Any]) -> bool:
|
|
136
|
+
"""Return true for prompt_toolkit's unhelpful ``Exception None`` report.
|
|
137
|
+
|
|
138
|
+
prompt_toolkit prints ``Unhandled exception in event loop`` and blocks on
|
|
139
|
+
``Press ENTER to continue`` even when asyncio only supplied a diagnostic
|
|
140
|
+
context with no exception object. That message has no traceback or useful
|
|
141
|
+
recovery action for users, so Pythinker logs it instead of surfacing a modal
|
|
142
|
+
terminal pause.
|
|
143
|
+
"""
|
|
144
|
+
if context.get("exception") is not None:
|
|
145
|
+
return False
|
|
146
|
+
message = str(context.get("message") or "")
|
|
147
|
+
if not message:
|
|
148
|
+
return True
|
|
149
|
+
return message.startswith(("Task was destroyed but it is pending", "Future exception"))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Python 3.14 can report prompt_toolkit's already-cancelled key-timeout coroutine as an
|
|
153
|
+
# unraisable KeyError("__import__") during interpreter/module teardown. The RuntimeWarning filters
|
|
154
|
+
# above catch the normal warning path; this hook catches the shutdown-only unraisable path while
|
|
155
|
+
# delegating every other unraisable exception to Python's original hook.
|
|
156
|
+
if sys.unraisablehook is not _pythinker_unraisable_hook:
|
|
157
|
+
sys.unraisablehook = _pythinker_unraisable_hook
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CwdLostError(OSError):
|
|
161
|
+
"""Raised when the working directory no longer exists (e.g. external drive unplugged)."""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _slash_command_token_before_cursor(document: Document) -> str | None:
|
|
165
|
+
"""Return the active slash-command token, or ``None`` when completion should stay hidden."""
|
|
166
|
+
text = document.text_before_cursor
|
|
167
|
+
|
|
168
|
+
if document.text_after_cursor.strip():
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
last_space = text.rfind(" ")
|
|
172
|
+
token = text[last_space + 1 :]
|
|
173
|
+
prefix = text[: last_space + 1] if last_space != -1 else ""
|
|
174
|
+
|
|
175
|
+
if prefix.strip() or not token.startswith("/"):
|
|
176
|
+
return None
|
|
177
|
+
return token
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SlashCommandCompleter(Completer):
|
|
181
|
+
"""
|
|
182
|
+
A completer that:
|
|
183
|
+
- Shows one line per slash command using the canonical "/name"
|
|
184
|
+
- Matches exact names first, then name/alias prefixes, while inserting the canonical "/name"
|
|
185
|
+
- Only activates when the current token starts with '/'
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
available_commands: Sequence[SlashCommand[Any]],
|
|
191
|
+
*,
|
|
192
|
+
annotate_meta: bool = False,
|
|
193
|
+
command_scope: str = "command",
|
|
194
|
+
) -> None:
|
|
195
|
+
super().__init__()
|
|
196
|
+
self._available_commands = sorted(available_commands, key=lambda c: c.name)
|
|
197
|
+
self._annotate_meta = annotate_meta
|
|
198
|
+
self._command_scope = command_scope
|
|
199
|
+
self._command_lookup: dict[str, list[SlashCommand[Any]]] = {}
|
|
200
|
+
|
|
201
|
+
for cmd in self._available_commands:
|
|
202
|
+
self._command_lookup.setdefault(cmd.name, []).append(cmd)
|
|
203
|
+
for alias in cmd.aliases:
|
|
204
|
+
self._command_lookup.setdefault(alias, []).append(cmd)
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def should_complete(document: Document) -> bool:
|
|
208
|
+
"""Return whether slash command completion should be active for the current buffer."""
|
|
209
|
+
return _slash_command_token_before_cursor(document) is not None
|
|
210
|
+
|
|
211
|
+
@override
|
|
212
|
+
def get_completions(
|
|
213
|
+
self, document: Document, complete_event: CompleteEvent
|
|
214
|
+
) -> Iterable[Completion]:
|
|
215
|
+
if not self.should_complete(document):
|
|
216
|
+
return
|
|
217
|
+
token = _slash_command_token_before_cursor(document)
|
|
218
|
+
if token is None:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
typed = token[1:]
|
|
222
|
+
typed_lower = typed.lower()
|
|
223
|
+
seen: set[str] = set()
|
|
224
|
+
|
|
225
|
+
def emit(cmd: SlashCommand[Any]) -> Iterable[Completion]:
|
|
226
|
+
if cmd.name in seen:
|
|
227
|
+
return
|
|
228
|
+
seen.add(cmd.name)
|
|
229
|
+
yield Completion(
|
|
230
|
+
text=f"/{cmd.name}",
|
|
231
|
+
start_position=-len(token),
|
|
232
|
+
display=f"/{cmd.name}",
|
|
233
|
+
display_meta=self._display_meta(cmd),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if not typed:
|
|
237
|
+
for cmd in self._available_commands:
|
|
238
|
+
yield from emit(cmd)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
exact: list[SlashCommand[Any]] = []
|
|
242
|
+
prefix: list[SlashCommand[Any]] = []
|
|
243
|
+
for candidate, commands in self._command_lookup.items():
|
|
244
|
+
candidate_lower = candidate.lower()
|
|
245
|
+
if candidate_lower == typed_lower:
|
|
246
|
+
exact.extend(commands)
|
|
247
|
+
elif candidate_lower.startswith(typed_lower):
|
|
248
|
+
prefix.extend(commands)
|
|
249
|
+
|
|
250
|
+
for cmd in exact:
|
|
251
|
+
yield from emit(cmd)
|
|
252
|
+
for cmd in prefix:
|
|
253
|
+
yield from emit(cmd)
|
|
254
|
+
|
|
255
|
+
def _display_meta(self, cmd: SlashCommand[Any]) -> str:
|
|
256
|
+
if not self._annotate_meta:
|
|
257
|
+
return cmd.description
|
|
258
|
+
|
|
259
|
+
if cmd.name.startswith("skill:"):
|
|
260
|
+
kind = "skill"
|
|
261
|
+
elif cmd.name.startswith("flow:"):
|
|
262
|
+
kind = "flow"
|
|
263
|
+
else:
|
|
264
|
+
kind = self._command_scope
|
|
265
|
+
|
|
266
|
+
parts = [f"[{kind}]", cmd.description]
|
|
267
|
+
if cmd.aliases:
|
|
268
|
+
parts.append(f"aliases: {', '.join('/' + alias for alias in cmd.aliases)}")
|
|
269
|
+
return " ".join(part for part in parts if part)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _card_side_padding() -> int:
|
|
273
|
+
return _CARD_SIDE_PADDING if is_card_style() else 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _card_side_indent() -> str:
|
|
277
|
+
return " " * _card_side_padding()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _truncate_to_width(text: str, width: int) -> str:
|
|
281
|
+
if width <= 0:
|
|
282
|
+
return ""
|
|
283
|
+
|
|
284
|
+
total = 0
|
|
285
|
+
chars: list[str] = []
|
|
286
|
+
for ch in text:
|
|
287
|
+
ch_width = get_cwidth(ch)
|
|
288
|
+
if total + ch_width > width:
|
|
289
|
+
break
|
|
290
|
+
chars.append(ch)
|
|
291
|
+
total += ch_width
|
|
292
|
+
|
|
293
|
+
if total == get_cwidth(text):
|
|
294
|
+
return text + (" " * max(0, width - total))
|
|
295
|
+
|
|
296
|
+
ellipsis = "..."
|
|
297
|
+
ellipsis_width = get_cwidth(ellipsis)
|
|
298
|
+
if width <= ellipsis_width:
|
|
299
|
+
return "." * width
|
|
300
|
+
|
|
301
|
+
available = width - ellipsis_width
|
|
302
|
+
total = 0
|
|
303
|
+
chars = []
|
|
304
|
+
for ch in text:
|
|
305
|
+
ch_width = get_cwidth(ch)
|
|
306
|
+
if total + ch_width > available:
|
|
307
|
+
break
|
|
308
|
+
chars.append(ch)
|
|
309
|
+
total += ch_width
|
|
310
|
+
return "".join(chars) + ellipsis + (" " * max(0, width - total - ellipsis_width))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _formatted_text_display_rows(fragments: FormattedText, columns: int) -> list[FormattedText]:
|
|
314
|
+
"""Split formatted text into terminal display rows, preserving styles."""
|
|
315
|
+
rows: list[FormattedText] = [FormattedText()]
|
|
316
|
+
col = 0
|
|
317
|
+
for style, text, *_ in fragments:
|
|
318
|
+
for ch in text:
|
|
319
|
+
if ch == "\n":
|
|
320
|
+
rows.append(FormattedText())
|
|
321
|
+
col = 0
|
|
322
|
+
continue
|
|
323
|
+
width = max(0, get_cwidth(ch))
|
|
324
|
+
if width and col + width > columns:
|
|
325
|
+
rows.append(FormattedText())
|
|
326
|
+
col = 0
|
|
327
|
+
rows[-1].append((style, ch))
|
|
328
|
+
col += width
|
|
329
|
+
return rows
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _extend_rows(out: FormattedText, rows: list[FormattedText]) -> None:
|
|
333
|
+
for index, row in enumerate(rows):
|
|
334
|
+
out.extend(row)
|
|
335
|
+
if index != len(rows) - 1:
|
|
336
|
+
out.append(("", "\n"))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _background_task_summary(counts: BgTaskCounts) -> str | None:
|
|
340
|
+
total = counts.bash + counts.agent
|
|
341
|
+
if total <= 0:
|
|
342
|
+
return None
|
|
343
|
+
noun = "background task" if total == 1 else "background tasks"
|
|
344
|
+
parts: list[str] = []
|
|
345
|
+
if counts.bash:
|
|
346
|
+
parts.append(f"{counts.bash} bash")
|
|
347
|
+
if counts.agent:
|
|
348
|
+
parts.append(f"{counts.agent} agent")
|
|
349
|
+
detail = f" ({', '.join(parts)})" if parts else ""
|
|
350
|
+
return f"{total} {noun} running{detail} · /task to view"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _append_footer_hint_fragments(
|
|
354
|
+
fragments: list[tuple[str, str]],
|
|
355
|
+
tip_text: str,
|
|
356
|
+
*,
|
|
357
|
+
tip_style: str,
|
|
358
|
+
key_style: str,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Append toolbar tips with Codex-like key emphasis while preserving plain text."""
|
|
361
|
+
parts = tip_text.split(_TIP_SEPARATOR)
|
|
362
|
+
for index, part in enumerate(parts):
|
|
363
|
+
if index:
|
|
364
|
+
fragments.append((tip_style, _TIP_SEPARATOR))
|
|
365
|
+
key, sep, label = part.partition(": ")
|
|
366
|
+
if sep:
|
|
367
|
+
fragments.append((key_style, key))
|
|
368
|
+
fragments.append((tip_style, sep + label))
|
|
369
|
+
else:
|
|
370
|
+
fragments.append((tip_style, part))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _fit_formatted_text_to_rows(
|
|
374
|
+
fragments: FormattedText,
|
|
375
|
+
columns: int,
|
|
376
|
+
max_rows: int,
|
|
377
|
+
*,
|
|
378
|
+
preserve_tail_rows: int = 0,
|
|
379
|
+
) -> FormattedText:
|
|
380
|
+
"""Crop prompt preamble text so it cannot cover the input/footer area.
|
|
381
|
+
|
|
382
|
+
prompt_toolkit reserves the bottom toolbar separately. If the dynamic
|
|
383
|
+
prompt message grows taller than the terminal, the rendered tool card can
|
|
384
|
+
visually run underneath the input row and footer. Count wrapped display rows
|
|
385
|
+
and leave a compact truncation hint instead of allowing overlap.
|
|
386
|
+
|
|
387
|
+
``preserve_tail_rows`` keeps important trailing status rows, such as the
|
|
388
|
+
live thinking-word spinner, visible when a tall tool card has to be clipped.
|
|
389
|
+
"""
|
|
390
|
+
if max_rows <= 0:
|
|
391
|
+
return FormattedText([])
|
|
392
|
+
if columns <= 0:
|
|
393
|
+
columns = 80
|
|
394
|
+
|
|
395
|
+
rows = _formatted_text_display_rows(fragments, columns)
|
|
396
|
+
if len(rows) <= max_rows:
|
|
397
|
+
return fragments
|
|
398
|
+
|
|
399
|
+
tail_rows: list[FormattedText] = []
|
|
400
|
+
if preserve_tail_rows > 0 and max_rows > 2:
|
|
401
|
+
for row in reversed(rows):
|
|
402
|
+
if not any(text for _, text, *_ in row):
|
|
403
|
+
continue
|
|
404
|
+
tail_rows.append(row)
|
|
405
|
+
if len(tail_rows) >= preserve_tail_rows:
|
|
406
|
+
break
|
|
407
|
+
tail_rows.reverse()
|
|
408
|
+
tail_rows = tail_rows[: max(0, max_rows - 2)]
|
|
409
|
+
|
|
410
|
+
content_rows = max(0, max_rows - 1 - len(tail_rows))
|
|
411
|
+
if content_rows == 0:
|
|
412
|
+
return FormattedText(
|
|
413
|
+
[("class:dim", _truncate_right("… output clipped to fit terminal", columns))]
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
out: FormattedText = FormattedText()
|
|
417
|
+
_extend_rows(out, rows[:content_rows])
|
|
418
|
+
if out and not out[-1][1].endswith("\n"):
|
|
419
|
+
out.append(("", "\n"))
|
|
420
|
+
clip_hint = _truncate_right("… output clipped to fit terminal", columns)
|
|
421
|
+
out.append(("class:dim", clip_hint))
|
|
422
|
+
if tail_rows:
|
|
423
|
+
out.append(("", "\n"))
|
|
424
|
+
_extend_rows(out, tail_rows)
|
|
425
|
+
return out
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _prompt_preamble_max_rows(terminal_rows: int | None) -> int:
|
|
429
|
+
if terminal_rows is None or terminal_rows <= 0:
|
|
430
|
+
return 20
|
|
431
|
+
# Reserve rows for: spacer, separator, input row, toolbar separator, footer
|
|
432
|
+
# rows, and one safety row. This prevents large tool cards from painting
|
|
433
|
+
# underneath the prompt/footer on short terminals.
|
|
434
|
+
return max(1, terminal_rows - 7)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _wrap_to_width(text: str, width: int, *, max_lines: int | None = None) -> list[str]:
|
|
438
|
+
if width <= 0:
|
|
439
|
+
return []
|
|
440
|
+
|
|
441
|
+
words = text.split()
|
|
442
|
+
if not words:
|
|
443
|
+
return [""]
|
|
444
|
+
|
|
445
|
+
lines: list[str] = []
|
|
446
|
+
current_words: list[str] = []
|
|
447
|
+
current_width = 0
|
|
448
|
+
index = 0
|
|
449
|
+
|
|
450
|
+
while index < len(words):
|
|
451
|
+
word = words[index]
|
|
452
|
+
word_width = get_cwidth(word)
|
|
453
|
+
separator_width = 1 if current_words else 0
|
|
454
|
+
|
|
455
|
+
if current_words and current_width + separator_width + word_width <= width:
|
|
456
|
+
current_words.append(word)
|
|
457
|
+
current_width += separator_width + word_width
|
|
458
|
+
index += 1
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
if not current_words and word_width <= width:
|
|
462
|
+
current_words.append(word)
|
|
463
|
+
current_width = word_width
|
|
464
|
+
index += 1
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
if not current_words and word_width > width:
|
|
468
|
+
current_words.append(_truncate_to_width(word, width).rstrip())
|
|
469
|
+
current_width = get_cwidth(current_words[0])
|
|
470
|
+
index += 1
|
|
471
|
+
|
|
472
|
+
lines.append(" ".join(current_words))
|
|
473
|
+
current_words = []
|
|
474
|
+
current_width = 0
|
|
475
|
+
|
|
476
|
+
if max_lines is not None and len(lines) == max_lines:
|
|
477
|
+
remaining = " ".join(words[index:])
|
|
478
|
+
if remaining:
|
|
479
|
+
prefix = f"{lines[-1]} " if lines[-1] else ""
|
|
480
|
+
lines[-1] = _truncate_to_width(prefix + remaining, width).rstrip()
|
|
481
|
+
return lines
|
|
482
|
+
|
|
483
|
+
if current_words:
|
|
484
|
+
line = " ".join(current_words)
|
|
485
|
+
if max_lines is not None and len(lines) + 1 > max_lines:
|
|
486
|
+
if lines:
|
|
487
|
+
lines[-1] = _truncate_to_width(f"{lines[-1]} {line}", width).rstrip()
|
|
488
|
+
else:
|
|
489
|
+
lines.append(_truncate_to_width(line, width).rstrip())
|
|
490
|
+
else:
|
|
491
|
+
lines.append(line)
|
|
492
|
+
|
|
493
|
+
return lines
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _find_prompt_float_container(layout_container: object) -> FloatContainer | None:
|
|
497
|
+
if not isinstance(layout_container, HSplit):
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
for child in cast(Sequence[object], layout_container.children):
|
|
501
|
+
float_container = _extract_float_container(child)
|
|
502
|
+
if float_container is not None:
|
|
503
|
+
return float_container
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _extract_float_container(container: object) -> FloatContainer | None:
|
|
508
|
+
if isinstance(container, FloatContainer):
|
|
509
|
+
return container
|
|
510
|
+
if isinstance(container, ConditionalContainer):
|
|
511
|
+
if isinstance(container.content, FloatContainer):
|
|
512
|
+
return container.content
|
|
513
|
+
if isinstance(container.alternative_content, FloatContainer):
|
|
514
|
+
return container.alternative_content
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _find_default_buffer_container(
|
|
519
|
+
layout_container: object,
|
|
520
|
+
target_buffer: Buffer,
|
|
521
|
+
) -> ConditionalContainer | None:
|
|
522
|
+
seen: set[int] = set()
|
|
523
|
+
|
|
524
|
+
def _walk(node: object) -> ConditionalContainer | None:
|
|
525
|
+
if id(node) in seen:
|
|
526
|
+
return None
|
|
527
|
+
seen.add(id(node))
|
|
528
|
+
|
|
529
|
+
if isinstance(node, ConditionalContainer):
|
|
530
|
+
content = getattr(node, "content", None)
|
|
531
|
+
if isinstance(content, Window):
|
|
532
|
+
control = content.content
|
|
533
|
+
if isinstance(control, BufferControl) and control.buffer is target_buffer:
|
|
534
|
+
return node
|
|
535
|
+
|
|
536
|
+
if isinstance(node, DynamicContainer):
|
|
537
|
+
with contextlib.suppress(Exception):
|
|
538
|
+
found = _walk(node.get_container())
|
|
539
|
+
if found is not None:
|
|
540
|
+
return found
|
|
541
|
+
|
|
542
|
+
for attr in ("children", "content", "floats", "container"):
|
|
543
|
+
if not hasattr(node, attr):
|
|
544
|
+
continue
|
|
545
|
+
value = getattr(node, attr)
|
|
546
|
+
if attr == "children" and isinstance(value, Sequence):
|
|
547
|
+
for child in value: # pyright: ignore[reportUnknownVariableType]
|
|
548
|
+
found = _walk(child) # pyright: ignore[reportUnknownArgumentType]
|
|
549
|
+
if found is not None:
|
|
550
|
+
return found
|
|
551
|
+
elif attr == "floats" and isinstance(value, Sequence):
|
|
552
|
+
for float_ in value: # pyright: ignore[reportUnknownVariableType]
|
|
553
|
+
content = getattr(float_, "content", None) # pyright: ignore[reportUnknownArgumentType]
|
|
554
|
+
if content is None:
|
|
555
|
+
continue
|
|
556
|
+
found = _walk(content)
|
|
557
|
+
if found is not None:
|
|
558
|
+
return found
|
|
559
|
+
elif (
|
|
560
|
+
attr in {"content", "container"}
|
|
561
|
+
and value is not None
|
|
562
|
+
and type(value).__module__.startswith("prompt_toolkit")
|
|
563
|
+
):
|
|
564
|
+
found = _walk(value)
|
|
565
|
+
if found is not None:
|
|
566
|
+
return found
|
|
567
|
+
return None
|
|
568
|
+
|
|
569
|
+
return _walk(layout_container)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _container_contains(root: object, target: object) -> bool:
|
|
573
|
+
seen: set[int] = set()
|
|
574
|
+
|
|
575
|
+
def _walk(node: object) -> bool:
|
|
576
|
+
if id(node) in seen:
|
|
577
|
+
return False
|
|
578
|
+
seen.add(id(node))
|
|
579
|
+
if node is target:
|
|
580
|
+
return True
|
|
581
|
+
if isinstance(node, DynamicContainer):
|
|
582
|
+
with contextlib.suppress(Exception):
|
|
583
|
+
if _walk(node.get_container()):
|
|
584
|
+
return True
|
|
585
|
+
for attr in ("children", "content", "floats", "container", "alternative_content"):
|
|
586
|
+
if not hasattr(node, attr):
|
|
587
|
+
continue
|
|
588
|
+
value: object = getattr(node, attr)
|
|
589
|
+
if attr == "children" and isinstance(value, Sequence):
|
|
590
|
+
children = cast(Sequence[object], value)
|
|
591
|
+
if any(_walk(child) for child in children):
|
|
592
|
+
return True
|
|
593
|
+
elif attr == "floats" and isinstance(value, Sequence):
|
|
594
|
+
floats = cast(Sequence[object], value)
|
|
595
|
+
if any(_walk(cast(object, getattr(float_, "content", None))) for float_ in floats):
|
|
596
|
+
return True
|
|
597
|
+
elif value is not None and _walk(value):
|
|
598
|
+
return True
|
|
599
|
+
return False
|
|
600
|
+
|
|
601
|
+
return _walk(root)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class SlashCommandMenuControl(UIControl):
|
|
605
|
+
"""Render slash command completions as a full-width menu that matches the shell UI."""
|
|
606
|
+
|
|
607
|
+
_MAX_EXPANDED_META_LINES = 3
|
|
608
|
+
|
|
609
|
+
def __init__(
|
|
610
|
+
self,
|
|
611
|
+
*,
|
|
612
|
+
left_padding: Callable[[], int],
|
|
613
|
+
scroll_offset: int = 1,
|
|
614
|
+
) -> None:
|
|
615
|
+
self._left_padding = left_padding
|
|
616
|
+
self._scroll_offset = scroll_offset
|
|
617
|
+
|
|
618
|
+
def has_focus(self) -> bool:
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
def preferred_width(self, max_available_width: int) -> int | None:
|
|
622
|
+
return max_available_width
|
|
623
|
+
|
|
624
|
+
def preferred_height(
|
|
625
|
+
self,
|
|
626
|
+
width: int,
|
|
627
|
+
max_available_height: int,
|
|
628
|
+
wrap_lines: bool,
|
|
629
|
+
get_line_prefix: Callable[..., AnyFormattedText] | None,
|
|
630
|
+
) -> int | None:
|
|
631
|
+
app = get_app_or_none()
|
|
632
|
+
complete_state = (
|
|
633
|
+
getattr(app.current_buffer, "complete_state", None) if app is not None else None
|
|
634
|
+
)
|
|
635
|
+
if complete_state is None:
|
|
636
|
+
return 0
|
|
637
|
+
completions = complete_state.completions
|
|
638
|
+
selected_index = complete_state.complete_index
|
|
639
|
+
if selected_index is None:
|
|
640
|
+
return min(max_available_height, len(completions))
|
|
641
|
+
menu_width = max(0, width - self._left_padding())
|
|
642
|
+
marker_width = 2
|
|
643
|
+
command_width = self._command_column_width(completions, menu_width, marker_width)
|
|
644
|
+
gap_width = 3 if menu_width > command_width + 6 else 1
|
|
645
|
+
meta_width = max(0, menu_width - marker_width - command_width - gap_width)
|
|
646
|
+
selected_meta_lines = self._selected_meta_lines(
|
|
647
|
+
completions[selected_index].display_meta_text,
|
|
648
|
+
meta_width,
|
|
649
|
+
)
|
|
650
|
+
return min(max_available_height, len(completions) + len(selected_meta_lines) - 1)
|
|
651
|
+
|
|
652
|
+
def create_content(self, width: int, height: int) -> UIContent:
|
|
653
|
+
app = get_app_or_none()
|
|
654
|
+
complete_state = (
|
|
655
|
+
getattr(app.current_buffer, "complete_state", None) if app is not None else None
|
|
656
|
+
)
|
|
657
|
+
if complete_state is None or not complete_state.completions:
|
|
658
|
+
return UIContent()
|
|
659
|
+
|
|
660
|
+
completions = complete_state.completions
|
|
661
|
+
selected_index = complete_state.complete_index
|
|
662
|
+
available_rows = max(1, height)
|
|
663
|
+
match_prefix_len = self._match_prefix_len(app)
|
|
664
|
+
|
|
665
|
+
menu_width = max(0, width - self._left_padding())
|
|
666
|
+
marker_width = 2
|
|
667
|
+
command_width = self._command_column_width(completions, menu_width, marker_width)
|
|
668
|
+
gap_width = 3 if menu_width > command_width + 6 else 1
|
|
669
|
+
meta_width = max(0, menu_width - marker_width - command_width - gap_width)
|
|
670
|
+
|
|
671
|
+
rendered_lines: list[FormattedText] = []
|
|
672
|
+
selected_line_index = 0
|
|
673
|
+
|
|
674
|
+
if selected_index is None:
|
|
675
|
+
# Pre-highlight index 0 even before the user navigates: pressing
|
|
676
|
+
# Enter accepts the first completion, so the visual state should
|
|
677
|
+
# match that behavior. Without this the menu looks ambiguous (no
|
|
678
|
+
# row highlighted) but Enter still commits the top row.
|
|
679
|
+
end = min(len(completions) - 1, available_rows - 1)
|
|
680
|
+
for index in range(0, end + 1):
|
|
681
|
+
rendered_lines.append(
|
|
682
|
+
self._render_single_line_item(
|
|
683
|
+
width=width,
|
|
684
|
+
completion=completions[index],
|
|
685
|
+
marker_width=marker_width,
|
|
686
|
+
command_width=command_width,
|
|
687
|
+
meta_width=meta_width,
|
|
688
|
+
gap_width=gap_width,
|
|
689
|
+
is_current=index == 0,
|
|
690
|
+
match_prefix_len=match_prefix_len,
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
return UIContent(
|
|
695
|
+
get_line=lambda i: rendered_lines[i],
|
|
696
|
+
line_count=len(rendered_lines),
|
|
697
|
+
cursor_position=Point(x=0, y=0),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
selected_meta_lines = self._selected_meta_lines(
|
|
701
|
+
completions[selected_index].display_meta_text,
|
|
702
|
+
meta_width,
|
|
703
|
+
)
|
|
704
|
+
start, end = self._visible_window_bounds(
|
|
705
|
+
completion_count=len(completions),
|
|
706
|
+
selected_index=selected_index,
|
|
707
|
+
available_rows=available_rows,
|
|
708
|
+
selected_item_height=len(selected_meta_lines),
|
|
709
|
+
)
|
|
710
|
+
selected_line_index = 0
|
|
711
|
+
|
|
712
|
+
for index in range(start, end + 1):
|
|
713
|
+
completion = completions[index]
|
|
714
|
+
if index == selected_index:
|
|
715
|
+
selected_line_index = len(rendered_lines)
|
|
716
|
+
rendered_lines.extend(
|
|
717
|
+
self._render_selected_item_lines(
|
|
718
|
+
width=width,
|
|
719
|
+
completion=completion,
|
|
720
|
+
marker_width=marker_width,
|
|
721
|
+
command_width=command_width,
|
|
722
|
+
meta_width=meta_width,
|
|
723
|
+
gap_width=gap_width,
|
|
724
|
+
meta_lines=selected_meta_lines,
|
|
725
|
+
match_prefix_len=match_prefix_len,
|
|
726
|
+
)
|
|
727
|
+
)
|
|
728
|
+
continue
|
|
729
|
+
|
|
730
|
+
rendered_lines.append(
|
|
731
|
+
self._render_single_line_item(
|
|
732
|
+
width=width,
|
|
733
|
+
completion=completion,
|
|
734
|
+
marker_width=marker_width,
|
|
735
|
+
command_width=command_width,
|
|
736
|
+
meta_width=meta_width,
|
|
737
|
+
gap_width=gap_width,
|
|
738
|
+
is_current=False,
|
|
739
|
+
match_prefix_len=match_prefix_len,
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
return UIContent(
|
|
744
|
+
get_line=lambda i: rendered_lines[i],
|
|
745
|
+
line_count=len(rendered_lines),
|
|
746
|
+
cursor_position=Point(x=0, y=selected_line_index),
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
def _match_prefix_len(self, app: Any) -> int:
|
|
750
|
+
document = getattr(getattr(app, "current_buffer", None), "document", None)
|
|
751
|
+
if not isinstance(document, Document):
|
|
752
|
+
return 0
|
|
753
|
+
token = _slash_command_token_before_cursor(document)
|
|
754
|
+
if token is None:
|
|
755
|
+
return 0
|
|
756
|
+
return len(token[1:])
|
|
757
|
+
|
|
758
|
+
def _selected_meta_lines(self, text: str, meta_width: int) -> list[str]:
|
|
759
|
+
lines = _wrap_to_width(
|
|
760
|
+
text,
|
|
761
|
+
meta_width,
|
|
762
|
+
max_lines=self._MAX_EXPANDED_META_LINES,
|
|
763
|
+
)
|
|
764
|
+
return lines or [""]
|
|
765
|
+
|
|
766
|
+
def _visible_window_bounds(
|
|
767
|
+
self,
|
|
768
|
+
*,
|
|
769
|
+
completion_count: int,
|
|
770
|
+
selected_index: int,
|
|
771
|
+
available_rows: int,
|
|
772
|
+
selected_item_height: int,
|
|
773
|
+
) -> tuple[int, int]:
|
|
774
|
+
selected_item_height = min(selected_item_height, available_rows)
|
|
775
|
+
remaining_rows = max(0, available_rows - selected_item_height)
|
|
776
|
+
|
|
777
|
+
before = min(self._scroll_offset, selected_index, remaining_rows)
|
|
778
|
+
remaining_rows -= before
|
|
779
|
+
after = min(completion_count - selected_index - 1, remaining_rows)
|
|
780
|
+
remaining_rows -= after
|
|
781
|
+
|
|
782
|
+
extra_before = min(selected_index - before, remaining_rows)
|
|
783
|
+
before += extra_before
|
|
784
|
+
remaining_rows -= extra_before
|
|
785
|
+
|
|
786
|
+
extra_after = min(completion_count - selected_index - 1 - after, remaining_rows)
|
|
787
|
+
after += extra_after
|
|
788
|
+
|
|
789
|
+
return selected_index - before, selected_index + after
|
|
790
|
+
|
|
791
|
+
def _command_column_width(
|
|
792
|
+
self,
|
|
793
|
+
completions: Sequence[Completion],
|
|
794
|
+
menu_width: int,
|
|
795
|
+
marker_width: int,
|
|
796
|
+
) -> int:
|
|
797
|
+
if menu_width <= 0:
|
|
798
|
+
return 0
|
|
799
|
+
longest = max((get_cwidth(c.display_text) for c in completions), default=0)
|
|
800
|
+
preferred = longest + 2
|
|
801
|
+
usable_width = max(0, menu_width - marker_width)
|
|
802
|
+
minimum = min(usable_width, 18)
|
|
803
|
+
maximum = max(minimum, min(28, usable_width // 2))
|
|
804
|
+
return max(minimum, min(preferred, maximum))
|
|
805
|
+
|
|
806
|
+
def _render_command_text(
|
|
807
|
+
self,
|
|
808
|
+
text: str,
|
|
809
|
+
*,
|
|
810
|
+
width: int,
|
|
811
|
+
base_style: str,
|
|
812
|
+
is_current: bool,
|
|
813
|
+
match_prefix_len: int,
|
|
814
|
+
) -> FormattedText:
|
|
815
|
+
display = _truncate_to_width(text, width)
|
|
816
|
+
if match_prefix_len <= 0:
|
|
817
|
+
return FormattedText([(base_style, display)])
|
|
818
|
+
|
|
819
|
+
# Match highlighting mirrors Codex's slash popup: the leading slash stays
|
|
820
|
+
# in the normal command style; the typed command prefix is emphasized.
|
|
821
|
+
match_end = min(len(text), 1 + match_prefix_len)
|
|
822
|
+
match_style = (
|
|
823
|
+
"class:slash-completion-menu.command.match.current"
|
|
824
|
+
if is_current
|
|
825
|
+
else "class:slash-completion-menu.command.match"
|
|
826
|
+
)
|
|
827
|
+
fragments: FormattedText = FormattedText()
|
|
828
|
+
for index, ch in enumerate(display):
|
|
829
|
+
style = match_style if 0 < index < match_end and index < len(text) else base_style
|
|
830
|
+
fragments.append((style, ch))
|
|
831
|
+
return fragments
|
|
832
|
+
|
|
833
|
+
def _render_single_line_item(
|
|
834
|
+
self,
|
|
835
|
+
*,
|
|
836
|
+
width: int,
|
|
837
|
+
completion: Completion,
|
|
838
|
+
marker_width: int,
|
|
839
|
+
command_width: int,
|
|
840
|
+
meta_width: int,
|
|
841
|
+
gap_width: int,
|
|
842
|
+
is_current: bool,
|
|
843
|
+
match_prefix_len: int,
|
|
844
|
+
) -> FormattedText:
|
|
845
|
+
padding_width = max(0, width - marker_width - command_width - meta_width - gap_width)
|
|
846
|
+
left_padding = min(self._left_padding(), padding_width)
|
|
847
|
+
trailing_width = max(
|
|
848
|
+
0,
|
|
849
|
+
width - left_padding - marker_width - command_width - gap_width - meta_width,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
command_style = (
|
|
853
|
+
"class:slash-completion-menu.command.current"
|
|
854
|
+
if is_current
|
|
855
|
+
else "class:slash-completion-menu.command"
|
|
856
|
+
)
|
|
857
|
+
meta_style = (
|
|
858
|
+
"class:slash-completion-menu.meta.current"
|
|
859
|
+
if is_current
|
|
860
|
+
else "class:slash-completion-menu.meta"
|
|
861
|
+
)
|
|
862
|
+
marker_style = (
|
|
863
|
+
"class:slash-completion-menu.marker.current"
|
|
864
|
+
if is_current
|
|
865
|
+
else "class:slash-completion-menu.marker"
|
|
866
|
+
)
|
|
867
|
+
marker = "› " if is_current else " "
|
|
868
|
+
|
|
869
|
+
# When a row is selected, use the row.current background for the
|
|
870
|
+
# gap and trailing padding so the highlight reads as a contiguous bar
|
|
871
|
+
# rather than a fragmented set of pieces.
|
|
872
|
+
gap_style = (
|
|
873
|
+
"class:slash-completion-menu.row.current"
|
|
874
|
+
if is_current
|
|
875
|
+
else "class:slash-completion-menu"
|
|
876
|
+
)
|
|
877
|
+
fragments: FormattedText = FormattedText()
|
|
878
|
+
fragments.append(("class:slash-completion-menu", " " * left_padding))
|
|
879
|
+
fragments.append((marker_style, marker.ljust(marker_width)))
|
|
880
|
+
fragments.extend(
|
|
881
|
+
self._render_command_text(
|
|
882
|
+
completion.display_text,
|
|
883
|
+
width=command_width,
|
|
884
|
+
base_style=command_style,
|
|
885
|
+
is_current=is_current,
|
|
886
|
+
match_prefix_len=match_prefix_len,
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
fragments.append((gap_style, " " * gap_width))
|
|
890
|
+
fragments.append((meta_style, _truncate_to_width(completion.display_meta_text, meta_width)))
|
|
891
|
+
fragments.append((gap_style, " " * trailing_width))
|
|
892
|
+
return fragments
|
|
893
|
+
|
|
894
|
+
def _render_selected_item_lines(
|
|
895
|
+
self,
|
|
896
|
+
*,
|
|
897
|
+
width: int,
|
|
898
|
+
completion: Completion,
|
|
899
|
+
marker_width: int,
|
|
900
|
+
command_width: int,
|
|
901
|
+
meta_width: int,
|
|
902
|
+
gap_width: int,
|
|
903
|
+
meta_lines: Sequence[str],
|
|
904
|
+
match_prefix_len: int,
|
|
905
|
+
) -> list[FormattedText]:
|
|
906
|
+
lines = [
|
|
907
|
+
self._render_single_line_item(
|
|
908
|
+
width=width,
|
|
909
|
+
completion=Completion(
|
|
910
|
+
text=completion.text,
|
|
911
|
+
start_position=completion.start_position,
|
|
912
|
+
display=completion.display,
|
|
913
|
+
display_meta=meta_lines[0],
|
|
914
|
+
),
|
|
915
|
+
marker_width=marker_width,
|
|
916
|
+
command_width=command_width,
|
|
917
|
+
meta_width=meta_width,
|
|
918
|
+
gap_width=gap_width,
|
|
919
|
+
is_current=True,
|
|
920
|
+
match_prefix_len=match_prefix_len,
|
|
921
|
+
)
|
|
922
|
+
]
|
|
923
|
+
|
|
924
|
+
continuation_prefix = (
|
|
925
|
+
" " * self._left_padding() + " " * marker_width + " " * command_width + " " * gap_width
|
|
926
|
+
)
|
|
927
|
+
continuation_trailing = max(
|
|
928
|
+
0,
|
|
929
|
+
width - get_cwidth(continuation_prefix) - meta_width,
|
|
930
|
+
)
|
|
931
|
+
for meta_line in meta_lines[1:]:
|
|
932
|
+
fragments: FormattedText = FormattedText()
|
|
933
|
+
fragments.append(("class:slash-completion-menu", continuation_prefix))
|
|
934
|
+
fragments.append(
|
|
935
|
+
(
|
|
936
|
+
"class:slash-completion-menu.meta.current",
|
|
937
|
+
_truncate_to_width(meta_line, meta_width),
|
|
938
|
+
)
|
|
939
|
+
)
|
|
940
|
+
fragments.append(("class:slash-completion-menu", " " * continuation_trailing))
|
|
941
|
+
lines.append(fragments)
|
|
942
|
+
|
|
943
|
+
return lines
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
class LocalFileMentionCompleter(Completer):
|
|
947
|
+
"""Offer fuzzy `@` path completion by indexing workspace files.
|
|
948
|
+
|
|
949
|
+
File discovery and ignore rules are delegated to
|
|
950
|
+
:mod:`pythinker_code.utils.file_filter` so that the web backend can reuse
|
|
951
|
+
them.
|
|
952
|
+
"""
|
|
953
|
+
|
|
954
|
+
_FRAGMENT_PATTERN = re.compile(r"[^\s@]+")
|
|
955
|
+
_TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~"))
|
|
956
|
+
|
|
957
|
+
def __init__(
|
|
958
|
+
self,
|
|
959
|
+
root: Path,
|
|
960
|
+
*,
|
|
961
|
+
refresh_interval: float = 2.0,
|
|
962
|
+
limit: int = 1000,
|
|
963
|
+
) -> None:
|
|
964
|
+
self._root = root
|
|
965
|
+
self._refresh_interval = refresh_interval
|
|
966
|
+
self._limit = limit
|
|
967
|
+
self._cache_time: float = 0.0
|
|
968
|
+
self._cached_paths: list[str] = []
|
|
969
|
+
self._cache_scope: str | None = None
|
|
970
|
+
self._top_cache_time: float = 0.0
|
|
971
|
+
self._top_cached_paths: list[str] = []
|
|
972
|
+
self._fragment_hint: str | None = None
|
|
973
|
+
self._is_git: bool | None = None # lazily detected
|
|
974
|
+
self._git_index_mtime: float | None = None
|
|
975
|
+
|
|
976
|
+
self._word_completer = WordCompleter(
|
|
977
|
+
self._get_paths,
|
|
978
|
+
WORD=False,
|
|
979
|
+
pattern=self._FRAGMENT_PATTERN,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
self._fuzzy = FuzzyCompleter(
|
|
983
|
+
self._word_completer,
|
|
984
|
+
WORD=False,
|
|
985
|
+
pattern=r"^[^\s@]*",
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
def _get_paths(self) -> list[str]:
|
|
989
|
+
fragment = self._fragment_hint or ""
|
|
990
|
+
if "/" not in fragment and len(fragment) < 3:
|
|
991
|
+
return self._get_top_level_paths()
|
|
992
|
+
return self._get_deep_paths()
|
|
993
|
+
|
|
994
|
+
def _get_top_level_paths(self) -> list[str]:
|
|
995
|
+
from pythinker_code.utils.file_filter import is_ignored
|
|
996
|
+
|
|
997
|
+
now = time.monotonic()
|
|
998
|
+
if now - self._top_cache_time <= self._refresh_interval:
|
|
999
|
+
return self._top_cached_paths
|
|
1000
|
+
|
|
1001
|
+
entries: list[str] = []
|
|
1002
|
+
try:
|
|
1003
|
+
for entry in sorted(self._root.iterdir(), key=lambda p: p.name):
|
|
1004
|
+
name = entry.name
|
|
1005
|
+
if is_ignored(name):
|
|
1006
|
+
continue
|
|
1007
|
+
entries.append(f"{name}/" if entry.is_dir() else name)
|
|
1008
|
+
if len(entries) >= self._limit:
|
|
1009
|
+
break
|
|
1010
|
+
except OSError:
|
|
1011
|
+
return self._top_cached_paths
|
|
1012
|
+
|
|
1013
|
+
self._top_cached_paths = entries
|
|
1014
|
+
self._top_cache_time = now
|
|
1015
|
+
return self._top_cached_paths
|
|
1016
|
+
|
|
1017
|
+
def _get_deep_paths(self) -> list[str]:
|
|
1018
|
+
from pythinker_code.utils.file_filter import (
|
|
1019
|
+
detect_git,
|
|
1020
|
+
git_index_mtime,
|
|
1021
|
+
list_files_git,
|
|
1022
|
+
list_files_walk,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
fragment = self._fragment_hint or ""
|
|
1026
|
+
|
|
1027
|
+
scope: str | None = None
|
|
1028
|
+
if "/" in fragment:
|
|
1029
|
+
scope = fragment.rsplit("/", 1)[0]
|
|
1030
|
+
|
|
1031
|
+
now = time.monotonic()
|
|
1032
|
+
cache_valid = (
|
|
1033
|
+
now - self._cache_time <= self._refresh_interval and self._cache_scope == scope
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
# Invalidate on .git/index mtime change (like Claude Code).
|
|
1037
|
+
if cache_valid and self._is_git:
|
|
1038
|
+
mtime = git_index_mtime(self._root)
|
|
1039
|
+
if mtime != self._git_index_mtime:
|
|
1040
|
+
cache_valid = False
|
|
1041
|
+
|
|
1042
|
+
if cache_valid:
|
|
1043
|
+
return self._cached_paths
|
|
1044
|
+
|
|
1045
|
+
if self._is_git is None:
|
|
1046
|
+
self._is_git = detect_git(self._root)
|
|
1047
|
+
|
|
1048
|
+
paths: list[str] | None = None
|
|
1049
|
+
if self._is_git:
|
|
1050
|
+
paths = list_files_git(self._root, scope)
|
|
1051
|
+
self._git_index_mtime = git_index_mtime(self._root)
|
|
1052
|
+
if paths is None:
|
|
1053
|
+
paths = list_files_walk(self._root, scope, limit=self._limit)
|
|
1054
|
+
|
|
1055
|
+
self._cached_paths = paths
|
|
1056
|
+
self._cache_scope = scope
|
|
1057
|
+
self._cache_time = now
|
|
1058
|
+
return self._cached_paths
|
|
1059
|
+
|
|
1060
|
+
@staticmethod
|
|
1061
|
+
def _extract_fragment(text: str) -> str | None:
|
|
1062
|
+
index = text.rfind("@")
|
|
1063
|
+
if index == -1:
|
|
1064
|
+
return None
|
|
1065
|
+
|
|
1066
|
+
if index > 0:
|
|
1067
|
+
prev = text[index - 1]
|
|
1068
|
+
if prev.isalnum() or prev in LocalFileMentionCompleter._TRIGGER_GUARDS:
|
|
1069
|
+
return None
|
|
1070
|
+
|
|
1071
|
+
fragment = text[index + 1 :]
|
|
1072
|
+
if not fragment:
|
|
1073
|
+
return ""
|
|
1074
|
+
|
|
1075
|
+
if any(ch.isspace() for ch in fragment):
|
|
1076
|
+
return None
|
|
1077
|
+
|
|
1078
|
+
return fragment
|
|
1079
|
+
|
|
1080
|
+
def _is_completed_file(self, fragment: str) -> bool:
|
|
1081
|
+
candidate = fragment.rstrip("/")
|
|
1082
|
+
if not candidate:
|
|
1083
|
+
return False
|
|
1084
|
+
try:
|
|
1085
|
+
return (self._root / candidate).is_file()
|
|
1086
|
+
except OSError:
|
|
1087
|
+
return False
|
|
1088
|
+
|
|
1089
|
+
@override
|
|
1090
|
+
def get_completions(
|
|
1091
|
+
self, document: Document, complete_event: CompleteEvent
|
|
1092
|
+
) -> Iterable[Completion]:
|
|
1093
|
+
fragment = self._extract_fragment(document.text_before_cursor)
|
|
1094
|
+
if fragment is None:
|
|
1095
|
+
return
|
|
1096
|
+
if self._is_completed_file(fragment):
|
|
1097
|
+
return
|
|
1098
|
+
|
|
1099
|
+
mention_doc = Document(text=fragment, cursor_position=len(fragment))
|
|
1100
|
+
self._fragment_hint = fragment
|
|
1101
|
+
try:
|
|
1102
|
+
# First, ask the fuzzy completer for candidates.
|
|
1103
|
+
candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))
|
|
1104
|
+
|
|
1105
|
+
# re-rank: prefer basename matches
|
|
1106
|
+
frag_lower = fragment.lower()
|
|
1107
|
+
|
|
1108
|
+
def _rank(c: Completion) -> tuple[int, ...]:
|
|
1109
|
+
path = c.text
|
|
1110
|
+
base = path.rstrip("/").split("/")[-1].lower()
|
|
1111
|
+
if base.startswith(frag_lower):
|
|
1112
|
+
cat = 0
|
|
1113
|
+
elif frag_lower in base:
|
|
1114
|
+
cat = 1
|
|
1115
|
+
else:
|
|
1116
|
+
cat = 2
|
|
1117
|
+
# preserve original FuzzyCompleter's order in the same category
|
|
1118
|
+
return (cat,)
|
|
1119
|
+
|
|
1120
|
+
candidates.sort(key=_rank)
|
|
1121
|
+
yield from candidates
|
|
1122
|
+
finally:
|
|
1123
|
+
self._fragment_hint = None
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
class _HistoryEntry(BaseModel):
|
|
1127
|
+
content: str
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _load_history_entries(history_file: Path) -> list[_HistoryEntry]:
|
|
1131
|
+
entries: list[_HistoryEntry] = []
|
|
1132
|
+
if not history_file.exists():
|
|
1133
|
+
return entries
|
|
1134
|
+
|
|
1135
|
+
try:
|
|
1136
|
+
with history_file.open(encoding="utf-8") as f:
|
|
1137
|
+
for raw_line in f:
|
|
1138
|
+
line = raw_line.strip()
|
|
1139
|
+
if not line:
|
|
1140
|
+
continue
|
|
1141
|
+
try:
|
|
1142
|
+
record = json.loads(line)
|
|
1143
|
+
except json.JSONDecodeError:
|
|
1144
|
+
logger.warning(
|
|
1145
|
+
"Failed to parse user history line; skipping: {line}",
|
|
1146
|
+
line=line,
|
|
1147
|
+
)
|
|
1148
|
+
continue
|
|
1149
|
+
try:
|
|
1150
|
+
entry = _HistoryEntry.model_validate(record)
|
|
1151
|
+
entries.append(entry)
|
|
1152
|
+
except ValidationError:
|
|
1153
|
+
logger.warning(
|
|
1154
|
+
"Failed to validate user history entry; skipping: {line}",
|
|
1155
|
+
line=line,
|
|
1156
|
+
)
|
|
1157
|
+
continue
|
|
1158
|
+
except OSError as exc:
|
|
1159
|
+
logger.warning(
|
|
1160
|
+
"Failed to load user history file: {file} ({error})",
|
|
1161
|
+
file=history_file,
|
|
1162
|
+
error=exc,
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
return entries
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
class PromptMode(Enum):
|
|
1169
|
+
AGENT = "agent"
|
|
1170
|
+
SHELL = "shell"
|
|
1171
|
+
|
|
1172
|
+
def toggle(self) -> PromptMode:
|
|
1173
|
+
return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT
|
|
1174
|
+
|
|
1175
|
+
def __str__(self) -> str:
|
|
1176
|
+
return self.value
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
class PromptUIState(Enum):
|
|
1180
|
+
NORMAL_INPUT = "normal_input"
|
|
1181
|
+
MODAL_HIDDEN_INPUT = "modal_hidden_input"
|
|
1182
|
+
MODAL_TEXT_INPUT = "modal_text_input"
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
class UserInput(BaseModel):
|
|
1186
|
+
mode: PromptMode
|
|
1187
|
+
command: str
|
|
1188
|
+
"""The plain text representation of the user input."""
|
|
1189
|
+
resolved_command: str
|
|
1190
|
+
"""The text command after UI-only placeholders are expanded."""
|
|
1191
|
+
content: list[ContentPart]
|
|
1192
|
+
"""The rich content parts."""
|
|
1193
|
+
|
|
1194
|
+
def __str__(self) -> str:
|
|
1195
|
+
return self.command
|
|
1196
|
+
|
|
1197
|
+
def __bool__(self) -> bool:
|
|
1198
|
+
return bool(self.command)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
_IDLE_REFRESH_INTERVAL = 1.0
|
|
1202
|
+
_RUNNING_REFRESH_INTERVAL = 0.1
|
|
1203
|
+
|
|
1204
|
+
_GIT_BRANCH_TTL = 5.0
|
|
1205
|
+
_GIT_STATUS_TTL = 15.0
|
|
1206
|
+
_TIP_ROTATE_INTERVAL = 30.0
|
|
1207
|
+
_MAX_CWD_COLS = 30
|
|
1208
|
+
_MAX_BRANCH_COLS = 22
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
@dataclass
|
|
1212
|
+
class _GitBranchState:
|
|
1213
|
+
timestamp: float = 0.0
|
|
1214
|
+
branch: str | None = None
|
|
1215
|
+
proc: subprocess.Popen[str] | None = None
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
@dataclass
|
|
1219
|
+
class _GitStatusState:
|
|
1220
|
+
timestamp: float = 0.0
|
|
1221
|
+
dirty: bool = False
|
|
1222
|
+
ahead: int = 0
|
|
1223
|
+
behind: int = 0
|
|
1224
|
+
proc: subprocess.Popen[str] | None = None
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
_git_branch_state = _GitBranchState()
|
|
1228
|
+
_git_status_state = _GitStatusState()
|
|
1229
|
+
|
|
1230
|
+
_GIT_STATUS_AB_RE = re.compile(r"\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]")
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _get_git_branch() -> str | None:
|
|
1234
|
+
"""Return the current git branch name via a non-blocking cached subprocess."""
|
|
1235
|
+
state = _git_branch_state
|
|
1236
|
+
now = time.monotonic()
|
|
1237
|
+
|
|
1238
|
+
# Collect result if a previously launched process has finished
|
|
1239
|
+
if state.proc is not None:
|
|
1240
|
+
returncode = state.proc.poll()
|
|
1241
|
+
if returncode is not None:
|
|
1242
|
+
try:
|
|
1243
|
+
stdout, _ = state.proc.communicate()
|
|
1244
|
+
new_branch = stdout.strip() or None
|
|
1245
|
+
# Branch changed — discard any in-flight status subprocess so it cannot
|
|
1246
|
+
# write stale results for the old branch, then force an immediate refresh.
|
|
1247
|
+
if new_branch != state.branch:
|
|
1248
|
+
if _git_status_state.proc is not None:
|
|
1249
|
+
with contextlib.suppress(Exception):
|
|
1250
|
+
_git_status_state.proc.terminate()
|
|
1251
|
+
_git_status_state.proc = None
|
|
1252
|
+
_git_status_state.timestamp = 0.0
|
|
1253
|
+
state.branch = new_branch
|
|
1254
|
+
except Exception:
|
|
1255
|
+
state.branch = None
|
|
1256
|
+
state.proc = None
|
|
1257
|
+
|
|
1258
|
+
# Launch a new process when the TTL has expired and nothing is running
|
|
1259
|
+
if state.timestamp + _GIT_BRANCH_TTL <= now and state.proc is None:
|
|
1260
|
+
state.timestamp = now
|
|
1261
|
+
try:
|
|
1262
|
+
state.proc = subprocess.Popen(
|
|
1263
|
+
["git", "branch", "--show-current"],
|
|
1264
|
+
stdout=subprocess.PIPE,
|
|
1265
|
+
stderr=subprocess.DEVNULL,
|
|
1266
|
+
text=True,
|
|
1267
|
+
encoding="utf-8",
|
|
1268
|
+
errors="replace",
|
|
1269
|
+
)
|
|
1270
|
+
except Exception:
|
|
1271
|
+
state.branch = None
|
|
1272
|
+
|
|
1273
|
+
return state.branch
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _get_git_status() -> tuple[bool, int, int]:
|
|
1277
|
+
"""Return (dirty, ahead, behind) via a non-blocking cached subprocess.
|
|
1278
|
+
|
|
1279
|
+
Runs ``git status --porcelain -b`` (includes untracked files so newly created
|
|
1280
|
+
files show as dirty). TTL is longer than the branch check because file-tree
|
|
1281
|
+
scanning is expensive.
|
|
1282
|
+
"""
|
|
1283
|
+
state = _git_status_state
|
|
1284
|
+
now = time.monotonic()
|
|
1285
|
+
|
|
1286
|
+
if state.proc is not None:
|
|
1287
|
+
returncode = state.proc.poll()
|
|
1288
|
+
if returncode is not None:
|
|
1289
|
+
try:
|
|
1290
|
+
stdout, _ = state.proc.communicate()
|
|
1291
|
+
dirty = False
|
|
1292
|
+
ahead = 0
|
|
1293
|
+
behind = 0
|
|
1294
|
+
for line in stdout.splitlines():
|
|
1295
|
+
if line.startswith("## "):
|
|
1296
|
+
m = _GIT_STATUS_AB_RE.search(line)
|
|
1297
|
+
if m:
|
|
1298
|
+
ahead = int(m.group(1) or 0)
|
|
1299
|
+
behind = int(m.group(2) or 0)
|
|
1300
|
+
elif line.strip():
|
|
1301
|
+
dirty = True
|
|
1302
|
+
state.dirty = dirty
|
|
1303
|
+
state.ahead = ahead
|
|
1304
|
+
state.behind = behind
|
|
1305
|
+
except Exception:
|
|
1306
|
+
pass
|
|
1307
|
+
state.proc = None
|
|
1308
|
+
elif now - state.timestamp > _GIT_STATUS_TTL:
|
|
1309
|
+
# Subprocess is stuck (e.g. OS pipe buffer full from many untracked files).
|
|
1310
|
+
# Terminate it so the toolbar is not permanently frozen; retry after next TTL.
|
|
1311
|
+
with contextlib.suppress(Exception):
|
|
1312
|
+
state.proc.terminate()
|
|
1313
|
+
state.proc = None
|
|
1314
|
+
state.timestamp = now # delay next spawn by one full TTL
|
|
1315
|
+
|
|
1316
|
+
if state.timestamp + _GIT_STATUS_TTL <= now and state.proc is None:
|
|
1317
|
+
state.timestamp = now
|
|
1318
|
+
with contextlib.suppress(Exception):
|
|
1319
|
+
state.proc = subprocess.Popen(
|
|
1320
|
+
["git", "status", "--porcelain", "-b"],
|
|
1321
|
+
stdout=subprocess.PIPE,
|
|
1322
|
+
stderr=subprocess.DEVNULL,
|
|
1323
|
+
text=True,
|
|
1324
|
+
encoding="utf-8",
|
|
1325
|
+
errors="replace",
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
return state.dirty, state.ahead, state.behind
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _format_git_badge(branch: str, dirty: bool, ahead: int, behind: int) -> str:
|
|
1332
|
+
"""Format branch name with an optional status badge: ``main [± ↑3↓1]``."""
|
|
1333
|
+
parts: list[str] = []
|
|
1334
|
+
if dirty:
|
|
1335
|
+
parts.append("±")
|
|
1336
|
+
sync = ""
|
|
1337
|
+
if ahead:
|
|
1338
|
+
sync += f"↑{ahead}"
|
|
1339
|
+
if behind:
|
|
1340
|
+
sync += f"↓{behind}"
|
|
1341
|
+
if sync:
|
|
1342
|
+
parts.append(sync)
|
|
1343
|
+
if not parts:
|
|
1344
|
+
return branch
|
|
1345
|
+
return f"{branch} [{' '.join(parts)}]"
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _shorten_cwd(path: str) -> str:
|
|
1349
|
+
"""Replace the home directory prefix in *path* with ``~``."""
|
|
1350
|
+
home = str(Path.home())
|
|
1351
|
+
if path == home:
|
|
1352
|
+
return "~"
|
|
1353
|
+
if path.startswith(home + os.sep):
|
|
1354
|
+
return "~" + path[len(home) :]
|
|
1355
|
+
return path
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _display_width(text: str) -> int:
|
|
1359
|
+
"""Return the terminal column width of *text*, handling wide Unicode characters."""
|
|
1360
|
+
return sum(get_cwidth(c) for c in text)
|
|
1361
|
+
|
|
1362
|
+
|
|
1363
|
+
def _truncate_left(text: str, max_cols: int) -> str:
|
|
1364
|
+
"""Truncate *text* from the left, prepending '…' if it exceeds *max_cols*."""
|
|
1365
|
+
if max_cols <= 0:
|
|
1366
|
+
return ""
|
|
1367
|
+
if _display_width(text) <= max_cols:
|
|
1368
|
+
return text
|
|
1369
|
+
ellipsis = "…"
|
|
1370
|
+
budget = max_cols - _display_width(ellipsis)
|
|
1371
|
+
chars: list[str] = []
|
|
1372
|
+
width = 0
|
|
1373
|
+
for ch in reversed(text):
|
|
1374
|
+
w = get_cwidth(ch)
|
|
1375
|
+
if width + w > budget:
|
|
1376
|
+
break
|
|
1377
|
+
chars.append(ch)
|
|
1378
|
+
width += w
|
|
1379
|
+
return ellipsis + "".join(reversed(chars))
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _truncate_right(text: str, max_cols: int) -> str:
|
|
1383
|
+
"""Truncate *text* from the right, appending '…' if it exceeds *max_cols*."""
|
|
1384
|
+
if max_cols <= 0:
|
|
1385
|
+
return ""
|
|
1386
|
+
if _display_width(text) <= max_cols:
|
|
1387
|
+
return text
|
|
1388
|
+
ellipsis = "…"
|
|
1389
|
+
budget = max_cols - _display_width(ellipsis)
|
|
1390
|
+
chars: list[str] = []
|
|
1391
|
+
width = 0
|
|
1392
|
+
for ch in text:
|
|
1393
|
+
w = get_cwidth(ch)
|
|
1394
|
+
if width + w > budget:
|
|
1395
|
+
break
|
|
1396
|
+
chars.append(ch)
|
|
1397
|
+
width += w
|
|
1398
|
+
return "".join(chars) + ellipsis
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
@dataclass(slots=True)
|
|
1402
|
+
class _ToastEntry:
|
|
1403
|
+
topic: str | None
|
|
1404
|
+
"""There can be only one toast of each non-None topic in the queue."""
|
|
1405
|
+
message: str
|
|
1406
|
+
expires_at: float
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
class RunningPromptDelegate(Protocol):
|
|
1410
|
+
"""Protocol for components that can take over the bottom prompt area."""
|
|
1411
|
+
|
|
1412
|
+
modal_priority: int
|
|
1413
|
+
|
|
1414
|
+
def render_running_prompt_body(self, columns: int) -> AnyFormattedText: ...
|
|
1415
|
+
|
|
1416
|
+
def running_prompt_placeholder(self) -> AnyFormattedText | None: ...
|
|
1417
|
+
|
|
1418
|
+
def running_prompt_allows_text_input(self) -> bool: ...
|
|
1419
|
+
|
|
1420
|
+
def running_prompt_hides_input_buffer(self) -> bool: ...
|
|
1421
|
+
|
|
1422
|
+
def running_prompt_accepts_submission(self) -> bool: ...
|
|
1423
|
+
|
|
1424
|
+
def should_handle_running_prompt_key(self, key: str) -> bool: ...
|
|
1425
|
+
|
|
1426
|
+
def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: ...
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
@dataclass(frozen=True, slots=True)
|
|
1430
|
+
class BgTaskCounts:
|
|
1431
|
+
bash: int = 0
|
|
1432
|
+
agent: int = 0
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
@runtime_checkable
|
|
1436
|
+
class AgentStatusProvider(Protocol):
|
|
1437
|
+
"""Optional protocol for delegates that render always-visible agent status.
|
|
1438
|
+
|
|
1439
|
+
When the running prompt delegate implements this, ``_render_agent_status``
|
|
1440
|
+
will call ``render_agent_status`` instead of the fallback status block.
|
|
1441
|
+
This ensures spinners, content blocks, and tool calls remain visible
|
|
1442
|
+
even when a modal (approval/question/btw) is active.
|
|
1443
|
+
"""
|
|
1444
|
+
|
|
1445
|
+
def render_agent_status(self, columns: int) -> AnyFormattedText: ...
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
_toast_queues: dict[Literal["left", "right"], deque[_ToastEntry]] = {
|
|
1449
|
+
"left": deque(),
|
|
1450
|
+
"right": deque(),
|
|
1451
|
+
}
|
|
1452
|
+
"""The queue of toasts to show, including the one currently being shown (the first one)."""
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def toast(
|
|
1456
|
+
message: str,
|
|
1457
|
+
duration: float = 5.0,
|
|
1458
|
+
topic: str | None = None,
|
|
1459
|
+
immediate: bool = False,
|
|
1460
|
+
position: Literal["left", "right"] = "left",
|
|
1461
|
+
) -> None:
|
|
1462
|
+
queue = _toast_queues[position]
|
|
1463
|
+
duration = max(duration, _IDLE_REFRESH_INTERVAL)
|
|
1464
|
+
entry = _ToastEntry(topic=topic, message=message, expires_at=time.monotonic() + duration)
|
|
1465
|
+
if topic is not None:
|
|
1466
|
+
# Remove existing toasts with the same topic
|
|
1467
|
+
for existing in list(queue):
|
|
1468
|
+
if existing.topic == topic:
|
|
1469
|
+
queue.remove(existing)
|
|
1470
|
+
if immediate:
|
|
1471
|
+
queue.appendleft(entry)
|
|
1472
|
+
else:
|
|
1473
|
+
queue.append(entry)
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def _current_toast(position: Literal["left", "right"] = "left") -> _ToastEntry | None:
|
|
1477
|
+
queue = _toast_queues[position]
|
|
1478
|
+
now = time.monotonic()
|
|
1479
|
+
while queue and queue[0].expires_at <= now:
|
|
1480
|
+
queue.popleft()
|
|
1481
|
+
if not queue:
|
|
1482
|
+
return None
|
|
1483
|
+
return queue[0]
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def _build_toolbar_tips(clipboard_available: bool) -> list[str]:
|
|
1487
|
+
tips = [
|
|
1488
|
+
"ctrl-x: toggle mode",
|
|
1489
|
+
"shift-tab: plan mode",
|
|
1490
|
+
"ctrl-o: editor",
|
|
1491
|
+
"ctrl-j: newline",
|
|
1492
|
+
"/feedback: send feedback",
|
|
1493
|
+
"/theme: switch dark/light",
|
|
1494
|
+
]
|
|
1495
|
+
if clipboard_available:
|
|
1496
|
+
tips.append("ctrl-v: paste clipboard")
|
|
1497
|
+
tips.append("@: mention files")
|
|
1498
|
+
return tips
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
_TIP_SEPARATOR = " | "
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
class CustomPromptSession:
|
|
1505
|
+
def __init__(
|
|
1506
|
+
self,
|
|
1507
|
+
*,
|
|
1508
|
+
status_provider: Callable[[], StatusSnapshot],
|
|
1509
|
+
status_block_provider: Callable[[int], AnyFormattedText | None] | None = None,
|
|
1510
|
+
fast_refresh_provider: Callable[[], bool] | None = None,
|
|
1511
|
+
background_task_count_provider: Callable[[], BgTaskCounts] | None = None,
|
|
1512
|
+
model_capabilities: set[ModelCapability],
|
|
1513
|
+
model_name: str | None,
|
|
1514
|
+
thinking: bool,
|
|
1515
|
+
agent_mode_slash_commands: Sequence[SlashCommand[Any]],
|
|
1516
|
+
shell_mode_slash_commands: Sequence[SlashCommand[Any]],
|
|
1517
|
+
editor_command_provider: Callable[[], str] = lambda: "",
|
|
1518
|
+
plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None,
|
|
1519
|
+
) -> None:
|
|
1520
|
+
history_dir = get_share_dir() / "user-history"
|
|
1521
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
1522
|
+
work_dir_id = md5(
|
|
1523
|
+
str(HostPath.cwd()).encode(encoding="utf-8"), usedforsecurity=False
|
|
1524
|
+
).hexdigest()
|
|
1525
|
+
self._history_file = (history_dir / work_dir_id).with_suffix(".jsonl")
|
|
1526
|
+
self._status_provider = status_provider
|
|
1527
|
+
self._status_block_provider = status_block_provider
|
|
1528
|
+
self._fast_refresh_provider = fast_refresh_provider
|
|
1529
|
+
self._background_task_count_provider = background_task_count_provider
|
|
1530
|
+
self._editor_command_provider = editor_command_provider
|
|
1531
|
+
self._plan_mode_toggle_callback = plan_mode_toggle_callback
|
|
1532
|
+
self._model_capabilities = model_capabilities
|
|
1533
|
+
self._model_name = model_name
|
|
1534
|
+
self._last_history_content: str | None = None
|
|
1535
|
+
self._mode: PromptMode = PromptMode.AGENT
|
|
1536
|
+
self._thinking = thinking
|
|
1537
|
+
self._placeholder_manager = PromptPlaceholderManager()
|
|
1538
|
+
# Keep the old attribute for test compatibility and for any external imports.
|
|
1539
|
+
self._attachment_cache = self._placeholder_manager.attachment_cache
|
|
1540
|
+
self._last_tip_rotate_time: float = time.monotonic()
|
|
1541
|
+
self._last_submission_was_running = False
|
|
1542
|
+
self._last_input_activity_time: float = 0.0
|
|
1543
|
+
self._suppress_auto_completion: bool = False
|
|
1544
|
+
self._input_activity_event: asyncio.Event = asyncio.Event()
|
|
1545
|
+
self._running_prompt_previous_mode: PromptMode | None = None
|
|
1546
|
+
self._running_prompt_delegate: RunningPromptDelegate | None = None
|
|
1547
|
+
self._modal_delegates: list[RunningPromptDelegate] = []
|
|
1548
|
+
self._shortcut_help_open = False
|
|
1549
|
+
self._prompt_buffer_container: ConditionalContainer | None = None
|
|
1550
|
+
self._slash_menu_control: SlashCommandMenuControl | None = None
|
|
1551
|
+
self._last_ui_state: PromptUIState = PromptUIState.NORMAL_INPUT
|
|
1552
|
+
self._suspended_buffer_document: Document | None = None
|
|
1553
|
+
clipboard_available = is_clipboard_available()
|
|
1554
|
+
media_clipboard_available = is_media_clipboard_available()
|
|
1555
|
+
self._tips = _build_toolbar_tips(clipboard_available or media_clipboard_available)
|
|
1556
|
+
self._tip_rotation_index: int = random.randrange(len(self._tips)) if self._tips else 0
|
|
1557
|
+
|
|
1558
|
+
history_entries = _load_history_entries(self._history_file)
|
|
1559
|
+
history = InMemoryHistory()
|
|
1560
|
+
for entry in history_entries:
|
|
1561
|
+
history.append_string(entry.content)
|
|
1562
|
+
|
|
1563
|
+
if history_entries:
|
|
1564
|
+
# for consecutive deduplication
|
|
1565
|
+
self._last_history_content = history_entries[-1].content
|
|
1566
|
+
|
|
1567
|
+
# Build completers
|
|
1568
|
+
self._agent_mode_completer = merge_completers(
|
|
1569
|
+
[
|
|
1570
|
+
SlashCommandCompleter(
|
|
1571
|
+
agent_mode_slash_commands,
|
|
1572
|
+
annotate_meta=True,
|
|
1573
|
+
command_scope="command",
|
|
1574
|
+
),
|
|
1575
|
+
# TODO(host): we need an async HostFileMentionCompleter
|
|
1576
|
+
LocalFileMentionCompleter(HostPath.cwd().unsafe_to_local_path()),
|
|
1577
|
+
],
|
|
1578
|
+
deduplicate=True,
|
|
1579
|
+
)
|
|
1580
|
+
self._shell_mode_completer = SlashCommandCompleter(
|
|
1581
|
+
shell_mode_slash_commands,
|
|
1582
|
+
annotate_meta=True,
|
|
1583
|
+
command_scope="shell",
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
# Build key bindings
|
|
1587
|
+
_kb = KeyBindings()
|
|
1588
|
+
|
|
1589
|
+
def _accept_completion(buff: Buffer) -> None:
|
|
1590
|
+
"""Accept the current or first completion, suppressing re-completion."""
|
|
1591
|
+
completion = buff.complete_state.current_completion # type: ignore[union-attr]
|
|
1592
|
+
if not completion:
|
|
1593
|
+
completion = buff.complete_state.completions[0] # type: ignore[union-attr]
|
|
1594
|
+
self._suppress_auto_completion = True
|
|
1595
|
+
try:
|
|
1596
|
+
buff.apply_completion(completion)
|
|
1597
|
+
finally:
|
|
1598
|
+
self._suppress_auto_completion = False
|
|
1599
|
+
|
|
1600
|
+
def _is_slash_completion() -> bool:
|
|
1601
|
+
"""True when the active completion menu is for a slash command."""
|
|
1602
|
+
buff = self._session.default_buffer
|
|
1603
|
+
return bool(
|
|
1604
|
+
buff.complete_state
|
|
1605
|
+
and buff.complete_state.completions
|
|
1606
|
+
and SlashCommandCompleter.should_complete(buff.document)
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
_slash_completion_filter = has_completions & Condition(_is_slash_completion)
|
|
1610
|
+
_non_slash_completion_filter = has_completions & ~Condition(_is_slash_completion)
|
|
1611
|
+
|
|
1612
|
+
@_kb.add("enter", filter=_slash_completion_filter)
|
|
1613
|
+
def _(event: KeyPressEvent) -> None:
|
|
1614
|
+
"""Slash command completion: accept and submit in one step."""
|
|
1615
|
+
_accept_completion(event.current_buffer)
|
|
1616
|
+
event.current_buffer.validate_and_handle()
|
|
1617
|
+
|
|
1618
|
+
@_kb.add("enter", filter=_non_slash_completion_filter)
|
|
1619
|
+
def _(event: KeyPressEvent) -> None:
|
|
1620
|
+
"""Non-slash completion (file mentions, etc.): accept only."""
|
|
1621
|
+
_accept_completion(event.current_buffer)
|
|
1622
|
+
|
|
1623
|
+
@_kb.add("?", eager=True)
|
|
1624
|
+
def _(event: KeyPressEvent) -> None:
|
|
1625
|
+
"""Toggle a compact shortcuts popup when the input row is empty."""
|
|
1626
|
+
if self._active_prompt_delegate() is not None:
|
|
1627
|
+
event.current_buffer.insert_text("?")
|
|
1628
|
+
return
|
|
1629
|
+
if event.current_buffer.text.strip():
|
|
1630
|
+
event.current_buffer.insert_text("?")
|
|
1631
|
+
return
|
|
1632
|
+
self._shortcut_help_open = not self._shortcut_help_open
|
|
1633
|
+
event.app.invalidate()
|
|
1634
|
+
|
|
1635
|
+
@_kb.add("c-x", eager=True)
|
|
1636
|
+
def _(event: KeyPressEvent) -> None:
|
|
1637
|
+
if self._active_prompt_delegate() is not None:
|
|
1638
|
+
return
|
|
1639
|
+
self._mode = self._mode.toggle()
|
|
1640
|
+
from pythinker_code.telemetry import track
|
|
1641
|
+
|
|
1642
|
+
track("shortcut_mode_switch", to_mode=self._mode.value)
|
|
1643
|
+
# Apply mode-specific settings
|
|
1644
|
+
self._apply_mode(event)
|
|
1645
|
+
# Redraw UI
|
|
1646
|
+
event.app.invalidate()
|
|
1647
|
+
|
|
1648
|
+
@_kb.add("s-tab", eager=True)
|
|
1649
|
+
def _(event: KeyPressEvent) -> None:
|
|
1650
|
+
"""Toggle plan mode with Shift+Tab."""
|
|
1651
|
+
if self._active_prompt_delegate() is not None:
|
|
1652
|
+
return
|
|
1653
|
+
if self._plan_mode_toggle_callback is not None:
|
|
1654
|
+
|
|
1655
|
+
async def _toggle() -> None:
|
|
1656
|
+
assert self._plan_mode_toggle_callback is not None
|
|
1657
|
+
new_state = await self._plan_mode_toggle_callback()
|
|
1658
|
+
from pythinker_code.telemetry import track
|
|
1659
|
+
|
|
1660
|
+
track("shortcut_plan_toggle", enabled=new_state)
|
|
1661
|
+
if new_state:
|
|
1662
|
+
toast("plan mode ON", topic="plan_mode", duration=3.0, immediate=True)
|
|
1663
|
+
else:
|
|
1664
|
+
toast("plan mode OFF", topic="plan_mode", duration=3.0, immediate=True)
|
|
1665
|
+
event.app.invalidate()
|
|
1666
|
+
|
|
1667
|
+
event.app.create_background_task(_toggle())
|
|
1668
|
+
event.app.invalidate()
|
|
1669
|
+
|
|
1670
|
+
@_kb.add("escape", "enter", eager=True)
|
|
1671
|
+
@_kb.add("c-j", eager=True)
|
|
1672
|
+
def _(event: KeyPressEvent) -> None:
|
|
1673
|
+
"""Insert a newline when Alt-Enter or Ctrl-J is pressed."""
|
|
1674
|
+
from pythinker_code.telemetry import track
|
|
1675
|
+
|
|
1676
|
+
track("shortcut_newline")
|
|
1677
|
+
event.current_buffer.insert_text("\n")
|
|
1678
|
+
|
|
1679
|
+
@_kb.add("c-o", eager=True)
|
|
1680
|
+
def _(event: KeyPressEvent) -> None:
|
|
1681
|
+
"""Open current buffer in external editor."""
|
|
1682
|
+
from pythinker_code.telemetry import track
|
|
1683
|
+
|
|
1684
|
+
track("shortcut_editor")
|
|
1685
|
+
self._open_in_external_editor(event)
|
|
1686
|
+
|
|
1687
|
+
@_kb.add(
|
|
1688
|
+
"up",
|
|
1689
|
+
eager=True,
|
|
1690
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("up")),
|
|
1691
|
+
)
|
|
1692
|
+
def _(event: KeyPressEvent) -> None:
|
|
1693
|
+
self._handle_running_prompt_key("up", event)
|
|
1694
|
+
|
|
1695
|
+
@_kb.add(
|
|
1696
|
+
"down",
|
|
1697
|
+
eager=True,
|
|
1698
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("down")),
|
|
1699
|
+
)
|
|
1700
|
+
def _(event: KeyPressEvent) -> None:
|
|
1701
|
+
self._handle_running_prompt_key("down", event)
|
|
1702
|
+
|
|
1703
|
+
@_kb.add(
|
|
1704
|
+
"left",
|
|
1705
|
+
eager=True,
|
|
1706
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("left")),
|
|
1707
|
+
)
|
|
1708
|
+
def _(event: KeyPressEvent) -> None:
|
|
1709
|
+
self._handle_running_prompt_key("left", event)
|
|
1710
|
+
|
|
1711
|
+
@_kb.add(
|
|
1712
|
+
"right",
|
|
1713
|
+
eager=True,
|
|
1714
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("right")),
|
|
1715
|
+
)
|
|
1716
|
+
def _(event: KeyPressEvent) -> None:
|
|
1717
|
+
self._handle_running_prompt_key("right", event)
|
|
1718
|
+
|
|
1719
|
+
@_kb.add(
|
|
1720
|
+
"tab",
|
|
1721
|
+
eager=True,
|
|
1722
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("tab")),
|
|
1723
|
+
)
|
|
1724
|
+
def _(event: KeyPressEvent) -> None:
|
|
1725
|
+
self._handle_running_prompt_key("tab", event)
|
|
1726
|
+
|
|
1727
|
+
@_kb.add(
|
|
1728
|
+
"enter",
|
|
1729
|
+
eager=True,
|
|
1730
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("enter")),
|
|
1731
|
+
)
|
|
1732
|
+
def _(event: KeyPressEvent) -> None:
|
|
1733
|
+
self._handle_running_prompt_key("enter", event)
|
|
1734
|
+
|
|
1735
|
+
@_kb.add(
|
|
1736
|
+
"space",
|
|
1737
|
+
eager=True,
|
|
1738
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("space")),
|
|
1739
|
+
)
|
|
1740
|
+
def _(event: KeyPressEvent) -> None:
|
|
1741
|
+
self._handle_running_prompt_key("space", event)
|
|
1742
|
+
|
|
1743
|
+
@_kb.add(
|
|
1744
|
+
"c-s",
|
|
1745
|
+
eager=True,
|
|
1746
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-s")),
|
|
1747
|
+
)
|
|
1748
|
+
def _(event: KeyPressEvent) -> None:
|
|
1749
|
+
self._handle_running_prompt_key("c-s", event)
|
|
1750
|
+
|
|
1751
|
+
@_kb.add(
|
|
1752
|
+
"c-e",
|
|
1753
|
+
eager=True,
|
|
1754
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-e")),
|
|
1755
|
+
)
|
|
1756
|
+
def _(event: KeyPressEvent) -> None:
|
|
1757
|
+
self._handle_running_prompt_key("c-e", event)
|
|
1758
|
+
|
|
1759
|
+
@_kb.add(
|
|
1760
|
+
"c-c",
|
|
1761
|
+
eager=True,
|
|
1762
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-c")),
|
|
1763
|
+
)
|
|
1764
|
+
def _(event: KeyPressEvent) -> None:
|
|
1765
|
+
self._handle_running_prompt_key("c-c", event)
|
|
1766
|
+
|
|
1767
|
+
@_kb.add(
|
|
1768
|
+
"c-d",
|
|
1769
|
+
eager=True,
|
|
1770
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-d")),
|
|
1771
|
+
)
|
|
1772
|
+
def _(event: KeyPressEvent) -> None:
|
|
1773
|
+
self._handle_running_prompt_key("c-d", event)
|
|
1774
|
+
|
|
1775
|
+
@_kb.add(
|
|
1776
|
+
"escape",
|
|
1777
|
+
eager=True,
|
|
1778
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("escape")),
|
|
1779
|
+
)
|
|
1780
|
+
def _(event: KeyPressEvent) -> None:
|
|
1781
|
+
self._handle_running_prompt_key("escape", event)
|
|
1782
|
+
|
|
1783
|
+
@_kb.add(
|
|
1784
|
+
"escape",
|
|
1785
|
+
eager=True,
|
|
1786
|
+
filter=Condition(lambda: self._shortcut_help_open),
|
|
1787
|
+
)
|
|
1788
|
+
def _(event: KeyPressEvent) -> None:
|
|
1789
|
+
self._shortcut_help_open = False
|
|
1790
|
+
event.app.invalidate()
|
|
1791
|
+
|
|
1792
|
+
@_kb.add(
|
|
1793
|
+
"1",
|
|
1794
|
+
eager=True,
|
|
1795
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("1")),
|
|
1796
|
+
)
|
|
1797
|
+
def _(event: KeyPressEvent) -> None:
|
|
1798
|
+
self._handle_running_prompt_key("1", event)
|
|
1799
|
+
|
|
1800
|
+
@_kb.add(
|
|
1801
|
+
"2",
|
|
1802
|
+
eager=True,
|
|
1803
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("2")),
|
|
1804
|
+
)
|
|
1805
|
+
def _(event: KeyPressEvent) -> None:
|
|
1806
|
+
self._handle_running_prompt_key("2", event)
|
|
1807
|
+
|
|
1808
|
+
@_kb.add(
|
|
1809
|
+
"3",
|
|
1810
|
+
eager=True,
|
|
1811
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("3")),
|
|
1812
|
+
)
|
|
1813
|
+
def _(event: KeyPressEvent) -> None:
|
|
1814
|
+
self._handle_running_prompt_key("3", event)
|
|
1815
|
+
|
|
1816
|
+
@_kb.add(
|
|
1817
|
+
"4",
|
|
1818
|
+
eager=True,
|
|
1819
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("4")),
|
|
1820
|
+
)
|
|
1821
|
+
def _(event: KeyPressEvent) -> None:
|
|
1822
|
+
self._handle_running_prompt_key("4", event)
|
|
1823
|
+
|
|
1824
|
+
@_kb.add(
|
|
1825
|
+
"5",
|
|
1826
|
+
eager=True,
|
|
1827
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("5")),
|
|
1828
|
+
)
|
|
1829
|
+
def _(event: KeyPressEvent) -> None:
|
|
1830
|
+
self._handle_running_prompt_key("5", event)
|
|
1831
|
+
|
|
1832
|
+
@_kb.add(
|
|
1833
|
+
"6",
|
|
1834
|
+
eager=True,
|
|
1835
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("6")),
|
|
1836
|
+
)
|
|
1837
|
+
def _(event: KeyPressEvent) -> None:
|
|
1838
|
+
self._handle_running_prompt_key("6", event)
|
|
1839
|
+
|
|
1840
|
+
@_kb.add(Keys.BracketedPaste, eager=True)
|
|
1841
|
+
def _(event: KeyPressEvent) -> None:
|
|
1842
|
+
self._handle_bracketed_paste(event)
|
|
1843
|
+
|
|
1844
|
+
if clipboard_available or media_clipboard_available:
|
|
1845
|
+
|
|
1846
|
+
@_kb.add("c-v", eager=True)
|
|
1847
|
+
def _(event: KeyPressEvent) -> None:
|
|
1848
|
+
from pythinker_code.telemetry import track
|
|
1849
|
+
|
|
1850
|
+
track("shortcut_paste")
|
|
1851
|
+
if self._try_paste_media(event):
|
|
1852
|
+
return
|
|
1853
|
+
if clipboard_available:
|
|
1854
|
+
try:
|
|
1855
|
+
clipboard_data = event.app.clipboard.get_data()
|
|
1856
|
+
except Exception:
|
|
1857
|
+
return
|
|
1858
|
+
if clipboard_data is None: # type: ignore[reportUnnecessaryComparison]
|
|
1859
|
+
return
|
|
1860
|
+
self._insert_pasted_text(event.current_buffer, clipboard_data.text)
|
|
1861
|
+
event.app.invalidate()
|
|
1862
|
+
|
|
1863
|
+
# Only use PyperclipClipboard when pyperclip actually works.
|
|
1864
|
+
# PromptSession built-in keybindings (ctrl-k, ctrl-w, ctrl-y)
|
|
1865
|
+
# use clipboard without error handling, so a broken clipboard
|
|
1866
|
+
# object would crash the UI.
|
|
1867
|
+
clipboard = PyperclipClipboard() if clipboard_available else None
|
|
1868
|
+
|
|
1869
|
+
self._session = PromptSession[str](
|
|
1870
|
+
message=self._render_message,
|
|
1871
|
+
# prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
1872
|
+
completer=self._agent_mode_completer,
|
|
1873
|
+
complete_while_typing=True,
|
|
1874
|
+
reserve_space_for_menu=6,
|
|
1875
|
+
key_bindings=_kb,
|
|
1876
|
+
clipboard=clipboard,
|
|
1877
|
+
history=history,
|
|
1878
|
+
bottom_toolbar=self._render_bottom_toolbar,
|
|
1879
|
+
style=get_prompt_style(),
|
|
1880
|
+
)
|
|
1881
|
+
self._session.default_buffer.read_only = Condition(
|
|
1882
|
+
lambda: (
|
|
1883
|
+
(delegate := self._active_prompt_delegate()) is not None
|
|
1884
|
+
and not delegate.running_prompt_allows_text_input()
|
|
1885
|
+
)
|
|
1886
|
+
)
|
|
1887
|
+
self._install_prompt_exception_filter()
|
|
1888
|
+
self._install_slash_completion_menu()
|
|
1889
|
+
self._install_prompt_buffer_visibility()
|
|
1890
|
+
self._apply_mode()
|
|
1891
|
+
|
|
1892
|
+
# Allow completion to be triggered when the text is changed,
|
|
1893
|
+
# such as when backspace is used to delete text.
|
|
1894
|
+
@self._session.default_buffer.on_text_changed.add_handler
|
|
1895
|
+
def _(buffer: Buffer) -> None:
|
|
1896
|
+
self._last_input_activity_time = time.monotonic()
|
|
1897
|
+
self._input_activity_event.set()
|
|
1898
|
+
if buffer.complete_while_typing() and not self._suppress_auto_completion:
|
|
1899
|
+
buffer.start_completion()
|
|
1900
|
+
|
|
1901
|
+
# Pre-select the first slash-command completion as soon as the menu
|
|
1902
|
+
# appears. The visual hack in SlashCommandMenuControl.create_content
|
|
1903
|
+
# already paints index 0 as highlighted when complete_index is None,
|
|
1904
|
+
# but the underlying complete_state was still un-positioned, so the
|
|
1905
|
+
# first arrow-down moved None→0 (no visible change) and required a
|
|
1906
|
+
# second press to reach row 2. Setting complete_index=0 here makes
|
|
1907
|
+
# the visual and behavioral states agree from the start.
|
|
1908
|
+
@self._session.default_buffer.on_completions_changed.add_handler
|
|
1909
|
+
def _(buffer: Buffer) -> None:
|
|
1910
|
+
state = buffer.complete_state
|
|
1911
|
+
if state is None or not state.completions:
|
|
1912
|
+
return
|
|
1913
|
+
if state.complete_index is not None:
|
|
1914
|
+
return
|
|
1915
|
+
if not SlashCommandCompleter.should_complete(buffer.document):
|
|
1916
|
+
return
|
|
1917
|
+
state.complete_index = 0
|
|
1918
|
+
|
|
1919
|
+
self._status_refresh_task: asyncio.Task[None] | None = None
|
|
1920
|
+
|
|
1921
|
+
def _install_prompt_exception_filter(self) -> None:
|
|
1922
|
+
"""Avoid prompt_toolkit's blocking ``Exception None`` terminal pause."""
|
|
1923
|
+
app = self._session.app
|
|
1924
|
+
original_handler = app._handle_exception # pyright: ignore[reportPrivateUsage]
|
|
1925
|
+
|
|
1926
|
+
def _handle_exception(loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
1927
|
+
if _is_prompt_toolkit_empty_exception_context(context):
|
|
1928
|
+
logger.debug(
|
|
1929
|
+
"Suppressed prompt_toolkit empty exception context: {context}",
|
|
1930
|
+
context={k: repr(v) for k, v in context.items()},
|
|
1931
|
+
)
|
|
1932
|
+
return
|
|
1933
|
+
original_handler(loop, context)
|
|
1934
|
+
|
|
1935
|
+
app._handle_exception = _handle_exception # pyright: ignore[reportPrivateUsage]
|
|
1936
|
+
|
|
1937
|
+
def _install_slash_completion_menu(self) -> None:
|
|
1938
|
+
float_container = _find_prompt_float_container(self._session.layout.container)
|
|
1939
|
+
if not isinstance(float_container, FloatContainer):
|
|
1940
|
+
return
|
|
1941
|
+
|
|
1942
|
+
self._slash_menu_control = SlashCommandMenuControl(
|
|
1943
|
+
left_padding=self._slash_menu_left_padding
|
|
1944
|
+
)
|
|
1945
|
+
slash_menu = ConditionalContainer(
|
|
1946
|
+
Window(
|
|
1947
|
+
content=self._slash_menu_control,
|
|
1948
|
+
dont_extend_height=True,
|
|
1949
|
+
height=Dimension(max=10),
|
|
1950
|
+
style="class:slash-completion-menu",
|
|
1951
|
+
),
|
|
1952
|
+
filter=has_completions & Condition(self._should_show_slash_completion_menu),
|
|
1953
|
+
)
|
|
1954
|
+
root = self._session.layout.container
|
|
1955
|
+
buffer_container = _find_default_buffer_container(root, self._session.default_buffer)
|
|
1956
|
+
if isinstance(root, HSplit) and buffer_container is not None:
|
|
1957
|
+
children = cast(list[object], root.children)
|
|
1958
|
+
for index, child in enumerate(children):
|
|
1959
|
+
if _container_contains(child, buffer_container):
|
|
1960
|
+
children.insert(index + 1, slash_menu)
|
|
1961
|
+
break
|
|
1962
|
+
|
|
1963
|
+
original_float = next(
|
|
1964
|
+
(
|
|
1965
|
+
float_
|
|
1966
|
+
for float_ in float_container.floats
|
|
1967
|
+
if isinstance(float_.content, CompletionsMenu)
|
|
1968
|
+
),
|
|
1969
|
+
None,
|
|
1970
|
+
)
|
|
1971
|
+
if original_float is None:
|
|
1972
|
+
return
|
|
1973
|
+
original_float.content = ConditionalContainer(
|
|
1974
|
+
original_float.content,
|
|
1975
|
+
filter=~Condition(self._should_show_slash_completion_menu),
|
|
1976
|
+
)
|
|
1977
|
+
|
|
1978
|
+
def _install_prompt_buffer_visibility(self) -> None:
|
|
1979
|
+
buffer_container = _find_default_buffer_container(
|
|
1980
|
+
self._session.layout.container,
|
|
1981
|
+
self._session.default_buffer,
|
|
1982
|
+
)
|
|
1983
|
+
if buffer_container is None:
|
|
1984
|
+
return
|
|
1985
|
+
buffer_container.filter = buffer_container.filter & Condition(
|
|
1986
|
+
self._should_render_input_buffer
|
|
1987
|
+
)
|
|
1988
|
+
if isinstance(buffer_container.content, Window):
|
|
1989
|
+
buffer_window = buffer_container.content
|
|
1990
|
+
buffer_window.height = Dimension(min=1, max=5)
|
|
1991
|
+
buffer_window.dont_extend_height = Condition(lambda: True)
|
|
1992
|
+
buffer_window.style = "class:compact-input"
|
|
1993
|
+
self._prompt_buffer_container = buffer_container
|
|
1994
|
+
|
|
1995
|
+
def _should_show_slash_completion_menu(self) -> bool:
|
|
1996
|
+
document = self._session.default_buffer.document
|
|
1997
|
+
return SlashCommandCompleter.should_complete(document)
|
|
1998
|
+
|
|
1999
|
+
def _slash_menu_left_padding(self) -> int:
|
|
2000
|
+
side_padding = _card_side_padding()
|
|
2001
|
+
if self._mode == PromptMode.SHELL:
|
|
2002
|
+
return side_padding + max(1, get_cwidth(f"{PROMPT_SYMBOL_SHELL} ") - 2)
|
|
2003
|
+
# Agent mode: prompt prefix is "› " inside the compact input block.
|
|
2004
|
+
return side_padding + 1
|
|
2005
|
+
|
|
2006
|
+
def _render_message(self) -> FormattedText:
|
|
2007
|
+
if self._mode == PromptMode.SHELL:
|
|
2008
|
+
return self._render_shell_prompt_message()
|
|
2009
|
+
return self._render_agent_prompt_message()
|
|
2010
|
+
|
|
2011
|
+
def _render_shell_prompt_message(self) -> FormattedText:
|
|
2012
|
+
app = get_app_or_none()
|
|
2013
|
+
size = app.output.get_size() if app is not None else None
|
|
2014
|
+
columns = size.columns if size is not None else 80
|
|
2015
|
+
fragments: FormattedText = FormattedText()
|
|
2016
|
+
|
|
2017
|
+
if getattr(self, "_shortcut_help_open", False):
|
|
2018
|
+
fragments.extend(self._render_shortcut_help(columns))
|
|
2019
|
+
if fragments and not fragments[-1][1].endswith("\n"):
|
|
2020
|
+
fragments.append(("", "\n"))
|
|
2021
|
+
|
|
2022
|
+
# Dynamic preamble (agent status + modal/interactive body). Keep it
|
|
2023
|
+
# within the visible terminal area so it cannot overlap the input/footer.
|
|
2024
|
+
preamble: FormattedText = FormattedText()
|
|
2025
|
+
agent_status = self._render_agent_status(columns)
|
|
2026
|
+
if agent_status:
|
|
2027
|
+
preamble.extend(agent_status)
|
|
2028
|
+
if not agent_status[-1][1].endswith("\n"):
|
|
2029
|
+
preamble.append(("", "\n"))
|
|
2030
|
+
|
|
2031
|
+
body = self._render_interactive_body(columns)
|
|
2032
|
+
if body:
|
|
2033
|
+
preamble.extend(body)
|
|
2034
|
+
if not body[-1][1].endswith("\n"):
|
|
2035
|
+
preamble.append(("", "\n"))
|
|
2036
|
+
|
|
2037
|
+
if preamble:
|
|
2038
|
+
preamble = _fit_formatted_text_to_rows(
|
|
2039
|
+
preamble,
|
|
2040
|
+
columns,
|
|
2041
|
+
_prompt_preamble_max_rows(getattr(size, "rows", None)),
|
|
2042
|
+
preserve_tail_rows=1,
|
|
2043
|
+
)
|
|
2044
|
+
fragments.extend(preamble)
|
|
2045
|
+
|
|
2046
|
+
if self._active_modal_delegate() is not None:
|
|
2047
|
+
return fragments
|
|
2048
|
+
if is_card_style():
|
|
2049
|
+
if fragments and not fragments[-1][1].endswith("\n"):
|
|
2050
|
+
fragments.append(("", "\n"))
|
|
2051
|
+
tc = get_toolbar_colors()
|
|
2052
|
+
fragments.append((tc.separator, "─" * columns))
|
|
2053
|
+
fragments.append(("", "\n"))
|
|
2054
|
+
elif preamble:
|
|
2055
|
+
fragments.append(("", "\n"))
|
|
2056
|
+
fragments.append(("", _card_side_indent()))
|
|
2057
|
+
fragments.append(("bold", f"{PROMPT_SYMBOL_SHELL} "))
|
|
2058
|
+
return fragments
|
|
2059
|
+
|
|
2060
|
+
def _open_in_external_editor(self, event: KeyPressEvent) -> None:
|
|
2061
|
+
"""Open the current buffer content in an external editor."""
|
|
2062
|
+
from prompt_toolkit.application.run_in_terminal import run_in_terminal
|
|
2063
|
+
|
|
2064
|
+
from pythinker_code.utils.editor import edit_text_in_editor, get_editor_command
|
|
2065
|
+
|
|
2066
|
+
configured = self._editor_command_provider()
|
|
2067
|
+
|
|
2068
|
+
if get_editor_command(configured) is None:
|
|
2069
|
+
toast("No editor found. Set $VISUAL/$EDITOR or run /editor.")
|
|
2070
|
+
return
|
|
2071
|
+
|
|
2072
|
+
buff = event.current_buffer
|
|
2073
|
+
original_text = buff.text
|
|
2074
|
+
editor_text = self._get_placeholder_manager().expand_for_editor(original_text)
|
|
2075
|
+
|
|
2076
|
+
async def _run_editor() -> None:
|
|
2077
|
+
result = await run_in_terminal(
|
|
2078
|
+
lambda: edit_text_in_editor(editor_text, configured), in_executor=True
|
|
2079
|
+
)
|
|
2080
|
+
if result is not None:
|
|
2081
|
+
refolded = self._get_placeholder_manager().refold_after_editor(
|
|
2082
|
+
result, original_text
|
|
2083
|
+
)
|
|
2084
|
+
buff.document = Document(text=refolded, cursor_position=len(refolded))
|
|
2085
|
+
|
|
2086
|
+
event.app.create_background_task(_run_editor())
|
|
2087
|
+
|
|
2088
|
+
def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
|
|
2089
|
+
# Apply mode to the active buffer (not the PromptSession itself)
|
|
2090
|
+
try:
|
|
2091
|
+
buff = event.current_buffer if event is not None else self._session.default_buffer
|
|
2092
|
+
except Exception:
|
|
2093
|
+
buff = None
|
|
2094
|
+
|
|
2095
|
+
if self._mode == PromptMode.SHELL:
|
|
2096
|
+
if buff is not None:
|
|
2097
|
+
buff.completer = self._shell_mode_completer
|
|
2098
|
+
else:
|
|
2099
|
+
if buff is not None:
|
|
2100
|
+
buff.completer = self._agent_mode_completer
|
|
2101
|
+
self._sync_erase_when_done()
|
|
2102
|
+
|
|
2103
|
+
def _sync_erase_when_done(self) -> None:
|
|
2104
|
+
app = getattr(self._session, "app", None)
|
|
2105
|
+
if app is not None:
|
|
2106
|
+
app.erase_when_done = self._mode == PromptMode.AGENT
|
|
2107
|
+
|
|
2108
|
+
def _active_modal_delegate(self) -> RunningPromptDelegate | None:
|
|
2109
|
+
modal_delegates = getattr(self, "_modal_delegates", [])
|
|
2110
|
+
if not modal_delegates:
|
|
2111
|
+
return None
|
|
2112
|
+
_, delegate = max(
|
|
2113
|
+
enumerate(modal_delegates),
|
|
2114
|
+
key=lambda item: (item[1].modal_priority, item[0]),
|
|
2115
|
+
)
|
|
2116
|
+
return delegate
|
|
2117
|
+
|
|
2118
|
+
def _active_prompt_delegate(self) -> RunningPromptDelegate | None:
|
|
2119
|
+
if delegate := self._active_modal_delegate():
|
|
2120
|
+
return delegate
|
|
2121
|
+
return getattr(self, "_running_prompt_delegate", None)
|
|
2122
|
+
|
|
2123
|
+
def _active_ui_state(self) -> PromptUIState:
|
|
2124
|
+
delegate = self._active_modal_delegate()
|
|
2125
|
+
if delegate is None:
|
|
2126
|
+
return PromptUIState.NORMAL_INPUT
|
|
2127
|
+
if delegate.running_prompt_hides_input_buffer():
|
|
2128
|
+
return PromptUIState.MODAL_HIDDEN_INPUT
|
|
2129
|
+
if delegate.running_prompt_allows_text_input():
|
|
2130
|
+
return PromptUIState.MODAL_TEXT_INPUT
|
|
2131
|
+
return PromptUIState.NORMAL_INPUT
|
|
2132
|
+
|
|
2133
|
+
def _should_render_input_buffer(self) -> bool:
|
|
2134
|
+
return self._active_ui_state() != PromptUIState.MODAL_HIDDEN_INPUT
|
|
2135
|
+
|
|
2136
|
+
def _should_handle_running_prompt_key(self, key: str) -> bool:
|
|
2137
|
+
delegate = self._active_prompt_delegate()
|
|
2138
|
+
return delegate is not None and delegate.should_handle_running_prompt_key(key)
|
|
2139
|
+
|
|
2140
|
+
def _handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None:
|
|
2141
|
+
delegate = self._active_prompt_delegate()
|
|
2142
|
+
if delegate is None:
|
|
2143
|
+
return
|
|
2144
|
+
delegate.handle_running_prompt_key(key, event)
|
|
2145
|
+
event.app.invalidate()
|
|
2146
|
+
|
|
2147
|
+
def invalidate(self) -> None:
|
|
2148
|
+
self._sync_prompt_ui_state()
|
|
2149
|
+
app = get_app_or_none()
|
|
2150
|
+
if app is not None:
|
|
2151
|
+
app.invalidate()
|
|
2152
|
+
|
|
2153
|
+
def _sync_prompt_ui_state(self) -> None:
|
|
2154
|
+
new_state = self._active_ui_state()
|
|
2155
|
+
old_state = getattr(self, "_last_ui_state", PromptUIState.NORMAL_INPUT)
|
|
2156
|
+
buffer = self._session.default_buffer
|
|
2157
|
+
|
|
2158
|
+
if (
|
|
2159
|
+
old_state != PromptUIState.MODAL_HIDDEN_INPUT
|
|
2160
|
+
and new_state == PromptUIState.MODAL_HIDDEN_INPUT
|
|
2161
|
+
):
|
|
2162
|
+
if self._suspended_buffer_document is None and buffer.text:
|
|
2163
|
+
self._suspended_buffer_document = buffer.document
|
|
2164
|
+
buffer.set_document(Document(), bypass_readonly=True)
|
|
2165
|
+
elif (
|
|
2166
|
+
old_state == PromptUIState.MODAL_HIDDEN_INPUT
|
|
2167
|
+
and new_state != PromptUIState.MODAL_HIDDEN_INPUT
|
|
2168
|
+
and self._suspended_buffer_document is not None
|
|
2169
|
+
):
|
|
2170
|
+
if not buffer.text:
|
|
2171
|
+
buffer.set_document(self._suspended_buffer_document, bypass_readonly=True)
|
|
2172
|
+
else:
|
|
2173
|
+
# Buffer was externally modified (e.g. approval inline feedback).
|
|
2174
|
+
# Don't overwrite the new content, but log that the old input is lost.
|
|
2175
|
+
logger.debug(
|
|
2176
|
+
"Dropping suspended buffer document because buffer was modified externally"
|
|
2177
|
+
)
|
|
2178
|
+
self._suspended_buffer_document = None
|
|
2179
|
+
|
|
2180
|
+
self._last_ui_state = new_state
|
|
2181
|
+
|
|
2182
|
+
def _render_agent_prompt_message(self) -> FormattedText:
|
|
2183
|
+
app = get_app_or_none()
|
|
2184
|
+
size = app.output.get_size() if app is not None else None
|
|
2185
|
+
columns = size.columns if size is not None else 80
|
|
2186
|
+
fragments: FormattedText = FormattedText()
|
|
2187
|
+
|
|
2188
|
+
# 1–2. Dynamic preamble — agent status is always rendered from the
|
|
2189
|
+
# running prompt delegate, and body comes from the active modal/delegate.
|
|
2190
|
+
# Cap the visible rows so large cards do not overwrite the input/footer.
|
|
2191
|
+
# When a modal is active, preserve the whole modal body and clip older
|
|
2192
|
+
# agent status above it first; approval/question controls must remain usable.
|
|
2193
|
+
agent_status = self._render_agent_status(columns)
|
|
2194
|
+
body = self._render_interactive_body(columns)
|
|
2195
|
+
max_rows = _prompt_preamble_max_rows(getattr(size, "rows", None))
|
|
2196
|
+
modal_active = self._active_modal_delegate() is not None
|
|
2197
|
+
|
|
2198
|
+
if getattr(self, "_shortcut_help_open", False) and not modal_active:
|
|
2199
|
+
fragments.extend(self._render_shortcut_help(columns))
|
|
2200
|
+
if fragments and not fragments[-1][1].endswith("\n"):
|
|
2201
|
+
fragments.append(("", "\n"))
|
|
2202
|
+
|
|
2203
|
+
if modal_active and body:
|
|
2204
|
+
body_rows = len(_formatted_text_display_rows(body, columns))
|
|
2205
|
+
status_budget = max(0, max_rows - body_rows)
|
|
2206
|
+
if agent_status and status_budget > 0:
|
|
2207
|
+
clipped_status = _fit_formatted_text_to_rows(
|
|
2208
|
+
agent_status,
|
|
2209
|
+
columns,
|
|
2210
|
+
status_budget,
|
|
2211
|
+
preserve_tail_rows=1,
|
|
2212
|
+
)
|
|
2213
|
+
fragments.extend(clipped_status)
|
|
2214
|
+
if fragments and not fragments[-1][1].endswith("\n"):
|
|
2215
|
+
fragments.append(("", "\n"))
|
|
2216
|
+
fragments.extend(body)
|
|
2217
|
+
if body and not body[-1][1].endswith("\n"):
|
|
2218
|
+
fragments.append(("", "\n"))
|
|
2219
|
+
else:
|
|
2220
|
+
preamble: FormattedText = FormattedText()
|
|
2221
|
+
if agent_status:
|
|
2222
|
+
preamble.extend(agent_status)
|
|
2223
|
+
if not agent_status[-1][1].endswith("\n"):
|
|
2224
|
+
preamble.append(("", "\n"))
|
|
2225
|
+
if body:
|
|
2226
|
+
preamble.extend(body)
|
|
2227
|
+
if not body[-1][1].endswith("\n"):
|
|
2228
|
+
preamble.append(("", "\n"))
|
|
2229
|
+
if preamble:
|
|
2230
|
+
preamble = _fit_formatted_text_to_rows(
|
|
2231
|
+
preamble,
|
|
2232
|
+
columns,
|
|
2233
|
+
max_rows,
|
|
2234
|
+
preserve_tail_rows=1,
|
|
2235
|
+
)
|
|
2236
|
+
fragments.extend(preamble)
|
|
2237
|
+
|
|
2238
|
+
# 3. When a modal is active, skip the normal input chrome.
|
|
2239
|
+
if modal_active:
|
|
2240
|
+
return fragments
|
|
2241
|
+
|
|
2242
|
+
if is_card_style():
|
|
2243
|
+
if fragments and not fragments[-1][1].endswith("\n"):
|
|
2244
|
+
fragments.append(("", "\n"))
|
|
2245
|
+
tc = get_toolbar_colors()
|
|
2246
|
+
fragments.append((tc.separator, "─" * columns))
|
|
2247
|
+
fragments.append(("", "\n"))
|
|
2248
|
+
fragments.append(("", _card_side_indent()))
|
|
2249
|
+
else:
|
|
2250
|
+
fragments.append(("", "\n"))
|
|
2251
|
+
fragments.append(("class:compact-input.prompt", f"{PROMPT_SYMBOL_AGENT_INPUT} "))
|
|
2252
|
+
return fragments
|
|
2253
|
+
|
|
2254
|
+
def _render_shortcut_help(self, columns: int) -> FormattedText:
|
|
2255
|
+
"""Render a small Blackbox-style shortcuts popup above the prompt."""
|
|
2256
|
+
side_padding = min(_card_side_padding(), max(0, (columns - 2) // 2))
|
|
2257
|
+
indent = " " * side_padding
|
|
2258
|
+
available = max(1, columns - side_padding * 2)
|
|
2259
|
+
width = min(88, available)
|
|
2260
|
+
rows = [
|
|
2261
|
+
("Ctrl-X", "toggle agent/shell"),
|
|
2262
|
+
("Shift-Tab", "toggle plan mode"),
|
|
2263
|
+
("Ctrl-O", "open editor"),
|
|
2264
|
+
("Ctrl-J / Alt-Enter", "newline"),
|
|
2265
|
+
("Ctrl-V", "paste / images"),
|
|
2266
|
+
("@path", "mention files"),
|
|
2267
|
+
("/", "commands"),
|
|
2268
|
+
("Esc", "close this popup"),
|
|
2269
|
+
]
|
|
2270
|
+
key_width = min(20, max(get_cwidth(key) for key, _ in rows) + 1)
|
|
2271
|
+
tc = get_toolbar_colors()
|
|
2272
|
+
fragments: FormattedText = FormattedText()
|
|
2273
|
+
border = "─" * max(0, width - 2)
|
|
2274
|
+
fragments.append(("", indent))
|
|
2275
|
+
fragments.append((tc.separator, f"╭{border}╮\n"))
|
|
2276
|
+
title = " Shortcuts "
|
|
2277
|
+
padding = max(0, width - 2 - get_cwidth(title))
|
|
2278
|
+
fragments.append(("", indent))
|
|
2279
|
+
fragments.append((tc.separator, "│"))
|
|
2280
|
+
fragments.append(("class:slash-completion-menu.command.current", title))
|
|
2281
|
+
fragments.append(("class:slash-completion-menu.meta", "".ljust(padding)))
|
|
2282
|
+
fragments.append((tc.separator, "│\n"))
|
|
2283
|
+
for key, desc in rows:
|
|
2284
|
+
line = f" {key.ljust(key_width)} {desc}"
|
|
2285
|
+
pad = max(0, width - 2 - get_cwidth(line))
|
|
2286
|
+
fragments.append(("", indent))
|
|
2287
|
+
fragments.append((tc.separator, "│"))
|
|
2288
|
+
fragments.append(("class:slash-completion-menu.command", line[: width - 2]))
|
|
2289
|
+
fragments.append(("class:slash-completion-menu", " " * pad))
|
|
2290
|
+
fragments.append((tc.separator, "│\n"))
|
|
2291
|
+
fragments.append(("", indent))
|
|
2292
|
+
fragments.append((tc.separator, f"╰{border}╯"))
|
|
2293
|
+
return fragments
|
|
2294
|
+
|
|
2295
|
+
def _render_agent_status(self, columns: int) -> FormattedText:
|
|
2296
|
+
"""Render agent streaming output (always visible, independent of modals)."""
|
|
2297
|
+
running = self._running_prompt_delegate
|
|
2298
|
+
if running is not None and isinstance(running, AgentStatusProvider):
|
|
2299
|
+
rendered = to_formatted_text(running.render_agent_status(columns))
|
|
2300
|
+
if any(fragment for _, fragment, *_ in rendered):
|
|
2301
|
+
return rendered
|
|
2302
|
+
|
|
2303
|
+
fragments = self._render_background_working_status(columns)
|
|
2304
|
+
status = self._render_status_block(columns)
|
|
2305
|
+
if status:
|
|
2306
|
+
if fragments and not fragments[-1][1].endswith("\n"):
|
|
2307
|
+
fragments.append(("", "\n"))
|
|
2308
|
+
fragments.extend(status)
|
|
2309
|
+
return fragments
|
|
2310
|
+
|
|
2311
|
+
def _render_background_working_status(self, columns: int) -> FormattedText:
|
|
2312
|
+
"""Render an idle prompt spinner while background work is active."""
|
|
2313
|
+
counts = self._background_task_counts()
|
|
2314
|
+
total = counts.bash + counts.agent
|
|
2315
|
+
if total <= 0:
|
|
2316
|
+
return FormattedText([])
|
|
2317
|
+
now = time.monotonic()
|
|
2318
|
+
frame = "●" if int(now / 0.8) % 2 == 0 else " "
|
|
2319
|
+
noun = "process" if total == 1 else "processes"
|
|
2320
|
+
detail = f"{total} background {noun}"
|
|
2321
|
+
if counts.agent and counts.bash:
|
|
2322
|
+
detail = f"{counts.agent} agent, {counts.bash} bash"
|
|
2323
|
+
elif counts.agent:
|
|
2324
|
+
detail = f"{counts.agent} background agent{'s' if counts.agent != 1 else ''}"
|
|
2325
|
+
elif counts.bash:
|
|
2326
|
+
detail = f"{counts.bash} background bash task{'s' if counts.bash != 1 else ''}"
|
|
2327
|
+
text = f"{frame} {spinner_message(now)} {detail}"
|
|
2328
|
+
if _display_width(text) > columns:
|
|
2329
|
+
text = _truncate_right(text, columns)
|
|
2330
|
+
return FormattedText([("ansicyan", text)])
|
|
2331
|
+
|
|
2332
|
+
def _background_task_counts(self) -> BgTaskCounts:
|
|
2333
|
+
provider = getattr(self, "_background_task_count_provider", None)
|
|
2334
|
+
if provider is None:
|
|
2335
|
+
return BgTaskCounts()
|
|
2336
|
+
return provider()
|
|
2337
|
+
|
|
2338
|
+
def _has_background_tasks(self) -> bool:
|
|
2339
|
+
counts = self._background_task_counts()
|
|
2340
|
+
return counts.bash > 0 or counts.agent > 0
|
|
2341
|
+
|
|
2342
|
+
def _render_interactive_body(self, columns: int) -> FormattedText:
|
|
2343
|
+
"""Render the interactive area from the active delegate (modal or running prompt)."""
|
|
2344
|
+
delegate = self._active_prompt_delegate()
|
|
2345
|
+
if delegate is None:
|
|
2346
|
+
return FormattedText([])
|
|
2347
|
+
return to_formatted_text(delegate.render_running_prompt_body(columns))
|
|
2348
|
+
|
|
2349
|
+
def _render_status_block(self, columns: int) -> FormattedText:
|
|
2350
|
+
status_block_provider = getattr(self, "_status_block_provider", None)
|
|
2351
|
+
if status_block_provider is None:
|
|
2352
|
+
return FormattedText([])
|
|
2353
|
+
block = status_block_provider(columns)
|
|
2354
|
+
if block is None:
|
|
2355
|
+
return FormattedText([])
|
|
2356
|
+
return to_formatted_text(block)
|
|
2357
|
+
|
|
2358
|
+
def _render_agent_prompt_label(self) -> FormattedText:
|
|
2359
|
+
"""Render the prompt label (empty — cursor starts at column 0)."""
|
|
2360
|
+
return FormattedText([("", " ")])
|
|
2361
|
+
|
|
2362
|
+
def __enter__(self) -> CustomPromptSession:
|
|
2363
|
+
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
2364
|
+
return self
|
|
2365
|
+
|
|
2366
|
+
async def _refresh() -> None:
|
|
2367
|
+
try:
|
|
2368
|
+
while True:
|
|
2369
|
+
app = get_app_or_none()
|
|
2370
|
+
if app is not None:
|
|
2371
|
+
app.invalidate()
|
|
2372
|
+
|
|
2373
|
+
try:
|
|
2374
|
+
asyncio.get_running_loop()
|
|
2375
|
+
except RuntimeError:
|
|
2376
|
+
logger.warning("No running loop found, exiting status refresh task")
|
|
2377
|
+
self._status_refresh_task = None
|
|
2378
|
+
break
|
|
2379
|
+
|
|
2380
|
+
interval = (
|
|
2381
|
+
_RUNNING_REFRESH_INTERVAL
|
|
2382
|
+
if self._active_prompt_delegate() is not None
|
|
2383
|
+
or self._has_background_tasks()
|
|
2384
|
+
or (
|
|
2385
|
+
self._fast_refresh_provider is not None
|
|
2386
|
+
and self._fast_refresh_provider()
|
|
2387
|
+
)
|
|
2388
|
+
else _IDLE_REFRESH_INTERVAL
|
|
2389
|
+
)
|
|
2390
|
+
await asyncio.sleep(interval)
|
|
2391
|
+
except asyncio.CancelledError:
|
|
2392
|
+
# graceful exit
|
|
2393
|
+
pass
|
|
2394
|
+
|
|
2395
|
+
self._status_refresh_task = asyncio.create_task(_refresh())
|
|
2396
|
+
return self
|
|
2397
|
+
|
|
2398
|
+
def __exit__(self, *_) -> None:
|
|
2399
|
+
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
2400
|
+
self._status_refresh_task.cancel()
|
|
2401
|
+
self._status_refresh_task = None
|
|
2402
|
+
|
|
2403
|
+
def _get_placeholder_manager(self) -> PromptPlaceholderManager:
|
|
2404
|
+
manager = getattr(self, "_placeholder_manager", None)
|
|
2405
|
+
if manager is None:
|
|
2406
|
+
attachment_cache = getattr(self, "_attachment_cache", None)
|
|
2407
|
+
manager = PromptPlaceholderManager(attachment_cache=attachment_cache)
|
|
2408
|
+
self._placeholder_manager = manager
|
|
2409
|
+
self._attachment_cache = manager.attachment_cache
|
|
2410
|
+
return manager
|
|
2411
|
+
|
|
2412
|
+
def _insert_pasted_text(self, buffer: Buffer, text: str) -> None:
|
|
2413
|
+
normalized = normalize_pasted_text(text)
|
|
2414
|
+
if self._mode != PromptMode.AGENT:
|
|
2415
|
+
buffer.insert_text(normalized)
|
|
2416
|
+
return
|
|
2417
|
+
token_or_text = self._get_placeholder_manager().maybe_placeholderize_pasted_text(normalized)
|
|
2418
|
+
buffer.insert_text(token_or_text)
|
|
2419
|
+
|
|
2420
|
+
def _handle_bracketed_paste(self, event: KeyPressEvent) -> None:
|
|
2421
|
+
self._insert_pasted_text(event.current_buffer, event.data)
|
|
2422
|
+
event.app.invalidate()
|
|
2423
|
+
|
|
2424
|
+
def _try_paste_media(self, event: KeyPressEvent) -> bool:
|
|
2425
|
+
"""Try to paste media from the clipboard.
|
|
2426
|
+
|
|
2427
|
+
Reads the clipboard once and handles all detected content:
|
|
2428
|
+
non-image files (videos, PDFs, etc.) are inserted as paths,
|
|
2429
|
+
image files are cached and inserted as placeholders.
|
|
2430
|
+
Returns True if any media content was inserted.
|
|
2431
|
+
"""
|
|
2432
|
+
try:
|
|
2433
|
+
result = grab_media_from_clipboard()
|
|
2434
|
+
except Exception:
|
|
2435
|
+
# ImageGrab.grabclipboard() may fail on headless Linux if the
|
|
2436
|
+
# real xclip cannot connect to an X server. Silently ignore so
|
|
2437
|
+
# that the text-paste fallback can still be attempted.
|
|
2438
|
+
return False
|
|
2439
|
+
if result is None:
|
|
2440
|
+
return False
|
|
2441
|
+
|
|
2442
|
+
parts: list[str] = []
|
|
2443
|
+
|
|
2444
|
+
# 1. Insert file paths (videos, PDFs, etc.)
|
|
2445
|
+
if result.file_paths:
|
|
2446
|
+
logger.debug("Pasted {count} file path(s) from clipboard", count=len(result.file_paths))
|
|
2447
|
+
for p in result.file_paths:
|
|
2448
|
+
text = str(p)
|
|
2449
|
+
if self._mode == PromptMode.SHELL:
|
|
2450
|
+
text = shlex.quote(text)
|
|
2451
|
+
parts.append(text)
|
|
2452
|
+
|
|
2453
|
+
# 2. Insert images via cache.
|
|
2454
|
+
if result.images:
|
|
2455
|
+
if "image_in" not in self._model_capabilities:
|
|
2456
|
+
console.print(
|
|
2457
|
+
"[yellow]Image input is not supported by the selected LLM model[/yellow]"
|
|
2458
|
+
)
|
|
2459
|
+
else:
|
|
2460
|
+
for image in result.images:
|
|
2461
|
+
token = self._get_placeholder_manager().create_image_placeholder(image)
|
|
2462
|
+
if token is None:
|
|
2463
|
+
continue
|
|
2464
|
+
logger.debug(
|
|
2465
|
+
"Pasted image from clipboard placeholder: {token}, {image_size}",
|
|
2466
|
+
token=token,
|
|
2467
|
+
image_size=image.size,
|
|
2468
|
+
)
|
|
2469
|
+
parts.append(token)
|
|
2470
|
+
|
|
2471
|
+
if parts:
|
|
2472
|
+
event.current_buffer.insert_text(" ".join(parts))
|
|
2473
|
+
event.app.invalidate()
|
|
2474
|
+
return bool(parts)
|
|
2475
|
+
|
|
2476
|
+
def set_prefill_text(self, text: str) -> None:
|
|
2477
|
+
"""Pre-fill the input buffer with the given text.
|
|
2478
|
+
|
|
2479
|
+
Must be called after the prompt session is created but before the
|
|
2480
|
+
first prompt_async call. The text will appear as editable default
|
|
2481
|
+
input in the next prompt.
|
|
2482
|
+
"""
|
|
2483
|
+
self._prefill_text = text
|
|
2484
|
+
|
|
2485
|
+
async def prompt_next(self) -> UserInput:
|
|
2486
|
+
return await self._prompt_once(append_history=None)
|
|
2487
|
+
|
|
2488
|
+
@property
|
|
2489
|
+
def last_submission_was_running(self) -> bool:
|
|
2490
|
+
return getattr(self, "_last_submission_was_running", False)
|
|
2491
|
+
|
|
2492
|
+
def has_pending_input(self) -> bool:
|
|
2493
|
+
return bool(self._session.default_buffer.text)
|
|
2494
|
+
|
|
2495
|
+
def had_recent_input_activity(self, *, within_s: float) -> bool:
|
|
2496
|
+
if self._last_input_activity_time <= 0:
|
|
2497
|
+
return False
|
|
2498
|
+
return (time.monotonic() - self._last_input_activity_time) <= within_s
|
|
2499
|
+
|
|
2500
|
+
def recent_input_activity_remaining(self, *, within_s: float) -> float:
|
|
2501
|
+
if self._last_input_activity_time <= 0:
|
|
2502
|
+
return 0.0
|
|
2503
|
+
elapsed = time.monotonic() - self._last_input_activity_time
|
|
2504
|
+
return max(0.0, within_s - elapsed)
|
|
2505
|
+
|
|
2506
|
+
async def wait_for_input_activity(self) -> None:
|
|
2507
|
+
await self._input_activity_event.wait()
|
|
2508
|
+
self._input_activity_event.clear()
|
|
2509
|
+
|
|
2510
|
+
def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None:
|
|
2511
|
+
current = getattr(self, "_running_prompt_delegate", None)
|
|
2512
|
+
if current is delegate:
|
|
2513
|
+
return
|
|
2514
|
+
if current is None:
|
|
2515
|
+
self._running_prompt_previous_mode = self._mode
|
|
2516
|
+
self._running_prompt_delegate = delegate
|
|
2517
|
+
self._mode = PromptMode.AGENT
|
|
2518
|
+
self._apply_mode()
|
|
2519
|
+
self.invalidate()
|
|
2520
|
+
|
|
2521
|
+
def detach_running_prompt(self, delegate: RunningPromptDelegate) -> None:
|
|
2522
|
+
if getattr(self, "_running_prompt_delegate", None) is not delegate:
|
|
2523
|
+
return
|
|
2524
|
+
previous_mode = getattr(self, "_running_prompt_previous_mode", None)
|
|
2525
|
+
self._running_prompt_delegate = None
|
|
2526
|
+
self._running_prompt_previous_mode = None
|
|
2527
|
+
if previous_mode is not None:
|
|
2528
|
+
self._mode = previous_mode
|
|
2529
|
+
self._apply_mode()
|
|
2530
|
+
self.invalidate()
|
|
2531
|
+
|
|
2532
|
+
def attach_modal(self, delegate: RunningPromptDelegate) -> None:
|
|
2533
|
+
modal_delegates: list[RunningPromptDelegate] | None = getattr(
|
|
2534
|
+
self, "_modal_delegates", None
|
|
2535
|
+
)
|
|
2536
|
+
if modal_delegates is None:
|
|
2537
|
+
modal_delegates = []
|
|
2538
|
+
self._modal_delegates = modal_delegates
|
|
2539
|
+
if delegate in modal_delegates:
|
|
2540
|
+
return
|
|
2541
|
+
modal_delegates.append(delegate)
|
|
2542
|
+
self.invalidate()
|
|
2543
|
+
|
|
2544
|
+
def detach_modal(self, delegate: RunningPromptDelegate) -> None:
|
|
2545
|
+
modal_delegates = getattr(self, "_modal_delegates", None)
|
|
2546
|
+
if not modal_delegates or delegate not in modal_delegates:
|
|
2547
|
+
return
|
|
2548
|
+
modal_delegates.remove(delegate)
|
|
2549
|
+
self.invalidate()
|
|
2550
|
+
|
|
2551
|
+
def running_prompt_accepts_submission(self) -> bool:
|
|
2552
|
+
delegate = self._active_prompt_delegate()
|
|
2553
|
+
if delegate is None:
|
|
2554
|
+
return False
|
|
2555
|
+
return delegate.running_prompt_accepts_submission()
|
|
2556
|
+
|
|
2557
|
+
async def _prompt_once(self, *, append_history: bool | None) -> UserInput:
|
|
2558
|
+
placeholder = None
|
|
2559
|
+
if (delegate := self._active_prompt_delegate()) is not None:
|
|
2560
|
+
placeholder = delegate.running_prompt_placeholder()
|
|
2561
|
+
# Consume one-shot prefill text if set
|
|
2562
|
+
default = getattr(self, "_prefill_text", None) or ""
|
|
2563
|
+
self._prefill_text = None
|
|
2564
|
+
with patch_stdout(raw=True):
|
|
2565
|
+
command = str(
|
|
2566
|
+
await self._session.prompt_async(placeholder=placeholder, default=default)
|
|
2567
|
+
).strip()
|
|
2568
|
+
command = command.replace("\x00", "") # just in case null bytes are somehow inserted
|
|
2569
|
+
# Sanitize UTF-16 surrogates that may come from Windows clipboard
|
|
2570
|
+
command = sanitize_surrogates(command)
|
|
2571
|
+
was_running = self.running_prompt_accepts_submission()
|
|
2572
|
+
self._last_submission_was_running = was_running
|
|
2573
|
+
if append_history is None:
|
|
2574
|
+
append_history = not was_running
|
|
2575
|
+
if append_history:
|
|
2576
|
+
self._append_history_entry(command)
|
|
2577
|
+
self._tip_rotation_index += 1
|
|
2578
|
+
return self._build_user_input(command)
|
|
2579
|
+
|
|
2580
|
+
def _build_user_input(self, command: str) -> UserInput:
|
|
2581
|
+
resolved = self._get_placeholder_manager().resolve_command(command)
|
|
2582
|
+
|
|
2583
|
+
return UserInput(
|
|
2584
|
+
mode=self._mode,
|
|
2585
|
+
command=resolved.display_command,
|
|
2586
|
+
resolved_command=resolved.resolved_text,
|
|
2587
|
+
content=resolved.content,
|
|
2588
|
+
)
|
|
2589
|
+
|
|
2590
|
+
def _append_history_entry(self, text: str) -> None:
|
|
2591
|
+
safe_history_text = self._get_placeholder_manager().serialize_for_history(text).strip()
|
|
2592
|
+
entry = _HistoryEntry(content=safe_history_text)
|
|
2593
|
+
if not entry.content:
|
|
2594
|
+
return
|
|
2595
|
+
|
|
2596
|
+
# skip if same as last entry
|
|
2597
|
+
if entry.content == self._last_history_content:
|
|
2598
|
+
return
|
|
2599
|
+
|
|
2600
|
+
try:
|
|
2601
|
+
self._history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2602
|
+
with self._history_file.open("a", encoding="utf-8") as f:
|
|
2603
|
+
f.write(entry.model_dump_json(ensure_ascii=False) + "\n")
|
|
2604
|
+
self._last_history_content = entry.content
|
|
2605
|
+
except OSError as exc:
|
|
2606
|
+
logger.warning(
|
|
2607
|
+
"Failed to append user history entry: {file} ({error})",
|
|
2608
|
+
file=self._history_file,
|
|
2609
|
+
error=exc,
|
|
2610
|
+
)
|
|
2611
|
+
|
|
2612
|
+
def _render_bottom_toolbar(self) -> FormattedText:
|
|
2613
|
+
if (
|
|
2614
|
+
hasattr(self, "_session")
|
|
2615
|
+
and self._should_show_slash_completion_menu()
|
|
2616
|
+
and self._session.default_buffer.complete_state is not None
|
|
2617
|
+
):
|
|
2618
|
+
return FormattedText([])
|
|
2619
|
+
app = get_app_or_none()
|
|
2620
|
+
assert app is not None
|
|
2621
|
+
columns = app.output.get_size().columns
|
|
2622
|
+
|
|
2623
|
+
# Pythinker footer dispatch. Mirrors components/footer.ts layout while
|
|
2624
|
+
# reusing the existing data sources so we never lose information vs
|
|
2625
|
+
# the legacy toolbar.
|
|
2626
|
+
from pythinker_code.ui.tui_config import is_card_style
|
|
2627
|
+
|
|
2628
|
+
if is_card_style():
|
|
2629
|
+
return self._render_card_bottom_toolbar(columns)
|
|
2630
|
+
|
|
2631
|
+
fragments: list[tuple[str, str]] = []
|
|
2632
|
+
tc = get_toolbar_colors()
|
|
2633
|
+
|
|
2634
|
+
fragments.append((tc.separator, "─" * columns))
|
|
2635
|
+
fragments.append(("", "\n"))
|
|
2636
|
+
|
|
2637
|
+
remaining = columns
|
|
2638
|
+
|
|
2639
|
+
# Time-based tip rotation (every 30 s, independent of user submissions)
|
|
2640
|
+
now = time.monotonic()
|
|
2641
|
+
if now - self._last_tip_rotate_time >= _TIP_ROTATE_INTERVAL:
|
|
2642
|
+
self._tip_rotation_index += 1
|
|
2643
|
+
self._last_tip_rotate_time = now
|
|
2644
|
+
|
|
2645
|
+
# Status flags: yolo / auto / plan
|
|
2646
|
+
status = self._status_provider()
|
|
2647
|
+
if status.yolo_enabled:
|
|
2648
|
+
fragments.extend([(tc.yolo_label, "yolo"), ("", " ")])
|
|
2649
|
+
remaining -= 6 # "yolo" = 4, " " = 2
|
|
2650
|
+
if status.auto_enabled:
|
|
2651
|
+
fragments.extend([(tc.auto_label, "auto"), ("", " ")])
|
|
2652
|
+
remaining -= 6 # "auto" = 4, " " = 2
|
|
2653
|
+
if status.plan_mode:
|
|
2654
|
+
fragments.extend([(tc.plan_label, "plan"), ("", " ")])
|
|
2655
|
+
remaining -= 6
|
|
2656
|
+
|
|
2657
|
+
# Mode indicator (agent / shell) + model name + thinking indicator.
|
|
2658
|
+
# Degrade gracefully on narrow terminals:
|
|
2659
|
+
# full: "agent (model-name ○)" → mid: "agent ○" → bare: "agent"
|
|
2660
|
+
mode = str(self._mode)
|
|
2661
|
+
if self._mode == PromptMode.AGENT and self._model_name:
|
|
2662
|
+
thinking_dot = "●" if self._thinking else "○"
|
|
2663
|
+
mode_full = f"{mode} ({self._model_name} {thinking_dot})"
|
|
2664
|
+
mode_mid = f"{mode} {thinking_dot}"
|
|
2665
|
+
if _display_width(mode_full) <= remaining - 2:
|
|
2666
|
+
mode = mode_full
|
|
2667
|
+
elif _display_width(mode_mid) <= remaining - 2:
|
|
2668
|
+
mode = mode_mid
|
|
2669
|
+
# else: keep bare mode name — model_name and dot are both dropped
|
|
2670
|
+
fragments.extend([("", mode), ("", " ")])
|
|
2671
|
+
remaining -= _display_width(mode) + 2
|
|
2672
|
+
|
|
2673
|
+
# CWD (truncated from left) + git branch with status badge
|
|
2674
|
+
# Degrade gracefully on narrow terminals: full → cwd-only → truncated cwd → skip
|
|
2675
|
+
try:
|
|
2676
|
+
cwd = _truncate_left(_shorten_cwd(str(HostPath.cwd())), _MAX_CWD_COLS)
|
|
2677
|
+
except OSError:
|
|
2678
|
+
# CWD no longer exists (e.g. external drive unplugged). Ask
|
|
2679
|
+
# prompt_toolkit to exit; the raised exception will propagate out
|
|
2680
|
+
# of prompt_async() into the Shell's event router which prints a
|
|
2681
|
+
# crash report with session info and exits cleanly.
|
|
2682
|
+
app.exit(exception=CwdLostError())
|
|
2683
|
+
return FormattedText([])
|
|
2684
|
+
branch = _get_git_branch()
|
|
2685
|
+
if branch:
|
|
2686
|
+
dirty, ahead, behind = _get_git_status()
|
|
2687
|
+
branch = _truncate_right(branch, _MAX_BRANCH_COLS)
|
|
2688
|
+
badge = _format_git_badge(branch, dirty, ahead, behind)
|
|
2689
|
+
cwd_text = f"{cwd} {badge}"
|
|
2690
|
+
else:
|
|
2691
|
+
cwd_text = cwd
|
|
2692
|
+
cwd_w = _display_width(cwd_text)
|
|
2693
|
+
if cwd_w > remaining - 2:
|
|
2694
|
+
cwd_text = cwd # drop badge
|
|
2695
|
+
cwd_w = _display_width(cwd_text)
|
|
2696
|
+
if cwd_w > remaining - 2:
|
|
2697
|
+
cwd_text = _truncate_right(cwd, max(0, remaining - 2))
|
|
2698
|
+
cwd_w = _display_width(cwd_text)
|
|
2699
|
+
if cwd_text and remaining >= cwd_w + 2:
|
|
2700
|
+
fragments.extend([(tc.cwd, cwd_text), ("", " ")])
|
|
2701
|
+
remaining -= cwd_w + 2
|
|
2702
|
+
|
|
2703
|
+
# Active background task counts (bash + agent, each rendered as its own
|
|
2704
|
+
# badge). Order matters: bash renders first; if there isn't room for the
|
|
2705
|
+
# agent badge too, drop agent and keep bash.
|
|
2706
|
+
bg_counts = (
|
|
2707
|
+
self._background_task_count_provider()
|
|
2708
|
+
if self._background_task_count_provider
|
|
2709
|
+
else BgTaskCounts()
|
|
2710
|
+
)
|
|
2711
|
+
for kind_label, kind_count in (("bash", bg_counts.bash), ("agent", bg_counts.agent)):
|
|
2712
|
+
if kind_count <= 0:
|
|
2713
|
+
continue
|
|
2714
|
+
bg_text = f"◇ {kind_label}: {kind_count}"
|
|
2715
|
+
bg_width = _display_width(bg_text)
|
|
2716
|
+
if remaining < bg_width + 2:
|
|
2717
|
+
break
|
|
2718
|
+
fragments.extend([(tc.bg_tasks, bg_text), ("", " ")])
|
|
2719
|
+
remaining -= bg_width + 2
|
|
2720
|
+
|
|
2721
|
+
# Tips fill remaining space on line 1
|
|
2722
|
+
tip_text = self._get_two_rotating_tips()
|
|
2723
|
+
if tip_text and _display_width(tip_text) > remaining:
|
|
2724
|
+
tip_text = self._get_one_rotating_tip()
|
|
2725
|
+
if tip_text and _display_width(tip_text) <= remaining:
|
|
2726
|
+
_append_footer_hint_fragments(
|
|
2727
|
+
fragments,
|
|
2728
|
+
tip_text,
|
|
2729
|
+
tip_style=tc.tip,
|
|
2730
|
+
key_style=tc.tip_key,
|
|
2731
|
+
)
|
|
2732
|
+
|
|
2733
|
+
# ── line 2: toast (left) + context (right) — always rendered ──────
|
|
2734
|
+
fragments.append(("", "\n"))
|
|
2735
|
+
|
|
2736
|
+
right_text = self._render_right_span(status)
|
|
2737
|
+
right_width = _display_width(right_text)
|
|
2738
|
+
|
|
2739
|
+
left_toast = _current_toast("left")
|
|
2740
|
+
if left_toast is not None:
|
|
2741
|
+
max_left = max(0, columns - right_width - 2)
|
|
2742
|
+
if max_left > 0:
|
|
2743
|
+
left_text = left_toast.message
|
|
2744
|
+
if _display_width(left_text) > max_left:
|
|
2745
|
+
left_text = _truncate_right(left_text, max_left)
|
|
2746
|
+
left_width = _display_width(left_text)
|
|
2747
|
+
fragments.append(("", left_text))
|
|
2748
|
+
else:
|
|
2749
|
+
left_width = 0
|
|
2750
|
+
else:
|
|
2751
|
+
left_width = 0
|
|
2752
|
+
|
|
2753
|
+
fragments.append(("", " " * max(0, columns - left_width - right_width)))
|
|
2754
|
+
fragments.append(("", right_text))
|
|
2755
|
+
|
|
2756
|
+
return FormattedText(fragments)
|
|
2757
|
+
|
|
2758
|
+
def _render_card_bottom_toolbar(self, columns: int) -> FormattedText:
|
|
2759
|
+
"""Pythinker two-line footer.
|
|
2760
|
+
|
|
2761
|
+
Line 1: cwd (home-shortened) + ``(branch)`` + mode/flag chips.
|
|
2762
|
+
Line 2: context% + model on the right; toast/extension statuses left.
|
|
2763
|
+
"""
|
|
2764
|
+
from pythinker_code.extensions import footer_statuses
|
|
2765
|
+
from pythinker_code.ui.shell.components import format_tokens
|
|
2766
|
+
|
|
2767
|
+
fragments: list[tuple[str, str]] = []
|
|
2768
|
+
tc = get_toolbar_colors()
|
|
2769
|
+
|
|
2770
|
+
fragments.append((tc.separator, "─" * columns))
|
|
2771
|
+
fragments.append(("", "\n"))
|
|
2772
|
+
|
|
2773
|
+
# ── line 1: cwd + git + status flags ───────────────────────────────
|
|
2774
|
+
try:
|
|
2775
|
+
cwd_str = _shorten_cwd(str(HostPath.cwd()))
|
|
2776
|
+
except OSError:
|
|
2777
|
+
app = get_app_or_none()
|
|
2778
|
+
if app is not None:
|
|
2779
|
+
app.exit(exception=CwdLostError())
|
|
2780
|
+
return FormattedText([])
|
|
2781
|
+
cwd_text = _truncate_left(cwd_str, _MAX_CWD_COLS)
|
|
2782
|
+
branch = _get_git_branch()
|
|
2783
|
+
if branch:
|
|
2784
|
+
dirty, ahead, behind = _get_git_status()
|
|
2785
|
+
branch_short = _truncate_right(branch, _MAX_BRANCH_COLS)
|
|
2786
|
+
cwd_text = f"{cwd_text} {_format_git_badge(branch_short, dirty, ahead, behind)}"
|
|
2787
|
+
cwd_text = _truncate_right(cwd_text, max(0, columns))
|
|
2788
|
+
fragments.append((tc.cwd, cwd_text))
|
|
2789
|
+
|
|
2790
|
+
status = self._status_provider()
|
|
2791
|
+
flag_chips: list[tuple[str, str]] = []
|
|
2792
|
+
if status.yolo_enabled:
|
|
2793
|
+
flag_chips.append((tc.yolo_label, "yolo"))
|
|
2794
|
+
if status.auto_enabled:
|
|
2795
|
+
flag_chips.append((tc.auto_label, "auto"))
|
|
2796
|
+
if status.plan_mode:
|
|
2797
|
+
flag_chips.append((tc.plan_label, "plan"))
|
|
2798
|
+
for style, label in flag_chips:
|
|
2799
|
+
fragments.append(("", " "))
|
|
2800
|
+
fragments.append((style, label))
|
|
2801
|
+
|
|
2802
|
+
fragments.append(("", "\n"))
|
|
2803
|
+
|
|
2804
|
+
# ── line 2: extension statuses (left) + context% + model (right) ───
|
|
2805
|
+
right_parts: list[str] = []
|
|
2806
|
+
right_parts.append(
|
|
2807
|
+
format_context_status(
|
|
2808
|
+
status.context_usage,
|
|
2809
|
+
status.context_tokens,
|
|
2810
|
+
status.max_context_tokens,
|
|
2811
|
+
)
|
|
2812
|
+
)
|
|
2813
|
+
# Compact ``17k/200k`` glyph next to the percentage when both sides are known.
|
|
2814
|
+
if status.max_context_tokens:
|
|
2815
|
+
ctx_compact = (
|
|
2816
|
+
f"{format_tokens(status.context_tokens)}/{format_tokens(status.max_context_tokens)}"
|
|
2817
|
+
)
|
|
2818
|
+
right_parts.append(ctx_compact)
|
|
2819
|
+
if self._model_name:
|
|
2820
|
+
thinking_dot = "●" if self._thinking else "○"
|
|
2821
|
+
mode = str(self._mode)
|
|
2822
|
+
right_parts.append(f"{mode} {self._model_name} {thinking_dot}")
|
|
2823
|
+
right_text = " ".join(right_parts)
|
|
2824
|
+
right_width = _display_width(right_text)
|
|
2825
|
+
|
|
2826
|
+
# Left side: prefer extension statuses, then active background work,
|
|
2827
|
+
# then any active toast. The background-work copy mirrors Codex's
|
|
2828
|
+
# compact footer summary while keeping Pythinker's single /task command.
|
|
2829
|
+
max_left_width = max(0, columns - right_width - 2)
|
|
2830
|
+
ext = footer_statuses()
|
|
2831
|
+
if ext:
|
|
2832
|
+
ordered = sorted(ext.items())
|
|
2833
|
+
ext_line = " ".join(f"{k}:{v}" for k, v in ordered)
|
|
2834
|
+
ext_line = _truncate_right(ext_line, max_left_width)
|
|
2835
|
+
fragments.append((tc.tip, ext_line))
|
|
2836
|
+
left_width = _display_width(ext_line)
|
|
2837
|
+
elif (
|
|
2838
|
+
bg_summary := _background_task_summary(
|
|
2839
|
+
self._background_task_count_provider()
|
|
2840
|
+
if self._background_task_count_provider
|
|
2841
|
+
else BgTaskCounts()
|
|
2842
|
+
)
|
|
2843
|
+
) is not None:
|
|
2844
|
+
bg_summary = _truncate_right(bg_summary, max_left_width)
|
|
2845
|
+
fragments.append((tc.bg_tasks, bg_summary))
|
|
2846
|
+
left_width = _display_width(bg_summary)
|
|
2847
|
+
else:
|
|
2848
|
+
left_toast = _current_toast("left")
|
|
2849
|
+
if left_toast is not None:
|
|
2850
|
+
left_text = left_toast.message
|
|
2851
|
+
left_text = _truncate_right(left_text, max_left_width)
|
|
2852
|
+
fragments.append(("", left_text))
|
|
2853
|
+
left_width = _display_width(left_text)
|
|
2854
|
+
else:
|
|
2855
|
+
left_width = 0
|
|
2856
|
+
|
|
2857
|
+
fragments.append(("", " " * max(0, columns - left_width - right_width)))
|
|
2858
|
+
fragments.append(("", right_text))
|
|
2859
|
+
return FormattedText(fragments)
|
|
2860
|
+
|
|
2861
|
+
def _get_two_rotating_tips(self) -> str | None:
|
|
2862
|
+
"""Return a string with exactly 2 tips from the rotation, or fewer if not enough."""
|
|
2863
|
+
n = len(self._tips)
|
|
2864
|
+
if n == 0:
|
|
2865
|
+
return None
|
|
2866
|
+
if n == 1:
|
|
2867
|
+
return self._tips[0]
|
|
2868
|
+
offset = self._tip_rotation_index % n
|
|
2869
|
+
tip1 = self._tips[offset]
|
|
2870
|
+
tip2 = self._tips[(offset + 1) % n]
|
|
2871
|
+
return f"{tip1}{_TIP_SEPARATOR}{tip2}"
|
|
2872
|
+
|
|
2873
|
+
def _get_one_rotating_tip(self) -> str | None:
|
|
2874
|
+
"""Return the single leading tip for the current rotation."""
|
|
2875
|
+
if not self._tips:
|
|
2876
|
+
return None
|
|
2877
|
+
return self._tips[self._tip_rotation_index % len(self._tips)]
|
|
2878
|
+
|
|
2879
|
+
@staticmethod
|
|
2880
|
+
def _render_right_span(status: StatusSnapshot) -> str:
|
|
2881
|
+
current_toast = _current_toast("right")
|
|
2882
|
+
if current_toast is None:
|
|
2883
|
+
return format_context_status(
|
|
2884
|
+
status.context_usage,
|
|
2885
|
+
status.context_tokens,
|
|
2886
|
+
status.max_context_tokens,
|
|
2887
|
+
)
|
|
2888
|
+
return current_toast.message
|