@scemoon/cdh 1.0.10-beta.1 → 1.0.10-beta.2

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 (788) hide show
  1. package/ai-dlc-skill/SKILL.md +106 -0
  2. package/ai-dlc-skill/architecture/project-structure.md +78 -0
  3. package/ai-dlc-skill/brownfield/README.md +36 -0
  4. package/ai-dlc-skill/brownfield/entry.md +45 -0
  5. package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
  6. package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
  7. package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
  8. package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
  9. package/ai-dlc-skill/components/backend.md +47 -0
  10. package/ai-dlc-skill/components/desktop.md +63 -0
  11. package/ai-dlc-skill/components/mya.md +219 -0
  12. package/ai-dlc-skill/components/native.md +73 -0
  13. package/ai-dlc-skill/components/tta.md +235 -0
  14. package/ai-dlc-skill/components/web.md +77 -0
  15. package/ai-dlc-skill/components/wxa.md +219 -0
  16. package/ai-dlc-skill/contracts/README.md +25 -0
  17. package/ai-dlc-skill/core/adaptive-flow.md +107 -0
  18. package/ai-dlc-skill/core/security.md +29 -0
  19. package/ai-dlc-skill/core/task-registry.md +153 -0
  20. package/ai-dlc-skill/cross-tool/README.md +20 -0
  21. package/ai-dlc-skill/cross-tool/cline/export.sh +38 -0
  22. package/ai-dlc-skill/cross-tool/copilot/export.sh +41 -0
  23. package/ai-dlc-skill/cross-tool/cursor/export.sh +53 -0
  24. package/ai-dlc-skill/cross-tool/onecode/export.sh +27 -0
  25. package/ai-dlc-skill/cross-tool/opencode/export.sh +38 -0
  26. package/ai-dlc-skill/phases/debug/entry.md +48 -0
  27. package/ai-dlc-skill/phases/debug/lifecycle.md +284 -0
  28. package/ai-dlc-skill/phases/deliver/entry.md +35 -0
  29. package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
  30. package/ai-dlc-skill/phases/deliver/prompt.md +54 -0
  31. package/ai-dlc-skill/phases/deliver/rules.md +61 -0
  32. package/ai-dlc-skill/phases/plan/design_system/accessibility.md +268 -0
  33. package/ai-dlc-skill/phases/plan/design_system/atomic_design.md +341 -0
  34. package/ai-dlc-skill/phases/plan/design_system/component_spec.md +398 -0
  35. package/ai-dlc-skill/phases/plan/design_system/design_tokens.md +190 -0
  36. package/ai-dlc-skill/phases/plan/design_system/iconography.md +299 -0
  37. package/ai-dlc-skill/phases/plan/design_system/platform_ui/desktop.md +318 -0
  38. package/ai-dlc-skill/phases/plan/design_system/platform_ui/mya.md +354 -0
  39. package/ai-dlc-skill/phases/plan/design_system/platform_ui/native.md +328 -0
  40. package/ai-dlc-skill/phases/plan/design_system/platform_ui/tta.md +394 -0
  41. package/ai-dlc-skill/phases/plan/design_system/platform_ui/web.md +287 -0
  42. package/ai-dlc-skill/phases/plan/design_system/platform_ui/wxa.md +374 -0
  43. package/ai-dlc-skill/phases/plan/design_system/theme_system.md +318 -0
  44. package/ai-dlc-skill/phases/plan/entry.md +33 -0
  45. package/ai-dlc-skill/phases/plan/lifecycle.md +260 -0
  46. package/ai-dlc-skill/phases/plan/prompt.md +75 -0
  47. package/ai-dlc-skill/phases/plan/rules.md +60 -0
  48. package/ai-dlc-skill/phases/understand/entry.md +36 -0
  49. package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
  50. package/ai-dlc-skill/phases/understand/prompt.md +58 -0
  51. package/ai-dlc-skill/phases/understand/rules.md +49 -0
  52. package/ai-dlc-skill/phases/verify/config/base_url.md +65 -0
  53. package/ai-dlc-skill/phases/verify/entry.md +59 -0
  54. package/ai-dlc-skill/phases/verify/gates/browser_e2e.md +96 -0
  55. package/ai-dlc-skill/phases/verify/lifecycle.md +217 -0
  56. package/ai-dlc-skill/phases/verify/prompt.md +73 -0
  57. package/ai-dlc-skill/phases/verify/rules.md +91 -0
  58. package/ai-dlc-skill/phases/verify/runners/playwright_local.md +103 -0
  59. package/ai-dlc-skill/practices/bdd.md +119 -0
  60. package/ai-dlc-skill/practices/sdd.md +84 -0
  61. package/ai-dlc-skill/practices/tdd.md +128 -0
  62. package/ai-dlc-skill/providers/README.md +114 -0
  63. package/ai-dlc-skill/providers/aliyun/SKILL.md +204 -0
  64. package/ai-dlc-skill/providers/aliyun/api/rest-api.md +214 -0
  65. package/ai-dlc-skill/providers/aliyun/api/sdk.md +307 -0
  66. package/ai-dlc-skill/providers/aliyun/best-practices/database-design.md +363 -0
  67. package/ai-dlc-skill/providers/aliyun/best-practices/function-design.md +424 -0
  68. package/ai-dlc-skill/providers/aliyun/best-practices/troubleshooting.md +258 -0
  69. package/ai-dlc-skill/providers/aliyun/cli/database.md +263 -0
  70. package/ai-dlc-skill/providers/aliyun/cli/functions.md +263 -0
  71. package/ai-dlc-skill/providers/aliyun/cli/hosting.md +226 -0
  72. package/ai-dlc-skill/providers/aliyun/cli/setup.md +235 -0
  73. package/ai-dlc-skill/providers/aliyun/cli/storage.md +315 -0
  74. package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
  75. package/ai-dlc-skill/providers/aliyun/integration/ci-cd.md +288 -0
  76. package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
  77. package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
  78. package/ai-dlc-skill/providers/aliyun/security/authentication.md +254 -0
  79. package/ai-dlc-skill/providers/aliyun/services/cdn-hosting.md +220 -0
  80. package/ai-dlc-skill/providers/aliyun/services/database.md +319 -0
  81. package/ai-dlc-skill/providers/aliyun/services/function-compute.md +273 -0
  82. package/ai-dlc-skill/providers/aliyun/services/overview.md +163 -0
  83. package/ai-dlc-skill/providers/aliyun/services/serverless-app-engine.md +266 -0
  84. package/ai-dlc-skill/providers/aliyun/services/storage.md +269 -0
  85. package/ai-dlc-skill/providers/tcb/SKILL.md +208 -0
  86. package/ai-dlc-skill/providers/tcb/api/rest-api.md +338 -0
  87. package/ai-dlc-skill/providers/tcb/api/sdk.md +288 -0
  88. package/ai-dlc-skill/providers/tcb/best-practices/database-design.md +452 -0
  89. package/ai-dlc-skill/providers/tcb/best-practices/function-design.md +502 -0
  90. package/ai-dlc-skill/providers/tcb/best-practices/troubleshooting.md +462 -0
  91. package/ai-dlc-skill/providers/tcb/cli/cls.md +133 -0
  92. package/ai-dlc-skill/providers/tcb/cli/database.md +230 -0
  93. package/ai-dlc-skill/providers/tcb/cli/env.md +207 -0
  94. package/ai-dlc-skill/providers/tcb/cli/functions.md +276 -0
  95. package/ai-dlc-skill/providers/tcb/cli/hosting.md +219 -0
  96. package/ai-dlc-skill/providers/tcb/cli/setup.md +218 -0
  97. package/ai-dlc-skill/providers/tcb/cli/storage.md +233 -0
  98. package/ai-dlc-skill/providers/tcb/deployment.yaml +111 -0
  99. package/ai-dlc-skill/providers/tcb/integration/ci-cd.md +309 -0
  100. package/ai-dlc-skill/providers/tcb/integration/mcp-server.md +371 -0
  101. package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
  102. package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
  103. package/ai-dlc-skill/providers/tcb/security/authentication.md +241 -0
  104. package/ai-dlc-skill/providers/tcb/services/cloud-database.md +299 -0
  105. package/ai-dlc-skill/providers/tcb/services/cloud-functions.md +265 -0
  106. package/ai-dlc-skill/providers/tcb/services/cloud-hosting.md +264 -0
  107. package/ai-dlc-skill/providers/tcb/services/cloud-run.md +285 -0
  108. package/ai-dlc-skill/providers/tcb/services/cloud-storage.md +245 -0
  109. package/ai-dlc-skill/providers/tcb/services/overview.md +174 -0
  110. package/ai-dlc-skill/pybdd/README.md +41 -0
  111. package/ai-dlc-skill/pybdd/conftest.py +17 -0
  112. package/ai-dlc-skill/pybdd/example/auth/login.feature +32 -0
  113. package/ai-dlc-skill/pybdd/pytest.ini +5 -0
  114. package/ai-dlc-skill/pybdd/steps/.gitkeep +1 -0
  115. package/ai-dlc-skill/pybdd/steps/test_example_steps.py +65 -0
  116. package/ai-dlc-skill/requirements.md +140 -0
  117. package/ai-dlc-skill/skill.yaml +135 -0
  118. package/ai-dlc-skill/templates/brownfield/context.md +78 -0
  119. package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
  120. package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
  121. package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
  122. package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
  123. package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
  124. package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
  125. package/ai-dlc-skill/templates/plan/design.md +49 -0
  126. package/ai-dlc-skill/templates/plan/task-list.md +32 -0
  127. package/ai-dlc-skill/templates/project/README.md +126 -0
  128. package/ai-dlc-skill/templates/project/template.md +119 -0
  129. package/ai-dlc-skill/templates/understand/feature.feature +30 -0
  130. package/ai-dlc-skill/templates/understand/intent.md +29 -0
  131. package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
  132. package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
  133. package/ai-dlc-skill/walkthrough/collect.sh +50 -0
  134. package/ai-dlc-skill/walkthrough/template.md +54 -0
  135. package/cdh/__init__.py +0 -0
  136. package/cdh/__main__.py +4 -0
  137. package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
  138. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  139. package/cdh/__pycache__/cli_logging.cpython-314.pyc +0 -0
  140. package/cdh/__pycache__/config.cpython-314.pyc +0 -0
  141. package/cdh/__pycache__/project_loader.cpython-314.pyc +0 -0
  142. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  143. package/cdh/__pycache__/session_store.cpython-314.pyc +0 -0
  144. package/cdh/cdh_session_aggregator.py +258 -0
  145. package/cdh/cli.py +3000 -0
  146. package/cdh/cli_logging.py +45 -0
  147. package/cdh/config.py +28 -0
  148. package/cdh/event_loop/__init__.py +7 -0
  149. package/cdh/event_loop/bus.py +123 -0
  150. package/cdh/event_loop/events.py +25 -0
  151. package/cdh/event_loop/runner.py +28 -0
  152. package/cdh/event_loop/scheduler.py +158 -0
  153. package/cdh/generators/__init__.py +35 -0
  154. package/cdh/generators/_runtime.py +176 -0
  155. package/cdh/generators/ada/MANIFEST.toml +14 -0
  156. package/cdh/generators/ada/template.ads.tmpl +7 -0
  157. package/cdh/generators/bash/MANIFEST.toml +14 -0
  158. package/cdh/generators/bash/template.sh.tmpl +16 -0
  159. package/cdh/generators/builtin_index.json +590 -0
  160. package/cdh/generators/c/MANIFEST.toml +14 -0
  161. package/cdh/generators/c/template.h.tmpl +20 -0
  162. package/cdh/generators/cli.py +379 -0
  163. package/cdh/generators/clojure/MANIFEST.toml +14 -0
  164. package/cdh/generators/clojure/template.clj.tmpl +13 -0
  165. package/cdh/generators/cobol/MANIFEST.toml +14 -0
  166. package/cdh/generators/cobol/template.cbl.tmpl +14 -0
  167. package/cdh/generators/cpp/MANIFEST.toml +14 -0
  168. package/cdh/generators/cpp/template.hpp.tmpl +32 -0
  169. package/cdh/generators/crystal/MANIFEST.toml +14 -0
  170. package/cdh/generators/crystal/template.cr.tmpl +11 -0
  171. package/cdh/generators/csharp/MANIFEST.toml +23 -0
  172. package/cdh/generators/csharp/template.cs.tmpl +14 -0
  173. package/cdh/generators/cython/MANIFEST.toml +14 -0
  174. package/cdh/generators/cython/template.pyx.tmpl +17 -0
  175. package/cdh/generators/d/MANIFEST.toml +14 -0
  176. package/cdh/generators/d/template.d.tmpl +24 -0
  177. package/cdh/generators/dart/MANIFEST.toml +17 -0
  178. package/cdh/generators/dart/template.dart.tmpl +27 -0
  179. package/cdh/generators/elixir/MANIFEST.toml +17 -0
  180. package/cdh/generators/elixir/template.ex.tmpl +27 -0
  181. package/cdh/generators/elm/MANIFEST.toml +14 -0
  182. package/cdh/generators/elm/template.elm.tmpl +20 -0
  183. package/cdh/generators/erlang/MANIFEST.toml +14 -0
  184. package/cdh/generators/erlang/template.erl.tmpl +18 -0
  185. package/cdh/generators/fortran/MANIFEST.toml +14 -0
  186. package/cdh/generators/fortran/template.f90.tmpl +24 -0
  187. package/cdh/generators/fsharp/MANIFEST.toml +14 -0
  188. package/cdh/generators/fsharp/template.fs.tmpl +17 -0
  189. package/cdh/generators/go/MANIFEST.toml +18 -0
  190. package/cdh/generators/go/imports.go.tmpl +3 -0
  191. package/cdh/generators/go/template.go.tmpl +9 -0
  192. package/cdh/generators/graphql-schema/MANIFEST.toml +18 -0
  193. package/cdh/generators/graphql-schema/template.graphqls.tmpl +12 -0
  194. package/cdh/generators/groovy/MANIFEST.toml +14 -0
  195. package/cdh/generators/groovy/template.groovy.tmpl +28 -0
  196. package/cdh/generators/hack/MANIFEST.toml +14 -0
  197. package/cdh/generators/hack/template.hh.tmpl +24 -0
  198. package/cdh/generators/haskell/MANIFEST.toml +14 -0
  199. package/cdh/generators/haskell/template.hs.tmpl +20 -0
  200. package/cdh/generators/haxe/MANIFEST.toml +14 -0
  201. package/cdh/generators/haxe/template.hx.tmpl +20 -0
  202. package/cdh/generators/idris/MANIFEST.toml +14 -0
  203. package/cdh/generators/idris/template.idr.tmpl +22 -0
  204. package/cdh/generators/java/MANIFEST.toml +18 -0
  205. package/cdh/generators/java/template.java.tmpl +13 -0
  206. package/cdh/generators/julia/MANIFEST.toml +14 -0
  207. package/cdh/generators/julia/template.jl.tmpl +27 -0
  208. package/cdh/generators/kotlin/MANIFEST.toml +18 -0
  209. package/cdh/generators/kotlin/template.kt.tmpl +12 -0
  210. package/cdh/generators/lua/MANIFEST.toml +14 -0
  211. package/cdh/generators/lua/template.lua.tmpl +27 -0
  212. package/cdh/generators/matlab/MANIFEST.toml +14 -0
  213. package/cdh/generators/matlab/template.m.tmpl +26 -0
  214. package/cdh/generators/nim/MANIFEST.toml +14 -0
  215. package/cdh/generators/nim/template.nim.tmpl +12 -0
  216. package/cdh/generators/nix/MANIFEST.toml +14 -0
  217. package/cdh/generators/nix/template.nix.tmpl +8 -0
  218. package/cdh/generators/objective-c/MANIFEST.toml +14 -0
  219. package/cdh/generators/objective-c/template.m.tmpl +31 -0
  220. package/cdh/generators/ocaml/MANIFEST.toml +14 -0
  221. package/cdh/generators/ocaml/template.ml.tmpl +13 -0
  222. package/cdh/generators/pascal/MANIFEST.toml +14 -0
  223. package/cdh/generators/pascal/template.pas.tmpl +5 -0
  224. package/cdh/generators/perl/MANIFEST.toml +14 -0
  225. package/cdh/generators/perl/template.pm.tmpl +27 -0
  226. package/cdh/generators/php/MANIFEST.toml +17 -0
  227. package/cdh/generators/php/template.php.tmpl +31 -0
  228. package/cdh/generators/powershell/MANIFEST.toml +14 -0
  229. package/cdh/generators/powershell/template.ps1.tmpl +20 -0
  230. package/cdh/generators/protobuf/MANIFEST.toml +18 -0
  231. package/cdh/generators/protobuf/template.proto.tmpl +13 -0
  232. package/cdh/generators/python/MANIFEST.toml +18 -0
  233. package/cdh/generators/python/template.py.tmpl +12 -0
  234. package/cdh/generators/r/MANIFEST.toml +14 -0
  235. package/cdh/generators/r/template.R.tmpl +29 -0
  236. package/cdh/generators/racket/MANIFEST.toml +14 -0
  237. package/cdh/generators/racket/template.rkt.tmpl +15 -0
  238. package/cdh/generators/registry.py +211 -0
  239. package/cdh/generators/ruby/MANIFEST.toml +17 -0
  240. package/cdh/generators/ruby/template.rb.tmpl +27 -0
  241. package/cdh/generators/rust/MANIFEST.toml +18 -0
  242. package/cdh/generators/rust/template.rs.tmpl +16 -0
  243. package/cdh/generators/scala/MANIFEST.toml +20 -0
  244. package/cdh/generators/scala/template.scala.tmpl +13 -0
  245. package/cdh/generators/scheme/MANIFEST.toml +14 -0
  246. package/cdh/generators/scheme/template.scm.tmpl +16 -0
  247. package/cdh/generators/solidity/MANIFEST.toml +14 -0
  248. package/cdh/generators/solidity/template.sol.tmpl +24 -0
  249. package/cdh/generators/swift/MANIFEST.toml +18 -0
  250. package/cdh/generators/swift/template.swift.tmpl +10 -0
  251. package/cdh/generators/typescript/MANIFEST.toml +18 -0
  252. package/cdh/generators/typescript/template.ts.tmpl +7 -0
  253. package/cdh/generators/version.py +214 -0
  254. package/cdh/generators/zig/MANIFEST.toml +14 -0
  255. package/cdh/generators/zig/template.zig.tmpl +20 -0
  256. package/cdh/logging.py +314 -0
  257. package/cdh/optimizer/__init__.py +7 -0
  258. package/cdh/optimizer/loop.py +201 -0
  259. package/cdh/optimizer/mutation.py +85 -0
  260. package/cdh/optimizer/reward.py +51 -0
  261. package/cdh/optimizer/tracker.py +118 -0
  262. package/cdh/project_loader.py +1022 -0
  263. package/cdh/rules/patterns.yaml +132 -0
  264. package/cdh/rules/spectral.yaml +301 -0
  265. package/cdh/scaffold.py +1436 -0
  266. package/cdh/session_store.py +221 -0
  267. package/cdh/state_schema.py +127 -0
  268. package/cdh/tools/__init__.py +57 -0
  269. package/cdh/tools/cls_search.py +406 -0
  270. package/cdh/tools/contract_diff.py +644 -0
  271. package/cdh/tools/deploy_stack.py +590 -0
  272. package/cdh/tools/generate_shared.py +300 -0
  273. package/cdh/tools/iac_gen.py +224 -0
  274. package/cdh/tools/otel_export.py +125 -0
  275. package/cdh/tools/pattern_detect.py +451 -0
  276. package/cdh/tools/retrofit_labels.py +505 -0
  277. package/cdh/trace/__init__.py +101 -0
  278. package/cdh/trace/__pycache__/__init__.cpython-314.pyc +0 -0
  279. package/cdh/trace/dashboard/__init__.py +61 -0
  280. package/cdh/trace/dashboard/api.py +1032 -0
  281. package/cdh/trace/dashboard/app.html +1321 -0
  282. package/cdh/trace/dashboard/server.py +310 -0
  283. package/cdh/trace/otel_exporter.py +462 -0
  284. package/cdh/trace/otel_tracer.py +281 -0
  285. package/cdh/tui.py +1084 -0
  286. package/cdh/validators/__init__.py +16 -0
  287. package/cdh/validators/__pycache__/__init__.cpython-314.pyc +0 -0
  288. package/cdh/validators/__pycache__/bdd.cpython-314.pyc +0 -0
  289. package/cdh/validators/__pycache__/dag.cpython-314.pyc +0 -0
  290. package/cdh/validators/__pycache__/ears.cpython-314.pyc +0 -0
  291. package/cdh/validators/__pycache__/fr_namespace.cpython-314.pyc +0 -0
  292. package/cdh/validators/bdd.py +144 -0
  293. package/cdh/validators/dag.py +179 -0
  294. package/cdh/validators/ears.py +452 -0
  295. package/cdh/validators/fr_namespace.py +143 -0
  296. package/cdh/validators/spectral.py +475 -0
  297. package/cdh/verification/__init__.py +3 -0
  298. package/cdh/verification/aggregation.py +53 -0
  299. package/cdh/verification/gates/__init__.py +5 -0
  300. package/cdh/verification/gates/base.py +18 -0
  301. package/cdh/verification/gates/lint_gate.py +37 -0
  302. package/cdh/verification/gates/test_gate.py +41 -0
  303. package/cdh/verification/loop.py +93 -0
  304. package/cdh/verification/policy.py +24 -0
  305. package/onecode/__init__.py +1 -0
  306. package/onecode/__main__.py +4 -0
  307. package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
  308. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  309. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  310. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  311. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  312. package/onecode/__pycache__/migrate.cpython-314.pyc +0 -0
  313. package/onecode/agent/__init__.py +0 -0
  314. package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
  315. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  316. package/onecode/agent/__pycache__/checkpoint.cpython-314.pyc +0 -0
  317. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  318. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  319. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  320. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  321. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  322. package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
  323. package/onecode/agent/__pycache__/project_doc.cpython-314.pyc +0 -0
  324. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  325. package/onecode/agent/__pycache__/turn_record.cpython-314.pyc +0 -0
  326. package/onecode/agent/agents/__init__.py +29 -0
  327. package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  328. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  329. package/onecode/agent/agents/prompts/build-agent.md +50 -0
  330. package/onecode/agent/agents/prompts/compaction-instructions.md +12 -0
  331. package/onecode/agent/agents/prompts/explore-agent.md +42 -0
  332. package/onecode/agent/agents/prompts/plan-agent.md +44 -0
  333. package/onecode/agent/agents/prompts/plan-gate-hard.md +4 -0
  334. package/onecode/agent/agents/prompts/plan-gate-soft.md +3 -0
  335. package/onecode/agent/agents/prompts/solo-agent.md +57 -0
  336. package/onecode/agent/agents/prompts/subagent-constraints.md +10 -0
  337. package/onecode/agent/agents/prompts/summary-instructions.md +7 -0
  338. package/onecode/agent/agents/prompts/title-instructions.md +3 -0
  339. package/onecode/agent/agents/prompts/tool-descriptions.md +129 -0
  340. package/onecode/agent/agents/types.py +786 -0
  341. package/onecode/agent/attachments.py +112 -0
  342. package/onecode/agent/cdh_loader.py +3 -0
  343. package/onecode/agent/checkpoint.py +285 -0
  344. package/onecode/agent/context.py +465 -0
  345. package/onecode/agent/engine.py +3567 -0
  346. package/onecode/agent/event_bridge.py +75 -0
  347. package/onecode/agent/hooks.py +86 -0
  348. package/onecode/agent/onecode_agent_acp.py +2993 -0
  349. package/onecode/agent/permissions.py +150 -0
  350. package/onecode/agent/permissions_store.py +73 -0
  351. package/onecode/agent/project_doc.py +19 -0
  352. package/onecode/agent/question_detect.py +230 -0
  353. package/onecode/agent/session.py +3 -0
  354. package/onecode/agent/snapshot.py +144 -0
  355. package/onecode/agent/tools/__init__.py +17 -0
  356. package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  357. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  358. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  359. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  360. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  361. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  362. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  363. package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
  364. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  365. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  366. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  367. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  368. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  369. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  370. package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
  371. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  372. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  373. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  374. package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
  375. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  376. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  377. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  378. package/onecode/agent/tools/agent_tools.py +128 -0
  379. package/onecode/agent/tools/apply_patch_tool.py +138 -0
  380. package/onecode/agent/tools/bash_tool.py +121 -0
  381. package/onecode/agent/tools/communication_tools.py +154 -0
  382. package/onecode/agent/tools/config_tool.py +130 -0
  383. package/onecode/agent/tools/cron_tools.py +178 -0
  384. package/onecode/agent/tools/errors.py +17 -0
  385. package/onecode/agent/tools/file_ops.py +221 -0
  386. package/onecode/agent/tools/file_tools.py +225 -0
  387. package/onecode/agent/tools/git_tools.py +88 -0
  388. package/onecode/agent/tools/lsp_tools.py +404 -0
  389. package/onecode/agent/tools/mcp_tools.py +211 -0
  390. package/onecode/agent/tools/permission_handler.py +46 -0
  391. package/onecode/agent/tools/permissions.py +77 -0
  392. package/onecode/agent/tools/protocol.py +20 -0
  393. package/onecode/agent/tools/registry.py +210 -0
  394. package/onecode/agent/tools/sandbox.py +460 -0
  395. package/onecode/agent/tools/schema_validation.py +100 -0
  396. package/onecode/agent/tools/skill_tools.py +52 -0
  397. package/onecode/agent/tools/todo_tools.py +208 -0
  398. package/onecode/agent/tools/web_tools.py +530 -0
  399. package/onecode/agent/turn_record.py +28 -0
  400. package/onecode/builtin_skills/agent-browser/SKILL.md +212 -0
  401. package/onecode/builtin_skills/agent-browser/skill.yaml +8 -0
  402. package/onecode/builtin_skills/cloudbase/SKILL.md +162 -0
  403. package/onecode/builtin_skills/cloudbase/skill.yaml +7 -0
  404. package/onecode/builtin_skills/git/SKILL.md +64 -0
  405. package/onecode/builtin_skills/git/skill.yaml +7 -0
  406. package/onecode/builtin_skills/shell/SKILL.md +58 -0
  407. package/onecode/builtin_skills/shell/skill.yaml +8 -0
  408. package/onecode/builtin_skills/skill-creator/SKILL.md +106 -0
  409. package/onecode/builtin_skills/skill-creator/skill.yaml +8 -0
  410. package/onecode/cli.py +1325 -0
  411. package/onecode/codebase/__init__.py +69 -0
  412. package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
  413. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  414. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  415. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  416. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  417. package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
  418. package/onecode/codebase/chunker.py +125 -0
  419. package/onecode/codebase/indexer.py +154 -0
  420. package/onecode/codebase/retriever.py +240 -0
  421. package/onecode/codebase/storage.py +153 -0
  422. package/onecode/codebase/tools.py +59 -0
  423. package/onecode/commands.py +918 -0
  424. package/onecode/config.py +539 -0
  425. package/onecode/config_screen.py +486 -0
  426. package/onecode/cron/__init__.py +3 -0
  427. package/onecode/lsp/__init__.py +3 -0
  428. package/onecode/mcp/__init__.py +11 -0
  429. package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  430. package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
  431. package/onecode/mcp/__pycache__/cloudbase.cpython-314.pyc +0 -0
  432. package/onecode/mcp/__pycache__/config.cpython-314.pyc +0 -0
  433. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  434. package/onecode/mcp/__pycache__/oauth.cpython-314.pyc +0 -0
  435. package/onecode/mcp/client.py +305 -0
  436. package/onecode/mcp/cloudbase.py +188 -0
  437. package/onecode/mcp/config.py +363 -0
  438. package/onecode/mcp/manager.py +603 -0
  439. package/onecode/mcp/oauth.py +199 -0
  440. package/onecode/memory/__init__.py +106 -0
  441. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  442. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  443. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  444. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  445. package/onecode/memory/backend.py +179 -0
  446. package/onecode/memory/pyramid.py +145 -0
  447. package/onecode/memory/recall.py +322 -0
  448. package/onecode/migrate.py +122 -0
  449. package/onecode/models/__init__.py +4 -0
  450. package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
  451. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  452. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  453. package/onecode/models/__pycache__/ollama_manager.cpython-314.pyc +0 -0
  454. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  455. package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
  456. package/onecode/models/errors.py +104 -0
  457. package/onecode/models/messages.py +750 -0
  458. package/onecode/models/ollama_manager.py +167 -0
  459. package/onecode/models/provider.py +730 -0
  460. package/onecode/models/providers/__init__.py +17 -0
  461. package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  462. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  463. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  464. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  465. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  466. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  467. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  468. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  469. package/onecode/models/providers/anthropic_provider.py +286 -0
  470. package/onecode/models/providers/deepseek_provider.py +221 -0
  471. package/onecode/models/providers/glm_provider.py +219 -0
  472. package/onecode/models/providers/minimax_provider.py +182 -0
  473. package/onecode/models/providers/minimaxi_provider.py +426 -0
  474. package/onecode/models/providers/ollama_provider.py +183 -0
  475. package/onecode/models/providers/openai_provider.py +234 -0
  476. package/onecode/models/registry.py +349 -0
  477. package/onecode/server/__init__.py +3 -0
  478. package/onecode/server/app.py +251 -0
  479. package/onecode/skills/__init__.py +11 -0
  480. package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
  481. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  482. package/onecode/skills/__pycache__/bootstrap.cpython-314.pyc +0 -0
  483. package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
  484. package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
  485. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  486. package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
  487. package/onecode/skills/argument_substitution.py +38 -0
  488. package/onecode/skills/bootstrap.py +37 -0
  489. package/onecode/skills/create.py +56 -0
  490. package/onecode/skills/frontmatter.py +33 -0
  491. package/onecode/skills/loader.py +215 -0
  492. package/onecode/skills/manager.py +75 -0
  493. package/onecode/skills/model.py +43 -0
  494. package/onecode/storage/__init__.py +0 -0
  495. package/onecode/storage/activity.py +63 -0
  496. package/onecode/storage/project.py +54 -0
  497. package/onecode/storage/session.py +112 -0
  498. package/onecode/tasks/__init__.py +26 -0
  499. package/onecode/tasks/manager.py +232 -0
  500. package/onecode/tasks/models.py +240 -0
  501. package/onecode/trace/__init__.py +0 -0
  502. package/onecode/trace/tracer.py +173 -0
  503. package/onecode/utils/__init__.py +0 -0
  504. package/onecode/utils/helpers.py +20 -0
  505. package/onecode/verification/__init__.py +5 -0
  506. package/onecode/verification/__pycache__/__init__.cpython-314.pyc +0 -0
  507. package/onecode/verification/__pycache__/aggregation.cpython-314.pyc +0 -0
  508. package/onecode/verification/__pycache__/loop.cpython-314.pyc +0 -0
  509. package/onecode/verification/__pycache__/policy.cpython-314.pyc +0 -0
  510. package/onecode/verification/aggregation.py +53 -0
  511. package/onecode/verification/gates/__init__.py +6 -0
  512. package/onecode/verification/gates/__pycache__/__init__.cpython-314.pyc +0 -0
  513. package/onecode/verification/gates/__pycache__/base.cpython-314.pyc +0 -0
  514. package/onecode/verification/gates/__pycache__/lint_gate.cpython-314.pyc +0 -0
  515. package/onecode/verification/gates/__pycache__/test_gate.cpython-314.pyc +0 -0
  516. package/onecode/verification/gates/__pycache__/type_gate.cpython-314.pyc +0 -0
  517. package/onecode/verification/gates/base.py +20 -0
  518. package/onecode/verification/gates/lint_gate.py +45 -0
  519. package/onecode/verification/gates/test_gate.py +62 -0
  520. package/onecode/verification/gates/type_gate.py +46 -0
  521. package/onecode/verification/loop.py +95 -0
  522. package/onecode/verification/policy.py +29 -0
  523. package/package.json +2 -9
  524. package/pyproject.toml +84 -0
  525. package/tui/__init__.py +46 -0
  526. package/tui/__main__.py +4 -0
  527. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  528. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  529. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  530. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  531. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  532. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  533. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  534. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  535. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  536. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  537. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  538. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  539. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  540. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  541. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  542. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  543. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  544. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  545. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  546. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  547. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  548. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  549. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  550. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  551. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  552. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  553. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  554. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  555. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  556. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  557. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  558. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  559. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  560. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  561. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  562. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  563. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  564. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  565. package/tui/_loop.py +86 -0
  566. package/tui/_path_fuzzy_search.py +48 -0
  567. package/tui/_path_match.py +141 -0
  568. package/tui/about.py +95 -0
  569. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  570. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  571. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  572. package/tui/acp/__pycache__/event_tap.cpython-314.pyc +0 -0
  573. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  574. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  575. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  576. package/tui/acp/agent.py +1212 -0
  577. package/tui/acp/api.py +79 -0
  578. package/tui/acp/encode_tool_call_id.py +12 -0
  579. package/tui/acp/event_tap.py +107 -0
  580. package/tui/acp/messages.py +222 -0
  581. package/tui/acp/prompt.py +53 -0
  582. package/tui/acp/protocol.py +551 -0
  583. package/tui/agent.py +70 -0
  584. package/tui/agent_schema.py +76 -0
  585. package/tui/agents.py +46 -0
  586. package/tui/ansi/__init__.py +1 -0
  587. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  588. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  589. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  590. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  591. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  592. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  593. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  594. package/tui/ansi/_ansi.py +1689 -0
  595. package/tui/ansi/_ansi_colors.py +264 -0
  596. package/tui/ansi/_control_codes.py +37 -0
  597. package/tui/ansi/_keys.py +251 -0
  598. package/tui/ansi/_sgr_styles.py +64 -0
  599. package/tui/ansi/_stream_parser.py +418 -0
  600. package/tui/answer.py +12 -0
  601. package/tui/app.py +1204 -0
  602. package/tui/atomic.py +37 -0
  603. package/tui/cli.py +208 -0
  604. package/tui/code_analyze.py +27 -0
  605. package/tui/complete.py +38 -0
  606. package/tui/constants.py +61 -0
  607. package/tui/conversation_markdown.py +30 -0
  608. package/tui/danger.py +372 -0
  609. package/tui/data/agents/ampcode.com.toml +52 -0
  610. package/tui/data/agents/augmentcode.com.toml +41 -0
  611. package/tui/data/agents/cdh.cloud-dev-harness.toml +23 -0
  612. package/tui/data/agents/claude.com.toml +42 -0
  613. package/tui/data/agents/copilot.github.com.toml +29 -0
  614. package/tui/data/agents/crowai.dev.toml +36 -0
  615. package/tui/data/agents/cursor.com.toml +38 -0
  616. package/tui/data/agents/deepagents.langchain.com +34 -0
  617. package/tui/data/agents/docker.com.toml +60 -0
  618. package/tui/data/agents/geminicli.com.toml +28 -0
  619. package/tui/data/agents/goose.ai.toml +52 -0
  620. package/tui/data/agents/inference.huggingface.co.toml +41 -0
  621. package/tui/data/agents/kimi.com.toml +35 -0
  622. package/tui/data/agents/kiro.dev.toml +43 -0
  623. package/tui/data/agents/onecode.dev.toml +22 -0
  624. package/tui/data/agents/openai.com.toml +53 -0
  625. package/tui/data/agents/openclaw.ai.toml +37 -0
  626. package/tui/data/agents/opencode.ai.toml +39 -0
  627. package/tui/data/agents/openhands.dev.toml +46 -0
  628. package/tui/data/agents/stakpak.dev.toml +62 -0
  629. package/tui/data/agents/vibe.mistral.ai.toml +31 -0
  630. package/tui/data/agents/vtcode.dev.toml +63 -0
  631. package/tui/data/images/frog.png +0 -0
  632. package/tui/data/sounds/question.wav +0 -0
  633. package/tui/data/sounds/turn-over.wav +0 -0
  634. package/tui/db.py +213 -0
  635. package/tui/dec.py +332 -0
  636. package/tui/diff_utils.py +131 -0
  637. package/tui/directory.py +234 -0
  638. package/tui/directory_suggester.py +93 -0
  639. package/tui/directory_watcher.py +231 -0
  640. package/tui/format_path.py +25 -0
  641. package/tui/fuzzy.py +140 -0
  642. package/tui/fuzzy_index.py +275 -0
  643. package/tui/gist.py +33 -0
  644. package/tui/history.py +138 -0
  645. package/tui/jsonrpc.py +599 -0
  646. package/tui/menus.py +14 -0
  647. package/tui/message_log.py +69 -0
  648. package/tui/messages.py +144 -0
  649. package/tui/option_content.py +51 -0
  650. package/tui/os.py +0 -0
  651. package/tui/path_complete.py +217 -0
  652. package/tui/path_filter.py +124 -0
  653. package/tui/paths.py +87 -0
  654. package/tui/pill.py +23 -0
  655. package/tui/platform_commands.py +52 -0
  656. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  657. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  658. package/tui/prompt/extract.py +19 -0
  659. package/tui/prompt/resource.py +68 -0
  660. package/tui/protocol.py +28 -0
  661. package/tui/screens/__init__.py +0 -0
  662. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  663. package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
  664. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  665. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  666. package/tui/screens/action_modal.py +94 -0
  667. package/tui/screens/agent_modal.py +175 -0
  668. package/tui/screens/command_edit_modal.py +74 -0
  669. package/tui/screens/diff_explorer_screen.py +419 -0
  670. package/tui/screens/diff_screen.py +84 -0
  671. package/tui/screens/ide_screen.py +461 -0
  672. package/tui/screens/main.py +397 -0
  673. package/tui/screens/main.tcss +662 -0
  674. package/tui/screens/permissions.py +404 -0
  675. package/tui/screens/permissions.tcss +72 -0
  676. package/tui/screens/projects.tcss +84 -0
  677. package/tui/screens/projects_app.py +341 -0
  678. package/tui/screens/projects_screen.py +277 -0
  679. package/tui/screens/session_resume_modal.py +172 -0
  680. package/tui/screens/session_resume_modal.tcss +35 -0
  681. package/tui/screens/sessions.py +168 -0
  682. package/tui/screens/sessions.tcss +119 -0
  683. package/tui/screens/settings.py +293 -0
  684. package/tui/screens/settings.tcss +113 -0
  685. package/tui/screens/store.py +502 -0
  686. package/tui/screens/store.tcss +274 -0
  687. package/tui/screens/tool_content_screen.py +59 -0
  688. package/tui/screens/trace.py +317 -0
  689. package/tui/session_tracker.py +123 -0
  690. package/tui/settings.py +353 -0
  691. package/tui/settings_schema.py +467 -0
  692. package/tui/shell.py +304 -0
  693. package/tui/shell_read.py +42 -0
  694. package/tui/slash_command.py +34 -0
  695. package/tui/tui.tcss +246 -0
  696. package/tui/twitter.py +38 -0
  697. package/tui/version.py +80 -0
  698. package/tui/visuals/__init__.py +0 -0
  699. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  700. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  701. package/tui/visuals/columns.py +272 -0
  702. package/tui/widgets/__init__.py +0 -0
  703. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  704. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  705. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  706. package/tui/widgets/__pycache__/aidlc.cpython-314.pyc +0 -0
  707. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  708. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  709. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  710. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  711. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  712. package/tui/widgets/__pycache__/edit_field.cpython-314.pyc +0 -0
  713. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  714. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  715. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  716. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  717. package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
  718. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  719. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  720. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  721. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  722. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  723. package/tui/widgets/__pycache__/pending_prompts.cpython-314.pyc +0 -0
  724. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  725. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  726. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  727. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  728. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  729. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  730. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  731. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  732. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  733. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  734. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  735. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  736. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  737. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  738. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  739. package/tui/widgets/acp_content.py +70 -0
  740. package/tui/widgets/agent_response.py +76 -0
  741. package/tui/widgets/agent_thought.py +167 -0
  742. package/tui/widgets/aidlc.py +334 -0
  743. package/tui/widgets/ask_user.py +604 -0
  744. package/tui/widgets/command_pane.py +223 -0
  745. package/tui/widgets/condensed_path.py +115 -0
  746. package/tui/widgets/context_stats.py +66 -0
  747. package/tui/widgets/conversation.py +3234 -0
  748. package/tui/widgets/danger_warning.py +65 -0
  749. package/tui/widgets/diff_view.py +69 -0
  750. package/tui/widgets/directory_input.py +8 -0
  751. package/tui/widgets/edit_field.py +206 -0
  752. package/tui/widgets/flash.py +81 -0
  753. package/tui/widgets/future_text.py +126 -0
  754. package/tui/widgets/grid_select.py +234 -0
  755. package/tui/widgets/highlighted_textarea.py +183 -0
  756. package/tui/widgets/load_more.py +24 -0
  757. package/tui/widgets/mandelbrot.py +294 -0
  758. package/tui/widgets/markdown_note.py +13 -0
  759. package/tui/widgets/menu.py +147 -0
  760. package/tui/widgets/model_status.py +230 -0
  761. package/tui/widgets/modified_files.py +315 -0
  762. package/tui/widgets/non_selectable_label.py +5 -0
  763. package/tui/widgets/note.py +18 -0
  764. package/tui/widgets/path_search.py +492 -0
  765. package/tui/widgets/pending_prompts.py +36 -0
  766. package/tui/widgets/plan.py +285 -0
  767. package/tui/widgets/project_directory_tree.py +102 -0
  768. package/tui/widgets/project_grid_select.py +68 -0
  769. package/tui/widgets/project_summary.py +33 -0
  770. package/tui/widgets/prompt.py +935 -0
  771. package/tui/widgets/question.py +360 -0
  772. package/tui/widgets/session_grid_select.py +230 -0
  773. package/tui/widgets/session_summary.py +115 -0
  774. package/tui/widgets/session_tabs.py +194 -0
  775. package/tui/widgets/shell_result.py +35 -0
  776. package/tui/widgets/shell_terminal.py +40 -0
  777. package/tui/widgets/side_bar.py +124 -0
  778. package/tui/widgets/slash_complete.py +235 -0
  779. package/tui/widgets/strike_text.py +70 -0
  780. package/tui/widgets/subagent.py +287 -0
  781. package/tui/widgets/subagent_screen.py +715 -0
  782. package/tui/widgets/terminal.py +576 -0
  783. package/tui/widgets/terminal_tool.py +360 -0
  784. package/tui/widgets/throbber.py +100 -0
  785. package/tui/widgets/tool_call.py +432 -0
  786. package/tui/widgets/user_input.py +23 -0
  787. package/tui/widgets/version.py +5 -0
  788. package/tui/widgets/welcome.py +31 -0
