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