@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.
- package/ai-dlc-skill/SKILL.md +106 -0
- package/ai-dlc-skill/architecture/project-structure.md +78 -0
- package/ai-dlc-skill/brownfield/README.md +36 -0
- package/ai-dlc-skill/brownfield/entry.md +45 -0
- package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
- package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
- package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
- package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
- package/ai-dlc-skill/components/backend.md +47 -0
- package/ai-dlc-skill/components/desktop.md +63 -0
- package/ai-dlc-skill/components/mya.md +219 -0
- package/ai-dlc-skill/components/native.md +73 -0
- package/ai-dlc-skill/components/tta.md +235 -0
- package/ai-dlc-skill/components/web.md +77 -0
- package/ai-dlc-skill/components/wxa.md +219 -0
- package/ai-dlc-skill/contracts/README.md +25 -0
- package/ai-dlc-skill/core/adaptive-flow.md +107 -0
- package/ai-dlc-skill/core/security.md +29 -0
- package/ai-dlc-skill/core/task-registry.md +153 -0
- package/ai-dlc-skill/cross-tool/README.md +20 -0
- package/ai-dlc-skill/cross-tool/cline/export.sh +38 -0
- package/ai-dlc-skill/cross-tool/copilot/export.sh +41 -0
- package/ai-dlc-skill/cross-tool/cursor/export.sh +53 -0
- package/ai-dlc-skill/cross-tool/onecode/export.sh +27 -0
- package/ai-dlc-skill/cross-tool/opencode/export.sh +38 -0
- package/ai-dlc-skill/phases/debug/entry.md +48 -0
- package/ai-dlc-skill/phases/debug/lifecycle.md +284 -0
- package/ai-dlc-skill/phases/deliver/entry.md +35 -0
- package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
- package/ai-dlc-skill/phases/deliver/prompt.md +54 -0
- package/ai-dlc-skill/phases/deliver/rules.md +61 -0
- package/ai-dlc-skill/phases/plan/design_system/accessibility.md +268 -0
- package/ai-dlc-skill/phases/plan/design_system/atomic_design.md +341 -0
- package/ai-dlc-skill/phases/plan/design_system/component_spec.md +398 -0
- package/ai-dlc-skill/phases/plan/design_system/design_tokens.md +190 -0
- package/ai-dlc-skill/phases/plan/design_system/iconography.md +299 -0
- package/ai-dlc-skill/phases/plan/design_system/platform_ui/desktop.md +318 -0
- package/ai-dlc-skill/phases/plan/design_system/platform_ui/mya.md +354 -0
- package/ai-dlc-skill/phases/plan/design_system/platform_ui/native.md +328 -0
- package/ai-dlc-skill/phases/plan/design_system/platform_ui/tta.md +394 -0
- package/ai-dlc-skill/phases/plan/design_system/platform_ui/web.md +287 -0
- package/ai-dlc-skill/phases/plan/design_system/platform_ui/wxa.md +374 -0
- package/ai-dlc-skill/phases/plan/design_system/theme_system.md +318 -0
- package/ai-dlc-skill/phases/plan/entry.md +33 -0
- package/ai-dlc-skill/phases/plan/lifecycle.md +260 -0
- package/ai-dlc-skill/phases/plan/prompt.md +75 -0
- package/ai-dlc-skill/phases/plan/rules.md +60 -0
- package/ai-dlc-skill/phases/understand/entry.md +36 -0
- package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
- package/ai-dlc-skill/phases/understand/prompt.md +58 -0
- package/ai-dlc-skill/phases/understand/rules.md +49 -0
- package/ai-dlc-skill/phases/verify/config/base_url.md +65 -0
- package/ai-dlc-skill/phases/verify/entry.md +59 -0
- package/ai-dlc-skill/phases/verify/gates/browser_e2e.md +96 -0
- package/ai-dlc-skill/phases/verify/lifecycle.md +217 -0
- package/ai-dlc-skill/phases/verify/prompt.md +73 -0
- package/ai-dlc-skill/phases/verify/rules.md +91 -0
- package/ai-dlc-skill/phases/verify/runners/playwright_local.md +103 -0
- package/ai-dlc-skill/practices/bdd.md +119 -0
- package/ai-dlc-skill/practices/sdd.md +84 -0
- package/ai-dlc-skill/practices/tdd.md +128 -0
- package/ai-dlc-skill/providers/README.md +114 -0
- package/ai-dlc-skill/providers/aliyun/SKILL.md +204 -0
- package/ai-dlc-skill/providers/aliyun/api/rest-api.md +214 -0
- package/ai-dlc-skill/providers/aliyun/api/sdk.md +307 -0
- package/ai-dlc-skill/providers/aliyun/best-practices/database-design.md +363 -0
- package/ai-dlc-skill/providers/aliyun/best-practices/function-design.md +424 -0
- package/ai-dlc-skill/providers/aliyun/best-practices/troubleshooting.md +258 -0
- package/ai-dlc-skill/providers/aliyun/cli/database.md +263 -0
- package/ai-dlc-skill/providers/aliyun/cli/functions.md +263 -0
- package/ai-dlc-skill/providers/aliyun/cli/hosting.md +226 -0
- package/ai-dlc-skill/providers/aliyun/cli/setup.md +235 -0
- package/ai-dlc-skill/providers/aliyun/cli/storage.md +315 -0
- package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
- package/ai-dlc-skill/providers/aliyun/integration/ci-cd.md +288 -0
- package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
- package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
- package/ai-dlc-skill/providers/aliyun/security/authentication.md +254 -0
- package/ai-dlc-skill/providers/aliyun/services/cdn-hosting.md +220 -0
- package/ai-dlc-skill/providers/aliyun/services/database.md +319 -0
- package/ai-dlc-skill/providers/aliyun/services/function-compute.md +273 -0
- package/ai-dlc-skill/providers/aliyun/services/overview.md +163 -0
- package/ai-dlc-skill/providers/aliyun/services/serverless-app-engine.md +266 -0
- package/ai-dlc-skill/providers/aliyun/services/storage.md +269 -0
- package/ai-dlc-skill/providers/tcb/SKILL.md +208 -0
- package/ai-dlc-skill/providers/tcb/api/rest-api.md +338 -0
- package/ai-dlc-skill/providers/tcb/api/sdk.md +288 -0
- package/ai-dlc-skill/providers/tcb/best-practices/database-design.md +452 -0
- package/ai-dlc-skill/providers/tcb/best-practices/function-design.md +502 -0
- package/ai-dlc-skill/providers/tcb/best-practices/troubleshooting.md +462 -0
- package/ai-dlc-skill/providers/tcb/cli/cls.md +133 -0
- package/ai-dlc-skill/providers/tcb/cli/database.md +230 -0
- package/ai-dlc-skill/providers/tcb/cli/env.md +207 -0
- package/ai-dlc-skill/providers/tcb/cli/functions.md +276 -0
- package/ai-dlc-skill/providers/tcb/cli/hosting.md +219 -0
- package/ai-dlc-skill/providers/tcb/cli/setup.md +218 -0
- package/ai-dlc-skill/providers/tcb/cli/storage.md +233 -0
- package/ai-dlc-skill/providers/tcb/deployment.yaml +111 -0
- package/ai-dlc-skill/providers/tcb/integration/ci-cd.md +309 -0
- package/ai-dlc-skill/providers/tcb/integration/mcp-server.md +371 -0
- package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
- package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
- package/ai-dlc-skill/providers/tcb/security/authentication.md +241 -0
- package/ai-dlc-skill/providers/tcb/services/cloud-database.md +299 -0
- package/ai-dlc-skill/providers/tcb/services/cloud-functions.md +265 -0
- package/ai-dlc-skill/providers/tcb/services/cloud-hosting.md +264 -0
- package/ai-dlc-skill/providers/tcb/services/cloud-run.md +285 -0
- package/ai-dlc-skill/providers/tcb/services/cloud-storage.md +245 -0
- package/ai-dlc-skill/providers/tcb/services/overview.md +174 -0
- package/ai-dlc-skill/pybdd/README.md +41 -0
- package/ai-dlc-skill/pybdd/conftest.py +17 -0
- package/ai-dlc-skill/pybdd/example/auth/login.feature +32 -0
- package/ai-dlc-skill/pybdd/pytest.ini +5 -0
- package/ai-dlc-skill/pybdd/steps/.gitkeep +1 -0
- package/ai-dlc-skill/pybdd/steps/test_example_steps.py +65 -0
- package/ai-dlc-skill/requirements.md +140 -0
- package/ai-dlc-skill/skill.yaml +135 -0
- package/ai-dlc-skill/templates/brownfield/context.md +78 -0
- package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
- package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
- package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
- package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
- package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
- package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
- package/ai-dlc-skill/templates/plan/design.md +49 -0
- package/ai-dlc-skill/templates/plan/task-list.md +32 -0
- package/ai-dlc-skill/templates/project/README.md +126 -0
- package/ai-dlc-skill/templates/project/template.md +119 -0
- package/ai-dlc-skill/templates/understand/feature.feature +30 -0
- package/ai-dlc-skill/templates/understand/intent.md +29 -0
- package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
- package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
- package/ai-dlc-skill/walkthrough/collect.sh +50 -0
- package/ai-dlc-skill/walkthrough/template.md +54 -0
- package/cdh/__init__.py +0 -0
- package/cdh/__main__.py +4 -0
- package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cli_logging.cpython-314.pyc +0 -0
- package/cdh/__pycache__/config.cpython-314.pyc +0 -0
- package/cdh/__pycache__/project_loader.cpython-314.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/cdh/__pycache__/session_store.cpython-314.pyc +0 -0
- package/cdh/cdh_session_aggregator.py +258 -0
- package/cdh/cli.py +3000 -0
- package/cdh/cli_logging.py +45 -0
- package/cdh/config.py +28 -0
- package/cdh/event_loop/__init__.py +7 -0
- package/cdh/event_loop/bus.py +123 -0
- package/cdh/event_loop/events.py +25 -0
- package/cdh/event_loop/runner.py +28 -0
- package/cdh/event_loop/scheduler.py +158 -0
- package/cdh/generators/__init__.py +35 -0
- package/cdh/generators/_runtime.py +176 -0
- package/cdh/generators/ada/MANIFEST.toml +14 -0
- package/cdh/generators/ada/template.ads.tmpl +7 -0
- package/cdh/generators/bash/MANIFEST.toml +14 -0
- package/cdh/generators/bash/template.sh.tmpl +16 -0
- package/cdh/generators/builtin_index.json +590 -0
- package/cdh/generators/c/MANIFEST.toml +14 -0
- package/cdh/generators/c/template.h.tmpl +20 -0
- package/cdh/generators/cli.py +379 -0
- package/cdh/generators/clojure/MANIFEST.toml +14 -0
- package/cdh/generators/clojure/template.clj.tmpl +13 -0
- package/cdh/generators/cobol/MANIFEST.toml +14 -0
- package/cdh/generators/cobol/template.cbl.tmpl +14 -0
- package/cdh/generators/cpp/MANIFEST.toml +14 -0
- package/cdh/generators/cpp/template.hpp.tmpl +32 -0
- package/cdh/generators/crystal/MANIFEST.toml +14 -0
- package/cdh/generators/crystal/template.cr.tmpl +11 -0
- package/cdh/generators/csharp/MANIFEST.toml +23 -0
- package/cdh/generators/csharp/template.cs.tmpl +14 -0
- package/cdh/generators/cython/MANIFEST.toml +14 -0
- package/cdh/generators/cython/template.pyx.tmpl +17 -0
- package/cdh/generators/d/MANIFEST.toml +14 -0
- package/cdh/generators/d/template.d.tmpl +24 -0
- package/cdh/generators/dart/MANIFEST.toml +17 -0
- package/cdh/generators/dart/template.dart.tmpl +27 -0
- package/cdh/generators/elixir/MANIFEST.toml +17 -0
- package/cdh/generators/elixir/template.ex.tmpl +27 -0
- package/cdh/generators/elm/MANIFEST.toml +14 -0
- package/cdh/generators/elm/template.elm.tmpl +20 -0
- package/cdh/generators/erlang/MANIFEST.toml +14 -0
- package/cdh/generators/erlang/template.erl.tmpl +18 -0
- package/cdh/generators/fortran/MANIFEST.toml +14 -0
- package/cdh/generators/fortran/template.f90.tmpl +24 -0
- package/cdh/generators/fsharp/MANIFEST.toml +14 -0
- package/cdh/generators/fsharp/template.fs.tmpl +17 -0
- package/cdh/generators/go/MANIFEST.toml +18 -0
- package/cdh/generators/go/imports.go.tmpl +3 -0
- package/cdh/generators/go/template.go.tmpl +9 -0
- package/cdh/generators/graphql-schema/MANIFEST.toml +18 -0
- package/cdh/generators/graphql-schema/template.graphqls.tmpl +12 -0
- package/cdh/generators/groovy/MANIFEST.toml +14 -0
- package/cdh/generators/groovy/template.groovy.tmpl +28 -0
- package/cdh/generators/hack/MANIFEST.toml +14 -0
- package/cdh/generators/hack/template.hh.tmpl +24 -0
- package/cdh/generators/haskell/MANIFEST.toml +14 -0
- package/cdh/generators/haskell/template.hs.tmpl +20 -0
- package/cdh/generators/haxe/MANIFEST.toml +14 -0
- package/cdh/generators/haxe/template.hx.tmpl +20 -0
- package/cdh/generators/idris/MANIFEST.toml +14 -0
- package/cdh/generators/idris/template.idr.tmpl +22 -0
- package/cdh/generators/java/MANIFEST.toml +18 -0
- package/cdh/generators/java/template.java.tmpl +13 -0
- package/cdh/generators/julia/MANIFEST.toml +14 -0
- package/cdh/generators/julia/template.jl.tmpl +27 -0
- package/cdh/generators/kotlin/MANIFEST.toml +18 -0
- package/cdh/generators/kotlin/template.kt.tmpl +12 -0
- package/cdh/generators/lua/MANIFEST.toml +14 -0
- package/cdh/generators/lua/template.lua.tmpl +27 -0
- package/cdh/generators/matlab/MANIFEST.toml +14 -0
- package/cdh/generators/matlab/template.m.tmpl +26 -0
- package/cdh/generators/nim/MANIFEST.toml +14 -0
- package/cdh/generators/nim/template.nim.tmpl +12 -0
- package/cdh/generators/nix/MANIFEST.toml +14 -0
- package/cdh/generators/nix/template.nix.tmpl +8 -0
- package/cdh/generators/objective-c/MANIFEST.toml +14 -0
- package/cdh/generators/objective-c/template.m.tmpl +31 -0
- package/cdh/generators/ocaml/MANIFEST.toml +14 -0
- package/cdh/generators/ocaml/template.ml.tmpl +13 -0
- package/cdh/generators/pascal/MANIFEST.toml +14 -0
- package/cdh/generators/pascal/template.pas.tmpl +5 -0
- package/cdh/generators/perl/MANIFEST.toml +14 -0
- package/cdh/generators/perl/template.pm.tmpl +27 -0
- package/cdh/generators/php/MANIFEST.toml +17 -0
- package/cdh/generators/php/template.php.tmpl +31 -0
- package/cdh/generators/powershell/MANIFEST.toml +14 -0
- package/cdh/generators/powershell/template.ps1.tmpl +20 -0
- package/cdh/generators/protobuf/MANIFEST.toml +18 -0
- package/cdh/generators/protobuf/template.proto.tmpl +13 -0
- package/cdh/generators/python/MANIFEST.toml +18 -0
- package/cdh/generators/python/template.py.tmpl +12 -0
- package/cdh/generators/r/MANIFEST.toml +14 -0
- package/cdh/generators/r/template.R.tmpl +29 -0
- package/cdh/generators/racket/MANIFEST.toml +14 -0
- package/cdh/generators/racket/template.rkt.tmpl +15 -0
- package/cdh/generators/registry.py +211 -0
- package/cdh/generators/ruby/MANIFEST.toml +17 -0
- package/cdh/generators/ruby/template.rb.tmpl +27 -0
- package/cdh/generators/rust/MANIFEST.toml +18 -0
- package/cdh/generators/rust/template.rs.tmpl +16 -0
- package/cdh/generators/scala/MANIFEST.toml +20 -0
- package/cdh/generators/scala/template.scala.tmpl +13 -0
- package/cdh/generators/scheme/MANIFEST.toml +14 -0
- package/cdh/generators/scheme/template.scm.tmpl +16 -0
- package/cdh/generators/solidity/MANIFEST.toml +14 -0
- package/cdh/generators/solidity/template.sol.tmpl +24 -0
- package/cdh/generators/swift/MANIFEST.toml +18 -0
- package/cdh/generators/swift/template.swift.tmpl +10 -0
- package/cdh/generators/typescript/MANIFEST.toml +18 -0
- package/cdh/generators/typescript/template.ts.tmpl +7 -0
- package/cdh/generators/version.py +214 -0
- package/cdh/generators/zig/MANIFEST.toml +14 -0
- package/cdh/generators/zig/template.zig.tmpl +20 -0
- package/cdh/logging.py +314 -0
- package/cdh/optimizer/__init__.py +7 -0
- package/cdh/optimizer/loop.py +201 -0
- package/cdh/optimizer/mutation.py +85 -0
- package/cdh/optimizer/reward.py +51 -0
- package/cdh/optimizer/tracker.py +118 -0
- package/cdh/project_loader.py +1022 -0
- package/cdh/rules/patterns.yaml +132 -0
- package/cdh/rules/spectral.yaml +301 -0
- package/cdh/scaffold.py +1436 -0
- package/cdh/session_store.py +221 -0
- package/cdh/state_schema.py +127 -0
- package/cdh/tools/__init__.py +57 -0
- package/cdh/tools/cls_search.py +406 -0
- package/cdh/tools/contract_diff.py +644 -0
- package/cdh/tools/deploy_stack.py +590 -0
- package/cdh/tools/generate_shared.py +300 -0
- package/cdh/tools/iac_gen.py +224 -0
- package/cdh/tools/otel_export.py +125 -0
- package/cdh/tools/pattern_detect.py +451 -0
- package/cdh/tools/retrofit_labels.py +505 -0
- package/cdh/trace/__init__.py +101 -0
- package/cdh/trace/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cdh/trace/dashboard/__init__.py +61 -0
- package/cdh/trace/dashboard/api.py +1032 -0
- package/cdh/trace/dashboard/app.html +1321 -0
- package/cdh/trace/dashboard/server.py +310 -0
- package/cdh/trace/otel_exporter.py +462 -0
- package/cdh/trace/otel_tracer.py +281 -0
- package/cdh/tui.py +1084 -0
- package/cdh/validators/__init__.py +16 -0
- package/cdh/validators/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cdh/validators/__pycache__/bdd.cpython-314.pyc +0 -0
- package/cdh/validators/__pycache__/dag.cpython-314.pyc +0 -0
- package/cdh/validators/__pycache__/ears.cpython-314.pyc +0 -0
- package/cdh/validators/__pycache__/fr_namespace.cpython-314.pyc +0 -0
- package/cdh/validators/bdd.py +144 -0
- package/cdh/validators/dag.py +179 -0
- package/cdh/validators/ears.py +452 -0
- package/cdh/validators/fr_namespace.py +143 -0
- package/cdh/validators/spectral.py +475 -0
- package/cdh/verification/__init__.py +3 -0
- package/cdh/verification/aggregation.py +53 -0
- package/cdh/verification/gates/__init__.py +5 -0
- package/cdh/verification/gates/base.py +18 -0
- package/cdh/verification/gates/lint_gate.py +37 -0
- package/cdh/verification/gates/test_gate.py +41 -0
- package/cdh/verification/loop.py +93 -0
- package/cdh/verification/policy.py +24 -0
- package/onecode/__init__.py +1 -0
- package/onecode/__main__.py +4 -0
- package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
- package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config.cpython-314.pyc +0 -0
- package/onecode/__pycache__/migrate.cpython-314.pyc +0 -0
- package/onecode/agent/__init__.py +0 -0
- package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/checkpoint.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/project_doc.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/turn_record.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__init__.py +29 -0
- package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
- package/onecode/agent/agents/prompts/build-agent.md +50 -0
- package/onecode/agent/agents/prompts/compaction-instructions.md +12 -0
- package/onecode/agent/agents/prompts/explore-agent.md +42 -0
- package/onecode/agent/agents/prompts/plan-agent.md +44 -0
- package/onecode/agent/agents/prompts/plan-gate-hard.md +4 -0
- package/onecode/agent/agents/prompts/plan-gate-soft.md +3 -0
- package/onecode/agent/agents/prompts/solo-agent.md +57 -0
- package/onecode/agent/agents/prompts/subagent-constraints.md +10 -0
- package/onecode/agent/agents/prompts/summary-instructions.md +7 -0
- package/onecode/agent/agents/prompts/title-instructions.md +3 -0
- package/onecode/agent/agents/prompts/tool-descriptions.md +129 -0
- package/onecode/agent/agents/types.py +786 -0
- package/onecode/agent/attachments.py +112 -0
- package/onecode/agent/cdh_loader.py +3 -0
- package/onecode/agent/checkpoint.py +285 -0
- package/onecode/agent/context.py +465 -0
- package/onecode/agent/engine.py +3547 -0
- package/onecode/agent/event_bridge.py +75 -0
- package/onecode/agent/hooks.py +86 -0
- package/onecode/agent/onecode_agent_acp.py +2993 -0
- package/onecode/agent/permissions.py +150 -0
- package/onecode/agent/permissions_store.py +73 -0
- package/onecode/agent/project_doc.py +19 -0
- package/onecode/agent/question_detect.py +230 -0
- package/onecode/agent/session.py +3 -0
- package/onecode/agent/snapshot.py +144 -0
- package/onecode/agent/tools/__init__.py +17 -0
- package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/agent_tools.py +128 -0
- package/onecode/agent/tools/apply_patch_tool.py +138 -0
- package/onecode/agent/tools/bash_tool.py +121 -0
- package/onecode/agent/tools/communication_tools.py +154 -0
- package/onecode/agent/tools/config_tool.py +130 -0
- package/onecode/agent/tools/cron_tools.py +178 -0
- package/onecode/agent/tools/errors.py +17 -0
- package/onecode/agent/tools/file_ops.py +221 -0
- package/onecode/agent/tools/file_tools.py +225 -0
- package/onecode/agent/tools/git_tools.py +88 -0
- package/onecode/agent/tools/lsp_tools.py +404 -0
- package/onecode/agent/tools/mcp_tools.py +211 -0
- package/onecode/agent/tools/permission_handler.py +46 -0
- package/onecode/agent/tools/permissions.py +77 -0
- package/onecode/agent/tools/protocol.py +20 -0
- package/onecode/agent/tools/registry.py +210 -0
- package/onecode/agent/tools/sandbox.py +460 -0
- package/onecode/agent/tools/schema_validation.py +100 -0
- package/onecode/agent/tools/skill_tools.py +52 -0
- package/onecode/agent/tools/todo_tools.py +208 -0
- package/onecode/agent/tools/web_tools.py +530 -0
- package/onecode/agent/turn_record.py +28 -0
- package/onecode/builtin_skills/agent-browser/SKILL.md +212 -0
- package/onecode/builtin_skills/agent-browser/skill.yaml +8 -0
- package/onecode/builtin_skills/cloudbase/SKILL.md +162 -0
- package/onecode/builtin_skills/cloudbase/skill.yaml +7 -0
- package/onecode/builtin_skills/git/SKILL.md +64 -0
- package/onecode/builtin_skills/git/skill.yaml +7 -0
- package/onecode/builtin_skills/shell/SKILL.md +58 -0
- package/onecode/builtin_skills/shell/skill.yaml +8 -0
- package/onecode/builtin_skills/skill-creator/SKILL.md +106 -0
- package/onecode/builtin_skills/skill-creator/skill.yaml +8 -0
- package/onecode/cli.py +1325 -0
- package/onecode/codebase/__init__.py +69 -0
- package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
- package/onecode/codebase/chunker.py +125 -0
- package/onecode/codebase/indexer.py +154 -0
- package/onecode/codebase/retriever.py +240 -0
- package/onecode/codebase/storage.py +153 -0
- package/onecode/codebase/tools.py +59 -0
- package/onecode/commands.py +673 -0
- package/onecode/config.py +539 -0
- package/onecode/config_screen.py +486 -0
- package/onecode/cron/__init__.py +3 -0
- package/onecode/lsp/__init__.py +3 -0
- package/onecode/mcp/__init__.py +11 -0
- package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/cloudbase.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/config.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/oauth.cpython-314.pyc +0 -0
- package/onecode/mcp/client.py +305 -0
- package/onecode/mcp/cloudbase.py +188 -0
- package/onecode/mcp/config.py +363 -0
- package/onecode/mcp/manager.py +603 -0
- package/onecode/mcp/oauth.py +199 -0
- package/onecode/memory/__init__.py +106 -0
- package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
- package/onecode/memory/backend.py +179 -0
- package/onecode/memory/pyramid.py +145 -0
- package/onecode/memory/recall.py +322 -0
- package/onecode/migrate.py +122 -0
- package/onecode/models/__init__.py +4 -0
- package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/ollama_manager.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
- package/onecode/models/errors.py +104 -0
- package/onecode/models/messages.py +750 -0
- package/onecode/models/ollama_manager.py +167 -0
- package/onecode/models/provider.py +730 -0
- package/onecode/models/providers/__init__.py +17 -0
- package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/anthropic_provider.py +286 -0
- package/onecode/models/providers/deepseek_provider.py +221 -0
- package/onecode/models/providers/glm_provider.py +219 -0
- package/onecode/models/providers/minimax_provider.py +182 -0
- package/onecode/models/providers/minimaxi_provider.py +426 -0
- package/onecode/models/providers/ollama_provider.py +183 -0
- package/onecode/models/providers/openai_provider.py +234 -0
- package/onecode/models/registry.py +349 -0
- package/onecode/server/__init__.py +3 -0
- package/onecode/server/app.py +251 -0
- package/onecode/skills/__init__.py +11 -0
- package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
- package/onecode/skills/argument_substitution.py +38 -0
- package/onecode/skills/bootstrap.py +37 -0
- package/onecode/skills/create.py +56 -0
- package/onecode/skills/frontmatter.py +33 -0
- package/onecode/skills/loader.py +215 -0
- package/onecode/skills/manager.py +75 -0
- package/onecode/skills/model.py +43 -0
- package/onecode/storage/__init__.py +0 -0
- package/onecode/storage/activity.py +63 -0
- package/onecode/storage/project.py +54 -0
- package/onecode/storage/session.py +112 -0
- package/onecode/tasks/__init__.py +26 -0
- package/onecode/tasks/manager.py +232 -0
- package/onecode/tasks/models.py +240 -0
- package/onecode/trace/__init__.py +0 -0
- package/onecode/trace/tracer.py +173 -0
- package/onecode/utils/__init__.py +0 -0
- package/onecode/utils/helpers.py +20 -0
- package/onecode/verification/__init__.py +5 -0
- package/onecode/verification/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/verification/__pycache__/aggregation.cpython-314.pyc +0 -0
- package/onecode/verification/__pycache__/loop.cpython-314.pyc +0 -0
- package/onecode/verification/__pycache__/policy.cpython-314.pyc +0 -0
- package/onecode/verification/aggregation.py +53 -0
- package/onecode/verification/gates/__init__.py +6 -0
- package/onecode/verification/gates/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/verification/gates/__pycache__/base.cpython-314.pyc +0 -0
- package/onecode/verification/gates/__pycache__/lint_gate.cpython-314.pyc +0 -0
- package/onecode/verification/gates/__pycache__/test_gate.cpython-314.pyc +0 -0
- package/onecode/verification/gates/__pycache__/type_gate.cpython-314.pyc +0 -0
- package/onecode/verification/gates/base.py +20 -0
- package/onecode/verification/gates/lint_gate.py +45 -0
- package/onecode/verification/gates/test_gate.py +62 -0
- package/onecode/verification/gates/type_gate.py +46 -0
- package/onecode/verification/loop.py +95 -0
- package/onecode/verification/policy.py +29 -0
- package/package.json +2 -2
- package/pyproject.toml +84 -0
- package/tui/__init__.py +46 -0
- package/tui/__main__.py +4 -0
- package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
- package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
- package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
- package/tui/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
- package/tui/__pycache__/agents.cpython-314.pyc +0 -0
- package/tui/__pycache__/answer.cpython-314.pyc +0 -0
- package/tui/__pycache__/app.cpython-314.pyc +0 -0
- package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
- package/tui/__pycache__/complete.cpython-314.pyc +0 -0
- package/tui/__pycache__/constants.cpython-314.pyc +0 -0
- package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
- package/tui/__pycache__/db.cpython-314.pyc +0 -0
- package/tui/__pycache__/dec.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
- package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
- package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
- package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
- package/tui/__pycache__/history.cpython-314.pyc +0 -0
- package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
- package/tui/__pycache__/menus.cpython-314.pyc +0 -0
- package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
- package/tui/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
- package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
- package/tui/__pycache__/paths.cpython-314.pyc +0 -0
- package/tui/__pycache__/pill.cpython-314.pyc +0 -0
- package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
- package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
- package/tui/__pycache__/settings.cpython-314.pyc +0 -0
- package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
- package/tui/__pycache__/shell.cpython-314.pyc +0 -0
- package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
- package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
- package/tui/__pycache__/version.cpython-314.pyc +0 -0
- package/tui/_loop.py +86 -0
- package/tui/_path_fuzzy_search.py +48 -0
- package/tui/_path_match.py +141 -0
- package/tui/about.py +95 -0
- package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/event_tap.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/acp/agent.py +1212 -0
- package/tui/acp/api.py +79 -0
- package/tui/acp/encode_tool_call_id.py +12 -0
- package/tui/acp/event_tap.py +107 -0
- package/tui/acp/messages.py +222 -0
- package/tui/acp/prompt.py +53 -0
- package/tui/acp/protocol.py +551 -0
- package/tui/agent.py +70 -0
- package/tui/agent_schema.py +76 -0
- package/tui/agents.py +46 -0
- package/tui/ansi/__init__.py +1 -0
- package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
- package/tui/ansi/_ansi.py +1689 -0
- package/tui/ansi/_ansi_colors.py +264 -0
- package/tui/ansi/_control_codes.py +37 -0
- package/tui/ansi/_keys.py +251 -0
- package/tui/ansi/_sgr_styles.py +64 -0
- package/tui/ansi/_stream_parser.py +418 -0
- package/tui/answer.py +12 -0
- package/tui/app.py +1195 -0
- package/tui/atomic.py +37 -0
- package/tui/cli.py +208 -0
- package/tui/code_analyze.py +27 -0
- package/tui/complete.py +38 -0
- package/tui/constants.py +61 -0
- package/tui/conversation_markdown.py +30 -0
- package/tui/danger.py +372 -0
- package/tui/data/agents/ampcode.com.toml +52 -0
- package/tui/data/agents/augmentcode.com.toml +41 -0
- package/tui/data/agents/cdh.cloud-dev-harness.toml +23 -0
- package/tui/data/agents/claude.com.toml +41 -0
- package/tui/data/agents/copilot.github.com.toml +29 -0
- package/tui/data/agents/crowai.dev.toml +36 -0
- package/tui/data/agents/cursor.com.toml +38 -0
- package/tui/data/agents/deepagents.langchain.com +34 -0
- package/tui/data/agents/docker.com.toml +60 -0
- package/tui/data/agents/geminicli.com.toml +28 -0
- package/tui/data/agents/goose.ai.toml +52 -0
- package/tui/data/agents/inference.huggingface.co.toml +41 -0
- package/tui/data/agents/kimi.com.toml +35 -0
- package/tui/data/agents/kiro.dev.toml +43 -0
- package/tui/data/agents/onecode.dev.toml +22 -0
- package/tui/data/agents/openai.com.toml +53 -0
- package/tui/data/agents/openclaw.ai.toml +37 -0
- package/tui/data/agents/opencode.ai.toml +39 -0
- package/tui/data/agents/openhands.dev.toml +46 -0
- package/tui/data/agents/stakpak.dev.toml +62 -0
- package/tui/data/agents/vibe.mistral.ai.toml +31 -0
- package/tui/data/agents/vtcode.dev.toml +63 -0
- package/tui/data/images/frog.png +0 -0
- package/tui/data/sounds/question.wav +0 -0
- package/tui/data/sounds/turn-over.wav +0 -0
- package/tui/db.py +213 -0
- package/tui/dec.py +332 -0
- package/tui/diff_utils.py +131 -0
- package/tui/directory.py +234 -0
- package/tui/directory_suggester.py +93 -0
- package/tui/directory_watcher.py +231 -0
- package/tui/format_path.py +25 -0
- package/tui/fuzzy.py +140 -0
- package/tui/fuzzy_index.py +275 -0
- package/tui/gist.py +33 -0
- package/tui/history.py +138 -0
- package/tui/jsonrpc.py +599 -0
- package/tui/menus.py +14 -0
- package/tui/message_log.py +69 -0
- package/tui/messages.py +144 -0
- package/tui/option_content.py +51 -0
- package/tui/os.py +0 -0
- package/tui/path_complete.py +217 -0
- package/tui/path_filter.py +124 -0
- package/tui/paths.py +87 -0
- package/tui/pill.py +23 -0
- package/tui/platform_commands.py +52 -0
- package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
- package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
- package/tui/prompt/extract.py +19 -0
- package/tui/prompt/resource.py +68 -0
- package/tui/protocol.py +28 -0
- package/tui/screens/__init__.py +0 -0
- package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
- package/tui/screens/action_modal.py +94 -0
- package/tui/screens/agent_modal.py +175 -0
- package/tui/screens/command_edit_modal.py +74 -0
- package/tui/screens/diff_explorer_screen.py +419 -0
- package/tui/screens/diff_screen.py +84 -0
- package/tui/screens/ide_screen.py +461 -0
- package/tui/screens/main.py +397 -0
- package/tui/screens/main.tcss +662 -0
- package/tui/screens/permissions.py +404 -0
- package/tui/screens/permissions.tcss +72 -0
- package/tui/screens/projects.tcss +84 -0
- package/tui/screens/projects_app.py +341 -0
- package/tui/screens/projects_screen.py +277 -0
- package/tui/screens/session_resume_modal.py +172 -0
- package/tui/screens/session_resume_modal.tcss +35 -0
- package/tui/screens/sessions.py +171 -0
- package/tui/screens/sessions.tcss +119 -0
- package/tui/screens/settings.py +293 -0
- package/tui/screens/settings.tcss +113 -0
- package/tui/screens/store.py +502 -0
- package/tui/screens/store.tcss +274 -0
- package/tui/screens/tool_content_screen.py +59 -0
- package/tui/screens/trace.py +317 -0
- package/tui/session_tracker.py +123 -0
- package/tui/settings.py +353 -0
- package/tui/settings_schema.py +467 -0
- package/tui/shell.py +304 -0
- package/tui/shell_read.py +42 -0
- package/tui/slash_command.py +34 -0
- package/tui/tui.tcss +246 -0
- package/tui/twitter.py +38 -0
- package/tui/version.py +80 -0
- package/tui/visuals/__init__.py +0 -0
- package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
- package/tui/visuals/columns.py +272 -0
- package/tui/widgets/__init__.py +0 -0
- package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/aidlc.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/pending_prompts.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
- package/tui/widgets/acp_content.py +70 -0
- package/tui/widgets/agent_response.py +76 -0
- package/tui/widgets/agent_thought.py +167 -0
- package/tui/widgets/aidlc.py +334 -0
- package/tui/widgets/ask_user.py +604 -0
- package/tui/widgets/command_pane.py +223 -0
- package/tui/widgets/condensed_path.py +115 -0
- package/tui/widgets/context_stats.py +66 -0
- package/tui/widgets/conversation.py +3235 -0
- package/tui/widgets/danger_warning.py +65 -0
- package/tui/widgets/diff_view.py +69 -0
- package/tui/widgets/directory_input.py +8 -0
- package/tui/widgets/edit_field.py +206 -0
- package/tui/widgets/flash.py +81 -0
- package/tui/widgets/future_text.py +126 -0
- package/tui/widgets/grid_select.py +234 -0
- package/tui/widgets/highlighted_textarea.py +183 -0
- package/tui/widgets/load_more.py +24 -0
- package/tui/widgets/mandelbrot.py +294 -0
- package/tui/widgets/markdown_note.py +13 -0
- package/tui/widgets/menu.py +147 -0
- package/tui/widgets/model_status.py +230 -0
- package/tui/widgets/modified_files.py +315 -0
- package/tui/widgets/non_selectable_label.py +5 -0
- package/tui/widgets/note.py +18 -0
- package/tui/widgets/path_search.py +492 -0
- package/tui/widgets/pending_prompts.py +36 -0
- package/tui/widgets/plan.py +285 -0
- package/tui/widgets/project_directory_tree.py +102 -0
- package/tui/widgets/project_grid_select.py +68 -0
- package/tui/widgets/project_summary.py +33 -0
- package/tui/widgets/prompt.py +935 -0
- package/tui/widgets/question.py +360 -0
- package/tui/widgets/session_grid_select.py +230 -0
- package/tui/widgets/session_summary.py +115 -0
- package/tui/widgets/session_tabs.py +194 -0
- package/tui/widgets/shell_result.py +35 -0
- package/tui/widgets/shell_terminal.py +40 -0
- package/tui/widgets/side_bar.py +124 -0
- package/tui/widgets/slash_complete.py +235 -0
- package/tui/widgets/strike_text.py +70 -0
- package/tui/widgets/subagent.py +287 -0
- package/tui/widgets/subagent_screen.py +715 -0
- package/tui/widgets/terminal.py +576 -0
- package/tui/widgets/terminal_tool.py +360 -0
- package/tui/widgets/throbber.py +100 -0
- package/tui/widgets/tool_call.py +432 -0
- package/tui/widgets/user_input.py +23 -0
- package/tui/widgets/version.py +5 -0
- 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
|