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