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,1696 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from pythinker_core.chat_provider import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIEmptyResponseError,
|
|
17
|
+
APIStatusError,
|
|
18
|
+
APITimeoutError,
|
|
19
|
+
ChatProviderError,
|
|
20
|
+
)
|
|
21
|
+
from rich.console import Group, RenderableType
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
from pythinker_code.background import list_task_views
|
|
27
|
+
from pythinker_code.llm import model_display_name
|
|
28
|
+
from pythinker_code.notifications import NotificationManager, NotificationWatcher
|
|
29
|
+
from pythinker_code.soul import (
|
|
30
|
+
LLMNotSet,
|
|
31
|
+
LLMNotSupported,
|
|
32
|
+
MaxStepsReached,
|
|
33
|
+
RunCancelled,
|
|
34
|
+
Soul,
|
|
35
|
+
run_soul,
|
|
36
|
+
)
|
|
37
|
+
from pythinker_code.soul.pythinkersoul import FLOW_COMMAND_PREFIX, PythinkerSoul
|
|
38
|
+
from pythinker_code.ui.shell import update as _update_mod
|
|
39
|
+
from pythinker_code.ui.shell.console import console
|
|
40
|
+
from pythinker_code.ui.shell.echo import render_user_echo_text
|
|
41
|
+
from pythinker_code.ui.shell.mcp_status import render_mcp_prompt
|
|
42
|
+
from pythinker_code.ui.shell.prompt import (
|
|
43
|
+
BgTaskCounts,
|
|
44
|
+
CustomPromptSession,
|
|
45
|
+
CwdLostError,
|
|
46
|
+
PromptMode,
|
|
47
|
+
UserInput,
|
|
48
|
+
toast,
|
|
49
|
+
)
|
|
50
|
+
from pythinker_code.ui.shell.replay import replay_recent_history
|
|
51
|
+
from pythinker_code.ui.shell.slash import SKILL_COMMAND_PREFIX, shell_mode_registry
|
|
52
|
+
from pythinker_code.ui.shell.slash import registry as shell_slash_registry
|
|
53
|
+
from pythinker_code.ui.shell.update import LATEST_VERSION_FILE, UpdateResult, do_update, semver_tuple
|
|
54
|
+
from pythinker_code.ui.shell.visualize import (
|
|
55
|
+
ApprovalPromptDelegate,
|
|
56
|
+
visualize,
|
|
57
|
+
)
|
|
58
|
+
from pythinker_code.utils.aioqueue import QueueShutDown
|
|
59
|
+
from pythinker_code.utils.envvar import get_env_bool
|
|
60
|
+
from pythinker_code.utils.logging import logger, open_original_stderr
|
|
61
|
+
from pythinker_code.utils.signals import install_sigint_handler
|
|
62
|
+
from pythinker_code.utils.slashcmd import SlashCommand, SlashCommandCall, parse_slash_command_call
|
|
63
|
+
from pythinker_code.utils.subprocess_env import get_clean_env
|
|
64
|
+
from pythinker_code.utils.term import ensure_new_line, ensure_tty_sane
|
|
65
|
+
from pythinker_code.wire.types import (
|
|
66
|
+
ApprovalRequest,
|
|
67
|
+
ApprovalResponse,
|
|
68
|
+
ContentPart,
|
|
69
|
+
StatusUpdate,
|
|
70
|
+
WireMessage,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class _PromptEvent:
|
|
76
|
+
kind: str
|
|
77
|
+
user_input: UserInput | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_MAX_BG_AUTO_TRIGGER_FAILURES = 3
|
|
81
|
+
"""Stop auto-triggering after this many consecutive failures."""
|
|
82
|
+
|
|
83
|
+
_BG_AUTO_TRIGGER_INPUT_GRACE_S = 0.75
|
|
84
|
+
"""Delay background auto-trigger briefly after local prompt activity."""
|
|
85
|
+
|
|
86
|
+
_VISIBLE_WORKFLOW_SLASH_PREFIXES = (SKILL_COMMAND_PREFIX, FLOW_COMMAND_PREFIX)
|
|
87
|
+
"""Explicit skill/flow prefixes that should remain visible in transcript."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _BackgroundCompletionWatcher:
|
|
91
|
+
"""Watches for background task completions and auto-triggers the agent.
|
|
92
|
+
|
|
93
|
+
Sits between the idle event loop and the soul: when a background task
|
|
94
|
+
finishes while the agent is idle *and* the LLM hasn't consumed the
|
|
95
|
+
notification yet, it triggers a soul run.
|
|
96
|
+
|
|
97
|
+
Important: pre-existing pending notifications alone should not trigger a
|
|
98
|
+
foreground run immediately on session resume. They are consumed either by
|
|
99
|
+
the next actual background completion signal or by the next user-triggered
|
|
100
|
+
turn.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
soul: Soul,
|
|
106
|
+
*,
|
|
107
|
+
can_auto_trigger_pending: Callable[[], bool] | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._event: asyncio.Event | None = None
|
|
110
|
+
self._notifications: NotificationManager | None = None
|
|
111
|
+
self._can_auto_trigger_pending = can_auto_trigger_pending or (lambda: True)
|
|
112
|
+
if isinstance(soul, PythinkerSoul):
|
|
113
|
+
self._event = soul.runtime.background_tasks.completion_event
|
|
114
|
+
self._notifications = soul.runtime.notifications
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def enabled(self) -> bool:
|
|
118
|
+
return self._event is not None
|
|
119
|
+
|
|
120
|
+
def clear(self) -> None:
|
|
121
|
+
"""Clear stale signals from the previous soul run."""
|
|
122
|
+
if self._event is not None:
|
|
123
|
+
self._event.clear()
|
|
124
|
+
|
|
125
|
+
async def wait_for_next(self, idle_events: asyncio.Queue[_PromptEvent]) -> _PromptEvent | None:
|
|
126
|
+
"""Wait for either a user prompt event or a background completion.
|
|
127
|
+
|
|
128
|
+
Returns the prompt event if user input arrived first, or ``None``
|
|
129
|
+
if a background task completed with unclaimed LLM notifications.
|
|
130
|
+
User input always takes priority over background completions.
|
|
131
|
+
"""
|
|
132
|
+
if self.enabled and self._has_pending_llm_notifications():
|
|
133
|
+
# Pending notifications already exist (for example after resume).
|
|
134
|
+
# Before the user sends the first foreground turn after resume,
|
|
135
|
+
# pending background notifications should not auto-trigger a run.
|
|
136
|
+
# Once the shell is armed by a user-triggered turn, pending
|
|
137
|
+
# notifications can resume the normal auto-follow-up behavior.
|
|
138
|
+
try:
|
|
139
|
+
return idle_events.get_nowait()
|
|
140
|
+
except asyncio.QueueEmpty:
|
|
141
|
+
if self._can_auto_trigger_pending():
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
idle_task = asyncio.create_task(idle_events.get())
|
|
145
|
+
if not self.enabled:
|
|
146
|
+
return await idle_task
|
|
147
|
+
|
|
148
|
+
assert self._event is not None
|
|
149
|
+
bg_wait_task = asyncio.create_task(self._event.wait())
|
|
150
|
+
|
|
151
|
+
done, _ = await asyncio.wait(
|
|
152
|
+
[idle_task, bg_wait_task],
|
|
153
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
154
|
+
)
|
|
155
|
+
for t in (idle_task, bg_wait_task):
|
|
156
|
+
if t not in done:
|
|
157
|
+
t.cancel()
|
|
158
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
159
|
+
await t
|
|
160
|
+
|
|
161
|
+
if idle_task in done:
|
|
162
|
+
if bg_wait_task in done:
|
|
163
|
+
self._event.clear()
|
|
164
|
+
return idle_task.result()
|
|
165
|
+
|
|
166
|
+
# Only bg fired
|
|
167
|
+
self._event.clear()
|
|
168
|
+
if self._has_pending_llm_notifications():
|
|
169
|
+
if self._can_auto_trigger_pending():
|
|
170
|
+
return None
|
|
171
|
+
return _PromptEvent(kind="bg_noop")
|
|
172
|
+
return _PromptEvent(kind="bg_noop")
|
|
173
|
+
|
|
174
|
+
def _has_pending_llm_notifications(self) -> bool:
|
|
175
|
+
if self._notifications is None:
|
|
176
|
+
return False
|
|
177
|
+
return self._notifications.has_pending_for_sink("llm")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _BackgroundAutoTriggerPromptState(Protocol):
|
|
181
|
+
def has_pending_input(self) -> bool: ...
|
|
182
|
+
|
|
183
|
+
def had_recent_input_activity(self, *, within_s: float) -> bool: ...
|
|
184
|
+
|
|
185
|
+
def recent_input_activity_remaining(self, *, within_s: float) -> float: ...
|
|
186
|
+
|
|
187
|
+
async def wait_for_input_activity(self) -> None: ...
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_LM_STUDIO_NCTX_RE = re.compile(r"n_keep:\s*(\d+)\s*>=\s*n_ctx:\s*(\d+)")
|
|
191
|
+
_LM_STUDIO_LOAD_FAILED_RE = re.compile(r'Failed to load model\s+"([^"]+)"', re.IGNORECASE)
|
|
192
|
+
_LM_STUDIO_JINJA_ERROR_RE = re.compile(r"Error rendering prompt with jinja template", re.IGNORECASE)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _is_lm_studio_context_too_small(exc: BaseException) -> bool:
|
|
196
|
+
"""Detect LM Studio's `n_keep:N >= n_ctx:M` error pattern.
|
|
197
|
+
|
|
198
|
+
LM Studio returns an HTTP 400 with this message when the loaded
|
|
199
|
+
context length is smaller than the prompt the agent is trying to send.
|
|
200
|
+
"""
|
|
201
|
+
return _LM_STUDIO_NCTX_RE.search(str(exc)) is not None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_n_keep_n_ctx(message: str) -> tuple[int, int]:
|
|
205
|
+
"""Extract (n_keep, n_ctx) from an LM Studio context-too-small error.
|
|
206
|
+
|
|
207
|
+
Returns (0, 0) if the pattern doesn't match — caller should have
|
|
208
|
+
gated on `_is_lm_studio_context_too_small` first.
|
|
209
|
+
"""
|
|
210
|
+
match = _LM_STUDIO_NCTX_RE.search(message)
|
|
211
|
+
if match is None:
|
|
212
|
+
return (0, 0)
|
|
213
|
+
return (int(match.group(1)), int(match.group(2)))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _is_lm_studio_load_failed(exc: BaseException) -> bool:
|
|
217
|
+
"""Detect LM Studio's `Failed to load model "<id>"` pattern.
|
|
218
|
+
|
|
219
|
+
LM Studio returns this when JIT-loading on a chat request fails — usually
|
|
220
|
+
VRAM exhaustion, but also: model file corrupted, model not compatible
|
|
221
|
+
with the runtime, or the user manually evicted the model.
|
|
222
|
+
"""
|
|
223
|
+
return _LM_STUDIO_LOAD_FAILED_RE.search(str(exc)) is not None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _parse_lm_studio_load_failed_model(message: str) -> str:
|
|
227
|
+
"""Extract the failing model id; returns '' if the pattern doesn't match."""
|
|
228
|
+
match = _LM_STUDIO_LOAD_FAILED_RE.search(message)
|
|
229
|
+
return match.group(1) if match else ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _is_lm_studio_jinja_template_error(exc: BaseException) -> bool:
|
|
233
|
+
"""Detect LM Studio's jinja-template rendering errors.
|
|
234
|
+
|
|
235
|
+
Many GGUF prompt templates are buggy or version-mismatched (e.g., apply
|
|
236
|
+
string filter to a null value). The fix is on LM Studio's side — either
|
|
237
|
+
switch model variant or override the template — so Pythinker can only
|
|
238
|
+
point the user at the right place.
|
|
239
|
+
"""
|
|
240
|
+
return _LM_STUDIO_JINJA_ERROR_RE.search(str(exc)) is not None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Shell:
|
|
244
|
+
def __init__(
|
|
245
|
+
self,
|
|
246
|
+
soul: Soul,
|
|
247
|
+
welcome_info: list[WelcomeInfoItem] | None = None,
|
|
248
|
+
prefill_text: str | None = None,
|
|
249
|
+
):
|
|
250
|
+
self.soul = soul
|
|
251
|
+
self._welcome_info = list(welcome_info or [])
|
|
252
|
+
self._prefill_text = prefill_text
|
|
253
|
+
self._background_tasks: set[asyncio.Task[Any]] = set()
|
|
254
|
+
self._prompt_session: CustomPromptSession | None = None
|
|
255
|
+
self._running_input_handler: Callable[[UserInput], None] | None = None
|
|
256
|
+
self._running_interrupt_handler: Callable[[], None] | None = None
|
|
257
|
+
self._active_approval_sink: Any | None = None
|
|
258
|
+
self._active_view: Any | None = None
|
|
259
|
+
self._pending_approval_requests = deque[ApprovalRequest]()
|
|
260
|
+
self._current_prompt_approval_request: ApprovalRequest | None = None
|
|
261
|
+
self._approval_modal: ApprovalPromptDelegate | None = None
|
|
262
|
+
self._exit_after_run = False
|
|
263
|
+
self._available_slash_commands: dict[str, SlashCommand[Any]] = {
|
|
264
|
+
**{cmd.name: cmd for cmd in soul.available_slash_commands},
|
|
265
|
+
**{cmd.name: cmd for cmd in shell_slash_registry.list_commands()},
|
|
266
|
+
}
|
|
267
|
+
"""Shell-level slash commands + soul-level slash commands. Name to command mapping."""
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def available_slash_commands(self) -> dict[str, SlashCommand[Any]]:
|
|
271
|
+
"""Get all available slash commands, including shell-level and soul-level commands."""
|
|
272
|
+
return self._available_slash_commands
|
|
273
|
+
|
|
274
|
+
def _print_cwd_lost_crash(self) -> None:
|
|
275
|
+
"""Print a crash report when the working directory is no longer accessible."""
|
|
276
|
+
runtime = self.soul.runtime if isinstance(self.soul, PythinkerSoul) else None
|
|
277
|
+
session_id = runtime.session.id if runtime else "unknown"
|
|
278
|
+
work_dir = str(runtime.session.work_dir) if runtime else "unknown"
|
|
279
|
+
|
|
280
|
+
info = Table.grid(padding=(0, 1))
|
|
281
|
+
info.add_row("Session:", session_id)
|
|
282
|
+
info.add_row("Working directory:", work_dir)
|
|
283
|
+
|
|
284
|
+
panel = Panel(
|
|
285
|
+
Group(
|
|
286
|
+
Text(
|
|
287
|
+
"The working directory is no longer accessible "
|
|
288
|
+
"(external drive unplugged, directory deleted, or filesystem unmounted).",
|
|
289
|
+
),
|
|
290
|
+
Text(""),
|
|
291
|
+
info,
|
|
292
|
+
Text(""),
|
|
293
|
+
Text(
|
|
294
|
+
"Your conversation history has been saved. "
|
|
295
|
+
"Restart pythinker in a valid directory to continue.",
|
|
296
|
+
style="dim",
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
title="[bold red]Session crashed[/bold red]",
|
|
300
|
+
border_style="red",
|
|
301
|
+
)
|
|
302
|
+
console.print()
|
|
303
|
+
console.print(panel)
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _should_exit_input(user_input: UserInput | str) -> bool:
|
|
307
|
+
command = user_input if isinstance(user_input, str) else user_input.command
|
|
308
|
+
return command.strip() in {"exit", "quit", "/exit", "/quit"}
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _agent_slash_command_call(user_input: UserInput) -> SlashCommandCall | None:
|
|
312
|
+
if user_input.mode != PromptMode.AGENT:
|
|
313
|
+
return None
|
|
314
|
+
display_call = parse_slash_command_call(user_input.command)
|
|
315
|
+
if display_call is None:
|
|
316
|
+
return None
|
|
317
|
+
resolved_call = parse_slash_command_call(user_input.resolved_command)
|
|
318
|
+
if resolved_call is None or resolved_call.name != display_call.name:
|
|
319
|
+
return display_call
|
|
320
|
+
return resolved_call
|
|
321
|
+
|
|
322
|
+
@staticmethod
|
|
323
|
+
def _should_echo_workflow_slash_input(user_input: UserInput) -> bool:
|
|
324
|
+
command_call = Shell._agent_slash_command_call(user_input)
|
|
325
|
+
return command_call is not None and command_call.name.startswith(
|
|
326
|
+
_VISIBLE_WORKFLOW_SLASH_PREFIXES
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _should_echo_agent_input(self, user_input: UserInput) -> bool:
|
|
330
|
+
if user_input.mode != PromptMode.AGENT:
|
|
331
|
+
return False
|
|
332
|
+
if Shell._should_exit_input(user_input):
|
|
333
|
+
return False
|
|
334
|
+
# Phase 1 policy: keep operational slash commands hidden, but show
|
|
335
|
+
# explicit `/skill:*` and `/flow:*` inputs because they represent
|
|
336
|
+
# user-visible workflow intent and otherwise vanish from transcript
|
|
337
|
+
# even when the command later fails to resolve.
|
|
338
|
+
if self._should_echo_workflow_slash_input(user_input):
|
|
339
|
+
return True
|
|
340
|
+
return Shell._agent_slash_command_call(user_input) is None
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _echo_agent_input(user_input: UserInput) -> None:
|
|
344
|
+
console.print(render_user_echo_text(user_input.command))
|
|
345
|
+
|
|
346
|
+
def _bind_running_input(
|
|
347
|
+
self,
|
|
348
|
+
on_input: Callable[[UserInput], None],
|
|
349
|
+
on_interrupt: Callable[[], None],
|
|
350
|
+
) -> None:
|
|
351
|
+
self._running_input_handler = on_input
|
|
352
|
+
self._running_interrupt_handler = on_interrupt
|
|
353
|
+
|
|
354
|
+
def _unbind_running_input(self) -> None:
|
|
355
|
+
self._running_input_handler = None
|
|
356
|
+
self._running_interrupt_handler = None
|
|
357
|
+
|
|
358
|
+
async def _route_prompt_events(
|
|
359
|
+
self,
|
|
360
|
+
prompt_session: CustomPromptSession,
|
|
361
|
+
idle_events: asyncio.Queue[_PromptEvent],
|
|
362
|
+
resume_prompt: asyncio.Event,
|
|
363
|
+
) -> None:
|
|
364
|
+
while True:
|
|
365
|
+
# Keep exactly one active prompt read. Idle submissions pause the
|
|
366
|
+
# router until the shell decides whether the next prompt should
|
|
367
|
+
# wait for a blocking action or stay live during an agent run.
|
|
368
|
+
await resume_prompt.wait()
|
|
369
|
+
ensure_tty_sane()
|
|
370
|
+
try:
|
|
371
|
+
ensure_new_line()
|
|
372
|
+
user_input = await prompt_session.prompt_next()
|
|
373
|
+
except KeyboardInterrupt:
|
|
374
|
+
logger.debug("Prompt router got KeyboardInterrupt")
|
|
375
|
+
if (
|
|
376
|
+
self._running_input_handler is not None
|
|
377
|
+
and prompt_session.running_prompt_accepts_submission()
|
|
378
|
+
):
|
|
379
|
+
if self._running_interrupt_handler is not None:
|
|
380
|
+
self._running_interrupt_handler()
|
|
381
|
+
continue
|
|
382
|
+
resume_prompt.clear()
|
|
383
|
+
await idle_events.put(_PromptEvent(kind="interrupt"))
|
|
384
|
+
continue
|
|
385
|
+
except EOFError:
|
|
386
|
+
logger.debug("Prompt router got EOF")
|
|
387
|
+
if (
|
|
388
|
+
self._running_input_handler is not None
|
|
389
|
+
and prompt_session.running_prompt_accepts_submission()
|
|
390
|
+
):
|
|
391
|
+
self._exit_after_run = True
|
|
392
|
+
if self._running_interrupt_handler is not None:
|
|
393
|
+
self._running_interrupt_handler()
|
|
394
|
+
return
|
|
395
|
+
resume_prompt.clear()
|
|
396
|
+
await idle_events.put(_PromptEvent(kind="eof"))
|
|
397
|
+
return
|
|
398
|
+
except CwdLostError:
|
|
399
|
+
logger.error("Working directory no longer exists")
|
|
400
|
+
resume_prompt.clear()
|
|
401
|
+
await idle_events.put(_PromptEvent(kind="cwd_lost"))
|
|
402
|
+
return
|
|
403
|
+
except Exception:
|
|
404
|
+
logger.exception("Prompt router crashed")
|
|
405
|
+
resume_prompt.clear()
|
|
406
|
+
await idle_events.put(_PromptEvent(kind="error"))
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
if prompt_session.last_submission_was_running: # noqa: SIM102
|
|
410
|
+
if self._running_input_handler is not None:
|
|
411
|
+
if user_input:
|
|
412
|
+
self._running_input_handler(user_input)
|
|
413
|
+
continue
|
|
414
|
+
# Handler already unbound — fall through to idle path.
|
|
415
|
+
|
|
416
|
+
resume_prompt.clear()
|
|
417
|
+
await idle_events.put(_PromptEvent(kind="input", user_input=user_input))
|
|
418
|
+
|
|
419
|
+
async def run(self, command: str | None = None) -> bool:
|
|
420
|
+
_run_start_time = time.monotonic()
|
|
421
|
+
|
|
422
|
+
# Initialize theme from config
|
|
423
|
+
if isinstance(self.soul, PythinkerSoul):
|
|
424
|
+
from pythinker_code.ui.theme import set_active_theme
|
|
425
|
+
|
|
426
|
+
set_active_theme(self.soul.runtime.config.theme)
|
|
427
|
+
|
|
428
|
+
if command is not None:
|
|
429
|
+
# run single command and exit
|
|
430
|
+
logger.info("Running agent with command: {command}", command=command)
|
|
431
|
+
if isinstance(self.soul, PythinkerSoul):
|
|
432
|
+
self._start_background_task(self._watch_root_wire_hub())
|
|
433
|
+
try:
|
|
434
|
+
if self._should_exit_input(command):
|
|
435
|
+
console.print("Bye!")
|
|
436
|
+
return True
|
|
437
|
+
if (slash_cmd_call := parse_slash_command_call(command)) and (
|
|
438
|
+
shell_slash_registry.find_command(slash_cmd_call.name)
|
|
439
|
+
):
|
|
440
|
+
await self._run_slash_command(slash_cmd_call)
|
|
441
|
+
return True
|
|
442
|
+
return await self.run_soul_command(command)
|
|
443
|
+
finally:
|
|
444
|
+
self._cancel_background_tasks()
|
|
445
|
+
|
|
446
|
+
# Start auto-update background task if not disabled
|
|
447
|
+
if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"):
|
|
448
|
+
logger.info("Auto-update disabled by PYTHINKER_CLI_NO_AUTO_UPDATE environment variable")
|
|
449
|
+
else:
|
|
450
|
+
self._start_background_task(self._auto_update())
|
|
451
|
+
|
|
452
|
+
_print_welcome_info(self.soul.name or "Pythinker CLI", self._welcome_info)
|
|
453
|
+
|
|
454
|
+
# Start telemetry periodic flush and disk retry
|
|
455
|
+
from pythinker_code.telemetry import get_sink
|
|
456
|
+
|
|
457
|
+
_telemetry_sink = get_sink()
|
|
458
|
+
if _telemetry_sink is not None:
|
|
459
|
+
_telemetry_sink.start_periodic_flush()
|
|
460
|
+
self._start_background_task(_telemetry_sink.retry_disk_events())
|
|
461
|
+
|
|
462
|
+
if isinstance(self.soul, PythinkerSoul):
|
|
463
|
+
watcher = NotificationWatcher(
|
|
464
|
+
self.soul.runtime.notifications,
|
|
465
|
+
sink="shell",
|
|
466
|
+
before_poll=self.soul.runtime.background_tasks.reconcile,
|
|
467
|
+
on_notification=lambda notification: toast(
|
|
468
|
+
f"[{notification.event.type}] {notification.event.title}",
|
|
469
|
+
topic="notification",
|
|
470
|
+
duration=10.0,
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
self._start_background_task(watcher.run_forever())
|
|
474
|
+
self._start_background_task(self._watch_root_wire_hub())
|
|
475
|
+
await replay_recent_history(
|
|
476
|
+
self.soul.context.history,
|
|
477
|
+
wire_file=self.soul.wire_file,
|
|
478
|
+
show_thinking_stream=self.soul.runtime.config.show_thinking_stream,
|
|
479
|
+
)
|
|
480
|
+
await self.soul.start_background_mcp_loading()
|
|
481
|
+
|
|
482
|
+
async def _plan_mode_toggle() -> bool:
|
|
483
|
+
if isinstance(self.soul, PythinkerSoul):
|
|
484
|
+
return await self.soul.toggle_plan_mode_from_manual()
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
def _mcp_status_block(columns: int):
|
|
488
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
489
|
+
return None
|
|
490
|
+
snapshot = self.soul.status.mcp_status
|
|
491
|
+
if snapshot is None:
|
|
492
|
+
return None
|
|
493
|
+
return render_mcp_prompt(snapshot)
|
|
494
|
+
|
|
495
|
+
def _mcp_status_loading() -> bool:
|
|
496
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
497
|
+
return False
|
|
498
|
+
snapshot = self.soul.status.mcp_status
|
|
499
|
+
return bool(snapshot and snapshot.loading)
|
|
500
|
+
|
|
501
|
+
@dataclass
|
|
502
|
+
class _BgCountCache:
|
|
503
|
+
time: float = 0.0
|
|
504
|
+
counts: BgTaskCounts = BgTaskCounts()
|
|
505
|
+
|
|
506
|
+
_bg_cache = _BgCountCache()
|
|
507
|
+
|
|
508
|
+
def _bg_task_counts() -> BgTaskCounts:
|
|
509
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
510
|
+
return BgTaskCounts()
|
|
511
|
+
now = time.monotonic()
|
|
512
|
+
if now - _bg_cache.time < 1.0:
|
|
513
|
+
return _bg_cache.counts
|
|
514
|
+
views = list_task_views(self.soul.runtime.background_tasks, active_only=True)
|
|
515
|
+
bash_n = sum(1 for v in views if v.spec.kind == "bash")
|
|
516
|
+
agent_n = sum(1 for v in views if v.spec.kind == "agent")
|
|
517
|
+
_bg_cache.counts = BgTaskCounts(bash=bash_n, agent=agent_n)
|
|
518
|
+
_bg_cache.time = now
|
|
519
|
+
return _bg_cache.counts
|
|
520
|
+
|
|
521
|
+
with CustomPromptSession(
|
|
522
|
+
status_provider=lambda: self.soul.status,
|
|
523
|
+
status_block_provider=_mcp_status_block,
|
|
524
|
+
fast_refresh_provider=_mcp_status_loading,
|
|
525
|
+
background_task_count_provider=_bg_task_counts,
|
|
526
|
+
model_capabilities=self.soul.model_capabilities or set(),
|
|
527
|
+
model_name=model_display_name(
|
|
528
|
+
self.soul.model_name,
|
|
529
|
+
self.soul.runtime.llm.model_config
|
|
530
|
+
if isinstance(self.soul, PythinkerSoul) and self.soul.runtime.llm
|
|
531
|
+
else None,
|
|
532
|
+
),
|
|
533
|
+
thinking=self.soul.thinking or False,
|
|
534
|
+
agent_mode_slash_commands=list(self._available_slash_commands.values()),
|
|
535
|
+
shell_mode_slash_commands=shell_mode_registry.list_commands(),
|
|
536
|
+
editor_command_provider=lambda: (
|
|
537
|
+
self.soul.runtime.config.default_editor
|
|
538
|
+
if isinstance(self.soul, PythinkerSoul)
|
|
539
|
+
else ""
|
|
540
|
+
),
|
|
541
|
+
plan_mode_toggle_callback=_plan_mode_toggle,
|
|
542
|
+
) as prompt_session:
|
|
543
|
+
self._prompt_session = prompt_session
|
|
544
|
+
if self._prefill_text:
|
|
545
|
+
prompt_session.set_prefill_text(self._prefill_text)
|
|
546
|
+
self._prefill_text = None
|
|
547
|
+
if isinstance(self.soul, PythinkerSoul):
|
|
548
|
+
pythinker_soul = self.soul
|
|
549
|
+
snapshot = pythinker_soul.status.mcp_status
|
|
550
|
+
if snapshot and snapshot.loading:
|
|
551
|
+
|
|
552
|
+
async def _invalidate_after_mcp_loading() -> None:
|
|
553
|
+
try:
|
|
554
|
+
await pythinker_soul.wait_for_background_mcp_loading()
|
|
555
|
+
except Exception:
|
|
556
|
+
logger.debug("MCP loading finished with error while refreshing prompt")
|
|
557
|
+
if self._prompt_session is prompt_session:
|
|
558
|
+
prompt_session.invalidate()
|
|
559
|
+
|
|
560
|
+
self._start_background_task(_invalidate_after_mcp_loading())
|
|
561
|
+
self._exit_after_run = False
|
|
562
|
+
idle_events: asyncio.Queue[_PromptEvent] = asyncio.Queue()
|
|
563
|
+
# resume_prompt controls whether the prompt router reads input.
|
|
564
|
+
# Set BEFORE an await = prompt stays live during the operation
|
|
565
|
+
# (agent runs that accept steer input); set AFTER = prompt is
|
|
566
|
+
# paused until the operation finishes.
|
|
567
|
+
resume_prompt = asyncio.Event()
|
|
568
|
+
resume_prompt.set()
|
|
569
|
+
prompt_task = asyncio.create_task(
|
|
570
|
+
self._route_prompt_events(prompt_session, idle_events, resume_prompt)
|
|
571
|
+
)
|
|
572
|
+
background_autotrigger_armed = False
|
|
573
|
+
|
|
574
|
+
def _can_auto_trigger_pending() -> bool:
|
|
575
|
+
return background_autotrigger_armed
|
|
576
|
+
|
|
577
|
+
bg_watcher = _BackgroundCompletionWatcher(
|
|
578
|
+
self.soul,
|
|
579
|
+
can_auto_trigger_pending=_can_auto_trigger_pending,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
shell_ok = True
|
|
583
|
+
bg_auto_failures = 0
|
|
584
|
+
deferred_bg_trigger = False
|
|
585
|
+
try:
|
|
586
|
+
while True:
|
|
587
|
+
if deferred_bg_trigger and not self._should_defer_background_auto_trigger(
|
|
588
|
+
prompt_session
|
|
589
|
+
):
|
|
590
|
+
result = None
|
|
591
|
+
elif deferred_bg_trigger:
|
|
592
|
+
result = await self._wait_for_input_or_activity(
|
|
593
|
+
prompt_session,
|
|
594
|
+
idle_events,
|
|
595
|
+
timeout_s=self._background_auto_trigger_timeout_s(prompt_session),
|
|
596
|
+
)
|
|
597
|
+
else:
|
|
598
|
+
bg_watcher.clear()
|
|
599
|
+
if bg_auto_failures >= _MAX_BG_AUTO_TRIGGER_FAILURES:
|
|
600
|
+
result = await idle_events.get()
|
|
601
|
+
else:
|
|
602
|
+
result = await bg_watcher.wait_for_next(idle_events)
|
|
603
|
+
|
|
604
|
+
if result is None:
|
|
605
|
+
if self._should_defer_background_auto_trigger(prompt_session):
|
|
606
|
+
deferred_bg_trigger = True
|
|
607
|
+
resume_prompt.set()
|
|
608
|
+
continue
|
|
609
|
+
deferred_bg_trigger = False
|
|
610
|
+
logger.info("Background task completed while idle, triggering agent")
|
|
611
|
+
resume_prompt.set()
|
|
612
|
+
ok = await self.run_soul_command(
|
|
613
|
+
"<system-reminder>"
|
|
614
|
+
"Background tasks completed while you"
|
|
615
|
+
" were idle."
|
|
616
|
+
"</system-reminder>"
|
|
617
|
+
)
|
|
618
|
+
console.print()
|
|
619
|
+
if not ok:
|
|
620
|
+
bg_auto_failures += 1
|
|
621
|
+
logger.warning(
|
|
622
|
+
"Background auto-trigger failed ({n}/{max})",
|
|
623
|
+
n=bg_auto_failures,
|
|
624
|
+
max=_MAX_BG_AUTO_TRIGGER_FAILURES,
|
|
625
|
+
)
|
|
626
|
+
else:
|
|
627
|
+
bg_auto_failures = 0
|
|
628
|
+
if self._exit_after_run:
|
|
629
|
+
console.print("Bye!")
|
|
630
|
+
break
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
event = result
|
|
634
|
+
|
|
635
|
+
if event.kind == "input_activity":
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
if event.kind == "bg_noop":
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
if event.kind == "interrupt":
|
|
642
|
+
console.print("[grey50]Tip: press Ctrl-D or send 'exit' to quit[/grey50]")
|
|
643
|
+
resume_prompt.set()
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
if event.kind == "eof":
|
|
647
|
+
console.print("Bye!")
|
|
648
|
+
break
|
|
649
|
+
|
|
650
|
+
if event.kind == "cwd_lost":
|
|
651
|
+
self._print_cwd_lost_crash()
|
|
652
|
+
shell_ok = False
|
|
653
|
+
break
|
|
654
|
+
|
|
655
|
+
if event.kind == "error":
|
|
656
|
+
shell_ok = False
|
|
657
|
+
break
|
|
658
|
+
|
|
659
|
+
user_input = event.user_input
|
|
660
|
+
assert user_input is not None
|
|
661
|
+
bg_auto_failures = 0
|
|
662
|
+
deferred_bg_trigger = False
|
|
663
|
+
if not user_input:
|
|
664
|
+
logger.debug("Got empty input, skipping")
|
|
665
|
+
resume_prompt.set()
|
|
666
|
+
continue
|
|
667
|
+
logger.debug("Got user input: {user_input}", user_input=user_input)
|
|
668
|
+
|
|
669
|
+
if self._should_echo_agent_input(user_input):
|
|
670
|
+
self._echo_agent_input(user_input)
|
|
671
|
+
|
|
672
|
+
if self._should_exit_input(user_input):
|
|
673
|
+
logger.debug("Exiting by slash command")
|
|
674
|
+
console.print("Bye!")
|
|
675
|
+
break
|
|
676
|
+
|
|
677
|
+
if user_input.mode == PromptMode.SHELL:
|
|
678
|
+
await self._run_shell_command(user_input.command)
|
|
679
|
+
resume_prompt.set()
|
|
680
|
+
continue
|
|
681
|
+
|
|
682
|
+
# Unified input routing — intercept local commands
|
|
683
|
+
# before they reach the soul/wire.
|
|
684
|
+
from pythinker_code.ui.shell.visualize import InputAction, classify_input
|
|
685
|
+
|
|
686
|
+
# Use resolved_command (placeholder-expanded) so /btw
|
|
687
|
+
# receives the actual pasted content, not "[Pasted text #1]".
|
|
688
|
+
input_text = (
|
|
689
|
+
user_input.resolved_command
|
|
690
|
+
if hasattr(user_input, "resolved_command")
|
|
691
|
+
else str(user_input)
|
|
692
|
+
)
|
|
693
|
+
action = classify_input(input_text, is_streaming=False)
|
|
694
|
+
if action.kind == InputAction.BTW and isinstance(self.soul, PythinkerSoul):
|
|
695
|
+
from pythinker_code.telemetry import track
|
|
696
|
+
|
|
697
|
+
track("input_btw")
|
|
698
|
+
await self._run_btw_modal(action.args, prompt_session)
|
|
699
|
+
resume_prompt.set()
|
|
700
|
+
continue
|
|
701
|
+
if action.kind == InputAction.IGNORED:
|
|
702
|
+
console.print(f"[dim]{action.args}[/dim]")
|
|
703
|
+
resume_prompt.set()
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
if slash_cmd_call := self._agent_slash_command_call(user_input):
|
|
707
|
+
is_soul_slash = (
|
|
708
|
+
slash_cmd_call.name in self._available_slash_commands
|
|
709
|
+
and shell_slash_registry.find_command(slash_cmd_call.name) is None
|
|
710
|
+
)
|
|
711
|
+
if is_soul_slash:
|
|
712
|
+
from pythinker_code.telemetry import track
|
|
713
|
+
|
|
714
|
+
track("input_command", command=slash_cmd_call.name)
|
|
715
|
+
background_autotrigger_armed = True
|
|
716
|
+
resume_prompt.set()
|
|
717
|
+
await self.run_soul_command(slash_cmd_call.raw_input)
|
|
718
|
+
console.print()
|
|
719
|
+
if self._exit_after_run:
|
|
720
|
+
console.print("Bye!")
|
|
721
|
+
break
|
|
722
|
+
else:
|
|
723
|
+
await self._run_slash_command(slash_cmd_call)
|
|
724
|
+
resume_prompt.set()
|
|
725
|
+
continue
|
|
726
|
+
|
|
727
|
+
background_autotrigger_armed = True
|
|
728
|
+
resume_prompt.set()
|
|
729
|
+
await self.run_soul_command(user_input.content)
|
|
730
|
+
console.print()
|
|
731
|
+
if self._exit_after_run:
|
|
732
|
+
console.print("Bye!")
|
|
733
|
+
break
|
|
734
|
+
finally:
|
|
735
|
+
prompt_task.cancel()
|
|
736
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
737
|
+
await prompt_task
|
|
738
|
+
self._running_input_handler = None
|
|
739
|
+
self._running_interrupt_handler = None
|
|
740
|
+
if self._prompt_session is prompt_session and self._approval_modal is not None:
|
|
741
|
+
prompt_session.detach_modal(self._approval_modal)
|
|
742
|
+
self._approval_modal = None
|
|
743
|
+
self._prompt_session = None
|
|
744
|
+
self._cancel_background_tasks()
|
|
745
|
+
# Track exit and flush remaining telemetry events.
|
|
746
|
+
# Cap the exit-path flush at 3 s so we don't block for ~50 s
|
|
747
|
+
# when the endpoint is unreachable (in-process retry backoff).
|
|
748
|
+
# On timeout the CancelledError handler in transport.send()
|
|
749
|
+
# persists in-flight events to disk; flush_sync() catches any
|
|
750
|
+
# events still in the buffer.
|
|
751
|
+
from pythinker_code.telemetry import track
|
|
752
|
+
|
|
753
|
+
track("exit", duration_s=time.monotonic() - _run_start_time)
|
|
754
|
+
if _telemetry_sink is not None:
|
|
755
|
+
_telemetry_sink.stop_periodic_flush()
|
|
756
|
+
try:
|
|
757
|
+
await asyncio.wait_for(_telemetry_sink.flush(), timeout=3.0)
|
|
758
|
+
except (TimeoutError, Exception):
|
|
759
|
+
_telemetry_sink.flush_sync()
|
|
760
|
+
ensure_tty_sane()
|
|
761
|
+
|
|
762
|
+
return shell_ok
|
|
763
|
+
|
|
764
|
+
async def _run_shell_command(self, command: str) -> None:
|
|
765
|
+
"""Run a shell command in foreground."""
|
|
766
|
+
if not command.strip():
|
|
767
|
+
return
|
|
768
|
+
|
|
769
|
+
# Check if it's an allowed slash command in shell mode
|
|
770
|
+
if slash_cmd_call := parse_slash_command_call(command):
|
|
771
|
+
if shell_mode_registry.find_command(slash_cmd_call.name):
|
|
772
|
+
await self._run_slash_command(slash_cmd_call)
|
|
773
|
+
return
|
|
774
|
+
else:
|
|
775
|
+
console.print(
|
|
776
|
+
f'[yellow]"/{slash_cmd_call.name}" is not available in shell mode. '
|
|
777
|
+
"Press Ctrl-X to switch to agent mode.[/yellow]"
|
|
778
|
+
)
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
# Check if user is trying to use 'cd' command
|
|
782
|
+
stripped_cmd = command.strip()
|
|
783
|
+
split_cmd: list[str] | None = None
|
|
784
|
+
try:
|
|
785
|
+
split_cmd = shlex.split(stripped_cmd)
|
|
786
|
+
except ValueError as exc:
|
|
787
|
+
logger.debug("Failed to parse shell command for cd check: {error}", error=exc)
|
|
788
|
+
if split_cmd and len(split_cmd) == 2 and split_cmd[0] == "cd":
|
|
789
|
+
console.print(
|
|
790
|
+
"[yellow]Warning: Directory changes are not preserved across command executions."
|
|
791
|
+
"[/yellow]"
|
|
792
|
+
)
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
logger.info("Running shell command: {cmd}", cmd=command)
|
|
796
|
+
from pythinker_code.telemetry import track
|
|
797
|
+
|
|
798
|
+
track("input_bash")
|
|
799
|
+
|
|
800
|
+
proc: asyncio.subprocess.Process | None = None
|
|
801
|
+
|
|
802
|
+
def _handler():
|
|
803
|
+
logger.debug("SIGINT received.")
|
|
804
|
+
if proc:
|
|
805
|
+
proc.terminate()
|
|
806
|
+
|
|
807
|
+
loop = asyncio.get_running_loop()
|
|
808
|
+
remove_sigint = install_sigint_handler(loop, _handler)
|
|
809
|
+
try:
|
|
810
|
+
# TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
|
|
811
|
+
# Later we should consider making this behave like a real shell.
|
|
812
|
+
with open_original_stderr() as stderr:
|
|
813
|
+
kwargs: dict[str, Any] = {}
|
|
814
|
+
if stderr is not None:
|
|
815
|
+
kwargs["stderr"] = stderr
|
|
816
|
+
proc = await asyncio.create_subprocess_shell(command, env=get_clean_env(), **kwargs)
|
|
817
|
+
await proc.wait()
|
|
818
|
+
except Exception as e:
|
|
819
|
+
logger.exception("Failed to run shell command:")
|
|
820
|
+
console.print(f"[red]Failed to run shell command: {e}[/red]")
|
|
821
|
+
finally:
|
|
822
|
+
remove_sigint()
|
|
823
|
+
|
|
824
|
+
async def _run_slash_command(self, command_call: SlashCommandCall) -> None:
|
|
825
|
+
from pythinker_code.cli import Reload, SwitchToVis, SwitchToWeb
|
|
826
|
+
from pythinker_code.telemetry import track
|
|
827
|
+
|
|
828
|
+
if command_call.name not in self._available_slash_commands:
|
|
829
|
+
logger.info("Unknown slash command /{command}", command=command_call.name)
|
|
830
|
+
track("input_command_invalid")
|
|
831
|
+
console.print(
|
|
832
|
+
f'[red]Unknown slash command "/{command_call.name}", '
|
|
833
|
+
'type "/" for all available commands[/red]'
|
|
834
|
+
)
|
|
835
|
+
return
|
|
836
|
+
|
|
837
|
+
track("input_command", command=command_call.name)
|
|
838
|
+
|
|
839
|
+
command = shell_slash_registry.find_command(command_call.name)
|
|
840
|
+
if command is None:
|
|
841
|
+
# the input is a soul-level slash command call
|
|
842
|
+
await self.run_soul_command(command_call.raw_input)
|
|
843
|
+
return
|
|
844
|
+
|
|
845
|
+
logger.debug(
|
|
846
|
+
"Running shell-level slash command: /{command} with args: {args}",
|
|
847
|
+
command=command_call.name,
|
|
848
|
+
args=command_call.args,
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
try:
|
|
852
|
+
ret = command.func(self, command_call.args)
|
|
853
|
+
if isinstance(ret, Awaitable):
|
|
854
|
+
await ret
|
|
855
|
+
except (Reload, SwitchToWeb, SwitchToVis):
|
|
856
|
+
# just propagate
|
|
857
|
+
raise
|
|
858
|
+
except (asyncio.CancelledError, KeyboardInterrupt):
|
|
859
|
+
# Handle Ctrl-C during slash command execution, return to shell prompt
|
|
860
|
+
logger.debug("Slash command interrupted by KeyboardInterrupt")
|
|
861
|
+
console.print("[red]Interrupted by user[/red]")
|
|
862
|
+
except Exception as e:
|
|
863
|
+
logger.exception("Unknown error:")
|
|
864
|
+
console.print(f"[red]Unknown error: {e}[/red]")
|
|
865
|
+
raise # re-raise unknown error
|
|
866
|
+
|
|
867
|
+
async def run_soul_command(self, user_input: str | list[ContentPart]) -> bool:
|
|
868
|
+
"""
|
|
869
|
+
Run the soul and handle any known exceptions.
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
bool: Whether the run is successful.
|
|
873
|
+
"""
|
|
874
|
+
logger.info("Running soul with user input: {user_input}", user_input=user_input)
|
|
875
|
+
|
|
876
|
+
cancel_event = asyncio.Event()
|
|
877
|
+
|
|
878
|
+
def _handler():
|
|
879
|
+
logger.debug("SIGINT received.")
|
|
880
|
+
cancel_event.set()
|
|
881
|
+
|
|
882
|
+
loop = asyncio.get_running_loop()
|
|
883
|
+
remove_sigint = install_sigint_handler(loop, _handler)
|
|
884
|
+
|
|
885
|
+
# Declare before try so finally can always access it.
|
|
886
|
+
from pythinker_code.ui.shell.visualize import (
|
|
887
|
+
_PromptLiveView, # pyright: ignore[reportPrivateUsage]
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
captured_view: _PromptLiveView | None = None
|
|
891
|
+
pending: list[UserInput] = [] # queued messages being drained
|
|
892
|
+
|
|
893
|
+
try:
|
|
894
|
+
snap = self.soul.status
|
|
895
|
+
runtime = self.soul.runtime if isinstance(self.soul, PythinkerSoul) else None
|
|
896
|
+
show_thinking_stream = runtime.config.show_thinking_stream if runtime else False
|
|
897
|
+
# Capture view reference via closure — _clear_active_view sets
|
|
898
|
+
# _active_view=None inside visualize()'s finally (before run_soul
|
|
899
|
+
# returns), so we must capture the view object independently.
|
|
900
|
+
|
|
901
|
+
def _on_view_ready(view: Any) -> None:
|
|
902
|
+
nonlocal captured_view
|
|
903
|
+
self._set_active_view(view)
|
|
904
|
+
if isinstance(view, _PromptLiveView):
|
|
905
|
+
captured_view = view
|
|
906
|
+
|
|
907
|
+
await run_soul(
|
|
908
|
+
self.soul,
|
|
909
|
+
user_input,
|
|
910
|
+
lambda wire: visualize(
|
|
911
|
+
wire.ui_side(merge=False), # shell UI maintain its own merge buffer
|
|
912
|
+
initial_status=StatusUpdate(
|
|
913
|
+
context_usage=snap.context_usage,
|
|
914
|
+
context_tokens=snap.context_tokens,
|
|
915
|
+
max_context_tokens=snap.max_context_tokens,
|
|
916
|
+
mcp_status=snap.mcp_status,
|
|
917
|
+
),
|
|
918
|
+
cancel_event=cancel_event,
|
|
919
|
+
prompt_session=self._prompt_session,
|
|
920
|
+
steer=self.soul.steer if isinstance(self.soul, PythinkerSoul) else None,
|
|
921
|
+
btw_runner=self._make_btw_runner(),
|
|
922
|
+
bind_running_input=self._bind_running_input,
|
|
923
|
+
unbind_running_input=self._unbind_running_input,
|
|
924
|
+
on_view_ready=_on_view_ready,
|
|
925
|
+
on_view_closed=self._clear_active_view,
|
|
926
|
+
show_thinking_stream=show_thinking_stream,
|
|
927
|
+
),
|
|
928
|
+
cancel_event,
|
|
929
|
+
runtime.session.wire_file if runtime else None,
|
|
930
|
+
runtime,
|
|
931
|
+
)
|
|
932
|
+
# If btw is still showing, wait for user dismiss BEFORE draining
|
|
933
|
+
# queue. This runs AFTER visualize_loop returns (within run_soul's
|
|
934
|
+
# 0.5s ui_task timeout), so the btw modal is still attached to
|
|
935
|
+
# prompt_session and key events continue to work.
|
|
936
|
+
if captured_view is not None:
|
|
937
|
+
await captured_view.wait_for_btw_dismiss()
|
|
938
|
+
|
|
939
|
+
# Clear cancel_event so queued turns aren't tainted by a
|
|
940
|
+
# Ctrl+C that fired during btw dismiss wait.
|
|
941
|
+
cancel_event.clear()
|
|
942
|
+
|
|
943
|
+
# Drain queued messages and send each as a new turn.
|
|
944
|
+
# Safety valve: cap at 20 "generations" (new batches of messages
|
|
945
|
+
# from the view). A one-time backlog of 25 messages = 1 generation,
|
|
946
|
+
# but a user adding new messages every turn = 1 generation per turn.
|
|
947
|
+
_MAX_DRAIN_GENERATIONS = 20
|
|
948
|
+
pending.clear()
|
|
949
|
+
drain_generation = 0
|
|
950
|
+
while captured_view is not None and drain_generation < _MAX_DRAIN_GENERATIONS:
|
|
951
|
+
new_messages = captured_view.drain_queued_messages()
|
|
952
|
+
if new_messages:
|
|
953
|
+
drain_generation += 1
|
|
954
|
+
pending.extend(new_messages)
|
|
955
|
+
if not pending:
|
|
956
|
+
break
|
|
957
|
+
queued = pending.pop(0)
|
|
958
|
+
console.print(render_user_echo_text(queued.command))
|
|
959
|
+
await run_soul(
|
|
960
|
+
self.soul,
|
|
961
|
+
queued.content,
|
|
962
|
+
lambda wire: visualize(
|
|
963
|
+
wire.ui_side(merge=False),
|
|
964
|
+
initial_status=StatusUpdate(
|
|
965
|
+
context_usage=self.soul.status.context_usage,
|
|
966
|
+
context_tokens=self.soul.status.context_tokens,
|
|
967
|
+
max_context_tokens=self.soul.status.max_context_tokens,
|
|
968
|
+
mcp_status=self.soul.status.mcp_status,
|
|
969
|
+
),
|
|
970
|
+
cancel_event=cancel_event,
|
|
971
|
+
prompt_session=self._prompt_session,
|
|
972
|
+
steer=self.soul.steer if isinstance(self.soul, PythinkerSoul) else None,
|
|
973
|
+
btw_runner=self._make_btw_runner(),
|
|
974
|
+
bind_running_input=self._bind_running_input,
|
|
975
|
+
unbind_running_input=self._unbind_running_input,
|
|
976
|
+
on_view_ready=_on_view_ready,
|
|
977
|
+
on_view_closed=self._clear_active_view,
|
|
978
|
+
show_thinking_stream=show_thinking_stream,
|
|
979
|
+
),
|
|
980
|
+
cancel_event,
|
|
981
|
+
runtime.session.wire_file if runtime else None,
|
|
982
|
+
runtime,
|
|
983
|
+
)
|
|
984
|
+
# Wait for btw dismiss if one was triggered during this queued turn
|
|
985
|
+
if captured_view is not None:
|
|
986
|
+
await captured_view.wait_for_btw_dismiss()
|
|
987
|
+
cancel_event.clear() # same rationale as above
|
|
988
|
+
# captured_view is now the view from this turn;
|
|
989
|
+
# next iteration drains it for any new messages.
|
|
990
|
+
if drain_generation >= _MAX_DRAIN_GENERATIONS:
|
|
991
|
+
logger.warning(
|
|
992
|
+
"Queue drain hit safety limit ({n} generations)",
|
|
993
|
+
n=_MAX_DRAIN_GENERATIONS,
|
|
994
|
+
)
|
|
995
|
+
# Warn about remaining items in the local pending buffer.
|
|
996
|
+
# Clear after printing so finally doesn't duplicate.
|
|
997
|
+
for msg in pending:
|
|
998
|
+
console.print(f"[yellow]Queued message dropped: {msg.command}[/yellow]")
|
|
999
|
+
pending.clear()
|
|
1000
|
+
return True
|
|
1001
|
+
except LLMNotSet:
|
|
1002
|
+
logger.exception("LLM not set:")
|
|
1003
|
+
console.print('[red]LLM not set, send "/login" to login[/red]')
|
|
1004
|
+
except LLMNotSupported as e:
|
|
1005
|
+
# actually unsupported input/mode should already be blocked by prompt session
|
|
1006
|
+
logger.exception("LLM not supported:")
|
|
1007
|
+
console.print(f"[red]{e}[/red]")
|
|
1008
|
+
except ChatProviderError as e:
|
|
1009
|
+
logger.exception("LLM provider error:")
|
|
1010
|
+
if isinstance(e, APIStatusError) and e.status_code == 401:
|
|
1011
|
+
console.print(
|
|
1012
|
+
"[red]Authorization failed. Your session may have expired.[/red]\n"
|
|
1013
|
+
"[dim]Type [bold]/login[/bold] to re-authenticate.[/dim]\n"
|
|
1014
|
+
f"[dim]Server: {e}[/dim]"
|
|
1015
|
+
)
|
|
1016
|
+
elif isinstance(e, APIStatusError) and e.status_code == 402:
|
|
1017
|
+
console.print(
|
|
1018
|
+
f"[red]Membership expired, please renew your plan[/red]\n[dim]Server: {e}[/dim]"
|
|
1019
|
+
)
|
|
1020
|
+
elif isinstance(e, APIStatusError) and e.status_code == 403:
|
|
1021
|
+
console.print(
|
|
1022
|
+
"[red]Quota exceeded, please upgrade your plan or retry later[/red]\n"
|
|
1023
|
+
f"[dim]Server: {e}[/dim]"
|
|
1024
|
+
)
|
|
1025
|
+
elif isinstance(e, APIConnectionError):
|
|
1026
|
+
console.print(
|
|
1027
|
+
f"[red]Network connection failed: {e}[/red]\n"
|
|
1028
|
+
"[dim]Please check your network and try again.[/dim]"
|
|
1029
|
+
)
|
|
1030
|
+
elif isinstance(e, APITimeoutError):
|
|
1031
|
+
console.print(
|
|
1032
|
+
f"[red]Request timed out: {e}[/red]\n"
|
|
1033
|
+
"[dim]The server may be slow or unreachable. Please try again later.[/dim]"
|
|
1034
|
+
)
|
|
1035
|
+
elif isinstance(e, APIEmptyResponseError):
|
|
1036
|
+
console.print(
|
|
1037
|
+
"[red]The server returned an empty response.[/red]\n"
|
|
1038
|
+
"[dim]This is usually a temporary issue. Please try again.[/dim]"
|
|
1039
|
+
)
|
|
1040
|
+
elif _is_lm_studio_context_too_small(e):
|
|
1041
|
+
n_keep, n_ctx = _parse_n_keep_n_ctx(str(e))
|
|
1042
|
+
console.print(
|
|
1043
|
+
f"[red]LM Studio's loaded context window is too small "
|
|
1044
|
+
f"(loaded n_ctx={n_ctx}, agent needs at least {n_keep}).[/red]\n"
|
|
1045
|
+
"[dim]To fix:[/dim]\n"
|
|
1046
|
+
"[dim] 1. In LM Studio, open the model in the Chat tab "
|
|
1047
|
+
"and click the gear/settings icon (or use 'My Models' → 'Edit').[/dim]\n"
|
|
1048
|
+
"[dim] 2. Set [bold]Context Length[/bold] to at least "
|
|
1049
|
+
f"[bold]{max(n_keep + 4096, 32768)}[/bold] tokens (or the model's max).[/dim]\n"
|
|
1050
|
+
"[dim] 3. Reload the model.[/dim]\n"
|
|
1051
|
+
"[dim] 4. Restart pythinker (Ctrl+D then `uv run pythinker` "
|
|
1052
|
+
"or `pythinker -r <session-id>` to resume).[/dim]"
|
|
1053
|
+
)
|
|
1054
|
+
elif _is_lm_studio_load_failed(e):
|
|
1055
|
+
failed_model = _parse_lm_studio_load_failed_model(str(e))
|
|
1056
|
+
model_label = failed_model or "the requested model"
|
|
1057
|
+
console.print(
|
|
1058
|
+
f"[red]LM Studio could not load {model_label}.[/red]\n"
|
|
1059
|
+
"[dim]Most common cause: VRAM exhausted (the model is too big "
|
|
1060
|
+
"for your GPU at its current quantization).[/dim]\n"
|
|
1061
|
+
"[dim]To fix:[/dim]\n"
|
|
1062
|
+
"[dim] 1. Switch to a smaller model: [bold]/model[/bold] and "
|
|
1063
|
+
"pick one with fewer parameters or a lower-bit quantization "
|
|
1064
|
+
"(e.g., Q4_K_M instead of Q8_0).[/dim]\n"
|
|
1065
|
+
"[dim] 2. Or in LM Studio: unload other models "
|
|
1066
|
+
"(My Models → eject), then try again.[/dim]\n"
|
|
1067
|
+
"[dim] 3. Check the LM Studio app for the underlying error "
|
|
1068
|
+
"(Developer → Logs).[/dim]\n"
|
|
1069
|
+
"[dim]Note: the model is registered as a Pythinker alias even "
|
|
1070
|
+
"if LM Studio can't currently load it — you don't need to "
|
|
1071
|
+
"re-run [bold]/login --lm-studio[/bold].[/dim]"
|
|
1072
|
+
)
|
|
1073
|
+
elif _is_lm_studio_jinja_template_error(e):
|
|
1074
|
+
console.print(
|
|
1075
|
+
"[red]LM Studio failed to render this model's prompt template.[/red]\n"
|
|
1076
|
+
"[dim]This is a model-side bug (broken or version-mismatched "
|
|
1077
|
+
"Jinja template baked into the GGUF), not a Pythinker issue.[/dim]\n"
|
|
1078
|
+
"[dim]To fix:[/dim]\n"
|
|
1079
|
+
"[dim] 1. Easiest: switch to a different model with "
|
|
1080
|
+
"[bold]/model[/bold] (most well-known models work out of the box).[/dim]\n"
|
|
1081
|
+
"[dim] 2. Re-download the model from the [bold]lmstudio-community[/bold] "
|
|
1082
|
+
"namespace in LM Studio's model browser — those have audited templates.[/dim]\n"
|
|
1083
|
+
"[dim] 3. Or override the template manually in LM Studio: "
|
|
1084
|
+
"[bold]My Models → model settings → Prompt Template[/bold].[/dim]\n"
|
|
1085
|
+
f"[dim]Server: {e}[/dim]"
|
|
1086
|
+
)
|
|
1087
|
+
else:
|
|
1088
|
+
console.print(f"[red]LLM provider error: {e}[/red]")
|
|
1089
|
+
if not isinstance(e, APIStatusError) or e.status_code not in (401, 402, 403):
|
|
1090
|
+
console.print(
|
|
1091
|
+
"[dim]If this persists, run [bold]pythinker export[/bold] and send the "
|
|
1092
|
+
"exported data to support for assistance. "
|
|
1093
|
+
"Please do not share the exported file publicly.[/dim]"
|
|
1094
|
+
)
|
|
1095
|
+
except MaxStepsReached as e:
|
|
1096
|
+
logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
|
|
1097
|
+
console.print(
|
|
1098
|
+
f"[yellow]{e}[/yellow]\n"
|
|
1099
|
+
"[dim]Send another message to continue where it left off.[/dim]"
|
|
1100
|
+
)
|
|
1101
|
+
except RunCancelled:
|
|
1102
|
+
logger.info("Cancelled by user")
|
|
1103
|
+
from pythinker_code.telemetry import track
|
|
1104
|
+
|
|
1105
|
+
_at_step = (
|
|
1106
|
+
getattr(self.soul, "_current_step_no", 0)
|
|
1107
|
+
if isinstance(self.soul, PythinkerSoul)
|
|
1108
|
+
else 0
|
|
1109
|
+
)
|
|
1110
|
+
track("turn_interrupted", at_step=_at_step)
|
|
1111
|
+
console.print("[red]Interrupted by user[/red]")
|
|
1112
|
+
except Exception as e:
|
|
1113
|
+
logger.exception("Unexpected error:")
|
|
1114
|
+
console.print(
|
|
1115
|
+
f"[red]Unexpected error: {e}[/red]\n"
|
|
1116
|
+
"[dim]Run [bold]pythinker export[/bold] and send the exported data to support "
|
|
1117
|
+
"for assistance. Please do not share the exported file publicly.[/dim]"
|
|
1118
|
+
)
|
|
1119
|
+
raise # re-raise unknown error
|
|
1120
|
+
finally:
|
|
1121
|
+
# Clean up btw modal if it's still attached (exception skipped wait_for_btw_dismiss)
|
|
1122
|
+
if captured_view is not None:
|
|
1123
|
+
captured_view._dismiss_btw() # pyright: ignore[reportPrivateUsage]
|
|
1124
|
+
# Warn about queued messages lost due to error/cancel.
|
|
1125
|
+
# Check both: pending (already drained from view) and view (not yet drained).
|
|
1126
|
+
all_lost: list[UserInput] = list(pending)
|
|
1127
|
+
pending.clear()
|
|
1128
|
+
if captured_view is not None:
|
|
1129
|
+
all_lost.extend(captured_view.drain_queued_messages())
|
|
1130
|
+
for msg in all_lost:
|
|
1131
|
+
console.print(f"[yellow]Queued message dropped: {msg.command}[/yellow]")
|
|
1132
|
+
self._maybe_present_pending_approvals()
|
|
1133
|
+
remove_sigint()
|
|
1134
|
+
return False
|
|
1135
|
+
|
|
1136
|
+
@staticmethod
|
|
1137
|
+
def _should_defer_background_auto_trigger(
|
|
1138
|
+
prompt_session: _BackgroundAutoTriggerPromptState | None,
|
|
1139
|
+
) -> bool:
|
|
1140
|
+
if prompt_session is None:
|
|
1141
|
+
return False
|
|
1142
|
+
return prompt_session.has_pending_input() or prompt_session.had_recent_input_activity(
|
|
1143
|
+
within_s=_BG_AUTO_TRIGGER_INPUT_GRACE_S
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
@staticmethod
|
|
1147
|
+
def _background_auto_trigger_timeout_s(
|
|
1148
|
+
prompt_session: _BackgroundAutoTriggerPromptState | None,
|
|
1149
|
+
) -> float | None:
|
|
1150
|
+
if prompt_session is None or prompt_session.has_pending_input():
|
|
1151
|
+
return None
|
|
1152
|
+
remaining = prompt_session.recent_input_activity_remaining(
|
|
1153
|
+
within_s=_BG_AUTO_TRIGGER_INPUT_GRACE_S
|
|
1154
|
+
)
|
|
1155
|
+
return remaining if remaining > 0 else None
|
|
1156
|
+
|
|
1157
|
+
async def _wait_for_input_or_activity(
|
|
1158
|
+
self,
|
|
1159
|
+
prompt_session: _BackgroundAutoTriggerPromptState,
|
|
1160
|
+
idle_events: asyncio.Queue[_PromptEvent],
|
|
1161
|
+
*,
|
|
1162
|
+
timeout_s: float | None = None,
|
|
1163
|
+
) -> _PromptEvent:
|
|
1164
|
+
idle_task = asyncio.create_task(idle_events.get())
|
|
1165
|
+
activity_task = asyncio.create_task(prompt_session.wait_for_input_activity())
|
|
1166
|
+
timeout_task = (
|
|
1167
|
+
asyncio.create_task(asyncio.sleep(timeout_s)) if timeout_s is not None else None
|
|
1168
|
+
)
|
|
1169
|
+
done: set[asyncio.Task[Any]] = set()
|
|
1170
|
+
try:
|
|
1171
|
+
done, _ = await asyncio.wait(
|
|
1172
|
+
[task for task in (idle_task, activity_task, timeout_task) if task is not None],
|
|
1173
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1174
|
+
)
|
|
1175
|
+
finally:
|
|
1176
|
+
for task in (idle_task, activity_task, timeout_task):
|
|
1177
|
+
if task is None:
|
|
1178
|
+
continue
|
|
1179
|
+
if task.done():
|
|
1180
|
+
continue
|
|
1181
|
+
task.cancel()
|
|
1182
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1183
|
+
await task
|
|
1184
|
+
|
|
1185
|
+
if idle_task in done:
|
|
1186
|
+
return idle_task.result()
|
|
1187
|
+
return _PromptEvent(kind="input_activity")
|
|
1188
|
+
|
|
1189
|
+
async def _watch_root_wire_hub(self) -> None:
|
|
1190
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
1191
|
+
return
|
|
1192
|
+
if self.soul.runtime.root_wire_hub is None:
|
|
1193
|
+
return
|
|
1194
|
+
queue = self.soul.runtime.root_wire_hub.subscribe()
|
|
1195
|
+
try:
|
|
1196
|
+
while True:
|
|
1197
|
+
try:
|
|
1198
|
+
msg = await queue.get()
|
|
1199
|
+
except QueueShutDown:
|
|
1200
|
+
return
|
|
1201
|
+
try:
|
|
1202
|
+
await self._handle_root_hub_message(msg)
|
|
1203
|
+
except Exception:
|
|
1204
|
+
logger.exception("Failed to handle root hub message:")
|
|
1205
|
+
finally:
|
|
1206
|
+
self.soul.runtime.root_wire_hub.unsubscribe(queue)
|
|
1207
|
+
|
|
1208
|
+
async def _handle_root_hub_message(self, msg: WireMessage) -> None:
|
|
1209
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
1210
|
+
return
|
|
1211
|
+
match msg:
|
|
1212
|
+
case ApprovalRequest() as request:
|
|
1213
|
+
request = self._enrich_approval_request_for_ui(request)
|
|
1214
|
+
if self.soul.runtime.approval_runtime is None:
|
|
1215
|
+
return
|
|
1216
|
+
record = self.soul.runtime.approval_runtime.get_request(request.id)
|
|
1217
|
+
if record is None or record.status != "pending":
|
|
1218
|
+
return
|
|
1219
|
+
if self._prompt_session is not None:
|
|
1220
|
+
# Interactive mode: queue and present via modal
|
|
1221
|
+
self._queue_approval_request(request)
|
|
1222
|
+
self._maybe_present_pending_approvals()
|
|
1223
|
+
self._prompt_session.invalidate()
|
|
1224
|
+
elif self._active_approval_sink is not None:
|
|
1225
|
+
# Non-interactive with live view: forward to sink
|
|
1226
|
+
self._forward_approval_to_sink(request)
|
|
1227
|
+
else:
|
|
1228
|
+
# Queue for later
|
|
1229
|
+
self._queue_approval_request(request)
|
|
1230
|
+
case ApprovalResponse() as response:
|
|
1231
|
+
# External resolution (e.g. from web UI)
|
|
1232
|
+
if (
|
|
1233
|
+
self._approval_modal is not None
|
|
1234
|
+
and self._approval_modal.request.id == response.request_id
|
|
1235
|
+
):
|
|
1236
|
+
if not self._approval_modal.request.resolved:
|
|
1237
|
+
self._approval_modal.request.resolve(response.response)
|
|
1238
|
+
self._clear_current_prompt_approval_request(response.request_id)
|
|
1239
|
+
self._activate_prompt_approval_modal()
|
|
1240
|
+
self._remove_pending_approval_request(response.request_id)
|
|
1241
|
+
self._maybe_present_pending_approvals()
|
|
1242
|
+
if self._prompt_session is not None:
|
|
1243
|
+
self._prompt_session.invalidate()
|
|
1244
|
+
case _:
|
|
1245
|
+
return
|
|
1246
|
+
|
|
1247
|
+
def _enrich_approval_request_for_ui(self, request: ApprovalRequest) -> ApprovalRequest:
|
|
1248
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
1249
|
+
return request
|
|
1250
|
+
if request.agent_id is None:
|
|
1251
|
+
return request
|
|
1252
|
+
if self.soul.runtime.subagent_store is None:
|
|
1253
|
+
return request
|
|
1254
|
+
record = self.soul.runtime.subagent_store.get_instance(request.agent_id)
|
|
1255
|
+
if record is None:
|
|
1256
|
+
return request
|
|
1257
|
+
return request.model_copy(update={"source_description": record.description})
|
|
1258
|
+
|
|
1259
|
+
async def _run_btw_modal(
|
|
1260
|
+
self,
|
|
1261
|
+
question: str,
|
|
1262
|
+
prompt_session: CustomPromptSession,
|
|
1263
|
+
) -> None:
|
|
1264
|
+
"""Run /btw using the prompt session's modal system.
|
|
1265
|
+
|
|
1266
|
+
Attaches a ``_BtwModalDelegate`` that replaces the input line with
|
|
1267
|
+
the btw panel. A refresh loop animates the spinner. After the LLM
|
|
1268
|
+
responds, we start a new prompt read so prompt_toolkit can render the
|
|
1269
|
+
result and accept dismiss keys.
|
|
1270
|
+
"""
|
|
1271
|
+
from pythinker_code.soul.btw import execute_side_question
|
|
1272
|
+
from pythinker_code.ui.shell.visualize import (
|
|
1273
|
+
_BtwModalDelegate, # pyright: ignore[reportPrivateUsage]
|
|
1274
|
+
)
|
|
1275
|
+
|
|
1276
|
+
assert isinstance(self.soul, PythinkerSoul)
|
|
1277
|
+
|
|
1278
|
+
dismiss_event = asyncio.Event()
|
|
1279
|
+
modal = _BtwModalDelegate(on_dismiss=lambda: dismiss_event.set())
|
|
1280
|
+
import time
|
|
1281
|
+
|
|
1282
|
+
modal._question = question # pyright: ignore[reportPrivateUsage]
|
|
1283
|
+
modal.set_start_time(time.monotonic())
|
|
1284
|
+
prompt_session.attach_modal(modal)
|
|
1285
|
+
|
|
1286
|
+
# Refresh loop for spinner animation
|
|
1287
|
+
async def _refresh() -> None:
|
|
1288
|
+
try:
|
|
1289
|
+
while True:
|
|
1290
|
+
await asyncio.sleep(0.08)
|
|
1291
|
+
prompt_session.invalidate()
|
|
1292
|
+
except asyncio.CancelledError:
|
|
1293
|
+
pass
|
|
1294
|
+
|
|
1295
|
+
refresh_task = asyncio.create_task(_refresh())
|
|
1296
|
+
prompt_task: asyncio.Task[None] | None = None
|
|
1297
|
+
llm_task: asyncio.Task[tuple[str | None, str | None]] | None = None
|
|
1298
|
+
|
|
1299
|
+
try:
|
|
1300
|
+
|
|
1301
|
+
def _on_chunk(chunk: str) -> None:
|
|
1302
|
+
modal.append_text(chunk)
|
|
1303
|
+
|
|
1304
|
+
# Start a prompt read concurrently — renders the modal and
|
|
1305
|
+
# handles key events while the LLM call runs in parallel.
|
|
1306
|
+
async def _wait_for_dismiss() -> None:
|
|
1307
|
+
while not dismiss_event.is_set():
|
|
1308
|
+
try:
|
|
1309
|
+
await prompt_session.prompt_next()
|
|
1310
|
+
except (KeyboardInterrupt, EOFError):
|
|
1311
|
+
dismiss_event.set()
|
|
1312
|
+
break
|
|
1313
|
+
|
|
1314
|
+
prompt_task = asyncio.create_task(_wait_for_dismiss())
|
|
1315
|
+
|
|
1316
|
+
# Run LLM call as a separate task so Escape can cancel it
|
|
1317
|
+
llm_task = asyncio.create_task(
|
|
1318
|
+
execute_side_question(self.soul, question, on_text_chunk=_on_chunk)
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
# Wait for either LLM completion or user dismiss
|
|
1322
|
+
dismiss_task = asyncio.create_task(dismiss_event.wait())
|
|
1323
|
+
_done, _ = await asyncio.wait(
|
|
1324
|
+
[llm_task, dismiss_task],
|
|
1325
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
if llm_task.done() and not llm_task.cancelled():
|
|
1329
|
+
# LLM finished — show result, wait for user to dismiss
|
|
1330
|
+
dismiss_task.cancel()
|
|
1331
|
+
response, error = llm_task.result()
|
|
1332
|
+
modal.set_result(response, error)
|
|
1333
|
+
prompt_session.invalidate()
|
|
1334
|
+
await dismiss_event.wait()
|
|
1335
|
+
else:
|
|
1336
|
+
# User dismissed during loading — cancel the LLM call
|
|
1337
|
+
llm_task.cancel()
|
|
1338
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1339
|
+
await llm_task
|
|
1340
|
+
finally:
|
|
1341
|
+
# Cancel ALL child tasks
|
|
1342
|
+
if llm_task is not None and not llm_task.done():
|
|
1343
|
+
llm_task.cancel()
|
|
1344
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1345
|
+
await llm_task
|
|
1346
|
+
if prompt_task is not None:
|
|
1347
|
+
prompt_task.cancel()
|
|
1348
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1349
|
+
await prompt_task
|
|
1350
|
+
refresh_task.cancel()
|
|
1351
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
1352
|
+
await refresh_task
|
|
1353
|
+
prompt_session.detach_modal(modal)
|
|
1354
|
+
|
|
1355
|
+
def _make_btw_runner(self):
|
|
1356
|
+
"""Create a btw_runner callback bound to the current soul."""
|
|
1357
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
1358
|
+
return None
|
|
1359
|
+
|
|
1360
|
+
soul = self.soul
|
|
1361
|
+
|
|
1362
|
+
async def _runner(
|
|
1363
|
+
question: str,
|
|
1364
|
+
on_text_chunk: Callable[[str], None] | None = None,
|
|
1365
|
+
) -> tuple[str | None, str | None]:
|
|
1366
|
+
from pythinker_code.soul.btw import execute_side_question
|
|
1367
|
+
|
|
1368
|
+
return await execute_side_question(soul, question, on_text_chunk)
|
|
1369
|
+
|
|
1370
|
+
return _runner
|
|
1371
|
+
|
|
1372
|
+
def _set_active_view(self, view: Any) -> None:
|
|
1373
|
+
self._active_approval_sink = view
|
|
1374
|
+
self._active_view = view
|
|
1375
|
+
# In interactive mode, approvals are handled by the prompt modal,
|
|
1376
|
+
# not by the live view sink. Don't flush to avoid losing requests.
|
|
1377
|
+
if self._prompt_session is not None:
|
|
1378
|
+
return
|
|
1379
|
+
# Flush pending approvals to the newly active sink
|
|
1380
|
+
while self._pending_approval_requests:
|
|
1381
|
+
request = self._pending_approval_requests.popleft()
|
|
1382
|
+
|
|
1383
|
+
if (
|
|
1384
|
+
not isinstance(self.soul, PythinkerSoul)
|
|
1385
|
+
or self.soul.runtime.approval_runtime is None
|
|
1386
|
+
):
|
|
1387
|
+
break
|
|
1388
|
+
record = self.soul.runtime.approval_runtime.get_request(request.id)
|
|
1389
|
+
if record is None or record.status != "pending":
|
|
1390
|
+
continue
|
|
1391
|
+
self._forward_approval_to_sink(request)
|
|
1392
|
+
|
|
1393
|
+
def _clear_active_view(self) -> None:
|
|
1394
|
+
self._active_approval_sink = None
|
|
1395
|
+
self._active_view = None
|
|
1396
|
+
# Re-queue any approval requests that were forwarded to the sink
|
|
1397
|
+
# but not yet resolved. Without this, those requests would be
|
|
1398
|
+
# silently lost when the live view closes between turns.
|
|
1399
|
+
if not isinstance(self.soul, PythinkerSoul) or self.soul.runtime.approval_runtime is None:
|
|
1400
|
+
return
|
|
1401
|
+
for record in self.soul.runtime.approval_runtime.list_pending():
|
|
1402
|
+
self._queue_approval_request(
|
|
1403
|
+
self._enrich_approval_request_for_ui(
|
|
1404
|
+
ApprovalRequest(
|
|
1405
|
+
id=record.id,
|
|
1406
|
+
tool_call_id=record.tool_call_id,
|
|
1407
|
+
sender=record.sender,
|
|
1408
|
+
action=record.action,
|
|
1409
|
+
description=record.description,
|
|
1410
|
+
display=record.display,
|
|
1411
|
+
source_kind=record.source.kind,
|
|
1412
|
+
source_id=record.source.id,
|
|
1413
|
+
agent_id=record.source.agent_id,
|
|
1414
|
+
subagent_type=record.source.subagent_type,
|
|
1415
|
+
)
|
|
1416
|
+
)
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
def _forward_approval_to_sink(self, request: ApprovalRequest) -> None:
|
|
1420
|
+
"""Forward an approval request to the active live view sink and bridge the response."""
|
|
1421
|
+
if self._active_approval_sink is None:
|
|
1422
|
+
self._queue_approval_request(request)
|
|
1423
|
+
return
|
|
1424
|
+
self._active_approval_sink.enqueue_external_message(request)
|
|
1425
|
+
|
|
1426
|
+
async def _bridge() -> None:
|
|
1427
|
+
try:
|
|
1428
|
+
response = await request.wait()
|
|
1429
|
+
if (
|
|
1430
|
+
isinstance(self.soul, PythinkerSoul)
|
|
1431
|
+
and self.soul.runtime.approval_runtime is not None
|
|
1432
|
+
):
|
|
1433
|
+
self.soul.runtime.approval_runtime.resolve(
|
|
1434
|
+
request.id, response, feedback=request.feedback
|
|
1435
|
+
)
|
|
1436
|
+
finally:
|
|
1437
|
+
if self._prompt_session is not None:
|
|
1438
|
+
self._prompt_session.invalidate()
|
|
1439
|
+
|
|
1440
|
+
self._start_background_task(_bridge())
|
|
1441
|
+
|
|
1442
|
+
def _queue_approval_request(self, request: ApprovalRequest) -> None:
|
|
1443
|
+
if self._approval_modal is not None and self._approval_modal.request.id == request.id:
|
|
1444
|
+
return
|
|
1445
|
+
if (
|
|
1446
|
+
self._current_prompt_approval_request is not None
|
|
1447
|
+
and self._current_prompt_approval_request.id == request.id
|
|
1448
|
+
):
|
|
1449
|
+
return
|
|
1450
|
+
if any(r.id == request.id for r in self._pending_approval_requests):
|
|
1451
|
+
return
|
|
1452
|
+
self._pending_approval_requests.append(request)
|
|
1453
|
+
|
|
1454
|
+
def _remove_pending_approval_request(self, request_id: str) -> None:
|
|
1455
|
+
self._clear_current_prompt_approval_request(request_id)
|
|
1456
|
+
self._pending_approval_requests = deque(
|
|
1457
|
+
r for r in self._pending_approval_requests if r.id != request_id
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
def _clear_current_prompt_approval_request(self, request_id: str) -> None:
|
|
1461
|
+
if (
|
|
1462
|
+
self._current_prompt_approval_request is not None
|
|
1463
|
+
and self._current_prompt_approval_request.id == request_id
|
|
1464
|
+
):
|
|
1465
|
+
self._current_prompt_approval_request = None
|
|
1466
|
+
|
|
1467
|
+
def _maybe_present_pending_approvals(self) -> None:
|
|
1468
|
+
if self._prompt_session is not None:
|
|
1469
|
+
self._activate_prompt_approval_modal()
|
|
1470
|
+
return
|
|
1471
|
+
if self._active_approval_sink is not None:
|
|
1472
|
+
while self._pending_approval_requests:
|
|
1473
|
+
request = self._pending_approval_requests.popleft()
|
|
1474
|
+
|
|
1475
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
1476
|
+
break
|
|
1477
|
+
if self.soul.runtime.approval_runtime is None:
|
|
1478
|
+
break
|
|
1479
|
+
record = self.soul.runtime.approval_runtime.get_request(request.id)
|
|
1480
|
+
if record is None or record.status != "pending":
|
|
1481
|
+
continue
|
|
1482
|
+
self._forward_approval_to_sink(request)
|
|
1483
|
+
|
|
1484
|
+
def _get_default_buffer_text_and_cursor(self) -> tuple[str, int]:
|
|
1485
|
+
if self._prompt_session is None:
|
|
1486
|
+
return "", 0
|
|
1487
|
+
buf = self._prompt_session._session.default_buffer # pyright: ignore[reportPrivateUsage]
|
|
1488
|
+
return buf.text, buf.cursor_position
|
|
1489
|
+
|
|
1490
|
+
def _activate_prompt_approval_modal(self) -> None:
|
|
1491
|
+
if self._prompt_session is None:
|
|
1492
|
+
return
|
|
1493
|
+
current_request = self._current_prompt_approval_request
|
|
1494
|
+
if current_request is None:
|
|
1495
|
+
current_request = self._pop_next_pending_approval_request()
|
|
1496
|
+
self._current_prompt_approval_request = current_request
|
|
1497
|
+
if current_request is None:
|
|
1498
|
+
if self._approval_modal is not None:
|
|
1499
|
+
self._prompt_session.detach_modal(self._approval_modal)
|
|
1500
|
+
self._approval_modal = None
|
|
1501
|
+
return
|
|
1502
|
+
if self._approval_modal is None:
|
|
1503
|
+
self._approval_modal = ApprovalPromptDelegate(
|
|
1504
|
+
current_request,
|
|
1505
|
+
on_response=self._handle_prompt_approval_response,
|
|
1506
|
+
buffer_state_provider=self._get_default_buffer_text_and_cursor,
|
|
1507
|
+
text_expander=self._prompt_session._get_placeholder_manager().serialize_for_history, # pyright: ignore[reportPrivateUsage]
|
|
1508
|
+
)
|
|
1509
|
+
self._prompt_session.attach_modal(self._approval_modal)
|
|
1510
|
+
else:
|
|
1511
|
+
if self._approval_modal.request.id != current_request.id:
|
|
1512
|
+
self._approval_modal.set_request(current_request)
|
|
1513
|
+
self._prompt_session.invalidate()
|
|
1514
|
+
|
|
1515
|
+
def _handle_prompt_approval_response(
|
|
1516
|
+
self,
|
|
1517
|
+
request: ApprovalRequest,
|
|
1518
|
+
response: ApprovalResponse.Kind,
|
|
1519
|
+
feedback: str = "",
|
|
1520
|
+
) -> None:
|
|
1521
|
+
if not isinstance(self.soul, PythinkerSoul):
|
|
1522
|
+
return
|
|
1523
|
+
if self.soul.runtime.approval_runtime is None:
|
|
1524
|
+
return
|
|
1525
|
+
self.soul.runtime.approval_runtime.resolve(request.id, response, feedback=feedback)
|
|
1526
|
+
self._clear_current_prompt_approval_request(request.id)
|
|
1527
|
+
self._activate_prompt_approval_modal()
|
|
1528
|
+
|
|
1529
|
+
def _pop_next_pending_approval_request(self) -> ApprovalRequest | None:
|
|
1530
|
+
if not isinstance(self.soul, PythinkerSoul) or self.soul.runtime.approval_runtime is None:
|
|
1531
|
+
return None
|
|
1532
|
+
while self._pending_approval_requests:
|
|
1533
|
+
request = self._pending_approval_requests.popleft()
|
|
1534
|
+
|
|
1535
|
+
record = self.soul.runtime.approval_runtime.get_request(request.id)
|
|
1536
|
+
if record is None or record.status != "pending":
|
|
1537
|
+
continue
|
|
1538
|
+
return request
|
|
1539
|
+
return None
|
|
1540
|
+
|
|
1541
|
+
async def _auto_update(self) -> None:
|
|
1542
|
+
result = await do_update(print=False, check_only=True)
|
|
1543
|
+
if result == UpdateResult.UPDATED:
|
|
1544
|
+
toast("auto updated, restart to use the new version", topic="update", duration=5.0)
|
|
1545
|
+
|
|
1546
|
+
def _start_background_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
|
|
1547
|
+
task = asyncio.create_task(coro)
|
|
1548
|
+
self._background_tasks.add(task)
|
|
1549
|
+
|
|
1550
|
+
def _cleanup(t: asyncio.Task[Any]) -> None:
|
|
1551
|
+
self._background_tasks.discard(t)
|
|
1552
|
+
try:
|
|
1553
|
+
t.result()
|
|
1554
|
+
except asyncio.CancelledError:
|
|
1555
|
+
pass
|
|
1556
|
+
except Exception:
|
|
1557
|
+
logger.exception("Background task failed:")
|
|
1558
|
+
|
|
1559
|
+
task.add_done_callback(_cleanup)
|
|
1560
|
+
return task
|
|
1561
|
+
|
|
1562
|
+
def _cancel_background_tasks(self) -> None:
|
|
1563
|
+
"""Cancel all background tasks (notification watcher, auto-update, etc.)."""
|
|
1564
|
+
for task in self._background_tasks:
|
|
1565
|
+
task.cancel()
|
|
1566
|
+
self._background_tasks.clear()
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
# Palette transferred from the animated SVG (pythinker_animated.svg).
|
|
1570
|
+
_LOGO_NAVY = "#213853" # outline / chassis (head + body frame, mouth, neck)
|
|
1571
|
+
_LOGO_FACE = "#F9F2F5" # face / chest interior (cream)
|
|
1572
|
+
_LOGO_CORAL = "#EE9983" # antenna ball, ears, accent bits
|
|
1573
|
+
_LOGO_IRIS = "#AFE3F1" # eye iris + chest button glow (light cyan)
|
|
1574
|
+
_PYTHINKER_BORDER = "grey39"
|
|
1575
|
+
|
|
1576
|
+
_LOGO = (
|
|
1577
|
+
f" [{_LOGO_CORAL}]●[/]\n"
|
|
1578
|
+
f" [{_LOGO_NAVY}]│[/]\n"
|
|
1579
|
+
f" [{_LOGO_NAVY}]▛[/][{_LOGO_FACE}]▀▀▀▀▀▀▀[/][{_LOGO_NAVY}]▜[/]\n"
|
|
1580
|
+
f" [{_LOGO_CORAL}]◖[/][{_LOGO_NAVY}]█[/][{_LOGO_FACE}] [/]"
|
|
1581
|
+
f"[{_LOGO_IRIS}]◉[/][{_LOGO_FACE}] [/][{_LOGO_IRIS}]◉[/]"
|
|
1582
|
+
f"[{_LOGO_FACE}] [/][{_LOGO_NAVY}]█[/][{_LOGO_CORAL}]◗[/]\n"
|
|
1583
|
+
f" [{_LOGO_NAVY}]▙▄▄▄[/][{_LOGO_FACE}]≡[/][{_LOGO_NAVY}]▄▄▄▟[/]"
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
|
|
1587
|
+
@dataclass(slots=True)
|
|
1588
|
+
class WelcomeInfoItem:
|
|
1589
|
+
class Level(Enum):
|
|
1590
|
+
INFO = "grey50"
|
|
1591
|
+
WARN = "yellow"
|
|
1592
|
+
ERROR = "red"
|
|
1593
|
+
|
|
1594
|
+
name: str
|
|
1595
|
+
value: str
|
|
1596
|
+
level: Level = Level.INFO
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def _value_style_for_label(label: str, level: WelcomeInfoItem.Level) -> str:
|
|
1600
|
+
"""INFO-level styling per label; WARN/ERROR colors always win."""
|
|
1601
|
+
if level is not WelcomeInfoItem.Level.INFO:
|
|
1602
|
+
return level.value
|
|
1603
|
+
label = label.strip()
|
|
1604
|
+
if label == "Directory":
|
|
1605
|
+
return "cyan"
|
|
1606
|
+
if label == "Session":
|
|
1607
|
+
return "grey39"
|
|
1608
|
+
if label == "Model":
|
|
1609
|
+
return "bold bright_white"
|
|
1610
|
+
return level.value
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def _print_welcome_info(name: str, info_items: list[WelcomeInfoItem]) -> None:
|
|
1614
|
+
head = Text.from_markup("Welcome to Pythinker CLI!")
|
|
1615
|
+
help_text = Text.from_markup("[grey50]Send /help for help information.[/grey50]")
|
|
1616
|
+
help_text.highlight_regex(r"/help\b", "yellow bold")
|
|
1617
|
+
|
|
1618
|
+
# Use Table for precise width control
|
|
1619
|
+
logo = Text.from_markup(_LOGO)
|
|
1620
|
+
table = Table(show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False)
|
|
1621
|
+
table.add_column(justify="left")
|
|
1622
|
+
table.add_column(justify="left", vertical="bottom")
|
|
1623
|
+
table.add_row(logo, Group(head, help_text))
|
|
1624
|
+
|
|
1625
|
+
rows: list[RenderableType] = [table]
|
|
1626
|
+
|
|
1627
|
+
facts = [item for item in info_items if item.name.strip() != "Tip"]
|
|
1628
|
+
tips = [item for item in info_items if item.name.strip() == "Tip"]
|
|
1629
|
+
|
|
1630
|
+
if facts:
|
|
1631
|
+
rows.append(Text("")) # empty line
|
|
1632
|
+
info_table = Table(
|
|
1633
|
+
show_header=False, show_edge=False, box=None, padding=(0, 1), expand=False
|
|
1634
|
+
)
|
|
1635
|
+
info_table.add_column(justify="right", style="grey50")
|
|
1636
|
+
info_table.add_column(justify="center", style="grey39", no_wrap=True)
|
|
1637
|
+
info_table.add_column(justify="left")
|
|
1638
|
+
for item in facts:
|
|
1639
|
+
value_style = _value_style_for_label(item.name, item.level)
|
|
1640
|
+
info_table.add_row(item.name, "│", Text(item.value, style=value_style))
|
|
1641
|
+
rows.append(info_table)
|
|
1642
|
+
|
|
1643
|
+
if tips:
|
|
1644
|
+
rows.append(Text("")) # empty line
|
|
1645
|
+
rows.append(Text("Tips", style="grey50"))
|
|
1646
|
+
# 2-col table → wrapped tip lines hang-indent under the text column,
|
|
1647
|
+
# not under the bullet.
|
|
1648
|
+
tips_table = Table(
|
|
1649
|
+
show_header=False, show_edge=False, box=None, padding=(0, 0), expand=False
|
|
1650
|
+
)
|
|
1651
|
+
tips_table.add_column(style="grey50", no_wrap=True, width=4)
|
|
1652
|
+
tips_table.add_column(justify="left", overflow="fold")
|
|
1653
|
+
for item in tips:
|
|
1654
|
+
tip_text = Text(item.value, style=item.level.value)
|
|
1655
|
+
tip_text.highlight_regex(r"/[A-Za-z][A-Za-z0-9_-]*", "yellow bold")
|
|
1656
|
+
tips_table.add_row(" › ", tip_text)
|
|
1657
|
+
rows.append(tips_table)
|
|
1658
|
+
|
|
1659
|
+
if LATEST_VERSION_FILE.exists():
|
|
1660
|
+
from pythinker_code.constant import VERSION as current_version
|
|
1661
|
+
from pythinker_code.ui.shell.update import SKIPPED_VERSION_FILE
|
|
1662
|
+
from pythinker_code.utils.envvar import get_env_bool
|
|
1663
|
+
|
|
1664
|
+
if not get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"):
|
|
1665
|
+
try:
|
|
1666
|
+
latest_version = LATEST_VERSION_FILE.read_text(encoding="utf-8").strip()
|
|
1667
|
+
except OSError:
|
|
1668
|
+
latest_version = ""
|
|
1669
|
+
if latest_version and semver_tuple(latest_version) > semver_tuple(current_version):
|
|
1670
|
+
try:
|
|
1671
|
+
skipped = (
|
|
1672
|
+
SKIPPED_VERSION_FILE.read_text(encoding="utf-8").strip()
|
|
1673
|
+
if SKIPPED_VERSION_FILE.exists()
|
|
1674
|
+
else ""
|
|
1675
|
+
)
|
|
1676
|
+
except OSError:
|
|
1677
|
+
skipped = ""
|
|
1678
|
+
if skipped != latest_version:
|
|
1679
|
+
rows.append(
|
|
1680
|
+
Text.from_markup(
|
|
1681
|
+
f"\n[yellow]New version available: {latest_version}. "
|
|
1682
|
+
f"Please run `{_update_mod.UPGRADE_COMMAND}` to upgrade.[/yellow]"
|
|
1683
|
+
)
|
|
1684
|
+
)
|
|
1685
|
+
from pythinker_code.telemetry import track
|
|
1686
|
+
|
|
1687
|
+
track("update_prompted", current=current_version, latest=latest_version)
|
|
1688
|
+
|
|
1689
|
+
console.print(
|
|
1690
|
+
Panel(
|
|
1691
|
+
Group(*rows),
|
|
1692
|
+
border_style=_PYTHINKER_BORDER,
|
|
1693
|
+
expand=False,
|
|
1694
|
+
padding=(1, 2),
|
|
1695
|
+
)
|
|
1696
|
+
)
|