@scemoon/cdh 1.0.6 → 1.0.7

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