@@ -0,0 +1,3234 @@
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
+ "Down",
307
+ priority=True,
308
+ group=CURSOR_BINDING_GROUP,
309
+ ),
310
+ Binding(
311
+ "ctrl+k",
312
+ "cursor_up",
313
+ "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
+ show=False,
323
+ ),
324
+ Binding(
325
+ "ctrl+x",
326
+ "toggle_expand",
327
+ "Toggle",
328
+ tooltip="Toggle expand/collapse cursor block",
329
+ priority=True,
330
+ ),
331
+ Binding(
332
+ "escape",
333
+ "cancel",
334
+ "Cancel",
335
+ tooltip="Cancel agent's turn",
336
+ priority=True,
337
+ show=False,
338
+ ),
339
+ Binding(
340
+ "ctrl+f",
341
+ "focus_terminal",
342
+ "Focus",
343
+ tooltip="Focus the active terminal",
344
+ priority=True,
345
+ ),
346
+ Binding(
347
+ "ctrl+o",
348
+ "mode_switcher",
349
+ "Modes",
350
+ tooltip="Open the mode switcher",
351
+ ),
352
+ Binding(
353
+ "ctrl+n",
354
+ "fullscreen_block",
355
+ "Full",
356
+ tooltip="Open full-screen view of the cursor block",
357
+ ),
358
+ Binding(
359
+ "ctrl+c",
360
+ "interrupt",
361
+ "Interrupt",
362
+ tooltip="Interrupt running command",
363
+ ),
364
+ ]
365
+
366
+ busy_count = var(0)
367
+ cursor_offset = var(-1, init=False)
368
+ project_path = var("")
369
+ working_directory: var[str] = var("")
370
+ _blocks: var[list[MarkdownBlock] | None] = var(None)
371
+
372
+ throbber: getters.query_one[Throbber] = getters.query_one("#throbber")
373
+ contents = getters.query_one(Contents)
374
+ window = getters.query_one(Window)
375
+ cursor = getters.query_one(Cursor)
376
+ prompt = getters.query_one(Prompt)
377
+ app = getters.app(A2TUIApp)
378
+
379
+ _shell: var[Shell | None] = var(None)
380
+ shell_history_index: var[int] = var(0, init=False)
381
+ prompt_history_index: var[int] = var(0, init=False)
382
+
383
+ agent: var[AgentBase | None] = var(None, bindings=True)
384
+ agent_info: var[Content] = var(Content())
385
+ agent_ready: var[bool] = var(False)
386
+ modes: var[dict[str, Mode]] = var({}, bindings=True)
387
+ current_mode: var[Mode | None] = var(None)
388
+ turn: var[Literal["agent", "client"] | None] = var(None, bindings=True)
389
+ status: var[str] = var("")
390
+ column: var[bool] = var(False, toggle_class="-column")
391
+
392
+ title = var("")
393
+ _agent_session_title: var[str] = var("")
394
+
395
+ def __init__(
396
+ self,
397
+ project_path: Path,
398
+ agent: AgentData | None = None,
399
+ agent_session_id: str | None = None,
400
+ session_pk: int | None = None,
401
+ initial_prompt: str | None = None,
402
+ ) -> None:
403
+ super().__init__()
404
+
405
+ project_path = project_path.resolve().absolute()
406
+
407
+ self.set_reactive(Conversation.project_path, project_path)
408
+ self.set_reactive(Conversation.working_directory, str(project_path))
409
+ self.agent_slash_commands: list[SlashCommand] = []
410
+ self.terminals: dict[str, TerminalTool] = {}
411
+ self._loading: Loading | None = None
412
+ self._agent_response: AgentResponse | None = None
413
+ self._agent_thought: AgentThought | None = None
414
+ self._replay: bool = False
415
+ self._replay_buffer: list[dict] = []
416
+ self._active_subagents: dict = {}
417
+ self._last_escape_time: float = 0.0
418
+ self._agent_data = agent
419
+ self._agent_session_id = agent_session_id
420
+ self._session_pk = session_pk
421
+ self._agent_fail = False
422
+ self._mouse_down_offset: Offset | None = None
423
+
424
+ self._focusable_terminals: list[Terminal] = []
425
+
426
+ self.project_data_path = paths.get_project_data(project_path)
427
+ self.shell_history = History(self.project_data_path / "shell_history.jsonl")
428
+ self.prompt_history = History(self.project_data_path / "prompt_history.jsonl")
429
+
430
+ self.session_start_time: float | None = None
431
+ self._terminal_count = 0
432
+ self._require_check_prune = False
433
+
434
+ self._turn_count = 0
435
+ self._shell_count = 0
436
+ self._tool_call_total = 0
437
+
438
+ self._tool_call_success = 0
439
+ self._tool_call_failed = 0
440
+ self._tool_call_finalized: set[str] = set()
441
+
442
+ self._directory_changed = False
443
+ self._directory_watcher: DirectoryWatcher | None = None
444
+ self._suppress_directory_changed = False
445
+
446
+ self._initial_prompt = initial_prompt
447
+
448
+ self._msg_log: MessageLog | None = None
449
+
450
+ self._post_lock = asyncio.Lock()
451
+ self._auto_named_session = False
452
+ self._pending_prompts: list[str] = []
453
+ self._pending_ask_user: bool = False
454
+
455
+ self._total_messages: int = 0
456
+ self._visible_count: int = 0
457
+ self._loaded_up_to: int = 0
458
+ self._load_more_widget: LoadMoreIndicator | None = None
459
+ self._flush_append_at: int | None = None
460
+
461
+ def update_title(self) -> None:
462
+ """Update the screen title."""
463
+ screen = self.screen
464
+ if self._agent_session_title:
465
+ screen.title = self._agent_session_title
466
+ elif agent_title := self.agent_title:
467
+ project_path = format_path(self.project_path)
468
+ screen.title = f"{agent_title} {project_path}"
469
+ else:
470
+ screen.title = ""
471
+
472
+ @property
473
+ def agent_title(self) -> str | None:
474
+ if self._agent_data is not None:
475
+ return self._agent_data["name"]
476
+ return None
477
+
478
+ @property
479
+ def is_watching_directory(self) -> bool:
480
+ """Is the directory watcher enabled and watching?"""
481
+ if self._directory_watcher is None:
482
+ return False
483
+ return self._directory_watcher.enabled
484
+
485
+ def validate_shell_history_index(self, index: int) -> int:
486
+ return clamp(index, -self.shell_history.size, 0)
487
+
488
+ def validate_prompt_history_index(self, index: int) -> int:
489
+ return clamp(index, -self.prompt_history.size, 0)
490
+
491
+ def shell_complete(self, prefix: str) -> list[str]:
492
+ completes = self.shell_history.complete(prefix)
493
+ return completes
494
+
495
+ def insert_path_into_prompt(self, path: Path) -> None:
496
+ try:
497
+ insert_path_text = str(path.relative_to(self.project_path))
498
+ except Exception:
499
+ self.app.bell()
500
+ return
501
+
502
+ insert_text = (
503
+ f'@"{insert_path_text}"'
504
+ if " " in insert_path_text
505
+ else f"@{insert_path_text}"
506
+ )
507
+ self.prompt.prompt_text_area.insert(insert_text)
508
+ self.prompt.prompt_text_area.insert(" ")
509
+
510
+ def watch_project_path(self, path: Path) -> None:
511
+ self.working_directory = str(path)
512
+ self.post_message(messages.SessionUpdate(path=str(path)))
513
+
514
+ async def watch_shell_history_index(self, previous_index: int, index: int) -> None:
515
+ if previous_index == 0:
516
+ self.shell_history.current = self.prompt.text
517
+ try:
518
+ history_entry = await self.shell_history.get_entry(index)
519
+ except IndexError:
520
+ pass
521
+ else:
522
+ self.prompt.text = history_entry["input"]
523
+ self.prompt.shell_mode = True
524
+
525
+ async def watch_prompt_history_index(self, previous_index: int, index: int) -> None:
526
+ if previous_index == 0:
527
+ self.prompt_history.current = self.prompt.text
528
+ try:
529
+ history_entry = await self.prompt_history.get_entry(index)
530
+ except IndexError:
531
+ pass
532
+ else:
533
+ self.prompt.text = history_entry["input"]
534
+
535
+ def watch_turn(self, turn: str) -> None:
536
+ if turn == "client":
537
+ self.post_message(messages.SessionUpdate(state="idle"))
538
+ elif turn == "agent":
539
+ self.post_message(messages.SessionUpdate(state="busy"))
540
+
541
+ @on(events.Key)
542
+ async def on_key(self, event: events.Key):
543
+ if (
544
+ event.character is not None
545
+ and event.is_printable
546
+ and (event.character.isalnum() or event.character in "$/!")
547
+ and self.window.has_focus
548
+ ):
549
+ event.stop()
550
+ self.prompt.focus()
551
+ self.prompt.prompt_text_area.post_message(event)
552
+
553
+ def compose(self) -> ComposeResult:
554
+ yield Throbber(id="throbber")
555
+ yield SessionsTabs()
556
+ with Window():
557
+ with ContentsGrid():
558
+ with CursorContainer(id="cursor-container"):
559
+ yield Cursor()
560
+ yield Contents(id="contents")
561
+ yield Flash()
562
+ yield Prompt(complete_callback=self.shell_complete).data_bind(
563
+ project_path=Conversation.project_path,
564
+ working_directory=Conversation.working_directory,
565
+ agent_info=Conversation.agent_info,
566
+ agent_ready=Conversation.agent_ready,
567
+ current_mode=Conversation.current_mode,
568
+ modes=Conversation.modes,
569
+ status=Conversation.status,
570
+ )
571
+
572
+ @property
573
+ def _terminal(self) -> Terminal | None:
574
+ """Return the last focusable terminal, if there is one.
575
+
576
+ Returns:
577
+ A focusable (non finalized) terminal.
578
+ """
579
+ # Terminals should be removed in response to the Terminal.FInalized message
580
+ # This is a bit of a sanity check
581
+ self._focusable_terminals[:] = list(
582
+ filterfalse(attrgetter("is_finalized"), self._focusable_terminals)
583
+ )
584
+
585
+ for terminal in reversed(self._focusable_terminals):
586
+ if terminal.display:
587
+ return terminal
588
+ return None
589
+
590
+ def add_focusable_terminal(self, terminal: Terminal) -> None:
591
+ """Add a focusable terminal.
592
+
593
+ Args:
594
+ terminal: Terminal instance.
595
+ """
596
+ if not terminal.is_finalized:
597
+ self._focusable_terminals.append(terminal)
598
+
599
+ @on(ShellTerminal.Interrupt)
600
+ async def on_shell_terminal_terminate(self, event: ShellTerminal.Terminate) -> None:
601
+ if not event.teminal.is_finalized:
602
+ await self.shell.interrupt()
603
+ self.cursor_offset = -1
604
+ self.flash("Command interrupted", style="success")
605
+
606
+ @on(DirectoryChanged)
607
+ def on_directory_changed(self, event: DirectoryChanged) -> None:
608
+ event.stop()
609
+ self._directory_changed = True
610
+ if self._suppress_directory_changed:
611
+ return
612
+ self._suppress_directory_changed = True
613
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=self.app.project_dir))
614
+ self.set_timer(0.5, self._release_directory_changed_suppress)
615
+
616
+ def _release_directory_changed_suppress(self) -> None:
617
+ self._suppress_directory_changed = False
618
+
619
+ def directory_watcher_swap(self, project_path: Path) -> None:
620
+ """Stop the current DirectoryWatcher (if any) and start a new one
621
+ rooted at *project_path*.
622
+
623
+ Used by ``MainScreen`` when the user switches projects so we don't
624
+ keep receiving events for the old tree.
625
+ """
626
+ if self._directory_watcher is not None:
627
+ self._directory_watcher.stop()
628
+ self._directory_watcher = None
629
+ if project_path is None:
630
+ return
631
+ try:
632
+ watcher = DirectoryWatcher(project_path, self)
633
+ except Exception:
634
+ return
635
+ watcher.start()
636
+ self._directory_watcher = watcher
637
+
638
+ @on(Terminal.Finalized)
639
+ def on_terminal_finalized(self, event: Terminal.Finalized) -> None:
640
+ """Terminal was finalized, so we can remove it from the list."""
641
+ try:
642
+ self._focusable_terminals.remove(event.terminal)
643
+ except ValueError:
644
+ pass
645
+
646
+ if self._directory_changed or not self.is_watching_directory:
647
+ self.prompt.project_directory_updated()
648
+ self._directory_changed = False
649
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=self.app.project_dir))
650
+
651
+ @on(Terminal.LongRunning)
652
+ def on_terminal_long_running(self, event: Terminal.LongRunning) -> None:
653
+ if (
654
+ not event.terminal.is_finalized
655
+ and not event.terminal.has_focus
656
+ and not event.terminal.state.buffer.is_blank
657
+ ):
658
+ self.flash("Press [b]ctrl+f[/b] to focus command", style="default")
659
+
660
+ @on(Terminal.AlternateScreenChanged)
661
+ def on_terminal_alternate_screen_(
662
+ self, event: Terminal.AlternateScreenChanged
663
+ ) -> None:
664
+ """A terminal enabled or disabled alternate screen."""
665
+ if event.enabled:
666
+ event.terminal.focus()
667
+ else:
668
+ self.focus_prompt()
669
+
670
+ @on(events.DescendantFocus, "Terminal")
671
+ def on_terminal_focus(self, event: events.DescendantFocus) -> None:
672
+ self.flash("Press [b]escape[/b] [i]twice[/] to exit terminal", style="success")
673
+
674
+ @on(events.DescendantBlur, "Terminal")
675
+ def on_terminal_blur(self, event: events.DescendantFocus) -> None:
676
+ self.focus_prompt()
677
+
678
+ @on(Terminal.CancelRequested)
679
+ async def on_terminal_cancel_requested(self, event: Terminal.CancelRequested) -> None:
680
+ event.stop()
681
+ if (agent := self.agent) is not None:
682
+ await agent.cancel()
683
+ # Don't claim success yet — the agent only stops once the
684
+ # session/prompt response arrives with stopReason="cancelled",
685
+ # which is surfaced as a "Turn cancelled" flash in agent_turn_over.
686
+ self.flash("Cancel requested…", style="success")
687
+
688
+ @on(messages.Flash)
689
+ def on_flash(self, event: messages.Flash) -> None:
690
+ event.stop()
691
+ self.flash(event.content, duration=event.duration, style=event.style)
692
+
693
+ def flash(
694
+ self,
695
+ content: str | Content,
696
+ *,
697
+ duration: float | None = None,
698
+ style: Literal["default", "warning", "error", "success"] = "default",
699
+ ) -> None:
700
+ """Flash a single-line message to the user.
701
+
702
+ Args:
703
+ content: Content to flash.
704
+ style: A semantic style.
705
+ duration: Duration in seconds of the flash, or `None` to use default in settings.
706
+ """
707
+ self.query_one(Flash).flash(content, duration=duration, style=style)
708
+
709
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
710
+ if action == "focus_terminal":
711
+ return None if self._terminal is None else True
712
+ if action == "mode_switcher":
713
+ return bool(self.modes)
714
+ if action == "cancel":
715
+ if self._pending_ask_user:
716
+ return True
717
+ return True if (self.agent and self.turn == "agent") else None
718
+ if action in {"expand_block", "collapse_block", "toggle_expand"}:
719
+ if (cursor_block := self.cursor_block) is None:
720
+ return False
721
+ elif isinstance(cursor_block, ExpandProtocol):
722
+ if action == "expand_block":
723
+ return False if cursor_block.is_block_expanded() else True
724
+ elif action == "collapse_block":
725
+ return True if cursor_block.is_block_expanded() else False
726
+ return cursor_block.can_expand()
727
+ return None if action == "expand_block" else False
728
+
729
+ return True
730
+
731
+ async def action_focus_terminal(self) -> None:
732
+ if self._terminal is not None:
733
+ self._terminal.focus()
734
+ else:
735
+ self.flash("Nothing to focus...", style="error")
736
+
737
+ async def action_expand_block(self) -> None:
738
+ if (cursor_block := self.cursor_block) is not None:
739
+ if isinstance(cursor_block, ExpandProtocol):
740
+ cursor_block.expand_block()
741
+ self.refresh_bindings()
742
+ self.call_after_refresh(self.cursor.follow, cursor_block)
743
+
744
+ async def action_collapse_block(self) -> None:
745
+ if (cursor_block := self.cursor_block) is not None:
746
+ if isinstance(cursor_block, ExpandProtocol):
747
+ cursor_block.collapse_block()
748
+ self.refresh_bindings()
749
+ self.call_after_refresh(self.cursor.follow, cursor_block)
750
+
751
+ async def action_toggle_expand(self) -> None:
752
+ if (cursor_block := self.cursor_block) is not None:
753
+ if isinstance(cursor_block, ExpandProtocol):
754
+ if cursor_block.is_block_expanded():
755
+ cursor_block.collapse_block()
756
+ else:
757
+ cursor_block.expand_block()
758
+ self.refresh_bindings()
759
+ self.call_after_refresh(self.cursor.follow, cursor_block)
760
+
761
+ async def action_fullscreen_block(self) -> None:
762
+ if (cursor_block := self.cursor_block) is not None:
763
+ fullscreen = getattr(cursor_block, "action_fullscreen", None)
764
+ if fullscreen:
765
+ fullscreen()
766
+ self.call_after_refresh(self.cursor.follow, cursor_block)
767
+
768
+ async def post_agent_response(self, fragment: str = "") -> AgentResponse | None:
769
+ """Get or create an agent response widget."""
770
+ from tui.widgets.agent_response import AgentResponse
771
+
772
+ async with self._post_lock:
773
+ if self._agent_response is None:
774
+ self._agent_response = agent_response = AgentResponse(fragment)
775
+ await self.post(agent_response, new_block=False)
776
+ else:
777
+ await self._agent_response.append_fragment(fragment)
778
+ return self._agent_response
779
+
780
+ def _complete_thought(self) -> None:
781
+ """Mark the current thought widget as completed and drop the reference."""
782
+ if self._agent_thought is not None:
783
+ self._agent_thought.mark_completed()
784
+ self._agent_thought = None
785
+
786
+ async def post_agent_thought(self, thought_fragment: str, *, replay: bool = False) -> AgentThought | None:
787
+ """Get or create an agent thought widget."""
788
+ from tui.widgets.agent_thought import AgentThought
789
+
790
+ async with self._post_lock:
791
+ if self._agent_thought is None:
792
+ if thought_fragment.strip():
793
+ self._agent_thought = AgentThought(thought_fragment, replay=replay)
794
+ await self.post(self._agent_thought, new_block=False)
795
+ else:
796
+ await self._agent_thought.append_fragment(thought_fragment)
797
+ return self._agent_thought
798
+
799
+ @property
800
+ def cursor_block(self) -> Widget | None:
801
+ """The block next to the cursor, or `None` if no block cursor."""
802
+ if self.cursor_offset == -1 or not self.contents.displayed_children:
803
+ return None
804
+ try:
805
+ block_widget = self.contents.displayed_children[self.cursor_offset]
806
+ except IndexError:
807
+ return None
808
+ return block_widget
809
+
810
+ @property
811
+ def cursor_block_child(self) -> Widget | None:
812
+ if (cursor_block := self.cursor_block) is not None:
813
+ if isinstance(cursor_block, BlockProtocol):
814
+ return cursor_block.get_cursor_block()
815
+ return cursor_block
816
+
817
+ def get_cursor_block[BlockType](
818
+ self, block_type: type[BlockType] = Widget
819
+ ) -> BlockType | None:
820
+ """Get the cursor block if it matches a type.
821
+
822
+ Args:
823
+ block_type: The expected type.
824
+
825
+ Returns:
826
+ The widget next to the cursor, or `None` if the types don't match.
827
+ """
828
+ cursor_block = self.cursor_block_child
829
+ if isinstance(cursor_block, block_type):
830
+ return cursor_block
831
+ return None
832
+
833
+ @on(AgentReady)
834
+ async def on_agent_ready(self) -> None:
835
+ self.session_start_time = monotonic()
836
+ if self.agent is not None:
837
+ content = Content.assemble(self.agent.get_info(), " connected")
838
+ self.flash(content, style="success")
839
+ if self._agent_data is not None:
840
+ self.app.capture_event(
841
+ "agent-session-begin",
842
+ agent=self._agent_data["identity"],
843
+ )
844
+ session_id = getattr(self.agent, "session_id", None) or self._agent_session_id or "unknown"
845
+ self._msg_log = MessageLog(
846
+ session_id=session_id,
847
+ agent_name=self.agent_title or "unknown",
848
+ )
849
+
850
+ self.agent_ready = True
851
+
852
+ async def on_unmount(self) -> None:
853
+ if self._msg_log is not None:
854
+ self._msg_log.close()
855
+ if self._directory_watcher is not None:
856
+ self._directory_watcher.stop()
857
+ if self.agent is not None:
858
+ await self.agent.stop()
859
+
860
+ if self._agent_data is not None and self.session_start_time is not None:
861
+ session_time = monotonic() - self.session_start_time
862
+ self.app.capture_event(
863
+ "agent-session-end",
864
+ agent=self._agent_data["identity"],
865
+ duration=session_time,
866
+ agent_session_fail=self._agent_fail,
867
+ shell_count=self._shell_count,
868
+ turn_count=self._turn_count,
869
+ )
870
+
871
+ session_id = getattr(self.agent, "session_id", None) or self._agent_session_id or ""
872
+ self.app._exit_metrics = {
873
+ "session_id": session_id,
874
+ "tool_call_total": self._tool_call_total,
875
+ "tool_call_success": self._tool_call_success,
876
+ "tool_call_failed": self._tool_call_failed,
877
+ "turn_count": self._turn_count,
878
+ "shell_count": self._shell_count,
879
+ "wall_time": session_time,
880
+ "agent_title": self.agent_title or "Agent",
881
+ }
882
+
883
+ @on(AgentFail)
884
+ async def on_agent_fail(self, message: AgentFail) -> None:
885
+ self.agent_ready = True
886
+ self._agent_fail = True
887
+ if self._msg_log is not None:
888
+ self._msg_log.error(message.message, turn=self._turn_count, details=message.details)
889
+ self.notify(message.message, title="Agent failure", severity="error", timeout=5)
890
+
891
+ if self._agent_data is not None:
892
+ self.app.capture_event(
893
+ "agent-session-error",
894
+ agent=self._agent_data["identity"],
895
+ message=message.message,
896
+ details=message.details,
897
+ )
898
+
899
+ if message.message:
900
+ error = Content.assemble(
901
+ Content.from_markup(message.message).stylize("$text-error"),
902
+ " — ",
903
+ Content.from_markup(message.details.strip()).stylize("dim"),
904
+ )
905
+ else:
906
+ error = Content.from_markup(message.details.strip()).stylize("$text-error")
907
+ await self.post(Note(error, classes="-error"))
908
+
909
+ from tui.widgets.markdown_note import MarkdownNote
910
+
911
+ if message.help in AGENT_FAIL_HELP:
912
+ help = AGENT_FAIL_HELP[message.help]
913
+ else:
914
+ help = AGENT_FAIL_HELP["fail"]
915
+
916
+ await self.post(MarkdownNote(help))
917
+
918
+ @on(messages.WorkStarted)
919
+ def on_work_started(self) -> None:
920
+ self.busy_count += 1
921
+
922
+ @on(messages.WorkFinished)
923
+ def on_work_finished(self) -> None:
924
+ self.busy_count -= 1
925
+
926
+ @work
927
+ @on(messages.ChangeMode)
928
+ async def on_change_mode(self, event: messages.ChangeMode) -> None:
929
+ await self.set_mode(event.mode_id)
930
+
931
+ @on(acp_messages.ModeUpdate)
932
+ def on_mode_update(self, event: acp_messages.ModeUpdate) -> None:
933
+ if (modes := self.modes) is not None:
934
+ if (mode := modes.get(event.current_mode)) is not None:
935
+ self.current_mode = mode
936
+
937
+ @on(messages.UserInputSubmitted)
938
+ async def on_user_input_submitted(self, event: messages.UserInputSubmitted) -> None:
939
+ if not event.body.strip():
940
+ return
941
+ if event.shell:
942
+ if await self.shell.is_busy():
943
+ if self.shell.terminal is not None:
944
+ self.shell.terminal.focus(scroll_visible=False)
945
+ await self.shell.send_input(event.body, paste=True)
946
+ else:
947
+ await self.shell_history.append(event.body)
948
+ self.shell_history_index = 0
949
+ await self.post_shell(event.body)
950
+ self.window.scroll_end(animate=False)
951
+ elif text := event.body.strip():
952
+ await self.prompt_history.append(event.body)
953
+ self.prompt_history_index = 0
954
+ if text.startswith("/") and await self.slash_command(text):
955
+ # A2TUI has processed the slash command.
956
+ return
957
+
958
+ if self._pending_ask_user:
959
+ self._pending_ask_user = False
960
+ self.turn = "agent"
961
+ self.prompt.display = True
962
+ self.agent.send_ask_user_answer(text, cancelled=False)
963
+ await self.post(UserInput(text))
964
+ self.focus_prompt()
965
+ return
966
+
967
+ if self.turn == "agent":
968
+ self._pending_ask_user = False
969
+ self.prompt.display = True
970
+ self._pending_prompts.append(text)
971
+ self.prompt.pending_prompts = list(self._pending_prompts)
972
+ self.window.scroll_end(animate=False)
973
+ self._loading = await self.post(Loading("Please wait..."), loading=True)
974
+ await asyncio.sleep(0)
975
+ if self._msg_log is not None:
976
+ self._msg_log.user_input(text, self._turn_count)
977
+ return
978
+
979
+ await self.post(UserInput(text))
980
+ self.window.scroll_end(animate=False)
981
+ self._loading = await self.post(Loading("Please wait..."), loading=True)
982
+ await asyncio.sleep(0)
983
+ if self._msg_log is not None:
984
+ self._msg_log.user_input(text, self._turn_count)
985
+ self.send_prompt_to_agent(text)
986
+
987
+ async def _auto_name_session(self, prompt: str) -> None:
988
+ if self._auto_named_session or self.agent is None:
989
+ return
990
+
991
+ if self._agent_session_title not in ("", "New Session", "Untitled"):
992
+ self._auto_named_session = True
993
+ return
994
+
995
+ if self.agent.session_pk is None:
996
+ return
997
+
998
+ self._auto_named_session = True
999
+ name = prompt.strip().split("\n")[0][:60].strip()
1000
+ if name:
1001
+ await self.agent.set_session_name(name)
1002
+
1003
+ @work
1004
+ async def send_prompt_to_agent(self, prompt: str) -> None:
1005
+ if self.agent is not None:
1006
+ await self._auto_name_session(prompt)
1007
+ stop_reason: str | None = None
1008
+ self.busy_count += 1
1009
+ try:
1010
+ self.turn = "agent"
1011
+ stop_reason = await self.agent.send_prompt(prompt)
1012
+ except jsonrpc.APIError as error:
1013
+ from tui.widgets.markdown_note import MarkdownNote
1014
+
1015
+ self.turn = "client"
1016
+ if self._msg_log is not None:
1017
+ self._msg_log.error(str(error), turn=self._turn_count)
1018
+
1019
+ message = error.message or "no details were provided"
1020
+
1021
+ await self.post(
1022
+ MarkdownNote(
1023
+ INTERNAL_EROR.replace("$ERROR", message),
1024
+ classes="-stop-reason",
1025
+ )
1026
+ )
1027
+ finally:
1028
+ self.busy_count -= 1
1029
+ await self._auto_name_session(prompt)
1030
+ self.call_later(self.agent_turn_over, stop_reason)
1031
+
1032
+ async def agent_turn_over(self, stop_reason: str | None) -> None:
1033
+ """Called when the agent's turn is over.
1034
+
1035
+ Args:
1036
+ stop_reason: The stop reason returned from the Agent, or `None`.
1037
+ """
1038
+ self.turn = "client"
1039
+ if self._loading is not None:
1040
+ await self._loading.remove()
1041
+ self._agent_response = None
1042
+ self._complete_thought()
1043
+
1044
+ # Safety net: mark any still-active subagents as cancelled.
1045
+ # Normal flow emits subagent_end before getting here, but
1046
+ # cancellation or errors can leave widgets stuck in "running".
1047
+ if self._active_subagents:
1048
+ for sa in self._active_subagents.values():
1049
+ sa.complete(status="cancelled", error="Turn ended")
1050
+ self._active_subagents.clear()
1051
+
1052
+ if self._directory_changed or not self.is_watching_directory:
1053
+ self._directory_changed = False
1054
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=self.app.project_dir))
1055
+ self.prompt.project_directory_updated()
1056
+
1057
+ self._turn_count += 1
1058
+
1059
+ if self._msg_log is not None:
1060
+ self._msg_log.turn_end(self._turn_count, stop_reason)
1061
+
1062
+ self.post_message(messages.SessionUpdate(state="idle"))
1063
+
1064
+ if stop_reason != "end_turn":
1065
+ from tui.widgets.markdown_note import MarkdownNote
1066
+
1067
+ agent = (self.agent_title or "agent").title()
1068
+
1069
+ if stop_reason == "max_tokens":
1070
+ await self.post(
1071
+ MarkdownNote(
1072
+ STOP_REASON_MAX_TOKENS.replace("$AGENT", agent),
1073
+ classes="-stop-reason",
1074
+ )
1075
+ )
1076
+ elif stop_reason == "max_turn_requests":
1077
+ await self.post(
1078
+ MarkdownNote(
1079
+ STOP_REASON_MAX_TURN_REQUESTS.replace("$AGENT", agent),
1080
+ classes="-stop-reason",
1081
+ )
1082
+ )
1083
+ elif stop_reason == "refusal":
1084
+ await self.post(
1085
+ MarkdownNote(
1086
+ STOP_REASON_REFUSAL.replace("$AGENT", agent),
1087
+ classes="-stop-reason",
1088
+ )
1089
+ )
1090
+ elif stop_reason == "cancelled":
1091
+ # The agent genuinely stopped — only now is the earlier
1092
+ # "Cancel requested…" flash confirmed.
1093
+ self.flash("Turn cancelled", style="success")
1094
+
1095
+ if self.app.settings.get("notifications.turn_over", bool):
1096
+ self.app.system_notify(
1097
+ f"{self.agent_title} has finished working",
1098
+ title="Waiting for input",
1099
+ sound="turn-over",
1100
+ )
1101
+
1102
+ if self._pending_prompts:
1103
+ next_prompt = self._pending_prompts.pop(0)
1104
+ self.prompt.pending_prompts = list(self._pending_prompts)
1105
+ await self.post(UserInput(next_prompt))
1106
+ self.window.scroll_end(animate=False)
1107
+ self._loading = await self.post(Loading("Please wait..."), loading=True)
1108
+ await asyncio.sleep(0)
1109
+ if self._msg_log is not None:
1110
+ self._msg_log.user_input(next_prompt, self._turn_count)
1111
+ self.send_prompt_to_agent(next_prompt)
1112
+
1113
+ @on(Menu.OptionSelected)
1114
+ async def on_menu_option_selected(self, event: Menu.OptionSelected) -> None:
1115
+ event.stop()
1116
+ event.menu.display = False
1117
+ if event.action is not None:
1118
+ await self.run_action(event.action, {"block": event.owner})
1119
+ if (cursor_block := self.get_cursor_block()) is not None:
1120
+ self.call_after_refresh(self.cursor.follow, cursor_block)
1121
+ self.call_after_refresh(event.menu.remove)
1122
+
1123
+ @on(Menu.Dismissed)
1124
+ async def on_menu_dismissed(self, event: Menu.Dismissed) -> None:
1125
+ event.stop()
1126
+ if event.menu.has_focus:
1127
+ self.window.focus(scroll_visible=False)
1128
+ await event.menu.remove()
1129
+
1130
+ @on(CurrentWorkingDirectoryChanged)
1131
+ def on_current_working_directory_changed(
1132
+ self, event: CurrentWorkingDirectoryChanged
1133
+ ) -> None:
1134
+ self.working_directory = str(Path(event.path).resolve().absolute())
1135
+
1136
+ async def watch_busy_count(self, busy: int) -> None:
1137
+ if (throbber := self.query_one_optional("#throbber")) is not None:
1138
+ throbber.set_class(busy > 0, "-busy")
1139
+
1140
+ @on(acp_messages.ContextUpdate)
1141
+ async def on_context_update(self, message: acp_messages.ContextUpdate):
1142
+ message.stop()
1143
+ from tui.widgets.context_stats import ContextStats
1144
+ if stats := self.screen.query_one_optional("#context-stats", ContextStats):
1145
+ stats.update_stats(message.used, message.size)
1146
+
1147
+ @on(acp_messages.UpdateStatusLine)
1148
+ async def on_update_status_line(self, message: acp_messages.UpdateStatusLine):
1149
+ self.status = message.status_line
1150
+
1151
+ @on(acp_messages.Update)
1152
+ async def on_acp_agent_message(self, message: acp_messages.Update):
1153
+ message.stop()
1154
+ if self._replay:
1155
+ self._replay_buffer.append({"kind": "agent_text", "text": message.text})
1156
+ return
1157
+ self._complete_thought()
1158
+ if self._msg_log is not None:
1159
+ self._msg_log.agent_output(message.text, self._turn_count)
1160
+ await self.post_agent_response(message.text)
1161
+
1162
+ @on(acp_messages.UserMessage)
1163
+ async def on_acp_user_message(self, message: acp_messages.UserMessage):
1164
+ message.stop()
1165
+ if self._replay:
1166
+ self._replay_buffer.append({"kind": "user", "text": message.text})
1167
+ return
1168
+ self._complete_thought()
1169
+ self._agent_response = None
1170
+ await self.post(UserInput(message.text))
1171
+
1172
+ @on(acp_messages.Thinking)
1173
+ async def on_acp_agent_thinking(self, message: acp_messages.Thinking):
1174
+ message.stop()
1175
+ if self._replay:
1176
+ self._replay_buffer.append({"kind": "thought", "text": message.text})
1177
+ return
1178
+ if self._msg_log is not None:
1179
+ self._msg_log.agent_thought(message.text, self._turn_count)
1180
+ await self.post_agent_thought(message.text, replay=self._replay)
1181
+
1182
+ @on(acp_messages.SessionReplay)
1183
+ def on_acp_session_replay(self, message: acp_messages.SessionReplay):
1184
+ """Mark a session replay boundary so thought chunks are shown as completed."""
1185
+ message.stop()
1186
+ was_replay = self._replay
1187
+ self._replay = message.active
1188
+ # When the replay just ended, flush buffer and complete any pending thought.
1189
+ if was_replay and not message.active:
1190
+ if message.total_messages > 0:
1191
+ self._total_messages = message.total_messages
1192
+ self._visible_count = message.visible_count
1193
+ self._loaded_up_to = message.total_messages - message.visible_count
1194
+ self.call_after_refresh(self._flush_replay_buffer)
1195
+ self._complete_thought()
1196
+
1197
+ @work(thread=False)
1198
+ async def _flush_replay_buffer(self) -> None:
1199
+ """Process all buffered replay entries in one batch mount."""
1200
+ if not self._replay_buffer:
1201
+ return
1202
+ from tui.widgets.agent_response import AgentResponse
1203
+ from tui.widgets.agent_thought import AgentThought
1204
+ from tui.widgets.tool_call import ToolCall
1205
+ from tui.widgets.plan import Plan
1206
+ from tui.widgets.subagent import SubAgent
1207
+
1208
+ created_subagents: dict[str, SubAgent] = {}
1209
+ current_thought: AgentThought | None = None
1210
+ widgets: list[Widget] = []
1211
+ seen_tool_ids: set[str] = set()
1212
+ seen_ask_user_ids: set[str] = set()
1213
+
1214
+ for entry in self._replay_buffer:
1215
+ match entry["kind"]:
1216
+ case "user":
1217
+ current_thought = None
1218
+ self._complete_thought()
1219
+ self._agent_response = None
1220
+ widgets.append(UserInput(entry["text"]))
1221
+
1222
+ case "agent_text":
1223
+ current_thought = None
1224
+ self._complete_thought()
1225
+ self._agent_response = None
1226
+ widgets.append(AgentResponse(entry["text"]))
1227
+
1228
+ case "thought":
1229
+ if current_thought is None:
1230
+ current_thought = AgentThought(entry["text"], replay=True)
1231
+ widgets.append(current_thought)
1232
+ else:
1233
+ current_thought.append_fragment(entry["text"])
1234
+
1235
+ case "tool_call":
1236
+ tool_id = entry["tool_id"]
1237
+ if tool_id in seen_tool_ids:
1238
+ continue
1239
+ seen_tool_ids.add(tool_id)
1240
+ current_thought = None
1241
+ self.new_block()
1242
+ widgets.append(ToolCall(entry["tool_call"], id=tool_id))
1243
+
1244
+ case "plan":
1245
+ current_thought = None
1246
+ self.new_block()
1247
+ widgets.append(Plan(_plan_entries_from_dicts(entry["entries"])))
1248
+
1249
+ case "subagent_start":
1250
+ current_thought = None
1251
+ self.new_block()
1252
+ sa = SubAgent(entry["agent_type"], tool_id=entry["id"], prompt=entry.get("prompt", ""))
1253
+ created_subagents[entry["id"]] = sa
1254
+ widgets.append(sa)
1255
+
1256
+ case "subagent_chunk":
1257
+ sa = created_subagents.get(entry["id"])
1258
+ if sa is not None:
1259
+ sa.append_chunk(entry["text"])
1260
+
1261
+ case "subagent_thinking":
1262
+ sa = created_subagents.get(entry["id"])
1263
+ if sa is not None:
1264
+ sa.append_thinking(entry["text"])
1265
+
1266
+ case "subagent_end":
1267
+ sa = created_subagents.get(entry["id"])
1268
+ if sa is not None:
1269
+ sa.complete(
1270
+ entry.get("status", "completed"),
1271
+ entry.get("error", ""),
1272
+ )
1273
+
1274
+ case "subagent_tool_call":
1275
+ sa = created_subagents.get(entry["id"])
1276
+ if sa is not None:
1277
+ await sa.add_or_update_tool_call(
1278
+ entry["tool_id"], entry["tool_call"]
1279
+ )
1280
+
1281
+ case "ask_user":
1282
+ ask_id = entry["tool_id"]
1283
+ if ask_id in seen_ask_user_ids:
1284
+ continue
1285
+ seen_ask_user_ids.add(ask_id)
1286
+ current_thought = None
1287
+ self.new_block()
1288
+ widgets.append(AskUserWidget(
1289
+ ask_id,
1290
+ question=entry["question"],
1291
+ options=entry.get("options", []),
1292
+ questions=entry.get("questions", []),
1293
+ ))
1294
+
1295
+ if self._loaded_up_to > 0 and self._flush_append_at is None:
1296
+ load_more = LoadMoreIndicator(count=self._loaded_up_to)
1297
+ widgets.insert(0, load_more)
1298
+ self._load_more_widget = load_more
1299
+
1300
+ if widgets:
1301
+ insert_before = self._flush_append_at
1302
+ if insert_before is not None:
1303
+ await self.contents.mount(*widgets, before=insert_before)
1304
+ self._loaded_up_to = max(0, self._loaded_up_to - len(widgets))
1305
+ if self._loaded_up_to > 0:
1306
+ lm = LoadMoreIndicator(count=self._loaded_up_to)
1307
+ self._load_more_widget = lm
1308
+ await self.contents.mount(lm, before=len(widgets))
1309
+ self._flush_append_at = None
1310
+ else:
1311
+ await self.contents.mount(*widgets)
1312
+ self._replay_buffer.clear()
1313
+ self._complete_thought()
1314
+ self.window.scroll_end_if_following(animate=False)
1315
+
1316
+ @on(LoadMoreIndicator.LoadMorePressed)
1317
+ async def on_load_more_pressed(self, message: LoadMoreIndicator.LoadMorePressed) -> None:
1318
+ message.stop()
1319
+ if self._loaded_up_to <= 0:
1320
+ return
1321
+ batch_size = 50
1322
+ start = max(0, self._loaded_up_to - batch_size)
1323
+ limit = self._loaded_up_to - start
1324
+ if limit <= 0:
1325
+ return
1326
+
1327
+ if self._load_more_widget is not None:
1328
+ await self._load_more_widget.remove()
1329
+ self._load_more_widget = None
1330
+
1331
+ loading = Static(" Loading earlier messages... ")
1332
+ await self.contents.mount(loading, before=0)
1333
+
1334
+ self._flush_append_at = 0
1335
+ self.post_message(acp_messages.SessionReplay(active=True))
1336
+ await self.agent.acp_load_earlier_messages(offset=start, limit=limit)
1337
+ self.post_message(acp_messages.SessionReplay(active=False))
1338
+ await loading.remove()
1339
+
1340
+ @on(acp_messages.RequestPermission)
1341
+ async def on_acp_request_permission(self, message: acp_messages.RequestPermission):
1342
+ message.stop()
1343
+ options = [
1344
+ Answer(option["name"], option["optionId"], option["kind"])
1345
+ for option in message.options
1346
+ ]
1347
+ self.request_permissions(
1348
+ message.result_future,
1349
+ options,
1350
+ message.tool_call,
1351
+ )
1352
+ self._agent_response = None
1353
+ self._complete_thought()
1354
+
1355
+ @on(acp_messages.AskUser)
1356
+ async def on_acp_ask_user(self, message: acp_messages.AskUser):
1357
+ message.stop()
1358
+ if self._replay:
1359
+ tool_id = message.tool_id
1360
+ for existing in self._replay_buffer:
1361
+ if existing.get("kind") == "ask_user" and existing.get("tool_id") == tool_id:
1362
+ return
1363
+ self._replay_buffer.append({
1364
+ "kind": "ask_user",
1365
+ "tool_id": tool_id,
1366
+ "question": message.question,
1367
+ "options": message.options or [],
1368
+ "questions": message.questions or [],
1369
+ })
1370
+ return
1371
+
1372
+ # Single free-text question (no options, no questions) → skip widget,
1373
+ # let user type directly in the Prompt Input.
1374
+ has_options = bool(message.options) or bool(message.questions)
1375
+ if not has_options:
1376
+ self.turn = "client"
1377
+ self._pending_ask_user = True
1378
+ self.prompt.display = True
1379
+ self.window.scroll_end(animate=False)
1380
+ return
1381
+
1382
+ # If a previous ask_user is still open, remove it before posting the
1383
+ # new one — otherwise widgets stack up and the user's answer becomes
1384
+ # ambiguous about which question it targets.
1385
+ if self._pending_ask_user:
1386
+ try:
1387
+ existing = self.contents.get_child_by_id(
1388
+ message.tool_id, AskUserWidget
1389
+ )
1390
+ except Exception:
1391
+ existing = None
1392
+ if existing is not None:
1393
+ try:
1394
+ await existing.remove()
1395
+ except Exception:
1396
+ pass
1397
+
1398
+ self.window.scroll_end(animate=False)
1399
+
1400
+ # Hand control back to the user: switch turn out of "agent" so that
1401
+ # on_user_input_submitted routes the next input to send_ask_user_answer
1402
+ # rather than appending it to _pending_prompts as a brand-new task.
1403
+ self.turn = "client"
1404
+ if self._loading is not None:
1405
+ try:
1406
+ await self._loading.remove()
1407
+ except Exception:
1408
+ pass
1409
+ self._loading = None
1410
+
1411
+ self._pending_ask_user = True
1412
+ self.busy_count -= 1
1413
+
1414
+ widget = AskUserWidget(
1415
+ message.tool_id,
1416
+ question=message.question,
1417
+ options=message.options or [],
1418
+ questions=message.questions or [],
1419
+ checkpoint_id=message.checkpoint_id,
1420
+ )
1421
+ await self.post(widget, new_block=True)
1422
+ self.prompt.display = False
1423
+ self._agent_response = None
1424
+ self._complete_thought()
1425
+ self.call_after_refresh(lambda: self.window.scroll_end(animate=False))
1426
+
1427
+ @on(acp_messages.AwaitingUserInput)
1428
+ async def on_acp_awaiting_user_input(self, message: acp_messages.AwaitingUserInput):
1429
+ """The agent yielded control to the user without invoking AskUser.
1430
+
1431
+ Switch turn out of "agent" so the next user_input_submitted is treated
1432
+ as a reply to the question currently on screen rather than queued as a
1433
+ fresh prompt. Streaming output is intentionally NOT cancelled.
1434
+ """
1435
+ message.stop()
1436
+ if self._replay:
1437
+ return
1438
+ self.turn = "client"
1439
+ if self._loading is not None:
1440
+ try:
1441
+ await self._loading.remove()
1442
+ except Exception:
1443
+ pass
1444
+ self._loading = None
1445
+ self._complete_thought()
1446
+
1447
+ @on(AskUserSubmitted)
1448
+ async def on_ask_user_submitted(self, message: AskUserSubmitted) -> None:
1449
+ message.stop()
1450
+ self._pending_ask_user = False
1451
+ self.busy_count += 1
1452
+
1453
+ # Widget already removed by _finish() in ask_user.py
1454
+ try:
1455
+ existing = self.contents.get_child_by_id(
1456
+ message.tool_id, AskUserWidget
1457
+ )
1458
+ if existing is not None:
1459
+ await existing.remove()
1460
+ except Exception:
1461
+ pass
1462
+
1463
+ rollback = message.value == "__rollback__"
1464
+
1465
+ # Post answer to conversation as a user message
1466
+ if not rollback:
1467
+ await self.post(UserInput(message.value))
1468
+
1469
+ if self.agent is not None:
1470
+ self.agent.send_ask_user_answer(
1471
+ "" if rollback else message.value,
1472
+ cancelled=False,
1473
+ rollback=rollback,
1474
+ )
1475
+
1476
+ self.prompt.display = True
1477
+ self.focus_prompt()
1478
+ self.turn = "agent"
1479
+
1480
+ @on(acp_messages.Plan)
1481
+ async def on_acp_plan(self, message: acp_messages.Plan):
1482
+ if self._replay:
1483
+ self._replay_buffer.append({"kind": "plan", "entries": message.entries})
1484
+ return
1485
+ from tui.widgets.plan import Plan
1486
+
1487
+ entries = _plan_entries_from_dicts(message.entries)
1488
+
1489
+ # Look up any existing Plan widget anywhere in the conversation
1490
+ # tree — checking only ``children[-1]`` would create duplicates if
1491
+ # a ToolCall/SubAgent was posted after the Plan.
1492
+ try:
1493
+ existing_plan = self.query(Plan).first()
1494
+ except NoMatches:
1495
+ existing_plan = None
1496
+ if existing_plan is not None:
1497
+ if entries:
1498
+ existing_plan.entries = entries
1499
+ else:
1500
+ # An empty snapshot at the start of a turn means "no
1501
+ # plan yet for this turn". Remove the stale inline
1502
+ # widget from the previous turn so we don't keep
1503
+ # rendering its "No plan yet" placeholder once a new
1504
+ # turn has begun.
1505
+ await existing_plan.remove()
1506
+ elif entries:
1507
+ await self.post(Plan(entries))
1508
+
1509
+ @on(acp_messages.ToolCallUpdate)
1510
+ @on(acp_messages.ToolCall)
1511
+ async def on_acp_tool_call_update(
1512
+ self, message: acp_messages.ToolCall | acp_messages.ToolCallUpdate
1513
+ ):
1514
+ from tui.widgets.tool_call import ToolCall
1515
+
1516
+ tool_call = message.tool_call
1517
+ status = tool_call.get("status", "")
1518
+
1519
+ if tool_call.get("title") == "AskUser":
1520
+ return
1521
+ tool_id = message.tool_id
1522
+
1523
+ # Tools invoked *inside* a subagent carry a ``subagentId`` (the parent
1524
+ # Spawn toolCallId). Render them as ToolCall cards INSIDE the owning
1525
+ # SubAgent widget (same style as main-conversation tools) instead of
1526
+ # the main conversation.
1527
+ subagent_id = tool_call.get("subagentId")
1528
+ if subagent_id:
1529
+ if self._replay:
1530
+ self._replay_buffer.append({
1531
+ "kind": "subagent_tool_call",
1532
+ "id": subagent_id,
1533
+ "tool_id": tool_id,
1534
+ "tool_call": tool_call,
1535
+ })
1536
+ return
1537
+ sa = self._get_subagent(subagent_id)
1538
+ if sa is not None:
1539
+ await sa.add_or_update_tool_call(tool_id, tool_call)
1540
+ return
1541
+
1542
+ if self._replay:
1543
+ if isinstance(message, acp_messages.ToolCall):
1544
+ # Avoid duplicate tool_call entries with the same tool_id.
1545
+ for existing in self._replay_buffer:
1546
+ if existing.get("kind") == "tool_call" and existing.get("tool_id") == tool_id:
1547
+ break
1548
+ else:
1549
+ self._replay_buffer.append({"kind": "tool_call", "tool_call": tool_call, "tool_id": tool_id})
1550
+ else:
1551
+ # ToolCallUpdate during replay: merge result content/status/title
1552
+ # into the corresponding buffered tool_call entry. If no match,
1553
+ # create a new entry from the update data.
1554
+ matched = False
1555
+ for entry in self._replay_buffer:
1556
+ if entry.get("kind") == "tool_call" and entry.get("tool_id") == tool_id:
1557
+ new_content = tool_call.get("content", [])
1558
+ if new_content:
1559
+ entry["tool_call"]["content"] = new_content
1560
+ if status:
1561
+ entry["tool_call"]["status"] = status
1562
+ new_title = tool_call.get("title", "")
1563
+ if new_title:
1564
+ entry["tool_call"]["title"] = new_title
1565
+ matched = True
1566
+ break
1567
+ if not matched:
1568
+ self._replay_buffer.append({"kind": "tool_call", "tool_call": tool_call, "tool_id": tool_id})
1569
+ return
1570
+
1571
+ content = tool_call.get("content", None) or []
1572
+
1573
+ logger.debug(
1574
+ "on_acp_tool_call_update: type=%s id=%s status=%s content_blocks=%d",
1575
+ type(message).__name__, tool_id, status, len(content),
1576
+ )
1577
+
1578
+ if isinstance(message, acp_messages.ToolCall):
1579
+ if self._msg_log is not None:
1580
+ self._msg_log.tool_call(
1581
+ tool_call.get("title", "unknown"),
1582
+ tool_call.get("toolCallId", ""),
1583
+ self._turn_count,
1584
+ )
1585
+ if tool_id not in self._tool_call_finalized:
1586
+ self._tool_call_total += 1
1587
+
1588
+ if status in ("completed", "failed") and tool_id not in self._tool_call_finalized:
1589
+ self._tool_call_finalized.add(tool_id)
1590
+ if self._msg_log is not None:
1591
+ self._msg_log.tool_result(
1592
+ tool_call.get("title", "unknown"),
1593
+ tool_call.get("toolCallId", ""),
1594
+ self._turn_count,
1595
+ status=status,
1596
+ )
1597
+ if status == "completed":
1598
+ self._tool_call_success += 1
1599
+ else:
1600
+ self._tool_call_failed += 1
1601
+
1602
+ if status == "completed":
1603
+ self._complete_thought()
1604
+ self._agent_response = None
1605
+
1606
+ try:
1607
+ existing_tool_call: ToolCall | None = self.contents.get_child_by_id(
1608
+ tool_id, ToolCall
1609
+ )
1610
+ except NoMatches:
1611
+ widget = await self.post(ToolCall(tool_call, id=message.tool_id), new_block=True)
1612
+ await widget.update_tool_call(tool_call)
1613
+ logger.debug(
1614
+ " => created new ToolCall widget, mounted %d content blocks",
1615
+ len(content),
1616
+ )
1617
+ else:
1618
+ if existing_tool_call is not None:
1619
+ await existing_tool_call.update_tool_call(tool_call)
1620
+
1621
+ @on(acp_messages.SubAgentStart)
1622
+ async def on_acp_subagent_start(self, message: acp_messages.SubAgentStart):
1623
+ from tui.widgets.subagent import SubAgent
1624
+
1625
+ message.stop()
1626
+ logger.debug(
1627
+ "[TUI-SUBagent] on_start id=%s type=%s prompt_len=%d replay=%s",
1628
+ message.subagent_id, message.agent_type,
1629
+ len(message.prompt or ""), self._replay,
1630
+ )
1631
+ if self._replay:
1632
+ self._replay_buffer.append({"kind": "subagent_start", "agent_type": message.agent_type, "id": message.subagent_id, "prompt": message.prompt})
1633
+ return
1634
+ sa = SubAgent(message.agent_type, tool_id=message.subagent_id, prompt=message.prompt)
1635
+ self._active_subagents[message.subagent_id] = sa
1636
+ await self.post(sa, new_block=True)
1637
+ logger.debug("[TUI-SUBagent] on_start posted SubAgent id=%s", message.subagent_id)
1638
+
1639
+ def _get_subagent(self, subagent_id: str):
1640
+ from tui.widgets.subagent import SubAgent
1641
+
1642
+ sa = self._active_subagents.get(subagent_id)
1643
+ if sa is not None:
1644
+ return sa
1645
+ try:
1646
+ return self.contents.get_child_by_id(subagent_id, SubAgent)
1647
+ except NoMatches:
1648
+ return None
1649
+
1650
+ @on(acp_messages.SubAgentChunk)
1651
+ async def on_acp_subagent_chunk(self, message: acp_messages.SubAgentChunk):
1652
+ message.stop()
1653
+ sa = self._get_subagent(message.subagent_id)
1654
+ _cnt = (sa._chunk_count_log + 1) if sa else 0
1655
+ if sa:
1656
+ sa._chunk_count_log = _cnt
1657
+ if _cnt <= 5 or _cnt % 50 == 0:
1658
+ logger.debug(
1659
+ "[TUI-SUBagent] on_chunk id=%s chunk#%d bytes=%d sa=%s",
1660
+ message.subagent_id, _cnt, len(message.text or ""),
1661
+ "yes" if sa else "MISSING",
1662
+ )
1663
+ if self._replay:
1664
+ self._replay_buffer.append({"kind": "subagent_chunk", "id": message.subagent_id, "text": message.text})
1665
+ return
1666
+ if sa is not None:
1667
+ sa.append_chunk(message.text)
1668
+
1669
+ @on(acp_messages.SubAgentThinking)
1670
+ async def on_acp_subagent_thinking(self, message: acp_messages.SubAgentThinking):
1671
+ message.stop()
1672
+ if self._replay:
1673
+ self._replay_buffer.append({
1674
+ "kind": "subagent_thinking",
1675
+ "id": message.subagent_id,
1676
+ "text": message.text,
1677
+ })
1678
+ return
1679
+ sa = self._get_subagent(message.subagent_id)
1680
+ if sa is not None:
1681
+ sa.append_thinking(message.text)
1682
+
1683
+ @on(acp_messages.SubAgentEnd)
1684
+ async def on_acp_subagent_end(self, message: acp_messages.SubAgentEnd):
1685
+ message.stop()
1686
+ logger.debug(
1687
+ "[TUI-SUBagent] on_end id=%s status=%s err=%r active=%s "
1688
+ "chunks=%d bytes=%d",
1689
+ message.subagent_id, message.status, (message.error or "")[:80],
1690
+ message.subagent_id in self._active_subagents,
1691
+ (self._get_subagent(message.subagent_id)._chunk_count_log
1692
+ if self._get_subagent(message.subagent_id) else 0),
1693
+ (self._get_subagent(message.subagent_id)._byte_count_log
1694
+ if self._get_subagent(message.subagent_id) else 0),
1695
+ )
1696
+ if self._replay:
1697
+ self._replay_buffer.append({
1698
+ "kind": "subagent_end",
1699
+ "id": message.subagent_id,
1700
+ "status": message.status,
1701
+ "error": message.error,
1702
+ })
1703
+ return
1704
+ sa = self._get_subagent(message.subagent_id)
1705
+ if sa is not None:
1706
+ sa.complete(message.status, message.error)
1707
+ self._active_subagents.pop(message.subagent_id, None)
1708
+
1709
+ @on(acp_messages.AvailableCommandsUpdate)
1710
+ async def on_acp_available_commands_update(
1711
+ self, message: acp_messages.AvailableCommandsUpdate
1712
+ ):
1713
+ slash_commands: list[SlashCommand] = []
1714
+ for available_command in message.commands:
1715
+ input = available_command.get("input", {}) or {}
1716
+ slash_command = SlashCommand(
1717
+ f"/{available_command['name']}",
1718
+ available_command["description"],
1719
+ hint=input.get("hint"),
1720
+ )
1721
+ slash_commands.append(slash_command)
1722
+ self.agent_slash_commands = slash_commands
1723
+ self.update_slash_commands()
1724
+
1725
+ def get_terminal(self, terminal_id: str) -> TerminalTool | None:
1726
+ """Get a terminal from its id.
1727
+
1728
+ Args:
1729
+ terminal_id: ID of the terminal.
1730
+
1731
+ Returns:
1732
+ Terminal instance, or `None` if no terminal was found.
1733
+ """
1734
+ from tui.widgets.terminal_tool import TerminalTool
1735
+
1736
+ try:
1737
+ terminal = self.contents.query_one(f"#{terminal_id}", TerminalTool)
1738
+ except NoMatches:
1739
+ return None
1740
+ if terminal.released:
1741
+ return None
1742
+ return terminal
1743
+
1744
+ async def action_interrupt(self) -> None:
1745
+ terminal = self._terminal
1746
+ if terminal is not None and not terminal.is_finalized:
1747
+ await self.shell.interrupt()
1748
+ # self._shell = None
1749
+ self.flash("Command interrupted", style="success")
1750
+ else:
1751
+ raise SkipAction()
1752
+
1753
+ def action_focus_block(self, block_id: str) -> None:
1754
+ with suppress(NoMatches):
1755
+ self.query_one(f"#{block_id}").focus()
1756
+
1757
+ @work
1758
+ @on(acp_messages.CreateTerminal)
1759
+ async def on_acp_create_terminal(self, message: acp_messages.CreateTerminal):
1760
+ from tui.widgets.terminal_tool import TerminalTool, Command
1761
+
1762
+ command = Command(
1763
+ message.command,
1764
+ message.args or [],
1765
+ message.env or {},
1766
+ message.cwd or str(self.project_path),
1767
+ )
1768
+ width = self.window.size.width - 5 - self.window.styles.scrollbar_size_vertical
1769
+ height = self.window.scrollable_content_region.height - 2
1770
+
1771
+ terminal = TerminalTool(
1772
+ command,
1773
+ output_byte_limit=message.output_byte_limit,
1774
+ id=message.terminal_id,
1775
+ minimum_terminal_width=width,
1776
+ )
1777
+ self.terminals[message.terminal_id] = terminal
1778
+ terminal.display = False
1779
+
1780
+ try:
1781
+ await terminal.start(width, height)
1782
+ except Exception as error:
1783
+ log(str(error))
1784
+ message.result_future.set_result(False)
1785
+ return
1786
+
1787
+ try:
1788
+ await self.post(terminal)
1789
+ except Exception:
1790
+ message.result_future.set_result(False)
1791
+ else:
1792
+ message.result_future.set_result(True)
1793
+
1794
+ @on(acp_messages.KillTerminal)
1795
+ async def on_acp_kill_terminal(self, message: acp_messages.KillTerminal):
1796
+ if (terminal := self.get_terminal(message.terminal_id)) is not None:
1797
+ terminal.kill()
1798
+
1799
+ @on(acp_messages.GetTerminalState)
1800
+ def on_acp_get_terminal_state(self, message: acp_messages.GetTerminalState):
1801
+ if (terminal := self.get_terminal(message.terminal_id)) is None:
1802
+ message.result_future.set_exception(
1803
+ KeyError(f"No terminal with id {message.terminal_id!r}")
1804
+ )
1805
+ else:
1806
+ message.result_future.set_result(terminal.tool_state)
1807
+
1808
+ @on(acp_messages.ReleaseTerminal)
1809
+ def on_acp_terminal_release(self, message: acp_messages.ReleaseTerminal):
1810
+ if (terminal := self.get_terminal(message.terminal_id)) is not None:
1811
+ terminal.kill()
1812
+ terminal.release()
1813
+
1814
+ @work
1815
+ @on(acp_messages.WaitForTerminalExit)
1816
+ async def on_acp_wait_for_terminal_exit(
1817
+ self, message: acp_messages.WaitForTerminalExit
1818
+ ):
1819
+ if (terminal := self.get_terminal(message.terminal_id)) is None:
1820
+ message.result_future.set_exception(
1821
+ KeyError(f"No terminal with id {message.terminal_id!r}")
1822
+ )
1823
+ else:
1824
+ return_code, signal = await terminal.wait_for_exit()
1825
+ message.result_future.set_result((return_code or 0, signal))
1826
+
1827
+ async def set_mode(self, mode_id: str | None) -> None:
1828
+ """Set the mode give its id (if it exists).
1829
+
1830
+ Args:
1831
+ mode_id: Id of mode.
1832
+
1833
+ Returns:
1834
+ `True` if the mode was changed, `False` if it didn't exist.
1835
+ """
1836
+ if (agent := self.agent) is None:
1837
+ return
1838
+ if mode_id is None:
1839
+ self.current_mode = None
1840
+ else:
1841
+ if (error := await agent.set_mode(mode_id)) is not None:
1842
+ self.notify(error, title="Set Mode", severity="error")
1843
+ elif (new_mode := self.modes.get(mode_id)) is not None:
1844
+ self.current_mode = new_mode
1845
+ self.flash(
1846
+ Content.from_markup("Mode changed to [b]$mode", mode=new_mode.name),
1847
+ style="success",
1848
+ )
1849
+
1850
+ @on(acp_messages.SetModes)
1851
+ async def on_acp_set_modes(self, message: acp_messages.SetModes):
1852
+ print(f"[DEBUG on_acp_set_modes] current_mode={message.current_mode!r}, modes={list(message.modes.keys())!r}", flush=True)
1853
+ self.modes = message.modes
1854
+ self.current_mode = self.modes[message.current_mode]
1855
+ print(f"[DEBUG on_acp_set_modes] done. self.modes={list(self.modes.keys())!r}", flush=True)
1856
+
1857
+ @on(messages.HistoryMove)
1858
+ async def on_history_move(self, message: messages.HistoryMove) -> None:
1859
+ message.stop()
1860
+ if message.shell:
1861
+ await self.shell_history.open()
1862
+
1863
+ if self.shell_history_index == 0:
1864
+ current_shell_command = ""
1865
+ else:
1866
+ current_shell_command = (
1867
+ await self.shell_history.get_entry(self.shell_history_index)
1868
+ )["input"]
1869
+ while True:
1870
+ self.shell_history_index += message.direction
1871
+ new_entry = await self.shell_history.get_entry(self.shell_history_index)
1872
+ if (new_entry)["input"] != current_shell_command:
1873
+ break
1874
+ if message.direction == +1 and self.shell_history_index == 0:
1875
+ break
1876
+ if (
1877
+ message.direction == -1
1878
+ and self.shell_history_index <= -self.shell_history.size
1879
+ ):
1880
+ break
1881
+ else:
1882
+ await self.prompt_history.open()
1883
+ self.prompt_history_index += message.direction
1884
+
1885
+ @work
1886
+ async def request_permissions(
1887
+ self,
1888
+ result_future: Future[Answer],
1889
+ options: list[Answer],
1890
+ tool_call_update: acp_protocol.ToolCallUpdatePermissionRequest,
1891
+ ) -> None:
1892
+ kind = tool_call_update.get("kind", None)
1893
+ title = tool_call_update.get("title", "") or ""
1894
+
1895
+ contents = tool_call_update.get("content", []) or []
1896
+ # If all the content is diffs, we will set kind to "edit" to show the permisisons screen
1897
+ for content in contents:
1898
+ if content.get("type") != "diff":
1899
+ break
1900
+ else:
1901
+ kind = "edit"
1902
+
1903
+ self.post_message(messages.SessionUpdate(state="asking"))
1904
+
1905
+ if kind == "edit":
1906
+ diffs: list[tuple[str, str, str | None, str]] = []
1907
+
1908
+ contents = tool_call_update.get("content", []) or []
1909
+ for content in contents:
1910
+ match content:
1911
+ case {
1912
+ "type": "diff",
1913
+ "oldText": old_text,
1914
+ "newText": new_text,
1915
+ "path": path,
1916
+ }:
1917
+ diffs.append((path, path, old_text, new_text))
1918
+
1919
+ if diffs:
1920
+ from tui.screens.permissions import PermissionsScreen
1921
+
1922
+ self.app.terminal_alert()
1923
+ self.app.system_notify(
1924
+ f"{self.agent_title} would like to write files",
1925
+ title="Permissions request",
1926
+ sound="question",
1927
+ )
1928
+ permissions_screen = PermissionsScreen(
1929
+ options, diffs, agent_name=self.agent_title or "The Agent"
1930
+ )
1931
+ result = await self.app.push_screen_wait(
1932
+ permissions_screen, mode=self.screen.id
1933
+ )
1934
+ self.post_message(messages.SessionUpdate(state="busy"))
1935
+ self.app.terminal_alert(False)
1936
+ result_future.set_result(result)
1937
+ return
1938
+
1939
+ from tui.widgets.acp_content import ACPToolCallContent, _header_path
1940
+
1941
+ def answer_callback(answer: Answer) -> None:
1942
+ try:
1943
+ result_future.set_result(answer)
1944
+ except Exception:
1945
+ # I've seen this occur in shutdown with an `InvalidStateError`
1946
+ pass
1947
+
1948
+ if not self.prompt.ask_queue:
1949
+ self.post_message(messages.SessionUpdate(state="busy"))
1950
+
1951
+ tool_call_content = tool_call_update.get("content", None) or []
1952
+ fname = _header_path(tool_call_content)
1953
+ question_title = f"{title} — {fname}" if fname else title
1954
+ if kind == "edit":
1955
+ question_title = question_title or "Apply changes to file"
1956
+ else:
1957
+ question_title = question_title or f"Execute {kind} operation?"
1958
+ self.ask(
1959
+ options,
1960
+ question_title,
1961
+ (
1962
+ partial(ACPToolCallContent, tool_call_content)
1963
+ if tool_call_content
1964
+ else None
1965
+ ),
1966
+ answer_callback,
1967
+ )
1968
+ return
1969
+
1970
+ async def post_tool_call(
1971
+ self, tool_call_update: acp_protocol.ToolCallUpdate
1972
+ ) -> None:
1973
+ if (contents := tool_call_update.get("content")) is None:
1974
+ return
1975
+
1976
+ for content in contents:
1977
+ match content:
1978
+ case {
1979
+ "type": "diff",
1980
+ "oldText": old_text,
1981
+ "newText": new_text,
1982
+ "path": path,
1983
+ }:
1984
+ await self.post_diff(path, old_text, new_text)
1985
+
1986
+ async def post_diff(self, path: str, before: str | None, after: str) -> None:
1987
+ """Post a diff view.
1988
+
1989
+ Args:
1990
+ path: Path to the file.
1991
+ before: Content of file before edit.
1992
+ after: Content of file after edit.
1993
+ """
1994
+
1995
+ from tui.widgets.diff_view import make_diff
1996
+
1997
+ diff_view = make_diff(path, path, before, after, classes="block")
1998
+ await self.post(diff_view)
1999
+
2000
+ def ask(
2001
+ self,
2002
+ options: list[Answer],
2003
+ title: str = "",
2004
+ get_content: Callable[[], Widget] | None = None,
2005
+ callback: Callable[[Answer], Any] | None = None,
2006
+ ) -> None:
2007
+ """Replace the prompt with a dialog to ask a question
2008
+
2009
+ Args:
2010
+ question: Question to ask or empty string to omit.
2011
+ options: A list of (ANSWER, ANSWER_ID) tuples.
2012
+ callback: Optional callable that will be invoked with the result.
2013
+ """
2014
+ from tui.widgets.question import Ask
2015
+
2016
+ self.agent_info
2017
+
2018
+ if self.agent_title:
2019
+ notify_title = f"[{self.agent_title}] {title}"
2020
+ else:
2021
+ notify_title = title
2022
+ notify_message = "\n".join(f" • {option.text}" for option in options)
2023
+ self.app.system_notify(notify_message, title=notify_title, sound="question")
2024
+
2025
+ self.prompt.ask(Ask(title, options, get_content, callback))
2026
+
2027
+ def _build_slash_commands(self) -> list[SlashCommand]:
2028
+ slash_commands = [
2029
+ SlashCommand("/exit", "Exit A2TUI"),
2030
+ SlashCommand("/about", "About A2TUI"),
2031
+ SlashCommand(
2032
+ "/clear",
2033
+ "Clear conversation window",
2034
+ "<optional number of lines to preserve>",
2035
+ ),
2036
+ SlashCommand(
2037
+ "/session",
2038
+ "Session commands: list, load, new, close, rename",
2039
+ "<list|load|new|close|rename> [args]",
2040
+ ),
2041
+ SlashCommand(
2042
+ "/aidlc",
2043
+ "AIDLC commands: project, phase, gate, sync, update",
2044
+ "<project|phase|gate|sync|update> [args]",
2045
+ ),
2046
+ SlashCommand(
2047
+ "/trace",
2048
+ "Open the structured trace viewer",
2049
+ ),
2050
+ SlashCommand(
2051
+ "/engine",
2052
+ "Open the agent/engine selection screen",
2053
+ ),
2054
+ SlashCommand(
2055
+ "/cls",
2056
+ "Search TCB/SCF logs via CLS (requestId tracing)",
2057
+ "<--request-id ID --function NAME>",
2058
+ ),
2059
+ SlashCommand(
2060
+ "/diff",
2061
+ "Show project diff (git working tree changes)",
2062
+ ),
2063
+ SlashCommand(
2064
+ "/ide",
2065
+ "Open built-in code editor",
2066
+ "[filepath]",
2067
+ ),
2068
+ ]
2069
+
2070
+ slash_commands.extend(platform_commands.get_slash_commands())
2071
+ slash_commands.extend(self.agent_slash_commands)
2072
+ deduplicated_slash_commands = {
2073
+ slash_command.command: slash_command for slash_command in slash_commands
2074
+ }
2075
+ slash_commands = sorted(
2076
+ deduplicated_slash_commands.values(), key=attrgetter("command")
2077
+ )
2078
+ return slash_commands
2079
+
2080
+ def update_slash_commands(self) -> None:
2081
+ """Update slash commands, which may have changed since mounting."""
2082
+ self.prompt.slash_commands = self._build_slash_commands()
2083
+
2084
+ async def on_mount(self) -> None:
2085
+ self.trap_focus()
2086
+ self.prompt.focus()
2087
+ self.prompt.slash_commands = self._build_slash_commands()
2088
+ self.call_after_refresh(self.post_welcome)
2089
+ self.app.settings_changed_signal.subscribe(self, self._settings_changed)
2090
+
2091
+ self.shell_history.complete.add_words(
2092
+ self.app.settings.get("shell.allow_commands", expect_type=str).split()
2093
+ )
2094
+ self.shell
2095
+
2096
+ if self._agent_data is not None:
2097
+ commands_prefix = self._agent_data.get("commands_prefix", "")
2098
+ platform_commands.set_prefix(commands_prefix)
2099
+ commands_module = self._agent_data.get("commands_module", "")
2100
+ if commands_module:
2101
+ import importlib
2102
+ try:
2103
+ mod = importlib.import_module(commands_module)
2104
+ mod.register_commands()
2105
+ except Exception:
2106
+ log.warning(f"Failed to load commands from {commands_module}")
2107
+ self.prompt.slash_commands = self._build_slash_commands()
2108
+
2109
+ async def start_agent() -> None:
2110
+ """Start the agent after refreshing the UI."""
2111
+ assert self._agent_data is not None
2112
+ from tui.acp.agent import Agent
2113
+
2114
+ self.agent = Agent(
2115
+ self.project_path,
2116
+ self._agent_data,
2117
+ self._agent_session_id,
2118
+ self._session_pk,
2119
+ default_model=self.app.settings.get("model", str),
2120
+ )
2121
+ await self.agent.start(self)
2122
+
2123
+ self.call_after_refresh(start_agent)
2124
+
2125
+ else:
2126
+ self.agent_ready = True
2127
+
2128
+ self.update_title()
2129
+ self.window.anchor()
2130
+
2131
+ def _settings_changed(self, setting_item: tuple[str, str]) -> None:
2132
+ key, value = setting_item
2133
+ if key == "shell.allow_commands":
2134
+ self.shell_history.complete.add_words(value.split())
2135
+
2136
+ @work
2137
+ async def post_welcome(self) -> None:
2138
+ """Post any welcome content."""
2139
+
2140
+ def watch_agent(self, agent: AgentBase | None) -> None:
2141
+ if agent is None:
2142
+ self.agent_info = Content.styled("shell")
2143
+ else:
2144
+ self.agent_info = agent.get_info()
2145
+ self.agent_ready = False
2146
+ self.update_title()
2147
+
2148
+ @work
2149
+ async def watch_agent_ready(self, ready: bool) -> None:
2150
+ with suppress(asyncio.TimeoutError):
2151
+ async with asyncio.timeout(2.0):
2152
+ await self.shell.wait_for_ready()
2153
+ if ready:
2154
+ self._directory_watcher = DirectoryWatcher(self.project_path, self)
2155
+ self._directory_watcher.start()
2156
+ if ready and (agent_data := self._agent_data) is not None:
2157
+ welcome = agent_data.get("welcome", None)
2158
+ if welcome is not None:
2159
+ from tui.widgets.markdown_note import MarkdownNote
2160
+
2161
+ await self.post(MarkdownNote(welcome))
2162
+ if ready and self._initial_prompt is not None:
2163
+ prompt = self._initial_prompt
2164
+ if prompt.startswith("!"):
2165
+ self.post_message(
2166
+ messages.UserInputSubmitted(self._initial_prompt[1:], shell=True)
2167
+ )
2168
+ else:
2169
+ self.post_message(
2170
+ messages.UserInputSubmitted(self._initial_prompt, shell=False)
2171
+ )
2172
+ self._initial_prompt = None
2173
+
2174
+ def on_mouse_down(self, event: events.MouseDown) -> None:
2175
+ self._mouse_down_offset = event.screen_offset
2176
+
2177
+ def on_click(self, event: events.Click) -> None:
2178
+ if (
2179
+ self._mouse_down_offset is not None
2180
+ and event.screen_offset != self._mouse_down_offset
2181
+ ):
2182
+ return
2183
+ widget = event.widget
2184
+
2185
+ contents = self.contents
2186
+ if self.screen.get_selected_text():
2187
+ return
2188
+ if widget is None or widget.is_maximized:
2189
+ return
2190
+ try:
2191
+ widget.query_ancestor(Prompt)
2192
+ except NoMatches:
2193
+ pass
2194
+ else:
2195
+ return
2196
+
2197
+ # AskUserWidget is an interactive block (Input/Buttons): clicking
2198
+ # inside it must not move the block cursor, because
2199
+ # refresh_block_cursor() would call window.focus() and steal focus
2200
+ # from the ask-user input the user just clicked on.
2201
+ try:
2202
+ widget.query_ancestor(AskUserWidget)
2203
+ except NoMatches:
2204
+ pass
2205
+ else:
2206
+ return
2207
+
2208
+ if widget in contents.displayed_children:
2209
+ self.cursor_offset = contents.displayed_children.index(widget)
2210
+ self.refresh_block_cursor()
2211
+ return
2212
+ for parent in widget.ancestors:
2213
+ if not isinstance(parent, Widget):
2214
+ break
2215
+ if (
2216
+ parent is self or parent is contents
2217
+ ) and widget in contents.displayed_children:
2218
+ self.cursor_offset = contents.displayed_children.index(widget)
2219
+ self.refresh_block_cursor()
2220
+ break
2221
+ if (
2222
+ isinstance(parent, BlockProtocol)
2223
+ and parent in contents.displayed_children
2224
+ ):
2225
+ self.cursor_offset = contents.displayed_children.index(parent)
2226
+ parent.block_select(widget)
2227
+ self.refresh_block_cursor()
2228
+ break
2229
+ widget = parent
2230
+
2231
+ def new_block(self) -> None:
2232
+ """Start a new block for agent response."""
2233
+ self._complete_thought()
2234
+ self._agent_response = None
2235
+
2236
+ async def post[WidgetType: Widget](
2237
+ self,
2238
+ widget: WidgetType,
2239
+ *,
2240
+ loading: bool = False,
2241
+ new_block: bool = True,
2242
+ ) -> WidgetType:
2243
+ """Post a widget to the converstaion.
2244
+
2245
+ Args:
2246
+ widget: Widget to post.
2247
+ loading: Set the widget to an initial loading state?
2248
+ new_block: Start a new block?
2249
+
2250
+ Returns:
2251
+ The widget that was mounted.
2252
+ """
2253
+ if self._loading is not None:
2254
+ await self._loading.remove()
2255
+ if new_block and not loading:
2256
+ self.new_block()
2257
+ if not self.contents.is_attached:
2258
+ return widget
2259
+ await self.contents.mount(widget)
2260
+
2261
+ widget.loading = loading
2262
+ self._require_check_prune = True
2263
+ self.call_after_refresh(self.check_prune)
2264
+ return widget
2265
+
2266
+ async def check_prune(self) -> None:
2267
+ """Check if a prune is required."""
2268
+ if self._require_check_prune:
2269
+ self._require_check_prune = False
2270
+ low_mark = self.app.settings.get("ui.prune_low_mark", int)
2271
+ high_mark = low_mark + self.app.settings.get("ui.prune_excess", int)
2272
+ await self.prune_window(low_mark, high_mark)
2273
+
2274
+ async def prune_window(self, low_mark: int, high_mark: int) -> None:
2275
+ """Remove older children to keep within a certain range.
2276
+
2277
+ Args:
2278
+ low_mark: Height to aim for.
2279
+ high_mark: Height to start pruning.
2280
+ """
2281
+
2282
+ assert high_mark >= low_mark
2283
+
2284
+ contents = self.contents
2285
+
2286
+ height = contents.virtual_size.height
2287
+ if height <= high_mark:
2288
+ return
2289
+ prune_children: list[Widget] = []
2290
+ bottom_margin = 0
2291
+ prune_height = 0
2292
+
2293
+ if low_mark == 0:
2294
+ # Aggressive prune: only act on already-hidden children
2295
+ # (e.g. collapsed blocks). Wiping every visible widget
2296
+ # would erase the live ToolCall the user is interacting
2297
+ # with.
2298
+ prune_children = [
2299
+ child for child in contents.children if not child.display
2300
+ ]
2301
+ else:
2302
+ for child in contents.children:
2303
+ if not child.display:
2304
+ prune_children.append(child)
2305
+ continue
2306
+ top, _, bottom, _ = child.styles.margin
2307
+ child_height = child.outer_size.height
2308
+ prune_height = (
2309
+ (prune_height - bottom_margin + max(bottom_margin, top))
2310
+ + bottom
2311
+ + child_height
2312
+ )
2313
+ bottom_margin = bottom
2314
+ if height - prune_height <= low_mark:
2315
+ break
2316
+ prune_children.append(child)
2317
+
2318
+ if not prune_children:
2319
+ return
2320
+ self.cursor_offset = -1
2321
+ self.cursor.visible = False
2322
+ self.cursor.follow(None)
2323
+ contents.refresh()
2324
+
2325
+ if prune_children:
2326
+ await contents.remove_children(prune_children)
2327
+
2328
+ if self.window.scroll_y >= self.window.max_scroll_y:
2329
+ self.call_later(self.window.anchor)
2330
+
2331
+ async def new_terminal(self) -> Terminal:
2332
+ """Create a new interactive Terminal.
2333
+
2334
+ Args:
2335
+ width: Initial width of the terminal.
2336
+ display: Initial display.
2337
+
2338
+ Returns:
2339
+ A new (mounted) Terminal widget.
2340
+ """
2341
+
2342
+ if (terminal := self._terminal) is not None:
2343
+ if terminal.state.buffer.is_blank:
2344
+ terminal.finalize()
2345
+ await terminal.remove()
2346
+
2347
+ self._terminal_count += 1
2348
+
2349
+ terminal_width, terminal_height = self.get_terminal_dimensions()
2350
+ terminal = ShellTerminal(
2351
+ f"terminal #{self._terminal_count}",
2352
+ id=f"shell-terminal-{self._terminal_count}",
2353
+ size=(terminal_width, terminal_height),
2354
+ get_terminal_dimensions=self.get_terminal_dimensions,
2355
+ )
2356
+
2357
+ terminal.display = False
2358
+ terminal = await self.post(terminal)
2359
+ self.add_focusable_terminal(terminal)
2360
+ self.refresh_bindings()
2361
+ return terminal
2362
+
2363
+ def get_terminal_dimensions(self) -> tuple[int, int]:
2364
+ """Get the default dimensions of new terminals.
2365
+
2366
+ Returns:
2367
+ Tuple of (WIDTH, HEIGHT)
2368
+ """
2369
+ terminal_width = max(
2370
+ 16,
2371
+ (self.window.size.width - 2 - self.window.styles.scrollbar_size_vertical),
2372
+ )
2373
+ terminal_height = max(8, self.window.scrollable_content_region.height)
2374
+ return terminal_width, terminal_height
2375
+
2376
+ @property
2377
+ def shell(self) -> Shell:
2378
+ """A Shell instance."""
2379
+
2380
+ if self._shell is None or self._shell.is_finished:
2381
+ shell_command = self.app.settings.get(
2382
+ "shell.command",
2383
+ str,
2384
+ expand=False,
2385
+ )
2386
+ shell_start = self.app.settings.get(
2387
+ "shell.command_start",
2388
+ str,
2389
+ expand=False,
2390
+ )
2391
+ shell_directory = self.working_directory
2392
+ self._shell = Shell(
2393
+ self, shell_directory, shell=shell_command, start=shell_start
2394
+ )
2395
+ self._shell.start()
2396
+ return self._shell
2397
+
2398
+ async def post_shell(self, command: str) -> None:
2399
+ """Post a command to the shell.
2400
+
2401
+ Args:
2402
+ command: Command to execute.
2403
+ """
2404
+ from tui.widgets.shell_result import ShellResult
2405
+
2406
+ if command.strip():
2407
+ self._shell_count += 1
2408
+ await self.post(ShellResult(command))
2409
+ width, height = self.get_terminal_dimensions()
2410
+ await self.shell.send(command, width, height)
2411
+
2412
+ def action_cursor_up(self) -> None:
2413
+ if not self.contents.displayed_children or self.cursor_offset == 0:
2414
+ # No children
2415
+ return
2416
+ if self.cursor_offset == -1:
2417
+ # Start cursor at end
2418
+ self.cursor_offset = len(self.contents.displayed_children) - 1
2419
+ cursor_block = self.cursor_block
2420
+ if isinstance(cursor_block, BlockProtocol):
2421
+ cursor_block.block_cursor_clear()
2422
+ cursor_block.block_cursor_up()
2423
+ else:
2424
+ cursor_block = self.cursor_block
2425
+ if isinstance(cursor_block, BlockProtocol):
2426
+ if cursor_block.block_cursor_up() is None:
2427
+ self.cursor_offset -= 1
2428
+ cursor_block = self.cursor_block
2429
+ if isinstance(cursor_block, BlockProtocol):
2430
+ cursor_block.block_cursor_clear()
2431
+ cursor_block.block_cursor_up()
2432
+ else:
2433
+ # Move cursor up
2434
+ self.cursor_offset -= 1
2435
+ cursor_block = self.cursor_block
2436
+ if isinstance(cursor_block, BlockProtocol):
2437
+ cursor_block.block_cursor_clear()
2438
+ cursor_block.block_cursor_up()
2439
+ self.refresh_block_cursor()
2440
+
2441
+ def action_cursor_down(self) -> None:
2442
+ if not self.contents.displayed_children or self.cursor_offset == -1:
2443
+ # No children, or no cursor
2444
+ return
2445
+
2446
+ cursor_block = self.cursor_block
2447
+ if isinstance(cursor_block, BlockProtocol):
2448
+ if cursor_block.block_cursor_down() is None:
2449
+ self.cursor_offset += 1
2450
+ if self.cursor_offset >= len(self.contents.displayed_children):
2451
+ self.cursor_offset = -1
2452
+ self.refresh_block_cursor()
2453
+ return
2454
+ cursor_block = self.cursor_block
2455
+ if isinstance(cursor_block, BlockProtocol):
2456
+ cursor_block.block_cursor_clear()
2457
+ cursor_block.block_cursor_down()
2458
+ else:
2459
+ self.cursor_offset += 1
2460
+ if self.cursor_offset >= len(self.contents.displayed_children):
2461
+ self.cursor_offset = -1
2462
+ self.refresh_block_cursor()
2463
+ return
2464
+ cursor_block = self.cursor_block
2465
+ if isinstance(cursor_block, BlockProtocol):
2466
+ cursor_block.block_cursor_clear()
2467
+ cursor_block.block_cursor_down()
2468
+ self.refresh_block_cursor()
2469
+
2470
+ async def action_cancel(self) -> None:
2471
+ now = monotonic()
2472
+
2473
+ # If AskUser is showing (simple question path), single ESC closes it
2474
+ if self._pending_ask_user:
2475
+ self._pending_ask_user = False
2476
+ try:
2477
+ for child in self.contents.children:
2478
+ if isinstance(child, AskUserWidget):
2479
+ await child.remove()
2480
+ break
2481
+ except Exception:
2482
+ pass
2483
+ if self.agent is not None and self.agent._process is not None:
2484
+ self.agent.send_ask_user_answer("", cancelled=True)
2485
+ self.prompt.display = True
2486
+ self.flash("AskUser closed")
2487
+ self.focus_prompt()
2488
+ self._last_escape_time = 0.0
2489
+ self.turn = "agent"
2490
+ return
2491
+
2492
+ # Normal double-ESC logic for session cancellation
2493
+ if now - self._last_escape_time < 3:
2494
+ if (agent := self.agent) is not None:
2495
+ self._last_escape_time = 0.0
2496
+ await agent.cancel()
2497
+ self.flash("Cancel requested…", style="success")
2498
+ else:
2499
+ self.flash("Press [b]esc[/] again to cancel agent's turn")
2500
+ self._last_escape_time = now
2501
+
2502
+ def focus_prompt(self, reset_cursor: bool = True, scroll_end: bool = True) -> None:
2503
+ """Focus the prompt input.
2504
+
2505
+ Args:
2506
+ reset_cursor: Reset the block cursor.
2507
+ scroll_end: Scroll t the end of the content.
2508
+ """
2509
+ if reset_cursor:
2510
+ self.cursor_offset = -1
2511
+ self.cursor.visible = False
2512
+ if scroll_end:
2513
+ self.window.scroll_end()
2514
+ self.prompt.focus()
2515
+
2516
+ async def action_select_block(self) -> None:
2517
+ if (block := self.get_cursor_block(Widget)) is None:
2518
+ return
2519
+
2520
+ menu_options = [
2521
+ MenuItem("[u]C[/]opy to clipboard", "copy_to_clipboard", "c"),
2522
+ MenuItem("Co[u]p[/u]y to prompt", "copy_to_prompt", "p"),
2523
+ MenuItem("Open as S[u]V[/]G", "export_to_svg", "v"),
2524
+ ]
2525
+
2526
+ if block.allow_maximize:
2527
+ menu_options.append(MenuItem("[u]M[/u]aximize", "maximize_block", "m"))
2528
+
2529
+ if isinstance(block, MenuProtocol):
2530
+ menu_options.extend(block.get_block_menu())
2531
+ menu = Menu(block, menu_options)
2532
+ else:
2533
+ menu = Menu(block, menu_options)
2534
+
2535
+ menu.offset = Offset(1, block.region.offset.y)
2536
+ await self.mount(menu)
2537
+ menu.focus()
2538
+
2539
+ def action_copy_to_clipboard(
2540
+ self, block: Widget | None = None
2541
+ ) -> None:
2542
+ if block is None:
2543
+ block = self.get_cursor_block()
2544
+ if isinstance(block, MenuProtocol):
2545
+ text = block.get_block_content("clipboard")
2546
+ elif isinstance(block, MarkdownFence):
2547
+ text = block._content.plain
2548
+ elif isinstance(block, MarkdownBlock):
2549
+ text = block.source
2550
+ else:
2551
+ return
2552
+ if text:
2553
+ self.app.copy_to_clipboard(text)
2554
+ self.flash("Copied to clipboard")
2555
+
2556
+ def action_copy_to_prompt(self) -> None:
2557
+ block = self.get_cursor_block()
2558
+ if isinstance(block, MenuProtocol):
2559
+ text = block.get_block_content("prompt")
2560
+ elif isinstance(block, MarkdownFence):
2561
+ # Copy to prompt leaves MD formatting
2562
+ text = block.source
2563
+ elif isinstance(block, MarkdownBlock):
2564
+ text = block.source
2565
+ else:
2566
+ return
2567
+
2568
+ if text:
2569
+ self.prompt.append(text)
2570
+ self.flash("Copied to prompt")
2571
+ self.focus_prompt()
2572
+
2573
+ def action_maximize_block(self) -> None:
2574
+ if (block := self.get_cursor_block()) is not None:
2575
+ self.screen.maximize(block, container=False)
2576
+ block.focus()
2577
+
2578
+ def action_export_to_svg(self) -> None:
2579
+ block = self.get_cursor_block()
2580
+ if block is None:
2581
+ return
2582
+ import platformdirs
2583
+ from textual._compositor import Compositor
2584
+ from textual._files import generate_datetime_filename
2585
+
2586
+ width, height = block.outer_size
2587
+ compositor = Compositor()
2588
+ compositor.reflow(block, block.outer_size)
2589
+ render = compositor.render_full_update()
2590
+
2591
+ from rich.console import Console
2592
+ import io
2593
+ import os.path
2594
+
2595
+ console = Console(
2596
+ width=width,
2597
+ height=height,
2598
+ file=io.StringIO(),
2599
+ force_terminal=True,
2600
+ color_system="truecolor",
2601
+ record=True,
2602
+ legacy_windows=False,
2603
+ safe_box=False,
2604
+ )
2605
+ console.print(render)
2606
+ path = platformdirs.user_pictures_dir()
2607
+ svg_filename = generate_datetime_filename("A2TUI", ".svg", None)
2608
+ svg_path = os.path.expanduser(os.path.join(path, svg_filename))
2609
+ console.save_svg(svg_path)
2610
+ import webbrowser
2611
+
2612
+ webbrowser.open(f"file:///{svg_path}")
2613
+
2614
+ async def action_mode_switcher(self) -> None:
2615
+ self.prompt.mode_switcher.focus()
2616
+
2617
+ def refresh_block_cursor(self) -> None:
2618
+ if (cursor_block := self.cursor_block_child) is not None:
2619
+ self.window.focus()
2620
+ self.cursor.visible = True
2621
+ self.cursor.follow(cursor_block)
2622
+ self.call_after_refresh(
2623
+ self.window.scroll_to_center, cursor_block, immediate=True
2624
+ )
2625
+ else:
2626
+ self.cursor.visible = False
2627
+ self.window.anchor(False)
2628
+ self.window.scroll_end(duration=2 / 10)
2629
+ self.cursor.follow(None)
2630
+ self.prompt.focus()
2631
+ self.refresh_bindings()
2632
+
2633
+ async def slash_command(self, text: str) -> bool:
2634
+ """Give A2TUI the opertunity to process slash commands.
2635
+
2636
+ Args:
2637
+ text: The prompt, including the slash in the first position.
2638
+
2639
+ Returns:
2640
+ `True` if A2TUI has processed the slash command, `False` if it should
2641
+ be forwarded to the agent.
2642
+ """
2643
+ command, _, parameters = text[1:].partition(" ")
2644
+ if command == "about":
2645
+ from tui import about
2646
+ from tui.widgets.markdown_note import MarkdownNote
2647
+
2648
+ app = self.app
2649
+ about_md = about.render(app)
2650
+ await self.post(MarkdownNote(about_md, classes="about"))
2651
+ self.app.copy_to_clipboard(about_md)
2652
+ self.notify(
2653
+ "A copy of /about has been placed in your clipboard",
2654
+ title="/about",
2655
+ )
2656
+ return True
2657
+ elif command == "clear":
2658
+ try:
2659
+ line_count = max(0, int(parameters) if parameters.strip() else 0)
2660
+ except ValueError:
2661
+ self.notify(
2662
+ "Unable to clear—a number was expected",
2663
+ title="/clear",
2664
+ severity="error",
2665
+ )
2666
+ return True
2667
+ if line_count == 0:
2668
+ contents = self.contents
2669
+ self.cursor_offset = -1
2670
+ self.cursor.visible = False
2671
+ self.cursor.follow(None)
2672
+ contents.refresh()
2673
+ await contents.remove_children(list(contents.children))
2674
+ if self.window.scroll_y >= self.window.max_scroll_y:
2675
+ self.call_later(self.window.anchor)
2676
+ else:
2677
+ await self.prune_window(line_count, line_count)
2678
+ return True
2679
+ elif command == "trace":
2680
+ self.app.action_trace()
2681
+ return True
2682
+ elif command == "engine":
2683
+ self.app.push_screen("store")
2684
+ return True
2685
+ elif command == "cls":
2686
+ return await self._handle_cls_command(parameters)
2687
+ elif command == "exit":
2688
+ if self.session_start_time is not None:
2689
+ session_time = monotonic() - self.session_start_time
2690
+ session_id = getattr(self.agent, "session_id", None) or self._agent_session_id or ""
2691
+ self.app._exit_metrics = {
2692
+ "session_id": session_id,
2693
+ "tool_call_total": self._tool_call_total,
2694
+ "tool_call_success": self._tool_call_success,
2695
+ "tool_call_failed": self._tool_call_failed,
2696
+ "turn_count": self._turn_count,
2697
+ "shell_count": self._shell_count,
2698
+ "wall_time": session_time,
2699
+ "agent_title": self.agent_title or "Agent",
2700
+ }
2701
+ if self.agent is not None:
2702
+ await self.agent.stop()
2703
+ self.app.exit()
2704
+ return True
2705
+ elif command == "session":
2706
+ return await self._handle_session_command(parameters)
2707
+ elif command == "aidlc":
2708
+ return await self._handle_aidlc_command(parameters)
2709
+ elif command == "diff":
2710
+ return await self._handle_diff_command()
2711
+ elif command == "ide":
2712
+ return await self._handle_ide_command(parameters)
2713
+ return await self._handle_tui_session_project_dispatch(command, parameters)
2714
+
2715
+ async def _handle_diff_command(self) -> bool:
2716
+ from tui.screens.diff_explorer_screen import DiffExplorerScreen
2717
+ self.app.push_screen(DiffExplorerScreen(project_dir=self.project_path))
2718
+ return True
2719
+
2720
+ async def _handle_ide_command(self, parameters: str) -> bool:
2721
+ from tui.screens.ide_screen import IdeScreen
2722
+ filepath = parameters.strip() if parameters.strip() else None
2723
+ self.app.push_screen(
2724
+ IdeScreen(project_dir=self.project_path, filepath=filepath)
2725
+ )
2726
+ return True
2727
+
2728
+ async def _handle_cls_command(self, parameters: str) -> bool:
2729
+ import asyncio
2730
+ import re
2731
+ from pathlib import Path
2732
+ from tempfile import TemporaryDirectory
2733
+
2734
+ parts = parameters.strip().split()
2735
+ args = ["cdh", "cls", "search", "--json"]
2736
+ has_request_id = False
2737
+ has_function = False
2738
+
2739
+ i = 0
2740
+ while i < len(parts):
2741
+ part = parts[i]
2742
+ if part in ("--request-id", "--rid"):
2743
+ if i + 1 < len(parts):
2744
+ args.extend(["--request-id", parts[i + 1]])
2745
+ has_request_id = True
2746
+ i += 2
2747
+ else:
2748
+ self.notify("Missing value for --request-id", title="/cls", severity="error")
2749
+ return True
2750
+ elif part == "--function":
2751
+ if i + 1 < len(parts):
2752
+ args.extend(["--function", parts[i + 1]])
2753
+ has_function = True
2754
+ i += 2
2755
+ else:
2756
+ self.notify("Missing value for --function", title="/cls", severity="error")
2757
+ return True
2758
+ elif part == "--keyword":
2759
+ if i + 1 < len(parts):
2760
+ args.extend(["--keyword", parts[i + 1]])
2761
+ i += 2
2762
+ else:
2763
+ self.notify("Missing value for --keyword", title="/cls", severity="error")
2764
+ return True
2765
+ elif part == "--limit":
2766
+ if i + 1 < len(parts):
2767
+ args.extend(["--limit", parts[i + 1]])
2768
+ i += 2
2769
+ else:
2770
+ self.notify("Missing value for --limit", title="/cls", severity="error")
2771
+ return True
2772
+ elif part == "--region":
2773
+ if i + 1 < len(parts):
2774
+ args.extend(["--region", parts[i + 1]])
2775
+ i += 2
2776
+ else:
2777
+ self.notify("Missing value for --region", title="/cls", severity="error")
2778
+ return True
2779
+ elif part == "--env":
2780
+ if i + 1 < len(parts):
2781
+ args.extend(["--env", parts[i + 1]])
2782
+ i += 2
2783
+ else:
2784
+ self.notify("Missing value for --env", title="/cls", severity="error")
2785
+ return True
2786
+ elif part.startswith("-"):
2787
+ self.notify(f"Unknown option: {part}", title="/cls", severity="error")
2788
+ return True
2789
+ else:
2790
+ self.notify(f"Unexpected argument: {part}", title="/cls", severity="error")
2791
+ return True
2792
+
2793
+ if not has_request_id and not has_function:
2794
+ self.notify(
2795
+ "/cls requires --request-id or --function\n"
2796
+ "Usage: /cls --request-id <ID> --function <NAME>\n"
2797
+ " /cls --function <NAME> --keyword <KW>",
2798
+ title="/cls",
2799
+ severity="error"
2800
+ )
2801
+ return True
2802
+
2803
+ try:
2804
+ self.app.push_screen("terminal", run=f"cdh cls search --help")
2805
+
2806
+ proc = await asyncio.create_subprocess_exec(
2807
+ *args,
2808
+ stdout=asyncio.subprocess.PIPE,
2809
+ stderr=asyncio.subprocess.PIPE,
2810
+ )
2811
+ stdout, stderr = await proc.communicate()
2812
+
2813
+ if proc.returncode != 0:
2814
+ error_msg = stderr.decode() if stderr else "Unknown error"
2815
+ self.notify(f"CLS search failed: {error_msg}", title="/cls", severity="error")
2816
+ return True
2817
+
2818
+ import json
2819
+ results = json.loads(stdout.decode()) if stdout else []
2820
+
2821
+ if not results:
2822
+ self.notify("No logs found", title="/cls")
2823
+ return True
2824
+
2825
+ lines = [f"Found {len(results)} log entries:"]
2826
+ for log in results[:20]:
2827
+ time_str = log.get("time", "?")
2828
+ msg = log.get("message", "")
2829
+ req_id = log.get("request_id", "")
2830
+ func = log.get("function", "")
2831
+ level = log.get("level", "INFO")
2832
+ duration = log.get("duration_ms", 0)
2833
+
2834
+ header = f"[{time_str}]"
2835
+ if req_id:
2836
+ header += f" {req_id[:20]}..."
2837
+ if func:
2838
+ header += f" {func}"
2839
+ header += f" [{level}]"
2840
+ if duration:
2841
+ header += f" {duration}ms"
2842
+
2843
+ lines.append(header)
2844
+ if msg:
2845
+ lines.append(f" {msg[:200]}")
2846
+
2847
+ if len(results) > 20:
2848
+ lines.append(f"... and {len(results) - 20} more entries")
2849
+
2850
+ from tui.widgets.markdown_note import MarkdownNote
2851
+ content = "```\n" + "\n".join(lines) + "\n```"
2852
+ await self.post(MarkdownNote(content, classes="cls-results"))
2853
+ self.app.copy_to_clipboard(content)
2854
+ self.notify(
2855
+ f"Found {len(results)} logs — copied to clipboard",
2856
+ title="/cls"
2857
+ )
2858
+
2859
+ except FileNotFoundError:
2860
+ self.notify(
2861
+ "cdh command not found. Install with: pip install -e .",
2862
+ title="/cls",
2863
+ severity="error"
2864
+ )
2865
+ except json.JSONDecodeError:
2866
+ self.notify("Failed to parse CLS output", title="/cls", severity="error")
2867
+ except Exception as e:
2868
+ self.notify(f"Error: {e}", title="/cls", severity="error")
2869
+
2870
+ return True
2871
+
2872
+ async def _handle_tui_session_project_dispatch(
2873
+ self, command: str, parameters: str
2874
+ ) -> bool:
2875
+ if await platform_commands.dispatch(self, command, parameters):
2876
+ return True
2877
+ return False
2878
+
2879
+ async def _handle_session_command(self, parameters: str) -> bool:
2880
+ parts = parameters.strip().split(maxsplit=1)
2881
+ sub_cmd = parts[0] if parts else ""
2882
+ arg = parts[1] if len(parts) > 1 else ""
2883
+
2884
+ if sub_cmd == "list":
2885
+ self.app.push_screen("sessions")
2886
+ return True
2887
+ elif sub_cmd == "load":
2888
+ if not arg:
2889
+ self.notify("Session ID required", title="/session load", severity="error")
2890
+ return True
2891
+ try:
2892
+ session_pk = int(arg)
2893
+ except ValueError:
2894
+ self.notify("Invalid session ID", title="/session load", severity="error")
2895
+ return True
2896
+ self.post_message(messages.SessionLoad(session_pk))
2897
+ return True
2898
+ elif sub_cmd == "new":
2899
+ if self._agent_data is not None:
2900
+ self.post_message(
2901
+ messages.SessionNew(
2902
+ self.working_directory,
2903
+ self._agent_data["identity"],
2904
+ arg,
2905
+ )
2906
+ )
2907
+ return True
2908
+ return False
2909
+ elif sub_cmd == "close":
2910
+ if self.turn == "agent" and self.agent is not None:
2911
+ await self.agent.cancel()
2912
+ if self.screen.id is not None:
2913
+ self.post_message(messages.SessionClose(self.screen.id))
2914
+ return True
2915
+ return False
2916
+ elif sub_cmd == "rename":
2917
+ name = arg.strip()
2918
+ if not name:
2919
+ self.notify(
2920
+ "Expected a name for the session.\n"
2921
+ 'For example: "add comments to blog"',
2922
+ title="/session rename",
2923
+ severity="error",
2924
+ )
2925
+ return True
2926
+ if self.agent is not None:
2927
+ await self.agent.set_session_name(name)
2928
+ self.flash(f"Renamed session to [b]'{name}'", style="success")
2929
+ return True
2930
+ else:
2931
+ self.notify(
2932
+ "Usage: /session <list|load|new|close|rename>",
2933
+ title="/session",
2934
+ severity="error",
2935
+ )
2936
+ return True
2937
+
2938
+ def _emit_aidlc_state(self, target: Path) -> None:
2939
+ from cdh.project_loader import CdhProjectLoader
2940
+
2941
+ cdh_dir = CdhProjectLoader.find_cdh_dir(target)
2942
+ if cdh_dir is None:
2943
+ return
2944
+ state = CdhProjectLoader.load_project_state(cdh_dir)
2945
+ self.post_message(acp_messages.AIDLCState(
2946
+ current_phase=state.get("current_phase", ""),
2947
+ completed_phases=state.get("completed_phases", []),
2948
+ gate_results=state.get("gate_results", {}),
2949
+ ))
2950
+
2951
+ def _current_aidlc_phase(self) -> str:
2952
+ from pathlib import Path
2953
+ from cdh.project_loader import CdhProjectLoader
2954
+
2955
+ target = self.app.project_dir or Path.cwd()
2956
+ cdh_dir = CdhProjectLoader.find_cdh_dir(target)
2957
+ if cdh_dir is None:
2958
+ return "—"
2959
+ state = CdhProjectLoader.load_project_state(cdh_dir)
2960
+ return state.get("current_phase", "—")
2961
+
2962
+ async def _handle_aidlc_command(self, parameters: str) -> bool:
2963
+ parts = parameters.strip().split(maxsplit=1)
2964
+ sub_cmd = parts[0] if parts else ""
2965
+ rest = parts[1] if len(parts) > 1 else ""
2966
+
2967
+ # --- /aidlc project <list|new|init|check|load> ---
2968
+ if sub_cmd == "project":
2969
+ sub_parts = rest.strip().split(maxsplit=3)
2970
+ sub_sub = sub_parts[0] if sub_parts else ""
2971
+ sub_name = sub_parts[1] if len(sub_parts) > 1 else ""
2972
+ sub_path = sub_parts[2] if len(sub_parts) > 2 else ""
2973
+ sub_extra = sub_parts[3] if len(sub_parts) > 3 else ""
2974
+
2975
+ if not sub_sub:
2976
+ self.app.action_projects()
2977
+ return True
2978
+
2979
+ if sub_sub == "list":
2980
+ from pathlib import Path
2981
+ projects_dir = Path.home() / ".cdh" / "projects"
2982
+ if not projects_dir.exists():
2983
+ self.notify("No projects found", title="/aidlc project list")
2984
+ return True
2985
+ project_files = list(projects_dir.glob("*.yaml")) + list(projects_dir.glob("*.json"))
2986
+ if not project_files:
2987
+ self.notify("No projects found", title="/aidlc project list")
2988
+ return True
2989
+ lines = ["Projects:"]
2990
+ for pf in sorted(project_files):
2991
+ lines.append(f" {pf.stem}")
2992
+ self.notify("\n".join(lines), title="/aidlc project list")
2993
+ return True
2994
+
2995
+ elif sub_sub == "load":
2996
+ if not sub_name:
2997
+ self.notify("Project name required", title="/aidlc project load", severity="error")
2998
+ return True
2999
+ from pathlib import Path
3000
+ from cdh.config import write_active_project
3001
+ projects_dir = Path.home() / ".cdh" / "projects"
3002
+ project_file = None
3003
+ for ext in ["yaml", "yml", "json"]:
3004
+ pf = projects_dir / f"{sub_name}.{ext}"
3005
+ if pf.exists():
3006
+ project_file = pf
3007
+ break
3008
+ if not project_file:
3009
+ self.notify(f"Project '{sub_name}' not found", title="/aidlc project load", severity="error")
3010
+ return True
3011
+ import yaml
3012
+ proj_data = yaml.safe_load(project_file.read_text()) if project_file.suffix in [".yaml", ".yml"] else __import__("json").loads(project_file.read_text())
3013
+ project_path = proj_data.get("path", ".")
3014
+ write_active_project(sub_name, project_path)
3015
+ new_project_dir = Path(project_path) if project_path else Path.cwd()
3016
+ self.app.project_dir = new_project_dir
3017
+ self._emit_aidlc_state(new_project_dir)
3018
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=new_project_dir))
3019
+ launched = self.app._launch_new_session_for_project(new_project_dir)
3020
+ suffix = " (new session opened)" if launched else ""
3021
+ self.flash(f"Switched to project: {sub_name}{suffix}", style="success")
3022
+ return True
3023
+
3024
+ elif sub_sub == "new":
3025
+ if not sub_name:
3026
+ self.notify(
3027
+ "Usage: /aidlc project new <name> [path] [components]\n"
3028
+ " components: comma-separated ids (web,backend,native,etc.) or 'all'",
3029
+ title="/aidlc project new", severity="error",
3030
+ )
3031
+ return True
3032
+ from pathlib import Path
3033
+ from cdh.config import write_active_project
3034
+ from cdh.project_loader import CdhProjectLoader
3035
+ from cdh.scaffold import scaffold_dlc_project, COMPONENT_BY_ID
3036
+ import yaml
3037
+ projects_dir = Path.home() / ".cdh" / "projects"
3038
+ projects_dir.mkdir(parents=True, exist_ok=True)
3039
+ project_path = sub_path or str(Path.cwd())
3040
+ ws = Path(project_path).expanduser().resolve()
3041
+
3042
+ selected = []
3043
+ if sub_extra:
3044
+ if sub_extra == "all":
3045
+ selected = list(COMPONENT_BY_ID.keys())
3046
+ else:
3047
+ selected = [c.strip() for c in sub_extra.split(",") if c.strip()]
3048
+ unknown = [c for c in selected if c not in COMPONENT_BY_ID]
3049
+ if unknown:
3050
+ valid = ", ".join(sorted(COMPONENT_BY_ID.keys()))
3051
+ self.notify(
3052
+ f"Unknown components: {', '.join(unknown)}. Valid: {valid}",
3053
+ title="/aidlc project new", severity="error",
3054
+ )
3055
+ return True
3056
+
3057
+ scaffold_dlc_project(ws, sub_name, components=selected if selected else None)
3058
+ CdhProjectLoader.init_project(ws, sub_name)
3059
+ proj_data = {"name": sub_name, "path": str(ws), "description": ""}
3060
+ project_file = projects_dir / f"{sub_name}.yaml"
3061
+ project_file.write_text(yaml.dump(proj_data))
3062
+ write_active_project(sub_name, str(ws))
3063
+ self.app.project_dir = ws
3064
+ self._emit_aidlc_state(ws)
3065
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=ws))
3066
+ launched = self.app._launch_new_session_for_project(ws)
3067
+ session_suffix = " (new session opened)" if launched else ""
3068
+ components_suffix = f" (components: {', '.join(selected)})" if selected else ""
3069
+ self.flash(
3070
+ f"Created and switched to AIDLC project: {sub_name}{components_suffix}{session_suffix}",
3071
+ style="success",
3072
+ )
3073
+ return True
3074
+
3075
+ elif sub_sub == "init":
3076
+ from pathlib import Path
3077
+ from cdh.config import write_active_project
3078
+ from cdh.project_loader import CdhProjectLoader
3079
+ from cdh.scaffold import init_dlc_project as _init_dlc_project
3080
+ import yaml
3081
+ target = Path(sub_name or ".").expanduser().resolve()
3082
+ project_name = target.name
3083
+ projects_dir = Path.home() / ".cdh" / "projects"
3084
+ projects_dir.mkdir(parents=True, exist_ok=True)
3085
+ proj_file = projects_dir / f"{project_name}.yaml"
3086
+ if proj_file.exists():
3087
+ self.notify(
3088
+ f"Project '{project_name}' already exists",
3089
+ title="/aidlc project init",
3090
+ severity="error",
3091
+ )
3092
+ return True
3093
+ _init_dlc_project(target, project_name)
3094
+ CdhProjectLoader.init_project(target, project_name)
3095
+ proj_data = {"name": project_name, "path": str(target), "description": ""}
3096
+ proj_file.write_text(yaml.dump(proj_data))
3097
+ write_active_project(project_name, str(target))
3098
+ self.app.project_dir = target
3099
+ self._emit_aidlc_state(target)
3100
+ self.post_message(messages.ProjectDirectoryUpdated(project_dir=target))
3101
+ launched = self.app._launch_new_session_for_project(target)
3102
+ session_suffix = " (new session opened)" if launched else ""
3103
+ self.flash(f"Initialized AIDLC project in: {target}{session_suffix}", style="success")
3104
+ return True
3105
+
3106
+ elif sub_sub == "check":
3107
+ from pathlib import Path
3108
+ from cdh.scaffold import check_dlc_project as _check_dlc_project
3109
+ target = Path(sub_name or ".").expanduser().resolve()
3110
+ result = _check_dlc_project(target)
3111
+ if result["valid"]:
3112
+ lines = [f"\u2713 Valid AIDLC project: {result['name']}"]
3113
+ lines.append(f" Location: {result['path']}")
3114
+ if result["components"]:
3115
+ lines.append(f" Components: {', '.join(result['components'])}")
3116
+ else:
3117
+ lines.append(" Components: (none)")
3118
+ lines.append(
3119
+ f" CDH state: \u2713 initialized" if result["has_cdh"]
3120
+ else " CDH state: \u2717 not initialized"
3121
+ )
3122
+ else:
3123
+ lines = [f"\u2717 Not a valid AIDLC project: {target}"]
3124
+ for s in result["suggestions"]:
3125
+ lines.append(f" \u2022 {s}")
3126
+ self.notify("\n".join(lines), title="/aidlc project check")
3127
+ return True
3128
+
3129
+ else:
3130
+ self.notify(
3131
+ "Usage: /aidlc project <list|new|init|check|load> [args]",
3132
+ title="/aidlc project",
3133
+ severity="error",
3134
+ )
3135
+ return True
3136
+
3137
+ # --- /aidlc phase <phase> ---
3138
+ elif sub_cmd == "phase":
3139
+ from pathlib import Path
3140
+ from cdh.project_loader import CdhProjectLoader
3141
+
3142
+ rest_parts = rest.strip().split(maxsplit=1)
3143
+ target_phase = rest_parts[0] if rest_parts else ""
3144
+ valid_phases = {"init", "understand", "plan", "verify", "deliver"}
3145
+ if not target_phase or target_phase not in valid_phases:
3146
+ phases_list = "|".join(sorted(valid_phases))
3147
+ self.notify(
3148
+ f"Usage: /aidlc phase <{phases_list}>\nCurrent: {self._current_aidlc_phase()}",
3149
+ title="/aidlc phase",
3150
+ severity="error",
3151
+ )
3152
+ return True
3153
+ target = self.app.project_dir or Path.cwd()
3154
+ ok = CdhProjectLoader.advance_phase(target, target_phase)
3155
+ if ok:
3156
+ self.flash(f"Phase: {target_phase}", style="success")
3157
+ self._emit_aidlc_state(target)
3158
+ else:
3159
+ self.notify(
3160
+ "No .cdh/ directory found or invalid phase transition. "
3161
+ "Phases must advance forward in sequence "
3162
+ "(init\u2192understand\u2192plan\u2192verify\u2192deliver). "
3163
+ "Use 'init' to reset.\n"
3164
+ f"Current: {self._current_aidlc_phase()}",
3165
+ title="/aidlc phase",
3166
+ severity="error",
3167
+ )
3168
+ return True
3169
+
3170
+ # --- /aidlc gate <name> <passed|failed> ---
3171
+ elif sub_cmd == "gate":
3172
+ from pathlib import Path
3173
+ from cdh.project_loader import CdhProjectLoader
3174
+
3175
+ rest_parts = rest.strip().split(maxsplit=1)
3176
+ gate_name = rest_parts[0] if rest_parts else ""
3177
+ gate_status = rest_parts[1] if len(rest_parts) > 1 else ""
3178
+ if not gate_name or gate_status not in ("passed", "failed"):
3179
+ self.notify(
3180
+ "Usage: /aidlc gate <name> <passed|failed>",
3181
+ title="/aidlc gate",
3182
+ severity="error",
3183
+ )
3184
+ return True
3185
+ target = self.app.project_dir or Path.cwd()
3186
+ ok = CdhProjectLoader.record_gate_result(target, gate_name, gate_status)
3187
+ if ok:
3188
+ self.flash(f"Gate '{gate_name}': {gate_status}", style="success")
3189
+ self._emit_aidlc_state(target)
3190
+ else:
3191
+ self.notify(
3192
+ "No .cdh/ directory found. Run /aidlc project init first.",
3193
+ title="/aidlc gate",
3194
+ severity="error",
3195
+ )
3196
+ return True
3197
+
3198
+ # --- /aidlc sync or /aidlc update ---
3199
+ elif sub_cmd in ("sync", "update"):
3200
+ from pathlib import Path
3201
+ from cdh.scaffold import (
3202
+ _regenerate_agents_and_claude_md,
3203
+ scaffold_dlc_project as _scaffold_dlc_project,
3204
+ )
3205
+ from cdh.project_loader import CdhProjectLoader
3206
+
3207
+ target = self.app.project_dir or Path.cwd()
3208
+ project_yaml = target / "aidlc" / "project.yaml"
3209
+ if project_yaml.exists():
3210
+ _regenerate_agents_and_claude_md(target)
3211
+ self.flash("Regenerated AGENTS.md and CLAUDE.md", style="success")
3212
+ else:
3213
+ project_name = target.name
3214
+ _scaffold_dlc_project(target, project_name)
3215
+ CdhProjectLoader.init_project(target, project_name)
3216
+ self.flash(
3217
+ "Initialized AIDLC project and regenerated AGENTS.md/CLAUDE.md",
3218
+ style="success",
3219
+ )
3220
+ self._emit_aidlc_state(target)
3221
+ return True
3222
+
3223
+ else:
3224
+ self.notify(
3225
+ "Usage: /aidlc project <list|new|init|check|load> [args]\n"
3226
+ " /aidlc phase <phase>\n"
3227
+ " /aidlc gate <name> <passed|failed>\n"
3228
+ " /aidlc sync|update",
3229
+ title="/aidlc",
3230
+ severity="error",
3231
+ )
3232
+ return True
3233
+
3234
+ return False