@scemoon/cdh 1.0.0

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 (528) hide show
  1. package/ai-dlc-skill/SKILL.md +75 -0
  2. package/ai-dlc-skill/agents/deliver-agent.md +24 -0
  3. package/ai-dlc-skill/agents/master.md +44 -0
  4. package/ai-dlc-skill/agents/plan-agent.md +23 -0
  5. package/ai-dlc-skill/agents/understand-agent.md +23 -0
  6. package/ai-dlc-skill/agents/verify-agent.md +27 -0
  7. package/ai-dlc-skill/architecture/project-structure.md +78 -0
  8. package/ai-dlc-skill/brownfield/README.md +30 -0
  9. package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
  10. package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
  11. package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
  12. package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
  13. package/ai-dlc-skill/components/backend.md +34 -0
  14. package/ai-dlc-skill/components/desktop.md +30 -0
  15. package/ai-dlc-skill/components/mya.md +29 -0
  16. package/ai-dlc-skill/components/native.md +30 -0
  17. package/ai-dlc-skill/components/tta.md +29 -0
  18. package/ai-dlc-skill/components/web.md +31 -0
  19. package/ai-dlc-skill/components/wxa.md +29 -0
  20. package/ai-dlc-skill/contracts/README.md +25 -0
  21. package/ai-dlc-skill/core/adaptive-flow.md +36 -0
  22. package/ai-dlc-skill/core/security.md +29 -0
  23. package/ai-dlc-skill/cross-tool/cline/export.sh +31 -0
  24. package/ai-dlc-skill/cross-tool/copilot/export.sh +47 -0
  25. package/ai-dlc-skill/cross-tool/cursor/export.sh +32 -0
  26. package/ai-dlc-skill/cross-tool/export.sh +22 -0
  27. package/ai-dlc-skill/cross-tool/onecode/config.yaml.tpl +17 -0
  28. package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +31 -0
  29. package/ai-dlc-skill/cross-tool/onecode/export.sh +48 -0
  30. package/ai-dlc-skill/phases/deliver/entry.md +26 -0
  31. package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
  32. package/ai-dlc-skill/phases/deliver/prompt.md +40 -0
  33. package/ai-dlc-skill/phases/deliver/rules.md +61 -0
  34. package/ai-dlc-skill/phases/plan/entry.md +25 -0
  35. package/ai-dlc-skill/phases/plan/lifecycle.md +204 -0
  36. package/ai-dlc-skill/phases/plan/prompt.md +38 -0
  37. package/ai-dlc-skill/phases/plan/rules.md +25 -0
  38. package/ai-dlc-skill/phases/understand/entry.md +26 -0
  39. package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
  40. package/ai-dlc-skill/phases/understand/prompt.md +41 -0
  41. package/ai-dlc-skill/phases/understand/rules.md +37 -0
  42. package/ai-dlc-skill/phases/verify/entry.md +26 -0
  43. package/ai-dlc-skill/phases/verify/lifecycle.md +192 -0
  44. package/ai-dlc-skill/phases/verify/prompt.md +43 -0
  45. package/ai-dlc-skill/phases/verify/rules.md +73 -0
  46. package/ai-dlc-skill/practices/bdd.md +91 -0
  47. package/ai-dlc-skill/practices/sdd.md +84 -0
  48. package/ai-dlc-skill/practices/tdd.md +95 -0
  49. package/ai-dlc-skill/providers/README.md +114 -0
  50. package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
  51. package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
  52. package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
  53. package/ai-dlc-skill/providers/tcb/deployment.yaml +69 -0
  54. package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
  55. package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
  56. package/ai-dlc-skill/requirements.md +140 -0
  57. package/ai-dlc-skill/skill.yaml +147 -0
  58. package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
  59. package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
  60. package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
  61. package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
  62. package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
  63. package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
  64. package/ai-dlc-skill/templates/plan/design.md +49 -0
  65. package/ai-dlc-skill/templates/plan/task-list.md +32 -0
  66. package/ai-dlc-skill/templates/project/README.md +127 -0
  67. package/ai-dlc-skill/templates/project/template.md +119 -0
  68. package/ai-dlc-skill/templates/understand/feature.feature +23 -0
  69. package/ai-dlc-skill/templates/understand/intent.md +29 -0
  70. package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
  71. package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
  72. package/ai-dlc-skill/walkthrough/collect.sh +50 -0
  73. package/ai-dlc-skill/walkthrough/template.md +54 -0
  74. package/ai-dlc-skill/workflows/ai-dlc.yaml +110 -0
  75. package/ai-dlc-skill/workflows/deployment.yaml +199 -0
  76. package/cdh/__init__.py +0 -0
  77. package/cdh/__main__.py +4 -0
  78. package/cdh/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
  80. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  81. package/cdh/__pycache__/scaffold.cpython-313.pyc +0 -0
  82. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  83. package/cdh/cli.py +361 -0
  84. package/cdh/scaffold.py +312 -0
  85. package/cli.js +2 -0
  86. package/onecode/__init__.py +1 -0
  87. package/onecode/__main__.py +4 -0
  88. package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
  89. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  90. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  91. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  92. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  93. package/onecode/__pycache__/mock_data.cpython-314.pyc +0 -0
  94. package/onecode/agent/__init__.py +0 -0
  95. package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
  96. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-313.pyc +0 -0
  97. package/onecode/agent/__pycache__/cdh_agent_acp.cpython-314.pyc +0 -0
  98. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  99. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  100. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  101. package/onecode/agent/__pycache__/harness_skill.cpython-314.pyc +0 -0
  102. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  103. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  104. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  105. package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
  106. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  107. package/onecode/agent/agents/__init__.py +29 -0
  108. package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  109. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  110. package/onecode/agent/agents/types.py +662 -0
  111. package/onecode/agent/attachments.py +113 -0
  112. package/onecode/agent/cdh_loader.py +262 -0
  113. package/onecode/agent/context.py +338 -0
  114. package/onecode/agent/engine.py +1894 -0
  115. package/onecode/agent/hooks.py +86 -0
  116. package/onecode/agent/onecode_agent_acp.py +2203 -0
  117. package/onecode/agent/permissions.py +151 -0
  118. package/onecode/agent/permissions_store.py +62 -0
  119. package/onecode/agent/session.py +186 -0
  120. package/onecode/agent/snapshot.py +145 -0
  121. package/onecode/agent/tools/__init__.py +17 -0
  122. package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  123. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  124. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  125. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  126. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  127. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  128. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  129. package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
  130. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  131. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  132. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  133. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  134. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  135. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  136. package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
  137. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  138. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  139. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  140. package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
  141. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  142. package/onecode/agent/tools/__pycache__/task_tools.cpython-314.pyc +0 -0
  143. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  144. package/onecode/agent/tools/agent_tools.py +134 -0
  145. package/onecode/agent/tools/apply_patch_tool.py +144 -0
  146. package/onecode/agent/tools/bash_tool.py +91 -0
  147. package/onecode/agent/tools/communication_tools.py +149 -0
  148. package/onecode/agent/tools/config_tool.py +130 -0
  149. package/onecode/agent/tools/cron_tools.py +179 -0
  150. package/onecode/agent/tools/errors.py +17 -0
  151. package/onecode/agent/tools/file_ops.py +187 -0
  152. package/onecode/agent/tools/file_tools.py +226 -0
  153. package/onecode/agent/tools/git_tools.py +88 -0
  154. package/onecode/agent/tools/lsp_tools.py +404 -0
  155. package/onecode/agent/tools/mcp_tools.py +129 -0
  156. package/onecode/agent/tools/permission_handler.py +46 -0
  157. package/onecode/agent/tools/permissions.py +77 -0
  158. package/onecode/agent/tools/protocol.py +20 -0
  159. package/onecode/agent/tools/registry.py +134 -0
  160. package/onecode/agent/tools/sandbox.py +264 -0
  161. package/onecode/agent/tools/schema_validation.py +100 -0
  162. package/onecode/agent/tools/skill_tools.py +52 -0
  163. package/onecode/agent/tools/task_tools.py +249 -0
  164. package/onecode/agent/tools/web_tools.py +170 -0
  165. package/onecode/builtin_skills/git/SKILL.md +64 -0
  166. package/onecode/builtin_skills/git/skill.yaml +7 -0
  167. package/onecode/builtin_skills/shell/SKILL.md +58 -0
  168. package/onecode/builtin_skills/shell/skill.yaml +8 -0
  169. package/onecode/cli.py +755 -0
  170. package/onecode/cloud/__init__.py +0 -0
  171. package/onecode/cloud/base.py +20 -0
  172. package/onecode/codebase/__init__.py +69 -0
  173. package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
  174. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  175. package/onecode/codebase/__pycache__/config.cpython-314.pyc +0 -0
  176. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  177. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  178. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  179. package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
  180. package/onecode/codebase/chunker.py +130 -0
  181. package/onecode/codebase/indexer.py +136 -0
  182. package/onecode/codebase/retriever.py +192 -0
  183. package/onecode/codebase/storage.py +130 -0
  184. package/onecode/codebase/tools.py +59 -0
  185. package/onecode/commands.py +276 -0
  186. package/onecode/config.py +285 -0
  187. package/onecode/config_screen.py +594 -0
  188. package/onecode/cron/__init__.py +3 -0
  189. package/onecode/lsp/__init__.py +3 -0
  190. package/onecode/mcp/__init__.py +4 -0
  191. package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  192. package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
  193. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  194. package/onecode/mcp/client.py +193 -0
  195. package/onecode/mcp/manager.py +191 -0
  196. package/onecode/memory/__init__.py +95 -0
  197. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  198. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  199. package/onecode/memory/__pycache__/offload.cpython-314.pyc +0 -0
  200. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  201. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  202. package/onecode/memory/__pycache__/symbolic.cpython-314.pyc +0 -0
  203. package/onecode/memory/backend.py +185 -0
  204. package/onecode/memory/offload.py +114 -0
  205. package/onecode/memory/pyramid.py +202 -0
  206. package/onecode/memory/recall.py +168 -0
  207. package/onecode/memory/symbolic.py +164 -0
  208. package/onecode/models/__init__.py +4 -0
  209. package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
  210. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  211. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  212. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  213. package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
  214. package/onecode/models/errors.py +94 -0
  215. package/onecode/models/messages.py +662 -0
  216. package/onecode/models/provider.py +450 -0
  217. package/onecode/models/providers/__init__.py +17 -0
  218. package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  219. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  220. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  221. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  222. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  223. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  224. package/onecode/models/providers/__pycache__/mock_provider.cpython-314.pyc +0 -0
  225. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  226. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  227. package/onecode/models/providers/anthropic_provider.py +224 -0
  228. package/onecode/models/providers/deepseek_provider.py +210 -0
  229. package/onecode/models/providers/glm_provider.py +154 -0
  230. package/onecode/models/providers/minimax_provider.py +154 -0
  231. package/onecode/models/providers/minimaxi_provider.py +363 -0
  232. package/onecode/models/providers/ollama_provider.py +96 -0
  233. package/onecode/models/providers/openai_provider.py +233 -0
  234. package/onecode/models/registry.py +212 -0
  235. package/onecode/server/__init__.py +3 -0
  236. package/onecode/server/app.py +224 -0
  237. package/onecode/skills/__init__.py +11 -0
  238. package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
  239. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  240. package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
  241. package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
  242. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  243. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  244. package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
  245. package/onecode/skills/argument_substitution.py +39 -0
  246. package/onecode/skills/create.py +56 -0
  247. package/onecode/skills/frontmatter.py +33 -0
  248. package/onecode/skills/loader.py +157 -0
  249. package/onecode/skills/manager.py +75 -0
  250. package/onecode/skills/model.py +43 -0
  251. package/onecode/storage/__init__.py +0 -0
  252. package/onecode/storage/activity.py +64 -0
  253. package/onecode/storage/project.py +54 -0
  254. package/onecode/storage/session.py +113 -0
  255. package/onecode/tasks/__init__.py +26 -0
  256. package/onecode/tasks/manager.py +219 -0
  257. package/onecode/tasks/models.py +240 -0
  258. package/onecode/trace/__init__.py +0 -0
  259. package/onecode/trace/tracer.py +174 -0
  260. package/onecode/utils/__init__.py +0 -0
  261. package/onecode/utils/helpers.py +20 -0
  262. package/onecode-cli.js +2 -0
  263. package/package.json +32 -0
  264. package/pyproject.toml +60 -0
  265. package/run.js +86 -0
  266. package/tui/__init__.py +46 -0
  267. package/tui/__main__.py +4 -0
  268. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  269. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  270. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  271. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  272. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  273. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  274. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  275. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  276. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  277. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  278. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  279. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  280. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  281. package/tui/__pycache__/danger.cpython-314.pyc +0 -0
  282. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  283. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  284. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  285. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  286. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  287. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  288. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  289. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  290. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  291. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  292. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  293. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  294. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  295. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  296. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  297. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  298. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  299. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  300. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  301. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  302. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  303. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  304. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  305. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  306. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  307. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  308. package/tui/_loop.py +86 -0
  309. package/tui/_path_fuzzy_search.py +48 -0
  310. package/tui/_path_match.py +141 -0
  311. package/tui/about.py +95 -0
  312. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  313. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  314. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  315. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  316. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  317. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  318. package/tui/acp/agent.py +886 -0
  319. package/tui/acp/api.py +55 -0
  320. package/tui/acp/encode_tool_call_id.py +12 -0
  321. package/tui/acp/messages.py +190 -0
  322. package/tui/acp/prompt.py +110 -0
  323. package/tui/acp/protocol.py +462 -0
  324. package/tui/agent.py +70 -0
  325. package/tui/agent_schema.py +76 -0
  326. package/tui/agents.py +45 -0
  327. package/tui/ansi/__init__.py +1 -0
  328. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  329. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  330. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  331. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  332. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  333. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  334. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  335. package/tui/ansi/_ansi.py +1689 -0
  336. package/tui/ansi/_ansi_colors.py +264 -0
  337. package/tui/ansi/_control_codes.py +37 -0
  338. package/tui/ansi/_keys.py +251 -0
  339. package/tui/ansi/_sgr_styles.py +64 -0
  340. package/tui/ansi/_stream_parser.py +418 -0
  341. package/tui/answer.py +12 -0
  342. package/tui/app.py +1236 -0
  343. package/tui/atomic.py +37 -0
  344. package/tui/cli.py +313 -0
  345. package/tui/code_analyze.py +27 -0
  346. package/tui/complete.py +38 -0
  347. package/tui/constants.py +61 -0
  348. package/tui/conversation_markdown.py +30 -0
  349. package/tui/danger.py +372 -0
  350. package/tui/data/agents/ampcode.com.toml +51 -0
  351. package/tui/data/agents/augmentcode.com.toml +40 -0
  352. package/tui/data/agents/cdh.cloud-dev-harness.toml +22 -0
  353. package/tui/data/agents/claude.com.toml +41 -0
  354. package/tui/data/agents/copilot.github.com.toml +29 -0
  355. package/tui/data/agents/crowai.dev.toml +35 -0
  356. package/tui/data/agents/cursor.com.toml +38 -0
  357. package/tui/data/agents/deepagents.langchain.com +33 -0
  358. package/tui/data/agents/docker.com.toml +59 -0
  359. package/tui/data/agents/geminicli.com.toml +28 -0
  360. package/tui/data/agents/goose.ai.toml +51 -0
  361. package/tui/data/agents/inference.huggingface.co.toml +41 -0
  362. package/tui/data/agents/kimi.com.toml +35 -0
  363. package/tui/data/agents/kiro.dev.toml +42 -0
  364. package/tui/data/agents/openai.com.toml +53 -0
  365. package/tui/data/agents/openclaw.ai.toml +37 -0
  366. package/tui/data/agents/opencode.ai.toml +39 -0
  367. package/tui/data/agents/openhands.dev.toml +45 -0
  368. package/tui/data/agents/stakpak.dev.toml +61 -0
  369. package/tui/data/agents/vibe.mistral.ai.toml +30 -0
  370. package/tui/data/agents/vtcode.dev.toml +62 -0
  371. package/tui/data/images/frog.png +0 -0
  372. package/tui/data/sounds/question.wav +0 -0
  373. package/tui/data/sounds/turn-over.wav +0 -0
  374. package/tui/db.py +190 -0
  375. package/tui/dec.py +332 -0
  376. package/tui/directory.py +234 -0
  377. package/tui/directory_suggester.py +93 -0
  378. package/tui/directory_watcher.py +197 -0
  379. package/tui/format_path.py +25 -0
  380. package/tui/fuzzy.py +140 -0
  381. package/tui/fuzzy_index.py +275 -0
  382. package/tui/gist.py +33 -0
  383. package/tui/history.py +138 -0
  384. package/tui/jsonrpc.py +578 -0
  385. package/tui/menus.py +14 -0
  386. package/tui/message_log.py +69 -0
  387. package/tui/messages.py +139 -0
  388. package/tui/option_content.py +51 -0
  389. package/tui/os.py +0 -0
  390. package/tui/path_complete.py +217 -0
  391. package/tui/path_filter.py +124 -0
  392. package/tui/paths.py +71 -0
  393. package/tui/pill.py +23 -0
  394. package/tui/platform_commands.py +52 -0
  395. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  396. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  397. package/tui/prompt/extract.py +19 -0
  398. package/tui/prompt/resource.py +68 -0
  399. package/tui/protocol.py +28 -0
  400. package/tui/screens/__init__.py +0 -0
  401. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  402. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  403. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  404. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  405. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  406. package/tui/screens/__pycache__/settings.cpython-314.pyc +0 -0
  407. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  408. package/tui/screens/action_modal.py +94 -0
  409. package/tui/screens/agent_modal.py +175 -0
  410. package/tui/screens/command_edit_modal.py +74 -0
  411. package/tui/screens/log.py +553 -0
  412. package/tui/screens/main.py +411 -0
  413. package/tui/screens/main.tcss +655 -0
  414. package/tui/screens/permissions.py +404 -0
  415. package/tui/screens/permissions.tcss +72 -0
  416. package/tui/screens/projects.tcss +66 -0
  417. package/tui/screens/projects_app.py +319 -0
  418. package/tui/screens/projects_screen.py +142 -0
  419. package/tui/screens/session_resume_modal.py +172 -0
  420. package/tui/screens/session_resume_modal.tcss +35 -0
  421. package/tui/screens/sessions.py +166 -0
  422. package/tui/screens/sessions.tcss +119 -0
  423. package/tui/screens/settings.py +254 -0
  424. package/tui/screens/settings.tcss +101 -0
  425. package/tui/screens/store.py +500 -0
  426. package/tui/screens/store.tcss +274 -0
  427. package/tui/screens/tool_content_screen.py +59 -0
  428. package/tui/session_tracker.py +120 -0
  429. package/tui/settings.py +353 -0
  430. package/tui/settings_schema.py +431 -0
  431. package/tui/shell.py +304 -0
  432. package/tui/shell_read.py +42 -0
  433. package/tui/slash_command.py +34 -0
  434. package/tui/tui.tcss +246 -0
  435. package/tui/twitter.py +38 -0
  436. package/tui/version.py +80 -0
  437. package/tui/visuals/__init__.py +0 -0
  438. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  439. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  440. package/tui/visuals/columns.py +273 -0
  441. package/tui/widgets/__init__.py +0 -0
  442. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  443. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  444. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  445. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  446. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  447. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  448. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  449. package/tui/widgets/__pycache__/conversation.cpython-313.pyc +0 -0
  450. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  451. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  452. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  453. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  454. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  455. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  456. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  457. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  458. package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
  459. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  460. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  461. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  462. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  463. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  464. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  465. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  466. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  467. package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
  468. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  469. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  470. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  471. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  472. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  473. package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
  474. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  475. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  476. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  477. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  478. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  479. package/tui/widgets/__pycache__/terminal.cpython-313.pyc +0 -0
  480. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  481. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  482. package/tui/widgets/__pycache__/tool_call.cpython-313.pyc +0 -0
  483. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  484. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  485. package/tui/widgets/acp_content.py +70 -0
  486. package/tui/widgets/agent_response.py +77 -0
  487. package/tui/widgets/agent_thought.py +168 -0
  488. package/tui/widgets/ask_user.py +264 -0
  489. package/tui/widgets/command_pane.py +223 -0
  490. package/tui/widgets/condensed_path.py +115 -0
  491. package/tui/widgets/context_stats.py +66 -0
  492. package/tui/widgets/conversation.py +2441 -0
  493. package/tui/widgets/danger_warning.py +65 -0
  494. package/tui/widgets/diff_view.py +59 -0
  495. package/tui/widgets/directory_input.py +8 -0
  496. package/tui/widgets/flash.py +81 -0
  497. package/tui/widgets/future_text.py +126 -0
  498. package/tui/widgets/grid_select.py +234 -0
  499. package/tui/widgets/highlighted_textarea.py +180 -0
  500. package/tui/widgets/mandelbrot.py +294 -0
  501. package/tui/widgets/markdown_note.py +13 -0
  502. package/tui/widgets/menu.py +147 -0
  503. package/tui/widgets/modified_files.py +106 -0
  504. package/tui/widgets/non_selectable_label.py +5 -0
  505. package/tui/widgets/note.py +18 -0
  506. package/tui/widgets/path_search.py +492 -0
  507. package/tui/widgets/plan.py +263 -0
  508. package/tui/widgets/project_directory_tree.py +90 -0
  509. package/tui/widgets/project_grid_select.py +35 -0
  510. package/tui/widgets/project_summary.py +33 -0
  511. package/tui/widgets/prompt.py +795 -0
  512. package/tui/widgets/question.py +360 -0
  513. package/tui/widgets/session_grid_select.py +224 -0
  514. package/tui/widgets/session_summary.py +115 -0
  515. package/tui/widgets/session_tabs.py +188 -0
  516. package/tui/widgets/shell_result.py +35 -0
  517. package/tui/widgets/shell_terminal.py +40 -0
  518. package/tui/widgets/side_bar.py +114 -0
  519. package/tui/widgets/slash_complete.py +235 -0
  520. package/tui/widgets/strike_text.py +70 -0
  521. package/tui/widgets/subagent.py +87 -0
  522. package/tui/widgets/terminal.py +577 -0
  523. package/tui/widgets/terminal_tool.py +351 -0
  524. package/tui/widgets/throbber.py +100 -0
  525. package/tui/widgets/tool_call.py +368 -0
  526. package/tui/widgets/user_input.py +23 -0
  527. package/tui/widgets/version.py +5 -0
  528. package/tui/widgets/welcome.py +31 -0
