From dc8e94d705931d7fe0cf4e40b25c694b6937aaba Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 22 Apr 2026 22:36:45 -0500 Subject: [PATCH] docs: update documentation and cleanup for v0.3.33 --- .claude/commands/init-trimtab.md | 35 ---- .gitignore | 10 +- .trimtab/init-trimtab-protocol.md | 151 ----------------- AGENTS.md | 12 -- CHANGELOG.md | 3 +- CONTRIBUTING.md | 52 +++--- DEPENDENCY_GRAPH.md | 57 +------ 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/src/core/engine.rs | 99 ++++++++++- crates/tui/src/core/engine/tests.rs | 80 +++++++++ crates/tui/src/prompts.rs | 19 +++ crates/tui/src/prompts/plan.txt | 23 ++- crates/tui/src/tui/plan_prompt.rs | 171 +++++++++++++++---- crates/tui/src/tui/ui.rs | 125 ++++++++++---- crates/tui/src/tui/ui/tests.rs | 40 +++++ crates/tui/src/tui/user_input.rs | 244 ++++++++++++++++++++------- crates/tui/src/tui/widgets/header.rs | 68 +++++++- docs/CONFIGURATION.md | 29 ++-- docs/MCP.md | 48 +++--- npm/deepseek-tui/package.json | 4 +- 27 files changed, 832 insertions(+), 494 deletions(-) delete mode 100644 .claude/commands/init-trimtab.md delete mode 100644 .trimtab/init-trimtab-protocol.md diff --git a/.claude/commands/init-trimtab.md b/.claude/commands/init-trimtab.md deleted file mode 100644 index feca2d5c..00000000 --- a/.claude/commands/init-trimtab.md +++ /dev/null @@ -1,35 +0,0 @@ -# /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/.gitignore b/.gitignore index dea1497d..6a1b8dd8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,16 +34,13 @@ dist/ # Logs *.log -firebase-debug.log # Generated outputs/ - -# Rust -# Note: Cargo.lock is intentionally NOT ignored for reproducible builds -firebase-debug.log tmp/ +# Note: Cargo.lock is intentionally NOT ignored for reproducible builds + # Local dev scripts and temp files *.sh !scripts/** @@ -57,12 +54,11 @@ result.json count_deps.py project_overhaul_prompt.md .codex/ -docs/rlm-paper.txt .context/ # Local runtime state .deepseek/ -session_*.json +**/session_*.json *.db # Companion app (tracked separately) diff --git a/.trimtab/init-trimtab-protocol.md b/.trimtab/init-trimtab-protocol.md deleted file mode 100644 index 919355f3..00000000 --- a/.trimtab/init-trimtab-protocol.md +++ /dev/null @@ -1,151 +0,0 @@ -# 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: Plan, Agent, YOLO (visible). Hidden `/normal` and legacy `default_mode = "normal"` normalize to Agent. diff --git a/AGENTS.md b/AGENTS.md index 4ec40a32..24d722d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,18 +14,6 @@ This file provides context for AI assistants working on this project. ### Documentation See README.md for project overview, docs/ARCHITECTURE.md for internals. -## 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` (6 open items with priorities) -- **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 context or separate sub-agent). - ## DeepSeek-Specific Notes - **Thinking Tokens**: DeepSeek models output thinking blocks (`ContentBlock::Thinking`) before final answers. The TUI streams and displays these with visual distinction. diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb497e9..65163082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -443,7 +443,8 @@ 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.32...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.33...HEAD +[0.3.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...v0.3.33 [0.3.32]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...v0.3.32 [0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.31 [0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10be78c2..ac489c95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,8 +45,11 @@ Thank you for your interest in contributing to DeepSeek TUI! This document provi ### Testing - Write tests for new functionality -- Ensure all existing tests pass: `cargo test` -- For integration tests, use the `tests/` directory +- Ensure all existing tests pass: `cargo test --workspace --all-features` +- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]` + modules), and add integration tests under the owning crate's `tests/` + directory (for example `crates/tui/tests/` or `crates/state/tests/`). The + repository root `tests/` directory is not used ### Commit Messages @@ -59,35 +62,34 @@ Use clear, descriptive commit messages following conventional commits: - `test:` Adding or updating tests - `chore:` Maintenance tasks -Example: `feat: add --doctor command for system diagnostics` +Example: `feat: add doctor subcommand for system diagnostics` ## Project Structure +DeepSeek TUI is a Cargo workspace. The live runtime and the majority of TUI, +engine, and tool code currently live in `crates/tui/src/`. Smaller workspace +crates provide shared abstractions that are being extracted incrementally. + ``` -src/ -├── main.rs # Entry point and CLI definition -├── config.rs # Configuration management -├── client.rs # HTTP client for DeepSeek API -├── llm_client.rs # LLM abstraction layer -├── models.rs # Data structures -├── mcp.rs # Model Context Protocol support -├── hooks.rs # Hook system for extensibility -├── skills.rs # Skills/plugin system -├── core/ # Core engine components -│ ├── engine.rs # Main agent loop -│ ├── session.rs # Session management -│ └── ... -├── tools/ # Built-in tools -│ ├── shell.rs # Shell execution -│ ├── file.rs # File operations -│ └── ... -├── tui/ # Terminal UI -│ ├── app.rs # Application state -│ ├── ui.rs # Rendering logic -│ └── ... -└── sandbox/ # Sandbox execution (macOS) +crates/ +├── tui/ deepseek-tui binary (interactive TUI + runtime API) +├── cli/ deepseek binary (dispatcher facade) +├── app-server/ HTTP/SSE + JSON-RPC transport +├── core/ Agent loop / session / turn management +├── protocol/ Request/response framing +├── config/ Config loading, profiles, env precedence +├── state/ SQLite thread/session persistence +├── tools/ Typed tool specs and lifecycle +├── mcp/ MCP client + stdio server +├── hooks/ Lifecycle hooks (stdout/jsonl/webhook) +├── execpolicy/ Approval/sandbox policy engine +├── agent/ Model/provider registry +└── tui-core/ Event-driven TUI state machine scaffold ``` +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the live data flow across +these crates and [DEPENDENCY_GRAPH.md](DEPENDENCY_GRAPH.md) for build ordering. + ## Submitting Changes 1. Create a feature branch from `main`: diff --git a/DEPENDENCY_GRAPH.md b/DEPENDENCY_GRAPH.md index 70d93d78..823d4dfe 100644 --- a/DEPENDENCY_GRAPH.md +++ b/DEPENDENCY_GRAPH.md @@ -4,7 +4,7 @@ ``` deepseek-tui (binary: `deepseek-tui`) - (no workspace deps — uses monolith src/ directly) + (no workspace deps — monolith source under crates/tui/src/) deepseek-tui-cli (binary: `deepseek`) <- deepseek-agent @@ -48,8 +48,9 @@ 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. +monolith source tree (`crates/tui/src/main.rs`). The crate split is +structural — source migration into individual workspace crates is +incremental. ## Build Order (bottom-up) @@ -62,53 +63,3 @@ 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) ← DONE (v0.3.31) - -> landed: mode/model/token/cost layout, quadrant separators, context bar - -> remaining polish tracked in AI_HANDOFF.md - -[High] SHA-2795 Thinking vs Normal Chat Delineation ← DONE (v0.3.31) - -> landed: labeled delimiters, separate transcript cell, show_thinking - -[High] SHA-2798 Finance Tool Replacement ← DONE (v0.3.31) - -> landed: Yahoo Finance v8 + CoinGecko fallback - -[Med] SHA-2796 Intelligent Compaction UX ← DONE (v0.3.31) - -> landed: auto-compaction, /compact, status strip, CompactionCompleted stats - -[Med] SHA-2797 Escape Key After Plan Mode - -> base fix landed (v0.3.31); remaining: regression test coverage ← READY - -> files: crates/tui/src/tui/ui.rs, app.rs - -[Med] SHA-2799 "Alive and Animated" Feel - -> was blocked by SHA-2794, SHA-2795 (now done) ← READY - -> files: crates/tui/src/tui/ (various) - -[Med] SHA-2801 Docs and Workflow Update - -> was blocked by SHA-2798 (now done) ← READY - -> files: AGENTS.md, README.md, CHANGELOG.md - -[Med] SHA-2802 Release Prep - -> was blocked by SHA-2794, SHA-2795, SHA-2798 (all done) ← READY - -> files: Cargo.toml, CHANGELOG.md, npm/ - -[Low] SHA-2800 Header Redesign - -> was blocked by SHA-2794 (now done) ← READY - -> 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-2797** Escape Key regression test (Medium) -2. **SHA-2799** "Alive and Animated" Feel (Medium) -3. **SHA-2801** Docs and Workflow Update (Medium) -4. **SHA-2802** Release Prep (Medium) -5. **SHA-2800** Header Redesign (Low) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 4853f50c..34e4ccfc 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.31" } +deepseek-config = { path = "../config", version = "0.3.33" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 5de5f0d5..88f8e74b 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.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" } +deepseek-agent = { path = "../agent", version = "0.3.33" } +deepseek-config = { path = "../config", version = "0.3.33" } +deepseek-core = { path = "../core", version = "0.3.33" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.3.33" } +deepseek-hooks = { path = "../hooks", version = "0.3.33" } +deepseek-mcp = { path = "../mcp", version = "0.3.33" } +deepseek-protocol = { path = "../protocol", version = "0.3.33" } +deepseek-state = { path = "../state", version = "0.3.33" } +deepseek-tools = { path = "../tools", version = "0.3.33" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index da4eb9f3..8547b1f4 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.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" } +deepseek-agent = { path = "../agent", version = "0.3.33" } +deepseek-app-server = { path = "../app-server", version = "0.3.33" } +deepseek-config = { path = "../config", version = "0.3.33" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.3.33" } +deepseek-mcp = { path = "../mcp", version = "0.3.33" } +deepseek-state = { path = "../state", version = "0.3.33" } chrono.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 6a082a10..52a2022d 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.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" } +deepseek-agent = { path = "../agent", version = "0.3.33" } +deepseek-config = { path = "../config", version = "0.3.33" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.3.33" } +deepseek-hooks = { path = "../hooks", version = "0.3.33" } +deepseek-mcp = { path = "../mcp", version = "0.3.33" } +deepseek-protocol = { path = "../protocol", version = "0.3.33" } +deepseek-state = { path = "../state", version = "0.3.33" } +deepseek-tools = { path = "../tools", version = "0.3.33" } serde_json.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 80760840..09960f29 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.31" } +deepseek-protocol = { path = "../protocol", version = "0.3.33" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 3727e290..c9d7c08a 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.31" } +deepseek-protocol = { path = "../protocol", version = "0.3.33" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 6049f0c7..a15bfa43 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.31" } +deepseek-protocol = { path = "../protocol", version = "0.3.33" } serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 094291d5..9ffb6345 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.31" } +deepseek-protocol = { path = "../protocol", version = "0.3.33" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 6bfdad19..5f4ccafa 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -643,6 +643,27 @@ fn active_tool_list_from_catalog( .collect() } +fn active_tools_for_step( + catalog: &[Tool], + active: &std::collections::HashSet, + force_update_plan: bool, +) -> Vec { + // DeepSeek reasoning models reject explicit named tool_choice forcing here, so for + // obvious quick-plan asks we narrow the first-step tool surface to update_plan instead. + if force_update_plan { + let forced: Vec<_> = catalog + .iter() + .filter(|tool| tool.name == "update_plan") + .cloned() + .collect(); + if !forced.is_empty() { + return forced; + } + } + + active_tool_list_from_catalog(catalog, active) +} + fn tool_search_haystack(tool: &Tool) -> String { format!( "{}\n{}\n{}", @@ -902,6 +923,62 @@ fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool { }) } +fn should_stop_after_plan_tool( + mode: AppMode, + tool_name: &str, + result: &Result, +) -> bool { + mode == AppMode::Plan && tool_name == "update_plan" && result.is_ok() +} + +fn should_force_update_plan_first(mode: AppMode, content: &str) -> bool { + if mode != AppMode::Plan { + return false; + } + + let lower = content.to_ascii_lowercase(); + let asks_for_direct_plan = [ + "quick plan", + "short plan", + "simple plan", + "3-step plan", + "3 step plan", + "three-step plan", + "three step plan", + "high-level plan", + "high level plan", + "give me a plan", + "make a plan", + "outline a plan", + "draft a plan", + ] + .iter() + .any(|needle| lower.contains(needle)); + + if !asks_for_direct_plan { + return false; + } + + let asks_for_repo_exploration = [ + "inspect the repo", + "inspect the code", + "explore the repo", + "search the repo", + "read the code", + "review the code", + "analyze the code", + "investigate", + "look through", + "understand the current", + "ground it in the codebase", + "based on the codebase", + ] + .iter() + .any(|needle| lower.contains(needle)); + + !asks_for_repo_exploration +} + fn mcp_tool_is_parallel_safe(name: &str) -> bool { matches!( name, @@ -1420,6 +1497,7 @@ impl Engine { self.session .working_set .observe_user_message(&content, &self.session.workspace); + let force_update_plan_first = should_force_update_plan_first(mode, &content); // Add user message to session let user_msg = Message { @@ -1544,7 +1622,13 @@ impl Engine { // Main turn loop let (status, error) = self - .handle_deepseek_turn(&mut turn, tool_registry.as_ref(), tools, mode) + .handle_deepseek_turn( + &mut turn, + tool_registry.as_ref(), + tools, + mode, + force_update_plan_first, + ) .await; // Update session usage @@ -2130,6 +2214,7 @@ impl Engine { tool_registry: Option<&crate::tools::ToolRegistry>, tools: Option>, mode: AppMode, + force_update_plan_first: bool, ) -> (TurnOutcomeStatus, Option) { let client = self .deepseek_client @@ -2320,12 +2405,14 @@ impl Engine { } // Build the request + let force_update_plan_this_step = force_update_plan_first && turn.tool_calls.is_empty(); let active_tools = if tool_catalog.is_empty() { None } else { - Some(active_tool_list_from_catalog( + Some(active_tools_for_step( &tool_catalog, &active_tool_names, + force_update_plan_this_step, )) }; let request = MessageRequest { @@ -3208,6 +3295,7 @@ impl Engine { } let mut step_error_count = 0usize; + let mut stop_after_plan_tool = false; for outcome in outcomes.into_iter().flatten() { let duration = outcome.started_at.elapsed(); @@ -3215,6 +3303,8 @@ impl Engine { let tool_name_for_ws = outcome.name.clone(); let mut tool_call = TurnToolCall::new(outcome.id.clone(), outcome.name.clone(), outcome.input); + let should_stop_this_turn = + should_stop_after_plan_tool(mode, &outcome.name, &outcome.result); match outcome.result { Ok(output) => { @@ -3275,6 +3365,11 @@ impl Engine { } turn.record_tool_call(tool_call); + stop_after_plan_tool |= should_stop_this_turn; + } + + if stop_after_plan_tool { + break; } if self diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index ff06fcd1..de510121 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -73,6 +73,86 @@ fn parallel_batch_requires_read_only_parallel_tools() { assert!(!should_parallelize_tool_batch(&plans)); } +#[test] +fn successful_update_plan_ends_plan_mode_turn_immediately() { + assert!(should_stop_after_plan_tool( + AppMode::Plan, + "update_plan", + &Ok(ToolResult::success("planned")) + )); + assert!(!should_stop_after_plan_tool( + AppMode::Agent, + "update_plan", + &Ok(ToolResult::success("planned")) + )); + assert!(!should_stop_after_plan_tool( + AppMode::Plan, + "request_user_input", + &Ok(ToolResult::success("input")) + )); + assert!(!should_stop_after_plan_tool( + AppMode::Plan, + "update_plan", + &Err(ToolError::execution_failed("failed".to_string())) + )); +} + +#[test] +fn quick_plan_requests_force_update_plan_on_first_step() { + assert!(should_force_update_plan_first( + AppMode::Plan, + "Give me a quick 3-step plan to verify the UI changes." + )); + assert!(should_force_update_plan_first( + AppMode::Plan, + "Make a high-level plan for the footer work." + )); + assert!(!should_force_update_plan_first( + AppMode::Plan, + "Inspect the repo and then give me a quick plan." + )); + assert!(!should_force_update_plan_first( + AppMode::Agent, + "Give me a quick 3-step plan." + )); +} + +#[test] +fn quick_plan_turn_can_narrow_first_step_tools_to_update_plan() { + let catalog = vec![ + Tool { + tool_type: Some("function".to_string()), + name: "read_file".to_string(), + description: "Read a file".to_string(), + input_schema: json!({"type": "object"}), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }, + Tool { + tool_type: Some("function".to_string()), + name: "update_plan".to_string(), + description: "Publish a plan".to_string(), + input_schema: json!({"type": "object"}), + allowed_callers: Some(vec!["direct".to_string()]), + defer_loading: Some(false), + input_examples: None, + strict: None, + cache_control: None, + }, + ]; + let active = initial_active_tools(&catalog); + + let forced = active_tools_for_step(&catalog, &active, true); + assert_eq!(forced.len(), 1); + assert_eq!(forced[0].name, "update_plan"); + + let default = active_tools_for_step(&catalog, &active, false); + assert_eq!(default.len(), 2); +} + #[test] fn tool_error_messages_include_actionable_hints() { let path_error = ToolError::path_escape(PathBuf::from("../escape.txt")); diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index ed7fb5f6..c6c54b19 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -109,3 +109,22 @@ pub fn yolo_system_prompt() -> SystemPrompt { pub fn plan_system_prompt() -> SystemPrompt { system_prompt_for_mode(AppMode::Plan) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_prompt_prefers_best_effort_plans_over_clarifying_loops() { + let prompt = match system_prompt_for_mode(AppMode::Plan) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(_) => panic!("expected text system prompt"), + }; + + assert!(prompt.contains("Default to publishing a best-effort plan immediately.")); + assert!(prompt.contains("your first action should be update_plan.")); + assert!(prompt.contains("do not browse the repo first")); + assert!(prompt.contains("Do not ask clarifying questions for straightforward requests")); + assert!(prompt.contains("If the user asks for \"a 3-step plan\"")); + } +} diff --git a/crates/tui/src/prompts/plan.txt b/crates/tui/src/prompts/plan.txt index 16e95a82..b76bb0b9 100644 --- a/crates/tui/src/prompts/plan.txt +++ b/crates/tui/src/prompts/plan.txt @@ -9,10 +9,13 @@ In this mode, focus on: 4. Creating a detailed plan using update_plan before implementation. Interaction workflow: -1. Before publishing a plan, ask clarifying questions with request_user_input when requirements are ambiguous. -2. Use concise multiple-choice questions with numbered options and clear tradeoffs. -3. Keep it to 1-3 questions total, then synthesize the answers into update_plan output. -4. After emitting update_plan, stop and wait for explicit user approval before implementation. +1. For straightforward planning requests such as "quick plan", "3-step plan", "give me a plan", or review/checklist asks, your first action should be update_plan. +2. For those straightforward planning requests, do not browse the repo first and do not ask request_user_input unless the user explicitly asks for grounded investigation or you are blocked from producing a credible plan. +3. Default to publishing a best-effort plan immediately. +4. Ask clarifying questions with request_user_input only when you are blocked from producing a credible plan without the answer. +5. Do not ask clarifying questions for straightforward requests such as "give me a plan", "3-step plan", "high-level plan", review/checklist requests, or when reasonable assumptions are acceptable. State those assumptions in the plan instead. +6. If you do ask, use concise multiple-choice questions with numbered options and clear tradeoffs. Keep it to 1 question unless the first answer still leaves the task blocked. +7. After emitting update_plan, stop and wait for explicit user approval before implementation. Available tools: @@ -40,12 +43,20 @@ EXPLORATION: - diagnostics: Report workspace, git, sandbox, and toolchain info Guidelines: -- Prefer tool-centric planning: use grep_files/list_dir/read_file to ground the plan in the actual codebase. +- Prefer tool-centric planning for complex or implementation-grounded requests: use grep_files/list_dir/read_file to ground the plan in the actual codebase when that grounding materially improves the plan. +- Do not explore the repo just to produce a straightforward quick/high-level plan. - Use web.run for time-sensitive or uncertain facts, and cite sources as [cite:ref_id]. - Use update_plan to create structured plans with one step in_progress at a time. - Each step should be specific, actionable, and include expected outcomes. - Include explicit verification steps (tests/checks) after each planned change. - Include git hygiene in the plan: check git status early and before finishing; avoid reverting unrelated changes. - Identify dependencies, risks, edge cases, and rollback/mitigation ideas. -- Budget steps: if key facts are missing after 2-3 exploration attempts, ask a focused clarifying question. +- Prefer reasonable assumptions over questions when a solid plan is still possible. +- Treat verification-scope ambiguity as non-blocking: include the assumption in the plan instead of stopping to clarify. +- Ask clarifying questions only when missing facts would materially change the plan or make it unsafe. +- Budget steps: if key facts are missing after 2-3 exploration attempts and no reasonable assumption would work, ask a focused clarifying question. - Provide concise progress notes, then wait for user direction once the plan is ready. + +Examples: +- If the user asks for "a 3-step plan" or "a quick plan", call update_plan directly and avoid request_user_input. +- If the user asks to verify UI work, assume code-review-first unless they explicitly ask for runtime/manual testing. diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index 5d4a3f99..0ce6565d 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}; use crate::palette; use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent}; @@ -24,6 +24,77 @@ const PLAN_OPTIONS: [(&str, &str); 4] = [ ), ]; +fn modal_block() -> Block<'static> { + Block::default() + .title(Line::from(vec![Span::styled( + " Plan Confirmation ", + Style::default().fg(palette::DEEPSEEK_BLUE).bold(), + )])) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette::BORDER_COLOR)) + .style(Style::default().bg(palette::DEEPSEEK_INK)) + .padding(Padding::uniform(1)) +} + +fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) { + let shadow_x = popup_area.x.saturating_add(1); + let shadow_y = popup_area.y.saturating_add(1); + let shadow_right = area.x.saturating_add(area.width); + let shadow_bottom = area.y.saturating_add(area.height); + let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x)); + let shadow_height = popup_area + .height + .min(shadow_bottom.saturating_sub(shadow_y)); + + if shadow_width > 0 && shadow_height > 0 { + Block::default() + .style(Style::default().bg(palette::DEEPSEEK_NAVY)) + .render( + Rect { + x: shadow_x, + y: shadow_y, + width: shadow_width, + height: shadow_height, + }, + buf, + ); + } + + Clear.render(popup_area, buf); +} + +fn push_option_lines( + lines: &mut Vec>, + selected: bool, + number: usize, + label: &str, + description: &str, +) { + let row_style = if selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .bold() + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let detail_style = if selected { + row_style + } else { + Style::default().fg(palette::TEXT_MUTED) + }; + let prefix = if selected { ">" } else { " " }; + + lines.push(Line::from(Span::styled( + format!("{prefix} {number}) {label}"), + row_style, + ))); + lines.push(Line::from(Span::styled( + format!(" {description}"), + detail_style, + ))); +} + #[derive(Debug, Clone, Default)] pub struct PlanPromptView { selected: usize, @@ -115,56 +186,45 @@ impl ModalView for PlanPromptView { fn render(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = Vec::new(); lines.push(Line::from(vec![Span::styled( - "Plan ready. Confirm next step:", + "Action required", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )])); + lines.push(Line::from(vec![Span::styled( + "Choose what should happen after this plan.", Style::default().fg(palette::TEXT_PRIMARY).bold(), )])); lines.push(Line::from("")); for (idx, (label, description)) in PLAN_OPTIONS.iter().enumerate() { - let selected = self.selected == idx; - let prefix = if selected { ">" } else { " " }; let number = idx + 1; - let style = if selected { - Style::default() - .fg(palette::DEEPSEEK_SKY) - .bg(palette::SELECTION_BG) - .bold() - } else { - Style::default().fg(palette::TEXT_PRIMARY) - }; - lines.push(Line::from(vec![ - Span::raw(format!("{prefix} {number}) ")), - Span::styled((*label).to_string(), style), - Span::raw(" — "), - Span::styled( - (*description).to_string(), - Style::default().fg(palette::TEXT_MUTED), - ), - ])); + push_option_lines(&mut lines, self.selected == idx, number, label, description); } lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "1-4 / a / y / r / q = quick pick, Up/Down=select, Enter=confirm, Esc=close", - Style::default().fg(palette::TEXT_MUTED), - ))); + lines.push(Line::from(vec![ + Span::styled( + "1-4 / a / y / r / q", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ), + Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" close", Style::default().fg(palette::TEXT_MUTED)), + ])); let paragraph = Paragraph::new(lines) .alignment(Alignment::Left) .wrap(Wrap { trim: true }) - .block( - Block::default() - .title(Line::from(vec![Span::styled( - " Plan Confirmation ", - Style::default().fg(palette::DEEPSEEK_BLUE).bold(), - )])) - .borders(Borders::ALL) - .border_style(Style::default().fg(palette::BORDER_COLOR)) - .style(Style::default().bg(palette::DEEPSEEK_INK)) - .padding(Padding::uniform(1)), - ); + .block(modal_block()); - let popup_area = centered_rect(66, 42, area); + let popup_area = centered_rect(72, 52, area); + render_modal_chrome(area, popup_area, buf); paragraph.render(popup_area, buf); } } @@ -188,3 +248,40 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { ]) .split(popup_layout[1])[1] } + +#[cfg(test)] +mod tests { + use super::*; + + fn render_view(view: &PlanPromptView, width: u16, height: u16) -> String { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + (0..height) + .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::()) + .collect::>() + .join("\n") + } + + #[test] + fn plan_prompt_calls_out_required_action_and_controls() { + let rendered = render_view(&PlanPromptView::new(), 110, 36); + + assert!(rendered.contains("Action required")); + assert!(rendered.contains("Choose what should happen after this plan.")); + assert!(rendered.contains("1-4")); + assert!(rendered.contains("Enter")); + } + + #[test] + fn plan_prompt_keeps_selected_option_and_description_together() { + let mut view = PlanPromptView::new(); + view.selected = 1; + + let rendered = render_view(&view, 110, 36); + + assert!(rendered.contains("> 2) Accept plan (YOLO)")); + assert!(rendered.contains("Start implementation in YOLO mode (auto-approve)")); + } +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index ab76db67..1d2ffa2c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -585,15 +585,9 @@ async fn run_event_loop( app.is_compacting = false; app.status_message = Some(message); } - EngineEvent::CapacityDecision { - risk_band, - action, - reason, - .. - } => { - app.status_message = Some(format!( - "Capacity decision: risk={risk_band} action={action} ({reason})" - )); + EngineEvent::CapacityDecision { .. } => { + // Telemetry-only event. Surface actual interventions and failures + // instead of replacing the footer with no-op guardrail chatter. } EngineEvent::CapacityIntervention { action, @@ -721,7 +715,10 @@ async fn run_event_loop( } EngineEvent::UserInputRequired { id, request } => { app.view_stack.push(UserInputView::new(id.clone(), request)); - app.status_message = Some("User input requested".to_string()); + app.status_message = Some( + "Action required: answer the popup with 1-4, arrows, or Enter" + .to_string(), + ); } EngineEvent::ToolCallProgress { id, output } => { app.status_message = @@ -2129,13 +2126,13 @@ enum PlanChoice { fn plan_next_step_prompt() -> String { [ - "Plan ready. Review and choose:", + "Action required: choose the next step for this plan.", " 1) Accept + implement in Agent mode", " 2) Accept + implement in YOLO mode", " 3) Revise the plan / ask follow-ups", " 4) Return to Agent mode without implementing", "", - "Use the plan confirmation popup or type 1-4 and press Enter.", + "Use the plan confirmation popup, or type 1-4 and press Enter.", ] .join("\n") } @@ -2293,6 +2290,7 @@ fn render(f: &mut Frame, app: &mut App) { let footer_height = 1; let body_height = size.height.saturating_sub(header_height + footer_height); let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT); + let context_usage = context_usage_snapshot(app); let composer_max_height = body_height .saturating_sub(MIN_CHAT_HEIGHT) .max(MIN_COMPOSER_HEIGHT); @@ -2313,7 +2311,13 @@ fn render(f: &mut Frame, app: &mut App) { // Render header { - let context_window = crate::models::context_window_for_model(&app.model); + let sanitized_context_window = context_usage + .as_ref() + .map(|(_, max, _)| *max) + .or_else(|| crate::models::context_window_for_model(&app.model)); + let sanitized_prompt_tokens = context_usage + .as_ref() + .and_then(|(used, _, _)| u32::try_from(*used).ok()); let workspace_name = app .workspace .file_name() @@ -2329,9 +2333,9 @@ fn render(f: &mut Frame, app: &mut App) { ) .with_usage( app.total_conversation_tokens, - context_window, + sanitized_context_window, app.session_cost, - app.last_prompt_tokens, + sanitized_prompt_tokens, ); let header_widget = HeaderWidget::new(header_data); let buf = f.buffer_mut(); @@ -2856,10 +2860,8 @@ async fn handle_view_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(), - ); + app.status_message = + Some("Plan prompt closed. Type 1-4 and press Enter to choose.".to_string()); } ViewEvent::SessionSelected { session_id } => { let manager = match SessionManager::default_location() { @@ -3150,14 +3152,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { return; } - let right_spans = if app.session_cost > 0.001 { - vec![Span::styled( - format!("${:.2}", app.session_cost), - Style::default().fg(palette::TEXT_MUTED), - )] - } else { - Vec::new() - }; + let right_spans = footer_auxiliary_spans(app, available_width); let right_width = spans_width(&right_spans); let active_status = app.active_status_toast(); let min_gap = if right_width > 0 { 2 } else { 0 }; @@ -3183,6 +3178,58 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { f.render_widget(footer, area); } +fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { + let context_spans = footer_context_spans(app); + let cost_spans = if app.session_cost > 0.001 { + vec![Span::styled( + format!("${:.2}", app.session_cost), + Style::default().fg(palette::TEXT_MUTED), + )] + } else { + Vec::new() + }; + + let mut candidates = Vec::new(); + if !context_spans.is_empty() && !cost_spans.is_empty() { + let mut combined = context_spans.clone(); + combined.push(Span::raw(" ")); + combined.extend(cost_spans.clone()); + candidates.push(combined); + } + if !context_spans.is_empty() { + candidates.push(context_spans); + } + if !cost_spans.is_empty() { + candidates.push(cost_spans); + } + candidates.push(Vec::new()); + + candidates + .into_iter() + .find(|spans| spans_width(spans) <= max_width) + .unwrap_or_default() +} + +fn footer_context_spans(app: &App) -> Vec> { + let (_, _, percent) = match context_usage_snapshot(app) { + Some(snapshot) => snapshot, + None => return Vec::new(), + }; + + let color = if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { + palette::STATUS_ERROR + } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT { + palette::STATUS_WARNING + } else { + palette::DEEPSEEK_SKY + }; + + vec![Span::styled( + format!("ctx {:.0}%", percent), + Style::default().fg(color), + )] +} + fn footer_toast_spans( toast: &crate::tui::app::StatusToast, max_width: usize, @@ -3396,15 +3443,23 @@ fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> { .map(|tokens| tokens.max(0)); let estimated = estimated_context_tokens(app).map(|tokens| tokens.max(0)); - let used = match (reported, estimated) { - (Some(reported), Some(estimated)) - if reported > max_i64 && estimated > 0 && estimated <= max_i64 => - { - estimated + let used = if app.is_loading { + match (estimated, reported) { + (Some(estimated), _) => estimated, + (None, Some(reported)) => reported, + (None, None) => return None, + } + } else { + match (reported, estimated) { + (Some(reported), Some(estimated)) + if reported > max_i64 && estimated > 0 && estimated <= max_i64 => + { + estimated + } + (Some(reported), _) => reported, + (None, Some(estimated)) => estimated, + (None, None) => return None, } - (Some(reported), _) => reported, - (None, Some(estimated)) => estimated, - (None, None) => return None, }; let max_f64 = f64::from(max); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index f0ffa8b6..36746f75 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -317,6 +317,24 @@ fn footer_status_line_spans_truncate_long_model_names() { assert!(UnicodeWidthStr::width(line.as_str()) <= 40); } +#[test] +fn footer_auxiliary_spans_prioritize_context_when_busy() { + let mut app = create_test_app(); + app.is_loading = true; + app.last_prompt_tokens = Some(48_000); + app.session_cost = 12.34; + + let compact = spans_text(&footer_auxiliary_spans(&app, 8)); + assert!(compact.contains("ctx")); + assert!(compact.contains('%')); + assert!(!compact.contains('$')); + + let roomy = spans_text(&footer_auxiliary_spans(&app, 20)); + assert!(roomy.contains("ctx")); + assert!(roomy.contains('%')); + assert!(roomy.contains("$12.34")); +} + #[test] fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() { let mut app = create_test_app(); @@ -337,6 +355,28 @@ fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() { assert!(percent < 100.0); } +#[test] +fn context_usage_snapshot_prefers_live_estimate_while_loading() { + let mut app = create_test_app(); + app.is_loading = true; + app.last_prompt_tokens = Some(128); + app.api_messages = vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "context ".repeat(6_000), + cache_control: None, + }], + }]; + + let estimated = estimated_context_tokens(&app).expect("estimated context should be available"); + let (used, max, percent) = + context_usage_snapshot(&app).expect("context usage should be available"); + assert_eq!(used, estimated); + assert_eq!(max, 128_000); + assert!(used > i64::from(app.last_prompt_tokens.expect("reported tokens"))); + assert!(percent > 0.0); +} + #[test] fn should_auto_compact_before_send_respects_threshold_and_setting() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/user_input.rs b/crates/tui/src/tui/user_input.rs index 65cf61af..1296a0cb 100644 --- a/crates/tui/src/tui/user_input.rs +++ b/crates/tui/src/tui/user_input.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Alignment, Rect}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap}; use crate::palette; use crate::tools::user_input::{ @@ -23,6 +23,65 @@ fn modal_block(title: &str) -> Block<'static> { .padding(Padding::uniform(1)) } +fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) { + let shadow_x = popup_area.x.saturating_add(1); + let shadow_y = popup_area.y.saturating_add(1); + let shadow_right = area.x.saturating_add(area.width); + let shadow_bottom = area.y.saturating_add(area.height); + let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x)); + let shadow_height = popup_area + .height + .min(shadow_bottom.saturating_sub(shadow_y)); + + if shadow_width > 0 && shadow_height > 0 { + Block::default() + .style(Style::default().bg(palette::DEEPSEEK_NAVY)) + .render( + Rect { + x: shadow_x, + y: shadow_y, + width: shadow_width, + height: shadow_height, + }, + buf, + ); + } + + Clear.render(popup_area, buf); +} + +fn push_option_lines( + lines: &mut Vec>, + selected: bool, + number: usize, + label: String, + description: String, +) { + let row_style = if selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .bold() + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let detail_style = if selected { + row_style + } else { + Style::default().fg(palette::TEXT_MUTED) + }; + let prefix = if selected { ">" } else { " " }; + + lines.push(Line::from(Span::styled( + format!("{prefix} {number}) {label}"), + row_style, + ))); + lines.push(Line::from(Span::styled( + format!(" {description}"), + detail_style, + ))); +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputMode { Selecting, @@ -197,6 +256,21 @@ impl ModalView for UserInputView { ); let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![Span::styled( + "Action required", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )])); + lines.push(Line::from(vec![ + Span::styled( + question.header.clone(), + Style::default().fg(palette::TEXT_PRIMARY).bold(), + ), + Span::styled( + format!(" Question {} of {}", self.question_index + 1, total), + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + lines.push(Line::from("")); lines.push(Line::from(vec![Span::styled( question.question.clone(), Style::default().fg(palette::TEXT_PRIMARY).bold(), @@ -204,67 +278,33 @@ impl ModalView for UserInputView { lines.push(Line::from("")); for (idx, option) in question.options.iter().enumerate() { - let selected = self.selected == idx; - let prefix = if selected { ">" } else { " " }; let number = idx + 1; - if selected { - // Single span with consistent foreground and background - let content = format!( - "{prefix} {number}) {} - {}", - option.label, option.description - ); - lines.push(Line::from(Span::styled( - content, - Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) - .bold(), - ))); - } else { - // Keep original multi‑span formatting - lines.push(Line::from(vec![ - Span::raw(format!("{prefix} {number}) ")), - Span::styled( - option.label.clone(), - Style::default().fg(palette::TEXT_PRIMARY), - ), - Span::raw(" - "), - Span::styled( - option.description.clone(), - Style::default().fg(palette::TEXT_MUTED), - ), - ])); - } + push_option_lines( + &mut lines, + self.selected == idx, + number, + option.label.clone(), + option.description.clone(), + ); } let other_index = question.options.len(); - let other_selected = self.selected == other_index; let other_number = other_index + 1; - if other_selected { - let content = format!("> {other_number}) Other - Provide a custom response"); - lines.push(Line::from(Span::styled( - content, - Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) - .bold(), - ))); - } else { - lines.push(Line::from(vec![ - Span::raw(format!(" {other_number}) ")), - Span::styled("Other", Style::default().fg(palette::TEXT_PRIMARY)), - Span::raw(" - "), - Span::styled( - "Provide a custom response", - Style::default().fg(palette::TEXT_MUTED), - ), - ])); - } + push_option_lines( + &mut lines, + self.selected == other_index, + other_number, + "Other".to_string(), + "Type a custom response".to_string(), + ); if self.mode == InputMode::OtherInput { lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("Other:", Style::default().fg(palette::TEXT_PRIMARY)), + Span::styled( + "> Custom response:", + Style::default().fg(palette::TEXT_PRIMARY).bold(), + ), Span::raw(" "), Span::styled( if self.other_input.is_empty() { @@ -278,22 +318,37 @@ impl ModalView for UserInputView { } lines.push(Line::from("")); - let hint = if self.mode == InputMode::OtherInput { - "Enter=submit, Esc=back" + if self.mode == InputMode::OtherInput { + lines.push(Line::from(vec![ + Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)), + ])); } else { - "Number keys=quick pick, Up/Down=select, Enter=confirm, Esc=cancel" - }; - lines.push(Line::from(Span::styled( - hint, - Style::default().fg(palette::TEXT_MUTED), - ))); + lines.push(Line::from(vec![ + Span::styled("1-4", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)), + ])); + } let paragraph = Paragraph::new(lines) .alignment(Alignment::Left) .wrap(Wrap { trim: true }) .block(modal_block(&header)); - let popup_area = centered_rect(80, 60, area); + let popup_area = centered_rect(82, 68, area); + render_modal_chrome(area, popup_area, buf); paragraph.render(popup_area, buf); } } @@ -317,3 +372,68 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .split(popup_layout[1]); horizontal[1] } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::user_input::{UserInputOption, UserInputQuestion, UserInputRequest}; + + fn render_view(view: &UserInputView, width: u16, height: u16) -> String { + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + (0..height) + .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::()) + .collect::>() + .join("\n") + } + + fn sample_view() -> UserInputView { + UserInputView::new( + "tool-1", + UserInputRequest { + questions: vec![UserInputQuestion { + header: "Confirm".to_string(), + id: "confirm".to_string(), + question: "What should happen next?".to_string(), + options: vec![ + UserInputOption { + label: "Ship it".to_string(), + description: "Proceed with the current change set".to_string(), + }, + UserInputOption { + label: "Revise it".to_string(), + description: "Return to editing before continuing".to_string(), + }, + ], + }], + }, + ) + } + + #[test] + fn user_input_modal_calls_out_required_action_and_controls() { + let rendered = render_view(&sample_view(), 110, 36); + + assert!(rendered.contains("Action required")); + assert!(rendered.contains("Question 1 of 1")); + assert!(rendered.contains("1-4")); + assert!(rendered.contains("quick pick")); + } + + #[test] + fn user_input_modal_renders_custom_response_state() { + let mut view = sample_view(); + view.selected = 2; + view.mode = InputMode::OtherInput; + view.other_input = "Need one more pass".to_string(); + + let rendered = render_view(&view, 110, 36); + + assert!(rendered.contains("Custom response")); + assert!(rendered.contains("Need one more pass")); + assert!(rendered.contains("Enter")); + assert!(rendered.contains("submit")); + } +} diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index fdf02280..6a7a0598 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -140,7 +140,7 @@ impl<'a> HeaderWidget<'a> { if max <= 0.0 { return None; } - Some((used / max * 100.0).clamp(0.0, 999.0)) + Some((used / max * 100.0).clamp(0.0, 100.0)) } fn context_color(percent: f64) -> Color { @@ -180,7 +180,23 @@ impl<'a> HeaderWidget<'a> { spans } - fn status_variant(&self, show_stream_label: bool, show_percent: bool) -> Vec> { + fn context_percent_spans(&self) -> Vec> { + let Some(percent) = self.context_percent() else { + return Vec::new(); + }; + + vec![Span::styled( + format!("{percent:.0}%"), + Style::default().fg(Self::context_color(percent)), + )] + } + + fn status_variant( + &self, + show_stream_label: bool, + show_percent: bool, + show_signal: bool, + ) -> Vec> { let mut spans = Vec::new(); if self.data.is_streaming { @@ -199,7 +215,13 @@ impl<'a> HeaderWidget<'a> { } } - let context_spans = self.context_signal_spans(show_percent); + let context_spans = if show_signal { + self.context_signal_spans(show_percent) + } else if show_percent { + self.context_percent_spans() + } else { + Vec::new() + }; if !context_spans.is_empty() { if !spans.is_empty() { spans.push(Span::raw(" ")); @@ -212,9 +234,10 @@ impl<'a> HeaderWidget<'a> { fn right_spans(&self, max_width: usize) -> Vec> { let candidates = [ - self.status_variant(true, true), - self.status_variant(false, true), - self.status_variant(false, false), + self.status_variant(true, true, true), + self.status_variant(false, true, true), + self.status_variant(false, true, false), + self.status_variant(false, false, true), ]; candidates @@ -418,6 +441,21 @@ mod tests { assert!(rendered.contains("▰")); } + #[test] + fn narrow_header_keeps_context_percent_visible() { + let rendered = render_header( + HeaderData::new(AppMode::Agent, "", "", true, palette::DEEPSEEK_INK).with_usage( + 0, + Some(128_000), + 0.0, + Some(48_000), + ), + 14, + ); + + assert!(rendered.contains('%')); + } + #[test] fn narrow_header_falls_back_to_mode_without_rendering_all_modes() { let rendered = render_header( @@ -453,4 +491,22 @@ mod tests { assert!(!rendered.contains('%')); assert!(!rendered.contains("▰")); } + + #[test] + fn header_caps_context_signal_at_hundred_percent() { + let rendered = render_header( + HeaderData::new( + AppMode::Agent, + "deepseek-chat", + "repo", + false, + palette::DEEPSEEK_INK, + ) + .with_usage(1_000, Some(128_000), 0.0, Some(320_000)), + 48, + ); + + assert!(rendered.contains("100%")); + assert!(!rendered.contains("250%")); + } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 38162d81..0c3957e2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -15,8 +15,14 @@ Overrides: If both are set, `--config` wins. Environment variable overrides are applied after the file is loaded. -To bootstrap MCP and skills directories at their resolved paths, run `deepseek setup`. -To only scaffold MCP, run `deepseek mcp init`. +To bootstrap MCP and skills directories at their resolved paths, run `deepseek-tui setup`. +To only scaffold MCP, run `deepseek-tui mcp init`. + +Note: setup, doctor, mcp, features, sessions, resume/fork, exec, review, and eval +are subcommands of the `deepseek-tui` binary. The `deepseek` dispatcher exposes a +distinct set of commands (`auth`, `config`, `model`, `thread`, `sandbox`, +`app-server`, `mcp-server`, `completion`) and forwards plain prompts to +`deepseek-tui`. ## Profiles @@ -177,10 +183,10 @@ exec_policy = true You can also override features for a single run: -- `deepseek --enable web_search` -- `deepseek --disable subagents` +- `deepseek-tui --enable web_search` +- `deepseek-tui --disable subagents` -Use `deepseek features list` to inspect known flags and their effective state. +Use `deepseek-tui features list` to inspect known flags and their effective state. ## Managed Configuration and Requirements @@ -205,11 +211,12 @@ If configured values violate requirements, startup fails with a descriptive erro See `docs/capacity_controller.md` for formulas, intervention behavior, and telemetry. -## Notes On `deepseek doctor` +## Notes On `deepseek-tui doctor` -`deepseek doctor` now follows the same config resolution rules as the rest of the CLI. -That means `--config` / `DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills checks -use the resolved `mcp_config_path` / `skills_dir` (including env overrides). +`deepseek-tui doctor` follows the same config resolution rules as the rest of the +TUI. That means `--config` / `DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills +checks use the resolved `mcp_config_path` / `skills_dir` (including env overrides). -To bootstrap missing MCP/skills paths, run `deepseek setup --all`. You can also -run `deepseek setup --skills --local` to create a workspace-local `./skills` dir. +To bootstrap missing MCP/skills paths, run `deepseek-tui setup --all`. You can +also run `deepseek-tui setup --skills --local` to create a workspace-local +`./skills` dir. diff --git a/docs/MCP.md b/docs/MCP.md index c2b0c54e..589a4f87 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -7,30 +7,32 @@ Browsing note: - `web_search` remains available as a compatibility alias for older prompts and integrations. Server mode note: -- `deepseek serve --mcp` runs the MCP stdio server. -- `deepseek serve --http` runs the runtime HTTP/SSE API (separate mode). +- `deepseek-tui serve --mcp` runs the MCP stdio server. +- `deepseek-tui serve --http` runs the runtime HTTP/SSE API (separate mode). +- The `deepseek` dispatcher exposes `deepseek mcp-server` as an equivalent stdio + entrypoint used by the split CLI. ## Bootstrap MCP Config Create a starter MCP config at your resolved MCP path: ```bash -deepseek mcp init +deepseek-tui mcp init ``` -`deepseek setup --mcp` performs the same MCP bootstrap alongside skills setup. +`deepseek-tui setup --mcp` performs the same MCP bootstrap alongside skills setup. Common management commands: ```bash -deepseek mcp list -deepseek mcp tools [server] -deepseek mcp add --command "" --arg "" -deepseek mcp add --url "http://localhost:3000/mcp" -deepseek mcp enable -deepseek mcp disable -deepseek mcp remove -deepseek mcp validate +deepseek-tui mcp list +deepseek-tui mcp tools [server] +deepseek-tui mcp add --command "" --arg "" +deepseek-tui mcp add --url "http://localhost:3000/mcp" +deepseek-tui mcp enable +deepseek-tui mcp disable +deepseek-tui mcp remove +deepseek-tui mcp validate ``` ## Config File Location @@ -44,7 +46,7 @@ Overrides: - Config: `mcp_config_path = "/path/to/mcp.json"` - Env: `DEEPSEEK_MCP_CONFIG=/path/to/mcp.json` -`deepseek mcp init` (and `deepseek setup --mcp`) writes to this resolved path. +`deepseek-tui mcp init` (and `deepseek-tui setup --mcp`) writes to this resolved path. After editing the file, restart the TUI. @@ -94,10 +96,10 @@ You can register your local DeepSeek binary as an MCP server so other DeepSeek s ### Quick Setup ```bash -deepseek mcp add-self +deepseek-tui 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`. +This resolves the current binary path, generates a config entry that runs `deepseek-tui serve --mcp`, and writes it to your MCP config file. The default server name is `deepseek`. Options: @@ -120,7 +122,11 @@ Equivalent manual entry in `~/.deepseek/mcp.json`: } ``` -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. +The `deepseek-tui` binary supports `serve --mcp` directly. The `deepseek` +dispatcher offers the equivalent `deepseek mcp-server` stdio entrypoint. 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 @@ -138,7 +144,7 @@ For example, the `shell` tool becomes `mcp_deepseek_shell`. ### MCP Server vs HTTP/SSE API -| | `deepseek serve --mcp` | `deepseek serve --http` | +| | `deepseek-tui serve --mcp` | `deepseek-tui serve --http` | |---|---|---| | **Protocol** | MCP stdio | HTTP/SSE JSON-RPC | | **Use case** | Tool server for MCP clients | Runtime API for apps | @@ -152,8 +158,8 @@ Use `mcp add-self` when you want DeepSeek tools available to other MCP clients. After adding, test the connection: ```bash -deepseek mcp validate -deepseek mcp tools deepseek +deepseek-tui mcp validate +deepseek-tui mcp tools deepseek ``` ## Server Fields @@ -178,6 +184,6 @@ You should still only configure MCP servers you trust, and treat MCP server conf ## Troubleshooting -- Run `deepseek doctor` to confirm the MCP config path it resolved and whether it exists. -- If the MCP config is missing, run `deepseek mcp init --force` to regenerate it. +- Run `deepseek-tui doctor` to confirm the MCP config path it resolved and whether it exists. +- If the MCP config is missing, run `deepseek-tui mcp init --force` to regenerate it. - If tools don’t appear, verify the server command works from your shell and that the server supports MCP `tools/list`. diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index 96939303..de1548ea 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.3.32", - "deepseekBinaryVersion": "0.3.31", + "version": "0.3.33", + "deepseekBinaryVersion": "0.3.33", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",