docs: update documentation and cleanup for v0.3.33
This commit is contained in:
@@ -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
|
||||
+3
-7
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+27
-25
@@ -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`:
|
||||
|
||||
+4
-53
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -643,6 +643,27 @@ fn active_tool_list_from_catalog(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn active_tools_for_step(
|
||||
catalog: &[Tool],
|
||||
active: &std::collections::HashSet<String>,
|
||||
force_update_plan: bool,
|
||||
) -> Vec<Tool> {
|
||||
// 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<ToolResult, ToolError>,
|
||||
) -> 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<Vec<Tool>>,
|
||||
mode: AppMode,
|
||||
force_update_plan_first: bool,
|
||||
) -> (TurnOutcomeStatus, Option<String>) {
|
||||
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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Line<'static>>,
|
||||
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<Line> = 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::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.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)"));
|
||||
}
|
||||
}
|
||||
|
||||
+90
-35
@@ -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<Span<'static>> {
|
||||
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<Span<'static>> {
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Line<'static>>,
|
||||
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<Line> = 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::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Span<'static>> {
|
||||
fn context_percent_spans(&self) -> Vec<Span<'static>> {
|
||||
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<Span<'static>> {
|
||||
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<Span<'static>> {
|
||||
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%"));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-11
@@ -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.
|
||||
|
||||
+27
-21
@@ -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 <name> --command "<cmd>" --arg "<arg>"
|
||||
deepseek mcp add <name> --url "http://localhost:3000/mcp"
|
||||
deepseek mcp enable <name>
|
||||
deepseek mcp disable <name>
|
||||
deepseek mcp remove <name>
|
||||
deepseek mcp validate
|
||||
deepseek-tui mcp list
|
||||
deepseek-tui mcp tools [server]
|
||||
deepseek-tui mcp add <name> --command "<cmd>" --arg "<arg>"
|
||||
deepseek-tui mcp add <name> --url "http://localhost:3000/mcp"
|
||||
deepseek-tui mcp enable <name>
|
||||
deepseek-tui mcp disable <name>
|
||||
deepseek-tui mcp remove <name>
|
||||
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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user