pythinker-code 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythinker_code/CHANGELOG.md +16 -0
- pythinker_code/__init__.py +0 -0
- pythinker_code/__main__.py +92 -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 +298 -0
- pythinker_code/acp/mcp.py +46 -0
- pythinker_code/acp/server.py +497 -0
- pythinker_code/acp/session.py +496 -0
- pythinker_code/acp/tools.py +167 -0
- pythinker_code/acp/types.py +13 -0
- pythinker_code/acp/version.py +45 -0
- pythinker_code/agents/default/agent.yaml +36 -0
- pythinker_code/agents/default/coder.yaml +25 -0
- pythinker_code/agents/default/explore.yaml +46 -0
- pythinker_code/agents/default/plan.yaml +30 -0
- pythinker_code/agents/default/system.md +164 -0
- pythinker_code/agents/okabe/agent.yaml +22 -0
- pythinker_code/agentspec.py +163 -0
- pythinker_code/app.py +820 -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/lm_studio.py +418 -0
- pythinker_code/auth/minimax.py +203 -0
- pythinker_code/auth/oauth.py +1122 -0
- pythinker_code/auth/ollama.py +293 -0
- pythinker_code/auth/openai.py +771 -0
- pythinker_code/auth/opencode_go.py +203 -0
- pythinker_code/auth/openrouter.py +225 -0
- pythinker_code/auth/platforms.py +466 -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 +650 -0
- pythinker_code/background/models.py +105 -0
- pythinker_code/background/store.py +237 -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 +238 -0
- pythinker_code/cli/export.py +322 -0
- pythinker_code/cli/info.py +62 -0
- pythinker_code/cli/mcp.py +349 -0
- pythinker_code/cli/plugin.py +351 -0
- pythinker_code/cli/toad.py +74 -0
- pythinker_code/cli/vis.py +38 -0
- pythinker_code/cli/web.py +80 -0
- pythinker_code/config.py +453 -0
- pythinker_code/constant.py +33 -0
- pythinker_code/exception.py +43 -0
- pythinker_code/hooks/__init__.py +4 -0
- pythinker_code/hooks/config.py +34 -0
- pythinker_code/hooks/engine.py +371 -0
- pythinker_code/hooks/events.py +190 -0
- pythinker_code/hooks/runner.py +89 -0
- pythinker_code/llm.py +412 -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 +153 -0
- pythinker_code/plugin/tool.py +173 -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 +520 -0
- pythinker_code/soul/approval.py +267 -0
- pythinker_code/soul/btw.py +214 -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/pythinkersoul.py +1613 -0
- pythinker_code/soul/slash.py +340 -0
- pythinker_code/soul/toolset.py +788 -0
- pythinker_code/subagents/__init__.py +21 -0
- pythinker_code/subagents/builder.py +42 -0
- pythinker_code/subagents/core.py +86 -0
- pythinker_code/subagents/git_context.py +172 -0
- pythinker_code/subagents/models.py +54 -0
- pythinker_code/subagents/output.py +71 -0
- pythinker_code/subagents/registry.py +28 -0
- pythinker_code/subagents/runner.py +428 -0
- pythinker_code/subagents/store.py +196 -0
- pythinker_code/telemetry/__init__.py +211 -0
- pythinker_code/telemetry/config.py +54 -0
- pythinker_code/telemetry/crash.py +157 -0
- pythinker_code/telemetry/metrics.py +208 -0
- pythinker_code/telemetry/otel.py +240 -0
- pythinker_code/telemetry/sentry.py +167 -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 +277 -0
- pythinker_code/tools/agent/description.md +41 -0
- pythinker_code/tools/ask_user/__init__.py +159 -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 +30 -0
- pythinker_code/tools/file/glob.md +17 -0
- pythinker_code/tools/file/glob.py +160 -0
- pythinker_code/tools/file/grep.md +6 -0
- pythinker_code/tools/file/grep_local.py +589 -0
- pythinker_code/tools/file/plan_mode.py +45 -0
- pythinker_code/tools/file/read.md +16 -0
- pythinker_code/tools/file/read.py +300 -0
- pythinker_code/tools/file/read_media.md +24 -0
- pythinker_code/tools/file/read_media.py +217 -0
- pythinker_code/tools/file/replace.md +7 -0
- pythinker_code/tools/file/replace.py +195 -0
- pythinker_code/tools/file/utils.py +257 -0
- pythinker_code/tools/file/write.md +5 -0
- pythinker_code/tools/file/write.py +177 -0
- pythinker_code/tools/plan/__init__.py +327 -0
- pythinker_code/tools/plan/description.md +29 -0
- pythinker_code/tools/plan/enter.py +190 -0
- pythinker_code/tools/plan/enter_description.md +35 -0
- pythinker_code/tools/plan/heroes.py +277 -0
- pythinker_code/tools/shell/__init__.py +253 -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 +199 -0
- pythinker_code/tools/web/__init__.py +4 -0
- pythinker_code/tools/web/fetch.md +1 -0
- pythinker_code/tools/web/fetch.py +189 -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 +1696 -0
- pythinker_code/ui/shell/console.py +109 -0
- pythinker_code/ui/shell/debug.py +190 -0
- pythinker_code/ui/shell/echo.py +17 -0
- pythinker_code/ui/shell/export_import.py +117 -0
- pythinker_code/ui/shell/keyboard.py +300 -0
- pythinker_code/ui/shell/mcp_status.py +113 -0
- pythinker_code/ui/shell/model_picker.py +318 -0
- pythinker_code/ui/shell/oauth.py +272 -0
- pythinker_code/ui/shell/placeholders.py +531 -0
- pythinker_code/ui/shell/prompt.py +2278 -0
- pythinker_code/ui/shell/replay.py +215 -0
- pythinker_code/ui/shell/session_picker.py +227 -0
- pythinker_code/ui/shell/setup.py +212 -0
- pythinker_code/ui/shell/slash.py +898 -0
- pythinker_code/ui/shell/startup.py +32 -0
- pythinker_code/ui/shell/task_browser.py +486 -0
- pythinker_code/ui/shell/update.py +350 -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 +505 -0
- pythinker_code/ui/shell/visualize/_blocks.py +629 -0
- pythinker_code/ui/shell/visualize/_btw_panel.py +224 -0
- pythinker_code/ui/shell/visualize/_input_router.py +48 -0
- pythinker_code/ui/shell/visualize/_interactive.py +523 -0
- pythinker_code/ui/shell/visualize/_live_view.py +826 -0
- pythinker_code/ui/shell/visualize/_question_panel.py +586 -0
- pythinker_code/ui/theme.py +241 -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 +37 -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 +900 -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 +73 -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 +687 -0
- pythinker_code/vis/api/statistics.py +209 -0
- pythinker_code/vis/api/system.py +19 -0
- pythinker_code/vis/app.py +175 -0
- pythinker_code/vis/static/assets/highlighted-body-B3W2YXNL-D2MTYyJz.js +1 -0
- pythinker_code/vis/static/assets/index-CezafTt_.js +185 -0
- pythinker_code/vis/static/assets/index-DSRInNnm.css +1 -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 +208 -0
- pythinker_code/web/api/open_in.py +199 -0
- pythinker_code/web/api/sessions.py +1225 -0
- pythinker_code/web/app.py +451 -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--dyU3g5v.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-DkMjLpYa.js +1 -0
- pythinker_code/web/static/assets/architectureDiagram-VXUJARFQ-CHWVaGo9.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-DzdKe497.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-D84Blotg.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-CllSjjdl.js +1 -0
- pythinker_code/web/static/assets/chunk-4BX2VUAB-C9w8wleE.js +1 -0
- pythinker_code/web/static/assets/chunk-55IACEB6-YlYJ8HnF.js +1 -0
- pythinker_code/web/static/assets/chunk-B4BG7PRW-Bwtz_AHU.js +165 -0
- pythinker_code/web/static/assets/chunk-DI55MBZ5-BbEHkl8h.js +220 -0
- pythinker_code/web/static/assets/chunk-FMBD7UC4-BKPbvjLC.js +15 -0
- pythinker_code/web/static/assets/chunk-QN33PNHL-D73dQvKf.js +1 -0
- pythinker_code/web/static/assets/chunk-QZHKN3VN-zGiLKes_.js +1 -0
- pythinker_code/web/static/assets/chunk-TZMSLE5B-LHJCi2fy.js +1 -0
- pythinker_code/web/static/assets/clarity-D53aC0YG.js +1 -0
- pythinker_code/web/static/assets/classDiagram-2ON5EDUG-vX27iZwa.js +1 -0
- pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-vX27iZwa.js +1 -0
- pythinker_code/web/static/assets/clojure-P80f7IUj.js +1 -0
- pythinker_code/web/static/assets/clone-DYBkaPm2.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-NtKViZGl.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-DialjZpd.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-C_Fzpdck.js +321 -0
- pythinker_code/web/static/assets/d-85-TOEBH.js +1 -0
- pythinker_code/web/static/assets/dagre-6UL2VRFP-DfuvkZZ7.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-DLGctX3v.js +24 -0
- pythinker_code/web/static/assets/diagram-QEK2KX5R-DnxN6S0F.js +43 -0
- pythinker_code/web/static/assets/diagram-S2PKOQOG-Caq_Set9.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-BgTfALoK.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-QjW_fnGi.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-fqi8JFof.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-i7o6VQ8x.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-C0vZK2pT.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-BYCCk6-K.js +153 -0
- pythinker_code/web/static/assets/index-BpoLgcEt.js +1 -0
- pythinker_code/web/static/assets/index-Cpy4G3uJ.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/index-DdIkp80K.js +5 -0
- pythinker_code/web/static/assets/infoDiagram-WHAUD3N6-BMPpeZSM.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-DAM7gngo.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-ChpBpV0k.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-C3Jp1gKO.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-BGHtL1N4.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-C_HW6koB.js +465 -0
- pythinker_code/web/static/assets/mermaid-mWjccvbQ.js +1 -0
- pythinker_code/web/static/assets/mermaid.core-CnT4VrPC.js +191 -0
- pythinker_code/web/static/assets/min-Dn5VRVmX.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-x8EwhfIt.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-DgxBKGz2.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-DSpe_dqk.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-8o9hozL-.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-BLOSUixH.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-6F2G8JTU.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-DP8xP0iJ.js +1 -0
- pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-1l6-EZNX.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-DMgruDfK.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-Bo9ZkrAK.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-GeF2johi.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 +432 -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 +1069 -0
- pythinker_code/wire/types.py +698 -0
- pythinker_code-2.0.0.dist-info/METADATA +660 -0
- pythinker_code-2.0.0.dist-info/RECORD +731 -0
- pythinker_code-2.0.0.dist-info/WHEEL +4 -0
- pythinker_code-2.0.0.dist-info/entry_points.txt +4 -0
- pythinker_code-2.0.0.dist-info/licenses/LICENSE +202 -0
- pythinker_code-2.0.0.dist-info/licenses/NOTICE +14 -0
|
@@ -0,0 +1,2278 @@
|
|
|
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 time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from hashlib import md5
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Literal, Protocol, cast, override, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from prompt_toolkit import PromptSession
|
|
21
|
+
from prompt_toolkit.application.current import get_app_or_none
|
|
22
|
+
from prompt_toolkit.buffer import Buffer
|
|
23
|
+
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
|
|
24
|
+
from prompt_toolkit.completion import (
|
|
25
|
+
CompleteEvent,
|
|
26
|
+
Completer,
|
|
27
|
+
Completion,
|
|
28
|
+
FuzzyCompleter,
|
|
29
|
+
WordCompleter,
|
|
30
|
+
merge_completers,
|
|
31
|
+
)
|
|
32
|
+
from prompt_toolkit.data_structures import Point
|
|
33
|
+
from prompt_toolkit.document import Document
|
|
34
|
+
from prompt_toolkit.filters import Condition, has_completions, has_focus, is_done
|
|
35
|
+
from prompt_toolkit.formatted_text import AnyFormattedText, FormattedText, to_formatted_text
|
|
36
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
37
|
+
from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
|
|
38
|
+
from prompt_toolkit.keys import Keys
|
|
39
|
+
from prompt_toolkit.layout.containers import (
|
|
40
|
+
ConditionalContainer,
|
|
41
|
+
DynamicContainer,
|
|
42
|
+
Float,
|
|
43
|
+
FloatContainer,
|
|
44
|
+
HSplit,
|
|
45
|
+
Window,
|
|
46
|
+
)
|
|
47
|
+
from prompt_toolkit.layout.controls import BufferControl, UIContent, UIControl
|
|
48
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
49
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
50
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
51
|
+
from prompt_toolkit.utils import get_cwidth
|
|
52
|
+
from pydantic import BaseModel, ValidationError
|
|
53
|
+
from pythinker_host.path import HostPath
|
|
54
|
+
|
|
55
|
+
from pythinker_code.llm import ModelCapability
|
|
56
|
+
from pythinker_code.share import get_share_dir
|
|
57
|
+
from pythinker_code.soul import StatusSnapshot, format_context_status
|
|
58
|
+
from pythinker_code.ui.shell import placeholders as prompt_placeholders
|
|
59
|
+
from pythinker_code.ui.shell.console import console
|
|
60
|
+
from pythinker_code.ui.shell.placeholders import (
|
|
61
|
+
PromptPlaceholderManager,
|
|
62
|
+
normalize_pasted_text,
|
|
63
|
+
sanitize_surrogates,
|
|
64
|
+
)
|
|
65
|
+
from pythinker_code.ui.theme import get_prompt_style, get_toolbar_colors
|
|
66
|
+
from pythinker_code.utils.clipboard import (
|
|
67
|
+
grab_media_from_clipboard,
|
|
68
|
+
is_clipboard_available,
|
|
69
|
+
is_media_clipboard_available,
|
|
70
|
+
)
|
|
71
|
+
from pythinker_code.utils.logging import logger
|
|
72
|
+
from pythinker_code.utils.slashcmd import SlashCommand
|
|
73
|
+
from pythinker_code.wire.types import ContentPart
|
|
74
|
+
|
|
75
|
+
AttachmentCache = prompt_placeholders.AttachmentCache
|
|
76
|
+
CachedAttachment = prompt_placeholders.CachedAttachment
|
|
77
|
+
_parse_attachment_kind = prompt_placeholders.parse_attachment_kind
|
|
78
|
+
|
|
79
|
+
PROMPT_SYMBOL = "✨"
|
|
80
|
+
PROMPT_SYMBOL_SHELL = "$"
|
|
81
|
+
PROMPT_SYMBOL_THINKING = "💫"
|
|
82
|
+
PROMPT_SYMBOL_PLAN = "📋"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CwdLostError(OSError):
|
|
86
|
+
"""Raised when the working directory no longer exists (e.g. external drive unplugged)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SlashCommandCompleter(Completer):
|
|
90
|
+
"""
|
|
91
|
+
A completer that:
|
|
92
|
+
- Shows one line per slash command using the canonical "/name"
|
|
93
|
+
- Fuzzy-matches by primary name or any alias while inserting the canonical "/name"
|
|
94
|
+
- Only activates when the current token starts with '/'
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, available_commands: Sequence[SlashCommand[Any]]) -> None:
|
|
98
|
+
super().__init__()
|
|
99
|
+
self._available_commands = list(available_commands)
|
|
100
|
+
self._command_lookup: dict[str, list[SlashCommand[Any]]] = {}
|
|
101
|
+
words: list[str] = []
|
|
102
|
+
|
|
103
|
+
for cmd in sorted(self._available_commands, key=lambda c: c.name):
|
|
104
|
+
if cmd.name not in self._command_lookup:
|
|
105
|
+
self._command_lookup[cmd.name] = []
|
|
106
|
+
words.append(cmd.name)
|
|
107
|
+
self._command_lookup[cmd.name].append(cmd)
|
|
108
|
+
for alias in cmd.aliases:
|
|
109
|
+
if alias in self._command_lookup:
|
|
110
|
+
self._command_lookup[alias].append(cmd)
|
|
111
|
+
else:
|
|
112
|
+
self._command_lookup[alias] = [cmd]
|
|
113
|
+
words.append(alias)
|
|
114
|
+
|
|
115
|
+
self._word_pattern = re.compile(r"[^\s]+")
|
|
116
|
+
self._fuzzy_pattern = r"^[^\s]*"
|
|
117
|
+
self._word_completer = WordCompleter(words, WORD=False, pattern=self._word_pattern)
|
|
118
|
+
self._fuzzy = FuzzyCompleter(self._word_completer, WORD=False, pattern=self._fuzzy_pattern)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def should_complete(document: Document) -> bool:
|
|
122
|
+
"""Return whether slash command completion should be active for the current buffer."""
|
|
123
|
+
text = document.text_before_cursor
|
|
124
|
+
|
|
125
|
+
if document.text_after_cursor.strip():
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
last_space = text.rfind(" ")
|
|
129
|
+
token = text[last_space + 1 :]
|
|
130
|
+
prefix = text[: last_space + 1] if last_space != -1 else ""
|
|
131
|
+
|
|
132
|
+
return not prefix.strip() and token.startswith("/")
|
|
133
|
+
|
|
134
|
+
@override
|
|
135
|
+
def get_completions(
|
|
136
|
+
self, document: Document, complete_event: CompleteEvent
|
|
137
|
+
) -> Iterable[Completion]:
|
|
138
|
+
if not self.should_complete(document):
|
|
139
|
+
return
|
|
140
|
+
text = document.text_before_cursor
|
|
141
|
+
last_space = text.rfind(" ")
|
|
142
|
+
token = text[last_space + 1 :]
|
|
143
|
+
|
|
144
|
+
typed = token[1:]
|
|
145
|
+
if typed and typed in self._command_lookup:
|
|
146
|
+
return
|
|
147
|
+
mention_doc = Document(text=typed, cursor_position=len(typed))
|
|
148
|
+
candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))
|
|
149
|
+
|
|
150
|
+
seen: set[str] = set()
|
|
151
|
+
|
|
152
|
+
for candidate in candidates:
|
|
153
|
+
commands = self._command_lookup.get(candidate.text)
|
|
154
|
+
if not commands:
|
|
155
|
+
continue
|
|
156
|
+
for cmd in commands:
|
|
157
|
+
if cmd.name in seen:
|
|
158
|
+
continue
|
|
159
|
+
seen.add(cmd.name)
|
|
160
|
+
yield Completion(
|
|
161
|
+
text=f"/{cmd.name}",
|
|
162
|
+
start_position=-len(token),
|
|
163
|
+
display=f"/{cmd.name}",
|
|
164
|
+
display_meta=cmd.description,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _truncate_to_width(text: str, width: int) -> str:
|
|
169
|
+
if width <= 0:
|
|
170
|
+
return ""
|
|
171
|
+
|
|
172
|
+
total = 0
|
|
173
|
+
chars: list[str] = []
|
|
174
|
+
for ch in text:
|
|
175
|
+
ch_width = get_cwidth(ch)
|
|
176
|
+
if total + ch_width > width:
|
|
177
|
+
break
|
|
178
|
+
chars.append(ch)
|
|
179
|
+
total += ch_width
|
|
180
|
+
|
|
181
|
+
if total == get_cwidth(text):
|
|
182
|
+
return text + (" " * max(0, width - total))
|
|
183
|
+
|
|
184
|
+
ellipsis = "..."
|
|
185
|
+
ellipsis_width = get_cwidth(ellipsis)
|
|
186
|
+
if width <= ellipsis_width:
|
|
187
|
+
return "." * width
|
|
188
|
+
|
|
189
|
+
available = width - ellipsis_width
|
|
190
|
+
total = 0
|
|
191
|
+
chars = []
|
|
192
|
+
for ch in text:
|
|
193
|
+
ch_width = get_cwidth(ch)
|
|
194
|
+
if total + ch_width > available:
|
|
195
|
+
break
|
|
196
|
+
chars.append(ch)
|
|
197
|
+
total += ch_width
|
|
198
|
+
return "".join(chars) + ellipsis + (" " * max(0, width - total - ellipsis_width))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _wrap_to_width(text: str, width: int, *, max_lines: int | None = None) -> list[str]:
|
|
202
|
+
if width <= 0:
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
words = text.split()
|
|
206
|
+
if not words:
|
|
207
|
+
return [""]
|
|
208
|
+
|
|
209
|
+
lines: list[str] = []
|
|
210
|
+
current_words: list[str] = []
|
|
211
|
+
current_width = 0
|
|
212
|
+
index = 0
|
|
213
|
+
|
|
214
|
+
while index < len(words):
|
|
215
|
+
word = words[index]
|
|
216
|
+
word_width = get_cwidth(word)
|
|
217
|
+
separator_width = 1 if current_words else 0
|
|
218
|
+
|
|
219
|
+
if current_words and current_width + separator_width + word_width <= width:
|
|
220
|
+
current_words.append(word)
|
|
221
|
+
current_width += separator_width + word_width
|
|
222
|
+
index += 1
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
if not current_words and word_width <= width:
|
|
226
|
+
current_words.append(word)
|
|
227
|
+
current_width = word_width
|
|
228
|
+
index += 1
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
if not current_words and word_width > width:
|
|
232
|
+
current_words.append(_truncate_to_width(word, width).rstrip())
|
|
233
|
+
current_width = get_cwidth(current_words[0])
|
|
234
|
+
index += 1
|
|
235
|
+
|
|
236
|
+
lines.append(" ".join(current_words))
|
|
237
|
+
current_words = []
|
|
238
|
+
current_width = 0
|
|
239
|
+
|
|
240
|
+
if max_lines is not None and len(lines) == max_lines:
|
|
241
|
+
remaining = " ".join(words[index:])
|
|
242
|
+
if remaining:
|
|
243
|
+
prefix = f"{lines[-1]} " if lines[-1] else ""
|
|
244
|
+
lines[-1] = _truncate_to_width(prefix + remaining, width).rstrip()
|
|
245
|
+
return lines
|
|
246
|
+
|
|
247
|
+
if current_words:
|
|
248
|
+
line = " ".join(current_words)
|
|
249
|
+
if max_lines is not None and len(lines) + 1 > max_lines:
|
|
250
|
+
if lines:
|
|
251
|
+
lines[-1] = _truncate_to_width(f"{lines[-1]} {line}", width).rstrip()
|
|
252
|
+
else:
|
|
253
|
+
lines.append(_truncate_to_width(line, width).rstrip())
|
|
254
|
+
else:
|
|
255
|
+
lines.append(line)
|
|
256
|
+
|
|
257
|
+
return lines
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _find_prompt_float_container(layout_container: object) -> FloatContainer | None:
|
|
261
|
+
if not isinstance(layout_container, HSplit):
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
for child in cast(Sequence[object], layout_container.children):
|
|
265
|
+
float_container = _extract_float_container(child)
|
|
266
|
+
if float_container is not None:
|
|
267
|
+
return float_container
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _extract_float_container(container: object) -> FloatContainer | None:
|
|
272
|
+
if isinstance(container, FloatContainer):
|
|
273
|
+
return container
|
|
274
|
+
if isinstance(container, ConditionalContainer):
|
|
275
|
+
if isinstance(container.content, FloatContainer):
|
|
276
|
+
return container.content
|
|
277
|
+
if isinstance(container.alternative_content, FloatContainer):
|
|
278
|
+
return container.alternative_content
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _find_default_buffer_container(
|
|
283
|
+
layout_container: object,
|
|
284
|
+
target_buffer: Buffer,
|
|
285
|
+
) -> ConditionalContainer | None:
|
|
286
|
+
seen: set[int] = set()
|
|
287
|
+
|
|
288
|
+
def _walk(node: object) -> ConditionalContainer | None:
|
|
289
|
+
if id(node) in seen:
|
|
290
|
+
return None
|
|
291
|
+
seen.add(id(node))
|
|
292
|
+
|
|
293
|
+
if isinstance(node, ConditionalContainer):
|
|
294
|
+
content = getattr(node, "content", None)
|
|
295
|
+
if isinstance(content, Window):
|
|
296
|
+
control = content.content
|
|
297
|
+
if isinstance(control, BufferControl) and control.buffer is target_buffer:
|
|
298
|
+
return node
|
|
299
|
+
|
|
300
|
+
if isinstance(node, DynamicContainer):
|
|
301
|
+
with contextlib.suppress(Exception):
|
|
302
|
+
found = _walk(node.get_container())
|
|
303
|
+
if found is not None:
|
|
304
|
+
return found
|
|
305
|
+
|
|
306
|
+
for attr in ("children", "content", "floats", "container"):
|
|
307
|
+
if not hasattr(node, attr):
|
|
308
|
+
continue
|
|
309
|
+
value = getattr(node, attr)
|
|
310
|
+
if attr == "children" and isinstance(value, Sequence):
|
|
311
|
+
for child in value: # pyright: ignore[reportUnknownVariableType]
|
|
312
|
+
found = _walk(child) # pyright: ignore[reportUnknownArgumentType]
|
|
313
|
+
if found is not None:
|
|
314
|
+
return found
|
|
315
|
+
elif attr == "floats" and isinstance(value, Sequence):
|
|
316
|
+
for float_ in value: # pyright: ignore[reportUnknownVariableType]
|
|
317
|
+
content = getattr(float_, "content", None) # pyright: ignore[reportUnknownArgumentType]
|
|
318
|
+
if content is None:
|
|
319
|
+
continue
|
|
320
|
+
found = _walk(content)
|
|
321
|
+
if found is not None:
|
|
322
|
+
return found
|
|
323
|
+
elif (
|
|
324
|
+
attr in {"content", "container"}
|
|
325
|
+
and value is not None
|
|
326
|
+
and type(value).__module__.startswith("prompt_toolkit")
|
|
327
|
+
):
|
|
328
|
+
found = _walk(value)
|
|
329
|
+
if found is not None:
|
|
330
|
+
return found
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
return _walk(layout_container)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class SlashCommandMenuControl(UIControl):
|
|
337
|
+
"""Render slash command completions as a full-width menu that matches the shell UI."""
|
|
338
|
+
|
|
339
|
+
_MAX_EXPANDED_META_LINES = 3
|
|
340
|
+
|
|
341
|
+
def __init__(
|
|
342
|
+
self,
|
|
343
|
+
*,
|
|
344
|
+
left_padding: Callable[[], int],
|
|
345
|
+
scroll_offset: int = 1,
|
|
346
|
+
) -> None:
|
|
347
|
+
self._left_padding = left_padding
|
|
348
|
+
self._scroll_offset = scroll_offset
|
|
349
|
+
|
|
350
|
+
def has_focus(self) -> bool:
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
def preferred_width(self, max_available_width: int) -> int | None:
|
|
354
|
+
return max_available_width
|
|
355
|
+
|
|
356
|
+
def preferred_height(
|
|
357
|
+
self,
|
|
358
|
+
width: int,
|
|
359
|
+
max_available_height: int,
|
|
360
|
+
wrap_lines: bool,
|
|
361
|
+
get_line_prefix: Callable[..., AnyFormattedText] | None,
|
|
362
|
+
) -> int | None:
|
|
363
|
+
app = get_app_or_none()
|
|
364
|
+
complete_state = (
|
|
365
|
+
getattr(app.current_buffer, "complete_state", None) if app is not None else None
|
|
366
|
+
)
|
|
367
|
+
if complete_state is None:
|
|
368
|
+
return 0
|
|
369
|
+
completions = complete_state.completions
|
|
370
|
+
selected_index = complete_state.complete_index
|
|
371
|
+
if selected_index is None:
|
|
372
|
+
return min(max_available_height, len(completions) + 1)
|
|
373
|
+
menu_width = max(0, width - self._left_padding())
|
|
374
|
+
marker_width = 2
|
|
375
|
+
command_width = self._command_column_width(completions, menu_width, marker_width)
|
|
376
|
+
gap_width = 3 if menu_width > command_width + 6 else 1
|
|
377
|
+
meta_width = max(0, menu_width - marker_width - command_width - gap_width)
|
|
378
|
+
selected_meta_lines = self._selected_meta_lines(
|
|
379
|
+
completions[selected_index].display_meta_text,
|
|
380
|
+
meta_width,
|
|
381
|
+
)
|
|
382
|
+
return min(max_available_height, len(completions) + len(selected_meta_lines))
|
|
383
|
+
|
|
384
|
+
def create_content(self, width: int, height: int) -> UIContent:
|
|
385
|
+
app = get_app_or_none()
|
|
386
|
+
complete_state = (
|
|
387
|
+
getattr(app.current_buffer, "complete_state", None) if app is not None else None
|
|
388
|
+
)
|
|
389
|
+
if complete_state is None or not complete_state.completions:
|
|
390
|
+
return UIContent()
|
|
391
|
+
|
|
392
|
+
completions = complete_state.completions
|
|
393
|
+
selected_index = complete_state.complete_index
|
|
394
|
+
available_rows = max(1, height - 1)
|
|
395
|
+
|
|
396
|
+
menu_width = max(0, width - self._left_padding())
|
|
397
|
+
marker_width = 2
|
|
398
|
+
command_width = self._command_column_width(completions, menu_width, marker_width)
|
|
399
|
+
gap_width = 3 if menu_width > command_width + 6 else 1
|
|
400
|
+
meta_width = max(0, menu_width - marker_width - command_width - gap_width)
|
|
401
|
+
|
|
402
|
+
rendered_lines: list[FormattedText] = [
|
|
403
|
+
FormattedText([("class:slash-completion-menu.separator", "─" * max(0, width))])
|
|
404
|
+
]
|
|
405
|
+
selected_line_index = 0
|
|
406
|
+
|
|
407
|
+
if selected_index is None:
|
|
408
|
+
# Pre-highlight index 0 even before the user navigates: pressing
|
|
409
|
+
# Enter accepts the first completion, so the visual state should
|
|
410
|
+
# match that behavior. Without this the menu looks ambiguous (no
|
|
411
|
+
# row highlighted) but Enter still commits the top row.
|
|
412
|
+
end = min(len(completions) - 1, available_rows - 1)
|
|
413
|
+
for index in range(0, end + 1):
|
|
414
|
+
rendered_lines.append(
|
|
415
|
+
self._render_single_line_item(
|
|
416
|
+
width=width,
|
|
417
|
+
completion=completions[index],
|
|
418
|
+
marker_width=marker_width,
|
|
419
|
+
command_width=command_width,
|
|
420
|
+
meta_width=meta_width,
|
|
421
|
+
gap_width=gap_width,
|
|
422
|
+
is_current=index == 0,
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
return UIContent(
|
|
427
|
+
get_line=lambda i: rendered_lines[i],
|
|
428
|
+
line_count=len(rendered_lines),
|
|
429
|
+
cursor_position=Point(x=0, y=1 if completions else 0),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
selected_meta_lines = self._selected_meta_lines(
|
|
433
|
+
completions[selected_index].display_meta_text,
|
|
434
|
+
meta_width,
|
|
435
|
+
)
|
|
436
|
+
start, end = self._visible_window_bounds(
|
|
437
|
+
completion_count=len(completions),
|
|
438
|
+
selected_index=selected_index,
|
|
439
|
+
available_rows=available_rows,
|
|
440
|
+
selected_item_height=len(selected_meta_lines),
|
|
441
|
+
)
|
|
442
|
+
selected_line_index = 1
|
|
443
|
+
|
|
444
|
+
for index in range(start, end + 1):
|
|
445
|
+
completion = completions[index]
|
|
446
|
+
if index == selected_index:
|
|
447
|
+
selected_line_index = len(rendered_lines)
|
|
448
|
+
rendered_lines.extend(
|
|
449
|
+
self._render_selected_item_lines(
|
|
450
|
+
width=width,
|
|
451
|
+
completion=completion,
|
|
452
|
+
marker_width=marker_width,
|
|
453
|
+
command_width=command_width,
|
|
454
|
+
meta_width=meta_width,
|
|
455
|
+
gap_width=gap_width,
|
|
456
|
+
meta_lines=selected_meta_lines,
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
rendered_lines.append(
|
|
462
|
+
self._render_single_line_item(
|
|
463
|
+
width=width,
|
|
464
|
+
completion=completion,
|
|
465
|
+
marker_width=marker_width,
|
|
466
|
+
command_width=command_width,
|
|
467
|
+
meta_width=meta_width,
|
|
468
|
+
gap_width=gap_width,
|
|
469
|
+
is_current=False,
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
return UIContent(
|
|
474
|
+
get_line=lambda i: rendered_lines[i],
|
|
475
|
+
line_count=len(rendered_lines),
|
|
476
|
+
cursor_position=Point(x=0, y=selected_line_index),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
def _selected_meta_lines(self, text: str, meta_width: int) -> list[str]:
|
|
480
|
+
lines = _wrap_to_width(
|
|
481
|
+
text,
|
|
482
|
+
meta_width,
|
|
483
|
+
max_lines=self._MAX_EXPANDED_META_LINES,
|
|
484
|
+
)
|
|
485
|
+
return lines or [""]
|
|
486
|
+
|
|
487
|
+
def _visible_window_bounds(
|
|
488
|
+
self,
|
|
489
|
+
*,
|
|
490
|
+
completion_count: int,
|
|
491
|
+
selected_index: int,
|
|
492
|
+
available_rows: int,
|
|
493
|
+
selected_item_height: int,
|
|
494
|
+
) -> tuple[int, int]:
|
|
495
|
+
selected_item_height = min(selected_item_height, available_rows)
|
|
496
|
+
remaining_rows = max(0, available_rows - selected_item_height)
|
|
497
|
+
|
|
498
|
+
before = min(self._scroll_offset, selected_index, remaining_rows)
|
|
499
|
+
remaining_rows -= before
|
|
500
|
+
after = min(completion_count - selected_index - 1, remaining_rows)
|
|
501
|
+
remaining_rows -= after
|
|
502
|
+
|
|
503
|
+
extra_before = min(selected_index - before, remaining_rows)
|
|
504
|
+
before += extra_before
|
|
505
|
+
remaining_rows -= extra_before
|
|
506
|
+
|
|
507
|
+
extra_after = min(completion_count - selected_index - 1 - after, remaining_rows)
|
|
508
|
+
after += extra_after
|
|
509
|
+
|
|
510
|
+
return selected_index - before, selected_index + after
|
|
511
|
+
|
|
512
|
+
def _command_column_width(
|
|
513
|
+
self,
|
|
514
|
+
completions: Sequence[Completion],
|
|
515
|
+
menu_width: int,
|
|
516
|
+
marker_width: int,
|
|
517
|
+
) -> int:
|
|
518
|
+
if menu_width <= 0:
|
|
519
|
+
return 0
|
|
520
|
+
longest = max((get_cwidth(c.display_text) for c in completions), default=0)
|
|
521
|
+
preferred = longest + 2
|
|
522
|
+
usable_width = max(0, menu_width - marker_width)
|
|
523
|
+
minimum = min(usable_width, 18)
|
|
524
|
+
maximum = max(minimum, min(28, usable_width // 2))
|
|
525
|
+
return max(minimum, min(preferred, maximum))
|
|
526
|
+
|
|
527
|
+
def _render_single_line_item(
|
|
528
|
+
self,
|
|
529
|
+
*,
|
|
530
|
+
width: int,
|
|
531
|
+
completion: Completion,
|
|
532
|
+
marker_width: int,
|
|
533
|
+
command_width: int,
|
|
534
|
+
meta_width: int,
|
|
535
|
+
gap_width: int,
|
|
536
|
+
is_current: bool,
|
|
537
|
+
) -> FormattedText:
|
|
538
|
+
padding_width = max(0, width - marker_width - command_width - meta_width - gap_width)
|
|
539
|
+
left_padding = min(self._left_padding(), padding_width)
|
|
540
|
+
trailing_width = max(
|
|
541
|
+
0,
|
|
542
|
+
width - left_padding - marker_width - command_width - gap_width - meta_width,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
command_style = (
|
|
546
|
+
"class:slash-completion-menu.command.current"
|
|
547
|
+
if is_current
|
|
548
|
+
else "class:slash-completion-menu.command"
|
|
549
|
+
)
|
|
550
|
+
meta_style = (
|
|
551
|
+
"class:slash-completion-menu.meta.current"
|
|
552
|
+
if is_current
|
|
553
|
+
else "class:slash-completion-menu.meta"
|
|
554
|
+
)
|
|
555
|
+
marker_style = (
|
|
556
|
+
"class:slash-completion-menu.marker.current"
|
|
557
|
+
if is_current
|
|
558
|
+
else "class:slash-completion-menu.marker"
|
|
559
|
+
)
|
|
560
|
+
marker = "› " if is_current else " "
|
|
561
|
+
|
|
562
|
+
fragments: FormattedText = FormattedText()
|
|
563
|
+
fragments.append(("class:slash-completion-menu", " " * left_padding))
|
|
564
|
+
fragments.append((marker_style, marker.ljust(marker_width)))
|
|
565
|
+
fragments.append(
|
|
566
|
+
(command_style, _truncate_to_width(completion.display_text, command_width))
|
|
567
|
+
)
|
|
568
|
+
fragments.append(("class:slash-completion-menu", " " * gap_width))
|
|
569
|
+
fragments.append((meta_style, _truncate_to_width(completion.display_meta_text, meta_width)))
|
|
570
|
+
fragments.append(("class:slash-completion-menu", " " * trailing_width))
|
|
571
|
+
return fragments
|
|
572
|
+
|
|
573
|
+
def _render_selected_item_lines(
|
|
574
|
+
self,
|
|
575
|
+
*,
|
|
576
|
+
width: int,
|
|
577
|
+
completion: Completion,
|
|
578
|
+
marker_width: int,
|
|
579
|
+
command_width: int,
|
|
580
|
+
meta_width: int,
|
|
581
|
+
gap_width: int,
|
|
582
|
+
meta_lines: Sequence[str],
|
|
583
|
+
) -> list[FormattedText]:
|
|
584
|
+
lines = [
|
|
585
|
+
self._render_single_line_item(
|
|
586
|
+
width=width,
|
|
587
|
+
completion=Completion(
|
|
588
|
+
text=completion.text,
|
|
589
|
+
start_position=completion.start_position,
|
|
590
|
+
display=completion.display,
|
|
591
|
+
display_meta=meta_lines[0],
|
|
592
|
+
),
|
|
593
|
+
marker_width=marker_width,
|
|
594
|
+
command_width=command_width,
|
|
595
|
+
meta_width=meta_width,
|
|
596
|
+
gap_width=gap_width,
|
|
597
|
+
is_current=True,
|
|
598
|
+
)
|
|
599
|
+
]
|
|
600
|
+
|
|
601
|
+
continuation_prefix = (
|
|
602
|
+
" " * self._left_padding() + " " * marker_width + " " * command_width + " " * gap_width
|
|
603
|
+
)
|
|
604
|
+
continuation_trailing = max(
|
|
605
|
+
0,
|
|
606
|
+
width - get_cwidth(continuation_prefix) - meta_width,
|
|
607
|
+
)
|
|
608
|
+
for meta_line in meta_lines[1:]:
|
|
609
|
+
fragments: FormattedText = FormattedText()
|
|
610
|
+
fragments.append(("class:slash-completion-menu", continuation_prefix))
|
|
611
|
+
fragments.append(
|
|
612
|
+
(
|
|
613
|
+
"class:slash-completion-menu.meta.current",
|
|
614
|
+
_truncate_to_width(meta_line, meta_width),
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
fragments.append(("class:slash-completion-menu", " " * continuation_trailing))
|
|
618
|
+
lines.append(fragments)
|
|
619
|
+
|
|
620
|
+
return lines
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
class LocalFileMentionCompleter(Completer):
|
|
624
|
+
"""Offer fuzzy `@` path completion by indexing workspace files.
|
|
625
|
+
|
|
626
|
+
File discovery and ignore rules are delegated to
|
|
627
|
+
:mod:`pythinker_code.utils.file_filter` so that the web backend can reuse
|
|
628
|
+
them.
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
_FRAGMENT_PATTERN = re.compile(r"[^\s@]+")
|
|
632
|
+
_TRIGGER_GUARDS = frozenset((".", "-", "_", "`", "'", '"', ":", "@", "#", "~"))
|
|
633
|
+
|
|
634
|
+
def __init__(
|
|
635
|
+
self,
|
|
636
|
+
root: Path,
|
|
637
|
+
*,
|
|
638
|
+
refresh_interval: float = 2.0,
|
|
639
|
+
limit: int = 1000,
|
|
640
|
+
) -> None:
|
|
641
|
+
self._root = root
|
|
642
|
+
self._refresh_interval = refresh_interval
|
|
643
|
+
self._limit = limit
|
|
644
|
+
self._cache_time: float = 0.0
|
|
645
|
+
self._cached_paths: list[str] = []
|
|
646
|
+
self._cache_scope: str | None = None
|
|
647
|
+
self._top_cache_time: float = 0.0
|
|
648
|
+
self._top_cached_paths: list[str] = []
|
|
649
|
+
self._fragment_hint: str | None = None
|
|
650
|
+
self._is_git: bool | None = None # lazily detected
|
|
651
|
+
self._git_index_mtime: float | None = None
|
|
652
|
+
|
|
653
|
+
self._word_completer = WordCompleter(
|
|
654
|
+
self._get_paths,
|
|
655
|
+
WORD=False,
|
|
656
|
+
pattern=self._FRAGMENT_PATTERN,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
self._fuzzy = FuzzyCompleter(
|
|
660
|
+
self._word_completer,
|
|
661
|
+
WORD=False,
|
|
662
|
+
pattern=r"^[^\s@]*",
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
def _get_paths(self) -> list[str]:
|
|
666
|
+
fragment = self._fragment_hint or ""
|
|
667
|
+
if "/" not in fragment and len(fragment) < 3:
|
|
668
|
+
return self._get_top_level_paths()
|
|
669
|
+
return self._get_deep_paths()
|
|
670
|
+
|
|
671
|
+
def _get_top_level_paths(self) -> list[str]:
|
|
672
|
+
from pythinker_code.utils.file_filter import is_ignored
|
|
673
|
+
|
|
674
|
+
now = time.monotonic()
|
|
675
|
+
if now - self._top_cache_time <= self._refresh_interval:
|
|
676
|
+
return self._top_cached_paths
|
|
677
|
+
|
|
678
|
+
entries: list[str] = []
|
|
679
|
+
try:
|
|
680
|
+
for entry in sorted(self._root.iterdir(), key=lambda p: p.name):
|
|
681
|
+
name = entry.name
|
|
682
|
+
if is_ignored(name):
|
|
683
|
+
continue
|
|
684
|
+
entries.append(f"{name}/" if entry.is_dir() else name)
|
|
685
|
+
if len(entries) >= self._limit:
|
|
686
|
+
break
|
|
687
|
+
except OSError:
|
|
688
|
+
return self._top_cached_paths
|
|
689
|
+
|
|
690
|
+
self._top_cached_paths = entries
|
|
691
|
+
self._top_cache_time = now
|
|
692
|
+
return self._top_cached_paths
|
|
693
|
+
|
|
694
|
+
def _get_deep_paths(self) -> list[str]:
|
|
695
|
+
from pythinker_code.utils.file_filter import (
|
|
696
|
+
detect_git,
|
|
697
|
+
git_index_mtime,
|
|
698
|
+
list_files_git,
|
|
699
|
+
list_files_walk,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
fragment = self._fragment_hint or ""
|
|
703
|
+
|
|
704
|
+
scope: str | None = None
|
|
705
|
+
if "/" in fragment:
|
|
706
|
+
scope = fragment.rsplit("/", 1)[0]
|
|
707
|
+
|
|
708
|
+
now = time.monotonic()
|
|
709
|
+
cache_valid = (
|
|
710
|
+
now - self._cache_time <= self._refresh_interval and self._cache_scope == scope
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
# Invalidate on .git/index mtime change (like Claude Code).
|
|
714
|
+
if cache_valid and self._is_git:
|
|
715
|
+
mtime = git_index_mtime(self._root)
|
|
716
|
+
if mtime != self._git_index_mtime:
|
|
717
|
+
cache_valid = False
|
|
718
|
+
|
|
719
|
+
if cache_valid:
|
|
720
|
+
return self._cached_paths
|
|
721
|
+
|
|
722
|
+
if self._is_git is None:
|
|
723
|
+
self._is_git = detect_git(self._root)
|
|
724
|
+
|
|
725
|
+
paths: list[str] | None = None
|
|
726
|
+
if self._is_git:
|
|
727
|
+
paths = list_files_git(self._root, scope)
|
|
728
|
+
self._git_index_mtime = git_index_mtime(self._root)
|
|
729
|
+
if paths is None:
|
|
730
|
+
paths = list_files_walk(self._root, scope, limit=self._limit)
|
|
731
|
+
|
|
732
|
+
self._cached_paths = paths
|
|
733
|
+
self._cache_scope = scope
|
|
734
|
+
self._cache_time = now
|
|
735
|
+
return self._cached_paths
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def _extract_fragment(text: str) -> str | None:
|
|
739
|
+
index = text.rfind("@")
|
|
740
|
+
if index == -1:
|
|
741
|
+
return None
|
|
742
|
+
|
|
743
|
+
if index > 0:
|
|
744
|
+
prev = text[index - 1]
|
|
745
|
+
if prev.isalnum() or prev in LocalFileMentionCompleter._TRIGGER_GUARDS:
|
|
746
|
+
return None
|
|
747
|
+
|
|
748
|
+
fragment = text[index + 1 :]
|
|
749
|
+
if not fragment:
|
|
750
|
+
return ""
|
|
751
|
+
|
|
752
|
+
if any(ch.isspace() for ch in fragment):
|
|
753
|
+
return None
|
|
754
|
+
|
|
755
|
+
return fragment
|
|
756
|
+
|
|
757
|
+
def _is_completed_file(self, fragment: str) -> bool:
|
|
758
|
+
candidate = fragment.rstrip("/")
|
|
759
|
+
if not candidate:
|
|
760
|
+
return False
|
|
761
|
+
try:
|
|
762
|
+
return (self._root / candidate).is_file()
|
|
763
|
+
except OSError:
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
@override
|
|
767
|
+
def get_completions(
|
|
768
|
+
self, document: Document, complete_event: CompleteEvent
|
|
769
|
+
) -> Iterable[Completion]:
|
|
770
|
+
fragment = self._extract_fragment(document.text_before_cursor)
|
|
771
|
+
if fragment is None:
|
|
772
|
+
return
|
|
773
|
+
if self._is_completed_file(fragment):
|
|
774
|
+
return
|
|
775
|
+
|
|
776
|
+
mention_doc = Document(text=fragment, cursor_position=len(fragment))
|
|
777
|
+
self._fragment_hint = fragment
|
|
778
|
+
try:
|
|
779
|
+
# First, ask the fuzzy completer for candidates.
|
|
780
|
+
candidates = list(self._fuzzy.get_completions(mention_doc, complete_event))
|
|
781
|
+
|
|
782
|
+
# re-rank: prefer basename matches
|
|
783
|
+
frag_lower = fragment.lower()
|
|
784
|
+
|
|
785
|
+
def _rank(c: Completion) -> tuple[int, ...]:
|
|
786
|
+
path = c.text
|
|
787
|
+
base = path.rstrip("/").split("/")[-1].lower()
|
|
788
|
+
if base.startswith(frag_lower):
|
|
789
|
+
cat = 0
|
|
790
|
+
elif frag_lower in base:
|
|
791
|
+
cat = 1
|
|
792
|
+
else:
|
|
793
|
+
cat = 2
|
|
794
|
+
# preserve original FuzzyCompleter's order in the same category
|
|
795
|
+
return (cat,)
|
|
796
|
+
|
|
797
|
+
candidates.sort(key=_rank)
|
|
798
|
+
yield from candidates
|
|
799
|
+
finally:
|
|
800
|
+
self._fragment_hint = None
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
class _HistoryEntry(BaseModel):
|
|
804
|
+
content: str
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _load_history_entries(history_file: Path) -> list[_HistoryEntry]:
|
|
808
|
+
entries: list[_HistoryEntry] = []
|
|
809
|
+
if not history_file.exists():
|
|
810
|
+
return entries
|
|
811
|
+
|
|
812
|
+
try:
|
|
813
|
+
with history_file.open(encoding="utf-8") as f:
|
|
814
|
+
for raw_line in f:
|
|
815
|
+
line = raw_line.strip()
|
|
816
|
+
if not line:
|
|
817
|
+
continue
|
|
818
|
+
try:
|
|
819
|
+
record = json.loads(line)
|
|
820
|
+
except json.JSONDecodeError:
|
|
821
|
+
logger.warning(
|
|
822
|
+
"Failed to parse user history line; skipping: {line}",
|
|
823
|
+
line=line,
|
|
824
|
+
)
|
|
825
|
+
continue
|
|
826
|
+
try:
|
|
827
|
+
entry = _HistoryEntry.model_validate(record)
|
|
828
|
+
entries.append(entry)
|
|
829
|
+
except ValidationError:
|
|
830
|
+
logger.warning(
|
|
831
|
+
"Failed to validate user history entry; skipping: {line}",
|
|
832
|
+
line=line,
|
|
833
|
+
)
|
|
834
|
+
continue
|
|
835
|
+
except OSError as exc:
|
|
836
|
+
logger.warning(
|
|
837
|
+
"Failed to load user history file: {file} ({error})",
|
|
838
|
+
file=history_file,
|
|
839
|
+
error=exc,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
return entries
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
class PromptMode(Enum):
|
|
846
|
+
AGENT = "agent"
|
|
847
|
+
SHELL = "shell"
|
|
848
|
+
|
|
849
|
+
def toggle(self) -> PromptMode:
|
|
850
|
+
return PromptMode.SHELL if self == PromptMode.AGENT else PromptMode.AGENT
|
|
851
|
+
|
|
852
|
+
def __str__(self) -> str:
|
|
853
|
+
return self.value
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
class PromptUIState(Enum):
|
|
857
|
+
NORMAL_INPUT = "normal_input"
|
|
858
|
+
MODAL_HIDDEN_INPUT = "modal_hidden_input"
|
|
859
|
+
MODAL_TEXT_INPUT = "modal_text_input"
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
class UserInput(BaseModel):
|
|
863
|
+
mode: PromptMode
|
|
864
|
+
command: str
|
|
865
|
+
"""The plain text representation of the user input."""
|
|
866
|
+
resolved_command: str
|
|
867
|
+
"""The text command after UI-only placeholders are expanded."""
|
|
868
|
+
content: list[ContentPart]
|
|
869
|
+
"""The rich content parts."""
|
|
870
|
+
|
|
871
|
+
def __str__(self) -> str:
|
|
872
|
+
return self.command
|
|
873
|
+
|
|
874
|
+
def __bool__(self) -> bool:
|
|
875
|
+
return bool(self.command)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
_IDLE_REFRESH_INTERVAL = 1.0
|
|
879
|
+
_RUNNING_REFRESH_INTERVAL = 0.1
|
|
880
|
+
|
|
881
|
+
_GIT_BRANCH_TTL = 5.0
|
|
882
|
+
_GIT_STATUS_TTL = 15.0
|
|
883
|
+
_TIP_ROTATE_INTERVAL = 30.0
|
|
884
|
+
_MAX_CWD_COLS = 30
|
|
885
|
+
_MAX_BRANCH_COLS = 22
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
@dataclass
|
|
889
|
+
class _GitBranchState:
|
|
890
|
+
timestamp: float = 0.0
|
|
891
|
+
branch: str | None = None
|
|
892
|
+
proc: subprocess.Popen[str] | None = None
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@dataclass
|
|
896
|
+
class _GitStatusState:
|
|
897
|
+
timestamp: float = 0.0
|
|
898
|
+
dirty: bool = False
|
|
899
|
+
ahead: int = 0
|
|
900
|
+
behind: int = 0
|
|
901
|
+
proc: subprocess.Popen[str] | None = None
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
_git_branch_state = _GitBranchState()
|
|
905
|
+
_git_status_state = _GitStatusState()
|
|
906
|
+
|
|
907
|
+
_GIT_STATUS_AB_RE = re.compile(r"\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\]")
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _get_git_branch() -> str | None:
|
|
911
|
+
"""Return the current git branch name via a non-blocking cached subprocess."""
|
|
912
|
+
state = _git_branch_state
|
|
913
|
+
now = time.monotonic()
|
|
914
|
+
|
|
915
|
+
# Collect result if a previously launched process has finished
|
|
916
|
+
if state.proc is not None:
|
|
917
|
+
returncode = state.proc.poll()
|
|
918
|
+
if returncode is not None:
|
|
919
|
+
try:
|
|
920
|
+
stdout, _ = state.proc.communicate()
|
|
921
|
+
new_branch = stdout.strip() or None
|
|
922
|
+
# Branch changed — discard any in-flight status subprocess so it cannot
|
|
923
|
+
# write stale results for the old branch, then force an immediate refresh.
|
|
924
|
+
if new_branch != state.branch:
|
|
925
|
+
if _git_status_state.proc is not None:
|
|
926
|
+
with contextlib.suppress(Exception):
|
|
927
|
+
_git_status_state.proc.terminate()
|
|
928
|
+
_git_status_state.proc = None
|
|
929
|
+
_git_status_state.timestamp = 0.0
|
|
930
|
+
state.branch = new_branch
|
|
931
|
+
except Exception:
|
|
932
|
+
state.branch = None
|
|
933
|
+
state.proc = None
|
|
934
|
+
|
|
935
|
+
# Launch a new process when the TTL has expired and nothing is running
|
|
936
|
+
if state.timestamp + _GIT_BRANCH_TTL <= now and state.proc is None:
|
|
937
|
+
state.timestamp = now
|
|
938
|
+
try:
|
|
939
|
+
state.proc = subprocess.Popen(
|
|
940
|
+
["git", "branch", "--show-current"],
|
|
941
|
+
stdout=subprocess.PIPE,
|
|
942
|
+
stderr=subprocess.DEVNULL,
|
|
943
|
+
text=True,
|
|
944
|
+
encoding="utf-8",
|
|
945
|
+
errors="replace",
|
|
946
|
+
)
|
|
947
|
+
except Exception:
|
|
948
|
+
state.branch = None
|
|
949
|
+
|
|
950
|
+
return state.branch
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _get_git_status() -> tuple[bool, int, int]:
|
|
954
|
+
"""Return (dirty, ahead, behind) via a non-blocking cached subprocess.
|
|
955
|
+
|
|
956
|
+
Runs ``git status --porcelain -b`` (includes untracked files so newly created
|
|
957
|
+
files show as dirty). TTL is longer than the branch check because file-tree
|
|
958
|
+
scanning is expensive.
|
|
959
|
+
"""
|
|
960
|
+
state = _git_status_state
|
|
961
|
+
now = time.monotonic()
|
|
962
|
+
|
|
963
|
+
if state.proc is not None:
|
|
964
|
+
returncode = state.proc.poll()
|
|
965
|
+
if returncode is not None:
|
|
966
|
+
try:
|
|
967
|
+
stdout, _ = state.proc.communicate()
|
|
968
|
+
dirty = False
|
|
969
|
+
ahead = 0
|
|
970
|
+
behind = 0
|
|
971
|
+
for line in stdout.splitlines():
|
|
972
|
+
if line.startswith("## "):
|
|
973
|
+
m = _GIT_STATUS_AB_RE.search(line)
|
|
974
|
+
if m:
|
|
975
|
+
ahead = int(m.group(1) or 0)
|
|
976
|
+
behind = int(m.group(2) or 0)
|
|
977
|
+
elif line.strip():
|
|
978
|
+
dirty = True
|
|
979
|
+
state.dirty = dirty
|
|
980
|
+
state.ahead = ahead
|
|
981
|
+
state.behind = behind
|
|
982
|
+
except Exception:
|
|
983
|
+
pass
|
|
984
|
+
state.proc = None
|
|
985
|
+
elif now - state.timestamp > _GIT_STATUS_TTL:
|
|
986
|
+
# Subprocess is stuck (e.g. OS pipe buffer full from many untracked files).
|
|
987
|
+
# Terminate it so the toolbar is not permanently frozen; retry after next TTL.
|
|
988
|
+
with contextlib.suppress(Exception):
|
|
989
|
+
state.proc.terminate()
|
|
990
|
+
state.proc = None
|
|
991
|
+
state.timestamp = now # delay next spawn by one full TTL
|
|
992
|
+
|
|
993
|
+
if state.timestamp + _GIT_STATUS_TTL <= now and state.proc is None:
|
|
994
|
+
state.timestamp = now
|
|
995
|
+
with contextlib.suppress(Exception):
|
|
996
|
+
state.proc = subprocess.Popen(
|
|
997
|
+
["git", "status", "--porcelain", "-b"],
|
|
998
|
+
stdout=subprocess.PIPE,
|
|
999
|
+
stderr=subprocess.DEVNULL,
|
|
1000
|
+
text=True,
|
|
1001
|
+
encoding="utf-8",
|
|
1002
|
+
errors="replace",
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
return state.dirty, state.ahead, state.behind
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _format_git_badge(branch: str, dirty: bool, ahead: int, behind: int) -> str:
|
|
1009
|
+
"""Format branch name with an optional status badge: ``main [± ↑3↓1]``."""
|
|
1010
|
+
parts: list[str] = []
|
|
1011
|
+
if dirty:
|
|
1012
|
+
parts.append("±")
|
|
1013
|
+
sync = ""
|
|
1014
|
+
if ahead:
|
|
1015
|
+
sync += f"↑{ahead}"
|
|
1016
|
+
if behind:
|
|
1017
|
+
sync += f"↓{behind}"
|
|
1018
|
+
if sync:
|
|
1019
|
+
parts.append(sync)
|
|
1020
|
+
if not parts:
|
|
1021
|
+
return branch
|
|
1022
|
+
return f"{branch} [{' '.join(parts)}]"
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _shorten_cwd(path: str) -> str:
|
|
1026
|
+
"""Replace the home directory prefix in *path* with ``~``."""
|
|
1027
|
+
home = str(Path.home())
|
|
1028
|
+
if path == home:
|
|
1029
|
+
return "~"
|
|
1030
|
+
if path.startswith(home + os.sep):
|
|
1031
|
+
return "~" + path[len(home) :]
|
|
1032
|
+
return path
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _display_width(text: str) -> int:
|
|
1036
|
+
"""Return the terminal column width of *text*, handling wide Unicode characters."""
|
|
1037
|
+
return sum(get_cwidth(c) for c in text)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
def _truncate_left(text: str, max_cols: int) -> str:
|
|
1041
|
+
"""Truncate *text* from the left, prepending '…' if it exceeds *max_cols*."""
|
|
1042
|
+
if max_cols <= 0:
|
|
1043
|
+
return ""
|
|
1044
|
+
if _display_width(text) <= max_cols:
|
|
1045
|
+
return text
|
|
1046
|
+
ellipsis = "…"
|
|
1047
|
+
budget = max_cols - _display_width(ellipsis)
|
|
1048
|
+
chars: list[str] = []
|
|
1049
|
+
width = 0
|
|
1050
|
+
for ch in reversed(text):
|
|
1051
|
+
w = get_cwidth(ch)
|
|
1052
|
+
if width + w > budget:
|
|
1053
|
+
break
|
|
1054
|
+
chars.append(ch)
|
|
1055
|
+
width += w
|
|
1056
|
+
return ellipsis + "".join(reversed(chars))
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _truncate_right(text: str, max_cols: int) -> str:
|
|
1060
|
+
"""Truncate *text* from the right, appending '…' if it exceeds *max_cols*."""
|
|
1061
|
+
if max_cols <= 0:
|
|
1062
|
+
return ""
|
|
1063
|
+
if _display_width(text) <= max_cols:
|
|
1064
|
+
return text
|
|
1065
|
+
ellipsis = "…"
|
|
1066
|
+
budget = max_cols - _display_width(ellipsis)
|
|
1067
|
+
chars: list[str] = []
|
|
1068
|
+
width = 0
|
|
1069
|
+
for ch in text:
|
|
1070
|
+
w = get_cwidth(ch)
|
|
1071
|
+
if width + w > budget:
|
|
1072
|
+
break
|
|
1073
|
+
chars.append(ch)
|
|
1074
|
+
width += w
|
|
1075
|
+
return "".join(chars) + ellipsis
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
@dataclass(slots=True)
|
|
1079
|
+
class _ToastEntry:
|
|
1080
|
+
topic: str | None
|
|
1081
|
+
"""There can be only one toast of each non-None topic in the queue."""
|
|
1082
|
+
message: str
|
|
1083
|
+
expires_at: float
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
class RunningPromptDelegate(Protocol):
|
|
1087
|
+
"""Protocol for components that can take over the bottom prompt area."""
|
|
1088
|
+
|
|
1089
|
+
modal_priority: int
|
|
1090
|
+
|
|
1091
|
+
def render_running_prompt_body(self, columns: int) -> AnyFormattedText: ...
|
|
1092
|
+
|
|
1093
|
+
def running_prompt_placeholder(self) -> AnyFormattedText | None: ...
|
|
1094
|
+
|
|
1095
|
+
def running_prompt_allows_text_input(self) -> bool: ...
|
|
1096
|
+
|
|
1097
|
+
def running_prompt_hides_input_buffer(self) -> bool: ...
|
|
1098
|
+
|
|
1099
|
+
def running_prompt_accepts_submission(self) -> bool: ...
|
|
1100
|
+
|
|
1101
|
+
def should_handle_running_prompt_key(self, key: str) -> bool: ...
|
|
1102
|
+
|
|
1103
|
+
def handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None: ...
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
@dataclass(frozen=True, slots=True)
|
|
1107
|
+
class BgTaskCounts:
|
|
1108
|
+
bash: int = 0
|
|
1109
|
+
agent: int = 0
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
@runtime_checkable
|
|
1113
|
+
class AgentStatusProvider(Protocol):
|
|
1114
|
+
"""Optional protocol for delegates that render always-visible agent status.
|
|
1115
|
+
|
|
1116
|
+
When the running prompt delegate implements this, ``_render_agent_status``
|
|
1117
|
+
will call ``render_agent_status`` instead of the fallback status block.
|
|
1118
|
+
This ensures spinners, content blocks, and tool calls remain visible
|
|
1119
|
+
even when a modal (approval/question/btw) is active.
|
|
1120
|
+
"""
|
|
1121
|
+
|
|
1122
|
+
def render_agent_status(self, columns: int) -> AnyFormattedText: ...
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
_toast_queues: dict[Literal["left", "right"], deque[_ToastEntry]] = {
|
|
1126
|
+
"left": deque(),
|
|
1127
|
+
"right": deque(),
|
|
1128
|
+
}
|
|
1129
|
+
"""The queue of toasts to show, including the one currently being shown (the first one)."""
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def toast(
|
|
1133
|
+
message: str,
|
|
1134
|
+
duration: float = 5.0,
|
|
1135
|
+
topic: str | None = None,
|
|
1136
|
+
immediate: bool = False,
|
|
1137
|
+
position: Literal["left", "right"] = "left",
|
|
1138
|
+
) -> None:
|
|
1139
|
+
queue = _toast_queues[position]
|
|
1140
|
+
duration = max(duration, _IDLE_REFRESH_INTERVAL)
|
|
1141
|
+
entry = _ToastEntry(topic=topic, message=message, expires_at=time.monotonic() + duration)
|
|
1142
|
+
if topic is not None:
|
|
1143
|
+
# Remove existing toasts with the same topic
|
|
1144
|
+
for existing in list(queue):
|
|
1145
|
+
if existing.topic == topic:
|
|
1146
|
+
queue.remove(existing)
|
|
1147
|
+
if immediate:
|
|
1148
|
+
queue.appendleft(entry)
|
|
1149
|
+
else:
|
|
1150
|
+
queue.append(entry)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _current_toast(position: Literal["left", "right"] = "left") -> _ToastEntry | None:
|
|
1154
|
+
queue = _toast_queues[position]
|
|
1155
|
+
now = time.monotonic()
|
|
1156
|
+
while queue and queue[0].expires_at <= now:
|
|
1157
|
+
queue.popleft()
|
|
1158
|
+
if not queue:
|
|
1159
|
+
return None
|
|
1160
|
+
return queue[0]
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _build_toolbar_tips(clipboard_available: bool) -> list[str]:
|
|
1164
|
+
tips = [
|
|
1165
|
+
"ctrl-x: toggle mode",
|
|
1166
|
+
"shift-tab: plan mode",
|
|
1167
|
+
"ctrl-o: editor",
|
|
1168
|
+
"ctrl-j: newline",
|
|
1169
|
+
"/feedback: send feedback",
|
|
1170
|
+
"/theme: switch dark/light",
|
|
1171
|
+
]
|
|
1172
|
+
if clipboard_available:
|
|
1173
|
+
tips.append("ctrl-v: paste clipboard")
|
|
1174
|
+
tips.append("@: mention files")
|
|
1175
|
+
return tips
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
_TIP_SEPARATOR = " | "
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
class CustomPromptSession:
|
|
1182
|
+
def __init__(
|
|
1183
|
+
self,
|
|
1184
|
+
*,
|
|
1185
|
+
status_provider: Callable[[], StatusSnapshot],
|
|
1186
|
+
status_block_provider: Callable[[int], AnyFormattedText | None] | None = None,
|
|
1187
|
+
fast_refresh_provider: Callable[[], bool] | None = None,
|
|
1188
|
+
background_task_count_provider: Callable[[], BgTaskCounts] | None = None,
|
|
1189
|
+
model_capabilities: set[ModelCapability],
|
|
1190
|
+
model_name: str | None,
|
|
1191
|
+
thinking: bool,
|
|
1192
|
+
agent_mode_slash_commands: Sequence[SlashCommand[Any]],
|
|
1193
|
+
shell_mode_slash_commands: Sequence[SlashCommand[Any]],
|
|
1194
|
+
editor_command_provider: Callable[[], str] = lambda: "",
|
|
1195
|
+
plan_mode_toggle_callback: Callable[[], Awaitable[bool]] | None = None,
|
|
1196
|
+
) -> None:
|
|
1197
|
+
history_dir = get_share_dir() / "user-history"
|
|
1198
|
+
history_dir.mkdir(parents=True, exist_ok=True)
|
|
1199
|
+
work_dir_id = md5(str(HostPath.cwd()).encode(encoding="utf-8")).hexdigest()
|
|
1200
|
+
self._history_file = (history_dir / work_dir_id).with_suffix(".jsonl")
|
|
1201
|
+
self._status_provider = status_provider
|
|
1202
|
+
self._status_block_provider = status_block_provider
|
|
1203
|
+
self._fast_refresh_provider = fast_refresh_provider
|
|
1204
|
+
self._background_task_count_provider = background_task_count_provider
|
|
1205
|
+
self._editor_command_provider = editor_command_provider
|
|
1206
|
+
self._plan_mode_toggle_callback = plan_mode_toggle_callback
|
|
1207
|
+
self._model_capabilities = model_capabilities
|
|
1208
|
+
self._model_name = model_name
|
|
1209
|
+
self._last_history_content: str | None = None
|
|
1210
|
+
self._mode: PromptMode = PromptMode.AGENT
|
|
1211
|
+
self._thinking = thinking
|
|
1212
|
+
self._placeholder_manager = PromptPlaceholderManager()
|
|
1213
|
+
# Keep the old attribute for test compatibility and for any external imports.
|
|
1214
|
+
self._attachment_cache = self._placeholder_manager.attachment_cache
|
|
1215
|
+
self._last_tip_rotate_time: float = time.monotonic()
|
|
1216
|
+
self._last_submission_was_running = False
|
|
1217
|
+
self._last_input_activity_time: float = 0.0
|
|
1218
|
+
self._suppress_auto_completion: bool = False
|
|
1219
|
+
self._input_activity_event: asyncio.Event = asyncio.Event()
|
|
1220
|
+
self._running_prompt_previous_mode: PromptMode | None = None
|
|
1221
|
+
self._running_prompt_delegate: RunningPromptDelegate | None = None
|
|
1222
|
+
self._modal_delegates: list[RunningPromptDelegate] = []
|
|
1223
|
+
self._prompt_buffer_container: ConditionalContainer | None = None
|
|
1224
|
+
self._last_ui_state: PromptUIState = PromptUIState.NORMAL_INPUT
|
|
1225
|
+
self._suspended_buffer_document: Document | None = None
|
|
1226
|
+
clipboard_available = is_clipboard_available()
|
|
1227
|
+
media_clipboard_available = is_media_clipboard_available()
|
|
1228
|
+
self._tips = _build_toolbar_tips(clipboard_available or media_clipboard_available)
|
|
1229
|
+
self._tip_rotation_index: int = random.randrange(len(self._tips)) if self._tips else 0
|
|
1230
|
+
|
|
1231
|
+
history_entries = _load_history_entries(self._history_file)
|
|
1232
|
+
history = InMemoryHistory()
|
|
1233
|
+
for entry in history_entries:
|
|
1234
|
+
history.append_string(entry.content)
|
|
1235
|
+
|
|
1236
|
+
if history_entries:
|
|
1237
|
+
# for consecutive deduplication
|
|
1238
|
+
self._last_history_content = history_entries[-1].content
|
|
1239
|
+
|
|
1240
|
+
# Build completers
|
|
1241
|
+
self._agent_mode_completer = merge_completers(
|
|
1242
|
+
[
|
|
1243
|
+
SlashCommandCompleter(agent_mode_slash_commands),
|
|
1244
|
+
# TODO(host): we need an async HostFileMentionCompleter
|
|
1245
|
+
LocalFileMentionCompleter(HostPath.cwd().unsafe_to_local_path()),
|
|
1246
|
+
],
|
|
1247
|
+
deduplicate=True,
|
|
1248
|
+
)
|
|
1249
|
+
self._shell_mode_completer = SlashCommandCompleter(shell_mode_slash_commands)
|
|
1250
|
+
|
|
1251
|
+
# Build key bindings
|
|
1252
|
+
_kb = KeyBindings()
|
|
1253
|
+
|
|
1254
|
+
def _accept_completion(buff: Buffer) -> None:
|
|
1255
|
+
"""Accept the current or first completion, suppressing re-completion."""
|
|
1256
|
+
completion = buff.complete_state.current_completion # type: ignore[union-attr]
|
|
1257
|
+
if not completion:
|
|
1258
|
+
completion = buff.complete_state.completions[0] # type: ignore[union-attr]
|
|
1259
|
+
self._suppress_auto_completion = True
|
|
1260
|
+
try:
|
|
1261
|
+
buff.apply_completion(completion)
|
|
1262
|
+
finally:
|
|
1263
|
+
self._suppress_auto_completion = False
|
|
1264
|
+
|
|
1265
|
+
def _is_slash_completion() -> bool:
|
|
1266
|
+
"""True when the active completion menu is for a slash command."""
|
|
1267
|
+
buff = self._session.default_buffer
|
|
1268
|
+
return bool(
|
|
1269
|
+
buff.complete_state
|
|
1270
|
+
and buff.complete_state.completions
|
|
1271
|
+
and SlashCommandCompleter.should_complete(buff.document)
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
_slash_completion_filter = has_completions & Condition(_is_slash_completion)
|
|
1275
|
+
_non_slash_completion_filter = has_completions & ~Condition(_is_slash_completion)
|
|
1276
|
+
|
|
1277
|
+
@_kb.add("enter", filter=_slash_completion_filter)
|
|
1278
|
+
def _(event: KeyPressEvent) -> None:
|
|
1279
|
+
"""Slash command completion: accept and submit in one step."""
|
|
1280
|
+
_accept_completion(event.current_buffer)
|
|
1281
|
+
event.current_buffer.validate_and_handle()
|
|
1282
|
+
|
|
1283
|
+
@_kb.add("enter", filter=_non_slash_completion_filter)
|
|
1284
|
+
def _(event: KeyPressEvent) -> None:
|
|
1285
|
+
"""Non-slash completion (file mentions, etc.): accept only."""
|
|
1286
|
+
_accept_completion(event.current_buffer)
|
|
1287
|
+
|
|
1288
|
+
@_kb.add("c-x", eager=True)
|
|
1289
|
+
def _(event: KeyPressEvent) -> None:
|
|
1290
|
+
if self._active_prompt_delegate() is not None:
|
|
1291
|
+
return
|
|
1292
|
+
self._mode = self._mode.toggle()
|
|
1293
|
+
from pythinker_code.telemetry import track
|
|
1294
|
+
|
|
1295
|
+
track("shortcut_mode_switch", to_mode=self._mode.value)
|
|
1296
|
+
# Apply mode-specific settings
|
|
1297
|
+
self._apply_mode(event)
|
|
1298
|
+
# Redraw UI
|
|
1299
|
+
event.app.invalidate()
|
|
1300
|
+
|
|
1301
|
+
@_kb.add("s-tab", eager=True)
|
|
1302
|
+
def _(event: KeyPressEvent) -> None:
|
|
1303
|
+
"""Toggle plan mode with Shift+Tab."""
|
|
1304
|
+
if self._active_prompt_delegate() is not None:
|
|
1305
|
+
return
|
|
1306
|
+
if self._plan_mode_toggle_callback is not None:
|
|
1307
|
+
|
|
1308
|
+
async def _toggle() -> None:
|
|
1309
|
+
assert self._plan_mode_toggle_callback is not None
|
|
1310
|
+
new_state = await self._plan_mode_toggle_callback()
|
|
1311
|
+
from pythinker_code.telemetry import track
|
|
1312
|
+
|
|
1313
|
+
track("shortcut_plan_toggle", enabled=new_state)
|
|
1314
|
+
if new_state:
|
|
1315
|
+
toast("plan mode ON", topic="plan_mode", duration=3.0, immediate=True)
|
|
1316
|
+
else:
|
|
1317
|
+
toast("plan mode OFF", topic="plan_mode", duration=3.0, immediate=True)
|
|
1318
|
+
event.app.invalidate()
|
|
1319
|
+
|
|
1320
|
+
event.app.create_background_task(_toggle())
|
|
1321
|
+
event.app.invalidate()
|
|
1322
|
+
|
|
1323
|
+
@_kb.add("escape", "enter", eager=True)
|
|
1324
|
+
@_kb.add("c-j", eager=True)
|
|
1325
|
+
def _(event: KeyPressEvent) -> None:
|
|
1326
|
+
"""Insert a newline when Alt-Enter or Ctrl-J is pressed."""
|
|
1327
|
+
from pythinker_code.telemetry import track
|
|
1328
|
+
|
|
1329
|
+
track("shortcut_newline")
|
|
1330
|
+
event.current_buffer.insert_text("\n")
|
|
1331
|
+
|
|
1332
|
+
@_kb.add("c-o", eager=True)
|
|
1333
|
+
def _(event: KeyPressEvent) -> None:
|
|
1334
|
+
"""Open current buffer in external editor."""
|
|
1335
|
+
from pythinker_code.telemetry import track
|
|
1336
|
+
|
|
1337
|
+
track("shortcut_editor")
|
|
1338
|
+
self._open_in_external_editor(event)
|
|
1339
|
+
|
|
1340
|
+
@_kb.add(
|
|
1341
|
+
"up",
|
|
1342
|
+
eager=True,
|
|
1343
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("up")),
|
|
1344
|
+
)
|
|
1345
|
+
def _(event: KeyPressEvent) -> None:
|
|
1346
|
+
self._handle_running_prompt_key("up", event)
|
|
1347
|
+
|
|
1348
|
+
@_kb.add(
|
|
1349
|
+
"down",
|
|
1350
|
+
eager=True,
|
|
1351
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("down")),
|
|
1352
|
+
)
|
|
1353
|
+
def _(event: KeyPressEvent) -> None:
|
|
1354
|
+
self._handle_running_prompt_key("down", event)
|
|
1355
|
+
|
|
1356
|
+
@_kb.add(
|
|
1357
|
+
"left",
|
|
1358
|
+
eager=True,
|
|
1359
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("left")),
|
|
1360
|
+
)
|
|
1361
|
+
def _(event: KeyPressEvent) -> None:
|
|
1362
|
+
self._handle_running_prompt_key("left", event)
|
|
1363
|
+
|
|
1364
|
+
@_kb.add(
|
|
1365
|
+
"right",
|
|
1366
|
+
eager=True,
|
|
1367
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("right")),
|
|
1368
|
+
)
|
|
1369
|
+
def _(event: KeyPressEvent) -> None:
|
|
1370
|
+
self._handle_running_prompt_key("right", event)
|
|
1371
|
+
|
|
1372
|
+
@_kb.add(
|
|
1373
|
+
"tab",
|
|
1374
|
+
eager=True,
|
|
1375
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("tab")),
|
|
1376
|
+
)
|
|
1377
|
+
def _(event: KeyPressEvent) -> None:
|
|
1378
|
+
self._handle_running_prompt_key("tab", event)
|
|
1379
|
+
|
|
1380
|
+
@_kb.add(
|
|
1381
|
+
"enter",
|
|
1382
|
+
eager=True,
|
|
1383
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("enter")),
|
|
1384
|
+
)
|
|
1385
|
+
def _(event: KeyPressEvent) -> None:
|
|
1386
|
+
self._handle_running_prompt_key("enter", event)
|
|
1387
|
+
|
|
1388
|
+
@_kb.add(
|
|
1389
|
+
"space",
|
|
1390
|
+
eager=True,
|
|
1391
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("space")),
|
|
1392
|
+
)
|
|
1393
|
+
def _(event: KeyPressEvent) -> None:
|
|
1394
|
+
self._handle_running_prompt_key("space", event)
|
|
1395
|
+
|
|
1396
|
+
@_kb.add(
|
|
1397
|
+
"c-s",
|
|
1398
|
+
eager=True,
|
|
1399
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-s")),
|
|
1400
|
+
)
|
|
1401
|
+
def _(event: KeyPressEvent) -> None:
|
|
1402
|
+
self._handle_running_prompt_key("c-s", event)
|
|
1403
|
+
|
|
1404
|
+
@_kb.add(
|
|
1405
|
+
"c-e",
|
|
1406
|
+
eager=True,
|
|
1407
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-e")),
|
|
1408
|
+
)
|
|
1409
|
+
def _(event: KeyPressEvent) -> None:
|
|
1410
|
+
self._handle_running_prompt_key("c-e", event)
|
|
1411
|
+
|
|
1412
|
+
@_kb.add(
|
|
1413
|
+
"c-c",
|
|
1414
|
+
eager=True,
|
|
1415
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-c")),
|
|
1416
|
+
)
|
|
1417
|
+
def _(event: KeyPressEvent) -> None:
|
|
1418
|
+
self._handle_running_prompt_key("c-c", event)
|
|
1419
|
+
|
|
1420
|
+
@_kb.add(
|
|
1421
|
+
"c-d",
|
|
1422
|
+
eager=True,
|
|
1423
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("c-d")),
|
|
1424
|
+
)
|
|
1425
|
+
def _(event: KeyPressEvent) -> None:
|
|
1426
|
+
self._handle_running_prompt_key("c-d", event)
|
|
1427
|
+
|
|
1428
|
+
@_kb.add(
|
|
1429
|
+
"escape",
|
|
1430
|
+
eager=True,
|
|
1431
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("escape")),
|
|
1432
|
+
)
|
|
1433
|
+
def _(event: KeyPressEvent) -> None:
|
|
1434
|
+
self._handle_running_prompt_key("escape", event)
|
|
1435
|
+
|
|
1436
|
+
@_kb.add(
|
|
1437
|
+
"1",
|
|
1438
|
+
eager=True,
|
|
1439
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("1")),
|
|
1440
|
+
)
|
|
1441
|
+
def _(event: KeyPressEvent) -> None:
|
|
1442
|
+
self._handle_running_prompt_key("1", event)
|
|
1443
|
+
|
|
1444
|
+
@_kb.add(
|
|
1445
|
+
"2",
|
|
1446
|
+
eager=True,
|
|
1447
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("2")),
|
|
1448
|
+
)
|
|
1449
|
+
def _(event: KeyPressEvent) -> None:
|
|
1450
|
+
self._handle_running_prompt_key("2", event)
|
|
1451
|
+
|
|
1452
|
+
@_kb.add(
|
|
1453
|
+
"3",
|
|
1454
|
+
eager=True,
|
|
1455
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("3")),
|
|
1456
|
+
)
|
|
1457
|
+
def _(event: KeyPressEvent) -> None:
|
|
1458
|
+
self._handle_running_prompt_key("3", event)
|
|
1459
|
+
|
|
1460
|
+
@_kb.add(
|
|
1461
|
+
"4",
|
|
1462
|
+
eager=True,
|
|
1463
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("4")),
|
|
1464
|
+
)
|
|
1465
|
+
def _(event: KeyPressEvent) -> None:
|
|
1466
|
+
self._handle_running_prompt_key("4", event)
|
|
1467
|
+
|
|
1468
|
+
@_kb.add(
|
|
1469
|
+
"5",
|
|
1470
|
+
eager=True,
|
|
1471
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("5")),
|
|
1472
|
+
)
|
|
1473
|
+
def _(event: KeyPressEvent) -> None:
|
|
1474
|
+
self._handle_running_prompt_key("5", event)
|
|
1475
|
+
|
|
1476
|
+
@_kb.add(
|
|
1477
|
+
"6",
|
|
1478
|
+
eager=True,
|
|
1479
|
+
filter=Condition(lambda: self._should_handle_running_prompt_key("6")),
|
|
1480
|
+
)
|
|
1481
|
+
def _(event: KeyPressEvent) -> None:
|
|
1482
|
+
self._handle_running_prompt_key("6", event)
|
|
1483
|
+
|
|
1484
|
+
@_kb.add(Keys.BracketedPaste, eager=True)
|
|
1485
|
+
def _(event: KeyPressEvent) -> None:
|
|
1486
|
+
self._handle_bracketed_paste(event)
|
|
1487
|
+
|
|
1488
|
+
if clipboard_available or media_clipboard_available:
|
|
1489
|
+
|
|
1490
|
+
@_kb.add("c-v", eager=True)
|
|
1491
|
+
def _(event: KeyPressEvent) -> None:
|
|
1492
|
+
from pythinker_code.telemetry import track
|
|
1493
|
+
|
|
1494
|
+
track("shortcut_paste")
|
|
1495
|
+
if self._try_paste_media(event):
|
|
1496
|
+
return
|
|
1497
|
+
if clipboard_available:
|
|
1498
|
+
try:
|
|
1499
|
+
clipboard_data = event.app.clipboard.get_data()
|
|
1500
|
+
except Exception:
|
|
1501
|
+
return
|
|
1502
|
+
if clipboard_data is None: # type: ignore[reportUnnecessaryComparison]
|
|
1503
|
+
return
|
|
1504
|
+
self._insert_pasted_text(event.current_buffer, clipboard_data.text)
|
|
1505
|
+
event.app.invalidate()
|
|
1506
|
+
|
|
1507
|
+
# Only use PyperclipClipboard when pyperclip actually works.
|
|
1508
|
+
# PromptSession built-in keybindings (ctrl-k, ctrl-w, ctrl-y)
|
|
1509
|
+
# use clipboard without error handling, so a broken clipboard
|
|
1510
|
+
# object would crash the UI.
|
|
1511
|
+
clipboard = PyperclipClipboard() if clipboard_available else None
|
|
1512
|
+
|
|
1513
|
+
self._session = PromptSession[str](
|
|
1514
|
+
message=self._render_message,
|
|
1515
|
+
# prompt_continuation=FormattedText([("fg:#4d4d4d", "... ")]),
|
|
1516
|
+
completer=self._agent_mode_completer,
|
|
1517
|
+
complete_while_typing=True,
|
|
1518
|
+
reserve_space_for_menu=6,
|
|
1519
|
+
key_bindings=_kb,
|
|
1520
|
+
clipboard=clipboard,
|
|
1521
|
+
history=history,
|
|
1522
|
+
bottom_toolbar=self._render_bottom_toolbar,
|
|
1523
|
+
style=get_prompt_style(),
|
|
1524
|
+
)
|
|
1525
|
+
self._session.default_buffer.read_only = Condition(
|
|
1526
|
+
lambda: (
|
|
1527
|
+
(delegate := self._active_prompt_delegate()) is not None
|
|
1528
|
+
and not delegate.running_prompt_allows_text_input()
|
|
1529
|
+
)
|
|
1530
|
+
)
|
|
1531
|
+
self._install_slash_completion_menu()
|
|
1532
|
+
self._install_prompt_buffer_visibility()
|
|
1533
|
+
self._apply_mode()
|
|
1534
|
+
|
|
1535
|
+
# Allow completion to be triggered when the text is changed,
|
|
1536
|
+
# such as when backspace is used to delete text.
|
|
1537
|
+
@self._session.default_buffer.on_text_changed.add_handler
|
|
1538
|
+
def _(buffer: Buffer) -> None:
|
|
1539
|
+
self._last_input_activity_time = time.monotonic()
|
|
1540
|
+
self._input_activity_event.set()
|
|
1541
|
+
if buffer.complete_while_typing() and not self._suppress_auto_completion:
|
|
1542
|
+
buffer.start_completion()
|
|
1543
|
+
|
|
1544
|
+
# Pre-select the first slash-command completion as soon as the menu
|
|
1545
|
+
# appears. The visual hack in SlashCommandMenuControl.create_content
|
|
1546
|
+
# already paints index 0 as highlighted when complete_index is None,
|
|
1547
|
+
# but the underlying complete_state was still un-positioned, so the
|
|
1548
|
+
# first arrow-down moved None→0 (no visible change) and required a
|
|
1549
|
+
# second press to reach row 2. Setting complete_index=0 here makes
|
|
1550
|
+
# the visual and behavioral states agree from the start.
|
|
1551
|
+
@self._session.default_buffer.on_completions_changed.add_handler
|
|
1552
|
+
def _(buffer: Buffer) -> None:
|
|
1553
|
+
state = buffer.complete_state
|
|
1554
|
+
if state is None or not state.completions:
|
|
1555
|
+
return
|
|
1556
|
+
if state.complete_index is not None:
|
|
1557
|
+
return
|
|
1558
|
+
if not SlashCommandCompleter.should_complete(buffer.document):
|
|
1559
|
+
return
|
|
1560
|
+
state.complete_index = 0
|
|
1561
|
+
|
|
1562
|
+
self._status_refresh_task: asyncio.Task[None] | None = None
|
|
1563
|
+
|
|
1564
|
+
def _install_slash_completion_menu(self) -> None:
|
|
1565
|
+
float_container = _find_prompt_float_container(self._session.layout.container)
|
|
1566
|
+
if not isinstance(float_container, FloatContainer):
|
|
1567
|
+
return
|
|
1568
|
+
|
|
1569
|
+
slash_menu_filter = (
|
|
1570
|
+
has_focus(self._session.default_buffer)
|
|
1571
|
+
& has_completions
|
|
1572
|
+
& ~is_done
|
|
1573
|
+
& Condition(self._should_show_slash_completion_menu)
|
|
1574
|
+
)
|
|
1575
|
+
slash_menu = ConditionalContainer(
|
|
1576
|
+
Window(
|
|
1577
|
+
content=SlashCommandMenuControl(left_padding=self._slash_menu_left_padding),
|
|
1578
|
+
dont_extend_height=True,
|
|
1579
|
+
height=Dimension(max=10),
|
|
1580
|
+
style="class:slash-completion-menu",
|
|
1581
|
+
),
|
|
1582
|
+
filter=slash_menu_filter,
|
|
1583
|
+
)
|
|
1584
|
+
float_container.floats.insert(
|
|
1585
|
+
0,
|
|
1586
|
+
Float(
|
|
1587
|
+
left=0,
|
|
1588
|
+
right=0,
|
|
1589
|
+
ycursor=True,
|
|
1590
|
+
content=slash_menu,
|
|
1591
|
+
z_index=10**8,
|
|
1592
|
+
),
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
original_float = next(
|
|
1596
|
+
(
|
|
1597
|
+
float_
|
|
1598
|
+
for float_ in float_container.floats[1:]
|
|
1599
|
+
if isinstance(float_.content, CompletionsMenu)
|
|
1600
|
+
),
|
|
1601
|
+
None,
|
|
1602
|
+
)
|
|
1603
|
+
if original_float is None:
|
|
1604
|
+
return
|
|
1605
|
+
original_float.content = ConditionalContainer(
|
|
1606
|
+
original_float.content,
|
|
1607
|
+
filter=~Condition(self._should_show_slash_completion_menu),
|
|
1608
|
+
)
|
|
1609
|
+
|
|
1610
|
+
def _install_prompt_buffer_visibility(self) -> None:
|
|
1611
|
+
buffer_container = _find_default_buffer_container(
|
|
1612
|
+
self._session.layout.container,
|
|
1613
|
+
self._session.default_buffer,
|
|
1614
|
+
)
|
|
1615
|
+
if buffer_container is None:
|
|
1616
|
+
return
|
|
1617
|
+
buffer_container.filter = buffer_container.filter & Condition(
|
|
1618
|
+
self._should_render_input_buffer
|
|
1619
|
+
)
|
|
1620
|
+
self._prompt_buffer_container = buffer_container
|
|
1621
|
+
|
|
1622
|
+
def _should_show_slash_completion_menu(self) -> bool:
|
|
1623
|
+
document = self._session.default_buffer.document
|
|
1624
|
+
return SlashCommandCompleter.should_complete(document)
|
|
1625
|
+
|
|
1626
|
+
def _slash_menu_left_padding(self) -> int:
|
|
1627
|
+
if self._mode == PromptMode.SHELL:
|
|
1628
|
+
return max(1, get_cwidth(f"{PROMPT_SYMBOL_SHELL} ") - 2)
|
|
1629
|
+
# Agent mode: prompt prefix is "│ " (3 chars inside input panel)
|
|
1630
|
+
return 1
|
|
1631
|
+
|
|
1632
|
+
def _render_message(self) -> FormattedText:
|
|
1633
|
+
if self._mode == PromptMode.SHELL:
|
|
1634
|
+
return self._render_shell_prompt_message()
|
|
1635
|
+
return self._render_agent_prompt_message()
|
|
1636
|
+
|
|
1637
|
+
def _render_shell_prompt_message(self) -> FormattedText:
|
|
1638
|
+
app = get_app_or_none()
|
|
1639
|
+
columns = app.output.get_size().columns if app is not None else 80
|
|
1640
|
+
fragments: FormattedText = FormattedText()
|
|
1641
|
+
|
|
1642
|
+
# Agent status (always visible)
|
|
1643
|
+
agent_status = self._render_agent_status(columns)
|
|
1644
|
+
if agent_status:
|
|
1645
|
+
fragments.extend(agent_status)
|
|
1646
|
+
if not agent_status[-1][1].endswith("\n"):
|
|
1647
|
+
fragments.append(("", "\n"))
|
|
1648
|
+
|
|
1649
|
+
# Interactive body
|
|
1650
|
+
body = self._render_interactive_body(columns)
|
|
1651
|
+
if body:
|
|
1652
|
+
fragments.extend(body)
|
|
1653
|
+
if not body[-1][1].endswith("\n"):
|
|
1654
|
+
fragments.append(("", "\n"))
|
|
1655
|
+
|
|
1656
|
+
if self._active_modal_delegate() is not None:
|
|
1657
|
+
return fragments
|
|
1658
|
+
has_content = bool(agent_status or body)
|
|
1659
|
+
if has_content:
|
|
1660
|
+
fragments.append(("", "\n"))
|
|
1661
|
+
# Shell mode: simple separator + $ prefix (no panel border)
|
|
1662
|
+
fragments.append(("class:running-prompt-separator", "─" * max(0, columns)))
|
|
1663
|
+
fragments.append(("", "\n"))
|
|
1664
|
+
fragments.append(("bold", f"{PROMPT_SYMBOL_SHELL} "))
|
|
1665
|
+
return fragments
|
|
1666
|
+
|
|
1667
|
+
def _open_in_external_editor(self, event: KeyPressEvent) -> None:
|
|
1668
|
+
"""Open the current buffer content in an external editor."""
|
|
1669
|
+
from prompt_toolkit.application.run_in_terminal import run_in_terminal
|
|
1670
|
+
|
|
1671
|
+
from pythinker_code.utils.editor import edit_text_in_editor, get_editor_command
|
|
1672
|
+
|
|
1673
|
+
configured = self._editor_command_provider()
|
|
1674
|
+
|
|
1675
|
+
if get_editor_command(configured) is None:
|
|
1676
|
+
toast("No editor found. Set $VISUAL/$EDITOR or run /editor.")
|
|
1677
|
+
return
|
|
1678
|
+
|
|
1679
|
+
buff = event.current_buffer
|
|
1680
|
+
original_text = buff.text
|
|
1681
|
+
editor_text = self._get_placeholder_manager().expand_for_editor(original_text)
|
|
1682
|
+
|
|
1683
|
+
async def _run_editor() -> None:
|
|
1684
|
+
result = await run_in_terminal(
|
|
1685
|
+
lambda: edit_text_in_editor(editor_text, configured), in_executor=True
|
|
1686
|
+
)
|
|
1687
|
+
if result is not None:
|
|
1688
|
+
refolded = self._get_placeholder_manager().refold_after_editor(
|
|
1689
|
+
result, original_text
|
|
1690
|
+
)
|
|
1691
|
+
buff.document = Document(text=refolded, cursor_position=len(refolded))
|
|
1692
|
+
|
|
1693
|
+
event.app.create_background_task(_run_editor())
|
|
1694
|
+
|
|
1695
|
+
def _apply_mode(self, event: KeyPressEvent | None = None) -> None:
|
|
1696
|
+
# Apply mode to the active buffer (not the PromptSession itself)
|
|
1697
|
+
try:
|
|
1698
|
+
buff = event.current_buffer if event is not None else self._session.default_buffer
|
|
1699
|
+
except Exception:
|
|
1700
|
+
buff = None
|
|
1701
|
+
|
|
1702
|
+
if self._mode == PromptMode.SHELL:
|
|
1703
|
+
if buff is not None:
|
|
1704
|
+
buff.completer = self._shell_mode_completer
|
|
1705
|
+
else:
|
|
1706
|
+
if buff is not None:
|
|
1707
|
+
buff.completer = self._agent_mode_completer
|
|
1708
|
+
self._sync_erase_when_done()
|
|
1709
|
+
|
|
1710
|
+
def _sync_erase_when_done(self) -> None:
|
|
1711
|
+
app = getattr(self._session, "app", None)
|
|
1712
|
+
if app is not None:
|
|
1713
|
+
app.erase_when_done = self._mode == PromptMode.AGENT
|
|
1714
|
+
|
|
1715
|
+
def _active_modal_delegate(self) -> RunningPromptDelegate | None:
|
|
1716
|
+
modal_delegates = getattr(self, "_modal_delegates", [])
|
|
1717
|
+
if not modal_delegates:
|
|
1718
|
+
return None
|
|
1719
|
+
_, delegate = max(
|
|
1720
|
+
enumerate(modal_delegates),
|
|
1721
|
+
key=lambda item: (item[1].modal_priority, item[0]),
|
|
1722
|
+
)
|
|
1723
|
+
return delegate
|
|
1724
|
+
|
|
1725
|
+
def _active_prompt_delegate(self) -> RunningPromptDelegate | None:
|
|
1726
|
+
if delegate := self._active_modal_delegate():
|
|
1727
|
+
return delegate
|
|
1728
|
+
return getattr(self, "_running_prompt_delegate", None)
|
|
1729
|
+
|
|
1730
|
+
def _active_ui_state(self) -> PromptUIState:
|
|
1731
|
+
delegate = self._active_modal_delegate()
|
|
1732
|
+
if delegate is None:
|
|
1733
|
+
return PromptUIState.NORMAL_INPUT
|
|
1734
|
+
if delegate.running_prompt_hides_input_buffer():
|
|
1735
|
+
return PromptUIState.MODAL_HIDDEN_INPUT
|
|
1736
|
+
if delegate.running_prompt_allows_text_input():
|
|
1737
|
+
return PromptUIState.MODAL_TEXT_INPUT
|
|
1738
|
+
return PromptUIState.NORMAL_INPUT
|
|
1739
|
+
|
|
1740
|
+
def _should_render_input_buffer(self) -> bool:
|
|
1741
|
+
return self._active_ui_state() != PromptUIState.MODAL_HIDDEN_INPUT
|
|
1742
|
+
|
|
1743
|
+
def _should_handle_running_prompt_key(self, key: str) -> bool:
|
|
1744
|
+
delegate = self._active_prompt_delegate()
|
|
1745
|
+
return delegate is not None and delegate.should_handle_running_prompt_key(key)
|
|
1746
|
+
|
|
1747
|
+
def _handle_running_prompt_key(self, key: str, event: KeyPressEvent) -> None:
|
|
1748
|
+
delegate = self._active_prompt_delegate()
|
|
1749
|
+
if delegate is None:
|
|
1750
|
+
return
|
|
1751
|
+
delegate.handle_running_prompt_key(key, event)
|
|
1752
|
+
event.app.invalidate()
|
|
1753
|
+
|
|
1754
|
+
def invalidate(self) -> None:
|
|
1755
|
+
self._sync_prompt_ui_state()
|
|
1756
|
+
app = get_app_or_none()
|
|
1757
|
+
if app is not None:
|
|
1758
|
+
app.invalidate()
|
|
1759
|
+
|
|
1760
|
+
def _sync_prompt_ui_state(self) -> None:
|
|
1761
|
+
new_state = self._active_ui_state()
|
|
1762
|
+
old_state = getattr(self, "_last_ui_state", PromptUIState.NORMAL_INPUT)
|
|
1763
|
+
buffer = self._session.default_buffer
|
|
1764
|
+
|
|
1765
|
+
if (
|
|
1766
|
+
old_state != PromptUIState.MODAL_HIDDEN_INPUT
|
|
1767
|
+
and new_state == PromptUIState.MODAL_HIDDEN_INPUT
|
|
1768
|
+
):
|
|
1769
|
+
if self._suspended_buffer_document is None and buffer.text:
|
|
1770
|
+
self._suspended_buffer_document = buffer.document
|
|
1771
|
+
buffer.set_document(Document(), bypass_readonly=True)
|
|
1772
|
+
elif (
|
|
1773
|
+
old_state == PromptUIState.MODAL_HIDDEN_INPUT
|
|
1774
|
+
and new_state != PromptUIState.MODAL_HIDDEN_INPUT
|
|
1775
|
+
and self._suspended_buffer_document is not None
|
|
1776
|
+
):
|
|
1777
|
+
if not buffer.text:
|
|
1778
|
+
buffer.set_document(self._suspended_buffer_document, bypass_readonly=True)
|
|
1779
|
+
else:
|
|
1780
|
+
# Buffer was externally modified (e.g. approval inline feedback).
|
|
1781
|
+
# Don't overwrite the new content, but log that the old input is lost.
|
|
1782
|
+
logger.debug(
|
|
1783
|
+
"Dropping suspended buffer document because buffer was modified externally"
|
|
1784
|
+
)
|
|
1785
|
+
self._suspended_buffer_document = None
|
|
1786
|
+
|
|
1787
|
+
self._last_ui_state = new_state
|
|
1788
|
+
|
|
1789
|
+
def _render_agent_prompt_message(self) -> FormattedText:
|
|
1790
|
+
app = get_app_or_none()
|
|
1791
|
+
columns = app.output.get_size().columns if app is not None else 80
|
|
1792
|
+
fragments: FormattedText = FormattedText()
|
|
1793
|
+
|
|
1794
|
+
# 1. Agent status — ALWAYS rendered from running prompt delegate.
|
|
1795
|
+
# This ensures spinners, content blocks, tool calls etc. stay
|
|
1796
|
+
# visible even when a modal (btw/approval/question) is active.
|
|
1797
|
+
agent_status = self._render_agent_status(columns)
|
|
1798
|
+
if agent_status:
|
|
1799
|
+
fragments.extend(agent_status)
|
|
1800
|
+
if not agent_status[-1][1].endswith("\n"):
|
|
1801
|
+
fragments.append(("", "\n"))
|
|
1802
|
+
|
|
1803
|
+
# 2. Interactive area — from the active delegate (modal overrides).
|
|
1804
|
+
body = self._render_interactive_body(columns)
|
|
1805
|
+
if body:
|
|
1806
|
+
fragments.extend(body)
|
|
1807
|
+
if not body[-1][1].endswith("\n"):
|
|
1808
|
+
fragments.append(("", "\n"))
|
|
1809
|
+
|
|
1810
|
+
# 3. When a modal is active, skip input panel border.
|
|
1811
|
+
if self._active_modal_delegate() is not None:
|
|
1812
|
+
return fragments
|
|
1813
|
+
|
|
1814
|
+
# 4. Input section header — style varies by mode:
|
|
1815
|
+
# normal: ── input ───────────────── (grey, solid)
|
|
1816
|
+
# plan: ╌╌ input · plan ╌╌╌╌╌╌╌╌╌ (blue, dashed)
|
|
1817
|
+
status = self._status_provider()
|
|
1818
|
+
# Build title parts
|
|
1819
|
+
title_parts = ["input"]
|
|
1820
|
+
if status.plan_mode:
|
|
1821
|
+
title_parts.append("plan")
|
|
1822
|
+
# Queue count from running prompt delegate
|
|
1823
|
+
running = self._running_prompt_delegate
|
|
1824
|
+
queue_count = len(getattr(running, "_queued_messages", []))
|
|
1825
|
+
if queue_count > 0:
|
|
1826
|
+
title_parts.append(f"{queue_count} queued")
|
|
1827
|
+
title = f" {' · '.join(title_parts)} "
|
|
1828
|
+
if status.plan_mode:
|
|
1829
|
+
dash = "╌"
|
|
1830
|
+
style = "fg:#60a5fa" # blue
|
|
1831
|
+
else:
|
|
1832
|
+
dash = "─"
|
|
1833
|
+
style = "class:running-prompt-separator"
|
|
1834
|
+
border_fill = max(0, columns - len(title) - 2)
|
|
1835
|
+
top_border = f"{dash}{dash}{title}{dash * border_fill}"
|
|
1836
|
+
fragments.append(("", "\n"))
|
|
1837
|
+
fragments.append((style, top_border))
|
|
1838
|
+
fragments.append(("", "\n"))
|
|
1839
|
+
fragments.append(("", " "))
|
|
1840
|
+
return fragments
|
|
1841
|
+
|
|
1842
|
+
def _render_agent_status(self, columns: int) -> FormattedText:
|
|
1843
|
+
"""Render agent streaming output (always visible, independent of modals)."""
|
|
1844
|
+
running = self._running_prompt_delegate
|
|
1845
|
+
if running is not None and isinstance(running, AgentStatusProvider):
|
|
1846
|
+
return to_formatted_text(running.render_agent_status(columns))
|
|
1847
|
+
return self._render_status_block(columns)
|
|
1848
|
+
|
|
1849
|
+
def _render_interactive_body(self, columns: int) -> FormattedText:
|
|
1850
|
+
"""Render the interactive area from the active delegate (modal or running prompt)."""
|
|
1851
|
+
delegate = self._active_prompt_delegate()
|
|
1852
|
+
if delegate is None:
|
|
1853
|
+
return FormattedText([])
|
|
1854
|
+
return to_formatted_text(delegate.render_running_prompt_body(columns))
|
|
1855
|
+
|
|
1856
|
+
def _render_status_block(self, columns: int) -> FormattedText:
|
|
1857
|
+
status_block_provider = getattr(self, "_status_block_provider", None)
|
|
1858
|
+
if status_block_provider is None:
|
|
1859
|
+
return FormattedText([])
|
|
1860
|
+
block = status_block_provider(columns)
|
|
1861
|
+
if block is None:
|
|
1862
|
+
return FormattedText([])
|
|
1863
|
+
return to_formatted_text(block)
|
|
1864
|
+
|
|
1865
|
+
def _render_agent_prompt_label(self) -> FormattedText:
|
|
1866
|
+
"""Render the prompt label (empty — cursor starts at column 0)."""
|
|
1867
|
+
return FormattedText([("", " ")])
|
|
1868
|
+
|
|
1869
|
+
def __enter__(self) -> CustomPromptSession:
|
|
1870
|
+
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
1871
|
+
return self
|
|
1872
|
+
|
|
1873
|
+
async def _refresh() -> None:
|
|
1874
|
+
try:
|
|
1875
|
+
while True:
|
|
1876
|
+
app = get_app_or_none()
|
|
1877
|
+
if app is not None:
|
|
1878
|
+
app.invalidate()
|
|
1879
|
+
|
|
1880
|
+
try:
|
|
1881
|
+
asyncio.get_running_loop()
|
|
1882
|
+
except RuntimeError:
|
|
1883
|
+
logger.warning("No running loop found, exiting status refresh task")
|
|
1884
|
+
self._status_refresh_task = None
|
|
1885
|
+
break
|
|
1886
|
+
|
|
1887
|
+
interval = (
|
|
1888
|
+
_RUNNING_REFRESH_INTERVAL
|
|
1889
|
+
if self._active_prompt_delegate() is not None
|
|
1890
|
+
or (
|
|
1891
|
+
self._fast_refresh_provider is not None
|
|
1892
|
+
and self._fast_refresh_provider()
|
|
1893
|
+
)
|
|
1894
|
+
else _IDLE_REFRESH_INTERVAL
|
|
1895
|
+
)
|
|
1896
|
+
await asyncio.sleep(interval)
|
|
1897
|
+
except asyncio.CancelledError:
|
|
1898
|
+
# graceful exit
|
|
1899
|
+
pass
|
|
1900
|
+
|
|
1901
|
+
self._status_refresh_task = asyncio.create_task(_refresh())
|
|
1902
|
+
return self
|
|
1903
|
+
|
|
1904
|
+
def __exit__(self, *_) -> None:
|
|
1905
|
+
if self._status_refresh_task is not None and not self._status_refresh_task.done():
|
|
1906
|
+
self._status_refresh_task.cancel()
|
|
1907
|
+
self._status_refresh_task = None
|
|
1908
|
+
|
|
1909
|
+
def _get_placeholder_manager(self) -> PromptPlaceholderManager:
|
|
1910
|
+
manager = getattr(self, "_placeholder_manager", None)
|
|
1911
|
+
if manager is None:
|
|
1912
|
+
attachment_cache = getattr(self, "_attachment_cache", None)
|
|
1913
|
+
manager = PromptPlaceholderManager(attachment_cache=attachment_cache)
|
|
1914
|
+
self._placeholder_manager = manager
|
|
1915
|
+
self._attachment_cache = manager.attachment_cache
|
|
1916
|
+
return manager
|
|
1917
|
+
|
|
1918
|
+
def _insert_pasted_text(self, buffer: Buffer, text: str) -> None:
|
|
1919
|
+
normalized = normalize_pasted_text(text)
|
|
1920
|
+
if self._mode != PromptMode.AGENT:
|
|
1921
|
+
buffer.insert_text(normalized)
|
|
1922
|
+
return
|
|
1923
|
+
token_or_text = self._get_placeholder_manager().maybe_placeholderize_pasted_text(normalized)
|
|
1924
|
+
buffer.insert_text(token_or_text)
|
|
1925
|
+
|
|
1926
|
+
def _handle_bracketed_paste(self, event: KeyPressEvent) -> None:
|
|
1927
|
+
self._insert_pasted_text(event.current_buffer, event.data)
|
|
1928
|
+
event.app.invalidate()
|
|
1929
|
+
|
|
1930
|
+
def _try_paste_media(self, event: KeyPressEvent) -> bool:
|
|
1931
|
+
"""Try to paste media from the clipboard.
|
|
1932
|
+
|
|
1933
|
+
Reads the clipboard once and handles all detected content:
|
|
1934
|
+
non-image files (videos, PDFs, etc.) are inserted as paths,
|
|
1935
|
+
image files are cached and inserted as placeholders.
|
|
1936
|
+
Returns True if any media content was inserted.
|
|
1937
|
+
"""
|
|
1938
|
+
try:
|
|
1939
|
+
result = grab_media_from_clipboard()
|
|
1940
|
+
except Exception:
|
|
1941
|
+
# ImageGrab.grabclipboard() may fail on headless Linux if the
|
|
1942
|
+
# real xclip cannot connect to an X server. Silently ignore so
|
|
1943
|
+
# that the text-paste fallback can still be attempted.
|
|
1944
|
+
return False
|
|
1945
|
+
if result is None:
|
|
1946
|
+
return False
|
|
1947
|
+
|
|
1948
|
+
parts: list[str] = []
|
|
1949
|
+
|
|
1950
|
+
# 1. Insert file paths (videos, PDFs, etc.)
|
|
1951
|
+
if result.file_paths:
|
|
1952
|
+
logger.debug("Pasted {count} file path(s) from clipboard", count=len(result.file_paths))
|
|
1953
|
+
for p in result.file_paths:
|
|
1954
|
+
text = str(p)
|
|
1955
|
+
if self._mode == PromptMode.SHELL:
|
|
1956
|
+
text = shlex.quote(text)
|
|
1957
|
+
parts.append(text)
|
|
1958
|
+
|
|
1959
|
+
# 2. Insert images via cache.
|
|
1960
|
+
if result.images:
|
|
1961
|
+
if "image_in" not in self._model_capabilities:
|
|
1962
|
+
console.print(
|
|
1963
|
+
"[yellow]Image input is not supported by the selected LLM model[/yellow]"
|
|
1964
|
+
)
|
|
1965
|
+
else:
|
|
1966
|
+
for image in result.images:
|
|
1967
|
+
token = self._get_placeholder_manager().create_image_placeholder(image)
|
|
1968
|
+
if token is None:
|
|
1969
|
+
continue
|
|
1970
|
+
logger.debug(
|
|
1971
|
+
"Pasted image from clipboard placeholder: {token}, {image_size}",
|
|
1972
|
+
token=token,
|
|
1973
|
+
image_size=image.size,
|
|
1974
|
+
)
|
|
1975
|
+
parts.append(token)
|
|
1976
|
+
|
|
1977
|
+
if parts:
|
|
1978
|
+
event.current_buffer.insert_text(" ".join(parts))
|
|
1979
|
+
event.app.invalidate()
|
|
1980
|
+
return bool(parts)
|
|
1981
|
+
|
|
1982
|
+
def set_prefill_text(self, text: str) -> None:
|
|
1983
|
+
"""Pre-fill the input buffer with the given text.
|
|
1984
|
+
|
|
1985
|
+
Must be called after the prompt session is created but before the
|
|
1986
|
+
first prompt_async call. The text will appear as editable default
|
|
1987
|
+
input in the next prompt.
|
|
1988
|
+
"""
|
|
1989
|
+
self._prefill_text = text
|
|
1990
|
+
|
|
1991
|
+
async def prompt_next(self) -> UserInput:
|
|
1992
|
+
return await self._prompt_once(append_history=None)
|
|
1993
|
+
|
|
1994
|
+
@property
|
|
1995
|
+
def last_submission_was_running(self) -> bool:
|
|
1996
|
+
return getattr(self, "_last_submission_was_running", False)
|
|
1997
|
+
|
|
1998
|
+
def has_pending_input(self) -> bool:
|
|
1999
|
+
return bool(self._session.default_buffer.text)
|
|
2000
|
+
|
|
2001
|
+
def had_recent_input_activity(self, *, within_s: float) -> bool:
|
|
2002
|
+
if self._last_input_activity_time <= 0:
|
|
2003
|
+
return False
|
|
2004
|
+
return (time.monotonic() - self._last_input_activity_time) <= within_s
|
|
2005
|
+
|
|
2006
|
+
def recent_input_activity_remaining(self, *, within_s: float) -> float:
|
|
2007
|
+
if self._last_input_activity_time <= 0:
|
|
2008
|
+
return 0.0
|
|
2009
|
+
elapsed = time.monotonic() - self._last_input_activity_time
|
|
2010
|
+
return max(0.0, within_s - elapsed)
|
|
2011
|
+
|
|
2012
|
+
async def wait_for_input_activity(self) -> None:
|
|
2013
|
+
await self._input_activity_event.wait()
|
|
2014
|
+
self._input_activity_event.clear()
|
|
2015
|
+
|
|
2016
|
+
def attach_running_prompt(self, delegate: RunningPromptDelegate) -> None:
|
|
2017
|
+
current = getattr(self, "_running_prompt_delegate", None)
|
|
2018
|
+
if current is delegate:
|
|
2019
|
+
return
|
|
2020
|
+
if current is None:
|
|
2021
|
+
self._running_prompt_previous_mode = self._mode
|
|
2022
|
+
self._running_prompt_delegate = delegate
|
|
2023
|
+
self._mode = PromptMode.AGENT
|
|
2024
|
+
self._apply_mode()
|
|
2025
|
+
self.invalidate()
|
|
2026
|
+
|
|
2027
|
+
def detach_running_prompt(self, delegate: RunningPromptDelegate) -> None:
|
|
2028
|
+
if getattr(self, "_running_prompt_delegate", None) is not delegate:
|
|
2029
|
+
return
|
|
2030
|
+
previous_mode = getattr(self, "_running_prompt_previous_mode", None)
|
|
2031
|
+
self._running_prompt_delegate = None
|
|
2032
|
+
self._running_prompt_previous_mode = None
|
|
2033
|
+
if previous_mode is not None:
|
|
2034
|
+
self._mode = previous_mode
|
|
2035
|
+
self._apply_mode()
|
|
2036
|
+
self.invalidate()
|
|
2037
|
+
|
|
2038
|
+
def attach_modal(self, delegate: RunningPromptDelegate) -> None:
|
|
2039
|
+
modal_delegates: list[RunningPromptDelegate] | None = getattr(
|
|
2040
|
+
self, "_modal_delegates", None
|
|
2041
|
+
)
|
|
2042
|
+
if modal_delegates is None:
|
|
2043
|
+
modal_delegates = []
|
|
2044
|
+
self._modal_delegates = modal_delegates
|
|
2045
|
+
if delegate in modal_delegates:
|
|
2046
|
+
return
|
|
2047
|
+
modal_delegates.append(delegate)
|
|
2048
|
+
self.invalidate()
|
|
2049
|
+
|
|
2050
|
+
def detach_modal(self, delegate: RunningPromptDelegate) -> None:
|
|
2051
|
+
modal_delegates = getattr(self, "_modal_delegates", None)
|
|
2052
|
+
if not modal_delegates or delegate not in modal_delegates:
|
|
2053
|
+
return
|
|
2054
|
+
modal_delegates.remove(delegate)
|
|
2055
|
+
self.invalidate()
|
|
2056
|
+
|
|
2057
|
+
def running_prompt_accepts_submission(self) -> bool:
|
|
2058
|
+
delegate = self._active_prompt_delegate()
|
|
2059
|
+
if delegate is None:
|
|
2060
|
+
return False
|
|
2061
|
+
return delegate.running_prompt_accepts_submission()
|
|
2062
|
+
|
|
2063
|
+
async def _prompt_once(self, *, append_history: bool | None) -> UserInput:
|
|
2064
|
+
placeholder = None
|
|
2065
|
+
if (delegate := self._active_prompt_delegate()) is not None:
|
|
2066
|
+
placeholder = delegate.running_prompt_placeholder()
|
|
2067
|
+
# Consume one-shot prefill text if set
|
|
2068
|
+
default = getattr(self, "_prefill_text", None) or ""
|
|
2069
|
+
self._prefill_text = None
|
|
2070
|
+
with patch_stdout(raw=True):
|
|
2071
|
+
command = str(
|
|
2072
|
+
await self._session.prompt_async(placeholder=placeholder, default=default)
|
|
2073
|
+
).strip()
|
|
2074
|
+
command = command.replace("\x00", "") # just in case null bytes are somehow inserted
|
|
2075
|
+
# Sanitize UTF-16 surrogates that may come from Windows clipboard
|
|
2076
|
+
command = sanitize_surrogates(command)
|
|
2077
|
+
was_running = self.running_prompt_accepts_submission()
|
|
2078
|
+
self._last_submission_was_running = was_running
|
|
2079
|
+
if append_history is None:
|
|
2080
|
+
append_history = not was_running
|
|
2081
|
+
if append_history:
|
|
2082
|
+
self._append_history_entry(command)
|
|
2083
|
+
self._tip_rotation_index += 1
|
|
2084
|
+
return self._build_user_input(command)
|
|
2085
|
+
|
|
2086
|
+
def _build_user_input(self, command: str) -> UserInput:
|
|
2087
|
+
resolved = self._get_placeholder_manager().resolve_command(command)
|
|
2088
|
+
|
|
2089
|
+
return UserInput(
|
|
2090
|
+
mode=self._mode,
|
|
2091
|
+
command=resolved.display_command,
|
|
2092
|
+
resolved_command=resolved.resolved_text,
|
|
2093
|
+
content=resolved.content,
|
|
2094
|
+
)
|
|
2095
|
+
|
|
2096
|
+
def _append_history_entry(self, text: str) -> None:
|
|
2097
|
+
safe_history_text = self._get_placeholder_manager().serialize_for_history(text).strip()
|
|
2098
|
+
entry = _HistoryEntry(content=safe_history_text)
|
|
2099
|
+
if not entry.content:
|
|
2100
|
+
return
|
|
2101
|
+
|
|
2102
|
+
# skip if same as last entry
|
|
2103
|
+
if entry.content == self._last_history_content:
|
|
2104
|
+
return
|
|
2105
|
+
|
|
2106
|
+
try:
|
|
2107
|
+
self._history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
2108
|
+
with self._history_file.open("a", encoding="utf-8") as f:
|
|
2109
|
+
f.write(entry.model_dump_json(ensure_ascii=False) + "\n")
|
|
2110
|
+
self._last_history_content = entry.content
|
|
2111
|
+
except OSError as exc:
|
|
2112
|
+
logger.warning(
|
|
2113
|
+
"Failed to append user history entry: {file} ({error})",
|
|
2114
|
+
file=self._history_file,
|
|
2115
|
+
error=exc,
|
|
2116
|
+
)
|
|
2117
|
+
|
|
2118
|
+
def _render_bottom_toolbar(self) -> FormattedText:
|
|
2119
|
+
if (
|
|
2120
|
+
hasattr(self, "_session")
|
|
2121
|
+
and self._should_show_slash_completion_menu()
|
|
2122
|
+
and self._session.default_buffer.complete_state is not None
|
|
2123
|
+
):
|
|
2124
|
+
return FormattedText([])
|
|
2125
|
+
app = get_app_or_none()
|
|
2126
|
+
assert app is not None
|
|
2127
|
+
columns = app.output.get_size().columns
|
|
2128
|
+
|
|
2129
|
+
fragments: list[tuple[str, str]] = []
|
|
2130
|
+
tc = get_toolbar_colors()
|
|
2131
|
+
|
|
2132
|
+
fragments.append((tc.separator, "─" * columns))
|
|
2133
|
+
fragments.append(("", "\n"))
|
|
2134
|
+
|
|
2135
|
+
remaining = columns
|
|
2136
|
+
|
|
2137
|
+
# Time-based tip rotation (every 30 s, independent of user submissions)
|
|
2138
|
+
now = time.monotonic()
|
|
2139
|
+
if now - self._last_tip_rotate_time >= _TIP_ROTATE_INTERVAL:
|
|
2140
|
+
self._tip_rotation_index += 1
|
|
2141
|
+
self._last_tip_rotate_time = now
|
|
2142
|
+
|
|
2143
|
+
# Status flags: yolo / auto / plan
|
|
2144
|
+
status = self._status_provider()
|
|
2145
|
+
if status.yolo_enabled:
|
|
2146
|
+
fragments.extend([(tc.yolo_label, "yolo"), ("", " ")])
|
|
2147
|
+
remaining -= 6 # "yolo" = 4, " " = 2
|
|
2148
|
+
if status.auto_enabled:
|
|
2149
|
+
fragments.extend([(tc.auto_label, "auto"), ("", " ")])
|
|
2150
|
+
remaining -= 6 # "auto" = 4, " " = 2
|
|
2151
|
+
if status.plan_mode:
|
|
2152
|
+
fragments.extend([(tc.plan_label, "plan"), ("", " ")])
|
|
2153
|
+
remaining -= 6
|
|
2154
|
+
|
|
2155
|
+
# Mode indicator (agent / shell) + model name + thinking indicator.
|
|
2156
|
+
# Degrade gracefully on narrow terminals:
|
|
2157
|
+
# full: "agent (model-name ○)" → mid: "agent ○" → bare: "agent"
|
|
2158
|
+
mode = str(self._mode)
|
|
2159
|
+
if self._mode == PromptMode.AGENT and self._model_name:
|
|
2160
|
+
thinking_dot = "●" if self._thinking else "○"
|
|
2161
|
+
mode_full = f"{mode} ({self._model_name} {thinking_dot})"
|
|
2162
|
+
mode_mid = f"{mode} {thinking_dot}"
|
|
2163
|
+
if _display_width(mode_full) <= remaining - 2:
|
|
2164
|
+
mode = mode_full
|
|
2165
|
+
elif _display_width(mode_mid) <= remaining - 2:
|
|
2166
|
+
mode = mode_mid
|
|
2167
|
+
# else: keep bare mode name — model_name and dot are both dropped
|
|
2168
|
+
fragments.extend([("", mode), ("", " ")])
|
|
2169
|
+
remaining -= _display_width(mode) + 2
|
|
2170
|
+
|
|
2171
|
+
# CWD (truncated from left) + git branch with status badge
|
|
2172
|
+
# Degrade gracefully on narrow terminals: full → cwd-only → truncated cwd → skip
|
|
2173
|
+
try:
|
|
2174
|
+
cwd = _truncate_left(_shorten_cwd(str(HostPath.cwd())), _MAX_CWD_COLS)
|
|
2175
|
+
except OSError:
|
|
2176
|
+
# CWD no longer exists (e.g. external drive unplugged). Ask
|
|
2177
|
+
# prompt_toolkit to exit; the raised exception will propagate out
|
|
2178
|
+
# of prompt_async() into the Shell's event router which prints a
|
|
2179
|
+
# crash report with session info and exits cleanly.
|
|
2180
|
+
app.exit(exception=CwdLostError())
|
|
2181
|
+
return FormattedText([])
|
|
2182
|
+
branch = _get_git_branch()
|
|
2183
|
+
if branch:
|
|
2184
|
+
dirty, ahead, behind = _get_git_status()
|
|
2185
|
+
branch = _truncate_right(branch, _MAX_BRANCH_COLS)
|
|
2186
|
+
badge = _format_git_badge(branch, dirty, ahead, behind)
|
|
2187
|
+
cwd_text = f"{cwd} {badge}"
|
|
2188
|
+
else:
|
|
2189
|
+
cwd_text = cwd
|
|
2190
|
+
cwd_w = _display_width(cwd_text)
|
|
2191
|
+
if cwd_w > remaining - 2:
|
|
2192
|
+
cwd_text = cwd # drop badge
|
|
2193
|
+
cwd_w = _display_width(cwd_text)
|
|
2194
|
+
if cwd_w > remaining - 2:
|
|
2195
|
+
cwd_text = _truncate_right(cwd, max(0, remaining - 2))
|
|
2196
|
+
cwd_w = _display_width(cwd_text)
|
|
2197
|
+
if cwd_text and remaining >= cwd_w + 2:
|
|
2198
|
+
fragments.extend([(tc.cwd, cwd_text), ("", " ")])
|
|
2199
|
+
remaining -= cwd_w + 2
|
|
2200
|
+
|
|
2201
|
+
# Active background task counts (bash + agent, each rendered as its own
|
|
2202
|
+
# badge). Order matters: bash renders first; if there isn't room for the
|
|
2203
|
+
# agent badge too, drop agent and keep bash.
|
|
2204
|
+
bg_counts = (
|
|
2205
|
+
self._background_task_count_provider()
|
|
2206
|
+
if self._background_task_count_provider
|
|
2207
|
+
else BgTaskCounts()
|
|
2208
|
+
)
|
|
2209
|
+
for kind_label, kind_count in (("bash", bg_counts.bash), ("agent", bg_counts.agent)):
|
|
2210
|
+
if kind_count <= 0:
|
|
2211
|
+
continue
|
|
2212
|
+
bg_text = f"⚙ {kind_label}: {kind_count}"
|
|
2213
|
+
bg_width = _display_width(bg_text)
|
|
2214
|
+
if remaining < bg_width + 2:
|
|
2215
|
+
break
|
|
2216
|
+
fragments.extend([(tc.bg_tasks, bg_text), ("", " ")])
|
|
2217
|
+
remaining -= bg_width + 2
|
|
2218
|
+
|
|
2219
|
+
# Tips fill remaining space on line 1
|
|
2220
|
+
tip_text = self._get_two_rotating_tips()
|
|
2221
|
+
if tip_text and _display_width(tip_text) > remaining:
|
|
2222
|
+
tip_text = self._get_one_rotating_tip()
|
|
2223
|
+
if tip_text and _display_width(tip_text) <= remaining:
|
|
2224
|
+
fragments.append((tc.tip, tip_text))
|
|
2225
|
+
|
|
2226
|
+
# ── line 2: toast (left) + context (right) — always rendered ──────
|
|
2227
|
+
fragments.append(("", "\n"))
|
|
2228
|
+
|
|
2229
|
+
right_text = self._render_right_span(status)
|
|
2230
|
+
right_width = _display_width(right_text)
|
|
2231
|
+
|
|
2232
|
+
left_toast = _current_toast("left")
|
|
2233
|
+
if left_toast is not None:
|
|
2234
|
+
max_left = max(0, columns - right_width - 2)
|
|
2235
|
+
if max_left > 0:
|
|
2236
|
+
left_text = left_toast.message
|
|
2237
|
+
if _display_width(left_text) > max_left:
|
|
2238
|
+
left_text = _truncate_right(left_text, max_left)
|
|
2239
|
+
left_width = _display_width(left_text)
|
|
2240
|
+
fragments.append(("", left_text))
|
|
2241
|
+
else:
|
|
2242
|
+
left_width = 0
|
|
2243
|
+
else:
|
|
2244
|
+
left_width = 0
|
|
2245
|
+
|
|
2246
|
+
fragments.append(("", " " * max(0, columns - left_width - right_width)))
|
|
2247
|
+
fragments.append(("", right_text))
|
|
2248
|
+
|
|
2249
|
+
return FormattedText(fragments)
|
|
2250
|
+
|
|
2251
|
+
def _get_two_rotating_tips(self) -> str | None:
|
|
2252
|
+
"""Return a string with exactly 2 tips from the rotation, or fewer if not enough."""
|
|
2253
|
+
n = len(self._tips)
|
|
2254
|
+
if n == 0:
|
|
2255
|
+
return None
|
|
2256
|
+
if n == 1:
|
|
2257
|
+
return self._tips[0]
|
|
2258
|
+
offset = self._tip_rotation_index % n
|
|
2259
|
+
tip1 = self._tips[offset]
|
|
2260
|
+
tip2 = self._tips[(offset + 1) % n]
|
|
2261
|
+
return f"{tip1}{_TIP_SEPARATOR}{tip2}"
|
|
2262
|
+
|
|
2263
|
+
def _get_one_rotating_tip(self) -> str | None:
|
|
2264
|
+
"""Return the single leading tip for the current rotation."""
|
|
2265
|
+
if not self._tips:
|
|
2266
|
+
return None
|
|
2267
|
+
return self._tips[self._tip_rotation_index % len(self._tips)]
|
|
2268
|
+
|
|
2269
|
+
@staticmethod
|
|
2270
|
+
def _render_right_span(status: StatusSnapshot) -> str:
|
|
2271
|
+
current_toast = _current_toast("right")
|
|
2272
|
+
if current_toast is None:
|
|
2273
|
+
return format_context_status(
|
|
2274
|
+
status.context_usage,
|
|
2275
|
+
status.context_tokens,
|
|
2276
|
+
status.max_context_tokens,
|
|
2277
|
+
)
|
|
2278
|
+
return current_toast.message
|