@@ -0,0 +1,2441 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from asyncio import Future
5
+
6
+ logger = logging.getLogger("tui.conversation")
7
+ import asyncio
8
+
9
+ from contextlib import suppress
10
+ from functools import partial
11
+ from itertools import filterfalse
12
+ from operator import attrgetter
13
+ from typing import TYPE_CHECKING, Literal
14
+ from pathlib import Path
15
+ from time import monotonic
16
+
17
+ from typing import Callable, Any
18
+
19
+ from rich.segment import Segment
20
+
21
+ from textual import log, on, work
22
+ from textual.app import ComposeResult
23
+ from textual import containers
24
+ from textual import getters
25
+ from textual import events
26
+ from textual.actions import SkipAction
27
+ from textual.binding import Binding
28
+ from textual.content import Content
29
+ from textual.geometry import clamp
30
+ from textual.css.query import NoMatches
31
+ from textual.widget import Widget
32
+ from textual.widgets import Static
33
+ from textual.widgets.markdown import MarkdownBlock, MarkdownFence
34
+ from textual.geometry import Offset, Spacing, Region
35
+ from textual.reactive import var
36
+ from textual.layouts.grid import GridLayout
37
+ from textual.layout import WidgetPlacement
38
+ from textual.strip import Strip
39
+
40
+
41
+ from tui import jsonrpc, messages
42
+ from tui import paths
43
+ from tui import platform_commands
44
+ from tui.message_log import MessageLog
45
+ from tui.agent_schema import Agent as AgentData
46
+ from tui.acp import messages as acp_messages
47
+ from tui.app import A2TUIApp
48
+ from tui.acp import protocol as acp_protocol
49
+ from tui.answer import Answer
50
+ from tui.agent import AgentBase, AgentReady, AgentFail
51
+ from tui.format_path import format_path
52
+ from tui.directory_watcher import DirectoryWatcher, DirectoryChanged
53
+ from tui.history import History
54
+ from tui.widgets.flash import Flash
55
+ from tui.widgets.menu import Menu
56
+ from tui.widgets.note import Note
57
+ from tui.widgets.prompt import Prompt
58
+ from tui.widgets.session_tabs import SessionsTabs
59
+ from tui.widgets.terminal import Terminal
60
+ from tui.widgets.throbber import Throbber
61
+ from tui.widgets.user_input import UserInput
62
+ from tui.shell import Shell, CurrentWorkingDirectoryChanged
63
+ from tui.slash_command import SlashCommand
64
+ from tui.protocol import BlockProtocol, MenuProtocol, ExpandProtocol
65
+ from tui.menus import MenuItem
66
+ from tui.widgets.shell_terminal import ShellTerminal
67
+ from tui.widgets.ask_user import AskUserWidget, AskUserSubmitted
68
+
69
+ if TYPE_CHECKING:
70
+ from collections.abc import Awaitable, Callable
71
+ from tui.acp.agent import Mode
72
+ from tui.widgets.terminal import Terminal
73
+ from tui.widgets.agent_response import AgentResponse
74
+ from tui.widgets.agent_thought import AgentThought
75
+ from tui.widgets.terminal_tool import TerminalTool
76
+
77
+
78
+ AGENT_FAIL_HELP = {
79
+ "fail": """\
80
+ ## Agent failed to run
81
+
82
+ **The agent failed to start.**
83
+
84
+ Check that the agent is installed and up-to-date.
85
+
86
+ Note that some agents require an ACP adapter to be installed to work with A2TUI.
87
+
88
+ - Exit the app, and run `tui` again
89
+ - Select the agent and hit ENTER
90
+ - Click the dropdown, select "Install"
91
+ - Click the GO button
92
+ - Repeat the process to install an ACP adapter (if required)
93
+
94
+ Some agents may require you to restart your shell (open a new terminal) after installing.
95
+
96
+ If that fails, ask for help in [Discussions](https://github.com/batrachianai/tui/discussions)!
97
+ """,
98
+ "no_resume": """\
99
+ ## Agent does not support resume
100
+
101
+ The agent or ACP adapter does not support resuming sessions.
102
+
103
+ Try updating to see if support has been added.
104
+
105
+ - Exit the app, and run `tui` again
106
+ - Select the agent and hit ENTER
107
+ - Click the dropdown, select "Update" or "Install" again
108
+ - Repeat the process to update the ACP adapter (if required)
109
+
110
+ If that fails, ask for help in [Discussions](https://github.com/batrachianai/tui/discussions)!
111
+ """,
112
+ }
113
+
114
+ HELP_URL = "https://github.com/batrachianai/tui/discussions"
115
+
116
+ INTERNAL_EROR = f"""\
117
+ ## Internal error
118
+
119
+ The agent reported an internal error:
120
+
121
+ ```
122
+ $ERROR
123
+ ```
124
+
125
+ This is likely an issue with the agent, and not A2TUI.
126
+
127
+ - Try the prompt again
128
+ - Report the issue to the Agent developer
129
+
130
+ Ask on {HELP_URL} if you need assistance.
131
+
132
+ """
133
+
134
+ STOP_REASON_MAX_TOKENS = f"""\
135
+ ## Maximum tokens reached
136
+
137
+ $AGENT reported that your account is out of tokens.
138
+
139
+ - You may need to purchase additional tokens, or fund your account.
140
+ - If your account has tokens, try running any login or auth process again.
141
+
142
+ If that fails, ask on {HELP_URL}
143
+ """
144
+
145
+ STOP_REASON_MAX_TURN_REQUESTS = f"""\
146
+ ## Maximum model requests reached
147
+
148
+ $AGENT has exceeded the maximum number of model requests in a single turn.
149
+
150
+ Need help? Ask on {HELP_URL}
151
+ """
152
+
153
+ STOP_REASON_REFUSAL = f"""\
154
+ ## Agent refusal
155
+
156
+ $AGENT has refused to continue.
157
+
158
+ Need help? Ask on {HELP_URL}
159
+ """
160
+
161
+
162
+ class Loading(Static):
163
+ """Tiny widget to show loading indicator."""
164
+
165
+ DEFAULT_CLASSES = "block"
166
+ DEFAULT_CSS = """
167
+ Loading {
168
+ height: auto;
169
+ }
170
+ """
171
+
172
+
173
+ class Cursor(Static):
174
+ """The block 'cursor' -- A vertical line to the left of a block in the conversation that
175
+ is used to navigate the discussion history.
176
+ """
177
+
178
+ follow_widget: var[Widget | None] = var(None)
179
+ blink = var(True, toggle_class="-blink")
180
+
181
+ def on_mount(self) -> None:
182
+ self.visible = False
183
+ self.blink_timer = self.set_interval(0.5, self._update_blink, pause=True)
184
+
185
+ def _update_blink(self) -> None:
186
+ if self.query_ancestor(Window).has_focus and self.screen.is_active:
187
+ self.blink = not self.blink
188
+ else:
189
+ self.blink = False
190
+
191
+ def watch_follow_widget(self, widget: Widget | None) -> None:
192
+ self.visible = widget is not None
193
+
194
+ def update_follow(self) -> None:
195
+ if self.follow_widget and self.follow_widget.is_attached:
196
+ self.styles.height = max(1, self.follow_widget.outer_size.height)
197
+ follow_y = (
198
+ self.follow_widget.virtual_region.y
199
+ + self.follow_widget.parent.virtual_region.y
200
+ )
201
+ self.offset = Offset(0, follow_y)
202
+ else:
203
+ self.styles.height = None
204
+
205
+ def follow(self, widget: Widget | None) -> None:
206
+ self.follow_widget = widget
207
+ self.blink = False
208
+ if widget is None:
209
+ self.visible = False
210
+ self.blink_timer.reset()
211
+ self.blink_timer.pause()
212
+ self.styles.height = None
213
+ else:
214
+ self.visible = True
215
+ self.blink_timer.reset()
216
+ self.blink_timer.resume()
217
+ self.update_follow()
218
+
219
+
220
+ class Contents(containers.VerticalGroup, can_focus=False):
221
+ BLANK = True
222
+
223
+ def process_layout(
224
+ self, placements: list[WidgetPlacement]
225
+ ) -> list[WidgetPlacement]:
226
+ if placements:
227
+ last_placement = placements[-1]
228
+ top, right, _bottom, left = last_placement.margin
229
+ placements[-1] = last_placement._replace(
230
+ margin=Spacing(top, right, 0, left)
231
+ )
232
+ return placements
233
+
234
+
235
+ class ContentsGrid(containers.Grid):
236
+ BLANK = True
237
+
238
+ def pre_layout(self, layout) -> None:
239
+ assert isinstance(layout, GridLayout)
240
+ layout.stretch_height = True
241
+
242
+
243
+ class CursorContainer(containers.Vertical):
244
+ def render_lines(self, crop: Region) -> list[Strip]:
245
+ rich_style = self.visual_style.rich_style
246
+ strips = [Strip([Segment("▌", rich_style)], cell_length=1)] * crop.height
247
+ if crop.y == 0 and strips:
248
+ strips[0] = Strip([Segment(" ", rich_style)], cell_length=1)
249
+
250
+ return strips
251
+
252
+
253
+ class Window(containers.VerticalScroll):
254
+ HELP = """\
255
+ ## Conversation
256
+
257
+ This is a view of your conversation with the agent.
258
+
259
+ - **cursor keys** Scroll
260
+ - **ctrl+j / ctrl+k** Navigate content
261
+ - **start typing** Focus the prompt
262
+ """
263
+ BINDING_GROUP_TITLE = "View"
264
+ BINDINGS = [Binding("end", "screen.focus_prompt", "Prompt")]
265
+
266
+ def update_node_styles(self, animate: bool = True) -> None:
267
+ pass
268
+
269
+
270
+ class Conversation(containers.Vertical):
271
+ """Holds the agent conversation (input, output, and various controls / information)."""
272
+
273
+ BLANK = True
274
+ BINDING_GROUP_TITLE = "Conversation"
275
+ CURSOR_BINDING_GROUP = Binding.Group(description="Cursor")
276
+ BINDINGS = [
277
+ Binding(
278
+ "ctrl+j",
279
+ "cursor_down",
280
+ "Block cursor down",
281
+ priority=True,
282
+ group=CURSOR_BINDING_GROUP,
283
+ ),
284
+ Binding(
285
+ "ctrl+k",
286
+ "cursor_up",
287
+ "Block cursor up",
288
+ priority=True,
289
+ group=CURSOR_BINDING_GROUP,
290
+ ),
291
+ Binding(
292
+ "enter",
293
+ "select_block",
294
+ "Select",
295
+ tooltip="Select this block",
296
+ ),
297
+ Binding(
298
+ "ctrl+x",
299
+ "toggle_expand",
300
+ "Toggle expand",
301
+ tooltip="Toggle expand/collapse cursor block",
302
+ priority=True,
303
+ ),
304
+ Binding(
305
+ "escape",
306
+ "cancel",
307
+ "Cancel",
308
+ tooltip="Cancel agent's turn",
309
+ ),
310
+ Binding(
311
+ "ctrl+f",
312
+ "focus_terminal",
313
+ "Focus",
314
+ tooltip="Focus the active terminal",
315
+ priority=True,
316
+ ),
317
+ Binding(
318
+ "ctrl+o",
319
+ "mode_switcher",
320
+ "Modes",
321
+ tooltip="Open the mode switcher",
322
+ ),
323
+ Binding(
324
+ "ctrl+c",
325
+ "interrupt",
326
+ "Interrupt",
327
+ tooltip="Interrupt running command",
328
+ ),
329
+ ]
330
+
331
+ busy_count = var(0)
332
+ cursor_offset = var(-1, init=False)
333
+ project_path = var("")
334
+ working_directory: var[str] = var("")
335
+ _blocks: var[list[MarkdownBlock] | None] = var(None)
336
+
337
+ throbber: getters.query_one[Throbber] = getters.query_one("#throbber")
338
+ contents = getters.query_one(Contents)
339
+ window = getters.query_one(Window)
340
+ cursor = getters.query_one(Cursor)
341
+ prompt = getters.query_one(Prompt)
342
+ app = getters.app(A2TUIApp)
343
+
344
+ _shell: var[Shell | None] = var(None)
345
+ shell_history_index: var[int] = var(0, init=False)
346
+ prompt_history_index: var[int] = var(0, init=False)
347
+
348
+ agent: var[AgentBase | None] = var(None, bindings=True)
349
+ agent_info: var[Content] = var(Content())
350
+ agent_ready: var[bool] = var(False)
351
+ modes: var[dict[str, Mode]] = var({}, bindings=True)
352
+ current_mode: var[Mode | None] = var(None)
353
+ turn: var[Literal["agent", "client"] | None] = var(None, bindings=True)
354
+ status: var[str] = var("")
355
+ column: var[bool] = var(False, toggle_class="-column")
356
+
357
+ title = var("")
358
+ _agent_session_title: var[str] = var("")
359
+
360
+ def __init__(
361
+ self,
362
+ project_path: Path,
363
+ agent: AgentData | None = None,
364
+ agent_session_id: str | None = None,
365
+ session_pk: int | None = None,
366
+ initial_prompt: str | None = None,
367
+ ) -> None:
368
+ super().__init__()
369
+
370
+ project_path = project_path.resolve().absolute()
371
+
372
+ self.set_reactive(Conversation.project_path, project_path)
373
+ self.set_reactive(Conversation.working_directory, str(project_path))
374
+ self.agent_slash_commands: list[SlashCommand] = []
375
+ self.terminals: dict[str, TerminalTool] = {}
376
+ self._loading: Loading | None = None
377
+ self._agent_response: AgentResponse | None = None
378
+ self._agent_thought: AgentThought | None = None
379
+ self._replay: bool = False
380
+ self._replay_buffer: list[dict] = []
381
+ self._last_escape_time: float = monotonic()
382
+ self._agent_data = agent
383
+ self._agent_session_id = agent_session_id
384
+ self._session_pk = session_pk
385
+ self._agent_fail = False
386
+ self._mouse_down_offset: Offset | None = None
387
+
388
+ self._focusable_terminals: list[Terminal] = []
389
+
390
+ self.project_data_path = paths.get_project_data(project_path)
391
+ self.shell_history = History(self.project_data_path / "shell_history.jsonl")
392
+ self.prompt_history = History(self.project_data_path / "prompt_history.jsonl")
393
+
394
+ self.session_start_time: float | None = None
395
+ self._terminal_count = 0
396
+ self._require_check_prune = False
397
+
398
+ self._turn_count = 0
399
+ self._shell_count = 0
400
+ self._tool_call_total = 0
401
+ self._tool_call_success = 0
402
+ self._tool_call_failed = 0
403
+ self._tool_call_finalized: set[str] = set()
404
+
405
+ self._directory_changed = False
406
+ self._directory_watcher: DirectoryWatcher | None = None
407
+
408
+ self._initial_prompt = initial_prompt
409
+
410
+ self._msg_log: MessageLog | None = None
411
+
412
+ self._post_lock = asyncio.Lock()
413
+ self._auto_named_session = False
414
+
415
+ def update_title(self) -> None:
416
+ """Update the screen title."""
417
+ screen = self.screen
418
+ if self._agent_session_title:
419
+ screen.title = self._agent_session_title
420
+ elif agent_title := self.agent_title:
421
+ project_path = format_path(self.project_path)
422
+ screen.title = f"{agent_title} {project_path}"
423
+ else:
424
+ screen.title = ""
425
+
426
+ @property
427
+ def agent_title(self) -> str | None:
428
+ if self._agent_data is not None:
429
+ return self._agent_data["name"]
430
+ return None
431
+
432
+ @property
433
+ def is_watching_directory(self) -> bool:
434
+ """Is the directory watcher enabled and watching?"""
435
+ if self._directory_watcher is None:
436
+ return False
437
+ return self._directory_watcher.enabled
438
+
439
+ def validate_shell_history_index(self, index: int) -> int:
440
+ return clamp(index, -self.shell_history.size, 0)
441
+
442
+ def validate_prompt_history_index(self, index: int) -> int:
443
+ return clamp(index, -self.prompt_history.size, 0)
444
+
445
+ def shell_complete(self, prefix: str) -> list[str]:
446
+ completes = self.shell_history.complete(prefix)
447
+ return completes
448
+
449
+ def insert_path_into_prompt(self, path: Path) -> None:
450
+ try:
451
+ insert_path_text = str(path.relative_to(self.project_path))
452
+ except Exception:
453
+ self.app.bell()
454
+ return
455
+
456
+ insert_text = (
457
+ f'@"{insert_path_text}"'
458
+ if " " in insert_path_text
459
+ else f"@{insert_path_text}"
460
+ )
461
+ self.prompt.prompt_text_area.insert(insert_text)
462
+ self.prompt.prompt_text_area.insert(" ")
463
+
464
+ def watch_project_path(self, path: Path) -> None:
465
+ self.post_message(messages.SessionUpdate(path=str(path)))
466
+
467
+ async def watch_shell_history_index(self, previous_index: int, index: int) -> None:
468
+ if previous_index == 0:
469
+ self.shell_history.current = self.prompt.text
470
+ try:
471
+ history_entry = await self.shell_history.get_entry(index)
472
+ except IndexError:
473
+ pass
474
+ else:
475
+ self.prompt.text = history_entry["input"]
476
+ self.prompt.shell_mode = True
477
+
478
+ async def watch_prompt_history_index(self, previous_index: int, index: int) -> None:
479
+ if previous_index == 0:
480
+ self.prompt_history.current = self.prompt.text
481
+ try:
482
+ history_entry = await self.prompt_history.get_entry(index)
483
+ except IndexError:
484
+ pass
485
+ else:
486
+ self.prompt.text = history_entry["input"]
487
+
488
+ def watch_turn(self, turn: str) -> None:
489
+ if turn == "client":
490
+ self.post_message(messages.SessionUpdate(state="idle"))
491
+ elif turn == "agent":
492
+ self.post_message(messages.SessionUpdate(state="busy"))
493
+
494
+ @on(events.Key)
495
+ async def on_key(self, event: events.Key):
496
+ if (
497
+ event.character is not None
498
+ and event.is_printable
499
+ and (event.character.isalnum() or event.character in "$/!")
500
+ and self.window.has_focus
501
+ ):
502
+ event.stop()
503
+ self.prompt.focus()
504
+ self.prompt.prompt_text_area.post_message(event)
505
+
506
+ def compose(self) -> ComposeResult:
507
+ yield Throbber(id="throbber")
508
+ yield SessionsTabs()
509
+ with Window():
510
+ with ContentsGrid():
511
+ with CursorContainer(id="cursor-container"):
512
+ yield Cursor()
513
+ yield Contents(id="contents")
514
+ yield Flash()
515
+ yield Prompt(complete_callback=self.shell_complete).data_bind(
516
+ project_path=Conversation.project_path,
517
+ working_directory=Conversation.working_directory,
518
+ agent_info=Conversation.agent_info,
519
+ agent_ready=Conversation.agent_ready,
520
+ current_mode=Conversation.current_mode,
521
+ modes=Conversation.modes,
522
+ status=Conversation.status,
523
+ )
524
+
525
+ @property
526
+ def _terminal(self) -> Terminal | None:
527
+ """Return the last focusable terminal, if there is one.
528
+
529
+ Returns:
530
+ A focusable (non finalized) terminal.
531
+ """
532
+ # Terminals should be removed in response to the Terminal.FInalized message
533
+ # This is a bit of a sanity check
534
+ self._focusable_terminals[:] = list(
535
+ filterfalse(attrgetter("is_finalized"), self._focusable_terminals)
536
+ )
537
+
538
+ for terminal in reversed(self._focusable_terminals):
539
+ if terminal.display:
540
+ return terminal
541
+ return None
542
+
543
+ def add_focusable_terminal(self, terminal: Terminal) -> None:
544
+ """Add a focusable terminal.
545
+
546
+ Args:
547
+ terminal: Terminal instance.
548
+ """
549
+ if not terminal.is_finalized:
550
+ self._focusable_terminals.append(terminal)
551
+
552
+ @on(ShellTerminal.Interrupt)
553
+ async def on_shell_terminal_terminate(self, event: ShellTerminal.Terminate) -> None:
554
+ if not event.teminal.is_finalized:
555
+ await self.shell.interrupt()
556
+ self.cursor_offset = -1
557
+ self.flash("Command interrupted", style="success")
558
+
559
+ @on(DirectoryChanged)
560
+ def on_directory_changed(self, event: DirectoryChanged) -> None:
561
+ event.stop()
562
+ self.post_message(messages.ProjectDirectoryUpdated())
563
+
564
+ @on(Terminal.Finalized)
565
+ def on_terminal_finalized(self, event: Terminal.Finalized) -> None:
566
+ """Terminal was finalized, so we can remove it from the list."""
567
+ try:
568
+ self._focusable_terminals.remove(event.terminal)
569
+ except ValueError:
570
+ pass
571
+
572
+ if self._directory_changed or not self.is_watching_directory:
573
+ self.prompt.project_directory_updated()
574
+ self._directory_changed = False
575
+ self.post_message(messages.ProjectDirectoryUpdated())
576
+
577
+ @on(Terminal.LongRunning)
578
+ def on_terminal_long_running(self, event: Terminal.LongRunning) -> None:
579
+ if (
580
+ not event.terminal.is_finalized
581
+ and not event.terminal.has_focus
582
+ and not event.terminal.state.buffer.is_blank
583
+ ):
584
+ self.flash("Press [b]ctrl+f[/b] to focus command", style="default")
585
+
586
+ @on(Terminal.AlternateScreenChanged)
587
+ def on_terminal_alternate_screen_(
588
+ self, event: Terminal.AlternateScreenChanged
589
+ ) -> None:
590
+ """A terminal enabled or disabled alternate screen."""
591
+ if event.enabled:
592
+ event.terminal.focus()
593
+ else:
594
+ self.focus_prompt()
595
+
596
+ @on(events.DescendantFocus, "Terminal")
597
+ def on_terminal_focus(self, event: events.DescendantFocus) -> None:
598
+ self.flash("Press [b]escape[/b] [i]twice[/] to exit terminal", style="success")
599
+
600
+ @on(events.DescendantBlur, "Terminal")
601
+ def on_terminal_blur(self, event: events.DescendantFocus) -> None:
602
+ self.focus_prompt()
603
+
604
+ @on(Terminal.CancelRequested)
605
+ async def on_terminal_cancel_requested(self, event: Terminal.CancelRequested) -> None:
606
+ event.stop()
607
+ if (agent := self.agent) is not None:
608
+ cancelled = await agent.cancel()
609
+ if cancelled:
610
+ self.flash("Turn cancelled", style="success")
611
+ else:
612
+ self.flash("Cancel requested", style="success")
613
+
614
+ @on(messages.Flash)
615
+ def on_flash(self, event: messages.Flash) -> None:
616
+ event.stop()
617
+ self.flash(event.content, duration=event.duration, style=event.style)
618
+
619
+ def flash(
620
+ self,
621
+ content: str | Content,
622
+ *,
623
+ duration: float | None = None,
624
+ style: Literal["default", "warning", "error", "success"] = "default",
625
+ ) -> None:
626
+ """Flash a single-line message to the user.
627
+
628
+ Args:
629
+ content: Content to flash.
630
+ style: A semantic style.
631
+ duration: Duration in seconds of the flash, or `None` to use default in settings.
632
+ """
633
+ self.query_one(Flash).flash(content, duration=duration, style=style)
634
+
635
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
636
+ if action == "focus_terminal":
637
+ return None if self._terminal is None else True
638
+ if action == "mode_switcher":
639
+ return bool(self.modes)
640
+ if action == "cancel":
641
+ return True if (self.agent and self.turn == "agent") else None
642
+ if action in {"expand_block", "collapse_block", "toggle_expand"}:
643
+ if (cursor_block := self.cursor_block) is None:
644
+ return False
645
+ elif isinstance(cursor_block, ExpandProtocol):
646
+ if action == "expand_block":
647
+ return False if cursor_block.is_block_expanded() else True
648
+ elif action == "collapse_block":
649
+ return True if cursor_block.is_block_expanded() else False
650
+ return cursor_block.can_expand()
651
+ return None if action == "expand_block" else False
652
+
653
+ return True
654
+
655
+ async def action_focus_terminal(self) -> None:
656
+ if self._terminal is not None:
657
+ self._terminal.focus()
658
+ else:
659
+ self.flash("Nothing to focus...", style="error")
660
+
661
+ async def action_expand_block(self) -> None:
662
+ if (cursor_block := self.cursor_block) is not None:
663
+ if isinstance(cursor_block, ExpandProtocol):
664
+ cursor_block.expand_block()
665
+ self.refresh_bindings()
666
+ self.call_after_refresh(self.cursor.follow, cursor_block)
667
+
668
+ async def action_collapse_block(self) -> None:
669
+ if (cursor_block := self.cursor_block) is not None:
670
+ if isinstance(cursor_block, ExpandProtocol):
671
+ cursor_block.collapse_block()
672
+ self.refresh_bindings()
673
+ self.call_after_refresh(self.cursor.follow, cursor_block)
674
+
675
+ async def action_toggle_expand(self) -> None:
676
+ if (cursor_block := self.cursor_block) is not None:
677
+ if isinstance(cursor_block, ExpandProtocol):
678
+ if cursor_block.is_block_expanded():
679
+ cursor_block.collapse_block()
680
+ else:
681
+ cursor_block.expand_block()
682
+ self.refresh_bindings()
683
+ self.call_after_refresh(self.cursor.follow, cursor_block)
684
+
685
+ async def post_agent_response(self, fragment: str = "") -> AgentResponse | None:
686
+ """Get or create an agent response widget."""
687
+ from tui.widgets.agent_response import AgentResponse
688
+
689
+ async with self._post_lock:
690
+ if self._agent_response is None:
691
+ self._agent_response = agent_response = AgentResponse(fragment)
692
+ await self.post(agent_response, new_block=False)
693
+ else:
694
+ await self._agent_response.append_fragment(fragment)
695
+ return self._agent_response
696
+
697
+ def _complete_thought(self) -> None:
698
+ """Mark the current thought widget as completed and drop the reference."""
699
+ if self._agent_thought is not None:
700
+ self._agent_thought.mark_completed()
701
+ self._agent_thought = None
702
+
703
+ async def post_agent_thought(self, thought_fragment: str, *, replay: bool = False) -> AgentThought | None:
704
+ """Get or create an agent thought widget."""
705
+ from tui.widgets.agent_thought import AgentThought
706
+
707
+ async with self._post_lock:
708
+ if self._agent_thought is None:
709
+ if thought_fragment.strip():
710
+ self._agent_thought = AgentThought(thought_fragment, replay=replay)
711
+ await self.post(self._agent_thought, new_block=False)
712
+ else:
713
+ await self._agent_thought.append_fragment(thought_fragment)
714
+ return self._agent_thought
715
+
716
+ @property
717
+ def cursor_block(self) -> Widget | None:
718
+ """The block next to the cursor, or `None` if no block cursor."""
719
+ if self.cursor_offset == -1 or not self.contents.displayed_children:
720
+ return None
721
+ try:
722
+ block_widget = self.contents.displayed_children[self.cursor_offset]
723
+ except IndexError:
724
+ return None
725
+ return block_widget
726
+
727
+ @property
728
+ def cursor_block_child(self) -> Widget | None:
729
+ if (cursor_block := self.cursor_block) is not None:
730
+ if isinstance(cursor_block, BlockProtocol):
731
+ return cursor_block.get_cursor_block()
732
+ return cursor_block
733
+
734
+ def get_cursor_block[BlockType](
735
+ self, block_type: type[BlockType] = Widget
736
+ ) -> BlockType | None:
737
+ """Get the cursor block if it matches a type.
738
+
739
+ Args:
740
+ block_type: The expected type.
741
+
742
+ Returns:
743
+ The widget next to the cursor, or `None` if the types don't match.
744
+ """
745
+ cursor_block = self.cursor_block_child
746
+ if isinstance(cursor_block, block_type):
747
+ return cursor_block
748
+ return None
749
+
750
+ @on(AgentReady)
751
+ async def on_agent_ready(self) -> None:
752
+ self.session_start_time = monotonic()
753
+ if self.agent is not None:
754
+ content = Content.assemble(self.agent.get_info(), " connected")
755
+ self.flash(content, style="success")
756
+ if self._agent_data is not None:
757
+ self.app.capture_event(
758
+ "agent-session-begin",
759
+ agent=self._agent_data["identity"],
760
+ )
761
+ session_id = getattr(self.agent, "session_id", None) or self._agent_session_id or "unknown"
762
+ self._msg_log = MessageLog(
763
+ session_id=session_id,
764
+ agent_name=self.agent_title or "unknown",
765
+ )
766
+
767
+ self.agent_ready = True
768
+
769
+ async def on_unmount(self) -> None:
770
+ if self._msg_log is not None:
771
+ self._msg_log.close()
772
+ if self._directory_watcher is not None:
773
+ self._directory_watcher.stop()
774
+ if self.agent is not None:
775
+ await self.agent.stop()
776
+
777
+ if self._agent_data is not None and self.session_start_time is not None:
778
+ session_time = monotonic() - self.session_start_time
779
+ await self.app.capture_event(
780
+ "agent-session-end",
781
+ agent=self._agent_data["identity"],
782
+ duration=session_time,
783
+ agent_session_fail=self._agent_fail,
784
+ shell_count=self._shell_count,
785
+ turn_count=self._turn_count,
786
+ ).wait()
787
+
788
+ session_id = getattr(self.agent, "session_id", None) or self._agent_session_id or ""
789
+ self.app._exit_metrics = {
790
+ "session_id": session_id,
791
+ "tool_call_total": self._tool_call_total,
792
+ "tool_call_success": self._tool_call_success,
793
+ "tool_call_failed": self._tool_call_failed,
794
+ "turn_count": self._turn_count,
795
+ "shell_count": self._shell_count,
796
+ "wall_time": session_time,
797
+ "agent_title": self.agent_title or "Agent",
798
+ }
799
+
800
+ @on(AgentFail)
801
+ async def on_agent_fail(self, message: AgentFail) -> None:
802
+ self.agent_ready = True
803
+ self._agent_fail = True
804
+ if self._msg_log is not None:
805
+ self._msg_log.error(message.message, turn=self._turn_count, details=message.details)
806
+ self.notify(message.message, title="Agent failure", severity="error", timeout=5)
807
+
808
+ if self._agent_data is not None:
809
+ self.app.capture_event(
810
+ "agent-session-error",
811
+ agent=self._agent_data["identity"],
812
+ message=message.message,
813
+ details=message.details,
814
+ )
815
+
816
+ if message.message:
817
+ error = Content.assemble(
818
+ Content.from_markup(message.message).stylize("$text-error"),
819
+ " — ",
820
+ Content.from_markup(message.details.strip()).stylize("dim"),
821
+ )
822
+ else:
823
+ error = Content.from_markup(message.details.strip()).stylize("$text-error")
824
+ await self.post(Note(error, classes="-error"))
825
+
826
+ from tui.widgets.markdown_note import MarkdownNote
827
+
828
+ if message.help in AGENT_FAIL_HELP:
829
+ help = AGENT_FAIL_HELP[message.help]
830
+ else:
831
+ help = AGENT_FAIL_HELP["fail"]
832
+
833
+ await self.post(MarkdownNote(help))
834
+
835
+ @on(messages.WorkStarted)
836
+ def on_work_started(self) -> None:
837
+ self.busy_count += 1
838
+
839
+ @on(messages.WorkFinished)
840
+ def on_work_finished(self) -> None:
841
+ self.busy_count -= 1
842
+
843
+ @work
844
+ @on(messages.ChangeMode)
845
+ async def on_change_mode(self, event: messages.ChangeMode) -> None:
846
+ await self.set_mode(event.mode_id)
847
+
848
+ @on(acp_messages.ModeUpdate)
849
+ def on_mode_update(self, event: acp_messages.ModeUpdate) -> None:
850
+ if (modes := self.modes) is not None:
851
+ if (mode := modes.get(event.current_mode)) is not None:
852
+ self.current_mode = mode
853
+
854
+ @on(messages.UserInputSubmitted)
855
+ async def on_user_input_submitted(self, event: messages.UserInputSubmitted) -> None:
856
+ if not event.body.strip():
857
+ return
858
+ if event.shell:
859
+ if await self.shell.is_busy():
860
+ if self.shell.terminal is not None:
861
+ self.shell.terminal.focus(scroll_visible=False)
862
+ await self.shell.send_input(event.body, paste=True)
863
+ else:
864
+ await self.shell_history.append(event.body)
865
+ self.shell_history_index = 0
866
+ await self.post_shell(event.body)
867
+ self.window.scroll_end(animate=False)
868
+ elif text := event.body.strip():
869
+ await self.prompt_history.append(event.body)
870
+ self.prompt_history_index = 0
871
+ if text.startswith("/") and await self.slash_command(text):
872
+ # A2TUI has processed the slash command.
873
+ return
874
+ await self.post(UserInput(text))
875
+ self.window.scroll_end(animate=False)
876
+ self._loading = await self.post(Loading("Please wait..."), loading=True)
877
+ await asyncio.sleep(0)
878
+ if self._msg_log is not None:
879
+ self._msg_log.user_input(text, self._turn_count)
880
+ self.send_prompt_to_agent(text)
881
+
882
+ async def _auto_name_session(self, prompt: str) -> None:
883
+ if self._auto_named_session or self.agent is None:
884
+ return
885
+
886
+ if self._agent_session_title not in ("", "New Session", "Untitled"):
887
+ self._auto_named_session = True
888
+ return
889
+
890
+ self._auto_named_session = True
891
+ name = prompt.strip().split("\n")[0][:60].strip()
892
+ if name:
893
+ await self.agent.set_session_name(name)
894
+
895
+ @work
896
+ async def send_prompt_to_agent(self, prompt: str) -> None:
897
+ if self.agent is not None:
898
+ await self._auto_name_session(prompt)
899
+ stop_reason: str | None = None
900
+ self.busy_count += 1
901
+ try:
902
+ self.turn = "agent"
903
+ stop_reason = await self.agent.send_prompt(prompt)
904
+ except jsonrpc.APIError as error:
905
+ from tui.widgets.markdown_note import MarkdownNote
906
+
907
+ self.turn = "client"
908
+ if self._msg_log is not None:
909
+ self._msg_log.error(str(error), turn=self._turn_count)
910
+
911
+ message = error.message or "no details were provided"
912
+
913
+ await self.post(
914
+ MarkdownNote(
915
+ INTERNAL_EROR.replace("$ERROR", message),
916
+ classes="-stop-reason",
917
+ )
918
+ )
919
+ finally:
920
+ self.busy_count -= 1
921
+ self.call_later(self.agent_turn_over, stop_reason)
922
+
923
+ async def agent_turn_over(self, stop_reason: str | None) -> None:
924
+ """Called when the agent's turn is over.
925
+
926
+ Args:
927
+ stop_reason: The stop reason returned from the Agent, or `None`.
928
+ """
929
+ self.turn = "client"
930
+ if self._loading is not None:
931
+ await self._loading.remove()
932
+ self._agent_response = None
933
+ self._complete_thought()
934
+
935
+ if self._directory_changed or not self.is_watching_directory:
936
+ self._directory_changed = False
937
+ self.post_message(messages.ProjectDirectoryUpdated())
938
+ self.prompt.project_directory_updated()
939
+
940
+ self._turn_count += 1
941
+
942
+ if self._msg_log is not None:
943
+ self._msg_log.turn_end(self._turn_count, stop_reason)
944
+
945
+ self.post_message(messages.SessionUpdate(state="idle"))
946
+
947
+ if stop_reason != "end_turn":
948
+ from tui.widgets.markdown_note import MarkdownNote
949
+
950
+ agent = (self.agent_title or "agent").title()
951
+
952
+ if stop_reason == "max_tokens":
953
+ await self.post(
954
+ MarkdownNote(
955
+ STOP_REASON_MAX_TOKENS.replace("$AGENT", agent),
956
+ classes="-stop-reason",
957
+ )
958
+ )
959
+ elif stop_reason == "max_turn_requests":
960
+ await self.post(
961
+ MarkdownNote(
962
+ STOP_REASON_MAX_TURN_REQUESTS.replace("$AGENT", agent),
963
+ classes="-stop-reason",
964
+ )
965
+ )
966
+ elif stop_reason == "refusal":
967
+ await self.post(
968
+ MarkdownNote(
969
+ STOP_REASON_REFUSAL.replace("$AGENT", agent),
970
+ classes="-stop-reason",
971
+ )
972
+ )
973
+
974
+ if self.app.settings.get("notifications.turn_over", bool):
975
+ self.app.system_notify(
976
+ f"{self.agent_title} has finished working",
977
+ title="Waiting for input",
978
+ sound="turn-over",
979
+ )
980
+
981
+ @on(Menu.OptionSelected)
982
+ async def on_menu_option_selected(self, event: Menu.OptionSelected) -> None:
983
+ event.stop()
984
+ event.menu.display = False
985
+ if event.action is not None:
986
+ await self.run_action(event.action, {"block": event.owner})
987
+ if (cursor_block := self.get_cursor_block()) is not None:
988
+ self.call_after_refresh(self.cursor.follow, cursor_block)
989
+ self.call_after_refresh(event.menu.remove)
990
+
991
+ @on(Menu.Dismissed)
992
+ async def on_menu_dismissed(self, event: Menu.Dismissed) -> None:
993
+ event.stop()
994
+ if event.menu.has_focus:
995
+ self.window.focus(scroll_visible=False)
996
+ await event.menu.remove()
997
+
998
+ @on(CurrentWorkingDirectoryChanged)
999
+ def on_current_working_directory_changed(
1000
+ self, event: CurrentWorkingDirectoryChanged
1001
+ ) -> None:
1002
+ self.working_directory = str(Path(event.path).resolve().absolute())
1003
+
1004
+ async def watch_busy_count(self, busy: int) -> None:
1005
+ if (throbber := self.query_one_optional("#throbber")) is not None:
1006
+ throbber.set_class(busy > 0, "-busy")
1007
+
1008
+ @on(acp_messages.ContextUpdate)
1009
+ async def on_context_update(self, message: acp_messages.ContextUpdate):
1010
+ message.stop()
1011
+ from tui.widgets.context_stats import ContextStats
1012
+ if stats := self.screen.query_one_optional("#context-stats", ContextStats):
1013
+ stats.update_stats(message.used, message.size)
1014
+
1015
+ @on(acp_messages.UpdateStatusLine)
1016
+ async def on_update_status_line(self, message: acp_messages.UpdateStatusLine):
1017
+ self.status = message.status_line
1018
+
1019
+ @on(acp_messages.Update)
1020
+ async def on_acp_agent_message(self, message: acp_messages.Update):
1021
+ message.stop()
1022
+ if self._replay:
1023
+ self._replay_buffer.append({"kind": "agent_text", "text": message.text})
1024
+ return
1025
+ self._complete_thought()
1026
+ if self._msg_log is not None:
1027
+ self._msg_log.agent_output(message.text, self._turn_count)
1028
+ await self.post_agent_response(message.text)
1029
+
1030
+ @on(acp_messages.UserMessage)
1031
+ async def on_acp_user_message(self, message: acp_messages.UserMessage):
1032
+ message.stop()
1033
+ if self._replay:
1034
+ self._replay_buffer.append({"kind": "user", "text": message.text})
1035
+ return
1036
+ self._complete_thought()
1037
+ self._agent_response = None
1038
+ await self.post(UserInput(message.text))
1039
+
1040
+ @on(acp_messages.Thinking)
1041
+ async def on_acp_agent_thinking(self, message: acp_messages.Thinking):
1042
+ message.stop()
1043
+ if self._replay:
1044
+ self._replay_buffer.append({"kind": "thought", "text": message.text})
1045
+ return
1046
+ if self._msg_log is not None:
1047
+ self._msg_log.agent_thought(message.text, self._turn_count)
1048
+ await self.post_agent_thought(message.text, replay=self._replay)
1049
+
1050
+ @on(acp_messages.SessionReplay)
1051
+ def on_acp_session_replay(self, message: acp_messages.SessionReplay):
1052
+ """Mark a session replay boundary so thought chunks are shown as completed."""
1053
+ message.stop()
1054
+ was_replay = self._replay
1055
+ self._replay = message.active
1056
+ # When the replay just ended, flush buffer and complete any pending thought.
1057
+ if was_replay and not message.active:
1058
+ self.call_after_refresh(self._flush_replay_buffer)
1059
+ self._complete_thought()
1060
+
1061
+ @work(thread=False)
1062
+ async def _flush_replay_buffer(self) -> None:
1063
+ """Process all buffered replay entries in one batch mount."""
1064
+ if not self._replay_buffer:
1065
+ return
1066
+ from tui.widgets.agent_response import AgentResponse
1067
+ from tui.widgets.agent_thought import AgentThought
1068
+ from tui.widgets.tool_call import ToolCall
1069
+ from tui.widgets.plan import Plan
1070
+ from tui.widgets.subagent import SubAgent
1071
+
1072
+ created_subagents: dict[str, SubAgent] = {}
1073
+ current_thought: AgentThought | None = None
1074
+ widgets: list[Widget] = []
1075
+
1076
+ for entry in self._replay_buffer:
1077
+ match entry["kind"]:
1078
+ case "user":
1079
+ current_thought = None
1080
+ self._complete_thought()
1081
+ self._agent_response = None
1082
+ widgets.append(UserInput(entry["text"]))
1083
+
1084
+ case "agent_text":
1085
+ current_thought = None
1086
+ self._complete_thought()
1087
+ self._agent_response = None
1088
+ widgets.append(AgentResponse(entry["text"]))
1089
+
1090
+ case "thought":
1091
+ if current_thought is None:
1092
+ current_thought = AgentThought(entry["text"], replay=True)
1093
+ widgets.append(current_thought)
1094
+ else:
1095
+ current_thought.append_fragment(entry["text"])
1096
+
1097
+ case "tool_call":
1098
+ current_thought = None
1099
+ self.new_block()
1100
+ widgets.append(ToolCall(entry["tool_call"], id=entry["tool_id"]))
1101
+
1102
+ case "plan":
1103
+ current_thought = None
1104
+ self.new_block()
1105
+ widgets.append(Plan(entry["entries"]))
1106
+
1107
+ case "subagent_start":
1108
+ current_thought = None
1109
+ self.new_block()
1110
+ sa = SubAgent(entry["agent_type"], tool_id=entry["id"])
1111
+ created_subagents[entry["id"]] = sa
1112
+ widgets.append(sa)
1113
+
1114
+ case "subagent_chunk":
1115
+ sa = created_subagents.get(entry["id"])
1116
+ if sa is not None:
1117
+ sa.append_chunk(entry["text"])
1118
+
1119
+ case "subagent_end":
1120
+ sa = created_subagents.get(entry["id"])
1121
+ if sa is not None:
1122
+ sa.complete()
1123
+
1124
+ case "ask_user":
1125
+ current_thought = None
1126
+ self.new_block()
1127
+ widgets.append(AskUserWidget(
1128
+ entry["tool_id"],
1129
+ question=entry["question"],
1130
+ options=entry.get("options", []),
1131
+ questions=entry.get("questions", []),
1132
+ ))
1133
+
1134
+ if widgets:
1135
+ await self.contents.mount(*widgets)
1136
+ self._replay_buffer.clear()
1137
+ self._complete_thought()
1138
+ self.window.scroll_end(animate=False)
1139
+
1140
+ @on(acp_messages.RequestPermission)
1141
+ async def on_acp_request_permission(self, message: acp_messages.RequestPermission):
1142
+ message.stop()
1143
+ options = [
1144
+ Answer(option["name"], option["optionId"], option["kind"])
1145
+ for option in message.options
1146
+ ]
1147
+ self.request_permissions(
1148
+ message.result_future,
1149
+ options,
1150
+ message.tool_call,
1151
+ )
1152
+ self._agent_response = None
1153
+ self._complete_thought()
1154
+
1155
+ @on(acp_messages.AskUser)
1156
+ async def on_acp_ask_user(self, message: acp_messages.AskUser):
1157
+ message.stop()
1158
+ if self._replay:
1159
+ self._replay_buffer.append({
1160
+ "kind": "ask_user",
1161
+ "tool_id": message.tool_id,
1162
+ "question": message.question,
1163
+ "options": message.options or [],
1164
+ "questions": message.questions or [],
1165
+ })
1166
+ return
1167
+ self.window.scroll_end(animate=False)
1168
+
1169
+ widget = AskUserWidget(
1170
+ message.tool_id,
1171
+ question=message.question,
1172
+ options=message.options or [],
1173
+ questions=message.questions or [],
1174
+ )
1175
+ await self.post(widget, new_block=True)
1176
+ self._agent_response = None
1177
+ self._complete_thought()
1178
+
1179
+ @on(AskUserSubmitted)
1180
+ def on_ask_user_submitted(self, message: AskUserSubmitted) -> None:
1181
+ message.stop()
1182
+ if self.agent is not None:
1183
+ cancelled = message.value == "__cancel__"
1184
+ self.agent.send_ask_user_answer(
1185
+ message.value if not cancelled else "", cancelled=cancelled
1186
+ )
1187
+
1188
+ @on(acp_messages.Plan)
1189
+ async def on_acp_plan(self, message: acp_messages.Plan):
1190
+ if self._replay:
1191
+ self._replay_buffer.append({"kind": "plan", "entries": message.entries})
1192
+ return
1193
+ from tui.widgets.plan import Plan
1194
+
1195
+ entries = [
1196
+ Plan.Entry(
1197
+ Content(entry["content"]),
1198
+ entry.get("priority", "medium"),
1199
+ entry.get("status", "pending"),
1200
+ )
1201
+ for entry in message.entries
1202
+ ]
1203
+
1204
+ # Look up any existing Plan widget anywhere in the conversation
1205
+ # tree — checking only ``children[-1]`` would create duplicates if
1206
+ # a ToolCall/SubAgent was posted after the Plan.
1207
+ try:
1208
+ existing_plan = self.query(Plan).first()
1209
+ except NoMatches:
1210
+ existing_plan = None
1211
+ if existing_plan is not None:
1212
+ if entries:
1213
+ existing_plan.entries = entries
1214
+ else:
1215
+ # An empty snapshot at the start of a turn means "no
1216
+ # plan yet for this turn". Remove the stale inline
1217
+ # widget from the previous turn so we don't keep
1218
+ # rendering its "No plan yet" placeholder once a new
1219
+ # turn has begun.
1220
+ await existing_plan.remove()
1221
+ elif entries:
1222
+ await self.post(Plan(entries))
1223
+
1224
+ @on(acp_messages.ToolCallUpdate)
1225
+ @on(acp_messages.ToolCall)
1226
+ async def on_acp_tool_call_update(
1227
+ self, message: acp_messages.ToolCall | acp_messages.ToolCallUpdate
1228
+ ):
1229
+ from tui.widgets.tool_call import ToolCall
1230
+
1231
+ tool_call = message.tool_call
1232
+ status = tool_call.get("status", "")
1233
+
1234
+ if tool_call.get("title") == "AskUser":
1235
+ return
1236
+ tool_id = message.tool_id
1237
+
1238
+ if self._replay:
1239
+ if isinstance(message, acp_messages.ToolCall):
1240
+ self._replay_buffer.append({"kind": "tool_call", "tool_call": tool_call, "tool_id": tool_id})
1241
+ else:
1242
+ # ToolCallUpdate during replay: merge result content/status/title
1243
+ # into the corresponding buffered tool_call entry.
1244
+ for entry in self._replay_buffer:
1245
+ if entry.get("kind") == "tool_call" and entry.get("tool_id") == tool_id:
1246
+ new_content = tool_call.get("content", [])
1247
+ if new_content:
1248
+ entry["tool_call"]["content"] = new_content
1249
+ if status:
1250
+ entry["tool_call"]["status"] = status
1251
+ new_title = tool_call.get("title", "")
1252
+ if new_title:
1253
+ entry["tool_call"]["title"] = new_title
1254
+ break
1255
+ return
1256
+
1257
+ content = tool_call.get("content", None) or []
1258
+
1259
+ logger.debug(
1260
+ "on_acp_tool_call_update: type=%s id=%s status=%s content_blocks=%d",
1261
+ type(message).__name__, tool_id, status, len(content),
1262
+ )
1263
+
1264
+ if isinstance(message, acp_messages.ToolCall):
1265
+ if self._msg_log is not None:
1266
+ self._msg_log.tool_call(
1267
+ tool_call.get("title", "unknown"),
1268
+ tool_call.get("toolCallId", ""),
1269
+ self._turn_count,
1270
+ )
1271
+ if tool_id not in self._tool_call_finalized:
1272
+ self._tool_call_total += 1
1273
+
1274
+ if status in ("completed", "failed") and tool_id not in self._tool_call_finalized:
1275
+ self._tool_call_finalized.add(tool_id)
1276
+ if self._msg_log is not None:
1277
+ self._msg_log.tool_result(
1278
+ tool_call.get("title", "unknown"),
1279
+ tool_call.get("toolCallId", ""),
1280
+ self._turn_count,
1281
+ status=status,
1282
+ )
1283
+ if status == "completed":
1284
+ self._tool_call_success += 1
1285
+ else:
1286
+ self._tool_call_failed += 1
1287
+
1288
+ if status == "completed":
1289
+ self._complete_thought()
1290
+ self._agent_response = None
1291
+
1292
+ try:
1293
+ existing_tool_call: ToolCall | None = self.contents.get_child_by_id(
1294
+ tool_id, ToolCall
1295
+ )
1296
+ except NoMatches:
1297
+ widget = await self.post(ToolCall(tool_call, id=message.tool_id), new_block=True)
1298
+ await widget.update_tool_call(tool_call)
1299
+ logger.debug(
1300
+ " => created new ToolCall widget, mounted %d content blocks",
1301
+ len(content),
1302
+ )
1303
+ else:
1304
+ if existing_tool_call is not None:
1305
+ await existing_tool_call.update_tool_call(tool_call)
1306
+
1307
+ @on(acp_messages.SubAgentStart)
1308
+ async def on_acp_subagent_start(self, message: acp_messages.SubAgentStart):
1309
+ from tui.widgets.subagent import SubAgent
1310
+
1311
+ message.stop()
1312
+ if self._replay:
1313
+ self._replay_buffer.append({"kind": "subagent_start", "agent_type": message.agent_type, "id": message.subagent_id})
1314
+ return
1315
+ sa = SubAgent(message.agent_type, tool_id=message.subagent_id)
1316
+ await self.post(sa, new_block=True)
1317
+
1318
+ @on(acp_messages.SubAgentChunk)
1319
+ async def on_acp_subagent_chunk(self, message: acp_messages.SubAgentChunk):
1320
+ from tui.widgets.subagent import SubAgent
1321
+
1322
+ message.stop()
1323
+ if self._replay:
1324
+ self._replay_buffer.append({"kind": "subagent_chunk", "id": message.subagent_id, "text": message.text})
1325
+ return
1326
+ try:
1327
+ sa: SubAgent | None = self.contents.get_child_by_id(
1328
+ message.subagent_id, SubAgent
1329
+ )
1330
+ except NoMatches:
1331
+ return
1332
+ else:
1333
+ if sa is not None:
1334
+ sa.append_chunk(message.text)
1335
+
1336
+ @on(acp_messages.SubAgentEnd)
1337
+ async def on_acp_subagent_end(self, message: acp_messages.SubAgentEnd):
1338
+ from tui.widgets.subagent import SubAgent
1339
+
1340
+ message.stop()
1341
+ if self._replay:
1342
+ self._replay_buffer.append({"kind": "subagent_end", "id": message.subagent_id})
1343
+ return
1344
+ try:
1345
+ sa: SubAgent | None = self.contents.get_child_by_id(
1346
+ message.subagent_id, SubAgent
1347
+ )
1348
+ except NoMatches:
1349
+ return
1350
+ else:
1351
+ if sa is not None:
1352
+ sa.complete()
1353
+
1354
+ @on(acp_messages.AvailableCommandsUpdate)
1355
+ async def on_acp_available_commands_update(
1356
+ self, message: acp_messages.AvailableCommandsUpdate
1357
+ ):
1358
+ slash_commands: list[SlashCommand] = []
1359
+ for available_command in message.commands:
1360
+ input = available_command.get("input", {}) or {}
1361
+ slash_command = SlashCommand(
1362
+ f"/{available_command['name']}",
1363
+ available_command["description"],
1364
+ hint=input.get("hint"),
1365
+ )
1366
+ slash_commands.append(slash_command)
1367
+ self.agent_slash_commands = slash_commands
1368
+ self.update_slash_commands()
1369
+
1370
+ def get_terminal(self, terminal_id: str) -> TerminalTool | None:
1371
+ """Get a terminal from its id.
1372
+
1373
+ Args:
1374
+ terminal_id: ID of the terminal.
1375
+
1376
+ Returns:
1377
+ Terminal instance, or `None` if no terminal was found.
1378
+ """
1379
+ from tui.widgets.terminal_tool import TerminalTool
1380
+
1381
+ try:
1382
+ terminal = self.contents.query_one(f"#{terminal_id}", TerminalTool)
1383
+ except NoMatches:
1384
+ return None
1385
+ if terminal.released:
1386
+ return None
1387
+ return terminal
1388
+
1389
+ async def action_interrupt(self) -> None:
1390
+ terminal = self._terminal
1391
+ if terminal is not None and not terminal.is_finalized:
1392
+ await self.shell.interrupt()
1393
+ # self._shell = None
1394
+ self.flash("Command interrupted", style="success")
1395
+ else:
1396
+ raise SkipAction()
1397
+
1398
+ def action_focus_block(self, block_id: str) -> None:
1399
+ with suppress(NoMatches):
1400
+ self.query_one(f"#{block_id}").focus()
1401
+
1402
+ @work
1403
+ @on(acp_messages.CreateTerminal)
1404
+ async def on_acp_create_terminal(self, message: acp_messages.CreateTerminal):
1405
+ from tui.widgets.terminal_tool import TerminalTool, Command
1406
+
1407
+ command = Command(
1408
+ message.command,
1409
+ message.args or [],
1410
+ message.env or {},
1411
+ message.cwd or str(self.project_path),
1412
+ )
1413
+ width = self.window.size.width - 5 - self.window.styles.scrollbar_size_vertical
1414
+ height = self.window.scrollable_content_region.height - 2
1415
+
1416
+ terminal = TerminalTool(
1417
+ command,
1418
+ output_byte_limit=message.output_byte_limit,
1419
+ id=message.terminal_id,
1420
+ minimum_terminal_width=width,
1421
+ )
1422
+ self.terminals[message.terminal_id] = terminal
1423
+ terminal.display = False
1424
+
1425
+ try:
1426
+ await terminal.start(width, height)
1427
+ except Exception as error:
1428
+ log(str(error))
1429
+ message.result_future.set_result(False)
1430
+ return
1431
+
1432
+ try:
1433
+ await self.post(terminal)
1434
+ except Exception:
1435
+ message.result_future.set_result(False)
1436
+ else:
1437
+ message.result_future.set_result(True)
1438
+
1439
+ @on(acp_messages.KillTerminal)
1440
+ async def on_acp_kill_terminal(self, message: acp_messages.KillTerminal):
1441
+ if (terminal := self.get_terminal(message.terminal_id)) is not None:
1442
+ terminal.kill()
1443
+
1444
+ @on(acp_messages.GetTerminalState)
1445
+ def on_acp_get_terminal_state(self, message: acp_messages.GetTerminalState):
1446
+ if (terminal := self.get_terminal(message.terminal_id)) is None:
1447
+ message.result_future.set_exception(
1448
+ KeyError(f"No terminal with id {message.terminal_id!r}")
1449
+ )
1450
+ else:
1451
+ message.result_future.set_result(terminal.tool_state)
1452
+
1453
+ @on(acp_messages.ReleaseTerminal)
1454
+ def on_acp_terminal_release(self, message: acp_messages.ReleaseTerminal):
1455
+ if (terminal := self.get_terminal(message.terminal_id)) is not None:
1456
+ terminal.kill()
1457
+ terminal.release()
1458
+
1459
+ @work
1460
+ @on(acp_messages.WaitForTerminalExit)
1461
+ async def on_acp_wait_for_terminal_exit(
1462
+ self, message: acp_messages.WaitForTerminalExit
1463
+ ):
1464
+ if (terminal := self.get_terminal(message.terminal_id)) is None:
1465
+ message.result_future.set_exception(
1466
+ KeyError(f"No terminal with id {message.terminal_id!r}")
1467
+ )
1468
+ else:
1469
+ return_code, signal = await terminal.wait_for_exit()
1470
+ message.result_future.set_result((return_code or 0, signal))
1471
+
1472
+ async def set_mode(self, mode_id: str | None) -> None:
1473
+ """Set the mode give its id (if it exists).
1474
+
1475
+ Args:
1476
+ mode_id: Id of mode.
1477
+
1478
+ Returns:
1479
+ `True` if the mode was changed, `False` if it didn't exist.
1480
+ """
1481
+ if (agent := self.agent) is None:
1482
+ return
1483
+ if mode_id is None:
1484
+ self.current_mode = None
1485
+ else:
1486
+ if (error := await agent.set_mode(mode_id)) is not None:
1487
+ self.notify(error, title="Set Mode", severity="error")
1488
+ elif (new_mode := self.modes.get(mode_id)) is not None:
1489
+ self.current_mode = new_mode
1490
+ self.flash(
1491
+ Content.from_markup("Mode changed to [b]$mode", mode=new_mode.name),
1492
+ style="success",
1493
+ )
1494
+
1495
+ @on(acp_messages.SetModes)
1496
+ async def on_acp_set_modes(self, message: acp_messages.SetModes):
1497
+ self.modes = message.modes
1498
+ self.current_mode = self.modes[message.current_mode]
1499
+
1500
+ @on(messages.HistoryMove)
1501
+ async def on_history_move(self, message: messages.HistoryMove) -> None:
1502
+ message.stop()
1503
+ if message.shell:
1504
+ await self.shell_history.open()
1505
+
1506
+ if self.shell_history_index == 0:
1507
+ current_shell_command = ""
1508
+ else:
1509
+ current_shell_command = (
1510
+ await self.shell_history.get_entry(self.shell_history_index)
1511
+ )["input"]
1512
+ while True:
1513
+ self.shell_history_index += message.direction
1514
+ new_entry = await self.shell_history.get_entry(self.shell_history_index)
1515
+ if (new_entry)["input"] != current_shell_command:
1516
+ break
1517
+ if message.direction == +1 and self.shell_history_index == 0:
1518
+ break
1519
+ if (
1520
+ message.direction == -1
1521
+ and self.shell_history_index <= -self.shell_history.size
1522
+ ):
1523
+ break
1524
+ else:
1525
+ await self.prompt_history.open()
1526
+ self.prompt_history_index += message.direction
1527
+
1528
+ @work
1529
+ async def request_permissions(
1530
+ self,
1531
+ result_future: Future[Answer],
1532
+ options: list[Answer],
1533
+ tool_call_update: acp_protocol.ToolCallUpdatePermissionRequest,
1534
+ ) -> None:
1535
+ kind = tool_call_update.get("kind", None)
1536
+ title = tool_call_update.get("title", "") or ""
1537
+
1538
+ contents = tool_call_update.get("content", []) or []
1539
+ # If all the content is diffs, we will set kind to "edit" to show the permisisons screen
1540
+ for content in contents:
1541
+ if content.get("type") != "diff":
1542
+ break
1543
+ else:
1544
+ kind = "edit"
1545
+
1546
+ self.post_message(messages.SessionUpdate(state="asking"))
1547
+
1548
+ if kind == "edit":
1549
+ diffs: list[tuple[str, str, str | None, str]] = []
1550
+
1551
+ contents = tool_call_update.get("content", []) or []
1552
+ for content in contents:
1553
+ match content:
1554
+ case {
1555
+ "type": "diff",
1556
+ "oldText": old_text,
1557
+ "newText": new_text,
1558
+ "path": path,
1559
+ }:
1560
+ diffs.append((path, path, old_text, new_text))
1561
+
1562
+ if diffs:
1563
+ from tui.screens.permissions import PermissionsScreen
1564
+
1565
+ self.app.terminal_alert()
1566
+ self.app.system_notify(
1567
+ f"{self.agent_title} would like to write files",
1568
+ title="Permissions request",
1569
+ sound="question",
1570
+ )
1571
+ permissions_screen = PermissionsScreen(
1572
+ options, diffs, agent_name=self.agent_title or "The Agent"
1573
+ )
1574
+ result = await self.app.push_screen_wait(
1575
+ permissions_screen, mode=self.screen.id
1576
+ )
1577
+ self.post_message(messages.SessionUpdate(state="busy"))
1578
+ self.app.terminal_alert(False)
1579
+ result_future.set_result(result)
1580
+ return
1581
+
1582
+ from tui.widgets.acp_content import ACPToolCallContent, _header_path
1583
+
1584
+ def answer_callback(answer: Answer) -> None:
1585
+ try:
1586
+ result_future.set_result(answer)
1587
+ except Exception:
1588
+ # I've seen this occur in shutdown with an `InvalidStateError`
1589
+ pass
1590
+
1591
+ if not self.prompt.ask_queue:
1592
+ self.post_message(messages.SessionUpdate(state="busy"))
1593
+
1594
+ tool_call_content = tool_call_update.get("content", None) or []
1595
+ fname = _header_path(tool_call_content)
1596
+ question_title = f"{title} — {fname}" if fname else title
1597
+ if kind == "edit":
1598
+ question_title = question_title or "Apply changes to file"
1599
+ else:
1600
+ question_title = question_title or f"Execute {kind} operation?"
1601
+ self.ask(
1602
+ options,
1603
+ question_title,
1604
+ (
1605
+ partial(ACPToolCallContent, tool_call_content)
1606
+ if tool_call_content
1607
+ else None
1608
+ ),
1609
+ answer_callback,
1610
+ )
1611
+ return
1612
+
1613
+ async def post_tool_call(
1614
+ self, tool_call_update: acp_protocol.ToolCallUpdate
1615
+ ) -> None:
1616
+ if (contents := tool_call_update.get("content")) is None:
1617
+ return
1618
+
1619
+ for content in contents:
1620
+ match content:
1621
+ case {
1622
+ "type": "diff",
1623
+ "oldText": old_text,
1624
+ "newText": new_text,
1625
+ "path": path,
1626
+ }:
1627
+ await self.post_diff(path, old_text, new_text)
1628
+
1629
+ async def post_diff(self, path: str, before: str | None, after: str) -> None:
1630
+ """Post a diff view.
1631
+
1632
+ Args:
1633
+ path: Path to the file.
1634
+ before: Content of file before edit.
1635
+ after: Content of file after edit.
1636
+ """
1637
+
1638
+ from tui.widgets.diff_view import make_diff
1639
+
1640
+ diff_view = make_diff(path, path, before, after, classes="block")
1641
+ await self.post(diff_view)
1642
+
1643
+ def ask(
1644
+ self,
1645
+ options: list[Answer],
1646
+ title: str = "",
1647
+ get_content: Callable[[], Widget] | None = None,
1648
+ callback: Callable[[Answer], Any] | None = None,
1649
+ ) -> None:
1650
+ """Replace the prompt with a dialog to ask a question
1651
+
1652
+ Args:
1653
+ question: Question to ask or empty string to omit.
1654
+ options: A list of (ANSWER, ANSWER_ID) tuples.
1655
+ callback: Optional callable that will be invoked with the result.
1656
+ """
1657
+ from tui.widgets.question import Ask
1658
+
1659
+ self.agent_info
1660
+
1661
+ if self.agent_title:
1662
+ notify_title = f"[{self.agent_title}] {title}"
1663
+ else:
1664
+ notify_title = title
1665
+ notify_message = "\n".join(f" • {option.text}" for option in options)
1666
+ self.app.system_notify(notify_message, title=notify_title, sound="question")
1667
+
1668
+ self.prompt.ask(Ask(title, options, get_content, callback))
1669
+
1670
+ def _build_slash_commands(self) -> list[SlashCommand]:
1671
+ slash_commands = [
1672
+ SlashCommand("/exit", "Exit A2TUI"),
1673
+ SlashCommand("/about", "About A2TUI"),
1674
+ SlashCommand(
1675
+ "/clear",
1676
+ "Clear conversation window",
1677
+ "<optional number of lines to preserve>",
1678
+ ),
1679
+ SlashCommand(
1680
+ "/session",
1681
+ "Session commands: list, load, new, close, rename",
1682
+ "<list|load|new|close|rename> [args]",
1683
+ ),
1684
+ SlashCommand(
1685
+ "/project",
1686
+ "Project commands: list, load, new",
1687
+ "<list|load|new> [args]",
1688
+ ),
1689
+ ]
1690
+
1691
+ slash_commands.extend(platform_commands.get_slash_commands())
1692
+ slash_commands.extend(self.agent_slash_commands)
1693
+ deduplicated_slash_commands = {
1694
+ slash_command.command: slash_command for slash_command in slash_commands
1695
+ }
1696
+ slash_commands = sorted(
1697
+ deduplicated_slash_commands.values(), key=attrgetter("command")
1698
+ )
1699
+ return slash_commands
1700
+
1701
+ def update_slash_commands(self) -> None:
1702
+ """Update slash commands, which may have changed since mounting."""
1703
+ self.prompt.slash_commands = self._build_slash_commands()
1704
+
1705
+ async def on_mount(self) -> None:
1706
+ self.trap_focus()
1707
+ self.prompt.focus()
1708
+ self.prompt.slash_commands = self._build_slash_commands()
1709
+ self.call_after_refresh(self.post_welcome)
1710
+ self.app.settings_changed_signal.subscribe(self, self._settings_changed)
1711
+
1712
+ self.shell_history.complete.add_words(
1713
+ self.app.settings.get("shell.allow_commands", expect_type=str).split()
1714
+ )
1715
+ self.shell
1716
+
1717
+ if self._agent_data is not None:
1718
+ commands_prefix = self._agent_data.get("commands_prefix", "")
1719
+ platform_commands.set_prefix(commands_prefix)
1720
+ commands_module = self._agent_data.get("commands_module", "")
1721
+ if commands_module:
1722
+ import importlib
1723
+ try:
1724
+ mod = importlib.import_module(commands_module)
1725
+ mod.register_commands()
1726
+ except Exception:
1727
+ log.warning(f"Failed to load commands from {commands_module}")
1728
+ self.prompt.slash_commands = self._build_slash_commands()
1729
+
1730
+ async def start_agent() -> None:
1731
+ """Start the agent after refreshing the UI."""
1732
+ assert self._agent_data is not None
1733
+ from tui.acp.agent import Agent
1734
+
1735
+ self.agent = Agent(
1736
+ self.project_path,
1737
+ self._agent_data,
1738
+ self._agent_session_id,
1739
+ self._session_pk,
1740
+ )
1741
+ await self.agent.start(self)
1742
+
1743
+ self.call_after_refresh(start_agent)
1744
+
1745
+ else:
1746
+ self.agent_ready = True
1747
+
1748
+ self.update_title()
1749
+ self.window.anchor()
1750
+
1751
+ def _settings_changed(self, setting_item: tuple[str, str]) -> None:
1752
+ key, value = setting_item
1753
+ if key == "shell.allow_commands":
1754
+ self.shell_history.complete.add_words(value.split())
1755
+
1756
+ @work
1757
+ async def post_welcome(self) -> None:
1758
+ """Post any welcome content."""
1759
+
1760
+ def watch_agent(self, agent: AgentBase | None) -> None:
1761
+ if agent is None:
1762
+ self.agent_info = Content.styled("shell")
1763
+ else:
1764
+ self.agent_info = agent.get_info()
1765
+ self.agent_ready = False
1766
+ self.update_title()
1767
+
1768
+ @work
1769
+ async def watch_agent_ready(self, ready: bool) -> None:
1770
+ with suppress(asyncio.TimeoutError):
1771
+ async with asyncio.timeout(2.0):
1772
+ await self.shell.wait_for_ready()
1773
+ if ready:
1774
+ self._directory_watcher = DirectoryWatcher(self.project_path, self)
1775
+ self._directory_watcher.start()
1776
+ if ready and (agent_data := self._agent_data) is not None:
1777
+ welcome = agent_data.get("welcome", None)
1778
+ if welcome is not None:
1779
+ from tui.widgets.markdown_note import MarkdownNote
1780
+
1781
+ await self.post(MarkdownNote(welcome))
1782
+ if ready and self._initial_prompt is not None:
1783
+ prompt = self._initial_prompt
1784
+ if prompt.startswith("!"):
1785
+ self.post_message(
1786
+ messages.UserInputSubmitted(self._initial_prompt[1:], shell=True)
1787
+ )
1788
+ else:
1789
+ self.post_message(
1790
+ messages.UserInputSubmitted(self._initial_prompt, shell=False)
1791
+ )
1792
+ self._initial_prompt = None
1793
+
1794
+ def on_mouse_down(self, event: events.MouseDown) -> None:
1795
+ self._mouse_down_offset = event.screen_offset
1796
+
1797
+ def on_click(self, event: events.Click) -> None:
1798
+ if (
1799
+ self._mouse_down_offset is not None
1800
+ and event.screen_offset != self._mouse_down_offset
1801
+ ):
1802
+ return
1803
+ widget = event.widget
1804
+
1805
+ contents = self.contents
1806
+ if self.screen.get_selected_text():
1807
+ return
1808
+ if widget is None or widget.is_maximized:
1809
+ return
1810
+ try:
1811
+ widget.query_ancestor(Prompt)
1812
+ except NoMatches:
1813
+ pass
1814
+ else:
1815
+ return
1816
+
1817
+ if widget in contents.displayed_children:
1818
+ self.cursor_offset = contents.displayed_children.index(widget)
1819
+ self.refresh_block_cursor()
1820
+ return
1821
+ for parent in widget.ancestors:
1822
+ if not isinstance(parent, Widget):
1823
+ break
1824
+ if (
1825
+ parent is self or parent is contents
1826
+ ) and widget in contents.displayed_children:
1827
+ self.cursor_offset = contents.displayed_children.index(widget)
1828
+ self.refresh_block_cursor()
1829
+ break
1830
+ if (
1831
+ isinstance(parent, BlockProtocol)
1832
+ and parent in contents.displayed_children
1833
+ ):
1834
+ self.cursor_offset = contents.displayed_children.index(parent)
1835
+ parent.block_select(widget)
1836
+ self.refresh_block_cursor()
1837
+ break
1838
+ widget = parent
1839
+
1840
+ def new_block(self) -> None:
1841
+ """Start a new block for agent response."""
1842
+ self._complete_thought()
1843
+ self._agent_response = None
1844
+
1845
+ async def post[WidgetType: Widget](
1846
+ self,
1847
+ widget: WidgetType,
1848
+ *,
1849
+ loading: bool = False,
1850
+ new_block: bool = True,
1851
+ ) -> WidgetType:
1852
+ """Post a widget to the converstaion.
1853
+
1854
+ Args:
1855
+ widget: Widget to post.
1856
+ loading: Set the widget to an initial loading state?
1857
+ new_block: Start a new block?
1858
+
1859
+ Returns:
1860
+ The widget that was mounted.
1861
+ """
1862
+ if self._loading is not None:
1863
+ await self._loading.remove()
1864
+ if new_block and not loading:
1865
+ self.new_block()
1866
+ if not self.contents.is_attached:
1867
+ return widget
1868
+ await self.contents.mount(widget)
1869
+
1870
+ widget.loading = loading
1871
+ self._require_check_prune = True
1872
+ self.call_after_refresh(self.check_prune)
1873
+ return widget
1874
+
1875
+ async def check_prune(self) -> None:
1876
+ """Check if a prune is required."""
1877
+ if self._require_check_prune:
1878
+ self._require_check_prune = False
1879
+ low_mark = self.app.settings.get("ui.prune_low_mark", int)
1880
+ high_mark = low_mark + self.app.settings.get("ui.prune_excess", int)
1881
+ await self.prune_window(low_mark, high_mark)
1882
+
1883
+ async def prune_window(self, low_mark: int, high_mark: int) -> None:
1884
+ """Remove older children to keep within a certain range.
1885
+
1886
+ Args:
1887
+ low_mark: Height to aim for.
1888
+ high_mark: Height to start pruning.
1889
+ """
1890
+
1891
+ assert high_mark >= low_mark
1892
+
1893
+ contents = self.contents
1894
+
1895
+ height = contents.virtual_size.height
1896
+ if height <= high_mark:
1897
+ return
1898
+ prune_children: list[Widget] = []
1899
+ bottom_margin = 0
1900
+ prune_height = 0
1901
+
1902
+ if low_mark == 0:
1903
+ # Aggressive prune: only act on already-hidden children
1904
+ # (e.g. collapsed blocks). Wiping every visible widget
1905
+ # would erase the live ToolCall the user is interacting
1906
+ # with.
1907
+ prune_children = [
1908
+ child for child in contents.children if not child.display
1909
+ ]
1910
+ else:
1911
+ for child in contents.children:
1912
+ if not child.display:
1913
+ prune_children.append(child)
1914
+ continue
1915
+ top, _, bottom, _ = child.styles.margin
1916
+ child_height = child.outer_size.height
1917
+ prune_height = (
1918
+ (prune_height - bottom_margin + max(bottom_margin, top))
1919
+ + bottom
1920
+ + child_height
1921
+ )
1922
+ bottom_margin = bottom
1923
+ if height - prune_height <= low_mark:
1924
+ break
1925
+ prune_children.append(child)
1926
+
1927
+ if not prune_children:
1928
+ return
1929
+ self.cursor_offset = -1
1930
+ self.cursor.visible = False
1931
+ self.cursor.follow(None)
1932
+ contents.refresh(layout=True)
1933
+
1934
+ if prune_children:
1935
+ await contents.remove_children(prune_children)
1936
+
1937
+ self.call_later(self.window.anchor)
1938
+
1939
+ async def new_terminal(self) -> Terminal:
1940
+ """Create a new interactive Terminal.
1941
+
1942
+ Args:
1943
+ width: Initial width of the terminal.
1944
+ display: Initial display.
1945
+
1946
+ Returns:
1947
+ A new (mounted) Terminal widget.
1948
+ """
1949
+
1950
+ if (terminal := self._terminal) is not None:
1951
+ if terminal.state.buffer.is_blank:
1952
+ terminal.finalize()
1953
+ await terminal.remove()
1954
+
1955
+ self._terminal_count += 1
1956
+
1957
+ terminal_width, terminal_height = self.get_terminal_dimensions()
1958
+ terminal = ShellTerminal(
1959
+ f"terminal #{self._terminal_count}",
1960
+ id=f"shell-terminal-{self._terminal_count}",
1961
+ size=(terminal_width, terminal_height),
1962
+ get_terminal_dimensions=self.get_terminal_dimensions,
1963
+ )
1964
+
1965
+ terminal.display = False
1966
+ terminal = await self.post(terminal)
1967
+ self.add_focusable_terminal(terminal)
1968
+ self.refresh_bindings()
1969
+ return terminal
1970
+
1971
+ def get_terminal_dimensions(self) -> tuple[int, int]:
1972
+ """Get the default dimensions of new terminals.
1973
+
1974
+ Returns:
1975
+ Tuple of (WIDTH, HEIGHT)
1976
+ """
1977
+ terminal_width = max(
1978
+ 16,
1979
+ (self.window.size.width - 2 - self.window.styles.scrollbar_size_vertical),
1980
+ )
1981
+ terminal_height = max(8, self.window.scrollable_content_region.height)
1982
+ return terminal_width, terminal_height
1983
+
1984
+ @property
1985
+ def shell(self) -> Shell:
1986
+ """A Shell instance."""
1987
+
1988
+ if self._shell is None or self._shell.is_finished:
1989
+ shell_command = self.app.settings.get(
1990
+ "shell.command",
1991
+ str,
1992
+ expand=False,
1993
+ )
1994
+ shell_start = self.app.settings.get(
1995
+ "shell.command_start",
1996
+ str,
1997
+ expand=False,
1998
+ )
1999
+ shell_directory = self.working_directory
2000
+ self._shell = Shell(
2001
+ self, shell_directory, shell=shell_command, start=shell_start
2002
+ )
2003
+ self._shell.start()
2004
+ return self._shell
2005
+
2006
+ async def post_shell(self, command: str) -> None:
2007
+ """Post a command to the shell.
2008
+
2009
+ Args:
2010
+ command: Command to execute.
2011
+ """
2012
+ from tui.widgets.shell_result import ShellResult
2013
+
2014
+ if command.strip():
2015
+ self._shell_count += 1
2016
+ await self.post(ShellResult(command))
2017
+ width, height = self.get_terminal_dimensions()
2018
+ await self.shell.send(command, width, height)
2019
+
2020
+ def action_cursor_up(self) -> None:
2021
+ if not self.contents.displayed_children or self.cursor_offset == 0:
2022
+ # No children
2023
+ return
2024
+ if self.cursor_offset == -1:
2025
+ # Start cursor at end
2026
+ self.cursor_offset = len(self.contents.displayed_children) - 1
2027
+ cursor_block = self.cursor_block
2028
+ if isinstance(cursor_block, BlockProtocol):
2029
+ cursor_block.block_cursor_clear()
2030
+ cursor_block.block_cursor_up()
2031
+ else:
2032
+ cursor_block = self.cursor_block
2033
+ if isinstance(cursor_block, BlockProtocol):
2034
+ if cursor_block.block_cursor_up() is None:
2035
+ self.cursor_offset -= 1
2036
+ cursor_block = self.cursor_block
2037
+ if isinstance(cursor_block, BlockProtocol):
2038
+ cursor_block.block_cursor_clear()
2039
+ cursor_block.block_cursor_up()
2040
+ else:
2041
+ # Move cursor up
2042
+ self.cursor_offset -= 1
2043
+ cursor_block = self.cursor_block
2044
+ if isinstance(cursor_block, BlockProtocol):
2045
+ cursor_block.block_cursor_clear()
2046
+ cursor_block.block_cursor_up()
2047
+ self.refresh_block_cursor()
2048
+
2049
+ def action_cursor_down(self) -> None:
2050
+ if not self.contents.displayed_children or self.cursor_offset == -1:
2051
+ # No children, or no cursor
2052
+ return
2053
+
2054
+ cursor_block = self.cursor_block
2055
+ if isinstance(cursor_block, BlockProtocol):
2056
+ if cursor_block.block_cursor_down() is None:
2057
+ self.cursor_offset += 1
2058
+ if self.cursor_offset >= len(self.contents.displayed_children):
2059
+ self.cursor_offset = -1
2060
+ self.refresh_block_cursor()
2061
+ return
2062
+ cursor_block = self.cursor_block
2063
+ if isinstance(cursor_block, BlockProtocol):
2064
+ cursor_block.block_cursor_clear()
2065
+ cursor_block.block_cursor_down()
2066
+ else:
2067
+ self.cursor_offset += 1
2068
+ if self.cursor_offset >= len(self.contents.displayed_children):
2069
+ self.cursor_offset = -1
2070
+ self.refresh_block_cursor()
2071
+ return
2072
+ cursor_block = self.cursor_block
2073
+ if isinstance(cursor_block, BlockProtocol):
2074
+ cursor_block.block_cursor_clear()
2075
+ cursor_block.block_cursor_down()
2076
+ self.refresh_block_cursor()
2077
+
2078
+ @work
2079
+ async def action_cancel(self) -> None:
2080
+ if monotonic() - self._last_escape_time < 3:
2081
+ if (agent := self.agent) is not None:
2082
+ if await agent.cancel():
2083
+ self.flash("Turn cancelled", style="success")
2084
+ else:
2085
+ self.flash("Agent declined to cancel. Please wait.", style="error")
2086
+ else:
2087
+ self.flash("Press [b]esc[/] again to cancel agent's turn")
2088
+ self._last_escape_time = monotonic()
2089
+
2090
+ def focus_prompt(self, reset_cursor: bool = True, scroll_end: bool = True) -> None:
2091
+ """Focus the prompt input.
2092
+
2093
+ Args:
2094
+ reset_cursor: Reset the block cursor.
2095
+ scroll_end: Scroll t the end of the content.
2096
+ """
2097
+ if reset_cursor:
2098
+ self.cursor_offset = -1
2099
+ self.cursor.visible = False
2100
+ if scroll_end:
2101
+ self.window.scroll_end()
2102
+ self.prompt.focus()
2103
+
2104
+ async def action_select_block(self) -> None:
2105
+ if (block := self.get_cursor_block(Widget)) is None:
2106
+ return
2107
+
2108
+ menu_options = [
2109
+ MenuItem("[u]C[/]opy to clipboard", "copy_to_clipboard", "c"),
2110
+ MenuItem("Co[u]p[/u]y to prompt", "copy_to_prompt", "p"),
2111
+ MenuItem("Open as S[u]V[/]G", "export_to_svg", "v"),
2112
+ ]
2113
+
2114
+ if block.allow_maximize:
2115
+ menu_options.append(MenuItem("[u]M[/u]aximize", "maximize_block", "m"))
2116
+
2117
+ if isinstance(block, MenuProtocol):
2118
+ menu_options.extend(block.get_block_menu())
2119
+ menu = Menu(block, menu_options)
2120
+ else:
2121
+ menu = Menu(block, menu_options)
2122
+
2123
+ menu.offset = Offset(1, block.region.offset.y)
2124
+ await self.mount(menu)
2125
+ menu.focus()
2126
+
2127
+ def action_copy_to_clipboard(
2128
+ self, block: Widget | None = None
2129
+ ) -> None:
2130
+ if block is None:
2131
+ block = self.get_cursor_block()
2132
+ if isinstance(block, MenuProtocol):
2133
+ text = block.get_block_content("clipboard")
2134
+ elif isinstance(block, MarkdownFence):
2135
+ text = block._content.plain
2136
+ elif isinstance(block, MarkdownBlock):
2137
+ text = block.source
2138
+ else:
2139
+ return
2140
+ if text:
2141
+ self.app.copy_to_clipboard(text)
2142
+ self.flash("Copied to clipboard")
2143
+
2144
+ def action_copy_to_prompt(self) -> None:
2145
+ block = self.get_cursor_block()
2146
+ if isinstance(block, MenuProtocol):
2147
+ text = block.get_block_content("prompt")
2148
+ elif isinstance(block, MarkdownFence):
2149
+ # Copy to prompt leaves MD formatting
2150
+ text = block.source
2151
+ elif isinstance(block, MarkdownBlock):
2152
+ text = block.source
2153
+ else:
2154
+ return
2155
+
2156
+ if text:
2157
+ self.prompt.append(text)
2158
+ self.flash("Copied to prompt")
2159
+ self.focus_prompt()
2160
+
2161
+ def action_maximize_block(self) -> None:
2162
+ if (block := self.get_cursor_block()) is not None:
2163
+ self.screen.maximize(block, container=False)
2164
+ block.focus()
2165
+
2166
+ def action_export_to_svg(self) -> None:
2167
+ block = self.get_cursor_block()
2168
+ if block is None:
2169
+ return
2170
+ import platformdirs
2171
+ from textual._compositor import Compositor
2172
+ from textual._files import generate_datetime_filename
2173
+
2174
+ width, height = block.outer_size
2175
+ compositor = Compositor()
2176
+ compositor.reflow(block, block.outer_size)
2177
+ render = compositor.render_full_update()
2178
+
2179
+ from rich.console import Console
2180
+ import io
2181
+ import os.path
2182
+
2183
+ console = Console(
2184
+ width=width,
2185
+ height=height,
2186
+ file=io.StringIO(),
2187
+ force_terminal=True,
2188
+ color_system="truecolor",
2189
+ record=True,
2190
+ legacy_windows=False,
2191
+ safe_box=False,
2192
+ )
2193
+ console.print(render)
2194
+ path = platformdirs.user_pictures_dir()
2195
+ svg_filename = generate_datetime_filename("A2TUI", ".svg", None)
2196
+ svg_path = os.path.expanduser(os.path.join(path, svg_filename))
2197
+ console.save_svg(svg_path)
2198
+ import webbrowser
2199
+
2200
+ webbrowser.open(f"file:///{svg_path}")
2201
+
2202
+ async def action_mode_switcher(self) -> None:
2203
+ self.prompt.mode_switcher.focus()
2204
+
2205
+ def refresh_block_cursor(self) -> None:
2206
+ if (cursor_block := self.cursor_block_child) is not None:
2207
+ self.window.focus()
2208
+ self.cursor.visible = True
2209
+ self.cursor.follow(cursor_block)
2210
+ self.call_after_refresh(
2211
+ self.window.scroll_to_center, cursor_block, immediate=True
2212
+ )
2213
+ else:
2214
+ self.cursor.visible = False
2215
+ self.window.anchor(False)
2216
+ self.window.scroll_end(duration=2 / 10)
2217
+ self.cursor.follow(None)
2218
+ self.prompt.focus()
2219
+ self.refresh_bindings()
2220
+
2221
+ async def slash_command(self, text: str) -> bool:
2222
+ """Give A2TUI the opertunity to process slash commands.
2223
+
2224
+ Args:
2225
+ text: The prompt, including the slash in the first position.
2226
+
2227
+ Returns:
2228
+ `True` if A2TUI has processed the slash command, `False` if it should
2229
+ be forwarded to the agent.
2230
+ """
2231
+ command, _, parameters = text[1:].partition(" ")
2232
+ if command == "about":
2233
+ from tui import about
2234
+ from tui.widgets.markdown_note import MarkdownNote
2235
+
2236
+ app = self.app
2237
+ about_md = about.render(app)
2238
+ await self.post(MarkdownNote(about_md, classes="about"))
2239
+ self.app.copy_to_clipboard(about_md)
2240
+ self.notify(
2241
+ "A copy of /about has been placed in your clipboard",
2242
+ title="/about",
2243
+ )
2244
+ return True
2245
+ elif command == "clear":
2246
+ try:
2247
+ line_count = max(0, int(parameters) if parameters.strip() else 0)
2248
+ except ValueError:
2249
+ self.notify(
2250
+ "Unable to clear—a number was expected",
2251
+ title="/clear",
2252
+ severity="error",
2253
+ )
2254
+ return True
2255
+ await self.prune_window(line_count, line_count)
2256
+ return True
2257
+ elif command == "exit":
2258
+ if self.session_start_time is not None:
2259
+ session_time = monotonic() - self.session_start_time
2260
+ session_id = getattr(self.agent, "session_id", None) or self._agent_session_id or ""
2261
+ self.app._exit_metrics = {
2262
+ "session_id": session_id,
2263
+ "tool_call_total": self._tool_call_total,
2264
+ "tool_call_success": self._tool_call_success,
2265
+ "tool_call_failed": self._tool_call_failed,
2266
+ "turn_count": self._turn_count,
2267
+ "shell_count": self._shell_count,
2268
+ "wall_time": session_time,
2269
+ "agent_title": self.agent_title or "Agent",
2270
+ }
2271
+ self.app.exit()
2272
+ return True
2273
+ elif command == "session":
2274
+ return await self._handle_session_command(parameters)
2275
+ elif command == "project":
2276
+ return await self._handle_project_command(parameters)
2277
+ return await self._handle_tui_session_project_dispatch(command, parameters)
2278
+
2279
+ async def _handle_tui_session_project_dispatch(
2280
+ self, command: str, parameters: str
2281
+ ) -> bool:
2282
+ if await platform_commands.dispatch(self, command, parameters):
2283
+ return True
2284
+ return False
2285
+
2286
+ async def _handle_session_command(self, parameters: str) -> bool:
2287
+ parts = parameters.strip().split(maxsplit=1)
2288
+ sub_cmd = parts[0] if parts else ""
2289
+ arg = parts[1] if len(parts) > 1 else ""
2290
+
2291
+ if sub_cmd == "list":
2292
+ from tui.db import DB
2293
+ db = DB()
2294
+ recent = await db.session_get_recent(max_results=20)
2295
+ if not recent:
2296
+ self.notify("No sessions found", title="/session list")
2297
+ return True
2298
+ lines = ["Recent sessions:"]
2299
+ for s in recent:
2300
+ title = s.get("title", "Untitled") or "Untitled"
2301
+ aid = s.get("agent_identity", "unknown")
2302
+ sid = s.get("agent_session_id", "")[:8]
2303
+ lines.append(f" [{s['id']}] {title} ({aid[:20]}... {sid})")
2304
+ self.notify("\n".join(lines), title="/session list")
2305
+ return True
2306
+ elif sub_cmd == "load":
2307
+ if not arg:
2308
+ self.notify("Session ID required", title="/session load", severity="error")
2309
+ return True
2310
+ try:
2311
+ session_pk = int(arg)
2312
+ except ValueError:
2313
+ self.notify("Invalid session ID", title="/session load", severity="error")
2314
+ return True
2315
+ self.post_message(messages.SessionLoad(session_pk))
2316
+ return True
2317
+ elif sub_cmd == "new":
2318
+ if self._agent_data is not None:
2319
+ self.post_message(
2320
+ messages.SessionNew(
2321
+ self.working_directory,
2322
+ self._agent_data["identity"],
2323
+ arg,
2324
+ )
2325
+ )
2326
+ return True
2327
+ return False
2328
+ elif sub_cmd == "close":
2329
+ if self.turn == "agent" and self.agent is not None:
2330
+ await self.agent.cancel()
2331
+ if self.screen.id is not None:
2332
+ self.post_message(messages.SessionClose(self.screen.id))
2333
+ return True
2334
+ return False
2335
+ elif sub_cmd == "rename":
2336
+ name = arg.strip()
2337
+ if not name:
2338
+ self.notify(
2339
+ "Expected a name for the session.\n"
2340
+ 'For example: "add comments to blog"',
2341
+ title="/session rename",
2342
+ severity="error",
2343
+ )
2344
+ return True
2345
+ if self.agent is not None:
2346
+ await self.agent.set_session_name(name)
2347
+ self.flash(f"Renamed session to [b]'{name}'", style="success")
2348
+ return True
2349
+ else:
2350
+ self.notify(
2351
+ "Usage: /session <list|load|new|close|rename>",
2352
+ title="/session",
2353
+ severity="error",
2354
+ )
2355
+ return True
2356
+
2357
+ async def _handle_project_command(self, parameters: str) -> bool:
2358
+ parts = parameters.strip().split(maxsplit=2)
2359
+ sub_cmd = parts[0] if parts else ""
2360
+ name = parts[1] if len(parts) > 1 else ""
2361
+ path = parts[2] if len(parts) > 2 else ""
2362
+
2363
+ if sub_cmd == "list":
2364
+ from pathlib import Path
2365
+ from onecode.config import CLOUD_DEV_HARNESS_DIR
2366
+ projects_dir = CLOUD_DEV_HARNESS_DIR / "projects"
2367
+ if not projects_dir.exists():
2368
+ self.notify("No projects found", title="/project list")
2369
+ return True
2370
+ project_files = list(projects_dir.glob("*.yaml")) + list(projects_dir.glob("*.json"))
2371
+ if not project_files:
2372
+ self.notify("No projects found", title="/project list")
2373
+ return True
2374
+ lines = ["Projects:"]
2375
+ for pf in sorted(project_files):
2376
+ lines.append(f" {pf.stem}")
2377
+ self.notify("\n".join(lines), title="/project list")
2378
+ return True
2379
+ elif sub_cmd == "load":
2380
+ if not name:
2381
+ self.notify("Project name required", title="/project load", severity="error")
2382
+ return True
2383
+ from pathlib import Path
2384
+ from onecode.config import load_config, save_config, CLOUD_DEV_HARNESS_DIR
2385
+ projects_dir = CLOUD_DEV_HARNESS_DIR / "projects"
2386
+ project_file = None
2387
+ for ext in ["yaml", "yml", "json"]:
2388
+ pf = projects_dir / f"{name}.{ext}"
2389
+ if pf.exists():
2390
+ project_file = pf
2391
+ break
2392
+ if not project_file:
2393
+ self.notify(f"Project '{name}' not found", title="/project load", severity="error")
2394
+ return True
2395
+ import yaml
2396
+ proj_data = yaml.safe_load(project_file.read_text()) if project_file.suffix in [".yaml", ".yml"] else __import__("json").loads(project_file.read_text())
2397
+ project_path = proj_data.get("path", ".")
2398
+ cfg = load_config()
2399
+ cfg.current_project = name
2400
+ cfg.current_project_path = project_path
2401
+ save_config(cfg)
2402
+ new_project_dir = Path(project_path) if project_path else Path.cwd()
2403
+ self.app.project_dir = new_project_dir
2404
+ self.post_message(messages.ProjectDirectoryUpdated())
2405
+ self.flash(f"Switched to project: {name}", style="success")
2406
+ return True
2407
+ elif sub_cmd == "new":
2408
+ if not name:
2409
+ self.notify("Usage: /project new <name> [path]", title="/project new", severity="error")
2410
+ return True
2411
+ from pathlib import Path
2412
+ from onecode.config import load_config, save_config, CLOUD_DEV_HARNESS_DIR
2413
+ from onecode.agent.cdh_loader import CdhProjectLoader
2414
+ from cdh.scaffold import scaffold_dlc_project
2415
+ import yaml
2416
+ projects_dir = CLOUD_DEV_HARNESS_DIR / "projects"
2417
+ projects_dir.mkdir(parents=True, exist_ok=True)
2418
+ project_path = path or str(Path.cwd())
2419
+ ws = Path(project_path).expanduser().resolve()
2420
+ scaffold_dlc_project(ws, name)
2421
+ CdhProjectLoader.init_project(ws, name)
2422
+ proj_data = {"name": name, "path": str(ws), "description": ""}
2423
+ project_file = projects_dir / f"{name}.yaml"
2424
+ project_file.write_text(yaml.dump(proj_data))
2425
+ cfg = load_config()
2426
+ cfg.current_project = name
2427
+ cfg.current_project_path = str(ws)
2428
+ save_config(cfg)
2429
+ self.app.project_dir = ws
2430
+ self.post_message(messages.ProjectDirectoryUpdated())
2431
+ self.flash(f"Created and switched to project: {name}", style="success")
2432
+ return True
2433
+ else:
2434
+ self.notify(
2435
+ "Usage: /project <list|load|new> [name] [path]",
2436
+ title="/project",
2437
+ severity="error",
2438
+ )
2439
+ return True
2440
+
2441
+ return False