From 7b911690172f8b5d4acbade2ac8a07f5b71f35a1 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 11 Mar 2026 20:00:38 -0500 Subject: [PATCH] refactor: move source files into workspace crates - Move src/* into crates/tui/src/ to create a proper workspace structure - Add .claude/ and .trimtab/ directories for Trimtab closed-loop workflow - Add DEPENDENCY_GRAPH.md and update documentation - Update Cargo.toml files to reflect new crate dependencies - Update CI workflows and npm package scripts - All tests pass, release build works --- .claude/commands/init-trimtab.md | 35 + .github/workflows/ci.yml | 45 + .github/workflows/crates-publish.yml | 17 +- .github/workflows/release.yml | 12 + .gitignore | 2 + .trimtab/init-trimtab-protocol.md | 151 +++ AGENTS.md | 15 +- CHANGELOG.md | 65 +- CONTRIBUTING.md | 12 +- Cargo.lock | 26 +- Cargo.toml | 2 +- DEPENDENCY_GRAPH.md | 117 ++ README.md | 32 +- config.example.toml | 6 +- crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 +- crates/cli/Cargo.toml | 12 +- crates/core/Cargo.toml | 16 +- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/mcp/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/Cargo.toml | 2 +- {src => crates/tui/src}/audit.rs | 0 {src => crates/tui/src}/automation_manager.rs | 0 {src => crates/tui/src}/client.rs | 0 {src => crates/tui/src}/command_safety.rs | 0 {src => crates/tui/src}/commands/config.rs | 9 +- {src => crates/tui/src}/commands/core.rs | 4 +- {src => crates/tui/src}/commands/debug.rs | 0 {src => crates/tui/src}/commands/init.rs | 0 {src => crates/tui/src}/commands/mod.rs | 0 {src => crates/tui/src}/commands/note.rs | 0 {src => crates/tui/src}/commands/queue.rs | 0 {src => crates/tui/src}/commands/review.rs | 0 {src => crates/tui/src}/commands/session.rs | 0 {src => crates/tui/src}/commands/skills.rs | 0 {src => crates/tui/src}/commands/task.rs | 0 {src => crates/tui/src}/compaction.rs | 0 {src => crates/tui/src}/config.rs | 14 +- {src => crates/tui/src}/core/capacity.rs | 0 .../tui/src}/core/capacity_memory.rs | 0 {src => crates/tui/src}/core/engine.rs | 126 +- {src => crates/tui/src}/core/engine/tests.rs | 15 +- {src => crates/tui/src}/core/events.rs | 6 + {src => crates/tui/src}/core/mod.rs | 0 {src => crates/tui/src}/core/ops.rs | 3 + {src => crates/tui/src}/core/session.rs | 4 + {src => crates/tui/src}/core/tool_parser.rs | 0 {src => crates/tui/src}/core/turn.rs | 0 {src => crates/tui/src}/error_taxonomy.rs | 0 {src => crates/tui/src}/eval.rs | 0 {src => crates/tui/src}/execpolicy/amend.rs | 0 .../tui/src}/execpolicy/decision.rs | 0 {src => crates/tui/src}/execpolicy/error.rs | 0 .../tui/src}/execpolicy/execpolicycheck.rs | 0 {src => crates/tui/src}/execpolicy/matcher.rs | 0 {src => crates/tui/src}/execpolicy/mod.rs | 0 {src => crates/tui/src}/execpolicy/parser.rs | 0 {src => crates/tui/src}/execpolicy/policy.rs | 0 {src => crates/tui/src}/execpolicy/rule.rs | 0 {src => crates/tui/src}/execpolicy/rules.rs | 0 {src => crates/tui/src}/features.rs | 2 +- {src => crates/tui/src}/hooks.rs | 0 {src => crates/tui/src}/llm_client.rs | 0 {src => crates/tui/src}/logging.rs | 0 {src => crates/tui/src}/main.rs | 249 +++- {src => crates/tui/src}/mcp.rs | 4 +- {src => crates/tui/src}/mcp_server.rs | 0 {src => crates/tui/src}/models.rs | 0 {src => crates/tui/src}/modules/mod.rs | 0 {src => crates/tui/src}/modules/text.rs | 2 +- {src => crates/tui/src}/palette.rs | 0 {src => crates/tui/src}/pricing.rs | 1 + {src => crates/tui/src}/project_context.rs | 4 +- {src => crates/tui/src}/project_doc.rs | 0 {src => crates/tui/src}/prompts.rs | 0 {src => crates/tui/src}/prompts/agent.txt | 14 +- {src => crates/tui/src}/prompts/base.txt | 4 +- {src => crates/tui/src}/prompts/normal.txt | 14 +- {src => crates/tui/src}/prompts/plan.txt | 12 +- {src => crates/tui/src}/prompts/yolo.txt | 14 +- .../tui/src}/responses_api_proxy/mod.rs | 0 .../src}/responses_api_proxy/read_api_key.rs | 0 {src => crates/tui/src}/runtime_api.rs | 2 +- {src => crates/tui/src}/runtime_threads.rs | 809 +++++++++++- {src => crates/tui/src}/sandbox/landlock.rs | 0 {src => crates/tui/src}/sandbox/mod.rs | 2 +- {src => crates/tui/src}/sandbox/policy.rs | 2 +- {src => crates/tui/src}/sandbox/seatbelt.rs | 0 {src => crates/tui/src}/sandbox/windows.rs | 0 {src => crates/tui/src}/session_manager.rs | 0 {src => crates/tui/src}/settings.rs | 16 +- {src => crates/tui/src}/skills.rs | 0 {src => crates/tui/src}/task_manager.rs | 0 {src => crates/tui/src}/test_support.rs | 0 {src => crates/tui/src}/tools/apply_patch.rs | 0 {src => crates/tui/src}/tools/diagnostics.rs | 0 {src => crates/tui/src}/tools/file.rs | 0 {src => crates/tui/src}/tools/file_search.rs | 0 {src => crates/tui/src}/tools/git.rs | 0 {src => crates/tui/src}/tools/git_history.rs | 0 {src => crates/tui/src}/tools/mod.rs | 1 + {src => crates/tui/src}/tools/parallel.rs | 0 {src => crates/tui/src}/tools/plan.rs | 0 {src => crates/tui/src}/tools/project.rs | 0 {src => crates/tui/src}/tools/registry.rs | 0 {src => crates/tui/src}/tools/review.rs | 0 {src => crates/tui/src}/tools/search.rs | 0 {src => crates/tui/src}/tools/shell.rs | 0 {src => crates/tui/src}/tools/shell/tests.rs | 0 {src => crates/tui/src}/tools/shell_output.rs | 0 {src => crates/tui/src}/tools/spec.rs | 13 +- {src => crates/tui/src}/tools/subagent.rs | 29 +- {src => crates/tui/src}/tools/swarm.rs | 28 +- {src => crates/tui/src}/tools/test_runner.rs | 0 {src => crates/tui/src}/tools/todo.rs | 0 {src => crates/tui/src}/tools/user_input.rs | 0 .../tui/src}/tools/validate_data.rs | 0 {src => crates/tui/src}/tools/web_run.rs | 265 +++- {src => crates/tui/src}/tools/web_search.rs | 7 +- {src => crates/tui/src}/tui/app.rs | 51 +- {src => crates/tui/src}/tui/approval.rs | 247 +++- {src => crates/tui/src}/tui/clipboard.rs | 0 .../tui/src}/tui/command_palette.rs | 105 +- {src => crates/tui/src}/tui/diff_render.rs | 0 {src => crates/tui/src}/tui/event_broker.rs | 0 {src => crates/tui/src}/tui/history.rs | 117 +- .../tui/src}/tui/markdown_render.rs | 0 {src => crates/tui/src}/tui/mod.rs | 0 .../tui/src}/tui/onboarding/api_key.rs | 4 + {src => crates/tui/src}/tui/onboarding/mod.rs | 43 +- .../src}/tui/onboarding/trust_directory.rs | 4 +- .../tui/src}/tui/onboarding/welcome.rs | 12 +- {src => crates/tui/src}/tui/pager.rs | 0 {src => crates/tui/src}/tui/paste_burst.rs | 0 {src => crates/tui/src}/tui/plan_prompt.rs | 2 +- {src => crates/tui/src}/tui/scrolling.rs | 0 {src => crates/tui/src}/tui/selection.rs | 0 {src => crates/tui/src}/tui/session_picker.rs | 0 {src => crates/tui/src}/tui/streaming.rs | 68 +- {src => crates/tui/src}/tui/transcript.rs | 6 +- {src => crates/tui/src}/tui/ui.rs | 1125 ++++++++++------- {src => crates/tui/src}/tui/ui/tests.rs | 110 +- {src => crates/tui/src}/tui/ui_text.rs | 0 {src => crates/tui/src}/tui/user_input.rs | 0 {src => crates/tui/src}/tui/views/mod.rs | 37 +- {src => crates/tui/src}/tui/widgets/header.rs | 144 +-- {src => crates/tui/src}/tui/widgets/mod.rs | 99 +- .../tui/src}/tui/widgets/renderable.rs | 0 {src => crates/tui/src}/ui.rs | 0 {src => crates/tui/src}/utils.rs | 0 {src => crates/tui/src}/working_set.rs | 0 {tests => crates/tui/tests}/eval_harness.rs | 0 {tests => crates/tui/tests}/palette_audit.rs | 21 - docs/ARCHITECTURE.md | 14 +- docs/CONFIGURATION.md | 10 +- docs/MCP.md | 75 +- docs/MODES.md | 15 +- docs/OPERATIONS_RUNBOOK.md | 6 +- docs/RELEASE_RUNBOOK.md | 156 +++ docs/RUNTIME_API.md | 16 +- docs/parity_release_and_ci.md | 13 +- npm/deepseek-tui/README.md | 39 +- npm/deepseek-tui/package.json | 8 +- npm/deepseek-tui/scripts/artifacts.js | 50 +- npm/deepseek-tui/scripts/install.js | 78 +- .../scripts/verify-release-assets.js | 140 ++ .../release/prepare-local-release-assets.js | 76 ++ scripts/release/publish-crates.sh | 110 ++ scripts/release/verify-workspace-version.sh | 36 + 171 files changed, 4326 insertions(+), 981 deletions(-) create mode 100644 .claude/commands/init-trimtab.md create mode 100644 .trimtab/init-trimtab-protocol.md create mode 100644 DEPENDENCY_GRAPH.md rename {src => crates/tui/src}/audit.rs (100%) rename {src => crates/tui/src}/automation_manager.rs (100%) rename {src => crates/tui/src}/client.rs (100%) rename {src => crates/tui/src}/command_safety.rs (100%) rename {src => crates/tui/src}/commands/config.rs (98%) rename {src => crates/tui/src}/commands/core.rs (99%) rename {src => crates/tui/src}/commands/debug.rs (100%) rename {src => crates/tui/src}/commands/init.rs (100%) rename {src => crates/tui/src}/commands/mod.rs (100%) rename {src => crates/tui/src}/commands/note.rs (100%) rename {src => crates/tui/src}/commands/queue.rs (100%) rename {src => crates/tui/src}/commands/review.rs (100%) rename {src => crates/tui/src}/commands/session.rs (100%) rename {src => crates/tui/src}/commands/skills.rs (100%) rename {src => crates/tui/src}/commands/task.rs (100%) rename {src => crates/tui/src}/compaction.rs (100%) rename {src => crates/tui/src}/config.rs (99%) rename {src => crates/tui/src}/core/capacity.rs (100%) rename {src => crates/tui/src}/core/capacity_memory.rs (100%) rename {src => crates/tui/src}/core/engine.rs (97%) rename {src => crates/tui/src}/core/engine/tests.rs (97%) rename {src => crates/tui/src}/core/events.rs (95%) rename {src => crates/tui/src}/core/mod.rs (100%) rename {src => crates/tui/src}/core/ops.rs (96%) rename {src => crates/tui/src}/core/session.rs (95%) rename {src => crates/tui/src}/core/tool_parser.rs (100%) rename {src => crates/tui/src}/core/turn.rs (100%) rename {src => crates/tui/src}/error_taxonomy.rs (100%) rename {src => crates/tui/src}/eval.rs (100%) rename {src => crates/tui/src}/execpolicy/amend.rs (100%) rename {src => crates/tui/src}/execpolicy/decision.rs (100%) rename {src => crates/tui/src}/execpolicy/error.rs (100%) rename {src => crates/tui/src}/execpolicy/execpolicycheck.rs (100%) rename {src => crates/tui/src}/execpolicy/matcher.rs (100%) rename {src => crates/tui/src}/execpolicy/mod.rs (100%) rename {src => crates/tui/src}/execpolicy/parser.rs (100%) rename {src => crates/tui/src}/execpolicy/policy.rs (100%) rename {src => crates/tui/src}/execpolicy/rule.rs (100%) rename {src => crates/tui/src}/execpolicy/rules.rs (100%) rename {src => crates/tui/src}/features.rs (98%) rename {src => crates/tui/src}/hooks.rs (100%) rename {src => crates/tui/src}/llm_client.rs (100%) rename {src => crates/tui/src}/logging.rs (100%) rename {src => crates/tui/src}/main.rs (88%) rename {src => crates/tui/src}/mcp.rs (99%) rename {src => crates/tui/src}/mcp_server.rs (100%) rename {src => crates/tui/src}/models.rs (100%) rename {src => crates/tui/src}/modules/mod.rs (100%) rename {src => crates/tui/src}/modules/text.rs (99%) rename {src => crates/tui/src}/palette.rs (100%) rename {src => crates/tui/src}/pricing.rs (99%) rename {src => crates/tui/src}/project_context.rs (99%) rename {src => crates/tui/src}/project_doc.rs (100%) rename {src => crates/tui/src}/prompts.rs (100%) rename {src => crates/tui/src}/prompts/agent.txt (93%) rename {src => crates/tui/src}/prompts/base.txt (93%) rename {src => crates/tui/src}/prompts/normal.txt (86%) rename {src => crates/tui/src}/prompts/plan.txt (88%) rename {src => crates/tui/src}/prompts/yolo.txt (93%) rename {src => crates/tui/src}/responses_api_proxy/mod.rs (100%) rename {src => crates/tui/src}/responses_api_proxy/read_api_key.rs (100%) rename {src => crates/tui/src}/runtime_api.rs (99%) rename {src => crates/tui/src}/runtime_threads.rs (79%) rename {src => crates/tui/src}/sandbox/landlock.rs (100%) rename {src => crates/tui/src}/sandbox/mod.rs (99%) rename {src => crates/tui/src}/sandbox/policy.rs (99%) rename {src => crates/tui/src}/sandbox/seatbelt.rs (100%) rename {src => crates/tui/src}/sandbox/windows.rs (100%) rename {src => crates/tui/src}/session_manager.rs (100%) rename {src => crates/tui/src}/settings.rs (95%) rename {src => crates/tui/src}/skills.rs (100%) rename {src => crates/tui/src}/task_manager.rs (100%) rename {src => crates/tui/src}/test_support.rs (100%) rename {src => crates/tui/src}/tools/apply_patch.rs (100%) rename {src => crates/tui/src}/tools/diagnostics.rs (100%) rename {src => crates/tui/src}/tools/file.rs (100%) rename {src => crates/tui/src}/tools/file_search.rs (100%) rename {src => crates/tui/src}/tools/git.rs (100%) rename {src => crates/tui/src}/tools/git_history.rs (100%) rename {src => crates/tui/src}/tools/mod.rs (99%) rename {src => crates/tui/src}/tools/parallel.rs (100%) rename {src => crates/tui/src}/tools/plan.rs (100%) rename {src => crates/tui/src}/tools/project.rs (100%) rename {src => crates/tui/src}/tools/registry.rs (100%) rename {src => crates/tui/src}/tools/review.rs (100%) rename {src => crates/tui/src}/tools/search.rs (100%) rename {src => crates/tui/src}/tools/shell.rs (100%) rename {src => crates/tui/src}/tools/shell/tests.rs (100%) rename {src => crates/tui/src}/tools/shell_output.rs (100%) rename {src => crates/tui/src}/tools/spec.rs (97%) rename {src => crates/tui/src}/tools/subagent.rs (99%) rename {src => crates/tui/src}/tools/swarm.rs (97%) rename {src => crates/tui/src}/tools/test_runner.rs (100%) rename {src => crates/tui/src}/tools/todo.rs (100%) rename {src => crates/tui/src}/tools/user_input.rs (100%) rename {src => crates/tui/src}/tools/validate_data.rs (100%) rename {src => crates/tui/src}/tools/web_run.rs (83%) rename {src => crates/tui/src}/tools/web_search.rs (96%) rename {src => crates/tui/src}/tui/app.rs (96%) rename {src => crates/tui/src}/tui/approval.rs (78%) rename {src => crates/tui/src}/tui/clipboard.rs (100%) rename {src => crates/tui/src}/tui/command_palette.rs (86%) rename {src => crates/tui/src}/tui/diff_render.rs (100%) rename {src => crates/tui/src}/tui/event_broker.rs (100%) rename {src => crates/tui/src}/tui/history.rs (93%) rename {src => crates/tui/src}/tui/markdown_render.rs (100%) rename {src => crates/tui/src}/tui/mod.rs (100%) rename {src => crates/tui/src}/tui/onboarding/api_key.rs (92%) rename {src => crates/tui/src}/tui/onboarding/mod.rs (77%) rename {src => crates/tui/src}/tui/onboarding/trust_directory.rs (90%) rename {src => crates/tui/src}/tui/onboarding/welcome.rs (87%) rename {src => crates/tui/src}/tui/pager.rs (100%) rename {src => crates/tui/src}/tui/paste_burst.rs (100%) rename {src => crates/tui/src}/tui/plan_prompt.rs (98%) rename {src => crates/tui/src}/tui/scrolling.rs (100%) rename {src => crates/tui/src}/tui/selection.rs (100%) rename {src => crates/tui/src}/tui/session_picker.rs (100%) rename {src => crates/tui/src}/tui/streaming.rs (86%) rename {src => crates/tui/src}/tui/transcript.rs (92%) rename {src => crates/tui/src}/tui/ui.rs (86%) rename {src => crates/tui/src}/tui/ui/tests.rs (83%) rename {src => crates/tui/src}/tui/ui_text.rs (100%) rename {src => crates/tui/src}/tui/user_input.rs (100%) rename {src => crates/tui/src}/tui/views/mod.rs (97%) rename {src => crates/tui/src}/tui/widgets/header.rs (54%) rename {src => crates/tui/src}/tui/widgets/mod.rs (89%) rename {src => crates/tui/src}/tui/widgets/renderable.rs (100%) rename {src => crates/tui/src}/ui.rs (100%) rename {src => crates/tui/src}/utils.rs (100%) rename {src => crates/tui/src}/working_set.rs (100%) rename {tests => crates/tui/tests}/eval_harness.rs (100%) rename {tests => crates/tui/tests}/palette_audit.rs (86%) create mode 100644 docs/RELEASE_RUNBOOK.md create mode 100644 npm/deepseek-tui/scripts/verify-release-assets.js create mode 100755 scripts/release/prepare-local-release-assets.js create mode 100755 scripts/release/publish-crates.sh create mode 100755 scripts/release/verify-workspace-version.sh diff --git a/.claude/commands/init-trimtab.md b/.claude/commands/init-trimtab.md new file mode 100644 index 00000000..feca2d5c --- /dev/null +++ b/.claude/commands/init-trimtab.md @@ -0,0 +1,35 @@ +# /init-trimtab + +Bootstrap or retune the Trimtab closed-loop workflow for this repository. + +**Canonical protocol:** `.trimtab/init-trimtab-protocol.md` + +## What this command does + +1. Reads the canonical protocol from `.trimtab/init-trimtab-protocol.md` +2. Inspects the repo's current state (docs, CI, task surfaces, dependency graph) +3. Decides whether to bootstrap, upgrade, or retune +4. Aligns all workflow surfaces to the protocol + +## Invocation + +Run `/init-trimtab` in Claude Code to initialize or retune the workflow. + +## Surfaces managed + +| File | Purpose | +|------|---------| +| `.trimtab/init-trimtab-protocol.md` | Canonical shared protocol | +| `.claude/commands/init-trimtab.md` | This file (Claude Code entrypoint) | +| `.codex/skills/init-trimtab/SKILL.md` | Codex skill registration | +| `DEPENDENCY_GRAPH.md` | Crate + task dependency graph | +| `AI_HANDOFF.md` | Operative task queue and context for next agent | +| `CLAUDE.md` | Build/dev instructions (read-only — do not overwrite) | +| `AGENTS.md` | Project instructions for AI assistants | + +## Rules + +- Do not overwrite CLAUDE.md — it is the source of truth for build commands +- Do not flatten existing strong instructions into boilerplate +- The no-self-verdict rule is non-negotiable +- Keep Claude and Codex entrypoints thin; logic lives in the shared protocol diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e32191db..9fff4c6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,51 @@ jobs: - name: Build run: cargo build --release + npm-wrapper-smoke: + name: npm wrapper smoke + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-13] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: Swatinem/rust-cache@v2 + - name: Build wrapper binaries + run: cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui + - name: Prepare local release assets + run: node scripts/release/prepare-local-release-assets.js "$RUNNER_TEMP/release-assets" + - name: Start local release server + run: | + python3 -m http.server 8123 --directory "$RUNNER_TEMP/release-assets" >/tmp/deepseek-release-server.log 2>&1 & + sleep 1 + - name: Pack npm wrapper + working-directory: npm/deepseek-tui + env: + DEEPSEEK_TUI_FORCE_DOWNLOAD: "1" + DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/ + run: npm pack + - name: Install packed wrapper + env: + DEEPSEEK_TUI_FORCE_DOWNLOAD: "1" + DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/ + run: | + mkdir -p "$RUNNER_TEMP/npm-smoke" + cd "$RUNNER_TEMP/npm-smoke" + npm init -y + npm install "$GITHUB_WORKSPACE/npm/deepseek-tui"/deepseek-tui-*.tgz + - name: Smoke wrapper entrypoints + env: + DEEPSEEK_TUI_FORCE_DOWNLOAD: "1" + DEEPSEEK_TUI_RELEASE_BASE_URL: http://127.0.0.1:8123/ + run: | + cd "$RUNNER_TEMP/npm-smoke" + npx --no-install deepseek --help + npx --no-install deepseek-tui --help + # Check documentation builds without warnings docs: name: Documentation diff --git a/.github/workflows/crates-publish.yml b/.github/workflows/crates-publish.yml index bf621ac2..e469c585 100644 --- a/.github/workflows/crates-publish.yml +++ b/.github/workflows/crates-publish.yml @@ -7,7 +7,7 @@ on: jobs: publish: - name: Publish to crates.io + name: Publish workspace crates to crates.io runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -16,15 +16,12 @@ jobs: - name: Verify version matches tag if: github.event_name == 'release' - run: | - TAG_VERSION=${GITHUB_REF#refs/tags/v} - CARGO_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[0].version') - if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then - echo "Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)" - exit 1 - fi + run: ./scripts/release/verify-workspace-version.sh "${GITHUB_REF#refs/tags/v}" - - name: Publish to crates.io - run: cargo publish + - name: Preflight workspace crate publishes in workspace order + run: ./scripts/release/publish-crates.sh dry-run + + - name: Publish crates.io packages in workspace order + run: ./scripts/release/publish-crates.sh publish env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a290f33..3a589933 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,18 @@ jobs: path: artifacts - name: List artifacts run: find artifacts -type f + - name: Generate checksum manifest + shell: bash + run: | + mkdir -p artifacts/checksums + manifest="artifacts/checksums/deepseek-artifacts-sha256.txt" + : > "${manifest}" + while IFS= read -r -d '' file; do + hash="$(sha256sum "${file}" | awk '{print $1}')" + base="$(basename "${file}")" + printf '%s %s\n' "${hash}" "${base}" >> "${manifest}" + done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z) + cat "${manifest}" - uses: softprops/action-gh-release@v1 with: files: artifacts/*/* diff --git a/.gitignore b/.gitignore index 88cb49a2..603dd1f0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ tmp/ # Local dev scripts and temp files *.sh +!scripts/** test.txt TODO*.md todo*.md @@ -64,6 +65,7 @@ docs/rlm-paper.txt # Local runtime state .deepseek/ session_*.json +*.db # Companion app (tracked separately) apps/ diff --git a/.trimtab/init-trimtab-protocol.md b/.trimtab/init-trimtab-protocol.md new file mode 100644 index 00000000..48657342 --- /dev/null +++ b/.trimtab/init-trimtab-protocol.md @@ -0,0 +1,151 @@ +# Trimtab Protocol: deepseek-tui + +> Canonical workflow protocol for closed-loop, self-verifying agentic development. +> Claude Code and Codex entrypoints both delegate here. + +## Topology + +``` + Claude Code (Opus) — Orchestrator + / | \ + Sub-Opus agents Codex MCP Direct work + (UI, research, (infra, backend, (small edits, + review, plan) Rust systems) conversation) + | + Fresh Codex context + (Closure verifier / Coach) +``` + +- **Orchestrator / Player:** Claude Code (Opus) +- **Workers:** Claude sub-agents (Agent tool) + Codex MCP sessions +- **Closure Verifier / Coach:** Fresh Codex context (never the same context that wrote the code) + +## No-Self-Verdict Rule + +The agent that wrote or modified code MUST NOT be the one to declare it passes. +Every batch — including review-only or zero-edit batches — goes to an independent +verifier (fresh Codex context or separate sub-agent) before anyone says PASS. + +## Task Surface + +This repo uses **Linear** as the canonical issue tracker. + +- **Project:** https://linear.app/shannon-labs/project/deepseek-tui-6213bbbeaa26 +- **Team:** Shannon Labs (SHA) + +Operative task sources (priority order): +1. **Linear issues** — canonical for all tracked work (SHA-2794 through SHA-2803) +2. **DEPENDENCY_GRAPH.md** — local mirror of the task graph with ready queue +3. **AI_HANDOFF.md** — implementation notes and architecture context +4. **todo.md** — high-level goals + +When starting a session: +1. List Linear issues for the project (filter by state: not Done/Canceled) +2. Identify the highest-priority unblocked issue +3. Read the issue body directly — it is the task packet +4. Execute using the waterfall rule + +The issue body contains: Goal, Files, Pass/Fail Criteria, Boundary, Dependencies. +Do not invent a second prompt when the issue already has the packet. + +## Waterfall Rule + +After a verified issue closes, the player continues to the next unblocked issue +unless the operator explicitly reprioritizes. Do not stop and ask "what next?" +when the answer is visible in the task graph. + +## Work Packet Structure + +Each task should be expressed as a packet with: + +``` +GOAL: One sentence describing the desired end state +FILES: List of files expected to change +VERIFY: Concrete acceptance criteria (tests pass, clippy clean, visual check) +BOUNDARY: What is out of scope for this packet +``` + +## Build / Test / Lint Commands + +```bash +cargo build # Debug build +cargo build --release # Release build +cargo test --workspace --all-features # Full test suite +cargo fmt --all -- --check # Format check +cargo clippy --workspace --all-targets --all-features # Full lint +cargo doc --workspace --no-deps # Build docs +``` + +CI runs: fmt check, clippy, tests (Ubuntu/macOS/Windows), build. + +## Delegation Guidelines + +### Use Codex MCP for: +- Rust systems work, infrastructure, CI/CD, backend, shell scripting +- Second opinions on architecture or tradeoffs +- Debugging infra/backend issues +- Config: `approval-policy: "never"`, `sandbox: "workspace-write"`, `cwd: "/Volumes/VIXinSSD/deepseek-tui"` +- For advice-only: `sandbox: "read-only"` + +### Use Claude sub-agents (Agent tool) for: +- TUI/UI work, research, codebase exploration +- Code review and quality analysis +- Planning and architecture docs + +### Do directly: +- Small file edits, quick answers, reading/searching known files + +### Shared workspace warning: +When dispatching into a directory where other agents may be working, include: +"Note: You are in a shared workspace. Other agents may be reading or editing files +concurrently. Focus only on your assigned task." + +## Session Protocol + +### Session Start +1. Read CLAUDE.md, AI_HANDOFF.md, DEPENDENCY_GRAPH.md +2. Check `git status` and recent commits +3. Identify the current task from the task surface +4. Announce what you're working on + +### Session Close +1. `git status` — review all changes +2. `git add` specific files (never `git add -A` blindly) +3. `git commit` with conventional commit message +4. Verify: `cargo test --workspace --all-features` +5. Verify: `cargo clippy --workspace --all-targets --all-features` +6. Update AI_HANDOFF.md if task state changed +7. Push only if operator approves + +### Commit Convention +Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:` + +## Crate Architecture Reference + +``` +crates/ + cli/ deepseek-cli -> `deepseek` binary + tui/ deepseek-tui -> `deepseek-tui` binary + app-server/ deepseek-app-server HTTP/SSE + JSON-RPC server + core/ deepseek-core Agent loop, session, turns + protocol/ deepseek-protocol Request/response types + config/ deepseek-config Config loading, profiles + state/ deepseek-state SQLite persistence + tools/ deepseek-tools Tool registry + specs + mcp/ deepseek-mcp MCP server integration + hooks/ deepseek-hooks Lifecycle hooks + execpolicy/ deepseek-execpolicy Approval policy engine + agent/ deepseek-agent Model/provider registry + tui-core/ deepseek-tui-core TUI state machine scaffold +``` + +See DEPENDENCY_GRAPH.md for the full dependency graph. + +## Key Architectural Decisions + +- Edition 2024, Rust 1.85+ +- Workspace version 0.3.30 (all crates share version) +- TUI binary still references monolith source (src/) — migration incremental +- DeepSeek API: Responses API preferred, chat completions fallback +- Sandbox: macOS Seatbelt, Linux Landlock +- Modes: Normal, Plan, Agent, YOLO, RLM, Duo (each gates different tools) diff --git a/AGENTS.md b/AGENTS.md index 89cc99b0..5a969c05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,11 +44,24 @@ For complex, multi-step tasks, you should delegate work: - **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`. - **Testing**: Rigorously verify changes using `cargo test` and `cargo check`. +## Trimtab Workflow + +This repo uses the Trimtab closed-loop protocol for self-verifying agentic development. + +- **Protocol:** `.trimtab/init-trimtab-protocol.md` (canonical — read this first) +- **Task graph:** `DEPENDENCY_GRAPH.md` (crate deps + task deps with ready queue) +- **Task queue:** `AI_HANDOFF.md` (7 open issues with priorities) +- **Goals:** `todo.md` (high-level objectives) +- **Claude entrypoint:** `.claude/commands/init-trimtab.md` +- **Codex skill:** `.codex/skills/init-trimtab/SKILL.md` + +**No-self-verdict rule:** The agent that wrote code must not be the one to declare it passes. Always use an independent verifier (fresh Codex context or separate sub-agent). + ## Important Notes -- **Finance tool currently unavailable**: The finance tool relies on Stooq which frequently returns no data. As a workaround, use `web.run` to fetch financial data from web sources. + - **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate. - **Web.run tool name**: Note that the tool is named `web.run` (single dot), not `web..run`. Some earlier versions of the CLI may have had this typo. diff --git a/CHANGELOG.md b/CHANGELOG.md index 302972f4..9548ad2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.31] - 2026-03-08 + +### Added +- Replaced the finance tool backend with Yahoo Finance v8 + CoinGecko fallback for reliable real-time market data (stocks, ETFs, indices, forex, crypto). +- Added compaction UX: status strip shows animated COMPACTING indicator during context summarization, footer reflects compaction state, and CompactionCompleted events now include message count statistics. +- Added send flash: brief tinted background highlight on the last user message after sending. +- Added braille typing indicator with smooth 10-frame animation cycle. + +### Changed +- Redesigned the footer status strip with mode/model/token/cost layout, quadrant separators, and a context-usage bar. +- Added Unicode prefix indicators (▸ You, ◆ Answer, ● System) to chat history cells for visual distinction. +- Improved thinking token delineation with labeled delimiters in transcript rendering. + +### Fixed +- Fixed Plan mode ESC key dismissing the prompt without clearing `plan_prompt_pending`, which prevented the prompt from reappearing on subsequent plan completions. + +## [0.3.30] - 2026-03-06 + +### Added +- Added a release-ready local npm smoke path that builds binaries, serves release assets locally, packs the wrapper, installs the tarball, and checks both entrypoints before publish. +- Added an opt-in full-matrix local release-asset fixture so `npm run release:check` can be exercised before GitHub release assets exist. + +### Changed +- Bumped the Rust workspace crates and npm wrapper to `0.3.30`. +- Pointed the npm wrapper's default `deepseekBinaryVersion` at `0.3.30` for the next coordinated Rust + npm release. +- Updated the crates dry-run helper to work from a dirty workspace and to preflight dependent workspace crates without requiring unpublished versions to already exist on crates.io. + +## [0.3.29] - 2026-03-03 + +### Added +- Added npm publish-time release asset verification for the `deepseek-tui` package to fail fast when expected GitHub binaries are missing. +- Added checksum manifests to GitHub release assets and checksum verification in the npm installer. +- Added `npm pack` install-and-smoke CI coverage for the `deepseek-tui` wrapper package. +- Added an end-to-end release runbook covering crates.io, GitHub Releases, and npm publication. + +### Changed +- Updated npm package documentation for clearer install modes, environment overrides, and release integrity behavior. +- Improved installer support-matrix error messaging for unsupported platform/architecture combinations. +- Decoupled npm package version from default binary artifact version via `deepseekBinaryVersion`, enabling packaging-only npm releases. +- Moved the `deepseek-tui` binary target inside `crates/tui` so `cargo publish --dry-run -p deepseek-tui` works from the workspace package layout. +- Replaced the root-level crates publish workflow with an ordered workspace publish flow. +- Reworked first-run onboarding and README copy around primary workflows instead of shortcut memorization. +- Relaxed onboarding API-key format heuristics so unusual keys warn instead of blocking setup. + ## [0.3.28] - 2026-03-02 ### Added @@ -352,7 +396,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - 2026-01-12 ### Added -- Initial alpha release of DeepSeek CLI +- Initial alpha release of DeepSeek TUI - Interactive TUI chat interface - DeepSeek API integration (OpenAI-compatible Responses API) - Tool execution (shell, file ops) @@ -363,7 +407,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...HEAD +[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.30...v0.3.31 +[0.3.30]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.29...v0.3.30 +[0.3.29]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.29 [0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28 [0.3.23]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...v0.3.23 [0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22 @@ -389,10 +436,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2 [0.2.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0 [0.0.2]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.2 -[0.0.1]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.0.1 -[0.1.9]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.8...v0.1.9 -[0.1.8]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.7...v0.1.8 -[0.1.7]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.6...v0.1.7 -[0.1.6]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.5...v0.1.6 -[0.1.5]: https://github.com/Hmbown/DeepSeek-CLI/compare/v0.1.0...v0.1.5 -[0.1.0]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.1.0 +[0.0.1]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.1 +[0.1.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.8...v0.1.9 +[0.1.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.7...v0.1.8 +[0.1.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.6...v0.1.7 +[0.1.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.5...v0.1.6 +[0.1.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.1.0...v0.1.5 +[0.1.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd1b979b..10be78c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to DeepSeek CLI +# Contributing to DeepSeek TUI -Thank you for your interest in contributing to DeepSeek CLI! This document provides guidelines and instructions for contributing. +Thank you for your interest in contributing to DeepSeek TUI! This document provides guidelines and instructions for contributing. ## Getting Started @@ -14,8 +14,8 @@ Thank you for your interest in contributing to DeepSeek CLI! This document provi 1. Fork and clone the repository: ```bash - git clone https://github.com/YOUR_USERNAME/DeepSeek-CLI.git - cd DeepSeek-CLI + git clone https://github.com/YOUR_USERNAME/DeepSeek-TUI.git + cd DeepSeek-TUI ``` 2. Build the project: @@ -121,7 +121,7 @@ When reporting issues, please include: - Operating system and version - Rust version (`rustc --version`) -- DeepSeek CLI version (`deepseek --version`) +- DeepSeek TUI version (`deepseek --version`) - Steps to reproduce the issue - Expected vs actual behavior - Relevant error messages or logs @@ -132,7 +132,7 @@ Be respectful and inclusive. We welcome contributors of all backgrounds and expe ## License -By contributing to DeepSeek CLI, you agree that your contributions will be licensed under the MIT License. +By contributing to DeepSeek TUI, you agree that your contributions will be licensed under the MIT License. ## Questions? diff --git a/Cargo.lock b/Cargo.lock index 065e95e4..d28a93a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,7 +806,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.3.28" +version = "0.3.31" dependencies = [ "deepseek-config", "serde", @@ -814,7 +814,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "axum", @@ -837,7 +837,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "dirs", @@ -848,7 +848,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "chrono", @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "deepseek-protocol", @@ -876,7 +876,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "async-trait", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "deepseek-protocol", @@ -900,7 +900,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.3.28" +version = "0.3.31" dependencies = [ "serde", "serde_json", @@ -908,7 +908,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "chrono", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "async-trait", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "arboard", @@ -987,7 +987,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.3.28" +version = "0.3.31" dependencies = [ "anyhow", "chrono", @@ -1005,7 +1005,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.3.28" +version = "0.3.31" [[package]] name = "deranged" diff --git a/Cargo.toml b/Cargo.toml index 7e86242b..889aa381 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.3.28" +version = "0.3.31" edition = "2024" license = "MIT" repository = "https://github.com/Hmbown/DeepSeek-TUI" diff --git a/DEPENDENCY_GRAPH.md b/DEPENDENCY_GRAPH.md new file mode 100644 index 00000000..12718bbd --- /dev/null +++ b/DEPENDENCY_GRAPH.md @@ -0,0 +1,117 @@ +# Dependency Graph + +## Crate Dependencies (from Cargo.toml) + +``` +deepseek-tui (binary: `deepseek-tui`) + (no workspace deps — uses monolith src/ directly) + +deepseek-tui-cli (binary: `deepseek`) + <- deepseek-agent + <- deepseek-app-server + <- deepseek-config + <- deepseek-execpolicy + <- deepseek-mcp + <- deepseek-state + +deepseek-app-server + <- deepseek-agent + <- deepseek-config + <- deepseek-core + <- deepseek-execpolicy + <- deepseek-hooks + <- deepseek-mcp + <- deepseek-protocol + <- deepseek-state + <- deepseek-tools + +deepseek-core (agent loop) + <- deepseek-agent + <- deepseek-config + <- deepseek-execpolicy + <- deepseek-hooks + <- deepseek-mcp + <- deepseek-protocol + <- deepseek-state + <- deepseek-tools + +deepseek-tools <- deepseek-protocol +deepseek-mcp <- deepseek-protocol +deepseek-hooks <- deepseek-protocol +deepseek-execpolicy <- deepseek-protocol +deepseek-agent <- deepseek-config + +deepseek-config (leaf — no internal deps) +deepseek-protocol (leaf — no internal deps) +deepseek-state (leaf — no internal deps) +deepseek-tui-core (leaf — no internal deps) +``` + +Note: `deepseek-tui` has zero workspace deps because it still compiles the +monolith source tree (`src/main.rs`). The crate split is structural — actual +source migration into individual crates is incremental. + +## Build Order (bottom-up) + +``` +Layer 0 (leaves): deepseek-protocol, deepseek-config, deepseek-state, deepseek-tui-core +Layer 1: deepseek-tools, deepseek-mcp, deepseek-hooks, deepseek-execpolicy +Layer 2: deepseek-agent +Layer 3: deepseek-core +Layer 4: deepseek-app-server, deepseek-tui +Layer 5: deepseek-tui-cli +``` + +## Task Dependencies (Linear: shannon-labs/deepseek-tui) + +Canonical source: https://linear.app/shannon-labs/project/deepseek-tui-6213bbbeaa26 + +``` +[High] SHA-2794 UI Footer Redesign (Kimi CLI Style) + -> no blockers ← READY + -> files: crates/tui/src/tui/ui.rs, crates/tui/src/palette.rs + +[High] SHA-2795 Thinking vs Normal Chat Delineation + -> no blockers ← READY + -> files: crates/tui/src/tui/ui.rs, history.rs, streaming.rs + +[High] SHA-2798 Finance Tool Replacement + -> no blockers ← READY + -> files: crates/tui/src/tools/ + +[Med] SHA-2796 Intelligent Compaction UX + -> no blockers ← READY + -> files: crates/tui/src/compaction.rs, core/engine.rs + +[Med] SHA-2797 Escape Key After Plan Mode + -> no blockers (investigation) ← READY + -> files: crates/tui/src/tui/ui.rs, app.rs + +[Med] SHA-2799 "Alive and Animated" Feel + -> blocked by SHA-2794, SHA-2795 + -> files: crates/tui/src/tui/ (various) + +[Med] SHA-2801 Docs and Workflow Update + -> blocked by SHA-2798 + -> files: AGENTS.md, README.md, CHANGELOG.md + +[Med] SHA-2802 Release Prep + -> blocked by SHA-2794, SHA-2795, SHA-2798 + -> files: Cargo.toml, CHANGELOG.md, npm/ + +[Low] SHA-2800 Header Redesign + -> blocked by SHA-2794 + -> files: crates/tui/src/tui/widgets/header.rs + +[Low] SHA-2803 Context Window Visualization + -> blocked by SHA-2800 + -> files: crates/tui/src/tui/ui.rs +``` + +## Ready Queue (unblocked, by priority) + +1. **SHA-2794** UI Footer Redesign (High) +2. **SHA-2795** Thinking vs Chat Delineation (High) +3. **SHA-2798** Finance Tool Replacement (High) +4. **SHA-2796** Intelligent Compaction UX (Medium) +5. **SHA-2797** Escape Key After Plan Mode (Medium) diff --git a/README.md b/README.md index 83aeb16b..9bdec6aa 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ deepseek-tui ```bash # From crates.io (requires Rust 1.85+) -cargo install deepseek-tui --locked +cargo install deepseek-tui --locked # TUI +cargo install deepseek-tui-cli --locked # deepseek CLI facade # From source git clone https://github.com/Hmbown/DeepSeek-TUI.git @@ -43,20 +44,41 @@ cd DeepSeek-TUI cargo install --path crates/tui --locked ``` +The canonical crates.io packages for this repository are `deepseek-tui` and +`deepseek-tui-cli`. The unrelated `deepseek-cli` crate is not part of this +project. + ## What it does -An agent loop with file editing, shell execution, web search, git operations, task tracking, and [MCP](https://modelcontextprotocol.io) server integration. Context-aware memory compaction keeps long sessions on track. +An agent loop with file editing, shell execution, `web.run` browsing, git operations, task tracking, and [MCP](https://modelcontextprotocol.io) server integration. Context-aware memory compaction keeps long sessions on track. `crates/tui` remains the live shipped runtime while the workspace extraction continues. -Three modes (**Tab** to switch): +Four modes (**Tab** / **Shift+Tab** to cycle): | Mode | Behavior | |------|----------| +| **Normal** | Chat-first mode for questions, explanation, and low-friction steering | | **Plan** | Design-first — proposes before acting | | **Agent** | Multi-step autonomous tool use | | **YOLO** | Full auto-approve, no guardrails | +## First Run Workflow + +1. Paste your API key in onboarding. +2. Choose a mode for the task in front of you: + `Normal` to ask questions, `Plan` to review a plan first, `Agent` to let the model use tools, `YOLO` only inside a trusted workspace. +3. Watch the status area while work is running: + approvals, queued work, and active sub-agents stay there while the turn is live. +4. Recover work with `Ctrl+R` or `/sessions` if you need to resume an interrupted thread. + +## Everyday Workflows + +- Use `Ctrl+K` for the command palette when you want to switch modes, open config, resume sessions, or inspect a tool quickly. +- Use `/queue` to review or edit queued prompts before sending them. +- Use `/subagents` to inspect background agent state when autonomous work fans out. +- Use `/config` to adjust approval mode, theme, sidebar focus, and other runtime preferences. + ## Usage ```bash @@ -68,7 +90,7 @@ deepseek models # list available models deepseek serve --http # HTTP/SSE API server ``` -**F1** opens help. **Esc** cancels a running request. **Ctrl+K** opens command palette. +Controls: `F1` help, `Esc` walks the cancel stack, `Ctrl+K` command palette. ## Configuration @@ -80,7 +102,7 @@ Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md). ## Docs -[docs/](docs/) — architecture, modes, MCP integration, runtime API. +[docs/](docs/) — architecture, modes, MCP integration, runtime API, and release runbooks. The live runtime still ships from `crates/tui`; the newer workspace crates are incremental extraction targets. ## Contributing diff --git a/config.example.toml b/config.example.toml index d93b66b0..befdab9e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,5 @@ # ╔══════════════════════════════════════════════════════════════════════════════╗ -# ║ DeepSeek CLI Configuration ║ +# ║ DeepSeek TUI Configuration ║ # ║ ║ # ║ Unofficial CLI for DeepSeek Platform - Not affiliated with DeepSeek Inc. ║ # ╚══════════════════════════════════════════════════════════════════════════════╝ @@ -58,7 +58,7 @@ alternate_screen = "auto" # auto | always | never [features] shell_tool = true subagents = true -web_search = true # enables web.run and web_search +web_search = true # enables canonical web.run plus the compatibility web_search alias apply_patch = true mcp = true exec_policy = true @@ -125,7 +125,7 @@ allow_shell = true # # [[hooks.hooks]] # event = "session_start" -# command = "echo 'DeepSeek CLI session started'" +# command = "echo 'DeepSeek TUI session started'" # ───────────────────────────────────────────────────────────────────────────────── # Requirements (admin constraints) example file diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 0b5873ac..4853f50c 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.3.28" } +deepseek-config = { path = "../config", version = "0.3.31" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index e4197b8b..5de5f0d5 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.3.28" } -deepseek-config = { path = "../config", version = "0.3.28" } -deepseek-core = { path = "../core", version = "0.3.28" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.3.28" } -deepseek-hooks = { path = "../hooks", version = "0.3.28" } -deepseek-mcp = { path = "../mcp", version = "0.3.28" } -deepseek-protocol = { path = "../protocol", version = "0.3.28" } -deepseek-state = { path = "../state", version = "0.3.28" } -deepseek-tools = { path = "../tools", version = "0.3.28" } +deepseek-agent = { path = "../agent", version = "0.3.31" } +deepseek-config = { path = "../config", version = "0.3.31" } +deepseek-core = { path = "../core", version = "0.3.31" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" } +deepseek-hooks = { path = "../hooks", version = "0.3.31" } +deepseek-mcp = { path = "../mcp", version = "0.3.31" } +deepseek-protocol = { path = "../protocol", version = "0.3.31" } +deepseek-state = { path = "../state", version = "0.3.31" } +deepseek-tools = { path = "../tools", version = "0.3.31" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f98b8992..da4eb9f3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,12 +14,12 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.3.28" } -deepseek-app-server = { path = "../app-server", version = "0.3.28" } -deepseek-config = { path = "../config", version = "0.3.28" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.3.28" } -deepseek-mcp = { path = "../mcp", version = "0.3.28" } -deepseek-state = { path = "../state", version = "0.3.28" } +deepseek-agent = { path = "../agent", version = "0.3.31" } +deepseek-app-server = { path = "../app-server", version = "0.3.31" } +deepseek-config = { path = "../config", version = "0.3.31" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" } +deepseek-mcp = { path = "../mcp", version = "0.3.31" } +deepseek-state = { path = "../state", version = "0.3.31" } chrono.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f5289463..6a082a10 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.3.28" } -deepseek-config = { path = "../config", version = "0.3.28" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.3.28" } -deepseek-hooks = { path = "../hooks", version = "0.3.28" } -deepseek-mcp = { path = "../mcp", version = "0.3.28" } -deepseek-protocol = { path = "../protocol", version = "0.3.28" } -deepseek-state = { path = "../state", version = "0.3.28" } -deepseek-tools = { path = "../tools", version = "0.3.28" } +deepseek-agent = { path = "../agent", version = "0.3.31" } +deepseek-config = { path = "../config", version = "0.3.31" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" } +deepseek-hooks = { path = "../hooks", version = "0.3.31" } +deepseek-mcp = { path = "../mcp", version = "0.3.31" } +deepseek-protocol = { path = "../protocol", version = "0.3.31" } +deepseek-state = { path = "../state", version = "0.3.31" } +deepseek-tools = { path = "../tools", version = "0.3.31" } serde_json.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index f72d3dac..80760840 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.3.28" } +deepseek-protocol = { path = "../protocol", version = "0.3.31" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index a9901655..3727e290 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.3.28" } +deepseek-protocol = { path = "../protocol", version = "0.3.31" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 90d97b23..6049f0c7 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.3.28" } +deepseek-protocol = { path = "../protocol", version = "0.3.31" } serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 4c1169cb..094291d5 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.3.28" } +deepseek-protocol = { path = "../protocol", version = "0.3.31" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 46d66816..1e32c611 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -8,7 +8,7 @@ description = "Terminal UI for DeepSeek" [[bin]] name = "deepseek-tui" -path = "../../src/main.rs" +path = "src/main.rs" [dependencies] anyhow = "1.0.100" diff --git a/src/audit.rs b/crates/tui/src/audit.rs similarity index 100% rename from src/audit.rs rename to crates/tui/src/audit.rs diff --git a/src/automation_manager.rs b/crates/tui/src/automation_manager.rs similarity index 100% rename from src/automation_manager.rs rename to crates/tui/src/automation_manager.rs diff --git a/src/client.rs b/crates/tui/src/client.rs similarity index 100% rename from src/client.rs rename to crates/tui/src/client.rs diff --git a/src/command_safety.rs b/crates/tui/src/command_safety.rs similarity index 100% rename from src/command_safety.rs rename to crates/tui/src/command_safety.rs diff --git a/src/commands/config.rs b/crates/tui/src/commands/config.rs similarity index 98% rename from src/commands/config.rs rename to crates/tui/src/commands/config.rs index 9fa90d94..c7470822 100644 --- a/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -91,12 +91,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> app.mark_history_updated(); } "default_mode" | "mode" => { - let mode = match settings.default_mode.as_str() { - "agent" | "normal" => AppMode::Agent, - "plan" => AppMode::Plan, - "yolo" => AppMode::Yolo, - _ => AppMode::Agent, - }; + let mode = AppMode::from_setting(&settings.default_mode); app.set_mode(mode); } "max_history" | "history" => { @@ -465,7 +460,7 @@ mod tests { .unwrap() .as_nanos(); let temp_root = env::temp_dir().join(format!( - "deepseek-cli-logout-test-{}-{}", + "deepseek-tui-logout-test-{}-{}", std::process::id(), nanos )); diff --git a/src/commands/core.rs b/crates/tui/src/commands/core.rs similarity index 99% rename from src/commands/core.rs rename to crates/tui/src/commands/core.rs index 162f8a5e..67cef381 100644 --- a/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -132,7 +132,7 @@ pub fn home_dashboard(app: &mut App) -> CommandResult { let mut stats = String::new(); // Basic info - let _ = writeln!(stats, "DeepSeek CLI Home Dashboard"); + let _ = writeln!(stats, "DeepSeek TUI Home Dashboard"); let _ = writeln!(stats, "============================================"); // Model & mode @@ -418,7 +418,7 @@ mod tests { let result = home_dashboard(&mut app); assert!(result.message.is_some()); let msg = result.message.unwrap(); - assert!(msg.contains("DeepSeek CLI Home Dashboard")); + assert!(msg.contains("DeepSeek TUI Home Dashboard")); assert!(msg.contains("Model:")); assert!(msg.contains("Mode:")); assert!(msg.contains("Workspace:")); diff --git a/src/commands/debug.rs b/crates/tui/src/commands/debug.rs similarity index 100% rename from src/commands/debug.rs rename to crates/tui/src/commands/debug.rs diff --git a/src/commands/init.rs b/crates/tui/src/commands/init.rs similarity index 100% rename from src/commands/init.rs rename to crates/tui/src/commands/init.rs diff --git a/src/commands/mod.rs b/crates/tui/src/commands/mod.rs similarity index 100% rename from src/commands/mod.rs rename to crates/tui/src/commands/mod.rs diff --git a/src/commands/note.rs b/crates/tui/src/commands/note.rs similarity index 100% rename from src/commands/note.rs rename to crates/tui/src/commands/note.rs diff --git a/src/commands/queue.rs b/crates/tui/src/commands/queue.rs similarity index 100% rename from src/commands/queue.rs rename to crates/tui/src/commands/queue.rs diff --git a/src/commands/review.rs b/crates/tui/src/commands/review.rs similarity index 100% rename from src/commands/review.rs rename to crates/tui/src/commands/review.rs diff --git a/src/commands/session.rs b/crates/tui/src/commands/session.rs similarity index 100% rename from src/commands/session.rs rename to crates/tui/src/commands/session.rs diff --git a/src/commands/skills.rs b/crates/tui/src/commands/skills.rs similarity index 100% rename from src/commands/skills.rs rename to crates/tui/src/commands/skills.rs diff --git a/src/commands/task.rs b/crates/tui/src/commands/task.rs similarity index 100% rename from src/commands/task.rs rename to crates/tui/src/commands/task.rs diff --git a/src/compaction.rs b/crates/tui/src/compaction.rs similarity index 100% rename from src/compaction.rs rename to crates/tui/src/compaction.rs diff --git a/src/config.rs b/crates/tui/src/config.rs similarity index 99% rename from src/config.rs rename to crates/tui/src/config.rs index 12c42948..ef14da88 100644 --- a/src/config.rs +++ b/crates/tui/src/config.rs @@ -1,4 +1,4 @@ -//! Configuration loading and defaults for deepseek-cli. +//! Configuration loading and defaults for DeepSeek TUI. use std::collections::HashMap; use std::fmt::Write; @@ -915,7 +915,7 @@ pub fn save_api_key(api_key: &str) -> Result { } else { // Create new minimal config format!( - r#"# DeepSeek CLI Configuration + r#"# DeepSeek TUI Configuration # Get your API key from https://platform.deepseek.com # Or set DEEPSEEK_API_KEY environment variable @@ -1068,7 +1068,7 @@ mod tests { .unwrap() .as_nanos(); let temp_root = env::temp_dir().join(format!( - "deepseek-cli-test-{}-{}", + "deepseek-tui-test-{}-{}", std::process::id(), nanos )); @@ -1092,7 +1092,7 @@ mod tests { .unwrap() .as_nanos(); let temp_root = env::temp_dir().join(format!( - "deepseek-cli-tilde-test-{}-{}", + "deepseek-tui-tilde-test-{}-{}", std::process::id(), nanos )); @@ -1121,7 +1121,7 @@ mod tests { .unwrap() .as_nanos(); let temp_root = env::temp_dir().join(format!( - "deepseek-cli-load-tilde-test-{}-{}", + "deepseek-tui-load-tilde-test-{}-{}", std::process::id(), nanos )); @@ -1150,7 +1150,7 @@ mod tests { .unwrap() .as_nanos(); let temp_root = env::temp_dir().join(format!( - "deepseek-cli-load-fallback-test-{}-{}", + "deepseek-tui-load-fallback-test-{}-{}", std::process::id(), nanos )); @@ -1209,7 +1209,7 @@ mod tests { .unwrap() .as_nanos(); let temp_root = env::temp_dir().join(format!( - "deepseek-cli-api-key-test-{}-{}", + "deepseek-tui-api-key-test-{}-{}", std::process::id(), nanos )); diff --git a/src/core/capacity.rs b/crates/tui/src/core/capacity.rs similarity index 100% rename from src/core/capacity.rs rename to crates/tui/src/core/capacity.rs diff --git a/src/core/capacity_memory.rs b/crates/tui/src/core/capacity_memory.rs similarity index 100% rename from src/core/capacity_memory.rs rename to crates/tui/src/core/capacity_memory.rs diff --git a/src/core/engine.rs b/crates/tui/src/core/engine.rs similarity index 97% rename from src/core/engine.rs rename to crates/tui/src/core/engine.rs index 909898db..6bfdad19 100644 --- a/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -982,10 +982,6 @@ fn tool_result_is_noisy(tool_name: &str) -> bool { | "exec_shell_interact" | "multi_tool_use.parallel" | "web_search" - | "weather" - | "finance" - | "sports" - | "time" ) } @@ -1214,9 +1210,17 @@ impl Engine { model, allow_shell, trust_mode, + auto_approve, } => { - self.handle_send_message(content, mode, model, allow_shell, trust_mode) - .await; + self.handle_send_message( + content, + mode, + model, + allow_shell, + trust_mode, + auto_approve, + ) + .await; } Op::CancelRequest => { self.cancel_token.cancel(); @@ -1252,7 +1256,7 @@ impl Engine { client, self.session.model.clone(), // Sub-agents don't inherit YOLO mode - use Agent mode defaults - self.build_tool_context(AppMode::Agent), + self.build_tool_context(AppMode::Agent, self.session.auto_approve), self.session.allow_shell, Some(self.tx_event.clone()), ); @@ -1370,6 +1374,7 @@ impl Engine { model: String, allow_shell: bool, trust_mode: bool, + auto_approve: bool, ) { // Reset cancel token for fresh turn (in case previous was cancelled) self.reset_cancel_token(); @@ -1432,6 +1437,7 @@ impl Engine { self.config.allow_shell = allow_shell; self.session.trust_mode = trust_mode; self.config.trust_mode = trust_mode; + self.session.auto_approve = auto_approve; // Update system prompt to match current mode and include persisted compaction context. self.refresh_system_prompt(mode); @@ -1440,7 +1446,7 @@ impl Engine { let todo_list = self.config.todos.clone(); let plan_state = self.config.plan_state.clone(); - let tool_context = self.build_tool_context(mode); + let tool_context = self.build_tool_context(mode, auto_approve); let mut builder = if mode == AppMode::Plan { ToolRegistryBuilder::new() .with_read_only_file_tools() @@ -1557,6 +1563,11 @@ impl Engine { async fn handle_manual_compaction(&mut self) { let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]); + let zero_usage = Usage { + input_tokens: 0, + output_tokens: 0, + server_tool_use: None, + }; let Some(client) = self.deepseek_client.clone() else { let message = "Manual compaction unavailable: API client not configured".to_string(); let _ = self @@ -1567,7 +1578,18 @@ impl Engine { message: message.clone(), }) .await; - let _ = self.tx_event.send(Event::error(message, false)).await; + let _ = self + .tx_event + .send(Event::error(message.clone(), false)) + .await; + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: zero_usage, + status: TurnOutcomeStatus::Failed, + error: Some(message), + }) + .await; return; }; @@ -1586,6 +1608,9 @@ impl Engine { .working_set .pinned_message_indices(&self.session.messages, &self.session.workspace); let compaction_paths = self.session.working_set.top_paths(24); + let messages_before = self.session.messages.len(); + let mut turn_status = TurnOutcomeStatus::Completed; + let mut turn_error = None; match compact_messages_safe( &client, @@ -1599,15 +1624,19 @@ impl Engine { { Ok(result) => { if !result.messages.is_empty() || self.session.messages.is_empty() { + let messages_after = result.messages.len(); self.session.messages = result.messages; self.merge_compaction_summary(result.summary_prompt); + let removed = messages_before.saturating_sub(messages_after); let message = if result.retries_used > 0 { format!( - "Manual context compaction completed (after {} retries)", + "Compaction complete: {messages_before} → {messages_after} messages ({removed} removed, {} retries)", result.retries_used ) } else { - "Manual context compaction completed".to_string() + format!( + "Compaction complete: {messages_before} → {messages_after} messages ({removed} removed)" + ) }; let _ = self .tx_event @@ -1615,10 +1644,12 @@ impl Engine { id, auto: false, message, + messages_before: Some(messages_before), + messages_after: Some(messages_after), }) .await; } else { - let message = "Manual context compaction skipped: empty result".to_string(); + let message = "Compaction skipped: produced empty result".to_string(); let _ = self .tx_event .send(Event::CompactionFailed { @@ -1627,6 +1658,8 @@ impl Engine { message: message.clone(), }) .await; + turn_status = TurnOutcomeStatus::Failed; + turn_error = Some(message); } } Err(err) => { @@ -1639,9 +1672,20 @@ impl Engine { message: message.clone(), }) .await; - let _ = self.tx_event.send(Event::status(message)).await; + let _ = self.tx_event.send(Event::status(message.clone())).await; + turn_status = TurnOutcomeStatus::Failed; + turn_error = Some(message); } } + + let _ = self + .tx_event + .send(Event::TurnComplete { + usage: zero_usage, + status: turn_status, + error: turn_error, + }) + .await; } fn estimated_input_tokens(&self) -> usize { @@ -1737,15 +1781,15 @@ impl Engine { && (after_tokens < before_tokens || after_count < before_count || trimmed > 0); if recovered { + let removed = before_count.saturating_sub(after_count); let mut details = format!( - "Emergency context compaction complete: ~{} -> ~{} tokens", - before_tokens, after_tokens + "Emergency compaction complete: {before_count} → {after_count} messages ({removed} removed), ~{before_tokens} → ~{after_tokens} tokens" ); if retries_used > 0 { details.push_str(&format!(" ({} retries)", retries_used)); } if trimmed > 0 { - details.push_str(&format!(", trimmed {trimmed} oldest messages")); + details.push_str(&format!(", trimmed {trimmed} oldest")); } let _ = self .tx_event @@ -1753,6 +1797,8 @@ impl Engine { id, auto: true, message: details.clone(), + messages_before: Some(before_count), + messages_after: Some(after_count), }) .await; let _ = self.tx_event.send(Event::status(details)).await; @@ -1776,14 +1822,15 @@ impl Engine { false } - fn build_tool_context(&self, mode: AppMode) -> ToolContext { + fn build_tool_context(&self, mode: AppMode, auto_approve: bool) -> ToolContext { let ctx = ToolContext::with_auto_approve( self.session.workspace.clone(), self.session.trust_mode, self.session.notes_path.clone(), self.session.mcp_config_path.clone(), - mode == AppMode::Yolo, + mode == AppMode::Yolo || auto_approve, ) + .with_state_namespace(self.session.id.clone()) .with_features(self.config.features.clone()) .with_shell_manager(self.shell_manager.clone()); @@ -2167,6 +2214,7 @@ impl Engine { .tx_event .send(Event::status("Auto-compacting context...".to_string())) .await; + let auto_messages_before = self.session.messages.len(); match compact_messages_safe( &client, &self.session.messages, @@ -2180,15 +2228,19 @@ impl Engine { Ok(result) => { // Only update if we got valid messages (never corrupt state) if !result.messages.is_empty() || self.session.messages.is_empty() { + let auto_messages_after = result.messages.len(); self.session.messages = result.messages; self.merge_compaction_summary(result.summary_prompt); + let removed = auto_messages_before.saturating_sub(auto_messages_after); let status = if result.retries_used > 0 { format!( - "Auto-compaction complete (after {} retries)", + "Auto-compaction complete: {auto_messages_before} → {auto_messages_after} messages ({removed} removed, {} retries)", result.retries_used ) } else { - "Auto-compaction complete".to_string() + format!( + "Auto-compaction complete: {auto_messages_before} → {auto_messages_after} messages ({removed} removed)" + ) }; let _ = self .tx_event @@ -2196,6 +2248,8 @@ impl Engine { id: compaction_id.clone(), auto: true, message: status.clone(), + messages_before: Some(auto_messages_before), + messages_after: Some(auto_messages_after), }) .await; let _ = self.tx_event.send(Event::status(status)).await; @@ -4165,16 +4219,45 @@ pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle { pub(crate) struct MockEngineHandle { pub handle: EngineHandle, pub rx_op: mpsc::Receiver, + rx_approval: mpsc::Receiver, pub rx_steer: mpsc::Receiver, pub tx_event: mpsc::Sender, pub cancel_token: CancellationToken, } +#[cfg(test)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MockApprovalEvent { + Approved { + id: String, + }, + Denied { + id: String, + }, + RetryWithPolicy { + id: String, + policy: crate::sandbox::SandboxPolicy, + }, +} + +#[cfg(test)] +impl MockEngineHandle { + pub(crate) async fn recv_approval_event(&mut self) -> Option { + match self.rx_approval.recv().await? { + ApprovalDecision::Approved { id } => Some(MockApprovalEvent::Approved { id }), + ApprovalDecision::Denied { id } => Some(MockApprovalEvent::Denied { id }), + ApprovalDecision::RetryWithPolicy { id, policy } => { + Some(MockApprovalEvent::RetryWithPolicy { id, policy }) + } + } + } +} + #[cfg(test)] pub(crate) fn mock_engine_handle() -> MockEngineHandle { let (tx_op, rx_op) = mpsc::channel(32); let (tx_event, rx_event) = mpsc::channel(256); - let (tx_approval, _rx_approval) = mpsc::channel(64); + let (tx_approval, rx_approval) = mpsc::channel(64); let (tx_user_input, _rx_user_input) = mpsc::channel(32); let (tx_steer, rx_steer) = mpsc::channel(64); let cancel_token = CancellationToken::new(); @@ -4191,6 +4274,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { MockEngineHandle { handle, rx_op, + rx_approval, rx_steer, tx_event, cancel_token, diff --git a/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs similarity index 97% rename from src/core/engine/tests.rs rename to crates/tui/src/core/engine/tests.rs index 02bf7d62..ff06fcd1 100644 --- a/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -117,6 +117,19 @@ fn non_yolo_mode_retains_default_defer_policy() { assert!(!should_default_defer_tool("read_file", AppMode::Agent)); } +#[test] +fn agent_mode_can_build_auto_approved_tool_context() { + let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + + assert!( + !engine + .build_tool_context(AppMode::Agent, false) + .auto_approve + ); + assert!(engine.build_tool_context(AppMode::Agent, true).auto_approve); + assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve); +} + #[test] fn detects_context_length_errors_from_provider_payloads() { let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#; @@ -213,7 +226,7 @@ async fn post_tool_replay_invoked_when_high_non_severe_risk() { let registry = ToolRegistryBuilder::new() .with_read_only_file_tools() - .build(engine.build_tool_context(AppMode::Agent)); + .build(engine.build_tool_context(AppMode::Agent, false)); let restarted = engine .run_capacity_post_tool_checkpoint( diff --git a/src/core/events.rs b/crates/tui/src/core/events.rs similarity index 95% rename from src/core/events.rs rename to crates/tui/src/core/events.rs index e9bc9732..42e382d7 100644 --- a/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -102,6 +102,12 @@ pub enum Event { id: String, auto: bool, message: String, + /// Number of messages before compaction. + #[allow(dead_code)] + messages_before: Option, + /// Number of messages after compaction. + #[allow(dead_code)] + messages_after: Option, }, /// Context compaction failed. diff --git a/src/core/mod.rs b/crates/tui/src/core/mod.rs similarity index 100% rename from src/core/mod.rs rename to crates/tui/src/core/mod.rs diff --git a/src/core/ops.rs b/crates/tui/src/core/ops.rs similarity index 96% rename from src/core/ops.rs rename to crates/tui/src/core/ops.rs index 0578ae3b..f33f3558 100644 --- a/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -18,6 +18,7 @@ pub enum Op { model: String, allow_shell: bool, trust_mode: bool, + auto_approve: bool, }, /// Cancel the current request @@ -73,6 +74,7 @@ impl Op { model: impl Into, allow_shell: bool, trust_mode: bool, + auto_approve: bool, ) -> Self { Op::SendMessage { content: content.into(), @@ -80,6 +82,7 @@ impl Op { model: model.into(), allow_shell, trust_mode, + auto_approve, } } } diff --git a/src/core/session.rs b/crates/tui/src/core/session.rs similarity index 95% rename from src/core/session.rs rename to crates/tui/src/core/session.rs index 0f967ff1..59a964f8 100644 --- a/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -33,6 +33,9 @@ pub struct Session { /// Whether to trust paths outside workspace pub trust_mode: bool, + /// Whether the current session should auto-approve tool safety checks. + pub auto_approve: bool, + /// Notes file path pub notes_path: PathBuf, @@ -92,6 +95,7 @@ impl Session { total_usage: SessionUsage::default(), allow_shell, trust_mode, + auto_approve: false, notes_path, mcp_config_path, id: uuid::Uuid::new_v4().to_string(), diff --git a/src/core/tool_parser.rs b/crates/tui/src/core/tool_parser.rs similarity index 100% rename from src/core/tool_parser.rs rename to crates/tui/src/core/tool_parser.rs diff --git a/src/core/turn.rs b/crates/tui/src/core/turn.rs similarity index 100% rename from src/core/turn.rs rename to crates/tui/src/core/turn.rs diff --git a/src/error_taxonomy.rs b/crates/tui/src/error_taxonomy.rs similarity index 100% rename from src/error_taxonomy.rs rename to crates/tui/src/error_taxonomy.rs diff --git a/src/eval.rs b/crates/tui/src/eval.rs similarity index 100% rename from src/eval.rs rename to crates/tui/src/eval.rs diff --git a/src/execpolicy/amend.rs b/crates/tui/src/execpolicy/amend.rs similarity index 100% rename from src/execpolicy/amend.rs rename to crates/tui/src/execpolicy/amend.rs diff --git a/src/execpolicy/decision.rs b/crates/tui/src/execpolicy/decision.rs similarity index 100% rename from src/execpolicy/decision.rs rename to crates/tui/src/execpolicy/decision.rs diff --git a/src/execpolicy/error.rs b/crates/tui/src/execpolicy/error.rs similarity index 100% rename from src/execpolicy/error.rs rename to crates/tui/src/execpolicy/error.rs diff --git a/src/execpolicy/execpolicycheck.rs b/crates/tui/src/execpolicy/execpolicycheck.rs similarity index 100% rename from src/execpolicy/execpolicycheck.rs rename to crates/tui/src/execpolicy/execpolicycheck.rs diff --git a/src/execpolicy/matcher.rs b/crates/tui/src/execpolicy/matcher.rs similarity index 100% rename from src/execpolicy/matcher.rs rename to crates/tui/src/execpolicy/matcher.rs diff --git a/src/execpolicy/mod.rs b/crates/tui/src/execpolicy/mod.rs similarity index 100% rename from src/execpolicy/mod.rs rename to crates/tui/src/execpolicy/mod.rs diff --git a/src/execpolicy/parser.rs b/crates/tui/src/execpolicy/parser.rs similarity index 100% rename from src/execpolicy/parser.rs rename to crates/tui/src/execpolicy/parser.rs diff --git a/src/execpolicy/policy.rs b/crates/tui/src/execpolicy/policy.rs similarity index 100% rename from src/execpolicy/policy.rs rename to crates/tui/src/execpolicy/policy.rs diff --git a/src/execpolicy/rule.rs b/crates/tui/src/execpolicy/rule.rs similarity index 100% rename from src/execpolicy/rule.rs rename to crates/tui/src/execpolicy/rule.rs diff --git a/src/execpolicy/rules.rs b/crates/tui/src/execpolicy/rules.rs similarity index 100% rename from src/execpolicy/rules.rs rename to crates/tui/src/execpolicy/rules.rs diff --git a/src/features.rs b/crates/tui/src/features.rs similarity index 98% rename from src/features.rs rename to crates/tui/src/features.rs index 62abf5eb..3736ce66 100644 --- a/src/features.rs +++ b/crates/tui/src/features.rs @@ -1,7 +1,7 @@ // TODO(integrate): Wire feature flags into engine/tool registration — tracked as future work #![allow(dead_code)] -//! Feature flags and metadata for deepseek-cli. +//! Feature flags and metadata for DeepSeek TUI. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; diff --git a/src/hooks.rs b/crates/tui/src/hooks.rs similarity index 100% rename from src/hooks.rs rename to crates/tui/src/hooks.rs diff --git a/src/llm_client.rs b/crates/tui/src/llm_client.rs similarity index 100% rename from src/llm_client.rs rename to crates/tui/src/llm_client.rs diff --git a/src/logging.rs b/crates/tui/src/logging.rs similarity index 100% rename from src/logging.rs rename to crates/tui/src/logging.rs diff --git a/src/main.rs b/crates/tui/src/main.rs similarity index 88% rename from src/main.rs rename to crates/tui/src/main.rs index 54697067..3b6ea3b0 100644 --- a/src/main.rs +++ b/crates/tui/src/main.rs @@ -383,6 +383,22 @@ enum McpCommand { }, /// Validate MCP config and required servers Validate, + /// Register this DeepSeek binary as a local MCP stdio server. + /// + /// This adds a config entry that runs `deepseek serve --mcp` (stdio protocol). + /// For the HTTP/SSE runtime API, use `deepseek serve --http` directly instead. + #[command( + name = "add-self", + long_about = "Register this DeepSeek binary as a local MCP stdio server.\n\nAdds a config entry to ~/.deepseek/mcp.json that launches `deepseek serve --mcp`\nvia the stdio transport. Other DeepSeek sessions (or any MCP client) can then\ndiscover and call tools exposed by this server.\n\nUse `deepseek serve --http` instead if you need the HTTP/SSE runtime API." + )] + AddSelf { + /// Server name in mcp.json (default: "deepseek") + #[arg(long, default_value = "deepseek")] + name: String, + /// Workspace directory for the MCP server + #[arg(long)] + workspace: Option, + }, } #[derive(Args, Debug, Clone)] @@ -861,7 +877,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( "{}", - "DeepSeek CLI Doctor" + "DeepSeek TUI Doctor" .truecolor(blue_r, blue_g, blue_b) .bold() ); @@ -870,7 +886,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt // Version info println!("{}", "Version Information:".bold()); - println!(" deepseek-cli: {}", env!("CARGO_PKG_VERSION")); + println!(" deepseek-tui: {}", env!("CARGO_PKG_VERSION")); println!(" rust: {}", rustc_version()); println!(); @@ -1006,8 +1022,35 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "·".dimmed(), cfg.servers.len() ); - for name in cfg.servers.keys() { - println!(" - {name}"); + for (name, server) in &cfg.servers { + let status = doctor_check_mcp_server(server); + let icon = match status { + McpServerDoctorStatus::Ok(ref detail) => { + format!( + " {} {name}: {}", + "✓".truecolor(aqua_r, aqua_g, aqua_b), + detail + ) + } + McpServerDoctorStatus::Warning(ref detail) => { + format!( + " {} {name}: {}", + "!".truecolor(sky_r, sky_g, sky_b), + detail + ) + } + McpServerDoctorStatus::Error(ref detail) => { + format!( + " {} {name}: {}", + "✗".truecolor(red_r, red_g, red_b), + detail + ) + } + }; + println!("{icon}"); + if !server.enabled { + println!(" (disabled)"); + } } } Err(err) => { @@ -1784,6 +1827,56 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { } bail!("one or more MCP servers failed validation"); } + McpCommand::AddSelf { name, workspace } => { + let exe_path = std::env::current_exe() + .map_err(|e| anyhow!("Cannot resolve current binary path: {e}"))?; + let exe_str = exe_path.to_string_lossy().to_string(); + + let mut args = vec!["serve".to_string(), "--mcp".to_string()]; + if let Some(ref ws) = workspace { + args.push("--workspace".to_string()); + args.push(ws.clone()); + } + + let mut cfg = load_mcp_config(&config_path)?; + if cfg.servers.contains_key(&name) { + bail!( + "MCP server '{name}' already exists in {}. Use `deepseek mcp remove {name}` first, or choose a different --name.", + config_path.display() + ); + } + cfg.servers.insert( + name.clone(), + McpServerConfig { + command: Some(exe_str.clone()), + args, + env: std::collections::HashMap::new(), + url: None, + connect_timeout: None, + execute_timeout: None, + read_timeout: None, + disabled: false, + enabled: true, + required: false, + enabled_tools: Vec::new(), + disabled_tools: Vec::new(), + }, + ); + save_mcp_config(&config_path, &cfg)?; + println!( + "Registered DeepSeek as MCP server '{name}' in {}", + config_path.display() + ); + println!(" command: {exe_str}"); + println!( + " args: serve --mcp{}", + workspace.map_or(String::new(), |ws| format!(" --workspace {ws}")) + ); + println!(); + println!("Tip: Use `deepseek mcp validate` to test the connection."); + println!(" Use `deepseek serve --http` for the HTTP/SSE runtime API instead."); + Ok(()) + } } } @@ -1798,6 +1891,66 @@ fn load_mcp_config(path: &Path) -> Result { Ok(cfg) } +/// Diagnostic status for an MCP server entry. +#[derive(Debug)] +enum McpServerDoctorStatus { + Ok(String), + Warning(String), + Error(String), +} + +/// Check an MCP server config entry for common issues. +fn doctor_check_mcp_server(server: &McpServerConfig) -> McpServerDoctorStatus { + // No command or URL — incomplete entry. + if server.command.is_none() && server.url.is_none() { + return McpServerDoctorStatus::Error("no command or url configured".to_string()); + } + + // URL-based server — just report the URL. + if let Some(ref url) = server.url { + return McpServerDoctorStatus::Ok(format!("HTTP/SSE server at {url}")); + } + + // Command-based: validate command path exists. + let cmd = server.command.as_deref().unwrap_or(""); + if cmd.is_empty() { + return McpServerDoctorStatus::Error("empty command".to_string()); + } + + let cmd_path = Path::new(cmd); + let is_absolute = cmd_path.is_absolute(); + + if is_absolute && !cmd_path.exists() { + return McpServerDoctorStatus::Error(format!("command not found: {cmd}")); + } + + // Detect self-hosted DeepSeek server entries. + let is_self_hosted = server + .args + .windows(2) + .any(|w| w[0] == "serve" && w[1] == "--mcp"); + + let args_str = server.args.join(" "); + if is_self_hosted { + if is_absolute { + McpServerDoctorStatus::Ok(format!("self-hosted MCP server ({cmd} {args_str})")) + } else { + McpServerDoctorStatus::Warning(format!( + "self-hosted MCP server uses relative command \"{cmd}\" — consider using an absolute path" + )) + } + } else { + McpServerDoctorStatus::Ok(format!( + "stdio server ({cmd}{})", + if args_str.is_empty() { + String::new() + } else { + format!(" {args_str}") + } + )) + } +} + fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| { @@ -2140,6 +2293,7 @@ async fn run_exec_agent( model, auto_approve || config.allow_shell(), trust_mode, + auto_approve, )) .await?; @@ -2299,3 +2453,90 @@ async fn run_exec_agent( Ok(()) } + +#[cfg(test)] +mod doctor_mcp_tests { + use super::*; + + fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpServerConfig { + McpServerConfig { + command: command.map(String::from), + args: args.iter().map(|s| s.to_string()).collect(), + env: std::collections::HashMap::new(), + url: url.map(String::from), + connect_timeout: None, + execute_timeout: None, + read_timeout: None, + disabled: false, + enabled: true, + required: false, + enabled_tools: Vec::new(), + disabled_tools: Vec::new(), + } + } + + #[test] + fn test_no_command_or_url_is_error() { + let server = make_server(None, &[], None); + assert!(matches!( + doctor_check_mcp_server(&server), + McpServerDoctorStatus::Error(_) + )); + } + + #[test] + fn test_url_server_is_ok() { + let server = make_server(None, &[], Some("http://localhost:3000/mcp")); + match doctor_check_mcp_server(&server) { + McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("HTTP/SSE")), + other => panic!("Expected Ok, got {other:?}"), + } + } + + #[test] + fn test_command_server_is_ok() { + let server = make_server(Some("node"), &["server.js"], None); + match doctor_check_mcp_server(&server) { + McpServerDoctorStatus::Ok(detail) => assert!(detail.contains("stdio")), + other => panic!("Expected Ok, got {other:?}"), + } + } + + #[test] + fn test_self_hosted_absolute_is_ok() { + let server = make_server(Some("/usr/local/bin/deepseek"), &["serve", "--mcp"], None); + match doctor_check_mcp_server(&server) { + McpServerDoctorStatus::Ok(detail) | McpServerDoctorStatus::Error(detail) => { + // On systems where the path doesn't exist, this will be Error. + // On systems where it does, it'll be Ok. Either is valid for the test. + assert!( + detail.contains("self-hosted") || detail.contains("not found"), + "unexpected detail: {detail}" + ); + } + McpServerDoctorStatus::Warning(detail) => { + panic!("Absolute path should not warn: {detail}") + } + } + } + + #[test] + fn test_self_hosted_relative_is_warning() { + let server = make_server(Some("deepseek"), &["serve", "--mcp"], None); + match doctor_check_mcp_server(&server) { + McpServerDoctorStatus::Warning(detail) => { + assert!(detail.contains("relative")); + } + other => panic!("Expected Warning for relative path, got {other:?}"), + } + } + + #[test] + fn test_empty_command_is_error() { + let server = make_server(Some(""), &[], None); + assert!(matches!( + doctor_check_mcp_server(&server), + McpServerDoctorStatus::Error(_) + )); + } +} diff --git a/src/mcp.rs b/crates/tui/src/mcp.rs similarity index 99% rename from src/mcp.rs rename to crates/tui/src/mcp.rs index d64604b2..bbee4292 100644 --- a/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -498,7 +498,7 @@ impl McpConnection { "params": { "protocolVersion": "2024-11-05", "clientInfo": { - "name": "deepseek-cli", + "name": "deepseek-tui", "version": env!("CARGO_PKG_VERSION") }, "capabilities": { @@ -1478,7 +1478,7 @@ pub fn call_tool( "method": "initialize", "params": { "protocolVersion": "2024-11-05", - "clientInfo": { "name": "deepseek-cli", "version": env!("CARGO_PKG_VERSION") }, + "clientInfo": { "name": "deepseek-tui", "version": env!("CARGO_PKG_VERSION") }, "capabilities": {} } }); diff --git a/src/mcp_server.rs b/crates/tui/src/mcp_server.rs similarity index 100% rename from src/mcp_server.rs rename to crates/tui/src/mcp_server.rs diff --git a/src/models.rs b/crates/tui/src/models.rs similarity index 100% rename from src/models.rs rename to crates/tui/src/models.rs diff --git a/src/modules/mod.rs b/crates/tui/src/modules/mod.rs similarity index 100% rename from src/modules/mod.rs rename to crates/tui/src/modules/mod.rs diff --git a/src/modules/text.rs b/crates/tui/src/modules/text.rs similarity index 99% rename from src/modules/text.rs rename to crates/tui/src/modules/text.rs index af767a85..6a898a61 100644 --- a/src/modules/text.rs +++ b/crates/tui/src/modules/text.rs @@ -518,7 +518,7 @@ fn handle_command_official( } fn print_banner(mode: &str) { - println!("{}", ds_blue("DeepSeek CLI").bold()); + println!("{}", ds_blue("DeepSeek TUI").bold()); println!("Mode: {mode}"); println!("Type /help for commands. Use /exit to quit.\n"); } diff --git a/src/palette.rs b/crates/tui/src/palette.rs similarity index 100% rename from src/palette.rs rename to crates/tui/src/palette.rs diff --git a/src/pricing.rs b/crates/tui/src/pricing.rs similarity index 99% rename from src/pricing.rs rename to crates/tui/src/pricing.rs index 946a7274..74762b1a 100644 --- a/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -51,6 +51,7 @@ pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) - /// Format a USD cost for compact display. #[must_use] +#[allow(dead_code)] pub fn format_cost(cost: f64) -> String { if cost < 0.0001 { "<$0.0001".to_string() diff --git a/src/project_context.rs b/crates/tui/src/project_context.rs similarity index 99% rename from src/project_context.rs rename to crates/tui/src/project_context.rs index 882b0265..87440fb3 100644 --- a/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -1,4 +1,4 @@ -//! Project context loading for deepseek-cli. +//! Project context loading for DeepSeek TUI. //! //! This module handles loading project-specific context files that provide //! instructions and context to the AI agent. These include: @@ -210,7 +210,7 @@ pub fn create_default_agents_md(workspace: &Path) -> std::io::Result { let default_content = r#"# Project Agent Instructions -This file provides guidance to AI agents (DeepSeek CLI, Claude Code, etc.) when working with code in this repository. +This file provides guidance to AI agents (DeepSeek TUI, Claude Code, etc.) when working with code in this repository. ## File Location diff --git a/src/project_doc.rs b/crates/tui/src/project_doc.rs similarity index 100% rename from src/project_doc.rs rename to crates/tui/src/project_doc.rs diff --git a/src/prompts.rs b/crates/tui/src/prompts.rs similarity index 100% rename from src/prompts.rs rename to crates/tui/src/prompts.rs diff --git a/src/prompts/agent.txt b/crates/tui/src/prompts/agent.txt similarity index 93% rename from src/prompts/agent.txt rename to crates/tui/src/prompts/agent.txt index 47262768..26cf6e0c 100644 --- a/src/prompts/agent.txt +++ b/crates/tui/src/prompts/agent.txt @@ -1,6 +1,6 @@ -You are DeepSeek CLI, an agentic coding assistant with full tool access. +You are DeepSeek TUI, an agentic coding assistant with full tool access. -IMPORTANT: You are ALREADY running inside the DeepSeek CLI TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session. +IMPORTANT: You are ALREADY running inside the DeepSeek TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session. When given a task: 1. Understand the goal, constraints, and acceptance criteria first. @@ -46,11 +46,11 @@ FILE OPERATIONS: - web_search: Quick web search (fallback when citations are not needed) - request_user_input: Ask the user short multiple-choice questions - multi_tool_use.parallel: Execute multiple read-only tools in parallel -- weather: Get a daily weather forecast for a location -- finance: Get the latest price for a stock, fund, index, or crypto -- sports: Get schedules or standings for a league -- time: Get current time for a UTC offset -- calculator: Evaluate a basic arithmetic expression + + + + + - list_mcp_resources: List MCP resources (optionally filtered by server) - list_mcp_resource_templates: List MCP resource templates diff --git a/src/prompts/base.txt b/crates/tui/src/prompts/base.txt similarity index 93% rename from src/prompts/base.txt rename to crates/tui/src/prompts/base.txt index ee1daaa7..e2e981e1 100644 --- a/src/prompts/base.txt +++ b/crates/tui/src/prompts/base.txt @@ -1,4 +1,4 @@ -You are DeepSeek CLI, an agentic coding assistant. +You are DeepSeek TUI, an agentic coding assistant. When given a task: 1. Understand the goal, constraints, and acceptance criteria first. @@ -15,7 +15,7 @@ Tool selection guidance: - Use web.run for time-sensitive or uncertain facts; include citations as [cite:ref_id]. - Use multi_tool_use.parallel for multiple read-only tool calls that can run together. - Use request_user_input to ask short multiple-choice questions when needed. -- Use weather/finance/sports/time/calculator tools for their respective domains when applicable. + Planning and progress: - For non-trivial tasks, publish a checklist with update_plan. diff --git a/src/prompts/normal.txt b/crates/tui/src/prompts/normal.txt similarity index 86% rename from src/prompts/normal.txt rename to crates/tui/src/prompts/normal.txt index 486278c2..8958f244 100644 --- a/src/prompts/normal.txt +++ b/crates/tui/src/prompts/normal.txt @@ -1,6 +1,6 @@ -You are DeepSeek CLI, a helpful coding assistant running in NORMAL mode. +You are DeepSeek TUI, a helpful coding assistant running in NORMAL mode. -IMPORTANT: You are ALREADY running inside the DeepSeek CLI TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. +IMPORTANT: You are ALREADY running inside the DeepSeek TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. You help users with coding questions, explanations, debugging, and general programming assistance. @@ -15,11 +15,11 @@ Available tools in this mode: - web_search: Quick web search (fallback when citations are not needed) - request_user_input: Ask the user short multiple-choice questions - multi_tool_use.parallel: Execute multiple read-only tools in parallel -- weather: Get a daily weather forecast for a location -- finance: Get the latest price for a stock, fund, index, or crypto -- sports: Get schedules or standings for a league -- time: Get current time for a UTC offset -- calculator: Evaluate a basic arithmetic expression + + + + + - list_mcp_resources: List MCP resources (optionally filtered by server) - list_mcp_resource_templates: List MCP resource templates - git_status: Inspect repository status safely diff --git a/src/prompts/plan.txt b/crates/tui/src/prompts/plan.txt similarity index 88% rename from src/prompts/plan.txt rename to crates/tui/src/prompts/plan.txt index 47f1414b..16e95a82 100644 --- a/src/prompts/plan.txt +++ b/crates/tui/src/prompts/plan.txt @@ -1,4 +1,4 @@ -You are DeepSeek CLI in PLAN mode. Design before implementing. +You are DeepSeek TUI in PLAN mode. Design before implementing. This mode is read-only: you can analyze and plan, but you cannot edit files or run shell commands. @@ -28,11 +28,11 @@ EXPLORATION: - web_search: Quick web search (fallback when citations are not needed) - request_user_input: Ask the user short multiple-choice questions - multi_tool_use.parallel: Execute multiple read-only tools in parallel -- weather: Get a daily weather forecast for a location -- finance: Get the latest price for a stock, fund, index, or crypto -- sports: Get schedules or standings for a league -- time: Get current time for a UTC offset -- calculator: Evaluate a basic arithmetic expression + + + + + - list_mcp_resources: List MCP resources (optionally filtered by server) - list_mcp_resource_templates: List MCP resource templates - git_status: Inspect repository status safely diff --git a/src/prompts/yolo.txt b/crates/tui/src/prompts/yolo.txt similarity index 93% rename from src/prompts/yolo.txt rename to crates/tui/src/prompts/yolo.txt index 42f23ea5..2da45678 100644 --- a/src/prompts/yolo.txt +++ b/crates/tui/src/prompts/yolo.txt @@ -1,6 +1,6 @@ -You are DeepSeek CLI, an agentic coding assistant with full tool access running in YOLO mode. +You are DeepSeek TUI, an agentic coding assistant with full tool access running in YOLO mode. -IMPORTANT: You are ALREADY running inside the DeepSeek CLI TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session. +IMPORTANT: You are ALREADY running inside the DeepSeek TUI. You have direct access to all tools below - do NOT try to run or launch the CLI binary. Your tools execute directly in the current session. When given a task: 1. Understand the goal, constraints, and acceptance criteria first. @@ -46,11 +46,11 @@ FILE OPERATIONS: - web_search: Quick web search (fallback when citations are not needed) - request_user_input: Ask the user short multiple-choice questions - multi_tool_use.parallel: Execute multiple read-only tools in parallel -- weather: Get a daily weather forecast for a location -- finance: Get the latest price for a stock, fund, index, or crypto -- sports: Get schedules or standings for a league -- time: Get current time for a UTC offset -- calculator: Evaluate a basic arithmetic expression + + + + + - list_mcp_resources: List MCP resources (optionally filtered by server) - list_mcp_resource_templates: List MCP resource templates diff --git a/src/responses_api_proxy/mod.rs b/crates/tui/src/responses_api_proxy/mod.rs similarity index 100% rename from src/responses_api_proxy/mod.rs rename to crates/tui/src/responses_api_proxy/mod.rs diff --git a/src/responses_api_proxy/read_api_key.rs b/crates/tui/src/responses_api_proxy/read_api_key.rs similarity index 100% rename from src/responses_api_proxy/read_api_key.rs rename to crates/tui/src/responses_api_proxy/read_api_key.rs diff --git a/src/runtime_api.rs b/crates/tui/src/runtime_api.rs similarity index 99% rename from src/runtime_api.rs rename to crates/tui/src/runtime_api.rs index 6b57253d..ae1c9d8d 100644 --- a/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1055,7 +1055,7 @@ async fn stream_turn( let mode = req.mode.clone().unwrap_or_else(|| "agent".to_string()); let allow_shell = req.allow_shell.unwrap_or(state.config.allow_shell()); let trust_mode = req.trust_mode.unwrap_or(false); - let auto_approve = req.auto_approve.unwrap_or(true); + let auto_approve = req.auto_approve.unwrap_or(false); let prompt = req.prompt; let thread = state diff --git a/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs similarity index 79% rename from src/runtime_threads.rs rename to crates/tui/src/runtime_threads.rs index 305be076..58d062af 100644 --- a/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -35,6 +35,7 @@ const EVENT_CHANNEL_CAPACITY: usize = 1024; const MAX_ACTIVE_THREADS_DEFAULT: usize = 8; const SUMMARY_LIMIT: usize = 280; const CURRENT_RUNTIME_SCHEMA_VERSION: u32 = 1; +const RUNTIME_RESTART_REASON: &str = "Interrupted by process restart"; const fn default_runtime_schema_version() -> u32 { CURRENT_RUNTIME_SCHEMA_VERSION @@ -581,6 +582,13 @@ pub struct RuntimeThreadManager { cancel_token: CancellationToken, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuntimeApprovalDecision { + ApproveTool, + DenyTool, + RetryWithFullAccess, +} + impl RuntimeThreadManager { pub fn open( config: Config, @@ -589,7 +597,7 @@ impl RuntimeThreadManager { ) -> Result { let store = RuntimeThreadStore::open(manager_cfg.data_dir.clone())?; let (event_tx, _event_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY); - Ok(Self { + let manager = Self { config, workspace, store, @@ -597,7 +605,9 @@ impl RuntimeThreadManager { event_tx, manager_cfg, cancel_token: CancellationToken::new(), - }) + }; + manager.recover_interrupted_state()?; + Ok(manager) } #[allow(dead_code)] // Public API for external callers (runtime API, task manager) @@ -650,7 +660,7 @@ impl RuntimeThreadManager { .unwrap_or_else(|| "agent".to_string()); let allow_shell = req.allow_shell.unwrap_or_else(|| self.config.allow_shell()); let trust_mode = req.trust_mode.unwrap_or(false); - let auto_approve = req.auto_approve.unwrap_or(true); + let auto_approve = req.auto_approve.unwrap_or(false); let thread = ThreadRecord { schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, @@ -1027,6 +1037,7 @@ impl RuntimeThreadManager { let model = req.model.unwrap_or_else(|| thread.model.clone()); let allow_shell = req.allow_shell.unwrap_or(thread.allow_shell); let trust_mode = req.trust_mode.unwrap_or(thread.trust_mode); + let auto_approve = req.auto_approve.unwrap_or(thread.auto_approve); engine .send(Op::send( @@ -1035,6 +1046,7 @@ impl RuntimeThreadManager { model.clone(), allow_shell, trust_mode, + auto_approve, )) .await .map_err(|e| anyhow!("Failed to start turn: {e}"))?; @@ -1238,7 +1250,7 @@ impl RuntimeThreadManager { state.active_turn = Some(ActiveTurnState { turn_id: turn_id.clone(), interrupt_requested: false, - auto_approve: true, + auto_approve: thread.auto_approve, trust_mode: thread.trust_mode, }); touch_lru(&mut active.lru, thread_id); @@ -1616,7 +1628,9 @@ impl RuntimeThreadManager { ) .await?; } - EngineEvent::CompactionCompleted { id, auto, message } => { + EngineEvent::CompactionCompleted { + id, auto, message, .. + } => { if let Some(item_id) = compaction_items.remove(&id) { let mut item = self.store.load_item(&item_id)?; item.status = TurnItemLifecycleStatus::Completed; @@ -1831,12 +1845,16 @@ impl RuntimeThreadManager { .iter() .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) .count(); + let interrupted = agents + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Interrupted(_))) + .count(); let completed = agents .iter() .filter(|agent| matches!(agent.status, SubAgentStatus::Completed)) .count(); let message = format!( - "Sub-agent list refreshed: {} total ({running} running, {completed} completed)", + "Sub-agent list refreshed: {} total ({running} running, {interrupted} interrupted, {completed} completed)", agents.len() ); let item = TurnItemRecord { @@ -1883,14 +1901,15 @@ impl RuntimeThreadManager { let (auto_approve, trust_mode) = self .active_turn_flags(&thread_id, &turn_id) .await - .unwrap_or((true, false)); - if auto_approve { - let _ = engine.approve_tool_call(id).await; - } else { - let _ = engine.deny_tool_call(id).await; - } - if trust_mode { - let _ = trust_mode; + .unwrap_or((false, false)); + match Self::approval_decision(auto_approve, trust_mode, false) { + RuntimeApprovalDecision::ApproveTool => { + let _ = engine.approve_tool_call(id).await; + } + RuntimeApprovalDecision::DenyTool + | RuntimeApprovalDecision::RetryWithFullAccess => { + let _ = engine.deny_tool_call(id).await; + } } } EngineEvent::ElevationRequired { @@ -1914,16 +1933,20 @@ impl RuntimeThreadManager { let (auto_approve, trust_mode) = self .active_turn_flags(&thread_id, &turn_id) .await - .unwrap_or((true, false)); - if auto_approve && trust_mode { - let _ = engine - .retry_tool_with_policy( - tool_id, - crate::sandbox::SandboxPolicy::DangerFullAccess, - ) - .await; - } else { - let _ = engine.deny_tool_call(tool_id).await; + .unwrap_or((false, false)); + match Self::approval_decision(auto_approve, trust_mode, true) { + RuntimeApprovalDecision::RetryWithFullAccess => { + let _ = engine + .retry_tool_with_policy( + tool_id, + crate::sandbox::SandboxPolicy::DangerFullAccess, + ) + .await; + } + RuntimeApprovalDecision::ApproveTool + | RuntimeApprovalDecision::DenyTool => { + let _ = engine.deny_tool_call(tool_id).await; + } } } EngineEvent::Status { message } => { @@ -2098,6 +2121,70 @@ impl RuntimeThreadManager { Some((turn.auto_approve, turn.trust_mode)) } + fn approval_decision( + auto_approve: bool, + trust_mode: bool, + requires_full_access: bool, + ) -> RuntimeApprovalDecision { + if !auto_approve { + return RuntimeApprovalDecision::DenyTool; + } + if requires_full_access { + if trust_mode { + RuntimeApprovalDecision::RetryWithFullAccess + } else { + RuntimeApprovalDecision::DenyTool + } + } else { + RuntimeApprovalDecision::ApproveTool + } + } + + fn recover_interrupted_state(&self) -> Result<()> { + let now = Utc::now(); + for mut thread in self.store.list_threads()? { + let mut thread_changed = false; + for mut turn in self.store.list_turns_for_thread(&thread.id)? { + if !matches!( + turn.status, + RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress + ) { + continue; + } + + turn.status = RuntimeTurnStatus::Interrupted; + turn.error = Some(RUNTIME_RESTART_REASON.to_string()); + turn.ended_at = Some(now); + if let Some(started_at) = turn.started_at { + let elapsed = now.signed_duration_since(started_at); + turn.duration_ms = Some(elapsed.num_milliseconds().max(0) as u64); + } + self.store.save_turn(&turn)?; + + for item_id in &turn.item_ids { + let mut item = self.store.load_item(item_id)?; + if matches!( + item.status, + TurnItemLifecycleStatus::Queued | TurnItemLifecycleStatus::InProgress + ) { + item.status = TurnItemLifecycleStatus::Interrupted; + item.ended_at = Some(now); + self.store.save_item(&item)?; + } + } + + thread.updated_at = now; + thread_changed = true; + } + + if thread_changed { + self.store.save_thread(&thread)?; + } + } + + Ok(()) + } + #[cfg(test)] pub(crate) async fn install_test_engine( &self, @@ -2239,7 +2326,7 @@ fn write_json_atomic(path: &Path, value: &T) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::core::engine::mock_engine_handle; + use crate::core::engine::{MockApprovalEvent, mock_engine_handle}; use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus}; use std::time::{Duration, Instant}; use tokio::sync::oneshot; @@ -2265,6 +2352,64 @@ mod tests { ) } + fn sample_thread(thread_id: &str) -> ThreadRecord { + let now = Utc::now(); + ThreadRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: thread_id.to_string(), + created_at: now, + updated_at: now, + model: DEFAULT_TEXT_MODEL.to_string(), + workspace: PathBuf::from("."), + mode: AppMode::Agent.as_setting().to_string(), + allow_shell: false, + trust_mode: false, + auto_approve: false, + latest_turn_id: None, + latest_response_bookmark: None, + archived: false, + system_prompt: None, + } + } + + fn sample_turn(thread_id: &str, turn_id: &str, status: RuntimeTurnStatus) -> TurnRecord { + let now = Utc::now(); + TurnRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: turn_id.to_string(), + thread_id: thread_id.to_string(), + status, + input_summary: "sample".to_string(), + created_at: now, + started_at: Some(now), + ended_at: None, + duration_ms: None, + usage: None, + error: None, + item_ids: Vec::new(), + steer_count: 0, + } + } + + fn sample_item( + turn_id: &str, + item_id: &str, + status: TurnItemLifecycleStatus, + ) -> TurnItemRecord { + TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: item_id.to_string(), + turn_id: turn_id.to_string(), + kind: TurnItemKind::Status, + status, + summary: "sample item".to_string(), + detail: None, + artifact_refs: Vec::new(), + started_at: Some(Utc::now()), + ended_at: None, + } + } + async fn install_mock_engine( manager: &RuntimeThreadManager, thread_id: &str, @@ -2345,6 +2490,102 @@ mod tests { assert_eq!(active.lru.len(), 2); } + #[test] + fn approval_decision_matches_auto_approve_and_trust_mode() { + assert!(matches!( + RuntimeThreadManager::approval_decision(false, false, false), + RuntimeApprovalDecision::DenyTool + )); + assert!(matches!( + RuntimeThreadManager::approval_decision(true, false, false), + RuntimeApprovalDecision::ApproveTool + )); + assert!(matches!( + RuntimeThreadManager::approval_decision(true, false, true), + RuntimeApprovalDecision::DenyTool + )); + assert!(matches!( + RuntimeThreadManager::approval_decision(true, true, true), + RuntimeApprovalDecision::RetryWithFullAccess + )); + } + + #[test] + fn open_recovers_queued_and_in_progress_turns() -> Result<()> { + let runtime_dir = test_runtime_dir(); + let store = RuntimeThreadStore::open(runtime_dir.clone())?; + let thread = sample_thread("thr_recover"); + store.save_thread(&thread)?; + + let mut queued_turn = sample_turn(&thread.id, "turn_queued", RuntimeTurnStatus::Queued); + let mut in_progress_turn = + sample_turn(&thread.id, "turn_running", RuntimeTurnStatus::InProgress); + let completed_turn = sample_turn(&thread.id, "turn_done", RuntimeTurnStatus::Completed); + + let queued_item = sample_item( + &queued_turn.id, + "item_queued", + TurnItemLifecycleStatus::Queued, + ); + let in_progress_item = sample_item( + &in_progress_turn.id, + "item_running", + TurnItemLifecycleStatus::InProgress, + ); + let completed_item = sample_item( + &completed_turn.id, + "item_done", + TurnItemLifecycleStatus::Completed, + ); + + queued_turn.item_ids = vec![queued_item.id.clone()]; + in_progress_turn.item_ids = vec![in_progress_item.id.clone()]; + + store.save_item(&queued_item)?; + store.save_item(&in_progress_item)?; + store.save_item(&completed_item)?; + store.save_turn(&queued_turn)?; + store.save_turn(&in_progress_turn)?; + store.save_turn(&completed_turn)?; + + let manager = test_manager(runtime_dir)?; + + let queued_turn = manager.store.load_turn(&queued_turn.id)?; + assert_eq!(queued_turn.status, RuntimeTurnStatus::Interrupted); + assert_eq!(queued_turn.error.as_deref(), Some(RUNTIME_RESTART_REASON)); + assert!(queued_turn.ended_at.is_some()); + assert!(queued_turn.duration_ms.is_some()); + + let in_progress_turn = manager.store.load_turn(&in_progress_turn.id)?; + assert_eq!(in_progress_turn.status, RuntimeTurnStatus::Interrupted); + assert_eq!( + in_progress_turn.error.as_deref(), + Some(RUNTIME_RESTART_REASON) + ); + assert!(in_progress_turn.ended_at.is_some()); + assert!(in_progress_turn.duration_ms.is_some()); + + let completed_turn = manager.store.load_turn(&completed_turn.id)?; + assert_eq!(completed_turn.status, RuntimeTurnStatus::Completed); + assert!(completed_turn.error.is_none()); + + let queued_item = manager.store.load_item("item_queued")?; + assert_eq!(queued_item.status, TurnItemLifecycleStatus::Interrupted); + assert!(queued_item.ended_at.is_some()); + + let in_progress_item = manager.store.load_item("item_running")?; + assert_eq!( + in_progress_item.status, + TurnItemLifecycleStatus::Interrupted + ); + assert!(in_progress_item.ended_at.is_some()); + + let completed_item = manager.store.load_item("item_done")?; + assert_eq!(completed_item.status, TurnItemLifecycleStatus::Completed); + + Ok(()) + } + #[tokio::test] async fn thread_lifecycle_persists_across_restart() -> Result<()> { let runtime_dir = test_runtime_dir(); @@ -2431,6 +2672,191 @@ mod tests { Ok(()) } + #[tokio::test] + async fn create_thread_defaults_auto_approve_to_false() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + }) + .await?; + + assert!(!thread.auto_approve); + Ok(()) + } + + #[tokio::test] + async fn start_turn_passes_effective_auto_approve_to_engine() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(false), + archived: false, + system_prompt: None, + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + + let _turn = manager + .start_turn( + &thread.id, + StartTurnRequest { + prompt: "override approval".to_string(), + input_summary: None, + model: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(true), + }, + ) + .await?; + + match rx_op.recv().await { + Some(Op::SendMessage { auto_approve, .. }) => assert!(auto_approve), + other => panic!("expected SendMessage op, got {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn start_turn_can_override_thread_auto_approve_to_false() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(true), + archived: false, + system_prompt: None, + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + + let _turn = manager + .start_turn( + &thread.id, + StartTurnRequest { + prompt: "disable approval".to_string(), + input_summary: None, + model: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(false), + }, + ) + .await?; + + match rx_op.recv().await { + Some(Op::SendMessage { auto_approve, .. }) => assert!(!auto_approve), + other => panic!("expected SendMessage op, got {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn compact_thread_preserves_thread_auto_approve_policy() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(false), + archived: false, + system_prompt: None, + }) + .await?; + + let harness = install_mock_engine(&manager, &thread.id).await; + let mut rx_op = harness.rx_op; + + let turn = manager + .compact_thread(&thread.id, CompactThreadRequest::default()) + .await?; + + assert!(matches!(rx_op.recv().await, Some(Op::CompactContext))); + assert_eq!( + manager.active_turn_flags(&thread.id, &turn.id).await, + Some((false, false)) + ); + + Ok(()) + } + + #[tokio::test] + async fn compact_thread_with_real_engine_reaches_terminal_status() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: None, + archived: false, + system_prompt: None, + }) + .await?; + + let turn = manager + .compact_thread(&thread.id, CompactThreadRequest::default()) + .await?; + let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?; + + assert!(matches!( + terminal.status, + RuntimeTurnStatus::Completed | RuntimeTurnStatus::Failed + )); + assert!( + terminal.ended_at.is_some(), + "manual compaction should reach a terminal turn state" + ); + assert_eq!(manager.active_turn_flags(&thread.id, &turn.id).await, None); + + let expected_status = match terminal.status { + RuntimeTurnStatus::Completed => "completed", + RuntimeTurnStatus::Failed => "failed", + other => panic!("unexpected non-terminal compaction status: {other:?}"), + }; + let events = manager.events_since(&thread.id, None)?; + assert!(events.iter().any(|ev| { + ev.event == "turn.completed" + && ev + .payload + .get("turn") + .and_then(|turn| turn.get("status")) + .and_then(Value::as_str) + == Some(expected_status) + })); + Ok(()) + } + #[tokio::test] async fn multi_turn_continuity_same_thread() -> Result<()> { let manager = test_manager(test_runtime_dir())?; @@ -2643,6 +3069,167 @@ mod tests { Ok(()) } + #[tokio::test] + async fn approval_required_with_stale_active_turn_is_denied() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(true), + archived: false, + system_prompt: None, + }) + .await?; + + let mut harness = install_mock_engine(&manager, &thread.id).await; + let turn = manager + .start_turn( + &thread.id, + StartTurnRequest { + prompt: "needs approval".to_string(), + input_summary: None, + model: None, + mode: None, + allow_shell: None, + trust_mode: None, + auto_approve: Some(true), + }, + ) + .await?; + + assert!(matches!( + harness.rx_op.recv().await, + Some(Op::SendMessage { .. }) + )); + { + let mut active = manager.active.lock().await; + let state = active + .engines + .get_mut(&thread.id) + .context("missing active thread state")?; + state.active_turn = None; + } + + harness + .tx_event + .send(EngineEvent::ApprovalRequired { + id: "tool_stale".to_string(), + tool_name: "exec_command".to_string(), + description: "stale approval".to_string(), + }) + .await?; + + assert_eq!( + harness.recv_approval_event().await, + Some(MockApprovalEvent::Denied { + id: "tool_stale".to_string(), + }) + ); + + harness + .tx_event + .send(EngineEvent::TurnComplete { + usage: Usage { + input_tokens: 0, + output_tokens: 0, + server_tool_use: None, + }, + status: TurnOutcomeStatus::Completed, + error: None, + }) + .await?; + + let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?; + assert_eq!(terminal.status, RuntimeTurnStatus::Completed); + Ok(()) + } + + #[tokio::test] + async fn elevation_required_with_stale_active_turn_is_denied() -> Result<()> { + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + model: None, + workspace: None, + mode: None, + allow_shell: None, + trust_mode: Some(true), + auto_approve: Some(true), + archived: false, + system_prompt: None, + }) + .await?; + + let mut harness = install_mock_engine(&manager, &thread.id).await; + let turn = manager + .start_turn( + &thread.id, + StartTurnRequest { + prompt: "needs elevation".to_string(), + input_summary: None, + model: None, + mode: None, + allow_shell: None, + trust_mode: Some(true), + auto_approve: Some(true), + }, + ) + .await?; + + assert!(matches!( + harness.rx_op.recv().await, + Some(Op::SendMessage { .. }) + )); + { + let mut active = manager.active.lock().await; + let state = active + .engines + .get_mut(&thread.id) + .context("missing active thread state")?; + state.active_turn = None; + } + + harness + .tx_event + .send(EngineEvent::ElevationRequired { + tool_id: "tool_stale_elevated".to_string(), + tool_name: "exec_command".to_string(), + command: None, + denial_reason: "sandbox denied".to_string(), + blocked_network: false, + blocked_write: false, + }) + .await?; + + assert_eq!( + harness.recv_approval_event().await, + Some(MockApprovalEvent::Denied { + id: "tool_stale_elevated".to_string(), + }) + ); + + harness + .tx_event + .send(EngineEvent::TurnComplete { + usage: Usage { + input_tokens: 0, + output_tokens: 0, + server_tool_use: None, + }, + status: TurnOutcomeStatus::Completed, + error: None, + }) + .await?; + + let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?; + assert_eq!(terminal.status, RuntimeTurnStatus::Completed); + Ok(()) + } + #[tokio::test] async fn steer_turn_on_active_turn_records_item_and_event() -> Result<()> { let manager = test_manager(test_runtime_dir())?; @@ -2791,6 +3378,8 @@ mod tests { id: "auto_compact_1".to_string(), auto: true, message: "auto compact done".to_string(), + messages_before: None, + messages_after: None, }) .await; let _ = tx_event @@ -2819,6 +3408,8 @@ mod tests { id: "manual_compact_1".to_string(), auto: false, message: "manual compact done".to_string(), + messages_before: None, + messages_after: None, }) .await; let _ = tx_event @@ -2901,6 +3492,174 @@ mod tests { assert_eq!(out, "abcdefg..."); } + #[test] + fn approval_decision_requires_auto_approve_and_trust_for_full_access() { + assert_eq!( + RuntimeThreadManager::approval_decision(false, false, false), + RuntimeApprovalDecision::DenyTool + ); + assert_eq!( + RuntimeThreadManager::approval_decision(true, false, false), + RuntimeApprovalDecision::ApproveTool + ); + assert_eq!( + RuntimeThreadManager::approval_decision(true, false, true), + RuntimeApprovalDecision::DenyTool + ); + assert_eq!( + RuntimeThreadManager::approval_decision(true, true, true), + RuntimeApprovalDecision::RetryWithFullAccess + ); + } + + #[test] + fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> { + let data_dir = test_runtime_dir(); + let manager = test_manager(data_dir.clone())?; + let started_at = Utc::now() - chrono::Duration::seconds(5); + let created_at = started_at - chrono::Duration::seconds(1); + + let thread = ThreadRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: "thr_restart".to_string(), + created_at, + updated_at: created_at, + model: DEFAULT_TEXT_MODEL.to_string(), + workspace: PathBuf::from("."), + mode: "agent".to_string(), + allow_shell: false, + trust_mode: false, + auto_approve: false, + latest_turn_id: Some("turn_in_progress".to_string()), + latest_response_bookmark: None, + archived: false, + system_prompt: None, + }; + manager.store.save_thread(&thread)?; + + let completed_item = TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: "item_completed".to_string(), + turn_id: "turn_in_progress".to_string(), + kind: TurnItemKind::Status, + status: TurnItemLifecycleStatus::Completed, + summary: "done".to_string(), + detail: None, + artifact_refs: Vec::new(), + started_at: Some(started_at), + ended_at: Some(started_at + chrono::Duration::seconds(1)), + }; + let in_progress_item = TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: "item_in_progress".to_string(), + turn_id: "turn_in_progress".to_string(), + kind: TurnItemKind::ToolCall, + status: TurnItemLifecycleStatus::InProgress, + summary: "running".to_string(), + detail: None, + artifact_refs: Vec::new(), + started_at: Some(started_at), + ended_at: None, + }; + let queued_item = TurnItemRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: "item_queued".to_string(), + turn_id: "turn_queued".to_string(), + kind: TurnItemKind::ToolCall, + status: TurnItemLifecycleStatus::Queued, + summary: "queued".to_string(), + detail: None, + artifact_refs: Vec::new(), + started_at: None, + ended_at: None, + }; + manager.store.save_item(&completed_item)?; + manager.store.save_item(&in_progress_item)?; + manager.store.save_item(&queued_item)?; + + manager.store.save_turn(&TurnRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: "turn_in_progress".to_string(), + thread_id: thread.id.clone(), + status: RuntimeTurnStatus::InProgress, + input_summary: "hello".to_string(), + created_at, + started_at: Some(started_at), + ended_at: None, + duration_ms: None, + usage: None, + error: None, + item_ids: vec![completed_item.id.clone(), in_progress_item.id.clone()], + steer_count: 0, + })?; + manager.store.save_turn(&TurnRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + id: "turn_queued".to_string(), + thread_id: thread.id.clone(), + status: RuntimeTurnStatus::Queued, + input_summary: "later".to_string(), + created_at, + started_at: None, + ended_at: None, + duration_ms: None, + usage: None, + error: None, + item_ids: vec![queued_item.id.clone()], + steer_count: 0, + })?; + drop(manager); + + let recovered = test_manager(data_dir)?; + + let recovered_thread = recovered.store.load_thread(&thread.id)?; + assert!(recovered_thread.updated_at >= thread.updated_at); + + let recovered_in_progress_turn = recovered.store.load_turn("turn_in_progress")?; + assert_eq!( + recovered_in_progress_turn.status, + RuntimeTurnStatus::Interrupted + ); + assert_eq!( + recovered_in_progress_turn.error.as_deref(), + Some(RUNTIME_RESTART_REASON) + ); + assert!(recovered_in_progress_turn.ended_at.is_some()); + assert!( + recovered_in_progress_turn + .duration_ms + .is_some_and(|duration| duration >= 5_000) + ); + + let recovered_queued_turn = recovered.store.load_turn("turn_queued")?; + assert_eq!(recovered_queued_turn.status, RuntimeTurnStatus::Interrupted); + assert_eq!( + recovered_queued_turn.error.as_deref(), + Some(RUNTIME_RESTART_REASON) + ); + assert!(recovered_queued_turn.ended_at.is_some()); + assert_eq!(recovered_queued_turn.duration_ms, None); + + assert_eq!( + recovered.store.load_item(&completed_item.id)?.status, + TurnItemLifecycleStatus::Completed + ); + let recovered_in_progress_item = recovered.store.load_item(&in_progress_item.id)?; + assert_eq!( + recovered_in_progress_item.status, + TurnItemLifecycleStatus::Interrupted + ); + assert!(recovered_in_progress_item.ended_at.is_some()); + + let recovered_queued_item = recovered.store.load_item(&queued_item.id)?; + assert_eq!( + recovered_queued_item.status, + TurnItemLifecycleStatus::Interrupted + ); + assert!(recovered_queued_item.ended_at.is_some()); + + Ok(()) + } + #[test] fn parse_mode_defaults_to_agent() { assert_eq!(parse_mode("unknown"), AppMode::Agent); diff --git a/src/sandbox/landlock.rs b/crates/tui/src/sandbox/landlock.rs similarity index 100% rename from src/sandbox/landlock.rs rename to crates/tui/src/sandbox/landlock.rs diff --git a/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs similarity index 99% rename from src/sandbox/mod.rs rename to crates/tui/src/sandbox/mod.rs index 00938bc4..a1bf7f87 100644 --- a/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -6,7 +6,7 @@ //! //! This module provides sandboxing capabilities for shell commands executed by -//! deepseek-cli. Sandboxing restricts what system resources a command can access, +//! DeepSeek TUI. Sandboxing restricts what system resources a command can access, //! preventing accidental or malicious damage to the system. //! //! # Platform Support diff --git a/src/sandbox/policy.rs b/crates/tui/src/sandbox/policy.rs similarity index 99% rename from src/sandbox/policy.rs rename to crates/tui/src/sandbox/policy.rs index cf3f7329..46511935 100644 --- a/src/sandbox/policy.rs +++ b/crates/tui/src/sandbox/policy.rs @@ -34,7 +34,7 @@ pub enum SandboxPolicy { /// Indicates the process is already running in an external sandbox. /// - /// Use this when deepseek-cli is itself running inside a container, + /// Use this when DeepSeek TUI is itself running inside a container, /// VM, or other sandboxed environment. This avoids double-sandboxing /// which can cause issues. #[serde(rename = "external-sandbox")] diff --git a/src/sandbox/seatbelt.rs b/crates/tui/src/sandbox/seatbelt.rs similarity index 100% rename from src/sandbox/seatbelt.rs rename to crates/tui/src/sandbox/seatbelt.rs diff --git a/src/sandbox/windows.rs b/crates/tui/src/sandbox/windows.rs similarity index 100% rename from src/sandbox/windows.rs rename to crates/tui/src/sandbox/windows.rs diff --git a/src/session_manager.rs b/crates/tui/src/session_manager.rs similarity index 100% rename from src/session_manager.rs rename to crates/tui/src/session_manager.rs diff --git a/src/settings.rs b/crates/tui/src/settings.rs similarity index 95% rename from src/settings.rs rename to crates/tui/src/settings.rs index 9ba3d46b..c0946e79 100644 --- a/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -21,7 +21,7 @@ pub struct Settings { pub show_thinking: bool, /// Show detailed tool output pub show_tool_details: bool, - /// Default mode: "agent", "plan", "yolo" + /// Default mode: "normal", "agent", "plan", "yolo" pub default_mode: String, /// Sidebar width as percentage of terminal width pub sidebar_width_percent: u16, @@ -117,9 +117,9 @@ impl Settings { } "default_mode" | "mode" => { let normalized = normalize_mode(value); - if !["agent", "plan", "yolo"].contains(&normalized) { + if !["normal", "agent", "plan", "yolo"].contains(&normalized) { anyhow::bail!( - "Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo." + "Failed to update setting: invalid mode '{value}'. Expected: normal, agent, plan, yolo." ); } self.default_mode = normalized.to_string(); @@ -224,7 +224,7 @@ impl Settings { ("auto_compact", "Auto-compact conversations: on/off"), ("show_thinking", "Show model thinking: on/off"), ("show_tool_details", "Show detailed tool output: on/off"), - ("default_mode", "Default mode: agent, plan, yolo"), + ("default_mode", "Default mode: normal, agent, plan, yolo"), ("sidebar_width", "Sidebar width percentage: 10-50"), ( "sidebar_focus", @@ -251,8 +251,12 @@ fn parse_bool(value: &str) -> Result { } fn normalize_mode(value: &str) -> &str { - match value { - "edit" | "normal" => "agent", + match value.trim().to_ascii_lowercase().as_str() { + "edit" => "agent", + "normal" => "normal", + "agent" => "agent", + "plan" => "plan", + "yolo" => "yolo", _ => value, } } diff --git a/src/skills.rs b/crates/tui/src/skills.rs similarity index 100% rename from src/skills.rs rename to crates/tui/src/skills.rs diff --git a/src/task_manager.rs b/crates/tui/src/task_manager.rs similarity index 100% rename from src/task_manager.rs rename to crates/tui/src/task_manager.rs diff --git a/src/test_support.rs b/crates/tui/src/test_support.rs similarity index 100% rename from src/test_support.rs rename to crates/tui/src/test_support.rs diff --git a/src/tools/apply_patch.rs b/crates/tui/src/tools/apply_patch.rs similarity index 100% rename from src/tools/apply_patch.rs rename to crates/tui/src/tools/apply_patch.rs diff --git a/src/tools/diagnostics.rs b/crates/tui/src/tools/diagnostics.rs similarity index 100% rename from src/tools/diagnostics.rs rename to crates/tui/src/tools/diagnostics.rs diff --git a/src/tools/file.rs b/crates/tui/src/tools/file.rs similarity index 100% rename from src/tools/file.rs rename to crates/tui/src/tools/file.rs diff --git a/src/tools/file_search.rs b/crates/tui/src/tools/file_search.rs similarity index 100% rename from src/tools/file_search.rs rename to crates/tui/src/tools/file_search.rs diff --git a/src/tools/git.rs b/crates/tui/src/tools/git.rs similarity index 100% rename from src/tools/git.rs rename to crates/tui/src/tools/git.rs diff --git a/src/tools/git_history.rs b/crates/tui/src/tools/git_history.rs similarity index 100% rename from src/tools/git_history.rs rename to crates/tui/src/tools/git_history.rs diff --git a/src/tools/mod.rs b/crates/tui/src/tools/mod.rs similarity index 99% rename from src/tools/mod.rs rename to crates/tui/src/tools/mod.rs index 09daa688..731aaaf8 100644 --- a/src/tools/mod.rs +++ b/crates/tui/src/tools/mod.rs @@ -4,6 +4,7 @@ pub mod apply_patch; pub mod diagnostics; pub mod file; pub mod file_search; + pub mod git; pub mod git_history; pub mod parallel; diff --git a/src/tools/parallel.rs b/crates/tui/src/tools/parallel.rs similarity index 100% rename from src/tools/parallel.rs rename to crates/tui/src/tools/parallel.rs diff --git a/src/tools/plan.rs b/crates/tui/src/tools/plan.rs similarity index 100% rename from src/tools/plan.rs rename to crates/tui/src/tools/plan.rs diff --git a/src/tools/project.rs b/crates/tui/src/tools/project.rs similarity index 100% rename from src/tools/project.rs rename to crates/tui/src/tools/project.rs diff --git a/src/tools/registry.rs b/crates/tui/src/tools/registry.rs similarity index 100% rename from src/tools/registry.rs rename to crates/tui/src/tools/registry.rs diff --git a/src/tools/review.rs b/crates/tui/src/tools/review.rs similarity index 100% rename from src/tools/review.rs rename to crates/tui/src/tools/review.rs diff --git a/src/tools/search.rs b/crates/tui/src/tools/search.rs similarity index 100% rename from src/tools/search.rs rename to crates/tui/src/tools/search.rs diff --git a/src/tools/shell.rs b/crates/tui/src/tools/shell.rs similarity index 100% rename from src/tools/shell.rs rename to crates/tui/src/tools/shell.rs diff --git a/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs similarity index 100% rename from src/tools/shell/tests.rs rename to crates/tui/src/tools/shell/tests.rs diff --git a/src/tools/shell_output.rs b/crates/tui/src/tools/shell_output.rs similarity index 100% rename from src/tools/shell_output.rs rename to crates/tui/src/tools/shell_output.rs diff --git a/src/tools/spec.rs b/crates/tui/src/tools/spec.rs similarity index 97% rename from src/tools/spec.rs rename to crates/tui/src/tools/spec.rs index 9864aed7..bcea64d9 100644 --- a/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -1,4 +1,4 @@ -//! Tool specification traits for the deepseek-cli agent system. +//! Tool specification traits for the DeepSeek TUI agent system. //! //! This module defines the core abstractions for tools: //! - `ToolSpec`: The main trait that all tools must implement @@ -196,6 +196,8 @@ pub struct ToolContext { pub auto_approve: bool, /// Effective feature flag set for the running session. pub features: Features, + /// Namespace for tool state that should be scoped to the current session/thread. + pub state_namespace: String, } impl ToolContext { @@ -216,6 +218,7 @@ impl ToolContext { elevated_sandbox_policy: None, auto_approve: false, features: Features::with_defaults(), + state_namespace: "workspace".to_string(), } } @@ -239,6 +242,7 @@ impl ToolContext { elevated_sandbox_policy: None, auto_approve: false, features: Features::with_defaults(), + state_namespace: "workspace".to_string(), } } @@ -262,6 +266,7 @@ impl ToolContext { elevated_sandbox_policy: None, auto_approve, features: Features::with_defaults(), + state_namespace: "workspace".to_string(), } } @@ -415,6 +420,12 @@ impl ToolContext { self.elevated_sandbox_policy = Some(policy); self } + + /// Set the namespace used for session-scoped tool state. + pub fn with_state_namespace(mut self, namespace: impl Into) -> Self { + self.state_namespace = namespace.into(); + self + } } fn normalize_path(path: &Path) -> PathBuf { diff --git a/src/tools/subagent.rs b/crates/tui/src/tools/subagent.rs similarity index 99% rename from src/tools/subagent.rs rename to crates/tui/src/tools/subagent.rs index 21f007f2..e28478f2 100644 --- a/src/tools/subagent.rs +++ b/crates/tui/src/tools/subagent.rs @@ -195,6 +195,7 @@ impl SubAgentType { pub enum SubAgentStatus { Running, Completed, + Interrupted(String), Failed(String), Cancelled, } @@ -511,7 +512,7 @@ impl SubAgentManager { for persisted in state.agents { let mut status = persisted.status; if matches!(status, SubAgentStatus::Running) { - status = SubAgentStatus::Failed(SUBAGENT_RESTART_REASON.to_string()); + status = SubAgentStatus::Interrupted(SUBAGENT_RESTART_REASON.to_string()); } let started_at = instant_from_duration(Duration::from_millis(persisted.duration_ms)); @@ -3267,6 +3268,16 @@ After the tool call succeeds, stop.", result_json: Some(report.result), } } + SubAgentStatus::Interrupted(error) => CsvWorkerOutcome { + row_index, + item_id, + status: "interrupted".to_string(), + agent_id: Some(snapshot.agent_id), + duration_ms: snapshot.duration_ms, + error: Some(error), + result: snapshot.result, + result_json: None, + }, SubAgentStatus::Failed(error) => CsvWorkerOutcome { row_index, item_id, @@ -3455,6 +3466,7 @@ fn summarize_subagent_result(result: &SubAgentResult) -> String { match (&result.status, result.result.as_ref()) { (SubAgentStatus::Completed, Some(text)) => truncate_preview(text), (SubAgentStatus::Completed, None) => "Completed (no output)".to_string(), + (SubAgentStatus::Interrupted(error), _) => format!("Interrupted: {error}"), (SubAgentStatus::Cancelled, _) => "Cancelled".to_string(), (SubAgentStatus::Failed(error), _) => format!("Failed: {error}"), (SubAgentStatus::Running, _) => "Running".to_string(), @@ -3465,6 +3477,7 @@ fn subagent_status_name(status: &SubAgentStatus) -> &'static str { match status { SubAgentStatus::Running => "running", SubAgentStatus::Completed => "completed", + SubAgentStatus::Interrupted(_) => "interrupted", SubAgentStatus::Failed(_) => "failed", SubAgentStatus::Cancelled => "cancelled", } @@ -4154,7 +4167,7 @@ mod tests { } #[test] - fn test_persist_and_reload_marks_running_agent_as_failed() { + fn test_persist_and_reload_marks_running_agent_as_interrupted() { let tmp = tempdir().expect("tempdir"); let workspace = tmp.path().to_path_buf(); let state_path = default_state_path(tmp.path()); @@ -4180,7 +4193,17 @@ mod tests { .expect("reloaded agent should exist"); assert!(matches!( snapshot.status, - SubAgentStatus::Failed(ref message) if message.contains(SUBAGENT_RESTART_REASON) + SubAgentStatus::Interrupted(ref message) + if message.contains(SUBAGENT_RESTART_REASON) )); } + + #[test] + fn test_interrupted_status_name_and_summary() { + let snapshot = make_snapshot(SubAgentStatus::Interrupted( + SUBAGENT_RESTART_REASON.to_string(), + )); + assert_eq!(subagent_status_name(&snapshot.status), "interrupted"); + assert!(summarize_subagent_result(&snapshot).contains(SUBAGENT_RESTART_REASON)); + } } diff --git a/src/tools/swarm.rs b/crates/tui/src/tools/swarm.rs similarity index 97% rename from src/tools/swarm.rs rename to crates/tui/src/tools/swarm.rs index 52add080..71d98dc9 100644 --- a/src/tools/swarm.rs +++ b/crates/tui/src/tools/swarm.rs @@ -74,6 +74,7 @@ enum SwarmTaskStatus { Pending, Running, Completed, + Interrupted, Failed, Cancelled, Skipped, @@ -122,6 +123,7 @@ impl SwarmStatus { struct SwarmCounts { total: usize, completed: usize, + interrupted: usize, failed: usize, cancelled: usize, skipped: usize, @@ -682,7 +684,9 @@ async fn run_swarm( if snapshot.status != SubAgentStatus::Running { if matches!( snapshot.status, - SubAgentStatus::Failed(_) | SubAgentStatus::Cancelled + SubAgentStatus::Interrupted(_) + | SubAgentStatus::Failed(_) + | SubAgentStatus::Cancelled ) && schedule_retry_if_possible( task, &task_id, @@ -706,7 +710,9 @@ async fn run_swarm( if fail_fast && matches!( snapshot.status, - SubAgentStatus::Failed(_) | SubAgentStatus::Cancelled + SubAgentStatus::Interrupted(_) + | SubAgentStatus::Failed(_) + | SubAgentStatus::Cancelled ) { fail_fast_triggered = true; @@ -941,6 +947,7 @@ async fn run_swarm( } else if timed_out { SwarmStatus::Timeout } else if counts.failed > 0 + || counts.interrupted > 0 || counts.cancelled > 0 || counts.skipped > 0 || counts.pending > 0 @@ -985,6 +992,7 @@ fn build_failed_outcome(swarm_id: &str, error: String) -> SwarmOutcome { counts: SwarmCounts { total: 0, completed: 0, + interrupted: 0, failed: 1, cancelled: 0, skipped: 0, @@ -1027,12 +1035,13 @@ fn emit_swarm_status(event_tx: Option<&tokio::sync::mpsc::Sender>, outcom }; let message = format!( - "Swarm {}: status={} completed={}/{} running={} failed={} skipped={} cancelled={}", + "Swarm {}: status={} completed={}/{} running={} interrupted={} failed={} skipped={} cancelled={}", outcome.swarm_id, outcome.status.as_str(), outcome.counts.completed, outcome.counts.total, outcome.counts.running, + outcome.counts.interrupted, outcome.counts.failed, outcome.counts.skipped, outcome.counts.cancelled @@ -1209,7 +1218,7 @@ fn dependencies_failed(task: &SwarmTaskSpec, states: &HashMap matches!( result.status, - SubAgentStatus::Failed(_) | SubAgentStatus::Cancelled + SubAgentStatus::Interrupted(_) | SubAgentStatus::Failed(_) | SubAgentStatus::Cancelled ), Some(SwarmTaskState::Failed(_)) | Some(SwarmTaskState::Skipped(_)) => true, _ => false, @@ -1285,6 +1294,15 @@ fn build_task_outcomes( steps_taken: result.steps_taken, duration_ms: result.duration_ms, }, + SubAgentStatus::Interrupted(err) => SwarmTaskOutcome { + task_id: task_id.clone(), + agent_id: Some(result.agent_id.clone()), + status: SwarmTaskStatus::Interrupted, + result: result.result.clone(), + error: Some(err.clone()), + steps_taken: result.steps_taken, + duration_ms: result.duration_ms, + }, SubAgentStatus::Failed(err) => SwarmTaskOutcome { task_id: task_id.clone(), agent_id: Some(result.agent_id.clone()), @@ -1348,6 +1366,7 @@ fn build_counts(outcomes: &[SwarmTaskOutcome]) -> SwarmCounts { let mut counts = SwarmCounts { total: outcomes.len(), completed: 0, + interrupted: 0, failed: 0, cancelled: 0, skipped: 0, @@ -1358,6 +1377,7 @@ fn build_counts(outcomes: &[SwarmTaskOutcome]) -> SwarmCounts { for outcome in outcomes { match outcome.status { SwarmTaskStatus::Completed => counts.completed += 1, + SwarmTaskStatus::Interrupted => counts.interrupted += 1, SwarmTaskStatus::Failed => counts.failed += 1, SwarmTaskStatus::Cancelled => counts.cancelled += 1, SwarmTaskStatus::Skipped => counts.skipped += 1, diff --git a/src/tools/test_runner.rs b/crates/tui/src/tools/test_runner.rs similarity index 100% rename from src/tools/test_runner.rs rename to crates/tui/src/tools/test_runner.rs diff --git a/src/tools/todo.rs b/crates/tui/src/tools/todo.rs similarity index 100% rename from src/tools/todo.rs rename to crates/tui/src/tools/todo.rs diff --git a/src/tools/user_input.rs b/crates/tui/src/tools/user_input.rs similarity index 100% rename from src/tools/user_input.rs rename to crates/tui/src/tools/user_input.rs diff --git a/src/tools/validate_data.rs b/crates/tui/src/tools/validate_data.rs similarity index 100% rename from src/tools/validate_data.rs rename to crates/tui/src/tools/validate_data.rs diff --git a/src/tools/web_run.rs b/crates/tui/src/tools/web_run.rs similarity index 83% rename from src/tools/web_run.rs rename to crates/tui/src/tools/web_run.rs index 3c9b01b7..9ba78250 100644 --- a/src/tools/web_run.rs +++ b/crates/tui/src/tools/web_run.rs @@ -11,21 +11,157 @@ use async_trait::async_trait; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; +use std::hash::{Hash, Hasher}; use std::sync::{Mutex, OnceLock}; -use std::time::Duration; +use std::time::{Duration, Instant}; const MAX_RESULTS: usize = 10; const DEFAULT_TIMEOUT_MS: u64 = 15_000; const DEFAULT_OPEN_TIMEOUT_MS: u64 = 20_000; +const MAX_WEB_RUN_SESSIONS: usize = 64; +const MAX_PAGES_PER_SESSION: usize = 256; +const WEB_RUN_SESSION_TTL: Duration = Duration::from_secs(30 * 60); const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"; static WEB_RUN_STATE: OnceLock> = OnceLock::new(); #[derive(Default)] struct WebRunState { + sessions: HashMap, + pages: HashMap, +} + +struct WebRunSessionState { next_turn: u64, - pages: HashMap, + refs: VecDeque, + last_access: Instant, +} + +impl Default for WebRunSessionState { + fn default() -> Self { + Self { + next_turn: 0, + refs: VecDeque::new(), + last_access: Instant::now(), + } + } +} + +#[derive(Debug, Clone)] +struct StoredWebPage { + namespace: String, + page: WebPage, +} + +impl WebRunState { + fn cleanup(&mut self) { + let now = Instant::now(); + let expired = self + .sessions + .iter() + .filter_map(|(namespace, session)| { + if now.duration_since(session.last_access) > WEB_RUN_SESSION_TTL { + Some(namespace.clone()) + } else { + None + } + }) + .collect::>(); + for namespace in expired { + self.remove_session(&namespace); + } + + while self.sessions.len() > MAX_WEB_RUN_SESSIONS { + let Some(oldest_namespace) = self + .sessions + .iter() + .min_by_key(|(_, session)| session.last_access) + .map(|(namespace, _)| namespace.clone()) + else { + break; + }; + self.remove_session(&oldest_namespace); + } + } + + fn remove_session(&mut self, namespace: &str) { + if let Some(session) = self.sessions.remove(namespace) { + for ref_id in session.refs { + self.pages.remove(&ref_id); + } + } + } + + fn touch_session(&mut self, namespace: &str) { + self.cleanup(); + if !self.sessions.contains_key(namespace) && self.sessions.len() >= MAX_WEB_RUN_SESSIONS { + if let Some(oldest_namespace) = self + .sessions + .iter() + .min_by_key(|(_, session)| session.last_access) + .map(|(existing_namespace, _)| existing_namespace.clone()) + { + self.remove_session(&oldest_namespace); + } + } + + let session = self.sessions.entry(namespace.to_string()).or_default(); + session.last_access = Instant::now(); + } + + fn next_turn(&mut self, namespace: &str) -> u64 { + self.touch_session(namespace); + let session = self + .sessions + .get_mut(namespace) + .expect("session should exist after touch"); + let current = session.next_turn; + session.next_turn = session.next_turn.saturating_add(1); + current + } + + fn store_page(&mut self, namespace: &str, ref_id: &str, page: WebPage) { + self.touch_session(namespace); + let mut evicted_refs = Vec::new(); + { + let session = self + .sessions + .get_mut(namespace) + .expect("session should exist after touch"); + if let Some(existing_idx) = session.refs.iter().position(|existing| existing == ref_id) + { + session.refs.remove(existing_idx); + } + session.refs.push_back(ref_id.to_string()); + + while session.refs.len() > MAX_PAGES_PER_SESSION { + if let Some(evicted_ref) = session.refs.pop_front() { + evicted_refs.push(evicted_ref); + } + } + } + + self.pages.insert( + ref_id.to_string(), + StoredWebPage { + namespace: namespace.to_string(), + page, + }, + ); + for evicted_ref in evicted_refs { + self.pages.remove(&evicted_ref); + } + } + + fn get_page(&mut self, ref_id: &str) -> Option { + self.cleanup(); + let stored = self.pages.get(ref_id)?.clone(); + if let Some(session) = self.sessions.get_mut(&stored.namespace) { + session.last_access = Instant::now(); + } + Some(stored.page) + } } #[derive(Debug, Clone, Serialize)] @@ -300,15 +436,11 @@ impl ToolSpec for WebRunTool { ApprovalRequirement::Auto } - async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + async fn execute(&self, input: Value, context: &ToolContext) -> Result { let response_length = ResponseLength::from_input(input.get("response_length")); let mut output = WebRunOutput::default(); - - let turn = with_state(|state| { - let current = state.next_turn; - state.next_turn = state.next_turn.saturating_add(1); - current - }); + let scope = scoped_ref_prefix(&context.state_namespace); + let turn = with_state(|state| state.next_turn(&context.state_namespace)); let mut search_counter = 0usize; let mut view_counter = 0usize; @@ -353,10 +485,10 @@ impl ToolSpec for WebRunTool { warnings.push(w); } search_counter += 1; - let ref_id = format!("turn{turn}search{search_counter}"); + let ref_id = format!("{scope}turn{turn}search{search_counter}"); let page = page_from_search(&query, &entries); - store_page(&ref_id, page); + store_page(&context.state_namespace, &ref_id, page); results.push(SearchResult { ref_id, @@ -441,8 +573,8 @@ impl ToolSpec for WebRunTool { let page = resolve_or_fetch_page(&ref_id, DEFAULT_OPEN_TIMEOUT_MS).await?; view_counter += 1; - let view_ref = format!("turn{turn}view{view_counter}"); - store_page(&view_ref, page.clone()); + let view_ref = format!("{scope}turn{turn}view{view_counter}"); + store_page(&context.state_namespace, &view_ref, page.clone()); let view = render_view(&view_ref, &page, lineno, response_length); views.push(view); @@ -471,8 +603,8 @@ impl ToolSpec for WebRunTool { let target = link.url.clone(); let fetched = resolve_or_fetch_page(&target, DEFAULT_OPEN_TIMEOUT_MS).await?; click_counter += 1; - let click_ref = format!("turn{turn}click{click_counter}"); - store_page(&click_ref, fetched.clone()); + let click_ref = format!("{scope}turn{turn}click{click_counter}"); + store_page(&context.state_namespace, &click_ref, fetched.clone()); let view = render_view(&click_ref, &fetched, 1, response_length); views.push(view); } @@ -522,17 +654,36 @@ fn with_state(f: impl FnOnce(&mut WebRunState) -> T) -> T { let mut state = lock .lock() .expect("web run state mutex should not be poisoned"); + state.cleanup(); f(&mut state) } -fn store_page(ref_id: &str, page: WebPage) { +fn scoped_ref_prefix(namespace: &str) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + namespace.hash(&mut hasher); + format!("s{:016x}_", hasher.finish()) +} + +fn store_page(namespace: &str, ref_id: &str, page: WebPage) { with_state(|state| { - state.pages.insert(ref_id.to_string(), page); + state.store_page(namespace, ref_id, page); }); } fn get_page(ref_id: &str) -> Option { - with_state(|state| state.pages.get(ref_id).cloned()) + with_state(|state| state.get_page(ref_id)) +} + +#[cfg(test)] +fn reset_web_run_state() { + with_state(|state| { + *state = WebRunState::default(); + }); +} + +#[cfg(test)] +fn next_turn_for_namespace(namespace: &str) -> u64 { + with_state(|state| state.next_turn(namespace)) } async fn resolve_or_fetch_page(ref_id: &str, timeout_ms: u64) -> Result { @@ -1305,6 +1456,17 @@ fn url_encode(input: &str) -> String { mod tests { use super::*; + fn sample_page(url: &str) -> WebPage { + WebPage { + url: url.to_string(), + title: Some("Example".to_string()), + content_type: Some("text/html".to_string()), + lines: vec!["example line".to_string()], + links: Vec::new(), + pdf_pages: None, + } + } + #[test] fn html_link_parsing_extracts_links() { let html = r#" @@ -1347,4 +1509,69 @@ mod tests { Some("3-xyz_123".to_string()) ); } + + #[test] + fn scoped_ref_prefix_is_session_specific() { + reset_web_run_state(); + let alpha = scoped_ref_prefix("session-alpha"); + let beta = scoped_ref_prefix("session-beta"); + + assert_ne!(alpha, beta); + assert!(alpha.starts_with('s')); + assert!(alpha.ends_with('_')); + assert_eq!(alpha.len(), 18); + } + + #[test] + fn stored_pages_do_not_cross_scoped_sessions() { + reset_web_run_state(); + let shared_suffix = "turn1search1"; + let ref_alpha = format!("{}{}", scoped_ref_prefix("session-alpha"), shared_suffix); + let ref_beta = format!("{}{}", scoped_ref_prefix("session-beta"), shared_suffix); + + store_page( + "session-alpha", + &ref_alpha, + sample_page("https://example.com/alpha"), + ); + + assert!(get_page(&ref_alpha).is_some()); + assert!(get_page(&ref_beta).is_none()); + } + + #[test] + fn turn_counters_are_scoped_per_session() { + reset_web_run_state(); + + assert_eq!(next_turn_for_namespace("session-alpha"), 0); + assert_eq!(next_turn_for_namespace("session-alpha"), 1); + assert_eq!(next_turn_for_namespace("session-beta"), 0); + } + + #[test] + fn stale_session_pages_are_evicted() { + reset_web_run_state(); + let namespace = "session-alpha"; + let ref_id = format!("{}turn0search1", scoped_ref_prefix(namespace)); + store_page(namespace, &ref_id, sample_page("https://example.com/alpha")); + + with_state(|state| { + let session = state + .sessions + .get_mut(namespace) + .expect("session should exist"); + session.last_access = Instant::now() - WEB_RUN_SESSION_TTL - Duration::from_secs(1); + }); + + let _ = next_turn_for_namespace("session-beta"); + + assert!(get_page(&ref_id).is_none()); + } + + #[test] + fn direct_urls_remain_compatible_open_refs() { + assert!(looks_like_url("https://example.com")); + assert!(looks_like_url("http://example.com")); + assert!(!looks_like_url("turn0search0")); + } } diff --git a/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs similarity index 96% rename from src/tools/web_search.rs rename to crates/tui/src/tools/web_search.rs index 395caf63..cde6ee67 100644 --- a/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -1,4 +1,7 @@ -//! Web search tool backed by DuckDuckGo HTML results. +//! Compatibility web search helper backed by DuckDuckGo HTML results. +//! +//! Prefer `web.run` for new browsing calls. This legacy surface remains available +//! for older prompts and configs that still reference `web_search`. use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, @@ -66,7 +69,7 @@ impl ToolSpec for WebSearchTool { } fn description(&self) -> &'static str { - "Search the web and return a concise list of results." + "Compatibility web search helper. Prefer web.run for canonical browsing workflows." } fn input_schema(&self) -> Value { diff --git a/src/tui/app.rs b/crates/tui/src/tui/app.rs similarity index 96% rename from src/tui/app.rs rename to crates/tui/src/tui/app.rs index ddcdcdfe..bb3179c2 100644 --- a/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -25,6 +25,7 @@ use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::paste_burst::{FlushResult, PasteBurst}; use crate::tui::scrolling::{MouseScrollState, TranscriptScroll}; use crate::tui::selection::TranscriptSelection; +use crate::tui::streaming::StreamingState; use crate::tui::transcript::TranscriptViewCache; use crate::tui::views::ViewStack; @@ -37,9 +38,11 @@ fn format_welcome_banner(model: &str, workspace: &Path, yolo: bool) -> String { }; format!( - "Tips: Tab cycles modes forward/reverse (Normal→Agent→YOLO→Plan),\n\ - Alt+N/A/Y/P or Alt+1/2/3/4 for direct mode switch, F1 or /help, Esc to cancel\n\ - Alt+!/@/#/$/) to focus sidebar sections (Plan/Todos/Tasks/Agents/Auto), F1 for help\n\ + "Start with a workflow instead of a shortcut:\n\ + - Normal asks questions, Agent runs tools, Plan reviews the approach first\n\ + - Watch approvals, queued prompts, and sub-agents in the runtime status area\n\ + - Use /queue to edit pending work and Ctrl+R or /sessions to resume past threads\n\ + - Ctrl+K opens the command palette, F1 opens help, Esc cancels current work\n\ {mode_line}\ Directory: {}\n\ Model: {}", @@ -178,6 +181,26 @@ fn sanitize_api_key_text(text: &str) -> String { const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000; impl AppMode { + #[must_use] + pub fn from_setting(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "normal" => Self::Normal, + "plan" => Self::Plan, + "yolo" => Self::Yolo, + _ => Self::Agent, + } + } + + #[must_use] + pub fn as_setting(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Agent => "agent", + Self::Yolo => "yolo", + Self::Plan => "plan", + } + } + /// Short label used in the UI footer. pub fn label(self) -> &'static str { match self { @@ -354,6 +377,8 @@ pub struct App { pub last_exec_wait_command: Option, /// Current streaming assistant cell pub streaming_message_index: Option, + /// Newline-gated streaming collector state. + pub streaming_state: StreamingState, /// Accumulated reasoning text pub reasoning_buffer: String, /// Live reasoning header extracted from bold text @@ -384,6 +409,14 @@ pub struct App { pub task_panel: Vec, /// Whether the UI needs to be redrawn. pub needs_redraw: bool, + /// Session start time for elapsed-time display in the footer. + pub session_start: Instant, + /// When the current thinking block started (for duration tracking). + pub thinking_started_at: Option, + /// Whether context compaction is currently in progress. + pub is_compacting: bool, + /// Timestamp of the last user message send (for brief visual feedback). + pub last_send_at: Option, } /// Message queued while the engine is busy. @@ -481,12 +514,7 @@ impl App { let compact_threshold = compaction_threshold_for_model(&model); // Start in YOLO mode if --yolo flag was passed - let preferred_mode = match settings.default_mode.as_str() { - "plan" => AppMode::Plan, - "agent" | "normal" => AppMode::Agent, - "yolo" => AppMode::Yolo, - _ => AppMode::Agent, - }; + let preferred_mode = AppMode::from_setting(&settings.default_mode); let initial_mode = if yolo { AppMode::Yolo } else if start_in_agent_mode { @@ -621,6 +649,7 @@ impl App { ignored_tool_calls: HashSet::new(), last_exec_wait_command: None, streaming_message_index: None, + streaming_state: StreamingState::new(), reasoning_buffer: String::new(), reasoning_header: None, last_reasoning: None, @@ -636,6 +665,10 @@ impl App { workspace_context_refreshed_at: None, task_panel: Vec::new(), needs_redraw: true, + session_start: Instant::now(), + thinking_started_at: None, + is_compacting: false, + last_send_at: None, } } diff --git a/src/tui/approval.rs b/crates/tui/src/tui/approval.rs similarity index 78% rename from src/tui/approval.rs rename to crates/tui/src/tui/approval.rs index 49b82965..2a346fc6 100644 --- a/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -55,6 +55,14 @@ pub enum ToolCategory { FileWrite, /// Shell execution (`exec_shell`) Shell, + /// Network-oriented built-in tools + Network, + /// Read-only MCP discovery and resource access + McpRead, + /// MCP actions that may change remote state + McpAction, + /// Unknown or unclassified tool surface + Unknown, } /// Request for user approval of a tool execution @@ -64,20 +72,26 @@ pub struct ApprovalRequest { pub id: String, /// Tool being executed pub tool_name: String, + /// Human-readable tool description from the engine + pub description: String, /// Tool category pub category: ToolCategory, + /// Derived impact summary for the approval prompt + pub impacts: Vec, /// Tool parameters (for display) pub params: Value, } impl ApprovalRequest { - pub fn new(id: &str, tool_name: &str, params: &Value) -> Self { + pub fn new(id: &str, tool_name: &str, description: &str, params: &Value) -> Self { let category = get_tool_category(tool_name); Self { id: id.to_string(), tool_name: tool_name.to_string(), + description: description.to_string(), category, + impacts: build_impact_summary(tool_name, category, params), params: params.clone(), } } @@ -93,11 +107,146 @@ impl ApprovalRequest { pub fn get_tool_category(name: &str) -> ToolCategory { if matches!(name, "write_file" | "edit_file" | "apply_patch") { ToolCategory::FileWrite - } else if name == "exec_shell" || name.starts_with("mcp_") || name.starts_with("list_mcp_") { + } else if matches!(name, "web_run" | "web_search" | "fetch_url") { + ToolCategory::Network + } else if name == "exec_shell" { ToolCategory::Shell - } else { - // Default to safe (includes read/list/todo/note/update_plan and unknown tools) + } else if name.starts_with("list_mcp_") + || name.starts_with("read_mcp_") + || name.starts_with("get_mcp_") + { + ToolCategory::McpRead + } else if name.starts_with("mcp_") { + ToolCategory::McpAction + } else if matches!( + name, + "read_file" + | "list_dir" + | "todo_write" + | "todo_read" + | "note" + | "update_plan" + | "search" + | "file_search" + | "project" + | "diagnostics" + ) || name.starts_with("read_") + || name.starts_with("list_") + || name.starts_with("get_") + { ToolCategory::Safe + } else { + ToolCategory::Unknown + } +} + +fn param_preview(params: &Value, keys: &[&str], max_len: usize) -> Option { + let Value::Object(map) = params else { + return None; + }; + + for key in keys { + let Some(value) = map.get(*key) else { + continue; + }; + match value { + Value::String(text) => return Some(truncate_string_value(text, max_len)), + Value::Number(number) => return Some(number.to_string()), + Value::Bool(flag) => return Some(flag.to_string()), + Value::Array(items) if !items.is_empty() => { + let preview = items + .iter() + .take(3) + .map(|item| match item { + Value::String(text) => truncate_string_value(text, max_len / 2), + other => truncate_string_value(&other.to_string(), max_len / 2), + }) + .collect::>() + .join(", "); + return Some(truncate_string_value(&preview, max_len)); + } + other => return Some(truncate_string_value(&other.to_string(), max_len)), + } + } + + None +} + +fn mcp_server_hint(tool_name: &str) -> Option { + let remainder = tool_name.strip_prefix("mcp_")?; + let (server, _) = remainder.split_once('_')?; + if server.is_empty() { + None + } else { + Some(server.to_string()) + } +} + +fn build_impact_summary(tool_name: &str, category: ToolCategory, params: &Value) -> Vec { + match category { + ToolCategory::Safe => { + let mut impacts = vec!["Read-only operation.".to_string()]; + if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) { + impacts.push(format!("Reads: {path}")); + } + impacts + } + ToolCategory::FileWrite => { + let mut impacts = + vec!["Writes files in the workspace or an approved write scope.".to_string()]; + if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) { + impacts.push(format!("Writes: {path}")); + } + impacts + } + ToolCategory::Shell => { + let mut impacts = vec!["Executes a shell command.".to_string()]; + if let Some(command) = param_preview(params, &["cmd", "command"], 96) { + impacts.push(format!("Command: {command}")); + } + if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 72) { + impacts.push(format!("Working dir: {workdir}")); + } + impacts + } + ToolCategory::Network => { + let mut impacts = vec!["May reach network services or remote content.".to_string()]; + if let Some(target) = + param_preview(params, &["url", "q", "query", "location", "repo"], 96) + { + impacts.push(format!("Target: {target}")); + } + impacts + } + ToolCategory::McpRead => { + let mut impacts = + vec!["Reads from an MCP server without an obvious local write.".to_string()]; + if let Some(server) = mcp_server_hint(tool_name) { + impacts.push(format!("Server: {server}")); + } + impacts + } + ToolCategory::McpAction => { + let mut impacts = + vec!["Calls an MCP server action that may have side effects.".to_string()]; + if let Some(server) = mcp_server_hint(tool_name) { + impacts.push(format!("Server: {server}")); + } + impacts + } + ToolCategory::Unknown => { + let mut impacts = vec![ + "Tool is not classified. Review params carefully before approving.".to_string(), + ]; + if let Some(target) = param_preview( + params, + &["path", "cmd", "command", "url", "q", "query", "ref_id"], + 96, + ) { + impacts.push(format!("Primary input: {target}")); + } + impacts + } } } @@ -258,9 +407,9 @@ impl ElevationOption { /// Get the display label for this option. pub fn label(&self) -> &'static str { match self { - ElevationOption::WithNetwork => "Allow network access", - ElevationOption::WithWriteAccess(_) => "Allow write access", - ElevationOption::FullAccess => "Full access (no sandbox)", + ElevationOption::WithNetwork => "Allow outbound network", + ElevationOption::WithWriteAccess(_) => "Allow extra write access", + ElevationOption::FullAccess => "Full access (filesystem + network)", ElevationOption::Abort => "Abort", } } @@ -268,9 +417,15 @@ impl ElevationOption { /// Get a short description. pub fn description(&self) -> &'static str { match self { - ElevationOption::WithNetwork => "Retry with outbound network connections enabled", - ElevationOption::WithWriteAccess(_) => "Retry with additional writable paths", - ElevationOption::FullAccess => "Retry without any sandbox restrictions (dangerous!)", + ElevationOption::WithNetwork => { + "Retry this tool call with outbound network access for downloads and HTTP requests" + } + ElevationOption::WithWriteAccess(_) => { + "Retry this tool call with additional writable filesystem scope" + } + ElevationOption::FullAccess => { + "Retry without sandbox limits; grants unrestricted filesystem and network access" + } ElevationOption::Abort => "Cancel this tool execution", } } @@ -470,7 +625,6 @@ mod tests { assert_eq!(get_tool_category("todo_read"), ToolCategory::Safe); assert_eq!(get_tool_category("note"), ToolCategory::Safe); assert_eq!(get_tool_category("update_plan"), ToolCategory::Safe); - assert_eq!(get_tool_category("unknown_tool"), ToolCategory::Safe); } #[test] @@ -485,8 +639,16 @@ mod tests { fn test_get_tool_category_shell_tools() { // Shell execution tools should be Shell assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell); - assert_eq!(get_tool_category("mcp_tool"), ToolCategory::Shell); - assert_eq!(get_tool_category("list_mcp_tools"), ToolCategory::Shell); + assert_eq!( + get_tool_category("mcp_linear_save_issue"), + ToolCategory::McpAction + ); + assert_eq!(get_tool_category("list_mcp_tools"), ToolCategory::McpRead); + } + + #[test] + fn test_get_tool_category_unknown_tools_need_review() { + assert_eq!(get_tool_category("unknown_tool"), ToolCategory::Unknown); } // ======================================================================== @@ -496,7 +658,8 @@ mod tests { #[test] fn test_approval_request_new() { let params = json!({"path": "src/main.rs", "content": "test"}); - let request = ApprovalRequest::new("test-id", "write_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "write_file", "Write a file to disk", ¶ms); assert_eq!(request.id, "test-id"); assert_eq!(request.tool_name, "write_file"); @@ -509,7 +672,8 @@ mod tests { // Create params with a very long string let long_content = "x".repeat(300); let params = json!({"path": "src/main.rs", "content": long_content}); - let request = ApprovalRequest::new("test-id", "write_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "write_file", "Write a file to disk", ¶ms); let display = request.params_display(); // Should be truncated to around 200 chars @@ -520,12 +684,33 @@ mod tests { #[test] fn test_approval_request_params_display_short() { let params = json!({"path": "src/main.rs"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let display = request.params_display(); assert!(display.contains("src/main.rs")); } + #[test] + fn test_approval_request_derives_impact_summary() { + let params = json!({"cmd": "cargo test", "workdir": "/tmp/project"}); + let request = ApprovalRequest::new("test-id", "exec_shell", "Run a shell command", ¶ms); + + assert_eq!(request.category, ToolCategory::Shell); + assert!( + request + .impacts + .iter() + .any(|line| line.contains("Executes a shell command")) + ); + assert!( + request + .impacts + .iter() + .any(|line| line.contains("cargo test")) + ); + } + // ======================================================================== // ApprovalView Tests // ======================================================================== @@ -533,7 +718,8 @@ mod tests { #[test] fn test_approval_view_initial_state() { let params = json!({"path": "src/main.rs"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let view = ApprovalView::new(request.clone()); assert_eq!(view.selected, 0); @@ -543,7 +729,8 @@ mod tests { #[test] fn test_approval_view_navigation() { let params = json!({"path": "src/main.rs"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let mut view = ApprovalView::new(request); // Initially at 0 @@ -571,7 +758,8 @@ mod tests { #[test] fn test_approval_view_keybindings_decisions() { let params = json!({"path": "src/main.rs"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let mut view = ApprovalView::new(request.clone()); // Test 'y' -> Approved @@ -621,7 +809,8 @@ mod tests { #[test] fn test_approval_view_enter_uses_selected_option() { let params = json!({"path": "src/main.rs"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let mut view = ApprovalView::new(request); // Navigate to index 2 (Denied) @@ -643,7 +832,8 @@ mod tests { #[test] fn test_approval_view_navigation_keys() { let params = json!({"path": "src/main.rs"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let mut view = ApprovalView::new(request); // Test Up arrow @@ -666,7 +856,8 @@ mod tests { #[test] fn test_approval_view_view_params() { let params = json!({"path": "src/main.rs", "content": "test"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let mut view = ApprovalView::new(request.clone()); // Test 'v' to view params @@ -688,7 +879,8 @@ mod tests { #[test] fn test_approval_view_current_decision_mapping() { let params = json!({"path": "src/main.rs"}); - let request = ApprovalRequest::new("test-id", "read_file", ¶ms); + let request = + ApprovalRequest::new("test-id", "read_file", "Read a file from disk", ¶ms); let mut view = ApprovalView::new(request); // Index 0 -> Approved @@ -840,10 +1032,13 @@ mod tests { #[test] fn test_elevation_option_labels() { - assert_eq!(ElevationOption::WithNetwork.label(), "Allow network access"); + assert_eq!( + ElevationOption::WithNetwork.label(), + "Allow outbound network" + ); assert_eq!( ElevationOption::FullAccess.label(), - "Full access (no sandbox)" + "Full access (filesystem + network)" ); assert!( ElevationOption::WithWriteAccess(vec![]) @@ -863,7 +1058,7 @@ mod tests { assert!( ElevationOption::FullAccess .description() - .contains("dangerous") + .contains("filesystem and network access") ); assert!(ElevationOption::Abort.description().contains("Cancel")); } diff --git a/src/tui/clipboard.rs b/crates/tui/src/tui/clipboard.rs similarity index 100% rename from src/tui/clipboard.rs rename to crates/tui/src/tui/clipboard.rs diff --git a/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs similarity index 86% rename from src/tui/command_palette.rs rename to crates/tui/src/tui/command_palette.rs index b50f62f9..48bd1bb6 100644 --- a/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -19,7 +19,7 @@ use crate::skills::SkillRegistry; use crate::tools::spec::ApprovalRequirement; use crate::tools::spec::ToolCapability; use crate::tools::{ToolContext, ToolRegistryBuilder}; -use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; +use crate::tui::views::{CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum PaletteSection { @@ -34,6 +34,7 @@ pub struct CommandPaletteEntry { pub label: String, pub description: String, pub command: String, + pub action: CommandPaletteAction, } pub struct CommandPaletteView { @@ -52,11 +53,21 @@ pub fn build_entries(skills_dir: &Path, workspace: &Path) -> Vec Vec Vec>(); @@ -183,6 +201,60 @@ fn section_rank(section: PaletteSection) -> usize { } } +fn command_runs_directly(name: &str) -> bool { + matches!( + name, + "help" + | "clear" + | "exit" + | "model" + | "models" + | "queue" + | "subagents" + | "links" + | "home" + | "save" + | "sessions" + | "compact" + | "export" + | "config" + | "yolo" + | "normal" + | "agent" + | "plan" + | "trust" + | "logout" + | "tokens" + | "system" + | "context" + | "undo" + | "retry" + | "init" + | "settings" + | "skills" + | "cost" + | "task" + ) +} + +fn format_tool_details(name: &str, description: &str, tags: &[&str]) -> String { + let mut lines = vec![ + format!("Tool: {name}"), + String::new(), + description.to_string(), + ]; + if !tags.is_empty() { + lines.push(String::new()); + lines.push(format!("Capabilities: {}", tags.join(", "))); + } + lines.push(String::new()); + lines.push( + "Use slash commands and skills here for direct actions; use tool entries to inspect what the agent can call." + .to_string(), + ); + lines.join("\n") +} + fn term_score(term: &str, label: &str, description: &str, command: &str, haystack: &str) -> usize { if term.is_empty() { return 0; @@ -359,7 +431,7 @@ impl ModalView for CommandPaletteView { KeyCode::Enter => { if let Some(entry) = self.selected_entry() { ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected { - command: entry.command.clone(), + action: entry.action.clone(), }) } else { ViewAction::None @@ -506,7 +578,7 @@ impl ModalView for CommandPaletteView { .title(" Command Palette ") .title_bottom(Line::from(vec![ Span::styled(" ↑/↓/j/k move ", Style::default().fg(palette::TEXT_MUTED)), - Span::styled("Enter insert ", Style::default().fg(palette::TEXT_MUTED)), + Span::styled("Enter run/open ", Style::default().fg(palette::TEXT_MUTED)), Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)), ])); @@ -533,6 +605,9 @@ mod tests { label: label.to_string(), description: description.to_string(), command: command.to_string(), + action: CommandPaletteAction::InsertText { + text: command.to_string(), + }, } } @@ -649,4 +724,26 @@ mod tests { assert!(!command_labels.contains(&"/set")); assert!(!command_labels.contains(&"/deepseek")); } + + #[test] + fn command_palette_emits_actions_not_raw_insertions() { + let entries = vec![CommandPaletteEntry { + section: PaletteSection::Command, + label: "/config".to_string(), + description: "open config".to_string(), + command: "/config".to_string(), + action: CommandPaletteAction::ExecuteCommand { + command: "/config".to_string(), + }, + }]; + let mut view = CommandPaletteView::new(entries); + + let action = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + assert!(matches!( + action, + ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected { + action: CommandPaletteAction::ExecuteCommand { .. } + }) + )); + } } diff --git a/src/tui/diff_render.rs b/crates/tui/src/tui/diff_render.rs similarity index 100% rename from src/tui/diff_render.rs rename to crates/tui/src/tui/diff_render.rs diff --git a/src/tui/event_broker.rs b/crates/tui/src/tui/event_broker.rs similarity index 100% rename from src/tui/event_broker.rs rename to crates/tui/src/tui/event_broker.rs diff --git a/src/tui/history.rs b/crates/tui/src/tui/history.rs similarity index 93% rename from src/tui/history.rs rename to crates/tui/src/tui/history.rs index 6212909d..cf322244 100644 --- a/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -27,10 +27,21 @@ const TOOL_STATUS_SYMBOL_MS: u64 = 900; /// Renderable history cell for user/assistant/system entries. #[derive(Debug, Clone)] pub enum HistoryCell { - User { content: String }, - Assistant { content: String, streaming: bool }, - System { content: String }, - Thinking { content: String, streaming: bool }, + User { + content: String, + }, + Assistant { + content: String, + streaming: bool, + }, + System { + content: String, + }, + Thinking { + content: String, + streaming: bool, + duration_secs: Option, + }, Tool(ToolCell), } @@ -53,16 +64,18 @@ impl HistoryCell { /// Render the cell into a set of terminal lines. pub fn lines(&self, width: u16) -> Vec> { match self { - HistoryCell::User { content } => render_message("You", content, user_style(), width), + HistoryCell::User { content } => render_message("▸ You", content, user_style(), width), HistoryCell::Assistant { content, .. } => { - render_message("Answer", content, assistant_style(), width) + render_message("◆ Answer", content, assistant_style(), width) } HistoryCell::System { content } => { - render_message("System", content, system_style(), width) - } - HistoryCell::Thinking { content, streaming } => { - render_thinking(content, width, *streaming) + render_message("● System", content, system_style(), width) } + HistoryCell::Thinking { + content, + streaming, + duration_secs, + } => render_thinking(content, width, *streaming, *duration_secs), HistoryCell::Tool(cell) => cell.lines(width), } } @@ -100,6 +113,14 @@ impl HistoryCell { } ) } + + #[must_use] + pub fn is_conversational(&self) -> bool { + matches!( + self, + HistoryCell::User { .. } | HistoryCell::Assistant { .. } | HistoryCell::Thinking { .. } + ) + } } /// Convert a message into history cells for rendering. @@ -159,6 +180,7 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { cells.push(HistoryCell::Thinking { content: thinking.clone(), streaming: false, + duration_secs: None, }); } } @@ -1021,38 +1043,79 @@ pub fn extract_reasoning_summary(text: &str) -> Option { } } -fn render_thinking(content: &str, width: u16, streaming: bool) -> Vec> { +fn render_thinking( + content: &str, + width: u16, + streaming: bool, + duration_secs: Option, +) -> Vec> { let style = thinking_style(); - let prefix = "┆ "; + let border_style = Style::default().fg(palette::STATUS_WARNING); + let w = usize::from(width); + + // Top border: ┌─ THINKING (3.2s) ───────┐ let label = if streaming { - "[THINKING LIVE]" + " THINKING ".to_string() + } else if let Some(dur) = duration_secs { + format!(" THINKING ({dur:.1}s) ") } else { - "[THINKING]" + " THINKING ".to_string() + }; + let label_width = UnicodeWidthStr::width(label.as_str()); + let fill = w.saturating_sub(2 + label_width); // 2 for ┌ and ┐ + let top_line = format!("┌{}{}{}", "─".repeat(0), label, "─".repeat(fill)); + let top_line = if top_line.chars().count() < w { + format!("{}┐", &top_line) + } else { + truncate_to_width(&top_line, w) }; - let content_width = usize::from(width.saturating_sub(2).max(1)); - let rendered = markdown_render::render_markdown(content, content_width as u16, style); - let mut lines = Vec::new(); - lines.push(Line::from(Span::styled( - label, - Style::default() - .fg(palette::STATUS_WARNING) - .add_modifier(Modifier::BOLD), - ))); + lines.push(Line::from(Span::styled(top_line, border_style))); + + // Content with │ prefix + let content_width = width.saturating_sub(4).max(1); // 4 for "│ " and " │" + let rendered = markdown_render::render_markdown(content, content_width, style); + + if rendered.is_empty() && streaming { + // Show animated placeholder while waiting for first content + lines.push(Line::from(vec![ + Span::styled("│ ", border_style), + Span::styled("...", style), + ])); + } for line in rendered { - let mut spans = vec![Span::styled(prefix, style)]; + let mut spans = vec![Span::styled("│ ", border_style)]; spans.extend(line.spans); lines.push(Line::from(spans)); } - if lines.is_empty() { - lines.push(Line::from(vec![Span::styled(prefix, style)])); - } + // Bottom border: └────────────────────────┘ + let bottom_fill = w.saturating_sub(2); // 2 for └ and ┘ + let bottom_line = format!("└{}┘", "─".repeat(bottom_fill)); + lines.push(Line::from(Span::styled( + truncate_to_width(&bottom_line, w), + border_style, + ))); + lines } +fn truncate_to_width(s: &str, max_width: usize) -> String { + let mut out = String::new(); + let mut w = 0; + for ch in s.chars() { + let cw = UnicodeWidthChar::width(ch).unwrap_or(0); + if w + cw > max_width { + break; + } + out.push(ch); + w += cw; + } + out +} + fn render_message(prefix: &str, content: &str, style: Style, width: u16) -> Vec> { let prefix_width = UnicodeWidthStr::width(prefix); let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX); diff --git a/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs similarity index 100% rename from src/tui/markdown_render.rs rename to crates/tui/src/tui/markdown_render.rs diff --git a/src/tui/mod.rs b/crates/tui/src/tui/mod.rs similarity index 100% rename from src/tui/mod.rs rename to crates/tui/src/tui/mod.rs diff --git a/src/tui/onboarding/api_key.rs b/crates/tui/src/tui/onboarding/api_key.rs similarity index 92% rename from src/tui/onboarding/api_key.rs rename to crates/tui/src/tui/onboarding/api_key.rs index 8ac5cfe7..ae07ae51 100644 --- a/src/tui/onboarding/api_key.rs +++ b/crates/tui/src/tui/onboarding/api_key.rs @@ -27,6 +27,10 @@ pub fn lines(app: &App) -> Vec> { "Paste the full key exactly as issued (no spaces/newlines).", Style::default().fg(palette::TEXT_MUTED), )), + Line::from(Span::styled( + "Unusual-looking formats warn, but setup only blocks clearly broken input.", + Style::default().fg(palette::TEXT_MUTED), + )), Line::from(""), ]; diff --git a/src/tui/onboarding/mod.rs b/crates/tui/src/tui/onboarding/mod.rs similarity index 77% rename from src/tui/onboarding/mod.rs rename to crates/tui/src/tui/onboarding/mod.rs index 1584b95a..4e89941f 100644 --- a/src/tui/onboarding/mod.rs +++ b/crates/tui/src/tui/onboarding/mod.rs @@ -88,29 +88,46 @@ pub fn tips_lines() -> Vec> { vec![ Line::from(Span::styled( - "Quick Tips", + "Start With These Workflows", + Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::raw(" 1. Pick a mode for the task:")), + Line::from(Span::raw( + " Normal asks questions, Agent runs tools, Plan lets you review the approach first.", + )), + Line::from(Span::raw(" 2. Watch the runtime state while work runs:")), + Line::from(Span::raw( + " approvals, queued prompts, and active sub-agents stay visible in the status area.", + )), + Line::from(Span::raw( + " 3. Use /queue when you want to review or edit queued prompts.", + )), + Line::from(Span::raw( + " 4. Use /subagents or the status strip to inspect agent fan-out.", + )), + Line::from(Span::raw( + " 5. Use Ctrl+R or /sessions to resume interrupted work.", + )), + Line::from(""), + Line::from(Span::styled( + "Controls", Style::default() .fg(palette::DEEPSEEK_SKY) .add_modifier(Modifier::BOLD), )), Line::from(""), Line::from(Span::raw( - " - Tab cycles modes (Normal → Agent → YOLO → Plan), Shift+Tab reverses", + " - F1 help, Ctrl+K command palette, Esc cancel current work", )), Line::from(Span::raw( - " - Alt+1/2/3/4 switch modes (Normal/Agent/YOLO/Plan)", + " - Tab cycles modes, Alt+1/2/3/4 switches directly", )), Line::from(Span::raw( - " - Alt+!/@/#/$/) focus sidebar sections (Plan/Todos/Tasks/Agents/Auto)", + " - Alt+!/@/#/$/) focuses Plan/Todos/Tasks/Agents/Auto", )), - Line::from(Span::raw(" - Ctrl+R opens the session picker")), - Line::from(Span::raw(" - l opens the pager for the last message")), - Line::from(Span::raw(" - Ctrl+C cancels or exits")), - Line::from(Span::raw(" - /help lists all commands")), - Line::from(Span::raw( - " - Start with /config or /model for a quick check", - )), - Line::from(""), Line::from(vec![ Span::styled("Press ", Style::default().fg(palette::TEXT_MUTED)), Span::styled( @@ -120,7 +137,7 @@ pub fn tips_lines() -> Vec> { .add_modifier(Modifier::BOLD), ), Span::styled( - " to start chatting", + " to start working", Style::default().fg(palette::TEXT_MUTED), ), ]), diff --git a/src/tui/onboarding/trust_directory.rs b/crates/tui/src/tui/onboarding/trust_directory.rs similarity index 90% rename from src/tui/onboarding/trust_directory.rs rename to crates/tui/src/tui/onboarding/trust_directory.rs index 123e59a1..3d51107a 100644 --- a/src/tui/onboarding/trust_directory.rs +++ b/crates/tui/src/tui/onboarding/trust_directory.rs @@ -25,11 +25,11 @@ pub fn lines(app: &App) -> Vec> { ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Y = allow tools/agents to read outside this workspace when needed.", + "Y = let reviews, searches, and agents reach outside this workspace when a task needs it.", Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from(Span::styled( - "N = keep access scoped to this workspace only (safer default).", + "N = keep file access scoped to this workspace and review approvals case by case.", Style::default().fg(palette::TEXT_MUTED), ))); if let Some(message) = app.status_message.as_deref() { diff --git a/src/tui/onboarding/welcome.rs b/crates/tui/src/tui/onboarding/welcome.rs similarity index 87% rename from src/tui/onboarding/welcome.rs rename to crates/tui/src/tui/onboarding/welcome.rs index 18f660a2..97253abe 100644 --- a/src/tui/onboarding/welcome.rs +++ b/crates/tui/src/tui/onboarding/welcome.rs @@ -33,7 +33,7 @@ pub fn lines() -> Vec> { lines.push(Line::from(vec![ Span::styled("Welcome to ", Style::default().fg(palette::TEXT_PRIMARY)), Span::styled( - "DeepSeek CLI", + "DeepSeek TUI", Style::default() .fg(palette::DEEPSEEK_BLUE) .add_modifier(Modifier::BOLD), @@ -45,7 +45,11 @@ pub fn lines() -> Vec> { ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Unofficial CLI for the DeepSeek API", + "Agent workflows for the DeepSeek API in your terminal", + Style::default().fg(palette::TEXT_MUTED), + ))); + lines.push(Line::from(Span::styled( + "Set up your key, pick a mode, and watch approvals, queue state, and agents as work runs.", Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from(Span::styled( @@ -54,11 +58,11 @@ pub fn lines() -> Vec> { ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( - "Press Enter to continue setup.", + "Press Enter to start setup.", Style::default().fg(palette::TEXT_PRIMARY), ))); lines.push(Line::from(Span::styled( - "Press Ctrl+C to exit.", + "Ctrl+C exits at any point.", Style::default().fg(palette::TEXT_MUTED), ))); diff --git a/src/tui/pager.rs b/crates/tui/src/tui/pager.rs similarity index 100% rename from src/tui/pager.rs rename to crates/tui/src/tui/pager.rs diff --git a/src/tui/paste_burst.rs b/crates/tui/src/tui/paste_burst.rs similarity index 100% rename from src/tui/paste_burst.rs rename to crates/tui/src/tui/paste_burst.rs diff --git a/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs similarity index 98% rename from src/tui/plan_prompt.rs rename to crates/tui/src/tui/plan_prompt.rs index eaa270f9..5d4a3f99 100644 --- a/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -107,7 +107,7 @@ impl ModalView for PlanPromptView { Self::submit_number(number) } KeyCode::Enter => self.submit_selected(), - KeyCode::Esc => ViewAction::Close, + KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed), _ => ViewAction::None, } } diff --git a/src/tui/scrolling.rs b/crates/tui/src/tui/scrolling.rs similarity index 100% rename from src/tui/scrolling.rs rename to crates/tui/src/tui/scrolling.rs diff --git a/src/tui/selection.rs b/crates/tui/src/tui/selection.rs similarity index 100% rename from src/tui/selection.rs rename to crates/tui/src/tui/selection.rs diff --git a/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs similarity index 100% rename from src/tui/session_picker.rs rename to crates/tui/src/tui/session_picker.rs diff --git a/src/tui/streaming.rs b/crates/tui/src/tui/streaming.rs similarity index 86% rename from src/tui/streaming.rs rename to crates/tui/src/tui/streaming.rs index c2eb0621..1406e823 100644 --- a/src/tui/streaming.rs +++ b/crates/tui/src/tui/streaming.rs @@ -60,51 +60,57 @@ impl MarkdownStreamCollector { /// Only lines ending with '\n' are committed. /// Returns the newly committed lines since last call. pub fn commit_complete_lines(&mut self) -> Vec> { - if self.buffer.is_empty() { + let committed = self.commit_complete_text(); + if committed.is_empty() { return Vec::new(); } + self.render_lines(&committed) + } + + /// Commit complete text chunks ending in a newline. + /// Returns the raw text that became visible since the last call. + pub fn commit_complete_text(&mut self) -> String { + if self.buffer.is_empty() { + return String::new(); + } // Find the last newline - only process up to there let Some(last_newline_idx) = self.buffer.rfind('\n') else { - return Vec::new(); // No complete lines yet + return String::new(); // No complete lines yet }; // Extract the complete portion (up to and including last newline) let complete_portion = self.buffer[..=last_newline_idx].to_string(); - // Render all lines from the complete portion - let all_lines = self.render_lines(&complete_portion); - // Remove the committed portion from the buffer so finalize only emits the remainder self.buffer = self.buffer[last_newline_idx + 1..].to_string(); self.committed_line_count = 0; - all_lines + complete_portion } /// Finalize the stream and return any remaining content. /// Call this when the stream ends to emit the final incomplete line. pub fn finalize(&mut self) -> Vec> { + let remaining = self.finalize_text(); + if remaining.is_empty() { + return Vec::new(); + } + self.render_lines(&remaining) + } + + /// Finalize the stream and return any remaining raw text. + pub fn finalize_text(&mut self) -> String { self.is_streaming = false; if self.buffer.is_empty() { - return Vec::new(); + return String::new(); } - // Render all remaining content - let all_lines = self.render_lines(&self.buffer); - - // Return only the NEW lines since last commit - let new_lines = if self.committed_line_count < all_lines.len() { - all_lines[self.committed_line_count..].to_vec() - } else { - Vec::new() - }; - - // Mark as fully committed - self.committed_line_count = all_lines.len(); - - new_lines + let remaining = self.buffer.clone(); + self.buffer.clear(); + self.committed_line_count = 0; + remaining } /// Get all rendered lines (for final display after stream ends) @@ -254,6 +260,15 @@ impl StreamingState { } } + /// Get newly committed raw text from a block. + pub fn commit_text(&mut self, index: usize) -> String { + if let Some(Some(collector)) = self.collectors.get_mut(index) { + collector.commit_complete_text() + } else { + String::new() + } + } + /// Finalize a block and get remaining lines pub fn finalize_block(&mut self, index: usize) -> Vec> { if let Some(Some(collector)) = self.collectors.get_mut(index) { @@ -266,6 +281,17 @@ impl StreamingState { } } + /// Finalize a block and get remaining raw text. + pub fn finalize_block_text(&mut self, index: usize) -> String { + if let Some(Some(collector)) = self.collectors.get_mut(index) { + let text = collector.finalize_text(); + self.check_active(); + text + } else { + String::new() + } + } + /// Finalize all blocks pub fn finalize_all(&mut self) -> Vec<(usize, Vec>)> { let mut result = Vec::new(); diff --git a/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs similarity index 92% rename from src/tui/transcript.rs rename to crates/tui/src/tui/transcript.rs index c23bef85..4a48aa81 100644 --- a/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -61,7 +61,11 @@ impl TranscriptViewCache { }); } - if cell_index + 1 < cells.len() && !cell.is_stream_continuation() { + if cell_index + 1 < cells.len() + && !cell.is_stream_continuation() + && cell.is_conversational() + && cells[cell_index + 1].is_conversational() + { // Add subtle horizontal separator between messages let separator = Span::styled( "─".repeat(usize::from(width)), diff --git a/src/tui/ui.rs b/crates/tui/src/tui/ui.rs similarity index 86% rename from src/tui/ui.rs rename to crates/tui/src/tui/ui.rs index d26af330..ccbe576d 100644 --- a/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -97,12 +97,14 @@ const UI_TYPING_INDICATOR_MS: u64 = 120; const UI_STATUS_ANIMATION_MS: u64 = UI_DEEPSEEK_SQUIGGLE_MS; const MAX_ACTIVE_AGENT_STATUS_ROWS: usize = 2; const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15; +const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100; #[derive(Debug, Clone, PartialEq, Eq)] struct StatusLayoutPlan { status_height: u16, queued_preview: Vec, queued_compacted: bool, + compact_runtime_summary: bool, } /// Run the interactive TUI event loop. @@ -342,6 +344,8 @@ async fn run_event_loop( match event { EngineEvent::MessageStarted { .. } => { current_streaming_text.clear(); + app.streaming_state.reset(); + app.streaming_state.start_text(0, None); app.streaming_message_index = None; } EngineEvent::MessageDelta { content, .. } => { @@ -350,31 +354,25 @@ async fn run_event_loop( continue; } current_streaming_text.push_str(&sanitized); - let index = if let Some(index) = app.streaming_message_index { - index - } else { - app.add_message(HistoryCell::Assistant { - content: String::new(), - streaming: true, - }); - let index = app.history.len().saturating_sub(1); - app.streaming_message_index = Some(index); - index - }; - - if let Some(cell) = app.history.get_mut(index) { - if let HistoryCell::Assistant { content, .. } = cell { - content.push_str(&sanitized); - } - transcript_batch_updated = true; + let index = + ensure_streaming_history_cell(app, StreamingCellKind::Assistant); + app.streaming_state.push_content(0, &sanitized); + let committed = app.streaming_state.commit_text(0); + if !committed.is_empty() { + append_streaming_text(app, index, &committed); } } EngineEvent::MessageComplete { .. } => { - if let Some(index) = app.streaming_message_index.take() - && let Some(HistoryCell::Assistant { streaming, .. }) = + if let Some(index) = app.streaming_message_index.take() { + let remaining = app.streaming_state.finalize_block_text(0); + if !remaining.is_empty() { + append_streaming_text(app, index, &remaining); + } + if let Some(HistoryCell::Assistant { streaming, .. }) = app.history.get_mut(index) - { - *streaming = false; + { + *streaming = false; + } transcript_batch_updated = true; } @@ -417,12 +415,10 @@ async fn run_event_loop( EngineEvent::ThinkingStarted { .. } => { app.reasoning_buffer.clear(); app.reasoning_header = None; - - app.add_message(HistoryCell::Thinking { - content: String::new(), - streaming: true, - }); - app.streaming_message_index = Some(app.history.len().saturating_sub(1)); + app.thinking_started_at = Some(Instant::now()); + app.streaming_state.reset(); + app.streaming_state.start_thinking(0, None); + let _ = ensure_streaming_history_cell(app, StreamingCellKind::Thinking); } EngineEvent::ThinkingDelta { content, .. } => { let sanitized = sanitize_stream_chunk(&content); @@ -434,20 +430,32 @@ async fn run_event_loop( app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer); } - if let Some(index) = app.streaming_message_index - && let Some(HistoryCell::Thinking { content: c, .. }) = - app.history.get_mut(index) - { - c.push_str(&sanitized); - transcript_batch_updated = true; + let index = ensure_streaming_history_cell(app, StreamingCellKind::Thinking); + app.streaming_state.push_content(0, &sanitized); + let committed = app.streaming_state.commit_text(0); + if !committed.is_empty() { + append_streaming_text(app, index, &committed); } } EngineEvent::ThinkingComplete { .. } => { - if let Some(index) = app.streaming_message_index.take() - && let Some(HistoryCell::Thinking { streaming, .. }) = - app.history.get_mut(index) - { - *streaming = false; + let duration = app + .thinking_started_at + .take() + .map(|t| t.elapsed().as_secs_f32()); + if let Some(index) = app.streaming_message_index.take() { + let remaining = app.streaming_state.finalize_block_text(0); + if !remaining.is_empty() { + append_streaming_text(app, index, &remaining); + } + if let Some(HistoryCell::Thinking { + streaming, + duration_secs, + .. + }) = app.history.get_mut(index) + { + *streaming = false; + *duration_secs = duration; + } transcript_batch_updated = true; } @@ -488,6 +496,8 @@ async fn run_event_loop( app.is_loading = true; app.offline_mode = false; current_streaming_text.clear(); + app.streaming_state.reset(); + app.streaming_message_index = None; app.turn_started_at = Some(Instant::now()); app.runtime_turn_id = Some(turn_id); app.runtime_turn_status = Some("in_progress".to_string()); @@ -506,6 +516,7 @@ async fn run_event_loop( } => { app.is_loading = false; app.offline_mode = false; + app.streaming_state.reset(); app.turn_started_at = None; app.runtime_turn_status = Some(match status { crate::core::events::TurnOutcomeStatus::Completed => { @@ -560,6 +571,8 @@ async fn run_event_loop( } } EngineEvent::Error { message, .. } => { + app.streaming_state.reset(); + app.streaming_message_index = None; app.add_message(HistoryCell::System { content: format!("Error: {message}"), }); @@ -573,12 +586,15 @@ async fn run_event_loop( app.status_message = Some(message); } EngineEvent::CompactionStarted { message, .. } => { + app.is_compacting = true; app.status_message = Some(message); } EngineEvent::CompactionCompleted { message, .. } => { + app.is_compacting = false; app.status_message = Some(message); } EngineEvent::CompactionFailed { message, .. } => { + app.is_compacting = false; app.status_message = Some(message); } EngineEvent::CapacityDecision { @@ -625,9 +641,8 @@ async fn run_event_loop( if app.agent_activity_started_at.is_none() { app.agent_activity_started_at = Some(Instant::now()); } - app.add_message(HistoryCell::System { - content: format!("Sub-agent {id} spawned: {}", prompt_summary), - }); + app.status_message = + Some(format!("Sub-agent {id} starting: {prompt_summary}")); let _ = engine_handle.send(Op::ListSubAgents).await; } EngineEvent::AgentProgress { id, status } => { @@ -640,12 +655,10 @@ async fn run_event_loop( } EngineEvent::AgentComplete { id, result } => { app.agent_progress.remove(&id); - app.add_message(HistoryCell::System { - content: format!( - "Sub-agent {id} completed: {}", - summarize_tool_output(&result) - ), - }); + app.status_message = Some(format!( + "Sub-agent {id} completed: {}", + summarize_tool_output(&result) + )); let _ = engine_handle.send(Op::ListSubAgents).await; } EngineEvent::AgentList { agents } => { @@ -686,11 +699,8 @@ async fn run_event_loop( }), ); let _ = engine_handle.deny_tool_call(id.clone()).await; - app.add_message(HistoryCell::System { - content: format!( - "Blocked tool '{tool_name}' (approval_mode=never)" - ), - }); + app.status_message = + Some(format!("Blocked tool '{tool_name}' (approval_mode=never)")); } else { let tool_input = app .pending_tool_uses @@ -704,7 +714,8 @@ async fn run_event_loop( } // Create approval request and show overlay - let request = ApprovalRequest::new(&id, &tool_name, &tool_input); + let request = + ApprovalRequest::new(&id, &tool_name, &description, &tool_input); log_sensitive_event( "tool.approval.prompted", serde_json::json!({ @@ -715,18 +726,14 @@ async fn run_event_loop( }), ); app.view_stack.push(ApprovalView::new(request)); - app.add_message(HistoryCell::System { - content: format!( - "Approval required for tool '{tool_name}': {description}" - ), - }); + app.status_message = Some(format!( + "Approval required for '{tool_name}': {description}" + )); } } EngineEvent::UserInputRequired { id, request } => { app.view_stack.push(UserInputView::new(id.clone(), request)); - app.add_message(HistoryCell::System { - content: "User input requested".to_string(), - }); + app.status_message = Some("User input requested".to_string()); } EngineEvent::ToolCallProgress { id, output } => { app.status_message = @@ -778,9 +785,8 @@ async fn run_event_loop( blocked_write, ); app.view_stack.push(ElevationView::new(request)); - app.add_message(HistoryCell::System { - content: format!("Sandbox blocked {tool_name}: {denial_reason}"), - }); + app.status_message = + Some(format!("Sandbox blocked {tool_name}: {denial_reason}")); } } } @@ -817,11 +823,13 @@ async fn run_event_loop( if !events.is_empty() { app.needs_redraw = true; } - handle_view_events(app, &engine_handle, events).await; + if handle_view_events(app, config, &task_manager, &engine_handle, events).await? { + return Ok(()); + } } let has_running_agents = running_agent_count(app) > 0; - if (app.is_loading || has_running_agents) + if (app.is_loading || has_running_agents || app.is_compacting) && last_status_frame.elapsed() >= Duration::from_millis(UI_STATUS_ANIMATION_MS) { app.needs_redraw = true; @@ -843,7 +851,7 @@ async fn run_event_loop( app.needs_redraw = false; } - let mut poll_timeout = if app.is_loading || has_running_agents { + let mut poll_timeout = if app.is_loading || has_running_agents || app.is_compacting { Duration::from_millis(UI_ACTIVE_POLL_MS) } else { Duration::from_millis(UI_IDLE_POLL_MS) @@ -860,6 +868,7 @@ async fn run_event_loop( if app.onboarding == OnboardingState::ApiKey { // Paste into API key input app.insert_api_key_str(text); + sync_api_key_validation_status(app, false); } else { // Paste into main input if let Some(pending) = app.paste_burst.flush_before_modified_input() { @@ -921,12 +930,15 @@ async fn run_event_loop( } OnboardingState::ApiKey => { let key = app.api_key_input.trim().to_string(); - if let Some(warning) = validate_api_key_for_onboarding(&key) { - app.status_message = Some(warning); + if let ApiKeyValidation::Reject(message) = + validate_api_key_for_onboarding(&key) + { + app.status_message = Some(message); continue; } match app.submit_api_key() { Ok(_) => { + app.status_message = None; // Recreate the engine so it picks up the newly saved key // without requiring a full process restart. let _ = engine_handle.send(Op::Shutdown).await; @@ -982,15 +994,18 @@ async fn run_event_loop( } KeyCode::Backspace if app.onboarding == OnboardingState::ApiKey => { app.delete_api_key_char(); + sync_api_key_validation_status(app, false); } KeyCode::Char(c) if app.onboarding == OnboardingState::ApiKey => { app.insert_api_key_char(c); + sync_api_key_validation_status(app, false); } KeyCode::Char('v') | KeyCode::Char('V') if is_paste_shortcut(&key) && app.onboarding == OnboardingState::ApiKey => { // Cmd+V / Ctrl+V paste (bracketed paste handled above) app.paste_api_key_from_clipboard(); + sync_api_key_validation_status(app, false); } _ => {} } @@ -1028,7 +1043,9 @@ async fn run_event_loop( if !app.view_stack.is_empty() { let events = app.view_stack.handle_key(key); - handle_view_events(app, &engine_handle, events).await; + if handle_view_events(app, config, &task_manager, &engine_handle, events).await? { + return Ok(()); + } continue; } @@ -1159,6 +1176,7 @@ async fn run_event_loop( if app.is_loading { engine_handle.cancel(); app.is_loading = false; + app.streaming_state.reset(); app.status_message = Some("Request cancelled".to_string()); } else { let _ = engine_handle.send(Op::Shutdown).await; @@ -1171,19 +1189,21 @@ async fn run_event_loop( return Ok(()); } } - KeyCode::Esc => { - if slash_menu_open { - app.close_slash_menu(); - } else if app.is_loading { + KeyCode::Esc => match next_escape_action(app, slash_menu_open) { + EscapeAction::CloseSlashMenu => app.close_slash_menu(), + EscapeAction::CancelRequest => { engine_handle.cancel(); app.is_loading = false; + app.streaming_state.reset(); app.status_message = Some("Request cancelled".to_string()); - } else if !app.input.is_empty() { - app.clear_input(); - } else { - app.set_mode(AppMode::Normal); } - } + EscapeAction::DiscardQueuedDraft => { + app.queued_draft = None; + app.status_message = Some("Stopped editing queued message".to_string()); + } + EscapeAction::ClearInput => app.clear_input(), + EscapeAction::Noop => {} + }, KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => { app.scroll_up(3); } @@ -1263,180 +1283,16 @@ async fn run_event_loop( continue; } if input.starts_with('/') { - // Use the commands module for slash commands - let result = commands::execute(&input, app); - - // Handle command result - if let Some(msg) = result.message { - app.add_message(HistoryCell::System { content: msg }); - } - - if let Some(action) = result.action { - match action { - AppAction::Quit => { - let _ = engine_handle.send(Op::Shutdown).await; - return Ok(()); - } - AppAction::SaveSession(path) => { - app.status_message = - Some(format!("Session saved to {}", path.display())); - } - AppAction::LoadSession(path) => { - app.status_message = - Some(format!("Session loaded from {}", path.display())); - } - AppAction::SyncSession { - messages, - system_prompt, - model, - workspace, - } => { - let is_full_reset = - messages.is_empty() && system_prompt.is_none(); - let _ = engine_handle - .send(Op::SyncSession { - messages, - system_prompt, - model, - workspace, - }) - .await; - let _ = engine_handle - .send(Op::SetCompaction { - config: app.compaction_config(), - }) - .await; - if is_full_reset { - persist_session_snapshot(app); - clear_checkpoint(); - } - } - AppAction::SendMessage(content) => { - let queued = build_queued_message(app, content); - submit_or_steer_message(app, &engine_handle, queued) - .await?; - } - AppAction::ListSubAgents => { - let _ = engine_handle.send(Op::ListSubAgents).await; - } - AppAction::FetchModels => { - app.status_message = Some("Fetching models...".to_string()); - match fetch_available_models(config).await { - Ok(models) => { - app.add_message(HistoryCell::System { - content: format_available_models_message( - &app.model, &models, - ), - }); - app.status_message = Some(format!( - "Found {} model(s)", - models.len() - )); - } - Err(error) => { - app.add_message(HistoryCell::System { - content: format!( - "Failed to fetch models: {error}" - ), - }); - } - } - } - AppAction::UpdateCompaction(compaction) => { - let _ = engine_handle - .send(Op::SetCompaction { config: compaction }) - .await; - } - AppAction::OpenConfigView => { - if app.view_stack.top_kind() != Some(ModalKind::Config) { - app.view_stack.push(ConfigView::new_for_app(app)); - } - } - AppAction::CompactContext => { - app.status_message = - Some("Compacting context...".to_string()); - let _ = engine_handle.send(Op::CompactContext).await; - } - AppAction::TaskAdd { prompt } => { - let request = NewTaskRequest { - prompt: prompt.clone(), - model: Some(app.model.clone()), - workspace: Some(app.workspace.clone()), - mode: Some(task_mode_label(app.mode).to_string()), - allow_shell: Some(app.allow_shell), - trust_mode: Some(app.trust_mode), - auto_approve: Some(true), - }; - match task_manager.add_task(request).await { - Ok(task) => { - app.add_message(HistoryCell::System { - content: format!( - "Task queued: {} ({})", - task.id, - summarize_tool_output(&task.prompt) - ), - }); - app.status_message = - Some(format!("Queued {}", task.id)); - } - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Failed to queue task: {err}"), - }); - } - } - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); - } - AppAction::TaskList => { - let tasks = task_manager.list_tasks(Some(30)).await; - app.task_panel = tasks - .iter() - .cloned() - .map(task_summary_to_panel_entry) - .collect(); - app.add_message(HistoryCell::System { - content: format_task_list(&tasks), - }); - } - AppAction::TaskShow { id } => { - match task_manager.get_task(&id).await { - Ok(task) => open_task_pager(app, &task), - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Task lookup failed: {err}"), - }); - } - } - } - AppAction::TaskCancel { id } => { - match task_manager.cancel_task(&id).await { - Ok(task) => { - app.add_message(HistoryCell::System { - content: format!( - "Task {} status: {:?}", - task.id, task.status - ), - }); - } - Err(err) => { - app.add_message(HistoryCell::System { - content: format!("Task cancel failed: {err}"), - }); - } - } - app.task_panel = task_manager - .list_tasks(Some(10)) - .await - .into_iter() - .map(task_summary_to_panel_entry) - .collect(); - } - } + if execute_command_input( + app, + &engine_handle, + &task_manager, + config, + &input, + ) + .await? + { + return Ok(()); } } else { // Global @ file completion - works in any mode @@ -1785,7 +1641,7 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { u64::from(app.total_tokens), app.system_prompt.as_ref(), ); - updated.metadata.mode = Some(app.mode.label().to_string()); + updated.metadata.mode = Some(app.mode.as_setting().to_string()); updated } else { create_saved_session_with_mode( @@ -1794,7 +1650,7 @@ fn build_session_snapshot(app: &App, manager: &SessionManager) -> SavedSession { &app.workspace, u64::from(app.total_tokens), app.system_prompt.as_ref(), - Some(app.mode.label()), + Some(app.mode.as_setting()), ) } } @@ -1866,23 +1722,119 @@ fn sanitize_stream_chunk(chunk: &str) -> String { .collect() } -fn validate_api_key_for_onboarding(api_key: &str) -> Option { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StreamingCellKind { + Assistant, + Thinking, +} + +fn ensure_streaming_history_cell(app: &mut App, kind: StreamingCellKind) -> usize { + if let Some(index) = app.streaming_message_index { + return index; + } + + let cell = match kind { + StreamingCellKind::Assistant => HistoryCell::Assistant { + content: String::new(), + streaming: true, + }, + StreamingCellKind::Thinking => HistoryCell::Thinking { + content: String::new(), + streaming: true, + duration_secs: None, + }, + }; + app.add_message(cell); + let index = app.history.len().saturating_sub(1); + app.streaming_message_index = Some(index); + index +} + +fn append_streaming_text(app: &mut App, index: usize, text: &str) { + if text.is_empty() { + return; + } + + match app.history.get_mut(index) { + Some(HistoryCell::Assistant { content, .. }) + | Some(HistoryCell::Thinking { content, .. }) => { + content.push_str(text); + } + _ => {} + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EscapeAction { + CloseSlashMenu, + CancelRequest, + DiscardQueuedDraft, + ClearInput, + Noop, +} + +fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeAction { + if slash_menu_open { + EscapeAction::CloseSlashMenu + } else if app.is_loading { + EscapeAction::CancelRequest + } else if app.queued_draft.is_some() && app.input.is_empty() { + EscapeAction::DiscardQueuedDraft + } else if !app.input.is_empty() { + EscapeAction::ClearInput + } else { + EscapeAction::Noop + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ApiKeyValidation { + Accept { warning: Option }, + Reject(String), +} + +fn validate_api_key_for_onboarding(api_key: &str) -> ApiKeyValidation { let trimmed = api_key.trim(); if trimmed.is_empty() { - return Some("API key cannot be empty.".to_string()); + return ApiKeyValidation::Reject("API key cannot be empty.".to_string()); } if trimmed.contains(char::is_whitespace) { - return Some("API key appears malformed (contains whitespace).".to_string()); - } - if trimmed.len() < 16 { - return Some("API key appears too short. Please paste the full key.".to_string()); - } - if !trimmed.contains('-') { - return Some( - "API key format looks unusual. Check that the full key was copied.".to_string(), + return ApiKeyValidation::Reject( + "API key appears malformed (contains whitespace).".to_string(), ); } - None + if trimmed.len() < 16 { + return ApiKeyValidation::Accept { + warning: Some( + "API key looks short. Double-check it, but unusual formats are allowed." + .to_string(), + ), + }; + } + if !trimmed.contains('-') { + return ApiKeyValidation::Accept { + warning: Some( + "API key format looks unusual. Check that the full key was copied.".to_string(), + ), + }; + } + ApiKeyValidation::Accept { warning: None } +} + +fn sync_api_key_validation_status(app: &mut App, show_empty_error: bool) { + if app.api_key_input.trim().is_empty() && !show_empty_error { + app.status_message = None; + return; + } + + match validate_api_key_for_onboarding(&app.api_key_input) { + ApiKeyValidation::Accept { warning } => { + app.status_message = warning; + } + ApiKeyValidation::Reject(message) => { + app.status_message = Some(message); + } + } } fn build_queued_message(app: &mut App, input: String) -> QueuedMessage { @@ -1897,6 +1849,7 @@ async fn dispatch_user_message( ) -> Result<()> { // Set immediately to prevent double-dispatch before TurnStarted event arrives. app.is_loading = true; + app.last_send_at = Some(Instant::now()); let content = message.content(); app.system_prompt = Some(prompts::system_prompt_for_mode_with_context( @@ -1931,12 +1884,200 @@ async fn dispatch_user_message( model: app.model.clone(), allow_shell: app.allow_shell, trust_mode: app.trust_mode, + auto_approve: app.mode == AppMode::Yolo, }) .await?; Ok(()) } +fn open_text_pager(app: &mut App, title: String, content: String) { + let width = app + .last_transcript_area + .map(|area| area.width) + .unwrap_or(80); + app.view_stack.push(PagerView::from_text( + title, + &content, + width.saturating_sub(2), + )); +} + +async fn apply_command_result( + app: &mut App, + engine_handle: &EngineHandle, + task_manager: &SharedTaskManager, + config: &Config, + result: commands::CommandResult, +) -> Result { + if let Some(msg) = result.message { + app.add_message(HistoryCell::System { content: msg }); + } + + if let Some(action) = result.action { + match action { + AppAction::Quit => { + let _ = engine_handle.send(Op::Shutdown).await; + return Ok(true); + } + AppAction::SaveSession(path) => { + app.status_message = Some(format!("Session saved to {}", path.display())); + } + AppAction::LoadSession(path) => { + app.status_message = Some(format!("Session loaded from {}", path.display())); + } + AppAction::SyncSession { + messages, + system_prompt, + model, + workspace, + } => { + let is_full_reset = messages.is_empty() && system_prompt.is_none(); + let _ = engine_handle + .send(Op::SyncSession { + messages, + system_prompt, + model, + workspace, + }) + .await; + let _ = engine_handle + .send(Op::SetCompaction { + config: app.compaction_config(), + }) + .await; + if is_full_reset { + persist_session_snapshot(app); + clear_checkpoint(); + } + } + AppAction::SendMessage(content) => { + let queued = build_queued_message(app, content); + submit_or_steer_message(app, engine_handle, queued).await?; + } + AppAction::ListSubAgents => { + let _ = engine_handle.send(Op::ListSubAgents).await; + } + AppAction::FetchModels => { + app.status_message = Some("Fetching models...".to_string()); + match fetch_available_models(config).await { + Ok(models) => { + app.add_message(HistoryCell::System { + content: format_available_models_message(&app.model, &models), + }); + app.status_message = Some(format!("Found {} model(s)", models.len())); + } + Err(error) => { + app.add_message(HistoryCell::System { + content: format!("Failed to fetch models: {error}"), + }); + } + } + } + AppAction::UpdateCompaction(compaction) => { + let _ = engine_handle + .send(Op::SetCompaction { config: compaction }) + .await; + } + AppAction::OpenConfigView => { + if app.view_stack.top_kind() != Some(ModalKind::Config) { + app.view_stack.push(ConfigView::new_for_app(app)); + } + } + AppAction::CompactContext => { + app.status_message = Some("Compacting context...".to_string()); + let _ = engine_handle.send(Op::CompactContext).await; + } + AppAction::TaskAdd { prompt } => { + let request = NewTaskRequest { + prompt: prompt.clone(), + model: Some(app.model.clone()), + workspace: Some(app.workspace.clone()), + mode: Some(task_mode_label(app.mode).to_string()), + allow_shell: Some(app.allow_shell), + trust_mode: Some(app.trust_mode), + auto_approve: Some(app.approval_mode == ApprovalMode::Auto), + }; + match task_manager.add_task(request).await { + Ok(task) => { + app.add_message(HistoryCell::System { + content: format!( + "Task queued: {} ({})", + task.id, + summarize_tool_output(&task.prompt) + ), + }); + app.status_message = Some(format!("Queued {}", task.id)); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Failed to queue task: {err}"), + }); + } + } + app.task_panel = task_manager + .list_tasks(Some(10)) + .await + .into_iter() + .map(task_summary_to_panel_entry) + .collect(); + } + AppAction::TaskList => { + let tasks = task_manager.list_tasks(Some(30)).await; + app.task_panel = tasks + .iter() + .cloned() + .map(task_summary_to_panel_entry) + .collect(); + app.add_message(HistoryCell::System { + content: format_task_list(&tasks), + }); + } + AppAction::TaskShow { id } => match task_manager.get_task(&id).await { + Ok(task) => open_task_pager(app, &task), + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Task lookup failed: {err}"), + }); + } + }, + AppAction::TaskCancel { id } => { + match task_manager.cancel_task(&id).await { + Ok(task) => { + app.add_message(HistoryCell::System { + content: format!("Task {} status: {:?}", task.id, task.status), + }); + } + Err(err) => { + app.add_message(HistoryCell::System { + content: format!("Task cancel failed: {err}"), + }); + } + } + app.task_panel = task_manager + .list_tasks(Some(10)) + .await + .into_iter() + .map(task_summary_to_panel_entry) + .collect(); + } + } + } + + Ok(false) +} + +async fn execute_command_input( + app: &mut App, + engine_handle: &EngineHandle, + task_manager: &SharedTaskManager, + config: &Config, + input: &str, +) -> Result { + let result = commands::execute(input, app); + apply_command_result(app, engine_handle, task_manager, config, result).await +} + async fn steer_user_message( app: &mut App, engine_handle: &EngineHandle, @@ -2229,9 +2370,50 @@ fn reconcile_subagent_activity_state(app: &mut App) { } } +fn compact_runtime_parts(app: &App) -> Vec { + let mut parts = Vec::new(); + + let active_agents = running_agent_count(app); + if active_agents > 0 { + parts.push(format!( + "{active_agents} agent{}", + if active_agents == 1 { "" } else { "s" } + )); + } + + let running_tasks = app + .task_panel + .iter() + .filter(|task| task.status == "running") + .count(); + if running_tasks > 0 { + parts.push(format!( + "{running_tasks} task{}", + if running_tasks == 1 { "" } else { "s" } + )); + } + + let queued = app.queued_message_count(); + if queued > 0 { + parts.push(format!("{queued} queued")); + } + if app.queued_draft.is_some() { + parts.push("editing queue".to_string()); + } + + match app.view_stack.top_kind() { + Some(ModalKind::Approval) => parts.push("approval open".to_string()), + Some(ModalKind::Elevation) => parts.push("elevation open".to_string()), + _ => {} + } + + parts +} + fn compute_status_layout( app: &App, terminal_height: u16, + terminal_width: u16, composer_height: u16, ) -> StatusLayoutPlan { let status_budget = status_row_budget(terminal_height, 1, 1, composer_height); @@ -2240,11 +2422,15 @@ fn compute_status_layout( status_height: 0, queued_preview: Vec::new(), queued_compacted: app.queued_message_count() > 0, + compact_runtime_summary: false, }; } let active_agents = running_agent_count(app); - let fixed_rows = usize::from(app.is_loading) + let compact_runtime_summary = + terminal_width < SIDEBAR_VISIBLE_MIN_WIDTH && !compact_runtime_parts(app).is_empty(); + let fixed_rows = usize::from(app.is_loading || app.is_compacting) + + usize::from(compact_runtime_summary) + usize::from(app.queued_draft.is_some()) + usize::from(active_agents > 0); let queue_rows_budget = usize::from(status_budget).saturating_sub(fixed_rows); @@ -2274,6 +2460,7 @@ fn compute_status_layout( status_height, queued_preview, queued_compacted, + compact_runtime_summary, } } @@ -2303,7 +2490,7 @@ fn render(f: &mut Frame, app: &mut App) { ComposerWidget::new(app, prompt, max_composer_height, &slash_menu_entries); composer_widget.desired_height(size.width) }; - let status_layout = compute_status_layout(app, size.height, composer_for_budget); + let status_layout = compute_status_layout(app, size.height, size.width, composer_for_budget); let composer_max_height = body_height .saturating_sub(status_layout.status_height + chat_height_floor(body_height)) .max(MIN_COMPOSER_HEIGHT); @@ -2345,7 +2532,7 @@ fn render(f: &mut Frame, app: &mut App) { let mut chat_area = chunks[1]; let mut sidebar_area = None; - if chunks[1].width >= 100 { + if chunks[1].width >= SIDEBAR_VISIBLE_MIN_WIDTH { let preferred_sidebar = (u32::from(chunks[1].width) * u32::from(app.sidebar_width_percent.clamp(10, 50)) / 100) as u16; @@ -2380,6 +2567,7 @@ fn render(f: &mut Frame, app: &mut App) { app.queued_message_count(), &status_layout.queued_preview, status_layout.queued_compacted, + status_layout.compact_runtime_summary, ); } @@ -2699,6 +2887,7 @@ fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { let (status_label, status_color) = match &agent.status { SubAgentStatus::Running => ("running", palette::STATUS_WARNING), SubAgentStatus::Completed => ("done", palette::STATUS_SUCCESS), + SubAgentStatus::Interrupted(_) => ("interrupted", palette::STATUS_WARNING), SubAgentStatus::Failed(_) => ("failed", palette::STATUS_ERROR), SubAgentStatus::Cancelled => ("cancelled", palette::TEXT_MUTED), }; @@ -2757,24 +2946,36 @@ fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec) { +async fn handle_view_events( + app: &mut App, + config: &Config, + task_manager: &SharedTaskManager, + engine_handle: &EngineHandle, + events: Vec, +) -> Result { for event in events { match event { - ViewEvent::CommandPaletteSelected { command } => { - app.input = command; - app.cursor_position = app.input.chars().count(); - app.status_message = Some("Command inserted. Press Enter to run.".to_string()); - } + ViewEvent::CommandPaletteSelected { action } => match action { + crate::tui::views::CommandPaletteAction::ExecuteCommand { command } => { + if execute_command_input(app, engine_handle, task_manager, config, &command) + .await? + { + return Ok(true); + } + } + crate::tui::views::CommandPaletteAction::InsertText { text } => { + app.input = text; + app.cursor_position = app.input.chars().count(); + app.status_message = Some( + "Inserted into composer. Finish the input or press Enter.".to_string(), + ); + } + crate::tui::views::CommandPaletteAction::OpenTextPager { title, content } => { + open_text_pager(app, title, content); + } + }, ViewEvent::OpenTextPager { title, content } => { - let width = app - .last_transcript_area - .map(|area| area.width) - .unwrap_or(80); - app.view_stack.push(PagerView::from_text( - title, - &content, - width.saturating_sub(2), - )); + open_text_pager(app, title, content); } ViewEvent::ApprovalDecision { tool_id, @@ -2856,6 +3057,13 @@ async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events: } } } + ViewEvent::PlanPromptDismissed => { + app.plan_prompt_pending = true; + app.status_message = Some( + "Plan prompt dismissed. Type 1-4 with Enter or reopen it by finishing the plan turn again." + .to_string(), + ); + } ViewEvent::SessionSelected { session_id } => { let manager = match SessionManager::default_location() { Ok(manager) => manager, @@ -2933,6 +3141,8 @@ async fn handle_view_events(app: &mut App, engine_handle: &EngineHandle, events: } } } + + Ok(false) } fn apply_loaded_session(app: &mut App, session: &SavedSession) { @@ -3131,6 +3341,7 @@ fn render_status_indicator( queued_count: usize, queued: &[String], queued_compacted: bool, + compact_runtime_summary: bool, ) { let max_rows = usize::from(area.height); if max_rows == 0 { @@ -3147,16 +3358,25 @@ fn render_status_indicator( None }; let elapsed = app.turn_started_at.map(format_elapsed); - // Use typing indicator when streaming content, otherwise use a subtle status glyph. - let has_streaming_content = app.streaming_message_index.is_some(); - let spinner = if has_streaming_content { + // Distinguish thinking streaming from answer streaming. + let is_thinking_streaming = app.streaming_message_index.is_some_and(|idx| { + matches!( + app.history.get(idx), + Some(HistoryCell::Thinking { + streaming: true, + .. + }) + ) + }); + let is_answer_streaming = app.streaming_message_index.is_some() && !is_thinking_streaming; + let spinner = if is_answer_streaming { typing_indicator(app.turn_started_at) } else { deepseek_squiggle(app.turn_started_at) }; - let (label, label_color) = if has_streaming_content { + let (label, label_color) = if is_answer_streaming { ("ANSWER", palette::DEEPSEEK_SKY) - } else if app.show_thinking { + } else if app.show_thinking || is_thinking_streaming { ("THINKING", palette::STATUS_WARNING) } else { ("WORKING", palette::STATUS_WARNING) @@ -3166,7 +3386,7 @@ fn render_status_indicator( Span::raw(" "), Span::styled(label, Style::default().fg(label_color).bold()), ]; - if !has_streaming_content && let Some(header) = header { + if !is_answer_streaming && let Some(header) = header { spans.push(Span::raw(": ")); spans.push(Span::styled( header, @@ -3189,6 +3409,36 @@ fn render_status_indicator( )); lines.push(Line::from(spans)); + } else if app.is_compacting { + let spinner = deepseek_squiggle(None); + let spans = vec![ + Span::styled(spinner, Style::default().fg(palette::STATUS_WARNING).bold()), + Span::raw(" "), + Span::styled( + "COMPACTING", + Style::default().fg(palette::STATUS_WARNING).bold(), + ), + Span::raw(" "), + Span::styled( + "summarizing context...", + Style::default().fg(palette::TEXT_MUTED), + ), + ]; + lines.push(Line::from(spans)); + } + + if compact_runtime_summary && lines.len() < max_rows { + let summary = compact_runtime_parts(app).join(" | "); + if !summary.is_empty() { + let available = area.width as usize; + lines.push(Line::from(vec![ + Span::styled("STATE ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled( + truncate_line_to_width(&summary, available.saturating_sub(6).max(1)), + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + } } let active_agent_total = running_agent_count(app); @@ -3288,75 +3538,52 @@ fn status_color(level: StatusToastLevel) -> ratatui::style::Color { } fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { - let width = area.width; - let available_width = width as usize; + let available_width = area.width as usize; - // 1. Context Progress Bar (Right) + // Context percentage let context_snapshot = context_usage_snapshot(app); let percent = context_snapshot .map(|(_, _, pct)| pct as f32) .unwrap_or(0.0); - let bar_width = 10; // Width of the progress bar - let filled = ((percent / 100.0) * bar_width as f32).round() as usize; - let filled = filled.min(bar_width); - let empty = bar_width - filled; - let bar_color = context_color_for_percent(f64::from(percent)); - let bar_filled = "█".repeat(filled); - let bar_empty = "░".repeat(empty); - let context_text = format!("[{}{}] {:.0}%", bar_filled, bar_empty, percent); - let context_span = Span::styled(context_text, Style::default().fg(bar_color)); - let budget_span = context_snapshot.map(|(used, max, _)| { - Span::styled( - format_context_budget(used, max), - Style::default().fg(bar_color), - ) - }); + // Narrow terminal fallback (< 60 cols): just mode + percent with mini bar + if available_width < 60 { + let (mode_label, mode_color) = footer_mode_style(app); + let narrow_left = vec![Span::styled( + mode_label.to_string(), + Style::default().fg(mode_color), + )]; + let narrow_right = context_bar_spans(percent, bar_color, false); - // 2. Right side extras (Scroll, Selection) - Minimalist - let mut right_extras = Vec::new(); + let nl_width: usize = narrow_left.iter().map(|s| s.content.width()).sum(); + let nr_width: usize = narrow_right.iter().map(|s| s.content.width()).sum(); + let ns_width = available_width.saturating_sub(nl_width + nr_width); - // Scroll % - if !matches!(app.transcript_scroll, TranscriptScroll::ToBottom) - && let Some(scroll_pct) = transcript_scroll_percent( - app.last_transcript_top, - app.last_transcript_visible, - app.last_transcript_total, - ) - { - right_extras.push(Span::styled( - format!(" {scroll_pct}% "), - Style::default().fg(palette::FOOTER_HINT), - )); + let mut all_spans = narrow_left; + all_spans.push(Span::raw(" ".repeat(ns_width))); + all_spans.extend(narrow_right); + + let footer = Paragraph::new(Line::from(all_spans)); + f.render_widget(footer, area); + return; } - // Selection - if app.transcript_selection.is_active() { - right_extras.push(Span::styled( - " [SEL] ", - Style::default().fg(palette::FOOTER_HINT), - )); - } - - // Assemble Right Side - // context_span is always last - let mut right_spans = right_extras; - right_spans.push(Span::raw(" ")); // Space before context - if let Some(budget) = budget_span { - right_spans.push(budget); - right_spans.push(Span::raw(" ")); - } - right_spans.push(context_span); - + // Right side: context percentage + mini bar + token count + let show_tokens = available_width >= 80; + let right_spans = context_bar_spans_with_tokens( + percent, + bar_color, + show_tokens.then_some(context_snapshot).flatten(), + ); let right_width: usize = right_spans.iter().map(|s| s.content.width()).sum(); - // 3. Left side content (status toast or standard footer) + // Left side: toast or Kimi-style status line let active_status = app.active_status_toast(); let left_spans = if let Some(toast) = active_status.as_ref() { let max_left = available_width .saturating_sub(right_width) - .saturating_sub(1) + .saturating_sub(2) .max(1); let truncated = truncate_line_to_width(&toast.text, max_left); vec![Span::styled( @@ -3364,117 +3591,71 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { Style::default().fg(status_color(toast.level)), )] } else { - // Compact footer: mode + workspace + session + token cost + help hint + // Kimi-style: "00:07 yolo agent (model, thinking)" let mut spans = Vec::new(); - // Mode indicator - let (mode_label, mode_color) = match app.mode { - crate::tui::app::AppMode::Normal => ("[Normal]", palette::MODE_NORMAL), - crate::tui::app::AppMode::Agent => ("[Agent]", palette::MODE_AGENT), - crate::tui::app::AppMode::Yolo => ("[YOLO]", palette::MODE_YOLO), - crate::tui::app::AppMode::Plan => ("[Plan]", palette::MODE_PLAN), - }; + // Elapsed time HH:MM + let elapsed = app.session_start.elapsed(); + let total_secs = elapsed.as_secs(); + let hours = total_secs / 3600; + let mins = (total_secs % 3600) / 60; spans.push(Span::styled( - format!("{} ", mode_label), + format!("{hours:02}:{mins:02}"), + Style::default().fg(palette::FOOTER_HINT), + )); + spans.push(Span::raw(" ")); + + // Mode (lowercase, colored) + let (mode_label, mode_color) = footer_mode_style(app); + spans.push(Span::styled( + mode_label.to_string(), Style::default().fg(mode_color), )); + spans.push(Span::raw(" ")); - // Workspace (directory name) - if let Some(workspace_name) = app.workspace.file_name() - && let Some(name) = workspace_name.to_str() - { - let ws = format!("{} ", name); - spans.push(Span::styled(ws, Style::default().fg(palette::FOOTER_HINT))); - } - - if let Some(workspace_context) = app.workspace_context.as_deref() { - let context = - truncate_line_to_width(&format!("ctx: {workspace_context}"), available_width / 2); - if !context.is_empty() { - spans.push(Span::styled( - format!("{context} "), - Style::default().fg(palette::FOOTER_HINT), - )); - } - } - - // Session ID - if let Some(ref sid) = app.current_session_id { - spans.push(Span::styled( - format!("session:{} ", &sid[..8.min(sid.len())]), - Style::default().fg(palette::FOOTER_HINT), - )); - } - - // Token cost - if app.total_conversation_tokens > 0 { - let tokens_k = app.total_conversation_tokens as f64 / 1000.0; - spans.push(Span::styled( - format!("{tokens_k:.1}k tokens "), - Style::default().fg(palette::FOOTER_HINT), - )); - } - - // Help hint + // "agent" label + model name + status in parens + let model_short = app.model.rsplit('/').next().unwrap_or(&app.model); + let status = if app.is_compacting { + ", compacting" + } else if app.is_loading { + ", thinking" + } else { + "" + }; spans.push(Span::styled( - "F1 help", - Style::default().fg(palette::FOOTER_HINT), + "agent ", + Style::default().fg(palette::TEXT_HINT), + )); + spans.push(Span::styled( + format!("({model_short}{status})"), + Style::default().fg(palette::TEXT_HINT), )); spans }; - // Calculate Widths let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum(); - - // Spacer let spacer_width = available_width.saturating_sub(left_width + right_width); let mut all_spans = left_spans; - if spacer_width > 0 { - all_spans.push(Span::raw(" ".repeat(spacer_width))); - all_spans.extend(right_spans); - } else { - // Fallback for narrow screens - let simple_left = if let Some(toast) = active_status.as_ref() { - let max_left = available_width.saturating_sub(10).saturating_sub(1).max(1); - let truncated = truncate_line_to_width(&toast.text, max_left); - vec![Span::styled( - truncated, - Style::default().fg(status_color(toast.level)), - )] - } else { - vec![Span::styled( - "F1 help", - Style::default().fg(palette::FOOTER_HINT), - )] - }; - let bar_filled_narrow = "█".repeat(filled.min(5)); - let bar_empty_narrow = "░".repeat(5 - filled.min(5)); - let budget_prefix = context_snapshot - .map(|(used, max, _)| format!("{} ", format_context_budget(used, max))) - .unwrap_or_default(); - let simple_right = vec![Span::styled( - format!( - "{}[{}{}] {:.0}%", - budget_prefix, bar_filled_narrow, bar_empty_narrow, percent - ), - Style::default().fg(bar_color), - )]; - - let sl_width: usize = simple_left.iter().map(|s| s.content.width()).sum(); - let sr_width: usize = simple_right.iter().map(|s| s.content.width()).sum(); - let sp_width = available_width.saturating_sub(sl_width + sr_width); - - all_spans = simple_left; - all_spans.push(Span::raw(" ".repeat(sp_width))); - all_spans.extend(simple_right); - } + all_spans.push(Span::raw(" ".repeat(spacer_width))); + all_spans.extend(right_spans); let footer = Paragraph::new(Line::from(all_spans)); f.render_widget(footer, area); } +fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) { + let label = app.mode.as_setting(); + let color = match app.mode { + crate::tui::app::AppMode::Normal => palette::MODE_NORMAL, + crate::tui::app::AppMode::Agent => palette::MODE_AGENT, + crate::tui::app::AppMode::Yolo => palette::MODE_YOLO, + crate::tui::app::AppMode::Plan => palette::MODE_PLAN, + }; + (label, color) +} + fn format_token_count_compact(tokens: u64) -> String { if tokens >= 1_000_000 { format!("{:.1}M", tokens as f64 / 1_000_000.0) @@ -3485,6 +3666,7 @@ fn format_token_count_compact(tokens: u64) -> String { } } +#[allow(dead_code)] fn format_context_budget(used: i64, max: u32) -> String { let max_u64 = u64::from(max); let max_i64 = i64::from(max); @@ -3515,6 +3697,67 @@ fn context_color_for_percent(percent: f64) -> ratatui::style::Color { } } +/// Build context bar spans: `42.0% ▮▮▮░░░░░` +fn context_bar_spans( + percent: f32, + color: ratatui::style::Color, + show_label: bool, +) -> Vec> { + const BAR_WIDTH: u32 = 8; + let filled = ((percent as f64 / 100.0) * BAR_WIDTH as f64) + .round() + .clamp(0.0, BAR_WIDTH as f64) as u32; + let empty = BAR_WIDTH.saturating_sub(filled); + + let bar_filled: String = "▮".repeat(filled as usize); + let bar_empty: String = "░".repeat(empty as usize); + + let mut spans = Vec::new(); + if show_label { + spans.push(Span::styled( + format!("context: {percent:.1}% "), + Style::default().fg(color), + )); + } else { + spans.push(Span::styled( + format!("{percent:.0}% "), + Style::default().fg(color), + )); + } + spans.push(Span::styled(bar_filled, Style::default().fg(color))); + spans.push(Span::styled( + bar_empty, + Style::default().fg(palette::BORDER_COLOR), + )); + spans +} + +/// Build context bar spans with optional token count detail. +fn context_bar_spans_with_tokens( + percent: f32, + color: ratatui::style::Color, + snapshot: Option<(i64, u32, f64)>, +) -> Vec> { + let mut spans = context_bar_spans(percent, color, true); + + // Show token counts when available: " (12.3k/128k)" + if let Some((used, max, _)) = snapshot { + let used_u64 = u64::try_from(used.max(0)).unwrap_or(0); + let max_u64 = u64::from(max); + spans.push(Span::styled( + format!( + " ({}/{})", + format_token_count_compact(used_u64), + format_token_count_compact(max_u64) + ), + Style::default().fg(palette::TEXT_HINT), + )); + } + + spans +} + +#[allow(dead_code)] fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option { if total <= visible { return None; @@ -3680,8 +3923,8 @@ fn deepseek_squiggle(start: Option) -> &'static str { FRAMES[idx] } -/// Braille pattern frames for typing/thinking indicator animation. -const TYPING_FRAMES: &[&str] = &["⠁", "⠂", "⠄", "⠂"]; +/// Braille spinner frames — a prominent rotating circle pattern. +const TYPING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// Returns the typing indicator frame based on elapsed time. fn typing_indicator(start: Option) -> &'static str { @@ -3982,9 +4225,10 @@ fn extract_reasoning_header(text: &str) -> Option { fn subagent_status_rank(status: &SubAgentStatus) -> u8 { match status { SubAgentStatus::Running => 0, - SubAgentStatus::Failed(_) => 1, - SubAgentStatus::Completed => 2, - SubAgentStatus::Cancelled => 3, + SubAgentStatus::Interrupted(_) => 1, + SubAgentStatus::Failed(_) => 2, + SubAgentStatus::Completed => 3, + SubAgentStatus::Cancelled => 4, } } @@ -3998,12 +4242,7 @@ fn sort_subagents_in_place(agents: &mut [SubAgentResult]) { } fn task_mode_label(mode: AppMode) -> &'static str { - match mode { - AppMode::Normal => "normal", - AppMode::Agent => "agent", - AppMode::Yolo => "yolo", - AppMode::Plan => "plan", - } + mode.as_setting() } fn task_summary_to_panel_entry(summary: TaskSummary) -> TaskPanelEntry { diff --git a/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs similarity index 83% rename from src/tui/ui/tests.rs rename to crates/tui/src/tui/ui/tests.rs index 3564b4ba..c432bbe4 100644 --- a/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -168,17 +168,34 @@ fn running_agent_count_unions_cache_and_progress() { #[test] fn compute_status_layout_reserves_rows_for_active_agents() { let app = create_test_app(); - let baseline = compute_status_layout(&app, 30, 3); + let baseline = compute_status_layout(&app, 30, 120, 3); assert_eq!(baseline.status_height, 0); let mut with_agents = create_test_app(); with_agents .agent_progress .insert("agent_a".to_string(), "running".to_string()); - let active = compute_status_layout(&with_agents, 30, 3); + let active = compute_status_layout(&with_agents, 30, 120, 3); assert!(active.status_height >= 1); } +#[test] +fn narrow_layout_adds_compact_runtime_summary_without_sidebar() { + let mut app = create_test_app(); + app.agent_progress + .insert("agent_a".to_string(), "running".to_string()); + app.queue_message(crate::tui::app::QueuedMessage::new( + "queued message".to_string(), + None, + )); + + let narrow = compute_status_layout(&app, 30, 80, 3); + let wide = compute_status_layout(&app, 30, 120, 3); + + assert!(narrow.compact_runtime_summary); + assert!(!wide.compact_runtime_summary); +} + #[test] fn active_agent_rows_prefers_cache_order_and_progress_text() { let mut app = create_test_app(); @@ -305,19 +322,44 @@ fn test_esc_with_input_clears_input_when_not_loading() { } #[test] -fn test_esc_switches_to_normal_mode_when_idle() { +fn test_esc_discards_queued_draft_before_clearing_input() { + let mut app = create_test_app(); + app.is_loading = false; + app.input.clear(); + app.queued_draft = Some(crate::tui::app::QueuedMessage::new( + "queued draft".to_string(), + None, + )); + + assert_eq!( + next_escape_action(&app, false), + EscapeAction::DiscardQueuedDraft + ); +} + +#[test] +fn test_esc_is_noop_when_idle() { let mut app = create_test_app(); app.is_loading = false; app.input.clear(); app.cursor_position = 0; app.mode = AppMode::Agent; - // Simulate Esc key press when not loading and input empty - app.set_mode(AppMode::Normal); + assert_eq!(next_escape_action(&app, false), EscapeAction::Noop); + assert_eq!(app.mode, AppMode::Agent); +} - assert_eq!(app.mode, AppMode::Normal); - assert!(!app.is_loading); - assert!(app.input.is_empty()); +#[test] +fn test_esc_closes_slash_menu_before_other_actions() { + let mut app = create_test_app(); + app.is_loading = true; + app.input = "draft".to_string(); + app.queued_draft = Some(crate::tui::app::QueuedMessage::new( + "queued draft".to_string(), + None, + )); + + assert_eq!(next_escape_action(&app, true), EscapeAction::CloseSlashMenu); } #[test] @@ -364,26 +406,28 @@ fn test_ctrl_d_does_nothing_when_input_not_empty() { } #[test] -fn test_esc_priority_order_loading_then_input_then_mode() { - // Test 1: Loading state takes priority +fn test_esc_priority_order_matches_cancel_stack() { let mut app = create_test_app(); app.is_loading = true; app.input = "draft".to_string(); app.mode = AppMode::Yolo; - // Should cancel request (not clear input or change mode) - assert!(app.is_loading); + assert_eq!(next_escape_action(&app, false), EscapeAction::CancelRequest); - // Test 2: Input not empty takes priority when not loading app.is_loading = false; - assert!(!app.input.is_empty()); - // Should clear input (not change mode) + assert_eq!(next_escape_action(&app, false), EscapeAction::ClearInput); - // Test 3: Change mode when not loading and input empty app.input.clear(); - app.mode = AppMode::Yolo; - assert!(app.input.is_empty()); - assert_eq!(app.mode, AppMode::Yolo); - // Should change to Normal mode + app.queued_draft = Some(crate::tui::app::QueuedMessage::new( + "queued draft".to_string(), + None, + )); + assert_eq!( + next_escape_action(&app, false), + EscapeAction::DiscardQueuedDraft + ); + + app.queued_draft = None; + assert_eq!(next_escape_action(&app, false), EscapeAction::Noop); } #[test] @@ -432,12 +476,36 @@ fn status_layout_budget_preserves_chat_and_composer_on_tiny_heights() { )); } - let layout = compute_status_layout(&app, 9, 3); + let layout = compute_status_layout(&app, 9, 80, 3); assert_eq!(layout.status_height, 1); assert!(layout.queued_preview.is_empty()); assert!(layout.queued_compacted); } +#[test] +fn api_key_validation_warns_without_blocking_unusual_formats() { + assert!(matches!( + validate_api_key_for_onboarding(""), + ApiKeyValidation::Reject(_) + )); + assert!(matches!( + validate_api_key_for_onboarding("sk short"), + ApiKeyValidation::Reject(_) + )); + assert!(matches!( + validate_api_key_for_onboarding("short-key"), + ApiKeyValidation::Accept { warning: Some(_) } + )); + assert!(matches!( + validate_api_key_for_onboarding("averylongkeywithoutdash123456"), + ApiKeyValidation::Accept { warning: Some(_) } + )); + assert!(matches!( + validate_api_key_for_onboarding("sk-valid-format-1234567890"), + ApiKeyValidation::Accept { warning: None } + )); +} + #[test] fn compact_queued_preview_summarizes_hidden_messages() { let mut app = create_test_app(); diff --git a/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs similarity index 100% rename from src/tui/ui_text.rs rename to crates/tui/src/tui/ui_text.rs diff --git a/src/tui/user_input.rs b/crates/tui/src/tui/user_input.rs similarity index 100% rename from src/tui/user_input.rs rename to crates/tui/src/tui/user_input.rs diff --git a/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs similarity index 97% rename from src/tui/views/mod.rs rename to crates/tui/src/tui/views/mod.rs index cb56fb37..d2ed7165 100644 --- a/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -28,10 +28,17 @@ pub enum ModalKind { Config, } +#[derive(Debug, Clone)] +pub enum CommandPaletteAction { + ExecuteCommand { command: String }, + InsertText { text: String }, + OpenTextPager { title: String, content: String }, +} + #[derive(Debug, Clone)] pub enum ViewEvent { CommandPaletteSelected { - command: String, + action: CommandPaletteAction, }, OpenTextPager { title: String, @@ -63,6 +70,7 @@ pub enum ViewEvent { PlanPromptSelected { option: usize, }, + PlanPromptDismissed, SubAgentsRefresh, SessionSelected { session_id: String, @@ -546,7 +554,7 @@ fn config_hint_for_key(key: &str) -> &'static str { } "approval_mode" => "auto | suggest | never", "auto_compact" | "show_thinking" | "show_tool_details" => "on/off, true/false, yes/no, 1/0", - "default_mode" => "agent | plan | yolo", + "default_mode" => "normal | agent | plan | yolo", "theme" => "default | dark | light | whale", "sidebar_width" => "10..=50", "sidebar_focus" => "auto | plan | todos | tasks | agents", @@ -937,7 +945,7 @@ impl ModalView for HelpView { let mut help_lines: Vec = vec![ Line::from(vec![Span::styled( - "DeepSeek CLI Help", + "DeepSeek TUI Help", Style::default().fg(palette::DEEPSEEK_BLUE).bold(), )]), Line::from(""), @@ -973,7 +981,9 @@ impl ModalView for HelpView { Style::default().fg(palette::DEEPSEEK_SKY).bold(), )]), Line::from(" Enter - Submit message"), - Line::from(" Esc - Cancel request / clear input"), + Line::from( + " Esc - Close menu, cancel request, discard draft, or clear input", + ), Line::from(" Ctrl+C - Cancel request or exit application"), Line::from(" Ctrl+D - Exit when input is empty"), Line::from(" Ctrl+K - Open command palette"), @@ -1174,6 +1184,7 @@ impl ModalView for SubAgentsView { } else { let mut running = Vec::new(); let mut completed = Vec::new(); + let mut interrupted = Vec::new(); let mut failed = Vec::new(); let mut cancelled = Vec::new(); @@ -1181,6 +1192,7 @@ impl ModalView for SubAgentsView { match agent.status { SubAgentStatus::Running => running.push(agent), SubAgentStatus::Completed => completed.push(agent), + SubAgentStatus::Interrupted(_) => interrupted.push(agent), SubAgentStatus::Failed(_) => failed.push(agent), SubAgentStatus::Cancelled => cancelled.push(agent), } @@ -1189,6 +1201,7 @@ impl ModalView for SubAgentsView { let status_summary = [ ("Running", running.len(), palette::STATUS_WARNING), ("Completed", completed.len(), palette::STATUS_SUCCESS), + ("Interrupted", interrupted.len(), palette::STATUS_WARNING), ("Failed", failed.len(), palette::DEEPSEEK_RED), ("Cancelled", cancelled.len(), palette::TEXT_MUTED), ]; @@ -1227,6 +1240,10 @@ impl ModalView for SubAgentsView { let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type)); order.then_with(|| a.agent_id.cmp(&b.agent_id)) }); + interrupted.sort_by(|a, b| { + let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type)); + order.then_with(|| a.agent_id.cmp(&b.agent_id)) + }); failed.sort_by(|a, b| { let order = agent_type_order(&a.agent_type).cmp(&agent_type_order(&b.agent_type)); order.then_with(|| a.agent_id.cmp(&b.agent_id)) @@ -1250,6 +1267,13 @@ impl ModalView for SubAgentsView { &completed, content_width, ); + append_subagent_group( + &mut lines, + "Interrupted", + palette::STATUS_WARNING.into(), + &interrupted, + content_width, + ); append_subagent_group( &mut lines, "Failed", @@ -1420,6 +1444,11 @@ fn format_agent_status( Style::default().fg(palette::DEEPSEEK_BLUE), None, ), + SubAgentStatus::Interrupted(reason) => ( + "interrupted", + Style::default().fg(palette::STATUS_WARNING), + Some(reason.as_str()), + ), SubAgentStatus::Cancelled => ("cancelled", Style::default().fg(palette::TEXT_MUTED), None), SubAgentStatus::Failed(reason) => ( "failed", diff --git a/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs similarity index 54% rename from src/tui/widgets/header.rs rename to crates/tui/src/tui/widgets/header.rs index d52f2d47..1003a99e 100644 --- a/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -14,17 +14,6 @@ use crate::tui::app::AppMode; use super::Renderable; -/// Format a token count for compact display (e.g., "12.3k", "1.2M"). -fn format_token_count(tokens: u32) -> String { - if tokens >= 1_000_000 { - format!("{:.1}M", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - format!("{:.1}k", tokens as f64 / 1_000.0) - } else { - format!("{tokens}") - } -} - /// Data required to render the header bar. pub struct HeaderData<'a> { pub model: &'a str, @@ -81,7 +70,7 @@ impl<'a> HeaderData<'a> { /// Header bar widget (1 line height). /// -/// Layout: `[MODE] model-name | [streaming indicator]` +/// Layout: `mode model ●` pub struct HeaderWidget<'a> { data: HeaderData<'a>, } @@ -102,19 +91,18 @@ impl<'a> HeaderWidget<'a> { } } - /// Build the mode badge span. + /// Build the mode badge span (no brackets, lowercase, bold). fn mode_badge(&self) -> Span<'static> { - let label = self.data.mode.label(); + let label = self.data.mode.label().to_lowercase(); let color = Self::mode_color(self.data.mode); Span::styled( - format!("[{label}]"), + label, Style::default().fg(color).add_modifier(Modifier::BOLD), ) } - /// Build the model name span. + /// Build the model name span (muted, truncated). fn model_span(&self) -> Span<'static> { - // Truncate long model names (char-safe to avoid panics on multi-byte UTF-8) let display_name = if self.data.model.chars().count() > 25 { let truncated: String = self.data.model.chars().take(22).collect(); format!("{truncated}...") @@ -122,7 +110,7 @@ impl<'a> HeaderWidget<'a> { self.data.model.to_string() }; - Span::styled(display_name, Style::default().fg(palette::TEXT_MUTED)) + Span::styled(display_name, Style::default().fg(palette::TEXT_HINT)) } /// Build the streaming indicator span. @@ -138,56 +126,6 @@ impl<'a> HeaderWidget<'a> { .add_modifier(Modifier::BOLD), )) } - - /// Build the token/cost info span for the right side of the header. - fn usage_span(&self) -> Option> { - if self.data.total_tokens == 0 && self.data.session_cost < 0.0001 { - return None; - } - - let mut parts = Vec::new(); - - // Session token count (cumulative for this chat). - if self.data.total_tokens > 0 { - let token_str = format_token_count(self.data.total_tokens); - parts.push(format!("session {token_str}")); - } - - // Context utilization from the latest prompt usage. - if let (Some(ctx_window), Some(prompt_tokens)) = - (self.data.context_window, self.data.last_prompt_tokens) - && ctx_window > 0 - { - let pct = ((prompt_tokens as f64 / ctx_window as f64) * 100.0) - .round() - .clamp(0.0, 100.0) as u32; - parts.push(format!("ctx {pct}%")); - } - - if parts.is_empty() && self.data.total_tokens > 0 { - let token_str = format_token_count(self.data.total_tokens); - parts.push(token_str); - } - - // Cost - if self.data.session_cost >= 0.0001 { - parts.push(crate::pricing::format_cost(self.data.session_cost)); - } - - if parts.is_empty() { - return None; - } - - Some(Span::styled( - parts.join(" · "), - Style::default().fg(palette::TEXT_MUTED), - )) - } - - /// Build a subtle separator span. - fn separator_span(&self) -> Span<'static> { - Span::styled(" │ ", Style::default().fg(palette::BORDER_COLOR)) - } } impl Renderable for HeaderWidget<'_> { @@ -196,77 +134,46 @@ impl Renderable for HeaderWidget<'_> { return; } - // Build left section: mode badge + model name + // Build left section: mode + model let mode_span = self.mode_badge(); let model_span = self.model_span(); - // Build right section: usage info + streaming indicator + // Build right section: streaming indicator only. Footer owns context. let streaming_span = self.streaming_indicator(); - let usage_span = self.usage_span(); - - // Subtle separator (vertical bar) - let separator_span = self.separator_span(); - let separator_width = separator_span.content.width(); // Calculate widths let mode_width = mode_span.content.width(); let model_width = model_span.content.width(); let streaming_width = streaming_span.as_ref().map_or(0, |s| s.content.width()); - let usage_width = usage_span.as_ref().map_or(0, |s| s.content.width()); - let right_width = streaming_width - + usage_width - + if streaming_width > 0 && usage_width > 0 { - 1 - } else { - 0 - }; + let right_width = streaming_width; - let left_width = mode_width + 1 + model_width; // mode + space + model (without separator) - - // Determine if separator should be shown (when there's right‑side content) - let show_separator = usage_span.is_some() || streaming_span.is_some(); - let separator_visible_width = if show_separator { separator_width } else { 0 }; + let left_width = mode_width + 2 + model_width; // mode + " " + model let available = area.width as usize; // Build final line based on available space let mut spans = Vec::new(); - if available >= left_width + separator_visible_width + right_width + 2 { - // Full layout: [MODE] model | separator (optional) | (spacer) | usage streaming + if available >= left_width + right_width + 2 { + // Full layout: mode model (spacer) ● spans.push(mode_span); - spans.push(Span::raw(" ")); + spans.push(Span::raw(" ")); spans.push(model_span); - // Add separator if there is right‑side content - if show_separator { - spans.push(separator_span); - } - // Spacer to push right elements to the end - let padding_needed = - available.saturating_sub(left_width + separator_visible_width + right_width); + let padding_needed = available.saturating_sub(left_width + right_width); if padding_needed > 0 { spans.push(Span::raw(" ".repeat(padding_needed))); } - // Add usage info (right side) - if let Some(usage) = usage_span { - spans.push(usage); - if streaming_span.is_some() { - spans.push(Span::raw(" ")); - } - } - - // Add streaming indicator + // Streaming indicator if let Some(streaming) = streaming_span { spans.push(streaming); } - } else if available >= mode_width + 1 + model_width.min(10) { - // Compact layout: [MODE] truncated_model + } else if available >= mode_width + 2 + model_width.min(10) { + // Compact layout: mode truncated_model spans.push(mode_span); - spans.push(Span::raw(" ")); - // Truncate model if needed + spans.push(Span::raw(" ")); let model_str = self.data.model; let display_model = if model_str.chars().count() > 10 { let truncated: String = model_str.chars().take(7).collect(); @@ -276,15 +183,24 @@ impl Renderable for HeaderWidget<'_> { }; spans.push(Span::styled( display_model, - Style::default().fg(palette::TEXT_MUTED), + Style::default().fg(palette::TEXT_HINT), )); } else if available >= mode_width { // Minimal: just mode badge spans.push(mode_span); } else { - // Ultra-minimal: truncated mode + // Ultra-minimal: single lowercase char + let first_char = self + .data + .mode + .label() + .chars() + .next() + .unwrap_or('?') + .to_lowercase() + .to_string(); spans.push(Span::styled( - &self.data.mode.label()[..1.min(self.data.mode.label().len())], + first_char, Style::default().fg(Self::mode_color(self.data.mode)), )); } diff --git a/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs similarity index 89% rename from src/tui/widgets/mod.rs rename to crates/tui/src/tui/widgets/mod.rs index 76253fac..84398505 100644 --- a/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -4,22 +4,27 @@ mod renderable; pub use header::{HeaderData, HeaderWidget}; pub use renderable::Renderable; +use std::time::Duration; + use crate::palette; use crate::tui::app::App; use crate::tui::approval::{ApprovalRequest, ElevationOption, ElevationRequest, ToolCategory}; -use crate::tui::scrolling::TranscriptScroll; +use crate::tui::history::HistoryCell; +use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll}; use crate::{commands, config::COMMON_DEEPSEEK_MODELS}; use ratatui::{ buffer::Buffer, layout::Rect, prelude::Stylize, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}, }; use unicode_segmentation::UnicodeSegmentation; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +const SEND_FLASH_DURATION: Duration = Duration::from_millis(500); + pub struct ChatWidget { content_area: Rect, lines: Vec>, @@ -68,6 +73,15 @@ impl ChatWidget { app.transcript_cache.lines()[top..end].to_vec() }; + // Brief flash highlight on the most recently sent user message. + if let Some(send_at) = app.last_send_at { + if send_at.elapsed() < SEND_FLASH_DURATION { + apply_send_flash(&mut lines, top, &app.history, line_meta); + } else { + app.last_send_at = None; + } + } + apply_selection(&mut lines, top, app); if matches!(app.transcript_scroll, TranscriptScroll::ToBottom) { @@ -245,7 +259,7 @@ impl<'a> ApprovalWidget<'a> { impl Renderable for ApprovalWidget<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let popup_width = 65.min(area.width.saturating_sub(4)); - let popup_height = 18.min(area.height.saturating_sub(4)); + let popup_height = 22.min(area.height.saturating_sub(4)); let popup_area = Rect { x: (area.width.saturating_sub(popup_width)) / 2, y: (area.height.saturating_sub(popup_height)) / 2, @@ -272,6 +286,10 @@ impl Renderable for ApprovalWidget<'_> { ToolCategory::Safe => ("Safe", palette::STATUS_SUCCESS), ToolCategory::FileWrite => ("File Write", palette::STATUS_WARNING), ToolCategory::Shell => ("Shell Command", palette::STATUS_ERROR), + ToolCategory::Network => ("Network", palette::STATUS_WARNING), + ToolCategory::McpRead => ("MCP Read", palette::DEEPSEEK_SKY), + ToolCategory::McpAction => ("MCP Action", palette::STATUS_WARNING), + ToolCategory::Unknown => ("Unknown", palette::STATUS_ERROR), }; lines.push(Line::from(vec![ Span::raw(" Type: "), @@ -283,9 +301,20 @@ impl Renderable for ApprovalWidget<'_> { ), ])); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" About: {}", self.request.description), + Style::default().fg(palette::TEXT_MUTED), + ))); + for impact in self.request.impacts.iter().take(3) { + lines.push(Line::from(Span::styled( + format!(" Impact: {impact}"), + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } lines.push(Line::from("")); let params_str = self.request.params_display(); - let params_truncated = crate::utils::truncate_with_ellipsis(¶ms_str, 50, "..."); + let params_truncated = crate::utils::truncate_with_ellipsis(¶ms_str, 60, "..."); lines.push(Line::from(Span::styled( format!(" Params: {params_truncated}"), Style::default().fg(palette::TEXT_MUTED), @@ -355,7 +384,7 @@ impl<'a> ElevationWidget<'a> { impl Renderable for ElevationWidget<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { let popup_width = 70.min(area.width.saturating_sub(4)); - let popup_height = 20.min(area.height.saturating_sub(4)); + let popup_height = 22.min(area.height.saturating_sub(4)); let popup_area = Rect { x: (area.width.saturating_sub(popup_width)) / 2, y: (area.height.saturating_sub(popup_height)) / 2, @@ -403,6 +432,37 @@ impl Renderable for ElevationWidget<'_> { ), ])); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Impact if approved:", + Style::default().fg(palette::TEXT_MUTED), + ))); + if self + .request + .options + .iter() + .any(|option| matches!(option, ElevationOption::WithNetwork)) + { + lines.push(Line::from(Span::styled( + " - network retry enables outbound downloads and HTTP requests", + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } + if self + .request + .options + .iter() + .any(|option| matches!(option, ElevationOption::WithWriteAccess(_))) + { + lines.push(Line::from(Span::styled( + " - write retry expands writable filesystem scope for this tool call", + Style::default().fg(palette::TEXT_PRIMARY), + ))); + } + lines.push(Line::from(Span::styled( + " - full access removes sandbox restrictions entirely for this retry", + Style::default().fg(palette::TEXT_PRIMARY), + ))); lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Choose how to proceed:", @@ -522,6 +582,35 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) { } } +/// Apply a brief background tint to the last user message's visible lines. +fn apply_send_flash( + lines: &mut [Line<'static>], + top: usize, + history: &[HistoryCell], + line_meta: &[TranscriptLineMeta], +) { + // Find the last User cell index. + let last_user_cell = history + .iter() + .rposition(|cell| matches!(cell, HistoryCell::User { .. })); + let Some(target_cell) = last_user_cell else { + return; + }; + + let flash_bg = Color::Rgb(30, 40, 55); // subtle dark-blue tint + + for (idx, line) in lines.iter_mut().enumerate() { + let line_index = top + idx; + if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index) + && *cell_index == target_cell + { + for span in &mut line.spans { + span.style = span.style.bg(flash_bg); + } + } + } +} + fn apply_selection_to_line( line: &Line<'static>, col_start: usize, diff --git a/src/tui/widgets/renderable.rs b/crates/tui/src/tui/widgets/renderable.rs similarity index 100% rename from src/tui/widgets/renderable.rs rename to crates/tui/src/tui/widgets/renderable.rs diff --git a/src/ui.rs b/crates/tui/src/ui.rs similarity index 100% rename from src/ui.rs rename to crates/tui/src/ui.rs diff --git a/src/utils.rs b/crates/tui/src/utils.rs similarity index 100% rename from src/utils.rs rename to crates/tui/src/utils.rs diff --git a/src/working_set.rs b/crates/tui/src/working_set.rs similarity index 100% rename from src/working_set.rs rename to crates/tui/src/working_set.rs diff --git a/tests/eval_harness.rs b/crates/tui/tests/eval_harness.rs similarity index 100% rename from tests/eval_harness.rs rename to crates/tui/tests/eval_harness.rs diff --git a/tests/palette_audit.rs b/crates/tui/tests/palette_audit.rs similarity index 86% rename from tests/palette_audit.rs rename to crates/tui/tests/palette_audit.rs index 8f34a024..aea0e036 100644 --- a/tests/palette_audit.rs +++ b/crates/tui/tests/palette_audit.rs @@ -13,11 +13,7 @@ use ratatui::style::Color; #[allow(dead_code)] mod palette; -/// Colors that should not be used directly in TUI code. -/// Use semantic aliases (STATUS_SUCCESS, STATUS_WARNING, etc.) instead. const DEPRECATED_DIRECT_COLORS: &[&str] = &["DEEPSEEK_AQUA"]; - -/// Patterns that indicate proper usage (in palette.rs definitions) const ALLOWED_PATTERNS: &[&str] = &["pub const DEEPSEEK_AQUA", "DEEPSEEK_AQUA_RGB"]; fn color_to_rgb(color: Color) -> (u8, u8, u8) { @@ -75,7 +71,6 @@ fn assert_min_contrast(label: &str, foreground: Color, background: Color, min_ra ); } -/// Audit a single file for deprecated color usage. fn audit_file(path: &Path, violations: &mut Vec) { let content = match fs::read_to_string(path) { Ok(c) => c, @@ -84,10 +79,8 @@ fn audit_file(path: &Path, violations: &mut Vec) { for (line_num, line) in content.lines().enumerate() { for deprecated in DEPRECATED_DIRECT_COLORS { - // Check for palette::DEPRECATED usage let pattern = format!("palette::{}", deprecated); if line.contains(&pattern) { - // Skip if this is an allowed pattern (definition) let is_allowed = ALLOWED_PATTERNS.iter().any(|p| line.contains(p)); if !is_allowed { violations.push(format!( @@ -102,7 +95,6 @@ fn audit_file(path: &Path, violations: &mut Vec) { } } -/// Recursively audit a directory for deprecated color usage. fn audit_directory(dir: &Path, violations: &mut Vec) { let entries = match fs::read_dir(dir) { Ok(e) => e, @@ -114,7 +106,6 @@ fn audit_directory(dir: &Path, violations: &mut Vec) { if path.is_dir() { audit_directory(&path, violations); } else if path.extension().is_some_and(|e| e == "rs") { - // Skip palette.rs itself (where colors are defined) if path.file_name().is_some_and(|n| n == "palette.rs") { continue; } @@ -147,7 +138,6 @@ fn verify_status_success_uses_sky() { let palette_path = Path::new(manifest_dir).join("src/palette.rs"); let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs"); - // Verify STATUS_SUCCESS is set to DEEPSEEK_SKY assert!( content.contains("pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;"), "STATUS_SUCCESS should use DEEPSEEK_SKY, not DEEPSEEK_AQUA" @@ -160,7 +150,6 @@ fn verify_brand_colors_defined() { let palette_path = Path::new(manifest_dir).join("src/palette.rs"); let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs"); - // Verify primary brand colors are defined (check for the constant names with values) assert!( content.contains("DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229);"), "DEEPSEEK_BLUE should be #3578E5" @@ -221,14 +210,4 @@ fn contrast_guardrails_for_key_ui_pairs() { palette::DEEPSEEK_SLATE, min_readable, ); - - for theme in ["default", "dark", "light", "whale"] { - let selection_bg = palette::ui_theme(theme).selection_bg; - assert_min_contrast( - &format!("SELECTION_TEXT on {theme} selection"), - palette::SELECTION_TEXT, - selection_bg, - min_readable, - ); - } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36dd33f5..904b6ece 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,10 @@ -# DeepSeek CLI Architecture +# DeepSeek TUI Architecture -This document provides an overview of the DeepSeek CLI architecture for developers and contributors. +This document provides an overview of the DeepSeek TUI architecture for developers and contributors. + +Current boundary note: +- `crates/tui` is still the live end-user runtime for the TUI, runtime API, task manager, and tool execution loop. +- Other workspace crates are being split out incrementally, but they are not yet the sole runtime source of truth. ## High-Level Overview @@ -111,7 +115,7 @@ Responses API (with automatic fallback if needed). - **`tui/`** - Terminal UI components (ratatui-based) - `app.rs` - Application state and message handling - - `ui.rs` - Rendering logic + - `ui.rs` - Event handling, streaming state, and rendering logic - `approval.rs` - Tool approval dialog - `clipboard.rs` - Clipboard handling - `streaming.rs` - Streaming text collector @@ -227,11 +231,11 @@ command = "echo 'Running tool: $TOOL_NAME'" ## Key Design Decisions 1. **Streaming-first**: All LLM responses stream for responsiveness -2. **Tool safety**: Non-yolo mode requires approval for destructive operations, including side-effectful MCP tools +2. **Tool safety**: Non-YOLO mode requires approval for destructive operations, including side-effectful MCP tools 3. **Extensibility**: MCP, skills, and hooks allow customization without code changes 4. **Cross-platform**: Core works on Linux/macOS/Windows, sandboxing macOS-only 5. **Minimal dependencies**: Careful dependency selection for build speed -6. **Local-first runtime API**: HTTP/SSE endpoints are intended for trusted localhost access +6. **Local-first runtime API**: HTTP/SSE endpoints are intended for trusted localhost access and are served by the `crates/tui` runtime today ## Configuration Files diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b9175ef8..c70c00a1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,6 +1,6 @@ # Configuration -DeepSeek CLI reads configuration from a TOML file plus environment variables. +DeepSeek TUI reads configuration from a TOML file plus environment variables. ## Where It Looks @@ -36,7 +36,7 @@ Select a profile with: - CLI: `deepseek --profile work` - Env: `DEEPSEEK_PROFILE=work` -If a profile is selected but missing, DeepSeek CLI exits with an error listing available profiles. +If a profile is selected but missing, DeepSeek TUI exits with an error listing available profiles. ## Environment Variables @@ -72,7 +72,7 @@ These override config values: ## Settings File (Persistent UI Preferences) -DeepSeek CLI also stores user preferences in: +DeepSeek TUI also stores user preferences in: - `~/.config/deepseek/settings.toml` @@ -163,7 +163,7 @@ want to force on or off. [features] shell_tool = true subagents = true -web_search = true # enables web.run and web_search +web_search = true # enables canonical web.run plus the compatibility web_search alias apply_patch = true mcp = true exec_policy = true @@ -178,7 +178,7 @@ Use `deepseek features list` to inspect known flags and their effective state. ## Managed Configuration and Requirements -DeepSeek CLI supports a policy layering model: +DeepSeek TUI supports a policy layering model: 1. user config + profile + env overrides 2. managed config (if present) diff --git a/docs/MCP.md b/docs/MCP.md index 89ef1eb6..c2b0c54e 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -1,6 +1,10 @@ # MCP (External Tool Servers) -DeepSeek CLI can load additional tools via MCP (Model Context Protocol). MCP servers are local processes that the CLI starts and communicates with over stdio. +DeepSeek TUI can load additional tools via MCP (Model Context Protocol). MCP servers are local processes that the TUI starts and communicates with over stdio. + +Browsing note: +- `web.run` is the canonical built-in browsing tool. +- `web_search` remains available as a compatibility alias for older prompts and integrations. Server mode note: - `deepseek serve --mcp` runs the MCP stdio server. @@ -83,6 +87,75 @@ The CLI also exposes helper tools when MCP is enabled: You can also use `mcpServers` instead of `servers` for compatibility with other clients. +## Running DeepSeek as an MCP Server + +You can register your local DeepSeek binary as an MCP server so other DeepSeek sessions (or any MCP client) can call its tools. + +### Quick Setup + +```bash +deepseek mcp add-self +``` + +This resolves the current binary path, generates a config entry that runs `deepseek serve --mcp`, and writes it to your MCP config file. The default server name is `deepseek`. + +Options: + +- `--name ` — custom server name (default: `deepseek`) +- `--workspace ` — workspace directory for the server + +### Manual Config + +Equivalent manual entry in `~/.deepseek/mcp.json`: + +```json +{ + "servers": { + "deepseek": { + "command": "/path/to/deepseek", + "args": ["serve", "--mcp"], + "env": {} + } + } +} +``` + +Either the `deepseek` or `deepseek-tui` binary works — both support `serve --mcp`. Use whichever is on your `PATH` (run `which deepseek` or `which deepseek-tui` to find the full path). The `mcp add-self` command automatically resolves the correct binary. + +### Prerequisites + +- The binary referenced in `command` must exist and be executable. +- The MCP server runs as a child process via stdio — no network ports required. +- Each MCP client session spawns its own server process. + +### Tool Naming + +Tools from a self-hosted DeepSeek server follow the standard naming convention: + +- `mcp_deepseek_` (if the server is named `deepseek`) + +For example, the `shell` tool becomes `mcp_deepseek_shell`. + +### MCP Server vs HTTP/SSE API + +| | `deepseek serve --mcp` | `deepseek serve --http` | +|---|---|---| +| **Protocol** | MCP stdio | HTTP/SSE JSON-RPC | +| **Use case** | Tool server for MCP clients | Runtime API for apps | +| **Config** | `~/.deepseek/mcp.json` entry | Direct URL connection | +| **Lifecycle** | Spawned per client session | Long-running daemon | + +Use `mcp add-self` when you want DeepSeek tools available to other MCP clients. Use `serve --http` when building applications that consume the API directly. + +### Verification + +After adding, test the connection: + +```bash +deepseek mcp validate +deepseek mcp tools deepseek +``` + ## Server Fields Per-server settings: diff --git a/docs/MODES.md b/docs/MODES.md index 52b56c24..d256b440 100644 --- a/docs/MODES.md +++ b/docs/MODES.md @@ -1,19 +1,30 @@ # Modes and Approvals -DeepSeek CLI has two related concepts: +DeepSeek TUI has two related concepts: - **TUI mode**: what kind of interaction you’re in (Normal/Plan/Agent/YOLO). - **Approval mode**: how aggressively the UI asks before executing tools. ## TUI Modes -Press `Tab` to cycle: **Plan → Agent → YOLO → Plan**. +Press `Tab` to cycle forward: **Normal → Agent → YOLO → Plan → Normal**. +Press `Shift+Tab` to cycle in reverse. - **Normal**: chat-first. Approvals for file writes, shell, and paid tools. - **Plan**: design-first prompting. Approvals match Normal. - **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt). - **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos. +## Escape Key Behavior + +`Esc` is a cancel stack, not a mode switch. + +- Close slash menus or transient UI first. +- Cancel the active request if a turn is running. +- Discard a queued draft if the composer is empty. +- Clear the current input if text is present. +- Otherwise it is a no-op. + ## Approval Mode You can override approval behavior at runtime: diff --git a/docs/OPERATIONS_RUNBOOK.md b/docs/OPERATIONS_RUNBOOK.md index 8cdf8850..5a974f00 100644 --- a/docs/OPERATIONS_RUNBOOK.md +++ b/docs/OPERATIONS_RUNBOOK.md @@ -1,4 +1,4 @@ -# DeepSeek CLI Operations Runbook +# DeepSeek TUI Operations Runbook This runbook covers practical debugging and incident response for the local CLI/TUI runtime. @@ -28,9 +28,9 @@ Checks: 3. Confirm no local sandbox/permission deadlock in tool output Actions: -1. Cancel current turn (`Esc` in TUI) +1. Cancel current turn (`Esc` in TUI while loading) 2. Retry prompt; if still failing, restart TUI -3. On restart, verify crash checkpoint recovery message appears +3. On restart, verify the previous queued/in-flight runtime turn is shown as interrupted rather than left in a running state ## Incident: Network Outage / Offline Behavior diff --git a/docs/RELEASE_RUNBOOK.md b/docs/RELEASE_RUNBOOK.md new file mode 100644 index 00000000..8b979c64 --- /dev/null +++ b/docs/RELEASE_RUNBOOK.md @@ -0,0 +1,156 @@ +# DeepSeek TUI Release Runbook + +This runbook is the source of truth for shipping Rust crates, GitHub release assets, +and the `deepseek-tui` npm wrapper. + +Current packaging note: +- `deepseek-tui` is the live runtime and TUI package shipped to users today. +- `deepseek-tui-core` is a supporting workspace crate for the extraction/parity effort, not a replacement for the shipping runtime. + +## Canonical Publish Targets + +- End-user crates: + - `deepseek-tui` + - `deepseek-tui-cli` +- Supporting crates published from this workspace: + - `deepseek-config` + - `deepseek-protocol` + - `deepseek-state` + - `deepseek-agent` + - `deepseek-execpolicy` + - `deepseek-hooks` + - `deepseek-mcp` + - `deepseek-tools` + - `deepseek-core` + - `deepseek-app-server` + - `deepseek-tui-core` +- `deepseek-cli` on crates.io is an unrelated crate and is not part of this release flow. + +## Version Coordination + +- Rust crates inherit the shared workspace version from [Cargo.toml](../Cargo.toml). +- Internal path dependency versions should match that workspace version; stale `0.3.30` pins are release blockers once the workspace moves to `0.3.31+`. +- The npm wrapper version lives in [npm/deepseek-tui/package.json](../npm/deepseek-tui/package.json). +- `deepseekBinaryVersion` controls which GitHub release binaries the npm wrapper downloads. +- Packaging-only npm releases are allowed: + - bump the npm package version + - leave `deepseekBinaryVersion` pinned to the previously released Rust binaries + - rerun `npm pack` smoke checks before `npm publish` + +## Preflight + +Run these from the repository root before cutting a tag: + +```bash +cargo fmt --all -- --check +cargo check --workspace --all-targets --locked +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo test --workspace --all-features --locked +cargo publish --dry-run --locked --allow-dirty -p deepseek-tui +./scripts/release/publish-crates.sh dry-run +``` + +`publish-crates.sh dry-run` performs a full `cargo publish --dry-run` for crates +without unpublished workspace dependencies and a packaging preflight for dependent +workspace crates. That avoids false negatives from crates.io not yet containing the +new workspace version while still validating package contents before publish. + +For npm wrapper verification: + +```bash +cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui +node scripts/release/prepare-local-release-assets.js +python3 -m http.server 8123 --directory target/npm-release-assets +cd npm/deepseek-tui +DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npm pack +``` + +Then install the generated tarball in a clean temp directory and smoke the entrypoints: + +```bash +tmpdir="$(mktemp -d)" +cd "${tmpdir}" +npm init -y +DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npm install /path/to/deepseek-tui-*.tgz +DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npx --no-install deepseek --help +DEEPSEEK_TUI_FORCE_DOWNLOAD=1 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npx --no-install deepseek-tui --help +``` + +To exercise `npm run release:check` locally as well, regenerate the local asset +directory with a full asset matrix fixture before starting the server: + +```bash +DEEPSEEK_TUI_PREPARE_ALL_ASSETS=1 node scripts/release/prepare-local-release-assets.js +cd npm/deepseek-tui +DEEPSEEK_TUI_VERSION=0.3.31 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npm run release:check +``` + +The CI workflow runs the same tarball install + smoke test on Linux and macOS. + +## Rust Crates Release + +1. Update the workspace version in [Cargo.toml](../Cargo.toml). +2. Tag the release as `vX.Y.Z`. +3. Let `.github/workflows/crates-publish.yml` verify the workspace version and dry-run each crate. +4. Publish crates in this order: + - `deepseek-config` + - `deepseek-protocol` + - `deepseek-state` + - `deepseek-agent` + - `deepseek-execpolicy` + - `deepseek-hooks` + - `deepseek-mcp` + - `deepseek-tools` + - `deepseek-core` + - `deepseek-app-server` + - `deepseek-tui-core` + - `deepseek-tui-cli` + - `deepseek-tui` +5. Wait for each published crate version to appear on crates.io before publishing dependents. + +The publish helper is idempotent for reruns: already-published crate versions are skipped. + +## GitHub Release Assets + +`.github/workflows/release.yml` builds these binaries: + +- `deepseek-linux-x64` +- `deepseek-macos-x64` +- `deepseek-macos-arm64` +- `deepseek-windows-x64.exe` +- `deepseek-tui-linux-x64` +- `deepseek-tui-macos-x64` +- `deepseek-tui-macos-arm64` +- `deepseek-tui-windows-x64.exe` + +The release job also uploads `deepseek-artifacts-sha256.txt`. The npm installer and +release verification script both depend on that checksum manifest. + +## npm Wrapper Release + +1. Set the npm package version in [npm/deepseek-tui/package.json](../npm/deepseek-tui/package.json). +2. Set `deepseekBinaryVersion` to the GitHub release tag that should supply binaries. +3. Run: + +```bash +cd npm/deepseek-tui +npm pack +npm publish +``` + +`prepublishOnly` verifies that all expected release assets and the checksum manifest exist. + +## Recovery and Rollback + +- Crates publish partially: + - rerun `./scripts/release/publish-crates.sh publish` + - already-published crate versions will be skipped +- GitHub assets missing or checksum manifest incomplete: + - fix `.github/workflows/release.yml` + - retag or upload corrected assets before `npm publish` +- npm packaging-only problem: + - bump only the npm package version + - keep `deepseekBinaryVersion` on the last known-good Rust release + - repack and republish the wrapper +- A bad npm publish cannot be overwritten: + - publish a new npm version with corrected metadata or install logic diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 4a5e83fc..62f33301 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -1,6 +1,6 @@ # Runtime API (HTTP/SSE) -DeepSeek CLI can expose a local runtime API for external clients: +DeepSeek TUI can expose a local runtime API for external clients: ```bash deepseek serve --http --host 127.0.0.1 --port 7878 --workers 2 @@ -10,6 +10,10 @@ Defaults: - bind: `127.0.0.1:7878` - workers: `2` (clamped to `1..8`) +Implementation note: +- The current production runtime lives in `crates/tui` (`runtime_api.rs`, `runtime_threads.rs`, `task_manager.rs`). +- Workspace crate extraction is in progress, but external behavior should be read from the `crates/tui` implementation today. + ## Security Model (Local-First) - The server is designed for trusted local use. @@ -40,6 +44,13 @@ The event log is append-only with global monotonic `seq` for replay/resume. Session resume note: - Saved session `system_prompt` currently round-trips as plain text. Structured `SystemPrompt::Blocks` metadata is not preserved when resuming into runtime threads. +Restart note: +- If the process restarts while a turn or item is `queued` or `in_progress`, the recovered record is marked `interrupted` with an `"Interrupted by process restart"` error instead of remaining stuck in a live state. + +Approval note: +- `auto_approve` applies to the runtime approval bridge and the engine tool context. When enabled for a thread/turn/task, approval-required tools are auto-approved in the non-interactive runtime path, shell safety checks run in auto-approved mode, and spawned subagents inherit that effective setting for their own tool context. +- If omitted when creating a thread or starting `/v1/stream`, `auto_approve` defaults to `false`. + ## Endpoints ### Health and Session @@ -231,4 +242,5 @@ Runtime store (default under task data root): Task store: - default `~/.deepseek/tasks` (override with `DEEPSEEK_TASKS_DIR`) -Both runtime and task state are restart-safe. +Both runtime and task state are restart-aware. +Queued or in-progress runtime turns reload as `interrupted`; task execution performs its own recovery on top of the same persisted thread/turn store. diff --git a/docs/parity_release_and_ci.md b/docs/parity_release_and_ci.md index 30491c28..3ba71cb3 100644 --- a/docs/parity_release_and_ci.md +++ b/docs/parity_release_and_ci.md @@ -18,18 +18,21 @@ This repository now includes parity-oriented CI checks under `.github/workflows/ - `git diff --exit-code -- Cargo.lock` The tag-based release workflow now runs the same parity preflight before building artifacts. +For the full operator-facing publish flow, see [docs/RELEASE_RUNBOOK.md](./RELEASE_RUNBOOK.md). ## Expected contributor flow -1. Update workspace crates (`core`, `app-server`, `protocol`, `state`, `tools`, `mcp`, `execpolicy`, `hooks`, `tui`, `cli`). -2. Keep protocol and persistence tests green for parity-sensitive changes. -3. Ensure thread/tool/mcp event contracts remain backward-compatible across app-server endpoints. +1. Treat `crates/tui` as the current runtime source of truth for shipped behavior, even while workspace extraction continues. +2. Update workspace crates (`core`, `app-server`, `protocol`, `state`, `tools`, `mcp`, `execpolicy`, `hooks`, `tui`, `cli`) as needed. +3. Keep protocol and persistence tests green for parity-sensitive changes. +4. Ensure thread/tool/mcp event contracts remain backward-compatible across app-server endpoints. ## Release readiness checklist - CLI and app-server binaries compile from workspace members. - Session persistence schema changes include migration-safe SQL updates. - Protocol changes include test updates in `crates/protocol/tests`. -- New tool lifecycle behavior includes tests in `crates/tools/tests`. -- TUI reducer changes include deterministic snapshot updates in `crates/tui/tests`. +- New tool/runtime lifecycle behavior includes tests in the crate that owns the implementation, which is frequently `crates/tui` today. +- TUI reducer/runtime changes include deterministic snapshot or integration updates in `crates/tui/tests`. +- Root-level `tests/` is intentionally empty; workspace-executed TUI integration coverage lives under `crates/tui/tests`. - Release artifacts include `deepseek` (CLI) and `deepseek-tui` (TUI) binaries for all platforms. diff --git a/npm/deepseek-tui/README.md b/npm/deepseek-tui/README.md index 4bcc173c..fc0c0fd0 100644 --- a/npm/deepseek-tui/README.md +++ b/npm/deepseek-tui/README.md @@ -1,19 +1,24 @@ # deepseek-tui -This package installs the `deepseek` and `deepseek-tui` binaries from the -`DeepSeek-TUI` GitHub release artifacts and exposes them as Node-compatible -console entry points. +Install and run the `deepseek` and `deepseek-tui` binaries from GitHub release artifacts. ## Install ```bash -npm install deepseek-tui +npm install -g deepseek-tui # or -pnpm add deepseek-tui +pnpm add -g deepseek-tui ``` -This runs `postinstall`, downloads the platform-specific binaries for version -`0.3.28`, and makes `deepseek` and `deepseek-tui` available on your PATH. +For project-local usage: + +```bash +npm install deepseek-tui +npx deepseek-tui --help +``` + +`postinstall` downloads platform binaries into `bin/downloads/` and exposes +`deepseek` and `deepseek-tui` commands. ## Supported platforms @@ -21,11 +26,21 @@ This runs `postinstall`, downloads the platform-specific binaries for version - macOS x64 / arm64 - Windows x64 -## Notes +Other platform/architecture combinations are not supported and will fail during install. -- Binaries come directly from release assets in - `https://github.com/Hmbown/DeepSeek-TUI/releases`. -- Set `DEEPSEEK_VERSION` to install a different release version (defaults to package version). -- Set `DEEPSEEK_GITHUB_REPO` to override the repo source (defaults to `Hmbown/DeepSeek-TUI`). +## Configuration + +- Default binary version comes from `deepseekBinaryVersion` in `package.json`. +- Set `DEEPSEEK_TUI_VERSION` or `DEEPSEEK_VERSION` to override the release version. +- Set `DEEPSEEK_TUI_GITHUB_REPO` or `DEEPSEEK_GITHUB_REPO` to override the source repo (defaults to `Hmbown/DeepSeek-TUI`). - Set `DEEPSEEK_TUI_FORCE_DOWNLOAD=1` to force download even when the cached binary is already present. - Set `DEEPSEEK_TUI_DISABLE_INSTALL=1` to skip install-time download. + +## Release integrity + +- `npm publish` runs a release-asset check to ensure all required binary assets + exist for the target GitHub release before publishing. +- Install-time downloads are verified against the release checksum manifest before + the wrapper marks them executable. +- Set `DEEPSEEK_TUI_RELEASE_BASE_URL` to point the installer at a local or + staged release-asset directory for smoke tests. diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 6fb07f71..0a8effc1 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,6 +1,7 @@ { "name": "deepseek-tui", - "version": "0.3.28", + "version": "0.3.31", + "deepseekBinaryVersion": "0.3.31", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT", @@ -26,12 +27,17 @@ "deepseek-tui": "bin/deepseek-tui.js" }, "scripts": { + "release:check": "node scripts/verify-release-assets.js", "postinstall": "node scripts/install.js", + "prepublishOnly": "node scripts/verify-release-assets.js", "prepack": "node scripts/install.js" }, "engines": { "node": ">=18" }, + "publishConfig": { + "access": "public" + }, "preferGlobal": true, "files": [ "bin/*.js", diff --git a/npm/deepseek-tui/scripts/artifacts.js b/npm/deepseek-tui/scripts/artifacts.js index c79ad352..8dbc7148 100644 --- a/npm/deepseek-tui/scripts/artifacts.js +++ b/npm/deepseek-tui/scripts/artifacts.js @@ -1,19 +1,19 @@ const path = require("path"); const os = require("os"); +const CHECKSUM_MANIFEST = "deepseek-artifacts-sha256.txt"; + const ASSET_MATRIX = { linux: { x64: ["deepseek-linux-x64", "deepseek-tui-linux-x64"], - default: ["deepseek-linux-x64", "deepseek-tui-linux-x64"], + // arm64: ["deepseek-linux-arm64", "deepseek-tui-linux-arm64"], // Uncomment when binaries are available }, darwin: { x64: ["deepseek-macos-x64", "deepseek-tui-macos-x64"], arm64: ["deepseek-macos-arm64", "deepseek-tui-macos-arm64"], - default: ["deepseek-macos-x64", "deepseek-tui-macos-x64"], }, win32: { x64: ["deepseek-windows-x64.exe", "deepseek-tui-windows-x64.exe"], - default: ["deepseek-windows-x64.exe", "deepseek-tui-windows-x64.exe"], }, }; @@ -22,9 +22,14 @@ function detectBinaryNames() { const arch = os.arch(); const defaults = ASSET_MATRIX[platform]; if (!defaults) { - throw new Error(`Unsupported platform: ${platform}`); + const supported = Object.keys(ASSET_MATRIX).map(p => `'${p}'`).join(', '); + throw new Error(`Unsupported platform: ${platform}. Supported platforms: ${supported}`); + } + const pair = defaults[arch]; + if (!pair) { + const supported = Object.keys(defaults).map(a => `'${a}'`).join(', '); + throw new Error(`Unsupported architecture: ${arch} on platform ${platform}. Supported architectures: ${supported}`); } - const pair = defaults[arch] || defaults.default; return { platform, arch, @@ -37,17 +42,50 @@ function executableName(base, platform) { return platform === "win32" ? `${base}.exe` : base; } +function releaseBaseUrl(version, repo = "Hmbown/DeepSeek-TUI") { + const override = + process.env.DEEPSEEK_TUI_RELEASE_BASE_URL || process.env.DEEPSEEK_RELEASE_BASE_URL; + if (override) { + const trimmed = String(override).trim(); + return trimmed.endsWith("/") ? trimmed : `${trimmed}/`; + } + return `https://github.com/${repo}/releases/download/v${version}/`; +} + function releaseAssetUrl(baseName, version, repo = "Hmbown/DeepSeek-TUI") { - return `https://github.com/${repo}/releases/download/v${version}/${baseName}`; + return new URL(baseName, releaseBaseUrl(version, repo)).toString(); +} + +function checksumManifestUrl(version, repo = "Hmbown/DeepSeek-TUI") { + return releaseAssetUrl(CHECKSUM_MANIFEST, version, repo); } function releaseBinaryDirectory() { return path.join(__dirname, "..", "bin", "downloads"); } +function allAssetNames() { + const names = []; + for (const platformAssets of Object.values(ASSET_MATRIX)) { + for (const pair of Object.values(platformAssets)) { + names.push(pair[0], pair[1]); + } + } + return Array.from(new Set(names)); +} + +function allReleaseAssetNames() { + return [...allAssetNames(), CHECKSUM_MANIFEST]; +} + module.exports = { + allAssetNames, + allReleaseAssetNames, + CHECKSUM_MANIFEST, + checksumManifestUrl, detectBinaryNames, executableName, releaseAssetUrl, + releaseBaseUrl, releaseBinaryDirectory, }; diff --git a/npm/deepseek-tui/scripts/install.js b/npm/deepseek-tui/scripts/install.js index 2bd6a358..a325938e 100644 --- a/npm/deepseek-tui/scripts/install.js +++ b/npm/deepseek-tui/scripts/install.js @@ -1,20 +1,27 @@ const fs = require("fs"); const https = require("https"); const http = require("http"); -const { mkdir, chmod, stat, rename, readFile, writeFile } = fs.promises; +const crypto = require("crypto"); +const { mkdir, chmod, stat, rename, readFile, unlink, writeFile } = fs.promises; const { createWriteStream } = fs; const { pipeline } = require("stream/promises"); const path = require("path"); const { + checksumManifestUrl, detectBinaryNames, releaseAssetUrl, releaseBinaryDirectory, } = require("./artifacts"); +const pkg = require("../package.json"); function resolvePackageVersion() { - const pkg = require("../package.json"); - return process.env.DEEPSEEK_TUI_VERSION || process.env.DEEPSEEK_VERSION || pkg.version; + const configuredVersion = + process.env.DEEPSEEK_TUI_VERSION || + process.env.DEEPSEEK_VERSION || + pkg.deepseekBinaryVersion || + pkg.version; + return String(configuredVersion).trim(); } function resolveRepo() { @@ -64,6 +71,19 @@ async function download(url, destination) { await pipeline(resolved.response, createWriteStream(destination)); } +async function downloadText(url) { + const resolved = await httpGet(url); + if (resolved.redirect) { + return downloadText(resolved.redirect); + } + const chunks = []; + resolved.response.setEncoding("utf8"); + for await (const chunk of resolved.response) { + chunks.push(chunk); + } + return chunks.join(""); +} + async function readLocalVersion(file) { return readFile(file, "utf8").catch(() => ""); } @@ -77,7 +97,45 @@ async function fileExists(file) { } } -async function ensureBinary(targetPath, assetName, version, repo) { +function parseChecksumManifest(text) { + const checksums = new Map(); + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (!match) { + throw new Error(`Invalid checksum manifest line: ${trimmed}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +async function sha256File(filePath) { + const content = await readFile(filePath); + return crypto.createHash("sha256").update(content).digest("hex"); +} + +async function verifyChecksum(filePath, assetName, checksums) { + const expected = checksums.get(assetName); + if (!expected) { + throw new Error(`Checksum manifest is missing ${assetName}`); + } + const actual = await sha256File(filePath); + if (actual !== expected) { + throw new Error( + `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`, + ); + } +} + +async function loadChecksums(version, repo) { + return parseChecksumManifest(await downloadText(checksumManifestUrl(version, repo))); +} + +async function ensureBinary(targetPath, assetName, version, repo, checksums) { const marker = `${targetPath}.version`; const downloadIfNeeded = process.env.DEEPSEEK_TUI_FORCE_DOWNLOAD === "1" || process.env.DEEPSEEK_FORCE_DOWNLOAD === "1"; @@ -86,6 +144,7 @@ async function ensureBinary(targetPath, assetName, version, repo) { if (existing) { const markerVersion = await readLocalVersion(marker); if (markerVersion === String(version)) { + await verifyChecksum(targetPath, assetName, checksums); return targetPath; } } @@ -93,6 +152,12 @@ async function ensureBinary(targetPath, assetName, version, repo) { const url = releaseAssetUrl(assetName, version, repo); const destination = `${targetPath}.download`; await download(url, destination); + try { + await verifyChecksum(destination, assetName, checksums); + } catch (error) { + await unlink(destination).catch(() => {}); + throw error; + } if (process.platform !== "win32") { await chmod(destination, 0o755); } @@ -110,10 +175,11 @@ async function run() { const paths = binaryPaths(); const releaseDir = releaseBinaryDirectory(); await mkdir(releaseDir, { recursive: true }); + const checksums = await loadChecksums(version, repo); await Promise.all([ - ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo), - ensureBinary(paths.tui.target, paths.tui.asset, version, repo), + ensureBinary(paths.deepseek.target, paths.deepseek.asset, version, repo, checksums), + ensureBinary(paths.tui.target, paths.tui.asset, version, repo, checksums), ]); } diff --git a/npm/deepseek-tui/scripts/verify-release-assets.js b/npm/deepseek-tui/scripts/verify-release-assets.js new file mode 100644 index 00000000..3938eedb --- /dev/null +++ b/npm/deepseek-tui/scripts/verify-release-assets.js @@ -0,0 +1,140 @@ +const https = require("https"); +const http = require("http"); +const { + allAssetNames, + allReleaseAssetNames, + checksumManifestUrl, + releaseAssetUrl, +} = require("./artifacts"); + +const pkg = require("../package.json"); + +function resolveBinaryVersion() { + const configuredVersion = + process.env.DEEPSEEK_TUI_VERSION || + process.env.DEEPSEEK_VERSION || + pkg.deepseekBinaryVersion || + pkg.version; + return String(configuredVersion).trim(); +} + +function resolveRepo() { + return process.env.DEEPSEEK_TUI_GITHUB_REPO || process.env.DEEPSEEK_GITHUB_REPO || "Hmbown/DeepSeek-TUI"; +} + +function requestStatus(url, method = "HEAD", redirects = 0) { + if (redirects > 10) { + throw new Error(`Too many redirects while checking ${url}`); + } + const client = url.startsWith("https:") ? https : http; + return new Promise((resolve, reject) => { + const req = client.request( + url, + { + method, + headers: { + "User-Agent": "deepseek-tui-npm-release-check", + }, + }, + (res) => { + const status = res.statusCode || 0; + const location = res.headers.location; + res.resume(); + if (status >= 300 && status < 400 && location) { + const next = new URL(location, url).toString(); + resolve(requestStatus(next, method, redirects + 1)); + return; + } + resolve(status); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +async function verifyAsset(url, label) { + let status = await requestStatus(url, "HEAD"); + if (status === 403 || status === 405) { + status = await requestStatus(url, "GET"); + } + if (status < 200 || status >= 400) { + throw new Error(`${label} returned HTTP ${status} (${url})`); + } +} + +async function downloadText(url) { + const client = url.startsWith("https:") ? https : http; + return new Promise((resolve, reject) => { + client + .get( + url, + { + headers: { + "User-Agent": "deepseek-tui-npm-release-check", + }, + }, + (res) => { + const status = res.statusCode || 0; + if (status >= 300 && status < 400 && res.headers.location) { + const next = new URL(res.headers.location, url).toString(); + resolve(downloadText(next)); + return; + } + if (status !== 200) { + reject(new Error(`Request failed with status ${status}: ${url}`)); + res.resume(); + return; + } + const chunks = []; + res.setEncoding("utf8"); + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve(chunks.join(""))); + }, + ) + .on("error", reject); + }); +} + +function parseChecksumManifest(text) { + const checksums = new Map(); + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (!match) { + throw new Error(`Invalid checksum manifest line: ${trimmed}`); + } + checksums.set(match[2], match[1].toLowerCase()); + } + return checksums; +} + +async function run() { + const version = resolveBinaryVersion(); + const repo = resolveRepo(); + const assets = allReleaseAssetNames(); + + console.log(`Verifying ${assets.length} release assets for ${repo}@v${version}...`); + for (const asset of assets) { + const url = releaseAssetUrl(asset, version, repo); + await verifyAsset(url, asset); + console.log(` ok ${asset}`); + } + const checksums = parseChecksumManifest( + await downloadText(checksumManifestUrl(version, repo)), + ); + for (const asset of allAssetNames()) { + if (!checksums.has(asset)) { + throw new Error(`Checksum manifest is missing ${asset}`); + } + } + console.log("Release assets verified."); +} + +run().catch((error) => { + console.error("Release asset verification failed:", error.message); + process.exit(1); +}); diff --git a/scripts/release/prepare-local-release-assets.js b/scripts/release/prepare-local-release-assets.js new file mode 100755 index 00000000..404fc6e8 --- /dev/null +++ b/scripts/release/prepare-local-release-assets.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +const crypto = require("crypto"); +const fs = require("fs/promises"); +const path = require("path"); + +const { + allAssetNames, + CHECKSUM_MANIFEST, + detectBinaryNames, +} = require("../../npm/deepseek-tui/scripts/artifacts"); + +async function sha256(filePath) { + const content = await fs.readFile(filePath); + return crypto.createHash("sha256").update(content).digest("hex"); +} + +async function main() { + const prepareAllAssets = + process.env.DEEPSEEK_TUI_PREPARE_ALL_ASSETS === "1" || + process.env.DEEPSEEK_PREPARE_ALL_ASSETS === "1"; + const outputDir = path.resolve( + process.argv[2] || path.join("target", "npm-release-assets"), + ); + const buildDir = path.resolve( + process.argv[3] || path.join("target", "release"), + ); + const { deepseek, tui } = detectBinaryNames(); + const isWindows = process.platform === "win32"; + + const assets = [ + { + source: path.join(buildDir, isWindows ? "deepseek.exe" : "deepseek"), + target: deepseek, + }, + { + source: path.join(buildDir, isWindows ? "deepseek-tui.exe" : "deepseek-tui"), + target: tui, + }, + ]; + + if (prepareAllAssets) { + for (const assetName of allAssetNames()) { + if (assets.some((asset) => asset.target === assetName)) { + continue; + } + assets.push({ + source: assetName.startsWith("deepseek-tui") + ? path.join(buildDir, isWindows ? "deepseek-tui.exe" : "deepseek-tui") + : path.join(buildDir, isWindows ? "deepseek.exe" : "deepseek"), + target: assetName, + }); + } + } + + await fs.mkdir(outputDir, { recursive: true }); + + const manifestLines = []; + for (const asset of assets) { + const outputPath = path.join(outputDir, asset.target); + await fs.copyFile(asset.source, outputPath); + manifestLines.push(`${await sha256(outputPath)} ${asset.target}`); + } + + manifestLines.sort(); + const manifestPath = path.join(outputDir, CHECKSUM_MANIFEST); + await fs.writeFile(manifestPath, `${manifestLines.join("\n")}\n`, "utf8"); + + console.log(`Prepared ${assets.length} assets in ${outputDir}`); + console.log(`Wrote checksum manifest ${manifestPath}`); +} + +main().catch((error) => { + console.error("Failed to prepare local release assets:", error.message); + process.exit(1); +}); diff --git a/scripts/release/publish-crates.sh b/scripts/release/publish-crates.sh new file mode 100755 index 00000000..0a3eef60 --- /dev/null +++ b/scripts/release/publish-crates.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +mode="${1:-dry-run}" +case "${mode}" in + dry-run|publish) ;; + *) + echo "usage: $0 [dry-run|publish]" >&2 + exit 1 + ;; +esac + +packages=( + deepseek-config + deepseek-protocol + deepseek-state + deepseek-agent + deepseek-execpolicy + deepseek-hooks + deepseek-mcp + deepseek-tools + deepseek-core + deepseek-app-server + deepseek-tui-core + deepseek-tui-cli + deepseek-tui +) + +workspace_version="$( + python3 - <<'PY' +import json +import subprocess + +metadata = json.loads( + subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]) +) +workspace_members = set(metadata["workspace_members"]) +for pkg in metadata["packages"]: + if pkg["id"] in workspace_members: + print(pkg["version"]) + break +PY +)" + +package_has_workspace_deps() { + local package_name="$1" + python3 - "${package_name}" <<'PY' +import json +import subprocess +import sys + +package_name = sys.argv[1] +metadata = json.loads( + subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]) +) +workspace_ids = set(metadata["workspace_members"]) +workspace_packages = { + pkg["name"]: pkg for pkg in metadata["packages"] if pkg["id"] in workspace_ids +} +package = workspace_packages[package_name] +has_workspace_dep = any( + dep.get("path") and dep["name"] in workspace_packages + for dep in package["dependencies"] +) +print("1" if has_workspace_dep else "0") +PY +} + +crate_version_exists() { + local crate_name="$1" + local crate_version="$2" + curl -fsSL "https://crates.io/api/v1/crates/${crate_name}/${crate_version}" >/dev/null 2>&1 +} + +wait_for_crate_version() { + local crate_name="$1" + local crate_version="$2" + local attempts=30 + + for ((attempt = 1; attempt <= attempts; attempt += 1)); do + if crate_version_exists "${crate_name}" "${crate_version}"; then + return 0 + fi + echo "Waiting for ${crate_name} ${crate_version} to appear on crates.io (${attempt}/${attempts})..." + sleep 10 + done + + echo "Timed out waiting for ${crate_name} ${crate_version} to appear on crates.io" >&2 + return 1 +} + +for package in "${packages[@]}"; do + echo "::group::${mode} ${package}" + if [[ "${mode}" == "dry-run" ]]; then + if [[ "$(package_has_workspace_deps "${package}")" == "1" ]]; then + cargo package --allow-dirty --locked --list -p "${package}" >/dev/null + echo "Verified package contents for ${package}; full crates.io dry-run requires workspace dependencies at ${workspace_version} to be published first." + else + cargo publish --dry-run --locked --allow-dirty -p "${package}" + fi + else + if crate_version_exists "${package}" "${workspace_version}"; then + echo "Skipping ${package} ${workspace_version}; already published." + else + cargo publish --locked -p "${package}" + wait_for_crate_version "${package}" "${workspace_version}" + fi + fi + echo "::endgroup::" +done diff --git a/scripts/release/verify-workspace-version.sh b/scripts/release/verify-workspace-version.sh new file mode 100755 index 00000000..69b9499a --- /dev/null +++ b/scripts/release/verify-workspace-version.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +expected_version="${1:-}" +if [[ -z "${expected_version}" && "${GITHUB_REF:-}" == refs/tags/v* ]]; then + expected_version="${GITHUB_REF#refs/tags/v}" +fi + +if [[ -z "${expected_version}" ]]; then + echo "usage: $0 " >&2 + exit 1 +fi + +python3 - "${expected_version}" <<'PY' +import json +import subprocess +import sys + +expected = sys.argv[1] +metadata = json.loads( + subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]) +) +workspace_members = set(metadata["workspace_members"]) +packages = [pkg for pkg in metadata["packages"] if pkg["id"] in workspace_members] +mismatches = [ + f"{pkg['name']}={pkg['version']}" for pkg in packages if pkg["version"] != expected +] + +if mismatches: + print(f"Tag version {expected} does not match all workspace crates:", file=sys.stderr) + for item in mismatches: + print(f" - {item}", file=sys.stderr) + sys.exit(1) + +print(f"Verified {len(packages)} workspace packages at version {expected}") +PY