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.
Files changed (486) hide show
  1. agentengine_sdk_python-0.7.0.dist-info/METADATA +181 -0
  2. agentengine_sdk_python-0.7.0.dist-info/RECORD +482 -0
  3. {agentengine_sdk_python-0.6.4.dist-info → agentengine_sdk_python-0.7.0.dist-info}/WHEEL +1 -1
  4. agentengine_sdk_python-0.7.0.dist-info/entry_points.txt +3 -0
  5. agentengine_sdk_python-0.7.0.dist-info/licenses/LICENSE +175 -0
  6. agentengine_sdk_python-0.7.0.dist-info/top_level.txt +2 -0
  7. ksadk/__init__.py +13 -0
  8. ksadk/__main__.py +4 -0
  9. ksadk/a2a/__init__.py +15 -0
  10. ksadk/a2a/card_builder.py +64 -0
  11. ksadk/a2a/client.py +309 -0
  12. ksadk/a2a/executor.py +201 -0
  13. ksadk/a2a/server.py +79 -0
  14. ksadk/agents/__init__.py +20 -0
  15. ksadk/agents/base.py +370 -0
  16. ksadk/agents/context.py +38 -0
  17. ksadk/agents/event.py +35 -0
  18. ksadk/agents/loop_agent.py +71 -0
  19. ksadk/agents/parallel_agent.py +109 -0
  20. ksadk/agents/runner_adapter.py +24 -0
  21. ksadk/agents/sequential_agent.py +34 -0
  22. ksadk/api/__init__.py +3 -0
  23. ksadk/api/client.py +2246 -0
  24. ksadk/builders/__init__.py +20 -0
  25. ksadk/builders/base.py +66 -0
  26. ksadk/builders/code_builder.py +1990 -0
  27. ksadk/builders/container_builder.py +786 -0
  28. ksadk/builders/framework_requirements.py +59 -0
  29. ksadk/builders/ks3_uploader.py +389 -0
  30. ksadk/builders/mcp_builder.py +476 -0
  31. ksadk/builders/requirements_utils.py +75 -0
  32. ksadk/cli/__init__.py +531 -0
  33. ksadk/cli/__main__.py +4 -0
  34. ksadk/cli/agent_ref.py +204 -0
  35. ksadk/cli/cmd_a2a.py +167 -0
  36. ksadk/cli/cmd_agent.py +201 -0
  37. ksadk/cli/cmd_build.py +270 -0
  38. ksadk/cli/cmd_completion.py +304 -0
  39. ksadk/cli/cmd_config.py +758 -0
  40. ksadk/cli/cmd_create.py +2304 -0
  41. ksadk/cli/cmd_dashboard.py +900 -0
  42. ksadk/cli/cmd_deploy.py +704 -0
  43. ksadk/cli/cmd_destroy.py +345 -0
  44. ksadk/cli/cmd_files.py +1227 -0
  45. ksadk/cli/cmd_hermes.py +1263 -0
  46. ksadk/cli/cmd_invoke.py +1503 -0
  47. ksadk/cli/cmd_launch.py +564 -0
  48. ksadk/cli/cmd_mcp.py +1179 -0
  49. ksadk/cli/cmd_model.py +302 -0
  50. ksadk/cli/cmd_openclaw.py +3922 -0
  51. ksadk/cli/cmd_run.py +239 -0
  52. ksadk/cli/cmd_status.py +588 -0
  53. ksadk/cli/cmd_version.py +522 -0
  54. ksadk/cli/cmd_web.py +288 -0
  55. ksadk/cli/deploy_utils.py +87 -0
  56. ksadk/cli/dry_run.py +214 -0
  57. ksadk/cli/env_options.py +82 -0
  58. ksadk/cli/error_utils.py +525 -0
  59. ksadk/cli/global_options.py +31 -0
  60. ksadk/cli/local_runtime.py +97 -0
  61. ksadk/cli/model_catalog.py +136 -0
  62. ksadk/cli/network_options.py +412 -0
  63. ksadk/cli/resource_common.py +621 -0
  64. ksadk/cli/storage.py +84 -0
  65. ksadk/cli/ui.py +431 -0
  66. ksadk/cli/workflow_common.py +552 -0
  67. ksadk/common/__init__.py +14 -0
  68. ksadk/common/aicp_env.py +128 -0
  69. ksadk/common/auth.py +190 -0
  70. ksadk/common/constants.py +84 -0
  71. ksadk/common/llm_utils.py +61 -0
  72. ksadk/configs/__init__.py +61 -0
  73. ksadk/configs/env_registry.py +343 -0
  74. ksadk/configs/global_config.py +200 -0
  75. ksadk/configs/settings.py +706 -0
  76. ksadk/conversations/__init__.py +95 -0
  77. ksadk/conversations/attachment_storage.py +379 -0
  78. ksadk/conversations/attachments.py +774 -0
  79. ksadk/conversations/compaction_pipeline.py +387 -0
  80. ksadk/conversations/compaction_prompt.py +128 -0
  81. ksadk/conversations/context.py +303 -0
  82. ksadk/conversations/message_projection.py +269 -0
  83. ksadk/conversations/model_context.py +370 -0
  84. ksadk/conversations/model_options.py +136 -0
  85. ksadk/conversations/normalize.py +446 -0
  86. ksadk/conversations/reasoning_markup.py +91 -0
  87. ksadk/conversations/run_kinds.py +61 -0
  88. ksadk/conversations/run_status.py +53 -0
  89. ksadk/conversations/runtime.py +5276 -0
  90. ksadk/conversations/semantic_summary.py +359 -0
  91. ksadk/conversations/session_title.py +254 -0
  92. ksadk/deployment/__init__.py +83 -0
  93. ksadk/deployment/agent_access.py +226 -0
  94. ksadk/deployment/base.py +229 -0
  95. ksadk/deployment/manager.py +332 -0
  96. ksadk/deployment/providers/__init__.py +10 -0
  97. ksadk/deployment/providers/serverless.py +1115 -0
  98. ksadk/deployment/registry.py +120 -0
  99. ksadk/deployment/state.py +78 -0
  100. ksadk/deployment/ui_config.py +21 -0
  101. ksadk/detection/__init__.py +7 -0
  102. ksadk/detection/detector.py +431 -0
  103. ksadk/detection/mcp_detector.py +250 -0
  104. ksadk/hermes_terminal.py +467 -0
  105. ksadk/identity/__init__.py +17 -0
  106. ksadk/identity/resolver.py +385 -0
  107. ksadk/knowledge_base/__init__.py +62 -0
  108. ksadk/knowledge_base/adk_tool.py +47 -0
  109. ksadk/knowledge_base/client.py +297 -0
  110. ksadk/knowledge_base/langchain_tool.py +55 -0
  111. ksadk/knowledge_base/service.py +66 -0
  112. ksadk/knowledge_base/tool.py +50 -0
  113. ksadk/markdown.py +169 -0
  114. ksadk/mcp_runtime/__init__.py +231 -0
  115. ksadk/memory/__init__.py +57 -0
  116. ksadk/memory/adk/__init__.py +51 -0
  117. ksadk/memory/adk/backends/__init__.py +5 -0
  118. ksadk/memory/adk/backends/base_ltm_backend.py +52 -0
  119. ksadk/memory/adk/backends/http_ltm_backend.py +183 -0
  120. ksadk/memory/adk/backends/inmemory_ltm_backend.py +96 -0
  121. ksadk/memory/adk/backends/sdk_ltm_backend.py +456 -0
  122. ksadk/memory/adk/long_term_memory.py +273 -0
  123. ksadk/memory/adk/resilient_session_service.py +268 -0
  124. ksadk/memory/adk/short_term_memory.py +304 -0
  125. ksadk/memory/adk_tool.py +31 -0
  126. ksadk/memory/backends/__init__.py +6 -0
  127. ksadk/memory/backends/base.py +103 -0
  128. ksadk/memory/backends/memory.py +112 -0
  129. ksadk/memory/backends/redis.py +146 -0
  130. ksadk/memory/langchain_tool.py +39 -0
  131. ksadk/memory/ltm_backend_factory.py +24 -0
  132. ksadk/memory/manager.py +215 -0
  133. ksadk/memory/service.py +172 -0
  134. ksadk/memory/tool.py +96 -0
  135. ksadk/model_policy.py +223 -0
  136. ksadk/openclaw_gateway.py +378 -0
  137. ksadk/runners/__init__.py +8 -0
  138. ksadk/runners/adk_runner.py +1994 -0
  139. ksadk/runners/base_runner.py +305 -0
  140. ksadk/runners/deepagents_runner.py +13 -0
  141. ksadk/runners/factory.py +73 -0
  142. ksadk/runners/langchain_runner.py +751 -0
  143. ksadk/runners/langgraph_runner.py +985 -0
  144. ksadk/runners/patch_langchain.py +187 -0
  145. ksadk/runners/remote_runner.py +792 -0
  146. ksadk/runners/usage_accumulator.py +38 -0
  147. ksadk/runners/utils/__init__.py +19 -0
  148. ksadk/runners/utils/langfuse.py +230 -0
  149. ksadk/runners/utils/loader.py +62 -0
  150. ksadk/runtime_context.py +166 -0
  151. ksadk/runtime_state.py +25 -0
  152. ksadk/sandbox/__init__.py +28 -0
  153. ksadk/sandbox/backends/__init__.py +9 -0
  154. ksadk/sandbox/backends/e2b.py +154 -0
  155. ksadk/sandbox/backends/local_process.py +131 -0
  156. ksadk/sandbox/base.py +101 -0
  157. ksadk/sandbox/factory.py +53 -0
  158. ksadk/sandbox/registry.py +187 -0
  159. ksadk/server/__init__.py +7 -0
  160. ksadk/server/api_models.py +73 -0
  161. ksadk/server/app.py +3931 -0
  162. ksadk/server/static/assets/ArtifactsPanel-D-SSn2ee.js +1 -0
  163. ksadk/server/static/assets/CodeBlock-P16VOLYi.js +9 -0
  164. ksadk/server/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  165. ksadk/server/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  166. ksadk/server/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  167. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  168. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  169. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  170. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  171. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  172. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  173. ksadk/server/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  174. ksadk/server/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  175. ksadk/server/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  176. ksadk/server/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  177. ksadk/server/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  178. ksadk/server/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  179. ksadk/server/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  180. ksadk/server/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  181. ksadk/server/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  182. ksadk/server/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  183. ksadk/server/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  184. ksadk/server/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  185. ksadk/server/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  186. ksadk/server/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  187. ksadk/server/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  188. ksadk/server/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  189. ksadk/server/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  190. ksadk/server/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  191. ksadk/server/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  192. ksadk/server/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  193. ksadk/server/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  194. ksadk/server/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  195. ksadk/server/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  196. ksadk/server/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  197. ksadk/server/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  198. ksadk/server/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  199. ksadk/server/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  200. ksadk/server/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  201. ksadk/server/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  202. ksadk/server/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  203. ksadk/server/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  204. ksadk/server/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  205. ksadk/server/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  206. ksadk/server/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  207. ksadk/server/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  208. ksadk/server/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  209. ksadk/server/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  210. ksadk/server/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  211. ksadk/server/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  212. ksadk/server/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  213. ksadk/server/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  214. ksadk/server/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  215. ksadk/server/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  216. ksadk/server/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  217. ksadk/server/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  218. ksadk/server/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  219. ksadk/server/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  220. ksadk/server/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  221. ksadk/server/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  222. ksadk/server/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  223. ksadk/server/static/assets/MathMessageMarkdown-CdGiQWHR.js +8 -0
  224. ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +1 -0
  225. ksadk/server/static/assets/MermaidBlock-IOcal8cm.js +303 -0
  226. ksadk/server/static/assets/NativeTerminalPanel-CHOd7Rch.css +1 -0
  227. ksadk/server/static/assets/NativeTerminalPanel-DGSDWsei.js +1 -0
  228. ksadk/server/static/assets/addon-fit-DthTIhi3.js +1 -0
  229. ksadk/server/static/assets/apl-CnwPGSsG.js +1 -0
  230. ksadk/server/static/assets/arc-BA13sHc9.js +1 -0
  231. ksadk/server/static/assets/architecture-7EHR7CIX-BvUlsA6l.js +1 -0
  232. ksadk/server/static/assets/architectureDiagram-3BPJPVTR-DzJzgVoL.js +36 -0
  233. ksadk/server/static/assets/asciiarmor-qTkVPQu6.js +1 -0
  234. ksadk/server/static/assets/asn1-Dr8qZg38.js +1 -0
  235. ksadk/server/static/assets/asterisk-Bja0s6e1.js +1 -0
  236. ksadk/server/static/assets/blockDiagram-GPEHLZMM-UTdVcZhn.js +132 -0
  237. ksadk/server/static/assets/brainfuck-D5EjA2JK.js +1 -0
  238. ksadk/server/static/assets/c4Diagram-AAUBKEIU-Dix0GD4m.js +10 -0
  239. ksadk/server/static/assets/channel-CTuydARX.js +1 -0
  240. ksadk/server/static/assets/chunk-2J33WTMH-C2JxuZ42.js +1 -0
  241. ksadk/server/static/assets/chunk-4BX2VUAB-Be57QPgD.js +1 -0
  242. ksadk/server/static/assets/chunk-55IACEB6-WjSuy4nv.js +1 -0
  243. ksadk/server/static/assets/chunk-727SXJPM-BOovP-b5.js +206 -0
  244. ksadk/server/static/assets/chunk-AQP2D5EJ-DOmZNmWj.js +231 -0
  245. ksadk/server/static/assets/chunk-FMBD7UC4-DzwlodkU.js +15 -0
  246. ksadk/server/static/assets/chunk-ND2GUHAM-DMqAikEU.js +1 -0
  247. ksadk/server/static/assets/chunk-QZHKN3VN-BnCydivS.js +1 -0
  248. ksadk/server/static/assets/classDiagram-4FO5ZUOK-BNHg45Be.js +1 -0
  249. ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-BNHg45Be.js +1 -0
  250. ksadk/server/static/assets/clike-DHH8Ad3s.js +1 -0
  251. ksadk/server/static/assets/clojure-BflJGmDX.js +1 -0
  252. ksadk/server/static/assets/cmake-CyVbuzPu.js +1 -0
  253. ksadk/server/static/assets/cobol-B_OZ4V-R.js +1 -0
  254. ksadk/server/static/assets/coffeescript-B2pKbFk8.js +1 -0
  255. ksadk/server/static/assets/commonlisp-CcllspGY.js +1 -0
  256. ksadk/server/static/assets/cose-bilkent-S5V4N54A-Zhy2GBnP.js +1 -0
  257. ksadk/server/static/assets/crystal-M5-qzICs.js +1 -0
  258. ksadk/server/static/assets/css-CYpP4FRV.js +1 -0
  259. ksadk/server/static/assets/cypher-p9eYesGn.js +1 -0
  260. ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +321 -0
  261. ksadk/server/static/assets/d-BhMjBjQP.js +1 -0
  262. ksadk/server/static/assets/dagre-BM42HDAG-CbKrtIUz.js +4 -0
  263. ksadk/server/static/assets/dagre-Bx709z4p.js +1 -0
  264. ksadk/server/static/assets/defaultLocale-C8Fc0cco.js +1 -0
  265. ksadk/server/static/assets/diagram-2AECGRRQ-BRApgQRK.js +43 -0
  266. ksadk/server/static/assets/diagram-5GNKFQAL-D0xbnDiK.js +10 -0
  267. ksadk/server/static/assets/diagram-KO2AKTUF-CVugVI9L.js +3 -0
  268. ksadk/server/static/assets/diagram-LMA3HP47-DozUGgee.js +24 -0
  269. ksadk/server/static/assets/diagram-OG6HWLK6-ZOYsKlru.js +24 -0
  270. ksadk/server/static/assets/diff-ChtP43wD.js +1 -0
  271. ksadk/server/static/assets/dist-2hInLS_J.js +9 -0
  272. ksadk/server/static/assets/dist-8EEyGgDx.js +1 -0
  273. ksadk/server/static/assets/dist-8_SfK1IP.js +1 -0
  274. ksadk/server/static/assets/dist-B0NP_Z0z.js +23 -0
  275. ksadk/server/static/assets/dist-BIJVDMuB.js +1 -0
  276. ksadk/server/static/assets/dist-BmlMPsCc.js +2 -0
  277. ksadk/server/static/assets/dist-BrCcQ47T.js +1 -0
  278. ksadk/server/static/assets/dist-BssVmjIo.js +6 -0
  279. ksadk/server/static/assets/dist-C3HIIaN5.js +1 -0
  280. ksadk/server/static/assets/dist-CPBtqRTW.js +1 -0
  281. ksadk/server/static/assets/dist-CkEKJFtk.js +1 -0
  282. ksadk/server/static/assets/dist-D-JMdWPQ.js +1 -0
  283. ksadk/server/static/assets/dist-D-xOD048.js +1 -0
  284. ksadk/server/static/assets/dist-D57wnGWd.js +1 -0
  285. ksadk/server/static/assets/dist-DL_NKc_z.js +1 -0
  286. ksadk/server/static/assets/dist-DMezvBGJ.js +1 -0
  287. ksadk/server/static/assets/dist-DmFaq_Iy.js +1 -0
  288. ksadk/server/static/assets/dist-Dp0k7bWI.js +1 -0
  289. ksadk/server/static/assets/dist-DpKtt4Bo.js +1 -0
  290. ksadk/server/static/assets/dist-DyOkZmze.js +11 -0
  291. ksadk/server/static/assets/dist-DyWsb78Q.js +1 -0
  292. ksadk/server/static/assets/dist-gX6GOLKq.js +1 -0
  293. ksadk/server/static/assets/dockerfile-BLkNEvjs.js +1 -0
  294. ksadk/server/static/assets/dtd-DTF72YNO.js +1 -0
  295. ksadk/server/static/assets/dylan-C6jEEEk-.js +1 -0
  296. ksadk/server/static/assets/ebnf-D4c0_ac3.js +1 -0
  297. ksadk/server/static/assets/ecl-BSZaIHnp.js +1 -0
  298. ksadk/server/static/assets/eiffel-BEjRio4Q.js +1 -0
  299. ksadk/server/static/assets/elm-Cshzl8qu.js +1 -0
  300. ksadk/server/static/assets/erDiagram-TEJ5UH35-Bk-9bgE5.js +85 -0
  301. ksadk/server/static/assets/erlang-DaB2Rkuy.js +1 -0
  302. ksadk/server/static/assets/eventmodeling-FCH6USID-DsAgWnlf.js +1 -0
  303. ksadk/server/static/assets/factor-D7LVTn2l.js +1 -0
  304. ksadk/server/static/assets/fcl-ClOhbFR7.js +1 -0
  305. ksadk/server/static/assets/flowDiagram-I6XJVG4X-Dn9kZVuZ.js +162 -0
  306. ksadk/server/static/assets/forth-Cezjo90N.js +1 -0
  307. ksadk/server/static/assets/fortran-Bt6PBEDR.js +1 -0
  308. ksadk/server/static/assets/ganttDiagram-6RSMTGT7-BGxXlJqm.js +292 -0
  309. ksadk/server/static/assets/gas-BHEdbvp9.js +1 -0
  310. ksadk/server/static/assets/gherkin-oBAE_ms0.js +1 -0
  311. ksadk/server/static/assets/gitGraph-WXDBUCRP-BIao9Yjg.js +1 -0
  312. ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-C7Q0X-wi.js +106 -0
  313. ksadk/server/static/assets/graphlib-B8gBHxth.js +1 -0
  314. ksadk/server/static/assets/groovy-BC3IOsC8.js +1 -0
  315. ksadk/server/static/assets/haskell-BRsoo5mP.js +1 -0
  316. ksadk/server/static/assets/haxe-DIz0ZZqd.js +1 -0
  317. ksadk/server/static/assets/http-CLVgA2GD.js +1 -0
  318. ksadk/server/static/assets/idl-BUZw3wgd.js +1 -0
  319. ksadk/server/static/assets/index-C00Lwg3L.js +165 -0
  320. ksadk/server/static/assets/index-DAURbCsP.css +1 -0
  321. ksadk/server/static/assets/info-J43DQDTF-CyipqGxU.js +1 -0
  322. ksadk/server/static/assets/infoDiagram-5YYISTIA-BO_CSqq5.js +2 -0
  323. ksadk/server/static/assets/init-D6jRqBbL.js +1 -0
  324. ksadk/server/static/assets/ishikawaDiagram-YF4QCWOH-eYLPLd46.js +70 -0
  325. ksadk/server/static/assets/javascript-9Tg8ixDm.js +1 -0
  326. ksadk/server/static/assets/journeyDiagram-JHISSGLW-De9geSb0.js +139 -0
  327. ksadk/server/static/assets/julia-CQfgDRfP.js +1 -0
  328. ksadk/server/static/assets/kanban-definition-UN3LZRKU-Cs80kQto.js +89 -0
  329. ksadk/server/static/assets/katex-DSrCFD8n.js +265 -0
  330. ksadk/server/static/assets/linear-CN9sbKTF.js +1 -0
  331. ksadk/server/static/assets/livescript-BXxG0Yva.js +1 -0
  332. ksadk/server/static/assets/lua-CiA1ziua.js +1 -0
  333. ksadk/server/static/assets/mathematica-u8YMmbU0.js +1 -0
  334. ksadk/server/static/assets/mbox-BEMVcqjs.js +1 -0
  335. ksadk/server/static/assets/mermaid-parser.core-DlgSBU8l.js +161 -0
  336. ksadk/server/static/assets/mindmap-definition-RKZ34NQL-9afz7Oug.js +96 -0
  337. ksadk/server/static/assets/mirc-C9zpzAZU.js +1 -0
  338. ksadk/server/static/assets/mllike-B45xgp2S.js +1 -0
  339. ksadk/server/static/assets/modelica-CqHI_q-D.js +1 -0
  340. ksadk/server/static/assets/mscgen-qgSLujhx.js +1 -0
  341. ksadk/server/static/assets/mumps-CW_TBmxz.js +1 -0
  342. ksadk/server/static/assets/nginx-BMaDbqW3.js +1 -0
  343. ksadk/server/static/assets/nsis-AQ_alPln.js +1 -0
  344. ksadk/server/static/assets/ntriples-d28e9m0E.js +1 -0
  345. ksadk/server/static/assets/octave-D2Q8cUp1.js +1 -0
  346. ksadk/server/static/assets/ordinal-hYBb2elL.js +1 -0
  347. ksadk/server/static/assets/oz-BwTct_5T.js +1 -0
  348. ksadk/server/static/assets/packet-YPE3B663-CGkyRxlg.js +1 -0
  349. ksadk/server/static/assets/pascal-Dp_KdFgX.js +1 -0
  350. ksadk/server/static/assets/perl-CdEHsPfU.js +1 -0
  351. ksadk/server/static/assets/pie-LRSECV5Y-M0uWAf4n.js +1 -0
  352. ksadk/server/static/assets/pieDiagram-4H26LBE5-DPv_VstN.js +30 -0
  353. ksadk/server/static/assets/pig-AF4Hwhju.js +1 -0
  354. ksadk/server/static/assets/powershell-CzG71I4L.js +1 -0
  355. ksadk/server/static/assets/properties-C357ku5U.js +1 -0
  356. ksadk/server/static/assets/protobuf-D1ij_kNL.js +1 -0
  357. ksadk/server/static/assets/pug-Bugr-47h.js +1 -0
  358. ksadk/server/static/assets/puppet-B4hC2X4m.js +1 -0
  359. ksadk/server/static/assets/python-BqYw9LYJ.js +1 -0
  360. ksadk/server/static/assets/q-B9NhS7L_.js +1 -0
  361. ksadk/server/static/assets/quadrantDiagram-W4KKPZXB-o77wZ1XK.js +7 -0
  362. ksadk/server/static/assets/r-DADKenSm.js +1 -0
  363. ksadk/server/static/assets/radar-GUYGQ44K-Bdbm24-x.js +1 -0
  364. ksadk/server/static/assets/requirementDiagram-4Y6WPE33-BYVw81VC.js +84 -0
  365. ksadk/server/static/assets/rpm-gpDK5sbt.js +1 -0
  366. ksadk/server/static/assets/ruby-DsYpTuWg.js +1 -0
  367. ksadk/server/static/assets/sankeyDiagram-5OEKKPKP-B7nZFMKT.js +40 -0
  368. ksadk/server/static/assets/sas-C2OyD2Y6.js +1 -0
  369. ksadk/server/static/assets/scheme-CyDdOh7e.js +1 -0
  370. ksadk/server/static/assets/sequenceDiagram-3UESZ5HK-BGRRrFw5.js +162 -0
  371. ksadk/server/static/assets/shell-Du5Qg9im.js +1 -0
  372. ksadk/server/static/assets/sieve-BAxa3IjF.js +1 -0
  373. ksadk/server/static/assets/simple-mode-DRpGK0lJ.js +1 -0
  374. ksadk/server/static/assets/smalltalk-BLp5-jwC.js +1 -0
  375. ksadk/server/static/assets/solr-BBopId9w.js +1 -0
  376. ksadk/server/static/assets/sparql-Dz-mlCAC.js +1 -0
  377. ksadk/server/static/assets/spreadsheet-CTIncYxj.js +1 -0
  378. ksadk/server/static/assets/sql-DcCJ9jop.js +1 -0
  379. ksadk/server/static/assets/stateDiagram-AJRCARHV-BUfHCoPs.js +1 -0
  380. ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-8xBN_gLM.js +1 -0
  381. ksadk/server/static/assets/stex-sDrdk2BW.js +1 -0
  382. ksadk/server/static/assets/stylus-DLLZ8dgm.js +1 -0
  383. ksadk/server/static/assets/swift-DKB6_1j6.js +1 -0
  384. ksadk/server/static/assets/tcl-BGfruO1u.js +1 -0
  385. ksadk/server/static/assets/textile-DWBqthVk.js +1 -0
  386. ksadk/server/static/assets/tiddlywiki-VlUiK86J.js +1 -0
  387. ksadk/server/static/assets/tiki-CxVlf5KZ.js +1 -0
  388. ksadk/server/static/assets/timeline-definition-PNZ67QCA-FbCTGrf9.js +120 -0
  389. ksadk/server/static/assets/toml-LvoKBwCs.js +1 -0
  390. ksadk/server/static/assets/treeView-BLDUP644-npMo0ak1.js +1 -0
  391. ksadk/server/static/assets/treemap-LRROVOQU-CHdzrrdo.js +1 -0
  392. ksadk/server/static/assets/troff-C2dAgh7P.js +1 -0
  393. ksadk/server/static/assets/ttcn-BePYh-hG.js +1 -0
  394. ksadk/server/static/assets/ttcn-cfg-CY0aot7Q.js +1 -0
  395. ksadk/server/static/assets/turtle-BDLWE2cD.js +1 -0
  396. ksadk/server/static/assets/vb-BthdM_4N.js +1 -0
  397. ksadk/server/static/assets/vbscript-D9f6rSsU.js +1 -0
  398. ksadk/server/static/assets/velocity-CqftRsjg.js +1 -0
  399. ksadk/server/static/assets/vennDiagram-CIIHVFJN-DWgwtPsJ.js +34 -0
  400. ksadk/server/static/assets/verilog-D-x6ne0Z.js +1 -0
  401. ksadk/server/static/assets/vhdl-CMrOq2q1.js +1 -0
  402. ksadk/server/static/assets/wardley-L42UT6IY-BEQz7wyy.js +1 -0
  403. ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-2x8G-tTM.js +78 -0
  404. ksadk/server/static/assets/webidl-D5jiJvOU.js +1 -0
  405. ksadk/server/static/assets/xquery-fj_R-kMw.js +1 -0
  406. ksadk/server/static/assets/xterm-DooSxjI5.js +36 -0
  407. ksadk/server/static/assets/xychartDiagram-2RQKCTM6-Drma3AYl.js +7 -0
  408. ksadk/server/static/assets/yacas-CXXgtw0p.js +1 -0
  409. ksadk/server/static/assets/z80-CL1naaMV.js +1 -0
  410. ksadk/server/static/favicon.svg +1 -0
  411. ksadk/server/static/icons.svg +24 -0
  412. ksadk/server/static/index.html +14 -0
  413. ksadk/server/terminal_sessions.py +551 -0
  414. ksadk/sessions/__init__.py +279 -0
  415. ksadk/sessions/base.py +354 -0
  416. ksadk/sessions/continuity.py +337 -0
  417. ksadk/sessions/errors.py +7 -0
  418. ksadk/sessions/in_memory.py +273 -0
  419. ksadk/sessions/local_service.py +949 -0
  420. ksadk/sessions/postgres_service.py +901 -0
  421. ksadk/sessions/resilience.py +52 -0
  422. ksadk/sessions/resilient.py +360 -0
  423. ksadk/sessions/sqlite_service.py +13 -0
  424. ksadk/skills/__init__.py +9 -0
  425. ksadk/skills/loader.py +46 -0
  426. ksadk/skills/models.py +120 -0
  427. ksadk/skills/package_store.py +104 -0
  428. ksadk/skills/runtime/__init__.py +17 -0
  429. ksadk/skills/runtime/agent.py +183 -0
  430. ksadk/skills/runtime/artifacts.py +39 -0
  431. ksadk/skills/runtime/backends/__init__.py +1 -0
  432. ksadk/skills/runtime/backends/disabled.py +8 -0
  433. ksadk/skills/runtime/backends/e2b.py +168 -0
  434. ksadk/skills/runtime/backends/local.py +95 -0
  435. ksadk/skills/runtime/base.py +103 -0
  436. ksadk/skills/runtime/executor.py +219 -0
  437. ksadk/skills/runtime/factory.py +37 -0
  438. ksadk/skills/runtime/loader.py +131 -0
  439. ksadk/skills/runtime/registry.py +150 -0
  440. ksadk/skills/runtime/request.py +67 -0
  441. ksadk/skills/service_client.py +239 -0
  442. ksadk/skills/service_env.py +58 -0
  443. ksadk/skills/tool_defs.py +221 -0
  444. ksadk/terminal_client.py +69 -0
  445. ksadk/terminal_exec_policy.py +229 -0
  446. ksadk/tools/__init__.py +17 -0
  447. ksadk/tools/gateway.py +230 -0
  448. ksadk/tools/result_budget.py +154 -0
  449. ksadk/toolsets/__init__.py +854 -0
  450. ksadk/toolsets/_langchain.py +11 -0
  451. ksadk/toolsets/platform.py +93 -0
  452. ksadk/toolsets/sandbox.py +310 -0
  453. ksadk/toolsets/skills.py +217 -0
  454. ksadk/toolsets/web.py +266 -0
  455. ksadk/toolsets/workspace.py +917 -0
  456. ksadk/toolsets/workspace_state.py +44 -0
  457. ksadk/tracing/__init__.py +37 -0
  458. ksadk/tracing/exporters/__init__.py +1 -0
  459. ksadk/tracing/exporters/inmemory_exporter.py +116 -0
  460. ksadk/tracing/exporters/langfuse_exporter.py +445 -0
  461. ksadk/tracing/setup.py +892 -0
  462. ksadk/tracing/span_utils.py +114 -0
  463. ksadk/tui/__init__.py +27 -0
  464. ksadk/tui/loop.py +1877 -0
  465. ksadk/tui/stream_render.py +68 -0
  466. ksadk/ui_config.py +168 -0
  467. ksadk/version.py +4 -0
  468. ksadk_runtime_common/__init__.py +20 -0
  469. ksadk_runtime_common/memory_backend/__init__.py +29 -0
  470. ksadk_runtime_common/memory_backend/manifest.py +79 -0
  471. ksadk_runtime_common/memory_backend/providers/__init__.py +6 -0
  472. ksadk_runtime_common/memory_backend/providers/lancedb.py +151 -0
  473. ksadk_runtime_common/memory_backend/providers/mem0.py +74 -0
  474. ksadk_runtime_common/memory_backend/registry.py +56 -0
  475. ksadk_runtime_common/memory_backend/render.py +71 -0
  476. ksadk_runtime_common/schemas/memory_backend_manifest.schema.json +148 -0
  477. ksadk_runtime_common/workspace_files/__init__.py +29 -0
  478. ksadk_runtime_common/workspace_files/bootstrap.py +56 -0
  479. ksadk_runtime_common/workspace_files/constants.py +9 -0
  480. ksadk_runtime_common/workspace_files/path_utils.py +71 -0
  481. ksadk_runtime_common/workspace_files/preview.py +96 -0
  482. ksadk_runtime_common/workspace_files/router.py +254 -0
  483. agentengine_sdk_python/__init__.py +0 -3
  484. agentengine_sdk_python-0.6.4.dist-info/METADATA +0 -48
  485. agentengine_sdk_python-0.6.4.dist-info/RECORD +0 -5
  486. 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()