docs: update documentation and cleanup for v0.3.33

This commit is contained in:
Hunter Bown
2026-04-22 22:36:45 -05:00
parent f4dbf828c9
commit dc8e94d705
27 changed files with 832 additions and 494 deletions
-35
View File
@@ -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
View File
@@ -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)
-151
View File
@@ -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.
-12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
+6 -6
View File
@@ -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
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+97 -2
View File
@@ -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
+80
View File
@@ -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"));
+19
View File
@@ -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\""));
}
}
+17 -6
View File
@@ -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.
+134 -37
View File
@@ -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
View File
@@ -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);
+40
View File
@@ -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();
+182 -62
View File
@@ -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 multispan 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"));
}
}
+62 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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 dont appear, verify the server command works from your shell and that the server supports MCP `tools/list`.
+2 -2
View File
@@ -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",