agentengine-sdk-python 0.6.4__py3-none-any.whl → 0.7.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.
- agentengine_sdk_python-0.7.0.dist-info/METADATA +181 -0
- agentengine_sdk_python-0.7.0.dist-info/RECORD +482 -0
- {agentengine_sdk_python-0.6.4.dist-info → agentengine_sdk_python-0.7.0.dist-info}/WHEEL +1 -1
- agentengine_sdk_python-0.7.0.dist-info/entry_points.txt +3 -0
- agentengine_sdk_python-0.7.0.dist-info/licenses/LICENSE +175 -0
- agentengine_sdk_python-0.7.0.dist-info/top_level.txt +2 -0
- ksadk/__init__.py +13 -0
- ksadk/__main__.py +4 -0
- ksadk/a2a/__init__.py +15 -0
- ksadk/a2a/card_builder.py +64 -0
- ksadk/a2a/client.py +309 -0
- ksadk/a2a/executor.py +201 -0
- ksadk/a2a/server.py +79 -0
- ksadk/agents/__init__.py +20 -0
- ksadk/agents/base.py +370 -0
- ksadk/agents/context.py +38 -0
- ksadk/agents/event.py +35 -0
- ksadk/agents/loop_agent.py +71 -0
- ksadk/agents/parallel_agent.py +109 -0
- ksadk/agents/runner_adapter.py +24 -0
- ksadk/agents/sequential_agent.py +34 -0
- ksadk/api/__init__.py +3 -0
- ksadk/api/client.py +2246 -0
- ksadk/builders/__init__.py +20 -0
- ksadk/builders/base.py +66 -0
- ksadk/builders/code_builder.py +1990 -0
- ksadk/builders/container_builder.py +786 -0
- ksadk/builders/framework_requirements.py +59 -0
- ksadk/builders/ks3_uploader.py +389 -0
- ksadk/builders/mcp_builder.py +476 -0
- ksadk/builders/requirements_utils.py +75 -0
- ksadk/cli/__init__.py +531 -0
- ksadk/cli/__main__.py +4 -0
- ksadk/cli/agent_ref.py +204 -0
- ksadk/cli/cmd_a2a.py +167 -0
- ksadk/cli/cmd_agent.py +201 -0
- ksadk/cli/cmd_build.py +270 -0
- ksadk/cli/cmd_completion.py +304 -0
- ksadk/cli/cmd_config.py +758 -0
- ksadk/cli/cmd_create.py +2304 -0
- ksadk/cli/cmd_dashboard.py +900 -0
- ksadk/cli/cmd_deploy.py +704 -0
- ksadk/cli/cmd_destroy.py +345 -0
- ksadk/cli/cmd_files.py +1227 -0
- ksadk/cli/cmd_hermes.py +1263 -0
- ksadk/cli/cmd_invoke.py +1503 -0
- ksadk/cli/cmd_launch.py +564 -0
- ksadk/cli/cmd_mcp.py +1179 -0
- ksadk/cli/cmd_model.py +302 -0
- ksadk/cli/cmd_openclaw.py +3922 -0
- ksadk/cli/cmd_run.py +239 -0
- ksadk/cli/cmd_status.py +588 -0
- ksadk/cli/cmd_version.py +522 -0
- ksadk/cli/cmd_web.py +288 -0
- ksadk/cli/deploy_utils.py +87 -0
- ksadk/cli/dry_run.py +214 -0
- ksadk/cli/env_options.py +82 -0
- ksadk/cli/error_utils.py +525 -0
- ksadk/cli/global_options.py +31 -0
- ksadk/cli/local_runtime.py +97 -0
- ksadk/cli/model_catalog.py +136 -0
- ksadk/cli/network_options.py +412 -0
- ksadk/cli/resource_common.py +621 -0
- ksadk/cli/storage.py +84 -0
- ksadk/cli/ui.py +431 -0
- ksadk/cli/workflow_common.py +552 -0
- ksadk/common/__init__.py +14 -0
- ksadk/common/aicp_env.py +128 -0
- ksadk/common/auth.py +190 -0
- ksadk/common/constants.py +84 -0
- ksadk/common/llm_utils.py +61 -0
- ksadk/configs/__init__.py +61 -0
- ksadk/configs/env_registry.py +343 -0
- ksadk/configs/global_config.py +200 -0
- ksadk/configs/settings.py +706 -0
- ksadk/conversations/__init__.py +95 -0
- ksadk/conversations/attachment_storage.py +379 -0
- ksadk/conversations/attachments.py +774 -0
- ksadk/conversations/compaction_pipeline.py +387 -0
- ksadk/conversations/compaction_prompt.py +128 -0
- ksadk/conversations/context.py +303 -0
- ksadk/conversations/message_projection.py +269 -0
- ksadk/conversations/model_context.py +370 -0
- ksadk/conversations/model_options.py +136 -0
- ksadk/conversations/normalize.py +446 -0
- ksadk/conversations/reasoning_markup.py +91 -0
- ksadk/conversations/run_kinds.py +61 -0
- ksadk/conversations/run_status.py +53 -0
- ksadk/conversations/runtime.py +5276 -0
- ksadk/conversations/semantic_summary.py +359 -0
- ksadk/conversations/session_title.py +254 -0
- ksadk/deployment/__init__.py +83 -0
- ksadk/deployment/agent_access.py +226 -0
- ksadk/deployment/base.py +229 -0
- ksadk/deployment/manager.py +332 -0
- ksadk/deployment/providers/__init__.py +10 -0
- ksadk/deployment/providers/serverless.py +1115 -0
- ksadk/deployment/registry.py +120 -0
- ksadk/deployment/state.py +78 -0
- ksadk/deployment/ui_config.py +21 -0
- ksadk/detection/__init__.py +7 -0
- ksadk/detection/detector.py +431 -0
- ksadk/detection/mcp_detector.py +250 -0
- ksadk/hermes_terminal.py +467 -0
- ksadk/identity/__init__.py +17 -0
- ksadk/identity/resolver.py +385 -0
- ksadk/knowledge_base/__init__.py +62 -0
- ksadk/knowledge_base/adk_tool.py +47 -0
- ksadk/knowledge_base/client.py +297 -0
- ksadk/knowledge_base/langchain_tool.py +55 -0
- ksadk/knowledge_base/service.py +66 -0
- ksadk/knowledge_base/tool.py +50 -0
- ksadk/markdown.py +169 -0
- ksadk/mcp_runtime/__init__.py +231 -0
- ksadk/memory/__init__.py +57 -0
- ksadk/memory/adk/__init__.py +51 -0
- ksadk/memory/adk/backends/__init__.py +5 -0
- ksadk/memory/adk/backends/base_ltm_backend.py +52 -0
- ksadk/memory/adk/backends/http_ltm_backend.py +183 -0
- ksadk/memory/adk/backends/inmemory_ltm_backend.py +96 -0
- ksadk/memory/adk/backends/sdk_ltm_backend.py +456 -0
- ksadk/memory/adk/long_term_memory.py +273 -0
- ksadk/memory/adk/resilient_session_service.py +268 -0
- ksadk/memory/adk/short_term_memory.py +304 -0
- ksadk/memory/adk_tool.py +31 -0
- ksadk/memory/backends/__init__.py +6 -0
- ksadk/memory/backends/base.py +103 -0
- ksadk/memory/backends/memory.py +112 -0
- ksadk/memory/backends/redis.py +146 -0
- ksadk/memory/langchain_tool.py +39 -0
- ksadk/memory/ltm_backend_factory.py +24 -0
- ksadk/memory/manager.py +215 -0
- ksadk/memory/service.py +172 -0
- ksadk/memory/tool.py +96 -0
- ksadk/model_policy.py +223 -0
- ksadk/openclaw_gateway.py +378 -0
- ksadk/runners/__init__.py +8 -0
- ksadk/runners/adk_runner.py +1994 -0
- ksadk/runners/base_runner.py +305 -0
- ksadk/runners/deepagents_runner.py +13 -0
- ksadk/runners/factory.py +73 -0
- ksadk/runners/langchain_runner.py +751 -0
- ksadk/runners/langgraph_runner.py +985 -0
- ksadk/runners/patch_langchain.py +187 -0
- ksadk/runners/remote_runner.py +792 -0
- ksadk/runners/usage_accumulator.py +38 -0
- ksadk/runners/utils/__init__.py +19 -0
- ksadk/runners/utils/langfuse.py +230 -0
- ksadk/runners/utils/loader.py +62 -0
- ksadk/runtime_context.py +166 -0
- ksadk/runtime_state.py +25 -0
- ksadk/sandbox/__init__.py +28 -0
- ksadk/sandbox/backends/__init__.py +9 -0
- ksadk/sandbox/backends/e2b.py +154 -0
- ksadk/sandbox/backends/local_process.py +131 -0
- ksadk/sandbox/base.py +101 -0
- ksadk/sandbox/factory.py +53 -0
- ksadk/sandbox/registry.py +187 -0
- ksadk/server/__init__.py +7 -0
- ksadk/server/api_models.py +73 -0
- ksadk/server/app.py +3931 -0
- ksadk/server/static/assets/ArtifactsPanel-D-SSn2ee.js +1 -0
- ksadk/server/static/assets/CodeBlock-P16VOLYi.js +9 -0
- ksadk/server/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- ksadk/server/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- ksadk/server/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- ksadk/server/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- ksadk/server/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- ksadk/server/static/assets/MathMessageMarkdown-CdGiQWHR.js +8 -0
- ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +1 -0
- ksadk/server/static/assets/MermaidBlock-IOcal8cm.js +303 -0
- ksadk/server/static/assets/NativeTerminalPanel-CHOd7Rch.css +1 -0
- ksadk/server/static/assets/NativeTerminalPanel-DGSDWsei.js +1 -0
- ksadk/server/static/assets/addon-fit-DthTIhi3.js +1 -0
- ksadk/server/static/assets/apl-CnwPGSsG.js +1 -0
- ksadk/server/static/assets/arc-BA13sHc9.js +1 -0
- ksadk/server/static/assets/architecture-7EHR7CIX-BvUlsA6l.js +1 -0
- ksadk/server/static/assets/architectureDiagram-3BPJPVTR-DzJzgVoL.js +36 -0
- ksadk/server/static/assets/asciiarmor-qTkVPQu6.js +1 -0
- ksadk/server/static/assets/asn1-Dr8qZg38.js +1 -0
- ksadk/server/static/assets/asterisk-Bja0s6e1.js +1 -0
- ksadk/server/static/assets/blockDiagram-GPEHLZMM-UTdVcZhn.js +132 -0
- ksadk/server/static/assets/brainfuck-D5EjA2JK.js +1 -0
- ksadk/server/static/assets/c4Diagram-AAUBKEIU-Dix0GD4m.js +10 -0
- ksadk/server/static/assets/channel-CTuydARX.js +1 -0
- ksadk/server/static/assets/chunk-2J33WTMH-C2JxuZ42.js +1 -0
- ksadk/server/static/assets/chunk-4BX2VUAB-Be57QPgD.js +1 -0
- ksadk/server/static/assets/chunk-55IACEB6-WjSuy4nv.js +1 -0
- ksadk/server/static/assets/chunk-727SXJPM-BOovP-b5.js +206 -0
- ksadk/server/static/assets/chunk-AQP2D5EJ-DOmZNmWj.js +231 -0
- ksadk/server/static/assets/chunk-FMBD7UC4-DzwlodkU.js +15 -0
- ksadk/server/static/assets/chunk-ND2GUHAM-DMqAikEU.js +1 -0
- ksadk/server/static/assets/chunk-QZHKN3VN-BnCydivS.js +1 -0
- ksadk/server/static/assets/classDiagram-4FO5ZUOK-BNHg45Be.js +1 -0
- ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-BNHg45Be.js +1 -0
- ksadk/server/static/assets/clike-DHH8Ad3s.js +1 -0
- ksadk/server/static/assets/clojure-BflJGmDX.js +1 -0
- ksadk/server/static/assets/cmake-CyVbuzPu.js +1 -0
- ksadk/server/static/assets/cobol-B_OZ4V-R.js +1 -0
- ksadk/server/static/assets/coffeescript-B2pKbFk8.js +1 -0
- ksadk/server/static/assets/commonlisp-CcllspGY.js +1 -0
- ksadk/server/static/assets/cose-bilkent-S5V4N54A-Zhy2GBnP.js +1 -0
- ksadk/server/static/assets/crystal-M5-qzICs.js +1 -0
- ksadk/server/static/assets/css-CYpP4FRV.js +1 -0
- ksadk/server/static/assets/cypher-p9eYesGn.js +1 -0
- ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +321 -0
- ksadk/server/static/assets/d-BhMjBjQP.js +1 -0
- ksadk/server/static/assets/dagre-BM42HDAG-CbKrtIUz.js +4 -0
- ksadk/server/static/assets/dagre-Bx709z4p.js +1 -0
- ksadk/server/static/assets/defaultLocale-C8Fc0cco.js +1 -0
- ksadk/server/static/assets/diagram-2AECGRRQ-BRApgQRK.js +43 -0
- ksadk/server/static/assets/diagram-5GNKFQAL-D0xbnDiK.js +10 -0
- ksadk/server/static/assets/diagram-KO2AKTUF-CVugVI9L.js +3 -0
- ksadk/server/static/assets/diagram-LMA3HP47-DozUGgee.js +24 -0
- ksadk/server/static/assets/diagram-OG6HWLK6-ZOYsKlru.js +24 -0
- ksadk/server/static/assets/diff-ChtP43wD.js +1 -0
- ksadk/server/static/assets/dist-2hInLS_J.js +9 -0
- ksadk/server/static/assets/dist-8EEyGgDx.js +1 -0
- ksadk/server/static/assets/dist-8_SfK1IP.js +1 -0
- ksadk/server/static/assets/dist-B0NP_Z0z.js +23 -0
- ksadk/server/static/assets/dist-BIJVDMuB.js +1 -0
- ksadk/server/static/assets/dist-BmlMPsCc.js +2 -0
- ksadk/server/static/assets/dist-BrCcQ47T.js +1 -0
- ksadk/server/static/assets/dist-BssVmjIo.js +6 -0
- ksadk/server/static/assets/dist-C3HIIaN5.js +1 -0
- ksadk/server/static/assets/dist-CPBtqRTW.js +1 -0
- ksadk/server/static/assets/dist-CkEKJFtk.js +1 -0
- ksadk/server/static/assets/dist-D-JMdWPQ.js +1 -0
- ksadk/server/static/assets/dist-D-xOD048.js +1 -0
- ksadk/server/static/assets/dist-D57wnGWd.js +1 -0
- ksadk/server/static/assets/dist-DL_NKc_z.js +1 -0
- ksadk/server/static/assets/dist-DMezvBGJ.js +1 -0
- ksadk/server/static/assets/dist-DmFaq_Iy.js +1 -0
- ksadk/server/static/assets/dist-Dp0k7bWI.js +1 -0
- ksadk/server/static/assets/dist-DpKtt4Bo.js +1 -0
- ksadk/server/static/assets/dist-DyOkZmze.js +11 -0
- ksadk/server/static/assets/dist-DyWsb78Q.js +1 -0
- ksadk/server/static/assets/dist-gX6GOLKq.js +1 -0
- ksadk/server/static/assets/dockerfile-BLkNEvjs.js +1 -0
- ksadk/server/static/assets/dtd-DTF72YNO.js +1 -0
- ksadk/server/static/assets/dylan-C6jEEEk-.js +1 -0
- ksadk/server/static/assets/ebnf-D4c0_ac3.js +1 -0
- ksadk/server/static/assets/ecl-BSZaIHnp.js +1 -0
- ksadk/server/static/assets/eiffel-BEjRio4Q.js +1 -0
- ksadk/server/static/assets/elm-Cshzl8qu.js +1 -0
- ksadk/server/static/assets/erDiagram-TEJ5UH35-Bk-9bgE5.js +85 -0
- ksadk/server/static/assets/erlang-DaB2Rkuy.js +1 -0
- ksadk/server/static/assets/eventmodeling-FCH6USID-DsAgWnlf.js +1 -0
- ksadk/server/static/assets/factor-D7LVTn2l.js +1 -0
- ksadk/server/static/assets/fcl-ClOhbFR7.js +1 -0
- ksadk/server/static/assets/flowDiagram-I6XJVG4X-Dn9kZVuZ.js +162 -0
- ksadk/server/static/assets/forth-Cezjo90N.js +1 -0
- ksadk/server/static/assets/fortran-Bt6PBEDR.js +1 -0
- ksadk/server/static/assets/ganttDiagram-6RSMTGT7-BGxXlJqm.js +292 -0
- ksadk/server/static/assets/gas-BHEdbvp9.js +1 -0
- ksadk/server/static/assets/gherkin-oBAE_ms0.js +1 -0
- ksadk/server/static/assets/gitGraph-WXDBUCRP-BIao9Yjg.js +1 -0
- ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-C7Q0X-wi.js +106 -0
- ksadk/server/static/assets/graphlib-B8gBHxth.js +1 -0
- ksadk/server/static/assets/groovy-BC3IOsC8.js +1 -0
- ksadk/server/static/assets/haskell-BRsoo5mP.js +1 -0
- ksadk/server/static/assets/haxe-DIz0ZZqd.js +1 -0
- ksadk/server/static/assets/http-CLVgA2GD.js +1 -0
- ksadk/server/static/assets/idl-BUZw3wgd.js +1 -0
- ksadk/server/static/assets/index-C00Lwg3L.js +165 -0
- ksadk/server/static/assets/index-DAURbCsP.css +1 -0
- ksadk/server/static/assets/info-J43DQDTF-CyipqGxU.js +1 -0
- ksadk/server/static/assets/infoDiagram-5YYISTIA-BO_CSqq5.js +2 -0
- ksadk/server/static/assets/init-D6jRqBbL.js +1 -0
- ksadk/server/static/assets/ishikawaDiagram-YF4QCWOH-eYLPLd46.js +70 -0
- ksadk/server/static/assets/javascript-9Tg8ixDm.js +1 -0
- ksadk/server/static/assets/journeyDiagram-JHISSGLW-De9geSb0.js +139 -0
- ksadk/server/static/assets/julia-CQfgDRfP.js +1 -0
- ksadk/server/static/assets/kanban-definition-UN3LZRKU-Cs80kQto.js +89 -0
- ksadk/server/static/assets/katex-DSrCFD8n.js +265 -0
- ksadk/server/static/assets/linear-CN9sbKTF.js +1 -0
- ksadk/server/static/assets/livescript-BXxG0Yva.js +1 -0
- ksadk/server/static/assets/lua-CiA1ziua.js +1 -0
- ksadk/server/static/assets/mathematica-u8YMmbU0.js +1 -0
- ksadk/server/static/assets/mbox-BEMVcqjs.js +1 -0
- ksadk/server/static/assets/mermaid-parser.core-DlgSBU8l.js +161 -0
- ksadk/server/static/assets/mindmap-definition-RKZ34NQL-9afz7Oug.js +96 -0
- ksadk/server/static/assets/mirc-C9zpzAZU.js +1 -0
- ksadk/server/static/assets/mllike-B45xgp2S.js +1 -0
- ksadk/server/static/assets/modelica-CqHI_q-D.js +1 -0
- ksadk/server/static/assets/mscgen-qgSLujhx.js +1 -0
- ksadk/server/static/assets/mumps-CW_TBmxz.js +1 -0
- ksadk/server/static/assets/nginx-BMaDbqW3.js +1 -0
- ksadk/server/static/assets/nsis-AQ_alPln.js +1 -0
- ksadk/server/static/assets/ntriples-d28e9m0E.js +1 -0
- ksadk/server/static/assets/octave-D2Q8cUp1.js +1 -0
- ksadk/server/static/assets/ordinal-hYBb2elL.js +1 -0
- ksadk/server/static/assets/oz-BwTct_5T.js +1 -0
- ksadk/server/static/assets/packet-YPE3B663-CGkyRxlg.js +1 -0
- ksadk/server/static/assets/pascal-Dp_KdFgX.js +1 -0
- ksadk/server/static/assets/perl-CdEHsPfU.js +1 -0
- ksadk/server/static/assets/pie-LRSECV5Y-M0uWAf4n.js +1 -0
- ksadk/server/static/assets/pieDiagram-4H26LBE5-DPv_VstN.js +30 -0
- ksadk/server/static/assets/pig-AF4Hwhju.js +1 -0
- ksadk/server/static/assets/powershell-CzG71I4L.js +1 -0
- ksadk/server/static/assets/properties-C357ku5U.js +1 -0
- ksadk/server/static/assets/protobuf-D1ij_kNL.js +1 -0
- ksadk/server/static/assets/pug-Bugr-47h.js +1 -0
- ksadk/server/static/assets/puppet-B4hC2X4m.js +1 -0
- ksadk/server/static/assets/python-BqYw9LYJ.js +1 -0
- ksadk/server/static/assets/q-B9NhS7L_.js +1 -0
- ksadk/server/static/assets/quadrantDiagram-W4KKPZXB-o77wZ1XK.js +7 -0
- ksadk/server/static/assets/r-DADKenSm.js +1 -0
- ksadk/server/static/assets/radar-GUYGQ44K-Bdbm24-x.js +1 -0
- ksadk/server/static/assets/requirementDiagram-4Y6WPE33-BYVw81VC.js +84 -0
- ksadk/server/static/assets/rpm-gpDK5sbt.js +1 -0
- ksadk/server/static/assets/ruby-DsYpTuWg.js +1 -0
- ksadk/server/static/assets/sankeyDiagram-5OEKKPKP-B7nZFMKT.js +40 -0
- ksadk/server/static/assets/sas-C2OyD2Y6.js +1 -0
- ksadk/server/static/assets/scheme-CyDdOh7e.js +1 -0
- ksadk/server/static/assets/sequenceDiagram-3UESZ5HK-BGRRrFw5.js +162 -0
- ksadk/server/static/assets/shell-Du5Qg9im.js +1 -0
- ksadk/server/static/assets/sieve-BAxa3IjF.js +1 -0
- ksadk/server/static/assets/simple-mode-DRpGK0lJ.js +1 -0
- ksadk/server/static/assets/smalltalk-BLp5-jwC.js +1 -0
- ksadk/server/static/assets/solr-BBopId9w.js +1 -0
- ksadk/server/static/assets/sparql-Dz-mlCAC.js +1 -0
- ksadk/server/static/assets/spreadsheet-CTIncYxj.js +1 -0
- ksadk/server/static/assets/sql-DcCJ9jop.js +1 -0
- ksadk/server/static/assets/stateDiagram-AJRCARHV-BUfHCoPs.js +1 -0
- ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-8xBN_gLM.js +1 -0
- ksadk/server/static/assets/stex-sDrdk2BW.js +1 -0
- ksadk/server/static/assets/stylus-DLLZ8dgm.js +1 -0
- ksadk/server/static/assets/swift-DKB6_1j6.js +1 -0
- ksadk/server/static/assets/tcl-BGfruO1u.js +1 -0
- ksadk/server/static/assets/textile-DWBqthVk.js +1 -0
- ksadk/server/static/assets/tiddlywiki-VlUiK86J.js +1 -0
- ksadk/server/static/assets/tiki-CxVlf5KZ.js +1 -0
- ksadk/server/static/assets/timeline-definition-PNZ67QCA-FbCTGrf9.js +120 -0
- ksadk/server/static/assets/toml-LvoKBwCs.js +1 -0
- ksadk/server/static/assets/treeView-BLDUP644-npMo0ak1.js +1 -0
- ksadk/server/static/assets/treemap-LRROVOQU-CHdzrrdo.js +1 -0
- ksadk/server/static/assets/troff-C2dAgh7P.js +1 -0
- ksadk/server/static/assets/ttcn-BePYh-hG.js +1 -0
- ksadk/server/static/assets/ttcn-cfg-CY0aot7Q.js +1 -0
- ksadk/server/static/assets/turtle-BDLWE2cD.js +1 -0
- ksadk/server/static/assets/vb-BthdM_4N.js +1 -0
- ksadk/server/static/assets/vbscript-D9f6rSsU.js +1 -0
- ksadk/server/static/assets/velocity-CqftRsjg.js +1 -0
- ksadk/server/static/assets/vennDiagram-CIIHVFJN-DWgwtPsJ.js +34 -0
- ksadk/server/static/assets/verilog-D-x6ne0Z.js +1 -0
- ksadk/server/static/assets/vhdl-CMrOq2q1.js +1 -0
- ksadk/server/static/assets/wardley-L42UT6IY-BEQz7wyy.js +1 -0
- ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-2x8G-tTM.js +78 -0
- ksadk/server/static/assets/webidl-D5jiJvOU.js +1 -0
- ksadk/server/static/assets/xquery-fj_R-kMw.js +1 -0
- ksadk/server/static/assets/xterm-DooSxjI5.js +36 -0
- ksadk/server/static/assets/xychartDiagram-2RQKCTM6-Drma3AYl.js +7 -0
- ksadk/server/static/assets/yacas-CXXgtw0p.js +1 -0
- ksadk/server/static/assets/z80-CL1naaMV.js +1 -0
- ksadk/server/static/favicon.svg +1 -0
- ksadk/server/static/icons.svg +24 -0
- ksadk/server/static/index.html +14 -0
- ksadk/server/terminal_sessions.py +551 -0
- ksadk/sessions/__init__.py +279 -0
- ksadk/sessions/base.py +354 -0
- ksadk/sessions/continuity.py +337 -0
- ksadk/sessions/errors.py +7 -0
- ksadk/sessions/in_memory.py +273 -0
- ksadk/sessions/local_service.py +949 -0
- ksadk/sessions/postgres_service.py +901 -0
- ksadk/sessions/resilience.py +52 -0
- ksadk/sessions/resilient.py +360 -0
- ksadk/sessions/sqlite_service.py +13 -0
- ksadk/skills/__init__.py +9 -0
- ksadk/skills/loader.py +46 -0
- ksadk/skills/models.py +120 -0
- ksadk/skills/package_store.py +104 -0
- ksadk/skills/runtime/__init__.py +17 -0
- ksadk/skills/runtime/agent.py +183 -0
- ksadk/skills/runtime/artifacts.py +39 -0
- ksadk/skills/runtime/backends/__init__.py +1 -0
- ksadk/skills/runtime/backends/disabled.py +8 -0
- ksadk/skills/runtime/backends/e2b.py +168 -0
- ksadk/skills/runtime/backends/local.py +95 -0
- ksadk/skills/runtime/base.py +103 -0
- ksadk/skills/runtime/executor.py +219 -0
- ksadk/skills/runtime/factory.py +37 -0
- ksadk/skills/runtime/loader.py +131 -0
- ksadk/skills/runtime/registry.py +150 -0
- ksadk/skills/runtime/request.py +67 -0
- ksadk/skills/service_client.py +239 -0
- ksadk/skills/service_env.py +58 -0
- ksadk/skills/tool_defs.py +221 -0
- ksadk/terminal_client.py +69 -0
- ksadk/terminal_exec_policy.py +229 -0
- ksadk/tools/__init__.py +17 -0
- ksadk/tools/gateway.py +230 -0
- ksadk/tools/result_budget.py +154 -0
- ksadk/toolsets/__init__.py +854 -0
- ksadk/toolsets/_langchain.py +11 -0
- ksadk/toolsets/platform.py +93 -0
- ksadk/toolsets/sandbox.py +310 -0
- ksadk/toolsets/skills.py +217 -0
- ksadk/toolsets/web.py +266 -0
- ksadk/toolsets/workspace.py +917 -0
- ksadk/toolsets/workspace_state.py +44 -0
- ksadk/tracing/__init__.py +37 -0
- ksadk/tracing/exporters/__init__.py +1 -0
- ksadk/tracing/exporters/inmemory_exporter.py +116 -0
- ksadk/tracing/exporters/langfuse_exporter.py +445 -0
- ksadk/tracing/setup.py +892 -0
- ksadk/tracing/span_utils.py +114 -0
- ksadk/tui/__init__.py +27 -0
- ksadk/tui/loop.py +1877 -0
- ksadk/tui/stream_render.py +68 -0
- ksadk/ui_config.py +168 -0
- ksadk/version.py +4 -0
- ksadk_runtime_common/__init__.py +20 -0
- ksadk_runtime_common/memory_backend/__init__.py +29 -0
- ksadk_runtime_common/memory_backend/manifest.py +79 -0
- ksadk_runtime_common/memory_backend/providers/__init__.py +6 -0
- ksadk_runtime_common/memory_backend/providers/lancedb.py +151 -0
- ksadk_runtime_common/memory_backend/providers/mem0.py +74 -0
- ksadk_runtime_common/memory_backend/registry.py +56 -0
- ksadk_runtime_common/memory_backend/render.py +71 -0
- ksadk_runtime_common/schemas/memory_backend_manifest.schema.json +148 -0
- ksadk_runtime_common/workspace_files/__init__.py +29 -0
- ksadk_runtime_common/workspace_files/bootstrap.py +56 -0
- ksadk_runtime_common/workspace_files/constants.py +9 -0
- ksadk_runtime_common/workspace_files/path_utils.py +71 -0
- ksadk_runtime_common/workspace_files/preview.py +96 -0
- ksadk_runtime_common/workspace_files/router.py +254 -0
- agentengine_sdk_python/__init__.py +0 -3
- agentengine_sdk_python-0.6.4.dist-info/METADATA +0 -48
- agentengine_sdk_python-0.6.4.dist-info/RECORD +0 -5
- agentengine_sdk_python-0.6.4.dist-info/top_level.txt +0 -1
ksadk/tui/loop.py
ADDED
|
@@ -0,0 +1,1877 @@
|
|
|
1
|
+
"""Agent TUI based on a Codex-style inline prompt_toolkit application.
|
|
2
|
+
|
|
3
|
+
Transcript 使用 ANSI 保留 rich 颜色和 Markdown 落定格式,composer 紧跟内容,
|
|
4
|
+
终端原生 scrollback 保留。支持键盘滚动、流式跟底和输入排队。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from prompt_toolkit.completion import Completer
|
|
17
|
+
|
|
18
|
+
from ksadk.tui.stream_render import clean_response, extract_stream_delta
|
|
19
|
+
|
|
20
|
+
_TERMINAL_BG_PROBED = False
|
|
21
|
+
_TERMINAL_BG: tuple[int, int, int] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_terminal_rgb(response: str) -> tuple[int, int, int] | None:
|
|
25
|
+
"""Parse OSC 11 ``rgb:rrrr/gggg/bbbb`` or ``#rrggbb`` responses."""
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
match = re.search(r"(?:rgb:)([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", response)
|
|
29
|
+
if match:
|
|
30
|
+
channels: list[int] = []
|
|
31
|
+
for value in match.groups():
|
|
32
|
+
maximum = (16 ** len(value)) - 1
|
|
33
|
+
channels.append(int(int(value, 16) / maximum * 255) if maximum else 0)
|
|
34
|
+
return channels[0], channels[1], channels[2]
|
|
35
|
+
match = re.search(r"#([0-9a-fA-F]{6})", response)
|
|
36
|
+
if match:
|
|
37
|
+
value = match.group(1)
|
|
38
|
+
return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _terminal_background() -> tuple[int, int, int] | None:
|
|
43
|
+
"""Query the terminal default background using the same OSC 11 signal as Codex."""
|
|
44
|
+
global _TERMINAL_BG, _TERMINAL_BG_PROBED
|
|
45
|
+
if _TERMINAL_BG_PROBED:
|
|
46
|
+
return _TERMINAL_BG
|
|
47
|
+
_TERMINAL_BG_PROBED = True
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
import fcntl
|
|
51
|
+
import os
|
|
52
|
+
import select
|
|
53
|
+
import sys
|
|
54
|
+
import termios
|
|
55
|
+
import tty
|
|
56
|
+
|
|
57
|
+
if os.name != "posix" or os.getenv("TERM", "") == "dumb":
|
|
58
|
+
return None
|
|
59
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
60
|
+
return None
|
|
61
|
+
fd = sys.stdin.fileno()
|
|
62
|
+
previous_termios = termios.tcgetattr(fd)
|
|
63
|
+
previous_flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
64
|
+
data = bytearray()
|
|
65
|
+
try:
|
|
66
|
+
tty.setcbreak(fd)
|
|
67
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, previous_flags | os.O_NONBLOCK)
|
|
68
|
+
sys.stdout.write("\x1b]11;?\x1b\\")
|
|
69
|
+
sys.stdout.flush()
|
|
70
|
+
deadline = time.monotonic() + 0.12
|
|
71
|
+
while time.monotonic() < deadline:
|
|
72
|
+
readable, _, _ = select.select([fd], [], [], deadline - time.monotonic())
|
|
73
|
+
if not readable:
|
|
74
|
+
break
|
|
75
|
+
try:
|
|
76
|
+
chunk = os.read(fd, 128)
|
|
77
|
+
except BlockingIOError:
|
|
78
|
+
continue
|
|
79
|
+
if not chunk:
|
|
80
|
+
break
|
|
81
|
+
data.extend(chunk)
|
|
82
|
+
if b"\x07" in data or b"\x1b\\" in data:
|
|
83
|
+
break
|
|
84
|
+
finally:
|
|
85
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, previous_flags)
|
|
86
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, previous_termios)
|
|
87
|
+
_TERMINAL_BG = _parse_terminal_rgb(data.decode("ascii", errors="ignore"))
|
|
88
|
+
except Exception:
|
|
89
|
+
_TERMINAL_BG = None
|
|
90
|
+
return _TERMINAL_BG
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _codex_surface_rgb(
|
|
94
|
+
background: tuple[int, int, int] | None,
|
|
95
|
+
*,
|
|
96
|
+
composer: bool = False,
|
|
97
|
+
) -> tuple[int, int, int] | None:
|
|
98
|
+
"""Match Codex's user-message/composer surface blend for light and dark terminals."""
|
|
99
|
+
if background is None:
|
|
100
|
+
return None
|
|
101
|
+
r, g, b = background
|
|
102
|
+
is_light = (0.299 * r + 0.587 * g + 0.114 * b) > 128.0
|
|
103
|
+
top = (0, 0, 0) if is_light else (255, 255, 255)
|
|
104
|
+
# Keep Codex's 4% user-message blend. The composer spans the whole width,
|
|
105
|
+
# so 4% black on a light terminal is visually imperceptible; 8% gives the
|
|
106
|
+
# input surface a stable edge without introducing a border.
|
|
107
|
+
alpha = 0.08 if is_light and composer else 0.04 if is_light else 0.12
|
|
108
|
+
return tuple(int(top[i] * alpha + background[i] * (1.0 - alpha)) for i in range(3))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _codex_surface_style(background: tuple[int, int, int] | None) -> str:
|
|
112
|
+
blended = _codex_surface_rgb(background, composer=True)
|
|
113
|
+
if blended is None:
|
|
114
|
+
return ""
|
|
115
|
+
return f"bg:#{blended[0]:02x}{blended[1]:02x}{blended[2]:02x}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _terminal_clear_sequence() -> str:
|
|
119
|
+
"""Reset the inline viewport and purge terminal scrollback, like Codex ``/clear``."""
|
|
120
|
+
return "\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class InterruptPending(Exception):
|
|
124
|
+
"""interrupt chunk 信号:render_stream 抛出,InteractionLoop 捕获后弹确认。"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, interrupt_info: Any) -> None:
|
|
127
|
+
super().__init__("interrupt pending")
|
|
128
|
+
self.interrupt_info = interrupt_info
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _NullRenderer:
|
|
132
|
+
"""默认 no-op renderer(render_stream 不传 renderer 时用)。"""
|
|
133
|
+
|
|
134
|
+
async def on_text(self, full_text: str) -> None:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
async def on_thinking(self, full_thinking: str) -> None:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
async def on_tool_call(
|
|
141
|
+
self,
|
|
142
|
+
tool_name: str,
|
|
143
|
+
status: str,
|
|
144
|
+
args: Any = None,
|
|
145
|
+
call_id: str = "",
|
|
146
|
+
) -> None:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
async def on_usage(self, usage: dict) -> None:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
async def on_error(self, message: str) -> None:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
async def finalize(self) -> None:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def render_stream(
|
|
160
|
+
runner,
|
|
161
|
+
input_data: dict,
|
|
162
|
+
*,
|
|
163
|
+
renderer=None,
|
|
164
|
+
) -> tuple[str, Optional[dict]]:
|
|
165
|
+
"""消费 runner.stream,分派 chunk 到 renderer,返回 (cleaned_response, usage)。
|
|
166
|
+
|
|
167
|
+
- text/thinking → 累计全文,调 on_text/on_thinking
|
|
168
|
+
- tool_call → 按调用身份和参数快照去重,调 on_tool_call
|
|
169
|
+
- final/responses_output → 只取 usage(extract_stream_delta),不 append output
|
|
170
|
+
- interrupt → 抛 InterruptPending
|
|
171
|
+
- error → 调 on_error
|
|
172
|
+
- 流空且未中断 → fallback runner.invoke
|
|
173
|
+
流结束调 renderer.finalize()。
|
|
174
|
+
"""
|
|
175
|
+
r = renderer if renderer is not None else _NullRenderer()
|
|
176
|
+
full_response = ""
|
|
177
|
+
full_thinking = ""
|
|
178
|
+
usage: Optional[dict] = None
|
|
179
|
+
tool_event_keys: set[tuple[str, str, str, str]] = set()
|
|
180
|
+
stream_failed = False
|
|
181
|
+
|
|
182
|
+
# text/thinking/tool_call/terminal 都算有内容,避免 tool-only turn 误触发 fallback
|
|
183
|
+
saw_content = False
|
|
184
|
+
try:
|
|
185
|
+
async for chunk in runner.stream(input_data):
|
|
186
|
+
chunk_type = str(chunk.get("type") or "text")
|
|
187
|
+
|
|
188
|
+
if chunk_type == "thinking":
|
|
189
|
+
delta = chunk.get("delta") or ""
|
|
190
|
+
if delta:
|
|
191
|
+
full_thinking += str(delta)
|
|
192
|
+
saw_content = True
|
|
193
|
+
await r.on_thinking(full_thinking)
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if chunk_type == "interrupt":
|
|
197
|
+
raise InterruptPending(chunk.get("interrupt_info"))
|
|
198
|
+
|
|
199
|
+
if chunk_type == "tool_call":
|
|
200
|
+
tool_name = str(chunk.get("tool_name") or chunk.get("name") or "")
|
|
201
|
+
status = str(chunk.get("status") or "running").lower()
|
|
202
|
+
args = chunk.get("tool_args") or chunk.get("arguments") or {}
|
|
203
|
+
# 只过滤完全相同的工具事件。参数流会对同一个 call_id 连续给出更完整
|
|
204
|
+
# 的快照,必须继续透传给 renderer 覆盖;调用和结果也必须使用不同键。
|
|
205
|
+
call_id = str(chunk.get("call_id") or "")
|
|
206
|
+
dedup_key = (
|
|
207
|
+
"call",
|
|
208
|
+
call_id or tool_name,
|
|
209
|
+
status,
|
|
210
|
+
_compact_json(args),
|
|
211
|
+
)
|
|
212
|
+
if dedup_key in tool_event_keys:
|
|
213
|
+
continue
|
|
214
|
+
tool_event_keys.add(dedup_key)
|
|
215
|
+
saw_content = True
|
|
216
|
+
await r.on_tool_call(tool_name, status, args, call_id=call_id)
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
if chunk_type == "tool_result":
|
|
220
|
+
# responses 路径的 function_call_output。结果和调用分开去重,否则
|
|
221
|
+
# 同一个 call_id 的 running 事件会把最终结果错误吞掉。
|
|
222
|
+
tool_name = str(chunk.get("tool_name") or chunk.get("name") or "")
|
|
223
|
+
call_id = str(chunk.get("call_id") or "")
|
|
224
|
+
output = chunk.get("tool_output")
|
|
225
|
+
dedup_key = (
|
|
226
|
+
"result",
|
|
227
|
+
call_id or tool_name,
|
|
228
|
+
"result",
|
|
229
|
+
_compact_json(output),
|
|
230
|
+
)
|
|
231
|
+
if dedup_key in tool_event_keys:
|
|
232
|
+
continue
|
|
233
|
+
tool_event_keys.add(dedup_key)
|
|
234
|
+
saw_content = True
|
|
235
|
+
await r.on_tool_call(tool_name, "result", output, call_id=call_id)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
if chunk_type == "error":
|
|
239
|
+
await r.on_error(str(chunk.get("message") or "未知错误"))
|
|
240
|
+
saw_content = True
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
delta, chunk_usage, is_terminal = extract_stream_delta(chunk)
|
|
244
|
+
if is_terminal:
|
|
245
|
+
if chunk_usage:
|
|
246
|
+
usage = chunk_usage
|
|
247
|
+
await r.on_usage(chunk_usage)
|
|
248
|
+
saw_content = True
|
|
249
|
+
continue
|
|
250
|
+
if delta:
|
|
251
|
+
full_response += delta
|
|
252
|
+
saw_content = True
|
|
253
|
+
await r.on_text(full_response)
|
|
254
|
+
except InterruptPending:
|
|
255
|
+
raise # finalize 由 finally 统一处理
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
# HTTP/transport 错误(raise_for_status 等)→ on_error,不 crash TUI。
|
|
258
|
+
# 请求可能已在服务端执行,不能再 fallback invoke,否则工具副作用会执行两次。
|
|
259
|
+
stream_failed = True
|
|
260
|
+
await r.on_error(str(exc) or exc.__class__.__name__)
|
|
261
|
+
finally:
|
|
262
|
+
await r.finalize()
|
|
263
|
+
|
|
264
|
+
if not full_response and not saw_content and not stream_failed:
|
|
265
|
+
# 流完全空且未中断 → fallback invoke;失败显式 on_error,不静默吞。
|
|
266
|
+
try:
|
|
267
|
+
result = await runner.invoke(input_data)
|
|
268
|
+
full_response = str(result.get("output") or "")
|
|
269
|
+
if full_response:
|
|
270
|
+
await r.on_text(full_response)
|
|
271
|
+
if isinstance(result.get("usage"), dict):
|
|
272
|
+
usage = result["usage"]
|
|
273
|
+
await r.on_usage(usage)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
await r.on_error(str(exc) or exc.__class__.__name__)
|
|
276
|
+
|
|
277
|
+
return clean_response(full_response), usage
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class TranscriptEntry:
|
|
282
|
+
role: str
|
|
283
|
+
content: str = ""
|
|
284
|
+
status: str = ""
|
|
285
|
+
usage: Optional[dict[str, Any]] = None
|
|
286
|
+
thinking: str = ""
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass
|
|
290
|
+
class QueuedInput:
|
|
291
|
+
text: str
|
|
292
|
+
entry: TranscriptEntry
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class SlashCommandCompleter(Completer):
|
|
296
|
+
"""Slash command completer that works with `/c` style prefixes.
|
|
297
|
+
|
|
298
|
+
runner 可选:提供后 `/model ` 前缀补全 runner.available_models 的模型 id。
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
_COMMANDS = {
|
|
302
|
+
"/new": "start a new session",
|
|
303
|
+
"/clear": "clear transcript",
|
|
304
|
+
"/session": "show session id",
|
|
305
|
+
"/model": "show/switch model",
|
|
306
|
+
"/tools": "toggle tool details",
|
|
307
|
+
"/help": "show commands",
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
def __init__(self, runner=None) -> None:
|
|
311
|
+
self.runner = runner
|
|
312
|
+
|
|
313
|
+
def get_completions(self, document, complete_event):
|
|
314
|
+
from prompt_toolkit.completion import Completion
|
|
315
|
+
|
|
316
|
+
text = document.text_before_cursor
|
|
317
|
+
prefix = text.strip()
|
|
318
|
+
if not prefix.startswith("/"):
|
|
319
|
+
return
|
|
320
|
+
# /model <prefix> → 补全可选模型 id
|
|
321
|
+
if prefix.startswith("/model ") and self.runner is not None:
|
|
322
|
+
model_prefix = prefix[len("/model "):].strip()
|
|
323
|
+
available = getattr(self.runner, "available_models", None) or []
|
|
324
|
+
for m in available:
|
|
325
|
+
mid = str(m.get("id") or m.get("name") or "")
|
|
326
|
+
if mid and mid.startswith(model_prefix):
|
|
327
|
+
yield Completion(mid, start_position=-len(model_prefix), display_meta=m.get("display_name") or "")
|
|
328
|
+
return
|
|
329
|
+
for command, meta in self._COMMANDS.items():
|
|
330
|
+
if command.startswith(prefix):
|
|
331
|
+
yield Completion(command, start_position=-len(prefix), display_meta=meta)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class RichLiveRenderer:
|
|
335
|
+
"""Renderer adapter used by `render_stream` inside the inline TUI。
|
|
336
|
+
|
|
337
|
+
文本和工具事件按到达顺序保存在 ``_ordered_entries``。同一个 call_id 的
|
|
338
|
+
running/result 更新原位置,工具后的新文本进入新的 assistant 段,因此落定
|
|
339
|
+
后不会把已完成工具统一搬到最终回复下面。
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def __init__(
|
|
343
|
+
self,
|
|
344
|
+
loop: "InteractionLoop" | None = None,
|
|
345
|
+
assistant_entry: TranscriptEntry | None = None,
|
|
346
|
+
*,
|
|
347
|
+
show_thinking: bool = False,
|
|
348
|
+
) -> None:
|
|
349
|
+
self.loop = loop
|
|
350
|
+
self.assistant_entry = assistant_entry
|
|
351
|
+
self.show_thinking = show_thinking
|
|
352
|
+
self._tool_entries: list[TranscriptEntry] = []
|
|
353
|
+
self._ordered_entries: list[TranscriptEntry] = []
|
|
354
|
+
self._tool_entries_by_key: dict[str, TranscriptEntry] = {}
|
|
355
|
+
self._active_text_entry: TranscriptEntry | None = None
|
|
356
|
+
self._last_full_text = ""
|
|
357
|
+
|
|
358
|
+
def _compose_streaming(self) -> str:
|
|
359
|
+
parts: list[str] = []
|
|
360
|
+
for entry in self._ordered_entries:
|
|
361
|
+
if entry.role == "assistant":
|
|
362
|
+
if entry.content:
|
|
363
|
+
parts.append(entry.content)
|
|
364
|
+
continue
|
|
365
|
+
# streaming 期 tool 就地显示;完成态仍更新同一行,不改变事件位置。
|
|
366
|
+
name = (entry.content or "").split("\n", 1)[0]
|
|
367
|
+
parts.append(f"• {name}")
|
|
368
|
+
return "\n".join(parts)
|
|
369
|
+
|
|
370
|
+
async def on_text(self, full_text: str) -> None:
|
|
371
|
+
if self.loop is not None and self.assistant_entry is not None:
|
|
372
|
+
self.assistant_entry.content = full_text
|
|
373
|
+
self.assistant_entry.status = "streaming"
|
|
374
|
+
if full_text.startswith(self._last_full_text):
|
|
375
|
+
delta = full_text[len(self._last_full_text):]
|
|
376
|
+
else:
|
|
377
|
+
# A final/snapshot event can replace the cumulative text. Preserve
|
|
378
|
+
# ordering when possible; without tools it is safe to replace the
|
|
379
|
+
# sole assistant segment directly.
|
|
380
|
+
delta = full_text
|
|
381
|
+
if not self._tool_entries:
|
|
382
|
+
self._ordered_entries.clear()
|
|
383
|
+
self._active_text_entry = None
|
|
384
|
+
if delta:
|
|
385
|
+
if self._active_text_entry is None:
|
|
386
|
+
self._active_text_entry = TranscriptEntry(
|
|
387
|
+
role="assistant",
|
|
388
|
+
content="",
|
|
389
|
+
status="streaming",
|
|
390
|
+
)
|
|
391
|
+
self._ordered_entries.append(self._active_text_entry)
|
|
392
|
+
self._active_text_entry.content += delta
|
|
393
|
+
self._last_full_text = full_text
|
|
394
|
+
self.loop._set_streaming(self._compose_streaming())
|
|
395
|
+
|
|
396
|
+
async def on_thinking(self, full_thinking: str) -> None:
|
|
397
|
+
if self.loop is not None and self.assistant_entry is not None:
|
|
398
|
+
self.assistant_entry.thinking = full_thinking
|
|
399
|
+
# thinking 不进动态区,落定时随 assistant 一起渲染(show_thinking)
|
|
400
|
+
|
|
401
|
+
async def on_tool_call(self, tool_name: str, status: str, args: Any = None, call_id: str = "") -> None:
|
|
402
|
+
if self.loop is None:
|
|
403
|
+
return
|
|
404
|
+
content = f"{tool_name} [{status}]"
|
|
405
|
+
if args:
|
|
406
|
+
content = f"{content}\n{_compact_json(args)}"
|
|
407
|
+
entry = TranscriptEntry(role="tool", content=content, status=status)
|
|
408
|
+
# 同一调用按 call_id 覆盖(running → result 状态变化);无 call_id 回退按 name 覆盖。
|
|
409
|
+
# 不同 call_id(同名多次调用)各自追加,不互相覆盖。
|
|
410
|
+
merge_key = call_id or tool_name
|
|
411
|
+
existing = self._tool_entries_by_key.get(merge_key)
|
|
412
|
+
if existing is None:
|
|
413
|
+
self._tool_entries.append(entry)
|
|
414
|
+
self._ordered_entries.append(entry)
|
|
415
|
+
self._tool_entries_by_key[merge_key] = entry
|
|
416
|
+
# The next text delta belongs after this tool event.
|
|
417
|
+
self._active_text_entry = None
|
|
418
|
+
else:
|
|
419
|
+
existing.content = entry.content
|
|
420
|
+
existing.status = entry.status
|
|
421
|
+
entry = existing
|
|
422
|
+
entry._tool_call_id = merge_key # type: ignore[attr-defined]
|
|
423
|
+
# tool 行立即进动态区(就地在文本流里显示),不中途落定避免时序错乱。
|
|
424
|
+
self.loop._set_streaming(self._compose_streaming())
|
|
425
|
+
|
|
426
|
+
async def on_usage(self, usage: dict) -> None:
|
|
427
|
+
if self.loop is not None and self.assistant_entry is not None:
|
|
428
|
+
self.assistant_entry.usage = usage
|
|
429
|
+
self.loop._last_usage = usage
|
|
430
|
+
|
|
431
|
+
async def on_error(self, message: str) -> None:
|
|
432
|
+
if self.loop is not None:
|
|
433
|
+
# error 立即落定(错误不属于 assistant 文本流,单列醒目)。
|
|
434
|
+
await self.loop._commit_entry(TranscriptEntry(role="error", content=message))
|
|
435
|
+
|
|
436
|
+
async def finalize(self) -> None:
|
|
437
|
+
# 落定由 InteractionLoop._run_turn_async 统一处理,这里只清动态区。
|
|
438
|
+
if self.loop is not None:
|
|
439
|
+
self.loop._clear_streaming()
|
|
440
|
+
|
|
441
|
+
def final_entries(self, response: str) -> list[TranscriptEntry]:
|
|
442
|
+
"""Return finalized display cells in the original stream event order."""
|
|
443
|
+
if response and response.startswith(self._last_full_text):
|
|
444
|
+
delta = response[len(self._last_full_text):]
|
|
445
|
+
if delta:
|
|
446
|
+
if self._active_text_entry is None:
|
|
447
|
+
self._active_text_entry = TranscriptEntry(role="assistant")
|
|
448
|
+
self._ordered_entries.append(self._active_text_entry)
|
|
449
|
+
self._active_text_entry.content += delta
|
|
450
|
+
elif response and not self._tool_entries:
|
|
451
|
+
if self._ordered_entries:
|
|
452
|
+
self._ordered_entries[0].content = response
|
|
453
|
+
else:
|
|
454
|
+
self._ordered_entries.append(TranscriptEntry(role="assistant", content=response))
|
|
455
|
+
|
|
456
|
+
assistants = [entry for entry in self._ordered_entries if entry.role == "assistant"]
|
|
457
|
+
if self.assistant_entry is not None and self.assistant_entry.thinking:
|
|
458
|
+
if not assistants:
|
|
459
|
+
thinking_entry = TranscriptEntry(role="assistant")
|
|
460
|
+
self._ordered_entries.insert(0, thinking_entry)
|
|
461
|
+
assistants = [thinking_entry]
|
|
462
|
+
assistants[0].thinking = self.assistant_entry.thinking
|
|
463
|
+
if self.assistant_entry is not None and self.assistant_entry.usage and assistants:
|
|
464
|
+
assistants[-1].usage = self.assistant_entry.usage
|
|
465
|
+
|
|
466
|
+
for entry in assistants:
|
|
467
|
+
entry.status = ""
|
|
468
|
+
return [
|
|
469
|
+
entry
|
|
470
|
+
for entry in self._ordered_entries
|
|
471
|
+
if entry.role != "assistant" or entry.content or entry.thinking
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class InteractionLoop:
|
|
476
|
+
"""Inline Codex-style prompt_toolkit interaction loop."""
|
|
477
|
+
|
|
478
|
+
def __init__(
|
|
479
|
+
self,
|
|
480
|
+
runner,
|
|
481
|
+
*,
|
|
482
|
+
show_thinking: bool = False,
|
|
483
|
+
project_dir: str = ".",
|
|
484
|
+
no_alt_screen: bool = False,
|
|
485
|
+
) -> None:
|
|
486
|
+
self.runner = runner
|
|
487
|
+
self.show_thinking = show_thinking
|
|
488
|
+
self.project_dir = Path(project_dir).resolve()
|
|
489
|
+
self._no_alt_screen = no_alt_screen
|
|
490
|
+
self.session_id = str(getattr(runner, "session_id", None) or uuid.uuid4().hex[:8])
|
|
491
|
+
if getattr(runner, "session_id", None) is None:
|
|
492
|
+
runner.session_id = self.session_id
|
|
493
|
+
self.history: list[dict[str, str]] = []
|
|
494
|
+
self._model_name = _resolve_model_name(runner)
|
|
495
|
+
self._queued_inputs: list[QueuedInput] = []
|
|
496
|
+
self._active_task: Any = None
|
|
497
|
+
self._status_refresh_task: Any = None
|
|
498
|
+
self._turn_started_at: float | None = None
|
|
499
|
+
self._pending_interrupt: tuple[InterruptPending, dict[str, Any]] | None = None
|
|
500
|
+
self._app = None
|
|
501
|
+
self._input_buffer = None
|
|
502
|
+
self._entries: list[TranscriptEntry] = []
|
|
503
|
+
self._streaming_entry: TranscriptEntry | None = None
|
|
504
|
+
self._transcript_ansi = ""
|
|
505
|
+
self._transcript_window = None
|
|
506
|
+
self._user_scroll = 0 # 用户手动滚动位置(pin bottom 时忽略)
|
|
507
|
+
self._pin_to_bottom = True
|
|
508
|
+
self._last_max_scroll = 0
|
|
509
|
+
self._history_pager_active = False
|
|
510
|
+
self._entry_ansi_cache: dict[int, str] = {} # 历史 entries ANSI 缓存(避免 streaming 全量重渲)
|
|
511
|
+
self._last_usage: dict[str, Any] | None = None
|
|
512
|
+
self._showed_help = False # 首次运行显示帮助列表
|
|
513
|
+
self._welcome_ansi: str | None = None
|
|
514
|
+
self._emitted_entry_ids: set[int] = set()
|
|
515
|
+
self._show_tool_details = False
|
|
516
|
+
# 交互式模型选择器状态
|
|
517
|
+
self._model_picker_active = False
|
|
518
|
+
self._model_picker_index = 0
|
|
519
|
+
self._model_picker_models: list[dict[str, Any]] = []
|
|
520
|
+
|
|
521
|
+
def run(self) -> None:
|
|
522
|
+
asyncio.run(self.run_async())
|
|
523
|
+
|
|
524
|
+
async def run_async(self) -> None:
|
|
525
|
+
app = self._build_application()
|
|
526
|
+
self._print_initial_history(app)
|
|
527
|
+
await app.run_async()
|
|
528
|
+
|
|
529
|
+
def _print_initial_history(self, app) -> None:
|
|
530
|
+
"""Write the startup card and any preloaded entries once before live rendering."""
|
|
531
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
532
|
+
|
|
533
|
+
chunks = [self._welcome_history_ansi()]
|
|
534
|
+
for entry in self._entries:
|
|
535
|
+
chunks.append(self._history_entry_ansi(entry))
|
|
536
|
+
self._emitted_entry_ids.add(id(entry))
|
|
537
|
+
app.print_text(ANSI("\n".join(chunks)))
|
|
538
|
+
|
|
539
|
+
def _welcome_history_ansi(self) -> str:
|
|
540
|
+
if self._welcome_ansi is None:
|
|
541
|
+
self._welcome_ansi = _welcome_block(
|
|
542
|
+
self.session_id,
|
|
543
|
+
self._current_model_name(),
|
|
544
|
+
self.project_dir,
|
|
545
|
+
show_help=True,
|
|
546
|
+
)
|
|
547
|
+
self._showed_help = True
|
|
548
|
+
return self._welcome_ansi
|
|
549
|
+
|
|
550
|
+
def _history_entry_ansi(self, entry: TranscriptEntry) -> str:
|
|
551
|
+
key = id(entry)
|
|
552
|
+
ansi = self._entry_ansi_cache.get(key)
|
|
553
|
+
if ansi is None:
|
|
554
|
+
ansi = _render_entry_ansi(
|
|
555
|
+
entry,
|
|
556
|
+
show_thinking=self.show_thinking,
|
|
557
|
+
show_tool_details=self._show_tool_details,
|
|
558
|
+
)
|
|
559
|
+
self._entry_ansi_cache[key] = ansi
|
|
560
|
+
return ansi.rstrip("\n")
|
|
561
|
+
|
|
562
|
+
async def _emit_history_entry(self, entry: TranscriptEntry) -> None:
|
|
563
|
+
"""Commit one history cell above the live prompt, matching Codex scrollback."""
|
|
564
|
+
from prompt_toolkit.application import run_in_terminal
|
|
565
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
566
|
+
|
|
567
|
+
if id(entry) in self._emitted_entry_ids:
|
|
568
|
+
return
|
|
569
|
+
self._emitted_entry_ids.add(id(entry))
|
|
570
|
+
if self._app is not None and self._app.is_running:
|
|
571
|
+
await run_in_terminal(
|
|
572
|
+
lambda: self._app.print_text(ANSI(self._history_entry_ansi(entry) + "\n"))
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def _build_application(self):
|
|
576
|
+
from prompt_toolkit.application import Application
|
|
577
|
+
from prompt_toolkit.buffer import Buffer
|
|
578
|
+
from prompt_toolkit.cursor_shapes import CursorShape
|
|
579
|
+
from prompt_toolkit.filters import Condition
|
|
580
|
+
from prompt_toolkit.history import InMemoryHistory
|
|
581
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
582
|
+
from prompt_toolkit.layout import Layout
|
|
583
|
+
from prompt_toolkit.layout.containers import (
|
|
584
|
+
ConditionalContainer,
|
|
585
|
+
Float,
|
|
586
|
+
FloatContainer,
|
|
587
|
+
HSplit,
|
|
588
|
+
VSplit,
|
|
589
|
+
Window,
|
|
590
|
+
)
|
|
591
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
592
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
593
|
+
from prompt_toolkit.layout.menus import CompletionsMenu
|
|
594
|
+
from prompt_toolkit.layout.processors import AfterInput, ConditionalProcessor
|
|
595
|
+
from prompt_toolkit.styles import Style
|
|
596
|
+
|
|
597
|
+
self._input_buffer = Buffer(
|
|
598
|
+
completer=SlashCommandCompleter(runner=self.runner),
|
|
599
|
+
complete_while_typing=True,
|
|
600
|
+
multiline=True,
|
|
601
|
+
history=InMemoryHistory(),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# Codex uses an inline, content-first viewport: short transcripts keep
|
|
605
|
+
# the composer directly below the content and a filler absorbs the rest
|
|
606
|
+
# of the terminal. Long transcripts shrink to the available viewport.
|
|
607
|
+
self._transcript_height = lambda: Dimension(
|
|
608
|
+
min=1,
|
|
609
|
+
preferred=self._transcript_line_count(),
|
|
610
|
+
max=self._transcript_line_count(),
|
|
611
|
+
)
|
|
612
|
+
self._transcript_window = Window(
|
|
613
|
+
FormattedTextControl(
|
|
614
|
+
self._transcript_fragments,
|
|
615
|
+
get_cursor_position=self._transcript_cursor,
|
|
616
|
+
show_cursor=False,
|
|
617
|
+
),
|
|
618
|
+
height=self._transcript_height,
|
|
619
|
+
wrap_lines=True,
|
|
620
|
+
allow_scroll_beyond_bottom=True,
|
|
621
|
+
style="class:transcript",
|
|
622
|
+
)
|
|
623
|
+
self._transcript_window_left = Window(
|
|
624
|
+
width=2,
|
|
625
|
+
height=self._transcript_height,
|
|
626
|
+
char=" ",
|
|
627
|
+
style="class:transcript",
|
|
628
|
+
)
|
|
629
|
+
transcript_row = VSplit([self._transcript_window_left, self._transcript_window])
|
|
630
|
+
|
|
631
|
+
# Codex composer: a three-row input band, with the prompt vertically
|
|
632
|
+
# centered and a muted placeholder while the buffer is empty.
|
|
633
|
+
prompt_window = Window(
|
|
634
|
+
FormattedTextControl(lambda: [("class:prompt", "› ")]),
|
|
635
|
+
width=2,
|
|
636
|
+
dont_extend_width=True,
|
|
637
|
+
)
|
|
638
|
+
self._input_height = lambda: Dimension(
|
|
639
|
+
min=1,
|
|
640
|
+
preferred=self._input_display_height(),
|
|
641
|
+
max=max(1, self._input_display_height()),
|
|
642
|
+
)
|
|
643
|
+
placeholder = ConditionalProcessor(
|
|
644
|
+
processor=AfterInput(
|
|
645
|
+
"Ask KsADK to do anything",
|
|
646
|
+
style="class:input-placeholder",
|
|
647
|
+
),
|
|
648
|
+
filter=Condition(lambda: not self._input_buffer.text),
|
|
649
|
+
)
|
|
650
|
+
self._input_window = Window(
|
|
651
|
+
BufferControl(
|
|
652
|
+
buffer=self._input_buffer,
|
|
653
|
+
input_processors=[placeholder],
|
|
654
|
+
),
|
|
655
|
+
height=self._input_height,
|
|
656
|
+
wrap_lines=True,
|
|
657
|
+
style="class:input",
|
|
658
|
+
)
|
|
659
|
+
input_row = VSplit(
|
|
660
|
+
[prompt_window, self._input_window],
|
|
661
|
+
style="class:input-frame",
|
|
662
|
+
)
|
|
663
|
+
composer = HSplit(
|
|
664
|
+
[
|
|
665
|
+
Window(height=1, char=" ", style="class:input-frame"),
|
|
666
|
+
input_row,
|
|
667
|
+
Window(height=1, char=" ", style="class:input-frame"),
|
|
668
|
+
],
|
|
669
|
+
style="class:input-frame",
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
self._status_condition = Condition(
|
|
673
|
+
lambda: self._streaming_entry is not None or self._pending_interrupt is not None
|
|
674
|
+
)
|
|
675
|
+
self._status_window = Window(
|
|
676
|
+
FormattedTextControl(self._status_fragments),
|
|
677
|
+
height=1,
|
|
678
|
+
style="class:status",
|
|
679
|
+
)
|
|
680
|
+
status_block = ConditionalContainer(
|
|
681
|
+
HSplit(
|
|
682
|
+
[
|
|
683
|
+
Window(height=1),
|
|
684
|
+
self._status_window,
|
|
685
|
+
Window(height=1),
|
|
686
|
+
]
|
|
687
|
+
),
|
|
688
|
+
filter=self._status_condition,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
self._footer_window = Window(
|
|
692
|
+
FormattedTextControl(self._footer_fragments),
|
|
693
|
+
height=1,
|
|
694
|
+
style="class:footer",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
self._picker_condition = Condition(lambda: self._model_picker_active)
|
|
698
|
+
picker_reserve = ConditionalContainer(
|
|
699
|
+
Window(
|
|
700
|
+
height=lambda: Dimension(
|
|
701
|
+
preferred=5 + min(12, max(3, len(self._model_picker_models)))
|
|
702
|
+
),
|
|
703
|
+
char=" ",
|
|
704
|
+
style="class:app",
|
|
705
|
+
),
|
|
706
|
+
filter=self._picker_condition,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
body = HSplit(
|
|
710
|
+
[
|
|
711
|
+
transcript_row,
|
|
712
|
+
status_block,
|
|
713
|
+
Window(height=1),
|
|
714
|
+
picker_reserve,
|
|
715
|
+
composer,
|
|
716
|
+
self._footer_window,
|
|
717
|
+
Window(char=" ", style="class:app"),
|
|
718
|
+
],
|
|
719
|
+
style="class:app",
|
|
720
|
+
)
|
|
721
|
+
# Codex-style model selection surface: unframed title/subtitle, numbered
|
|
722
|
+
# rows, and a short confirmation hint above the composer.
|
|
723
|
+
picker_width = Dimension(min=32, preferred=68, max=88)
|
|
724
|
+
self._picker_window = Window(
|
|
725
|
+
FormattedTextControl(
|
|
726
|
+
self._model_picker_fragments,
|
|
727
|
+
get_cursor_position=self._picker_cursor,
|
|
728
|
+
show_cursor=False,
|
|
729
|
+
),
|
|
730
|
+
width=picker_width,
|
|
731
|
+
height=lambda: Dimension(preferred=min(12, max(3, len(self._model_picker_models)))),
|
|
732
|
+
wrap_lines=False,
|
|
733
|
+
allow_scroll_beyond_bottom=True,
|
|
734
|
+
style="class:model-picker",
|
|
735
|
+
)
|
|
736
|
+
picker_body = HSplit(
|
|
737
|
+
[
|
|
738
|
+
Window(
|
|
739
|
+
FormattedTextControl(self._model_picker_header_fragments),
|
|
740
|
+
width=picker_width,
|
|
741
|
+
height=3,
|
|
742
|
+
style="class:model-picker-header",
|
|
743
|
+
),
|
|
744
|
+
self._picker_window,
|
|
745
|
+
Window(
|
|
746
|
+
FormattedTextControl(self._model_picker_footer_fragments),
|
|
747
|
+
width=picker_width,
|
|
748
|
+
height=2,
|
|
749
|
+
style="class:model-picker-footer",
|
|
750
|
+
),
|
|
751
|
+
],
|
|
752
|
+
style="class:model-picker",
|
|
753
|
+
)
|
|
754
|
+
picker_float = Float(
|
|
755
|
+
# Sit above composer (3 rows), footer (1), and trailing app row (1).
|
|
756
|
+
bottom=5,
|
|
757
|
+
left=2,
|
|
758
|
+
content=ConditionalContainer(
|
|
759
|
+
picker_body,
|
|
760
|
+
filter=self._picker_condition,
|
|
761
|
+
),
|
|
762
|
+
)
|
|
763
|
+
root = FloatContainer(
|
|
764
|
+
content=body,
|
|
765
|
+
floats=[
|
|
766
|
+
Float(xcursor=True, ycursor=True, content=CompletionsMenu(max_height=6)),
|
|
767
|
+
picker_float,
|
|
768
|
+
],
|
|
769
|
+
)
|
|
770
|
+
bindings = KeyBindings()
|
|
771
|
+
|
|
772
|
+
@bindings.add("enter")
|
|
773
|
+
def _submit(event) -> None:
|
|
774
|
+
text = self._input_buffer.text
|
|
775
|
+
self._input_buffer.reset(append_to_history=bool(text.strip()))
|
|
776
|
+
self._submit_text(text)
|
|
777
|
+
|
|
778
|
+
@bindings.add("escape", "enter")
|
|
779
|
+
def _newline(event) -> None:
|
|
780
|
+
self._input_buffer.insert_text("\n")
|
|
781
|
+
|
|
782
|
+
@bindings.add("c-c")
|
|
783
|
+
def _interrupt_or_exit(event) -> None:
|
|
784
|
+
if self._has_active_turn():
|
|
785
|
+
self._active_task.cancel()
|
|
786
|
+
else:
|
|
787
|
+
event.app.exit()
|
|
788
|
+
|
|
789
|
+
@bindings.add("escape", filter=self._picker_condition)
|
|
790
|
+
def _picker_cancel(event) -> None:
|
|
791
|
+
self._close_model_picker()
|
|
792
|
+
event.app.invalidate()
|
|
793
|
+
|
|
794
|
+
# 注:ESC 取消流式因 prompt_toolkit 的 escape,enter 序列冲突不可靠,
|
|
795
|
+
# 流式取消用 c-c(_interrupt_or_exit 已处理)。
|
|
796
|
+
|
|
797
|
+
@bindings.add("c-d")
|
|
798
|
+
def _exit_on_empty(event) -> None:
|
|
799
|
+
if not self._input_buffer.text:
|
|
800
|
+
event.app.exit()
|
|
801
|
+
|
|
802
|
+
@bindings.add("pageup")
|
|
803
|
+
def _scroll_up(event) -> None:
|
|
804
|
+
self._scroll_transcript(-10)
|
|
805
|
+
event.app.invalidate()
|
|
806
|
+
|
|
807
|
+
@bindings.add("pagedown")
|
|
808
|
+
def _scroll_down(event) -> None:
|
|
809
|
+
self._scroll_transcript(10)
|
|
810
|
+
event.app.invalidate()
|
|
811
|
+
|
|
812
|
+
@bindings.add("c-u")
|
|
813
|
+
def _scroll_half_up(event) -> None:
|
|
814
|
+
self._scroll_transcript(-12)
|
|
815
|
+
event.app.invalidate()
|
|
816
|
+
|
|
817
|
+
@bindings.add("c-f")
|
|
818
|
+
def _scroll_half_down(event) -> None:
|
|
819
|
+
self._scroll_transcript(12)
|
|
820
|
+
event.app.invalidate()
|
|
821
|
+
|
|
822
|
+
composer_arrow_filter = Condition(lambda: not self._model_picker_active)
|
|
823
|
+
|
|
824
|
+
@bindings.add("up", filter=composer_arrow_filter)
|
|
825
|
+
def _composer_up(event) -> None:
|
|
826
|
+
# prompt_toolkit auto_up implements the desired priority:
|
|
827
|
+
# completion popup -> multiline cursor -> submitted input history.
|
|
828
|
+
event.current_buffer.auto_up()
|
|
829
|
+
|
|
830
|
+
@bindings.add("down", filter=composer_arrow_filter)
|
|
831
|
+
def _composer_down(event) -> None:
|
|
832
|
+
event.current_buffer.auto_down()
|
|
833
|
+
|
|
834
|
+
# 模型选择器键:仅 picker 激活时生效
|
|
835
|
+
@bindings.add("up", filter=self._picker_condition)
|
|
836
|
+
def _picker_up(event) -> None:
|
|
837
|
+
if self._model_picker_models:
|
|
838
|
+
self._model_picker_index = (self._model_picker_index - 1) % len(self._model_picker_models)
|
|
839
|
+
self._scroll_picker_to_selected()
|
|
840
|
+
event.app.invalidate()
|
|
841
|
+
|
|
842
|
+
@bindings.add("down", filter=self._picker_condition)
|
|
843
|
+
def _picker_down(event) -> None:
|
|
844
|
+
if self._model_picker_models:
|
|
845
|
+
self._model_picker_index = (self._model_picker_index + 1) % len(self._model_picker_models)
|
|
846
|
+
self._scroll_picker_to_selected()
|
|
847
|
+
event.app.invalidate()
|
|
848
|
+
|
|
849
|
+
@bindings.add("enter", filter=self._picker_condition)
|
|
850
|
+
def _picker_select(event) -> None:
|
|
851
|
+
models = self._model_picker_models
|
|
852
|
+
if models and 0 <= self._model_picker_index < len(models):
|
|
853
|
+
mid = str(models[self._model_picker_index].get("id") or models[self._model_picker_index].get("name") or "")
|
|
854
|
+
if mid:
|
|
855
|
+
self._apply_model_switch(mid)
|
|
856
|
+
self._close_model_picker()
|
|
857
|
+
self._ack(f"已切换到 model: {mid}(下一轮请求生效,不持久化)")
|
|
858
|
+
return
|
|
859
|
+
self._close_model_picker()
|
|
860
|
+
event.app.invalidate()
|
|
861
|
+
|
|
862
|
+
layout = Layout(root)
|
|
863
|
+
layout.focus(self._input_window)
|
|
864
|
+
composer_surface = _codex_surface_style(_terminal_background())
|
|
865
|
+
app = Application(
|
|
866
|
+
layout=layout,
|
|
867
|
+
key_bindings=bindings,
|
|
868
|
+
full_screen=False,
|
|
869
|
+
mouse_support=False,
|
|
870
|
+
min_redraw_interval=0.05,
|
|
871
|
+
cursor=CursorShape.BLINKING_BEAM,
|
|
872
|
+
style=Style.from_dict(
|
|
873
|
+
{
|
|
874
|
+
"app": "",
|
|
875
|
+
"transcript": "",
|
|
876
|
+
"prompt": f"bold {composer_surface}".strip(),
|
|
877
|
+
"input": composer_surface,
|
|
878
|
+
"input-frame": composer_surface,
|
|
879
|
+
"input-placeholder": f"dim {composer_surface}".strip(),
|
|
880
|
+
"footer": "ansigray",
|
|
881
|
+
"footer-warn": "ansired bold",
|
|
882
|
+
"status": "ansigray",
|
|
883
|
+
"status-bullet": "ansiwhite bold",
|
|
884
|
+
"system": "ansigray",
|
|
885
|
+
"welcome-border": "ansigray",
|
|
886
|
+
"model-picker": "",
|
|
887
|
+
"model-picker-header": "",
|
|
888
|
+
"model-picker-title": "bold",
|
|
889
|
+
"model-picker-subtitle": "ansigray",
|
|
890
|
+
"model-picker-item": "",
|
|
891
|
+
"model-picker-selected": "bold",
|
|
892
|
+
"model-picker-current": "bold",
|
|
893
|
+
"model-picker-footer": "ansigray",
|
|
894
|
+
}
|
|
895
|
+
),
|
|
896
|
+
)
|
|
897
|
+
self._app = app
|
|
898
|
+
self._refresh_transcript()
|
|
899
|
+
return app
|
|
900
|
+
|
|
901
|
+
# ---- 输入提交 / 轮次 ----
|
|
902
|
+
|
|
903
|
+
def _submit_text(self, user_input: str) -> None:
|
|
904
|
+
text = user_input.strip()
|
|
905
|
+
if not text:
|
|
906
|
+
return
|
|
907
|
+
|
|
908
|
+
if self._pending_interrupt is not None:
|
|
909
|
+
self._handle_interrupt_answer(text)
|
|
910
|
+
return
|
|
911
|
+
|
|
912
|
+
action = self._handle_command(text)
|
|
913
|
+
if action == "quit":
|
|
914
|
+
self._exit_app()
|
|
915
|
+
return
|
|
916
|
+
if action != "send":
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
if self._has_active_turn():
|
|
920
|
+
# streaming 时输入 → 排队下一轮(user entry 标记 queued 后落定)
|
|
921
|
+
queued_entry = TranscriptEntry(role="user", content=text, status="queued")
|
|
922
|
+
self._queued_inputs.append(QueuedInput(text=text, entry=queued_entry))
|
|
923
|
+
self._create_background_task(self._commit_entry(queued_entry))
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
self._start_turn(text)
|
|
927
|
+
|
|
928
|
+
def _start_turn(
|
|
929
|
+
self,
|
|
930
|
+
user_input: str,
|
|
931
|
+
*,
|
|
932
|
+
user_entry: TranscriptEntry | None = None,
|
|
933
|
+
input_data: dict[str, Any] | None = None,
|
|
934
|
+
is_resume: bool = False,
|
|
935
|
+
) -> None:
|
|
936
|
+
self._turn_started_at = time.monotonic()
|
|
937
|
+
|
|
938
|
+
async def _run():
|
|
939
|
+
if not is_resume:
|
|
940
|
+
if user_entry is not None:
|
|
941
|
+
user_entry.status = ""
|
|
942
|
+
# 队列的 user_entry 在 _submit_text 排队时已落定,不重复 commit(否则重复显示)
|
|
943
|
+
if user_entry not in self._entries:
|
|
944
|
+
await self._commit_entry(user_entry)
|
|
945
|
+
else:
|
|
946
|
+
self._refresh_transcript()
|
|
947
|
+
else:
|
|
948
|
+
await self._commit_entry(TranscriptEntry(role="user", content=user_input))
|
|
949
|
+
self.history.append({"role": "user", "content": user_input})
|
|
950
|
+
elif user_entry is not None:
|
|
951
|
+
user_entry.status = ""
|
|
952
|
+
|
|
953
|
+
turn_input = input_data or self._build_input_data(user_input)
|
|
954
|
+
assistant_entry = TranscriptEntry(role="assistant", content="", status="streaming")
|
|
955
|
+
self._streaming_entry = assistant_entry
|
|
956
|
+
self._refresh_transcript()
|
|
957
|
+
await self._run_turn_async(turn_input, assistant_entry, is_resume=is_resume)
|
|
958
|
+
|
|
959
|
+
self._create_background_task(_run())
|
|
960
|
+
self._ensure_status_refresh()
|
|
961
|
+
|
|
962
|
+
async def _run_turn_async(
|
|
963
|
+
self,
|
|
964
|
+
input_data: dict[str, Any],
|
|
965
|
+
assistant_entry: TranscriptEntry,
|
|
966
|
+
*,
|
|
967
|
+
is_resume: bool = False,
|
|
968
|
+
) -> None:
|
|
969
|
+
renderer = RichLiveRenderer(
|
|
970
|
+
self,
|
|
971
|
+
assistant_entry,
|
|
972
|
+
show_thinking=self.show_thinking,
|
|
973
|
+
)
|
|
974
|
+
try:
|
|
975
|
+
response, usage = await render_stream(self.runner, input_data, renderer=renderer)
|
|
976
|
+
except InterruptPending as exc:
|
|
977
|
+
self._clear_streaming()
|
|
978
|
+
assistant_entry.status = ""
|
|
979
|
+
# 中断时仍按原始事件顺序保留已产生的文本和工具调用。
|
|
980
|
+
for entry in renderer.final_entries(renderer._last_full_text):
|
|
981
|
+
await self._commit_entry(entry)
|
|
982
|
+
if is_resume:
|
|
983
|
+
await self._commit_entry(TranscriptEntry(role="system", content="该 runtime 暂不支持审批续跑,已取消"))
|
|
984
|
+
self._clear_current_task()
|
|
985
|
+
self._drain_queue()
|
|
986
|
+
else:
|
|
987
|
+
self._handle_interrupt(exc, input_data)
|
|
988
|
+
return
|
|
989
|
+
except asyncio.CancelledError:
|
|
990
|
+
# 取消流式:保留已产生内容,并维持文本/工具的原始到达顺序。
|
|
991
|
+
self._clear_streaming()
|
|
992
|
+
assistant_entry.status = ""
|
|
993
|
+
for entry in renderer.final_entries(renderer._last_full_text):
|
|
994
|
+
await self._commit_entry(entry)
|
|
995
|
+
await self._commit_entry(TranscriptEntry(role="system", content="已取消(保留已产生内容)"))
|
|
996
|
+
self._clear_current_task()
|
|
997
|
+
self._drain_queue()
|
|
998
|
+
return
|
|
999
|
+
finally:
|
|
1000
|
+
self._clear_current_task()
|
|
1001
|
+
|
|
1002
|
+
assistant_entry.status = ""
|
|
1003
|
+
assistant_entry.content = response
|
|
1004
|
+
if usage:
|
|
1005
|
+
assistant_entry.usage = usage
|
|
1006
|
+
self._last_usage = usage
|
|
1007
|
+
self._clear_streaming()
|
|
1008
|
+
if response or assistant_entry.thinking:
|
|
1009
|
+
self.history.append({"role": "model", "content": response})
|
|
1010
|
+
for entry in renderer.final_entries(response):
|
|
1011
|
+
await self._commit_entry(entry)
|
|
1012
|
+
self._drain_queue()
|
|
1013
|
+
|
|
1014
|
+
def _handle_interrupt(self, exc: InterruptPending, input_data: dict[str, Any]) -> None:
|
|
1015
|
+
self._pending_interrupt = (exc, input_data)
|
|
1016
|
+
info = exc.interrupt_info or {}
|
|
1017
|
+
tool_name = str(info.get("name") or info.get("server_label") or "敏感操作")
|
|
1018
|
+
|
|
1019
|
+
async def _prompt():
|
|
1020
|
+
await self._commit_entry(
|
|
1021
|
+
TranscriptEntry(role="system", content=f"确认执行 {tool_name}? 输入 y 确认,其他内容取消")
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
self._create_background_task(_prompt())
|
|
1025
|
+
|
|
1026
|
+
def _handle_interrupt_answer(self, user_input: str) -> None:
|
|
1027
|
+
pending = self._pending_interrupt
|
|
1028
|
+
self._pending_interrupt = None
|
|
1029
|
+
if pending is None:
|
|
1030
|
+
return
|
|
1031
|
+
_exc, input_data = pending
|
|
1032
|
+
if user_input.lower() in {"y", "yes"}:
|
|
1033
|
+
|
|
1034
|
+
async def _resume():
|
|
1035
|
+
await self._commit_entry(TranscriptEntry(role="system", content="已确认,尝试续跑..."))
|
|
1036
|
+
self._start_turn(
|
|
1037
|
+
"",
|
|
1038
|
+
input_data={**input_data, "resume": True},
|
|
1039
|
+
is_resume=True,
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
self._create_background_task(_resume())
|
|
1043
|
+
else:
|
|
1044
|
+
|
|
1045
|
+
async def _cancel():
|
|
1046
|
+
await self._commit_entry(TranscriptEntry(role="system", content="已取消"))
|
|
1047
|
+
self._drain_queue()
|
|
1048
|
+
|
|
1049
|
+
self._create_background_task(_cancel())
|
|
1050
|
+
|
|
1051
|
+
def _drain_queue(self) -> None:
|
|
1052
|
+
if self._pending_interrupt is not None or self._has_active_turn() or not self._queued_inputs:
|
|
1053
|
+
return
|
|
1054
|
+
item = self._queued_inputs.pop(0)
|
|
1055
|
+
self._start_turn(item.text, user_entry=item.entry)
|
|
1056
|
+
|
|
1057
|
+
def _build_input_data(self, user_input: str) -> dict[str, Any]:
|
|
1058
|
+
data: dict[str, Any] = {
|
|
1059
|
+
"input": user_input,
|
|
1060
|
+
"session_id": self.session_id,
|
|
1061
|
+
"history": list(self.history),
|
|
1062
|
+
}
|
|
1063
|
+
if str(getattr(self.runner, "api_format", "") or "").lower() == "responses":
|
|
1064
|
+
data["responses_conversation"] = True
|
|
1065
|
+
return data
|
|
1066
|
+
|
|
1067
|
+
def _has_active_turn(self) -> bool:
|
|
1068
|
+
return self._active_task is not None and not self._active_task.done()
|
|
1069
|
+
|
|
1070
|
+
def _clear_current_task(self) -> None:
|
|
1071
|
+
current = asyncio.current_task()
|
|
1072
|
+
if self._active_task is current:
|
|
1073
|
+
self._active_task = None
|
|
1074
|
+
self._turn_started_at = None
|
|
1075
|
+
|
|
1076
|
+
def _create_background_task(self, coro) -> None:
|
|
1077
|
+
if self._app is not None:
|
|
1078
|
+
self._active_task = self._app.create_background_task(coro)
|
|
1079
|
+
else:
|
|
1080
|
+
self._active_task = asyncio.create_task(coro)
|
|
1081
|
+
|
|
1082
|
+
def _ensure_status_refresh(self) -> None:
|
|
1083
|
+
"""Refresh elapsed-time status only while a turn is actively running."""
|
|
1084
|
+
if self._app is None or not self._app.is_running:
|
|
1085
|
+
return
|
|
1086
|
+
if self._status_refresh_task is None or self._status_refresh_task.done():
|
|
1087
|
+
self._status_refresh_task = self._app.create_background_task(
|
|
1088
|
+
self._status_refresh_loop()
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
async def _status_refresh_loop(self) -> None:
|
|
1092
|
+
current_task = asyncio.current_task()
|
|
1093
|
+
try:
|
|
1094
|
+
while self._has_active_turn():
|
|
1095
|
+
await asyncio.sleep(0.5)
|
|
1096
|
+
if self._app is not None and self._app.is_running:
|
|
1097
|
+
self._app.invalidate()
|
|
1098
|
+
finally:
|
|
1099
|
+
if self._status_refresh_task is current_task:
|
|
1100
|
+
self._status_refresh_task = None
|
|
1101
|
+
|
|
1102
|
+
def _ack(self, content: str) -> None:
|
|
1103
|
+
"""命令的系统提示落定到 transcript 列表。"""
|
|
1104
|
+
self._commit_entry_sync(TranscriptEntry(role="system", content=content))
|
|
1105
|
+
|
|
1106
|
+
def _commit_entry_sync(self, entry: TranscriptEntry) -> None:
|
|
1107
|
+
"""Record a history cell and schedule its one-time scrollback emission."""
|
|
1108
|
+
self._entries.append(entry)
|
|
1109
|
+
if self._app is not None and self._app.is_running:
|
|
1110
|
+
self._app.create_background_task(self._emit_history_entry(entry))
|
|
1111
|
+
self._refresh_transcript()
|
|
1112
|
+
|
|
1113
|
+
async def _commit_entry(self, entry: TranscriptEntry) -> None:
|
|
1114
|
+
"""Record and emit a history cell before resuming the live prompt."""
|
|
1115
|
+
self._entries.append(entry)
|
|
1116
|
+
await self._emit_history_entry(entry)
|
|
1117
|
+
self._refresh_transcript()
|
|
1118
|
+
|
|
1119
|
+
def _handle_command(self, user_input: str) -> str:
|
|
1120
|
+
"""分派命令。返回: 'quit'=退出, 'send'=发给 agent, 'handled'=命令已处理不发送。"""
|
|
1121
|
+
lower = user_input.lower()
|
|
1122
|
+
if lower in {"exit", "quit", "退出"}:
|
|
1123
|
+
return "quit"
|
|
1124
|
+
if user_input == "/new":
|
|
1125
|
+
if self._has_active_turn():
|
|
1126
|
+
self._ack("回复进行中,请先 Ctrl-C 取消再 /new")
|
|
1127
|
+
return "handled"
|
|
1128
|
+
self.session_id = uuid.uuid4().hex[:8]
|
|
1129
|
+
self.runner.session_id = self.session_id
|
|
1130
|
+
self.history = []
|
|
1131
|
+
self._queued_inputs = []
|
|
1132
|
+
self._pending_interrupt = None
|
|
1133
|
+
self._entries = []
|
|
1134
|
+
self._entry_ansi_cache.clear()
|
|
1135
|
+
self._welcome_ansi = None
|
|
1136
|
+
self._history_pager_active = False
|
|
1137
|
+
self._reset_scroll()
|
|
1138
|
+
self._clear_streaming()
|
|
1139
|
+
self._ack(f"新会话: {self.session_id}")
|
|
1140
|
+
elif user_input == "/clear":
|
|
1141
|
+
if self._has_active_turn():
|
|
1142
|
+
self._ack("回复进行中,请先 Ctrl-C 取消再 /clear")
|
|
1143
|
+
return "handled"
|
|
1144
|
+
self._entries = []
|
|
1145
|
+
self._entry_ansi_cache.clear()
|
|
1146
|
+
self._history_pager_active = False
|
|
1147
|
+
self._reset_scroll()
|
|
1148
|
+
self._clear_streaming()
|
|
1149
|
+
self._refresh_transcript()
|
|
1150
|
+
if self._app is not None and self._app.is_running:
|
|
1151
|
+
self._create_background_task(self._clear_terminal_scrollback())
|
|
1152
|
+
elif user_input == "/session":
|
|
1153
|
+
self._ack(f"session: {self.session_id}")
|
|
1154
|
+
elif user_input == "/model" or user_input.startswith("/model "):
|
|
1155
|
+
self._handle_model_command(user_input)
|
|
1156
|
+
elif user_input == "/tools":
|
|
1157
|
+
self._show_tool_details = not self._show_tool_details
|
|
1158
|
+
self._entry_ansi_cache.clear()
|
|
1159
|
+
self._ack(f"工具详情已{'展开' if self._show_tool_details else '折叠'}")
|
|
1160
|
+
elif user_input in {"?", "/help", "/?"}:
|
|
1161
|
+
self._ack(_help_text())
|
|
1162
|
+
elif user_input.startswith("/"):
|
|
1163
|
+
self._ack(f"未知命令: {user_input}(可用: /new /clear /session /model /tools ? exit)")
|
|
1164
|
+
else:
|
|
1165
|
+
return "send" # 普通输入,发给 agent
|
|
1166
|
+
return "handled"
|
|
1167
|
+
|
|
1168
|
+
def _handle_model_command(self, user_input: str) -> None:
|
|
1169
|
+
"""/model:弹出交互式选择器(↑↓ Enter Esc);/model <name>:直接切换(runnable 级,下轮生效)。"""
|
|
1170
|
+
arg = user_input[len("/model"):].strip()
|
|
1171
|
+
if arg:
|
|
1172
|
+
# 直接切换:改 runner.model,下轮请求带新 model
|
|
1173
|
+
self._apply_model_switch(arg)
|
|
1174
|
+
self._ack(f"已切换到 model: {arg}(下一轮请求生效,不持久化)")
|
|
1175
|
+
return
|
|
1176
|
+
# 无参:有可选列表 → 弹交互式选择器;否则文本提示
|
|
1177
|
+
available = getattr(self.runner, "available_models", None) or []
|
|
1178
|
+
if available:
|
|
1179
|
+
self._open_model_picker(available)
|
|
1180
|
+
else:
|
|
1181
|
+
self._ack(
|
|
1182
|
+
f"current model: {self._current_model_name()}\n"
|
|
1183
|
+
"(无可选模型列表;回复一次后显示真实名)\n"
|
|
1184
|
+
"切换: /model <name>"
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
def _apply_model_switch(self, model_id: str) -> None:
|
|
1188
|
+
"""切换模型:改 runner.model + 从 available_models 更新 model_metadata。
|
|
1189
|
+
|
|
1190
|
+
更新 metadata 让 footer 的 context window 用新模型的值;清 _observed_model
|
|
1191
|
+
避免旧回包观察名覆盖。runnable 级,下轮请求带新 model。
|
|
1192
|
+
"""
|
|
1193
|
+
self.runner.model = model_id
|
|
1194
|
+
available = getattr(self.runner, "available_models", None) or []
|
|
1195
|
+
matched = next((m for m in available if str(m.get("id") or m.get("name") or "") == model_id), None)
|
|
1196
|
+
if matched:
|
|
1197
|
+
self.runner.model_metadata = matched
|
|
1198
|
+
# 清旧观察名,让 _current_model_name 用新 runner.model
|
|
1199
|
+
if getattr(self.runner, "_observed_model", None):
|
|
1200
|
+
self.runner._observed_model = None
|
|
1201
|
+
|
|
1202
|
+
def _open_model_picker(self, models: list[dict[str, Any]]) -> None:
|
|
1203
|
+
self._model_picker_models = list(models)
|
|
1204
|
+
current = self._current_model_name()
|
|
1205
|
+
self._model_picker_index = 0
|
|
1206
|
+
for i, m in enumerate(self._model_picker_models):
|
|
1207
|
+
mid = str(m.get("id") or m.get("name") or "")
|
|
1208
|
+
if mid == current:
|
|
1209
|
+
self._model_picker_index = i
|
|
1210
|
+
break
|
|
1211
|
+
self._model_picker_active = True
|
|
1212
|
+
self._scroll_picker_to_selected()
|
|
1213
|
+
if self._app is not None:
|
|
1214
|
+
self._app.invalidate()
|
|
1215
|
+
|
|
1216
|
+
def _close_model_picker(self) -> None:
|
|
1217
|
+
self._model_picker_active = False
|
|
1218
|
+
self._model_picker_models = []
|
|
1219
|
+
if self._picker_window is not None:
|
|
1220
|
+
self._picker_window.vertical_scroll = 0
|
|
1221
|
+
if self._app is not None:
|
|
1222
|
+
self._app.invalidate()
|
|
1223
|
+
|
|
1224
|
+
def _scroll_picker_to_selected(self) -> None:
|
|
1225
|
+
"""让选中项始终在 picker 可见区内(列表超长时跟随滚动)。
|
|
1226
|
+
|
|
1227
|
+
picker 窗口封顶 12 行(见 _build_application 的 height lambda)。render_info
|
|
1228
|
+
首帧可能为 None,按 12 估算可见高度;渲染后 Window 会用设的 vertical_scroll。
|
|
1229
|
+
"""
|
|
1230
|
+
w = self._picker_window
|
|
1231
|
+
if w is None or not self._model_picker_models:
|
|
1232
|
+
return
|
|
1233
|
+
ri = getattr(w, "render_info", None)
|
|
1234
|
+
visible = int(getattr(ri, "window_height", 0) or 0) or 12
|
|
1235
|
+
# 选中项偏上 1/3 处可见,避免紧贴边缘
|
|
1236
|
+
target = max(0, self._model_picker_index - max(1, visible // 3))
|
|
1237
|
+
max_scroll = max(0, len(self._model_picker_models) - visible)
|
|
1238
|
+
w.vertical_scroll = min(target, max_scroll)
|
|
1239
|
+
|
|
1240
|
+
def _picker_cursor(self):
|
|
1241
|
+
"""cursor 跟随选中项行号,驱动 Window 滚动让选中项可见。"""
|
|
1242
|
+
from prompt_toolkit.layout.screen import Point
|
|
1243
|
+
|
|
1244
|
+
return Point(x=0, y=self._model_picker_index)
|
|
1245
|
+
|
|
1246
|
+
def _model_picker_header_fragments(self):
|
|
1247
|
+
return [
|
|
1248
|
+
("class:model-picker-title", "Select Model and Effort\n"),
|
|
1249
|
+
(
|
|
1250
|
+
"class:model-picker-subtitle",
|
|
1251
|
+
"Access legacy models by running codex -m <model_name> or in your config.toml\n\n",
|
|
1252
|
+
),
|
|
1253
|
+
]
|
|
1254
|
+
|
|
1255
|
+
def _model_picker_footer_fragments(self):
|
|
1256
|
+
return [("class:model-picker-footer", "\nPress enter to confirm or esc to go back")]
|
|
1257
|
+
|
|
1258
|
+
def _model_picker_fragments(self):
|
|
1259
|
+
"""Codex-style numbered list with a selection chevron and current marker."""
|
|
1260
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
1261
|
+
|
|
1262
|
+
current = self._current_model_name()
|
|
1263
|
+
frags: list[tuple[str, str]] = []
|
|
1264
|
+
for i, m in enumerate(self._model_picker_models):
|
|
1265
|
+
mid = str(m.get("id") or m.get("name") or "")
|
|
1266
|
+
is_cur_model = (mid == current)
|
|
1267
|
+
is_selected = (i == self._model_picker_index)
|
|
1268
|
+
marker = "›" if is_selected else " "
|
|
1269
|
+
line = f"{marker} {i + 1}. {mid}"
|
|
1270
|
+
if is_cur_model:
|
|
1271
|
+
line += " (current)"
|
|
1272
|
+
style = "class:model-picker-selected" if is_selected else "class:model-picker-item"
|
|
1273
|
+
frags.append((style, f"{line}\n"))
|
|
1274
|
+
return FormattedText(frags) if frags else FormattedText([("", "(no models)")])
|
|
1275
|
+
|
|
1276
|
+
# ---- streaming / transcript 渲染 ----
|
|
1277
|
+
|
|
1278
|
+
def _set_streaming(self, full_text: str) -> None:
|
|
1279
|
+
"""streaming 文本更新到当前 streaming entry(动态区 = transcript 末尾的 streaming entry)。"""
|
|
1280
|
+
if self._streaming_entry is not None:
|
|
1281
|
+
self._streaming_entry.content = full_text
|
|
1282
|
+
self._streaming_entry.status = "streaming"
|
|
1283
|
+
self._refresh_transcript()
|
|
1284
|
+
|
|
1285
|
+
def _clear_streaming(self) -> None:
|
|
1286
|
+
self._streaming_entry = None
|
|
1287
|
+
self._refresh_transcript()
|
|
1288
|
+
|
|
1289
|
+
def _refresh_transcript(self) -> None:
|
|
1290
|
+
"""Render only the mutable live tail; completed history lives in scrollback."""
|
|
1291
|
+
parts: list[str] = []
|
|
1292
|
+
if self._history_pager_active:
|
|
1293
|
+
parts = [
|
|
1294
|
+
self._history_entry_ansi(entry).rstrip("\n")
|
|
1295
|
+
for entry in self._entries
|
|
1296
|
+
if self._history_entry_ansi(entry).strip()
|
|
1297
|
+
]
|
|
1298
|
+
elif self._streaming_entry is not None and (
|
|
1299
|
+
self._streaming_entry.content or self._streaming_entry.status
|
|
1300
|
+
):
|
|
1301
|
+
ansi = _render_entry_ansi(self._streaming_entry, show_thinking=self.show_thinking)
|
|
1302
|
+
if ansi.strip():
|
|
1303
|
+
parts.append(ansi.rstrip("\n"))
|
|
1304
|
+
self._transcript_ansi = ("\n\n".join(parts) + "\n") if parts else ""
|
|
1305
|
+
# follow 快照(对标 Codex is_scrolled_to_bottom):若当前在底才跟新内容到底,
|
|
1306
|
+
# 用户翻走(vertical_scroll < max_scroll)则保持位置不覆盖。这样鼠标滚轮/键盘
|
|
1307
|
+
# 翻后不被 streaming 刷新拽回底部。
|
|
1308
|
+
if self._transcript_window is not None:
|
|
1309
|
+
# prompt_toolkit 的内建鼠标滚轮直接修改 Window.vertical_scroll,不会经过
|
|
1310
|
+
# _scroll_transcript。先用上一帧记录识别这种外部滚动,再决定是否跟底。
|
|
1311
|
+
current_vs = int(getattr(self._transcript_window, "vertical_scroll", 0) or 0)
|
|
1312
|
+
if current_vs != self._user_scroll:
|
|
1313
|
+
self._user_scroll = current_vs
|
|
1314
|
+
self._pin_to_bottom = current_vs >= max(0, self._last_max_scroll - 1)
|
|
1315
|
+
max_scroll = self._max_scroll()
|
|
1316
|
+
if self._pin_to_bottom:
|
|
1317
|
+
self._transcript_window.vertical_scroll = max_scroll
|
|
1318
|
+
self._user_scroll = max_scroll
|
|
1319
|
+
else:
|
|
1320
|
+
preserved = min(current_vs, max_scroll)
|
|
1321
|
+
self._transcript_window.vertical_scroll = preserved
|
|
1322
|
+
self._user_scroll = preserved
|
|
1323
|
+
self._last_max_scroll = max_scroll
|
|
1324
|
+
if self._app is not None:
|
|
1325
|
+
self._app.invalidate()
|
|
1326
|
+
|
|
1327
|
+
def _transcript_fragments(self):
|
|
1328
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
1329
|
+
|
|
1330
|
+
return ANSI(self._transcript_ansi)
|
|
1331
|
+
|
|
1332
|
+
def _transcript_cursor(self):
|
|
1333
|
+
"""cursor 跟随当前 vertical_scroll(视口顶行)。
|
|
1334
|
+
|
|
1335
|
+
这样 prompt_toolkit 的 do_scroll 不钳制(cursor 总在视口顶可见),
|
|
1336
|
+
手动设的 vertical_scroll 保留。pin bottom → 在 _refresh_transcript/
|
|
1337
|
+
_scroll_transcript 里设 vertical_scroll=max_scroll 跟底;用户翻 → 设 user_scroll。
|
|
1338
|
+
(经验证:cursor=末行 时 do_scroll 跟底 OK,但 cursor=中部行时视口不动 →
|
|
1339
|
+
用户翻页不生效。改用 cursor 跟随视口顶,手动设 vertical_scroll 控制位置。)
|
|
1340
|
+
"""
|
|
1341
|
+
from prompt_toolkit.layout.screen import Point
|
|
1342
|
+
|
|
1343
|
+
vs = int(getattr(self._transcript_window, "vertical_scroll", 0) or 0)
|
|
1344
|
+
# render_info belongs to the previous frame. During streaming finalize,
|
|
1345
|
+
# clear, or resize it can have more lines than the current fragments and
|
|
1346
|
+
# would make prompt_toolkit index past UIContent.get_line().
|
|
1347
|
+
line_count = self._transcript_line_count()
|
|
1348
|
+
return Point(x=0, y=min(vs, line_count - 1))
|
|
1349
|
+
|
|
1350
|
+
def _transcript_line_count(self) -> int:
|
|
1351
|
+
"""Return the logical line count of the fragments for the next frame."""
|
|
1352
|
+
return max(1, self._transcript_ansi.count("\n") + 1)
|
|
1353
|
+
|
|
1354
|
+
def _max_scroll(self) -> int:
|
|
1355
|
+
"""算 max_scroll:显示行数 - window_height。
|
|
1356
|
+
|
|
1357
|
+
Line count must come from the current transcript. ``render_info`` is a
|
|
1358
|
+
snapshot of the previous frame and can be stale while streaming content
|
|
1359
|
+
is replaced or cleared. Window height still comes from the last render,
|
|
1360
|
+
with a terminal-size fallback before the first frame.
|
|
1361
|
+
"""
|
|
1362
|
+
if self._transcript_window is None:
|
|
1363
|
+
return 0
|
|
1364
|
+
ri = getattr(self._transcript_window, "render_info", None)
|
|
1365
|
+
height = int(getattr(ri, "window_height", 0) or 0)
|
|
1366
|
+
if height <= 0:
|
|
1367
|
+
import shutil
|
|
1368
|
+
height = max(10, (shutil.get_terminal_size(fallback=(80, 24)).lines or 24) - 6)
|
|
1369
|
+
line_count = self._transcript_line_count()
|
|
1370
|
+
return max(0, line_count - height)
|
|
1371
|
+
|
|
1372
|
+
def _reset_scroll(self) -> None:
|
|
1373
|
+
"""/new /clear 时重置滚动状态到 pin bottom 顶部。"""
|
|
1374
|
+
self._user_scroll = 0
|
|
1375
|
+
self._pin_to_bottom = True
|
|
1376
|
+
self._last_max_scroll = 0
|
|
1377
|
+
if self._transcript_window is not None:
|
|
1378
|
+
self._transcript_window.vertical_scroll = 0
|
|
1379
|
+
|
|
1380
|
+
def _scroll_transcript(self, delta: int) -> None:
|
|
1381
|
+
"""Scroll the live tail or open the retained-history pager on demand."""
|
|
1382
|
+
if self._transcript_window is None:
|
|
1383
|
+
return
|
|
1384
|
+
if not self._history_pager_active and self._entries:
|
|
1385
|
+
if delta >= 0:
|
|
1386
|
+
return
|
|
1387
|
+
self._history_pager_active = True
|
|
1388
|
+
self._pin_to_bottom = True
|
|
1389
|
+
self._refresh_transcript()
|
|
1390
|
+
max_scroll = self._max_scroll()
|
|
1391
|
+
current = max_scroll if self._pin_to_bottom else self._user_scroll
|
|
1392
|
+
if self._history_pager_active and delta > 0 and current + delta >= max_scroll:
|
|
1393
|
+
self._history_pager_active = False
|
|
1394
|
+
self._reset_scroll()
|
|
1395
|
+
self._refresh_transcript()
|
|
1396
|
+
return
|
|
1397
|
+
new_scroll = max(0, min(current + delta, max_scroll))
|
|
1398
|
+
self._user_scroll = new_scroll
|
|
1399
|
+
self._pin_to_bottom = new_scroll >= max_scroll
|
|
1400
|
+
self._transcript_window.vertical_scroll = new_scroll
|
|
1401
|
+
if self._app is not None:
|
|
1402
|
+
self._app.invalidate()
|
|
1403
|
+
|
|
1404
|
+
# ---- footer / 输入框高度 ----
|
|
1405
|
+
|
|
1406
|
+
def _current_model_name(self) -> str:
|
|
1407
|
+
"""动态取模型名:metadata 到达后(_fetch_tui_model_metadata 挂载)优先用真实名。"""
|
|
1408
|
+
return _resolve_model_name(self.runner)
|
|
1409
|
+
|
|
1410
|
+
def _status_fragments(self):
|
|
1411
|
+
if self._pending_interrupt is not None:
|
|
1412
|
+
return [
|
|
1413
|
+
("class:status-bullet", "• "),
|
|
1414
|
+
("class:footer-warn", "Approval required (type y then Enter to confirm)"),
|
|
1415
|
+
]
|
|
1416
|
+
elapsed = 0
|
|
1417
|
+
if self._turn_started_at is not None:
|
|
1418
|
+
elapsed = max(0, int(time.monotonic() - self._turn_started_at))
|
|
1419
|
+
queued = f" · {len(self._queued_inputs)} queued" if self._queued_inputs else ""
|
|
1420
|
+
return [
|
|
1421
|
+
("class:status-bullet", "• "),
|
|
1422
|
+
("class:status", f"Working ({elapsed}s · Ctrl-C to interrupt{queued})"),
|
|
1423
|
+
]
|
|
1424
|
+
|
|
1425
|
+
def _footer_fragments(self):
|
|
1426
|
+
parts = [self._current_model_name()]
|
|
1427
|
+
try:
|
|
1428
|
+
short = "~/" + str(self.project_dir.relative_to(Path.home()))
|
|
1429
|
+
except ValueError:
|
|
1430
|
+
short = str(self.project_dir)
|
|
1431
|
+
ctx = self._context_percent()
|
|
1432
|
+
if ctx:
|
|
1433
|
+
parts.append(ctx.split(" · ", 1)[0])
|
|
1434
|
+
parts.append(short)
|
|
1435
|
+
return [("class:footer", " " + " · ".join(parts) + " ")]
|
|
1436
|
+
|
|
1437
|
+
def _context_percent(self) -> str | None:
|
|
1438
|
+
"""上下文窗口剩余占比,对标 Codex CLI 的 "Context 87% left · 12.3K used · 200K window"。
|
|
1439
|
+
|
|
1440
|
+
算法与 codex-rs/tui/src/token_usage.rs 一致:
|
|
1441
|
+
- tokens_in_context = last_usage.total_tokens(当前上下文总大小)
|
|
1442
|
+
- effective_window = context_window - BASELINE_TOKENS(12000)
|
|
1443
|
+
- used = max(0, total_tokens - BASELINE_TOKENS)
|
|
1444
|
+
- remaining% = (effective_window - used) / effective_window * 100
|
|
1445
|
+
BASELINE 折扣避免把 system prompt 等固定基线算进"已用"。
|
|
1446
|
+
返回 "Context {pct}% left · {used}K used · {window}K window"(· 分隔,大写 K)。
|
|
1447
|
+
"""
|
|
1448
|
+
meta = getattr(self.runner, "model_metadata", None) or {}
|
|
1449
|
+
if not isinstance(meta, dict):
|
|
1450
|
+
return None
|
|
1451
|
+
window = meta.get("context_window_tokens") or meta.get("max_input_tokens")
|
|
1452
|
+
last = self._last_usage or {}
|
|
1453
|
+
if not isinstance(last, dict):
|
|
1454
|
+
return None
|
|
1455
|
+
ctx_usage = last.get("last_usage") or last
|
|
1456
|
+
if not isinstance(ctx_usage, dict):
|
|
1457
|
+
return None
|
|
1458
|
+
tokens_in_context = ctx_usage.get("total_tokens") or ctx_usage.get("input_tokens")
|
|
1459
|
+
if not window or not tokens_in_context:
|
|
1460
|
+
return None
|
|
1461
|
+
try:
|
|
1462
|
+
window = int(window)
|
|
1463
|
+
tokens_in_context = int(tokens_in_context)
|
|
1464
|
+
except (TypeError, ValueError):
|
|
1465
|
+
return None
|
|
1466
|
+
if window <= 0:
|
|
1467
|
+
return None
|
|
1468
|
+
# 直接 used/window(不去 Codex BASELINE 12000:runtime 的 total_tokens 已含
|
|
1469
|
+
# 全部上下文,不需要再假设 12K 基线;BASELINE 会让小用量卡 100%)。
|
|
1470
|
+
# int() 不 round,避免 99.99% 进 100%。
|
|
1471
|
+
used = max(0, tokens_in_context)
|
|
1472
|
+
remaining = max(0, window - used)
|
|
1473
|
+
pct = int(remaining / window * 100)
|
|
1474
|
+
pct = max(0, min(100, pct))
|
|
1475
|
+
return (
|
|
1476
|
+
f"Context {pct}% left · {_format_token_count(tokens_in_context)} used"
|
|
1477
|
+
f" · {_format_token_count(window)} window"
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
def _input_display_height(self) -> int:
|
|
1481
|
+
if self._input_buffer is None:
|
|
1482
|
+
return 1
|
|
1483
|
+
try:
|
|
1484
|
+
import math
|
|
1485
|
+
import shutil
|
|
1486
|
+
|
|
1487
|
+
from prompt_toolkit.utils import get_cwidth
|
|
1488
|
+
|
|
1489
|
+
columns = max(20, shutil.get_terminal_size(fallback=(80, 24)).columns - 3)
|
|
1490
|
+
line_count = 0
|
|
1491
|
+
for line in (self._input_buffer.text or "").split("\n"):
|
|
1492
|
+
line_count += max(1, math.ceil(get_cwidth(line) / columns))
|
|
1493
|
+
return max(1, min(3, line_count))
|
|
1494
|
+
except Exception:
|
|
1495
|
+
return max(1, min(3, (self._input_buffer.text or "").count("\n") + 1))
|
|
1496
|
+
|
|
1497
|
+
def _exit_app(self) -> None:
|
|
1498
|
+
if self._app is not None:
|
|
1499
|
+
self._app.exit()
|
|
1500
|
+
|
|
1501
|
+
async def _clear_terminal_scrollback(self) -> None:
|
|
1502
|
+
if self._app is None or not self._app.is_running:
|
|
1503
|
+
return
|
|
1504
|
+
from prompt_toolkit.application import run_in_terminal
|
|
1505
|
+
|
|
1506
|
+
def _write_clear() -> None:
|
|
1507
|
+
import sys
|
|
1508
|
+
|
|
1509
|
+
sys.stdout.write(_terminal_clear_sequence())
|
|
1510
|
+
sys.stdout.flush()
|
|
1511
|
+
|
|
1512
|
+
await run_in_terminal(_write_clear)
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _resolve_model_name(runner) -> str:
|
|
1516
|
+
import os
|
|
1517
|
+
|
|
1518
|
+
# 优先 model_metadata["id"](catalog fetch),其次 _observed_model(流式回包观察到的真实名),
|
|
1519
|
+
# 其次 runner.model(用户 --model 指定),最后 MODEL_NAME 环境变量。
|
|
1520
|
+
meta = getattr(runner, "model_metadata", None)
|
|
1521
|
+
if isinstance(meta, dict) and meta.get("id"):
|
|
1522
|
+
return str(meta["id"])
|
|
1523
|
+
observed = getattr(runner, "_observed_model", None)
|
|
1524
|
+
if observed:
|
|
1525
|
+
return observed
|
|
1526
|
+
return getattr(runner, "model", None) or os.getenv("MODEL_NAME") or "unknown"
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def _help_text() -> str:
|
|
1530
|
+
return "命令: /new 新会话 · /clear 清屏 · /session 查看 · /tools 展开工具 · exit 退出 · Ctrl-C 中断/退出"
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _welcome_block(session_id: str, model_name: str, project_dir: Path, *, show_help: bool) -> str:
|
|
1534
|
+
"""Render the compact Codex-style session card and one-line tip."""
|
|
1535
|
+
try:
|
|
1536
|
+
from ksadk.version import VERSION
|
|
1537
|
+
version = VERSION
|
|
1538
|
+
except Exception:
|
|
1539
|
+
version = ""
|
|
1540
|
+
try:
|
|
1541
|
+
short = "~/" + str(project_dir.relative_to(Path.home()))
|
|
1542
|
+
except ValueError:
|
|
1543
|
+
short = str(project_dir)
|
|
1544
|
+
title = f">_ KsADK (v{version})" if version else ">_ KsADK"
|
|
1545
|
+
inner_lines = [
|
|
1546
|
+
title,
|
|
1547
|
+
"",
|
|
1548
|
+
f"model: {model_name} /model to change",
|
|
1549
|
+
f"directory: {short}",
|
|
1550
|
+
f"session: {session_id}",
|
|
1551
|
+
]
|
|
1552
|
+
# 按终端宽度算框宽,留 transcript 左缩进 2 + 右 margin 2,上限 56(Codex 同款)。
|
|
1553
|
+
try:
|
|
1554
|
+
import shutil
|
|
1555
|
+
cols = shutil.get_terminal_size(fallback=(80, 24)).columns
|
|
1556
|
+
except Exception:
|
|
1557
|
+
cols = 80
|
|
1558
|
+
max_inner = max(20, cols - 2 - 2 - 2) # 减去左缩进2 + 两侧│ + 右margin2
|
|
1559
|
+
longest = max(len(s) for s in inner_lines)
|
|
1560
|
+
inner = min(max_inner, max(20, longest)) # 内容能放下且不超终端
|
|
1561
|
+
inner_lines = [s[:inner] for s in inner_lines] # 防溢出截断
|
|
1562
|
+
|
|
1563
|
+
def _pad(s: str) -> str:
|
|
1564
|
+
# CJK 宽度近似:用 len 简化(圆角框对齐以纯文本宽度为准,rich 不再二次渲染框线)
|
|
1565
|
+
return s + " " * max(0, inner - len(s))
|
|
1566
|
+
|
|
1567
|
+
# Middle rows include one padding cell on both sides of the content, so
|
|
1568
|
+
# the horizontal rules must span ``inner + 2`` cells as well.
|
|
1569
|
+
outer_inner = inner + 2
|
|
1570
|
+
top = f"╭{'─' * outer_inner}╮"
|
|
1571
|
+
bottom = f"╰{'─' * outer_inner}╯"
|
|
1572
|
+
mid = [f"│ {_pad(line)} │" for line in inner_lines]
|
|
1573
|
+
block = [top, *mid, bottom]
|
|
1574
|
+
|
|
1575
|
+
lines = ["", *block, ""]
|
|
1576
|
+
if show_help:
|
|
1577
|
+
lines.append("Tip: describe a task, or type /help to see available commands.")
|
|
1578
|
+
lines.append("")
|
|
1579
|
+
return "\n".join(lines)
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _normalize_markdown(source: str) -> str:
|
|
1583
|
+
"""Normalize common model markdown typos without touching fenced code."""
|
|
1584
|
+
import re
|
|
1585
|
+
|
|
1586
|
+
source_lines = str(source).replace("\r\n", "\n").split("\n")
|
|
1587
|
+
if (
|
|
1588
|
+
len(source_lines) >= 2
|
|
1589
|
+
and source_lines[0].strip().lower() in {"```md", "```markdown"}
|
|
1590
|
+
and source_lines[-1].strip() == "```"
|
|
1591
|
+
):
|
|
1592
|
+
source_lines = source_lines[1:-1]
|
|
1593
|
+
|
|
1594
|
+
lines: list[str] = []
|
|
1595
|
+
in_fence = False
|
|
1596
|
+
for raw_line in source_lines:
|
|
1597
|
+
line = raw_line
|
|
1598
|
+
stripped = line.lstrip()
|
|
1599
|
+
if stripped.startswith("```"):
|
|
1600
|
+
in_fence = not in_fence
|
|
1601
|
+
lines.append(line)
|
|
1602
|
+
continue
|
|
1603
|
+
if not in_fence:
|
|
1604
|
+
# Models frequently emit ``###1.`` for numbered cards. Treat it as
|
|
1605
|
+
# a list item, otherwise Rich displays the hashes literally.
|
|
1606
|
+
line = re.sub(r"^(\s*)#{1,6}\s*(\d+)[.)]\s*", r"\1\2. ", line)
|
|
1607
|
+
# Common heading/list omissions: ``##标题`` and ``1) item``.
|
|
1608
|
+
line = re.sub(r"^(\s*)(#{1,6})(\S)", r"\1\2 \3", line)
|
|
1609
|
+
line = re.sub(r"^(\s*)(\d+)[)]\s*", r"\1\2. ", line)
|
|
1610
|
+
# Some model responses concatenate numbered emoji cards on one line
|
|
1611
|
+
# (``...技能2. 📄 ...3. 🌐 ...``). Split only when the marker is
|
|
1612
|
+
# followed by an icon/CJK lead, avoiding decimal numbers in prose.
|
|
1613
|
+
lead = r"[\u2600-\u27bf\U0001f000-\U0001faff\u4e00-\u9fff]"
|
|
1614
|
+
line = re.sub(
|
|
1615
|
+
rf"(?<=[^\d\s])\s*(?=\d{{1,2}}[.)]?\s+{lead})",
|
|
1616
|
+
"\n",
|
|
1617
|
+
line,
|
|
1618
|
+
)
|
|
1619
|
+
line = re.sub(
|
|
1620
|
+
rf"^(\s*)(\d{{1,2}})\s+(?={lead})",
|
|
1621
|
+
r"\1\2. ",
|
|
1622
|
+
line,
|
|
1623
|
+
flags=re.MULTILINE,
|
|
1624
|
+
)
|
|
1625
|
+
lines.append(line)
|
|
1626
|
+
return "\n".join(lines)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _render_entry_ansi(
|
|
1630
|
+
entry: TranscriptEntry,
|
|
1631
|
+
*,
|
|
1632
|
+
show_thinking: bool,
|
|
1633
|
+
show_tool_details: bool = True,
|
|
1634
|
+
) -> str:
|
|
1635
|
+
"""用 rich 把单条 entry 渲染成带前景色的 ANSI 字符串(去背景,适配浅/深色终端)。"""
|
|
1636
|
+
try:
|
|
1637
|
+
from rich.console import Console
|
|
1638
|
+
from rich.markdown import Markdown
|
|
1639
|
+
from rich.text import Text
|
|
1640
|
+
except ImportError:
|
|
1641
|
+
return _render_entry_plain(entry, show_thinking=show_thinking)
|
|
1642
|
+
|
|
1643
|
+
import io
|
|
1644
|
+
import shutil
|
|
1645
|
+
|
|
1646
|
+
width = 100
|
|
1647
|
+
try:
|
|
1648
|
+
width = max(60, shutil.get_terminal_size(fallback=(100, 24)).columns - 2)
|
|
1649
|
+
except Exception:
|
|
1650
|
+
pass
|
|
1651
|
+
|
|
1652
|
+
console = Console(
|
|
1653
|
+
color_system="standard",
|
|
1654
|
+
force_terminal=True,
|
|
1655
|
+
file=io.StringIO(),
|
|
1656
|
+
record=True,
|
|
1657
|
+
width=width,
|
|
1658
|
+
legacy_windows=False,
|
|
1659
|
+
)
|
|
1660
|
+
body = entry.content or ("..." if entry.role == "assistant" and entry.status else "")
|
|
1661
|
+
|
|
1662
|
+
if entry.role == "system":
|
|
1663
|
+
if body:
|
|
1664
|
+
console.print(Text(str(body), style="dim"))
|
|
1665
|
+
return console.export_text(styles=True).rstrip() + "\n"
|
|
1666
|
+
|
|
1667
|
+
if entry.role == "separator":
|
|
1668
|
+
# tool 与 assistant 间横线分隔(对标 Codex FinalMessageSeparator)
|
|
1669
|
+
console.print(Text(str(body or "─" * 40), style="dim"))
|
|
1670
|
+
return console.export_text(styles=True).rstrip() + "\n"
|
|
1671
|
+
|
|
1672
|
+
if entry.role == "user":
|
|
1673
|
+
suffix = f" · {entry.status}" if entry.status else ""
|
|
1674
|
+
surface = _codex_surface_rgb(_terminal_background())
|
|
1675
|
+
surface_style = "bold"
|
|
1676
|
+
if surface is not None:
|
|
1677
|
+
surface_style += f" on #{surface[0]:02x}{surface[1]:02x}{surface[2]:02x}"
|
|
1678
|
+
lines = str(body).splitlines() or [""]
|
|
1679
|
+
for index, line in enumerate(lines):
|
|
1680
|
+
prefix = "› " if index == 0 else " "
|
|
1681
|
+
text = Text(f"{prefix}{line}{suffix if index == 0 else ''}", style=surface_style)
|
|
1682
|
+
text.pad_right(max(0, width - text.cell_len))
|
|
1683
|
+
console.print(text, no_wrap=True, overflow="crop")
|
|
1684
|
+
return console.export_text(styles=True).rstrip() + "\n"
|
|
1685
|
+
|
|
1686
|
+
if entry.role == "assistant":
|
|
1687
|
+
# streaming 状态由 footer 表达,不在内容里加 "· streaming" 标记
|
|
1688
|
+
suffix = f" · {entry.status}" if entry.status and entry.status != "streaming" else ""
|
|
1689
|
+
if entry.thinking and show_thinking:
|
|
1690
|
+
console.print(Text("* thinking", style="yellow"))
|
|
1691
|
+
console.print(Markdown(_normalize_markdown(entry.thinking)))
|
|
1692
|
+
console.print(Text("• ", style="dim"), end="")
|
|
1693
|
+
if body:
|
|
1694
|
+
if entry.status == "streaming":
|
|
1695
|
+
# streaming 期用纯文本(不 rich Markdown),避免每 token 重渲增长的长内容卡顿。
|
|
1696
|
+
# 落定后(status="")才 Markdown 渲染完整格式(表格/代码块等)。
|
|
1697
|
+
console.print(Text(str(body)))
|
|
1698
|
+
else:
|
|
1699
|
+
console.print(Markdown(_normalize_markdown(f"{body}{suffix}")))
|
|
1700
|
+
else:
|
|
1701
|
+
console.print(Text(suffix.lstrip(), style="dim") if suffix else Text("...", style="dim"))
|
|
1702
|
+
return _strip_ansi_backgrounds(console.export_text(styles=True)).rstrip() + "\n"
|
|
1703
|
+
|
|
1704
|
+
if entry.role == "tool":
|
|
1705
|
+
# 对标 Codex exec_cell:单 bullet 表状态(running dim/完成绿),文字
|
|
1706
|
+
# Running→Ran + 工具名。结果折叠 5 行 head+… +N lines+tail。
|
|
1707
|
+
parts = (entry.content or "").split("\n", 1)
|
|
1708
|
+
tool_name = parts[0].split(" [")[0] if parts else ""
|
|
1709
|
+
output = parts[1] if len(parts) > 1 else ""
|
|
1710
|
+
status = entry.status or ""
|
|
1711
|
+
done = status in {"result", "completed"}
|
|
1712
|
+
bullet = Text("• ", style="green bold" if done else "dim")
|
|
1713
|
+
title = "Ran" if done else "Running"
|
|
1714
|
+
console.print(bullet, end="")
|
|
1715
|
+
console.print(Text(f"{title} {tool_name}", style="bold" if done else ""))
|
|
1716
|
+
if output and show_tool_details:
|
|
1717
|
+
# 尝试 JSON 美化(单行长 JSON → 多行可读),再按显示行数折叠
|
|
1718
|
+
display_output = output
|
|
1719
|
+
try:
|
|
1720
|
+
import json as _json
|
|
1721
|
+
parsed = _json.loads(output)
|
|
1722
|
+
# Tool adapters often JSON-encode an already serialized result.
|
|
1723
|
+
# Unwrap that second layer so structured output folds by fields
|
|
1724
|
+
# instead of rendering as one escaped line.
|
|
1725
|
+
if isinstance(parsed, str):
|
|
1726
|
+
nested = parsed.strip()
|
|
1727
|
+
# Some adapters wrap the JSON in a repr-like
|
|
1728
|
+
# ``content='...' name='...'`` envelope.
|
|
1729
|
+
if nested.startswith("content="):
|
|
1730
|
+
import ast as _ast
|
|
1731
|
+
|
|
1732
|
+
quote = nested[len("content="):len("content=") + 1]
|
|
1733
|
+
if quote in {"'", '"'}:
|
|
1734
|
+
body_start = len("content=") + 1
|
|
1735
|
+
body_end = body_start
|
|
1736
|
+
while body_end < len(nested):
|
|
1737
|
+
if nested[body_end] == quote:
|
|
1738
|
+
backslashes = 0
|
|
1739
|
+
cursor = body_end - 1
|
|
1740
|
+
while cursor >= body_start and nested[cursor] == "\\":
|
|
1741
|
+
backslashes += 1
|
|
1742
|
+
cursor -= 1
|
|
1743
|
+
if backslashes % 2 == 0:
|
|
1744
|
+
break
|
|
1745
|
+
body_end += 1
|
|
1746
|
+
try:
|
|
1747
|
+
nested = _ast.literal_eval(
|
|
1748
|
+
quote + nested[body_start:body_end] + quote
|
|
1749
|
+
).strip()
|
|
1750
|
+
except (SyntaxError, ValueError):
|
|
1751
|
+
pass
|
|
1752
|
+
if nested.startswith(("{", "[")):
|
|
1753
|
+
parsed = _json.loads(nested)
|
|
1754
|
+
display_output = _json.dumps(parsed, ensure_ascii=False, indent=2)
|
|
1755
|
+
except Exception:
|
|
1756
|
+
pass
|
|
1757
|
+
# 估算显示行数:每行按 console width 折算(长字符串占多行)
|
|
1758
|
+
import shutil as _su
|
|
1759
|
+
cw = max(40, (_su.get_terminal_size(fallback=(80, 24)).columns or 80) - 6)
|
|
1760
|
+
logical_lines = display_output.split("\n")
|
|
1761
|
+
# 估算显示行数:按显示宽度(CJK 占 2)折算,非 len
|
|
1762
|
+
from prompt_toolkit.utils import get_cwidth
|
|
1763
|
+
|
|
1764
|
+
def _width(s: str) -> int:
|
|
1765
|
+
return get_cwidth(s)
|
|
1766
|
+
|
|
1767
|
+
logical_lines = display_output.split("\n")
|
|
1768
|
+
display_line_count = sum(max(1, -(-_width(ln) // cw)) for ln in logical_lines)
|
|
1769
|
+
# 逻辑行 > 6 才 head/tail 折叠(避免逻辑行少时 head/tail 重叠重复);
|
|
1770
|
+
# 逻辑行 ≤ 6 但显示行长(长字符串)→ 不折叠,靠每行截断控制宽度。
|
|
1771
|
+
if display_line_count > 8 and len(logical_lines) > 6:
|
|
1772
|
+
head, tail = logical_lines[:3], logical_lines[-3:]
|
|
1773
|
+
shown = head + [f"… +{display_line_count-6} lines (PgUp to scroll)"] + tail
|
|
1774
|
+
else:
|
|
1775
|
+
shown = logical_lines
|
|
1776
|
+
for ln in shown:
|
|
1777
|
+
# 长逻辑行按显示宽度截断(CJK 占 2,避免溢出)
|
|
1778
|
+
if _width(ln) > cw:
|
|
1779
|
+
# 逐步截断到 cw 宽度
|
|
1780
|
+
cut = cw - 1
|
|
1781
|
+
while cut > 0 and _width(ln[:cut]) > cw - 1:
|
|
1782
|
+
cut -= 1
|
|
1783
|
+
ln = ln[:cut] + "…"
|
|
1784
|
+
console.print(Text(f" └ {ln}", style="dim"))
|
|
1785
|
+
return console.export_text(styles=True).rstrip() + "\n"
|
|
1786
|
+
|
|
1787
|
+
if entry.role == "error":
|
|
1788
|
+
console.print(Text(f"! {body}", style="red bold"))
|
|
1789
|
+
return console.export_text(styles=True).rstrip() + "\n"
|
|
1790
|
+
|
|
1791
|
+
if body:
|
|
1792
|
+
console.print(Text(str(body)))
|
|
1793
|
+
return console.export_text(styles=True).rstrip() + "\n"
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def _render_entry_plain(entry: TranscriptEntry, *, show_thinking: bool) -> str:
|
|
1797
|
+
"""rich 不可用时的纯文本回退。"""
|
|
1798
|
+
body = entry.content or ("..." if entry.role == "assistant" and entry.status else "")
|
|
1799
|
+
if entry.thinking and show_thinking:
|
|
1800
|
+
body = f"Thinking:\n{entry.thinking}\n\n{body}".strip()
|
|
1801
|
+
suffix = f" · {entry.status}" if entry.status and entry.role in {"user", "assistant"} else ""
|
|
1802
|
+
if entry.role == "user":
|
|
1803
|
+
return _prefix_block(f"› {body}{suffix}", continuation=" ") + "\n"
|
|
1804
|
+
if entry.role == "assistant":
|
|
1805
|
+
return _prefix_block(f"• {body}{suffix}", continuation=" ") + "\n"
|
|
1806
|
+
if entry.role == "tool":
|
|
1807
|
+
return _prefix_block(f"↳ {body}", continuation=" ") + "\n"
|
|
1808
|
+
if entry.role == "error":
|
|
1809
|
+
return f"! {body}\n"
|
|
1810
|
+
return f"{body}\n"
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def _prefix_block(text: str, *, continuation: str) -> str:
|
|
1814
|
+
lines = str(text).splitlines() or [""]
|
|
1815
|
+
rendered = [lines[0]]
|
|
1816
|
+
rendered.extend(f"{continuation}{line}" if line else "" for line in lines[1:])
|
|
1817
|
+
return "\n".join(rendered)
|
|
1818
|
+
|
|
1819
|
+
|
|
1820
|
+
def _compact_json(value: Any) -> str:
|
|
1821
|
+
try:
|
|
1822
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
1823
|
+
except TypeError:
|
|
1824
|
+
return str(value)
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def _format_token_count(value: Any) -> str:
|
|
1828
|
+
try:
|
|
1829
|
+
number = int(value or 0)
|
|
1830
|
+
except (TypeError, ValueError):
|
|
1831
|
+
number = 0
|
|
1832
|
+
if number >= 1_000_000:
|
|
1833
|
+
return f"{number / 1_000_000:.1f}M"
|
|
1834
|
+
if number >= 1_000:
|
|
1835
|
+
return f"{number / 1_000:.1f}K"
|
|
1836
|
+
return str(number)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def _strip_ansi_backgrounds(text: str) -> str:
|
|
1840
|
+
"""只剥 ANSI 背景色(40/48),保留前景色——避免浅色终端背景色糊住文字。"""
|
|
1841
|
+
import re
|
|
1842
|
+
|
|
1843
|
+
def _clean(match: "re.Match[str]") -> str:
|
|
1844
|
+
params = match.group(1).split(";")
|
|
1845
|
+
cleaned: list[str] = []
|
|
1846
|
+
index = 0
|
|
1847
|
+
while index < len(params):
|
|
1848
|
+
code = params[index]
|
|
1849
|
+
if code == "40" or code == "49":
|
|
1850
|
+
index += 1
|
|
1851
|
+
continue
|
|
1852
|
+
if code == "48":
|
|
1853
|
+
mode = params[index + 1] if index + 1 < len(params) else ""
|
|
1854
|
+
if mode == "2":
|
|
1855
|
+
index += 5
|
|
1856
|
+
continue
|
|
1857
|
+
if mode == "5":
|
|
1858
|
+
index += 3
|
|
1859
|
+
continue
|
|
1860
|
+
cleaned.append(code)
|
|
1861
|
+
index += 1
|
|
1862
|
+
return f"\x1b[{';'.join(cleaned)}m" if cleaned else ""
|
|
1863
|
+
|
|
1864
|
+
return re.sub(r"\x1b\[([0-9;]*)m", _clean, text)
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
def run_tui(runner, *, show_thinking: bool = False, project_dir: str = ".", no_alt_screen: bool = False) -> None:
|
|
1868
|
+
"""TUI 入口:被 cmd_invoke._invoke_tui / cmd_run._run_custom 调用。
|
|
1869
|
+
|
|
1870
|
+
``no_alt_screen`` 作为兼容参数保留;TUI 现在默认使用 Codex-style inline viewport。
|
|
1871
|
+
"""
|
|
1872
|
+
InteractionLoop(
|
|
1873
|
+
runner,
|
|
1874
|
+
show_thinking=show_thinking,
|
|
1875
|
+
project_dir=project_dir,
|
|
1876
|
+
no_alt_screen=no_alt_screen,
|
|
1877
|
+
).run()
|