feat: runtime API, task manager, and extensive improvements (v0.3.16)

Major Features:
- Runtime API for external integrations and turn management
- Task manager with persistence and recovery
- Shell output streaming and improved tool execution
- Error taxonomy and audit logging
- Command palette and UI enhancements

Documentation:
- Runtime API documentation
- Operations runbook
- Architecture updates

Fixes:
- Auto-compaction threshold and triggering logic
- Doctor command API key validation
- Clippy and formatting compliance
This commit is contained in:
Hunter Bown
2026-02-16 10:51:39 -06:00
parent fa3905041d
commit ab2c708ca7
57 changed files with 12030 additions and 836 deletions
+24 -1
View File
@@ -12,7 +12,7 @@ This file provides context for AI assistants working on this project.
- Format: `cargo fmt`
- Lint: `cargo clippy`
### Project: deepseek-cli
### Project: deepseek-tui
### Documentation
See README.md for project overview.
@@ -47,3 +47,26 @@ For complex, multi-step tasks, you should delegate work:
## Important Notes
<!-- Add project-specific notes here -->
### DeepSeek-Specific Capabilities
This project is built specifically for DeepSeek models, leveraging their unique features:
**Thinking Tokens**: DeepSeek models can output thinking blocks (`ContentBlock::Thinking`) before providing final answers. The TUI supports streaming and displaying thinking tokens with visual distinction. You can use thinking tokens to reason step-by-step before committing to a response.
**Reasoning Models**: DeepSeek offers specialized reasoning models (e.g., `deepseek-reasoner`, `deepseek-r1`) that excel at step-by-step problem solving. Consider using these models for complex tasks.
**Large Context Window**: DeepSeek models have 128k context windows, allowing you to process large codebases. Use `project_map` and `file_search` to navigate efficiently.
**DeepSeek API**: The CLI uses DeepSeek's OpenAIcompatible API with support for the Responses API endpoint. The base URL can be configured for global (`api.deepseek.com`) or China (`api.deepseeki.com`).
**Web Browsing**: For uptodate information about DeepSeek models, documentation, or API changes, use `web.run` with citations. Example search: “DeepSeek API documentation”.
### Dogfooding Tips
As a DeepSeek model working on this project, you are “dogfooding” your own tool. Use this opportunity to:
- Test the toolset thoroughly and report any issues.
- Suggest improvements that would make DeepSeek models more effective.
- Keep changes small, focused, and welltested.
Remember to run `cargo test` and `cargo check` after any changes.
+36 -4
View File
@@ -5,7 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.3.16] - 2026-02-15
### Added
- `deepseek models` CLI command to fetch and list models from the configured `/v1/models` endpoint (with `--json` output mode).
- `/models` slash command to fetch and display live model IDs in the TUI.
- Slash-command autocomplete hints in the composer plus `Tab` completion for `/` commands.
- Command palette modal (`Ctrl+K`) for quick insertion of slash commands and skills.
- Persistent right sidebar in wide terminals showing live plan/todo/sub-agent state.
- Expandable tool payload views (`v` in transcript, `v` in approval modal) for full params/output inspection.
- Runtime HTTP/SSE API (`deepseek serve --http`) with durable thread/turn/item lifecycle, interrupt/steer, and replayable event timeline.
- Background task queue (`/task add|list|show|cancel` and `POST /v1/tasks`) with persistent storage, bounded worker pool, and timeline/artifact tracking.
### Changed
- Centralized the default text model (`DEFAULT_TEXT_MODEL`) and shared common model list to reduce drift across runtime/config paths.
- `/model` now clarifies that any valid DeepSeek model ID is accepted (including future releases), while still showing common model IDs.
### Fixed
- Expanded reasoning-model detection for chat history reconstruction (supports R-series and reasoner-style naming without hardcoding single versions).
- Aligned docs/config examples with actual runtime default model (`deepseek-v3.2`).
## [0.3.14] - 2026-02-05
@@ -30,6 +48,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Map dotted tool names to API-safe identifiers for DeepSeek tool calls
- Encode any invalid tool names for API tool lists while preserving internal names
## [0.3.11] - 2026-02-04
### Fixed
- Fix tool name mapping for DeepSeek API
## [0.3.10] - 2026-02-04
### Fixed
@@ -249,15 +272,24 @@ 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.5...HEAD
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.14...HEAD
[0.3.14]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.13...v0.3.14
[0.3.13]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.12...v0.3.13
[0.3.12]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.11...v0.3.12
[0.3.11]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.10...v0.3.11
[0.3.10]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
[0.3.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
[0.3.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
[0.3.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
[0.3.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.1
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
[0.2.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0
[0.0.2]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.2
[0.0.1]: https://github.com/Hmbown/DeepSeek-CLI/releases/tag/v0.0.1
Generated
+86 -19
View File
@@ -242,6 +242,58 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
@@ -674,12 +726,13 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.3.15"
version = "0.3.16"
dependencies = [
"anyhow",
"arboard",
"async-stream",
"async-trait",
"axum",
"base64",
"bytes",
"chrono",
@@ -693,7 +746,6 @@ dependencies = [
"ignore",
"indicatif",
"libc",
"meval",
"multimap",
"pdf-extract",
"portable-pty",
@@ -1853,7 +1905,7 @@ dependencies = [
"itoa",
"log",
"md-5",
"nom 7.1.3",
"nom",
"rangemap",
"time",
"weezl",
@@ -1887,6 +1939,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
version = "0.10.6"
@@ -1912,16 +1970,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "meval"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9"
dependencies = [
"fnv",
"nom 1.2.4",
]
[[package]]
name = "mime"
version = "0.3.17"
@@ -2055,12 +2103,6 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
[[package]]
name = "nom"
version = "7.1.3"
@@ -2885,6 +2927,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -2905,6 +2958,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "serial"
version = "0.4.0"
@@ -3536,6 +3601,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -3574,6 +3640,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "deepseek-tui"
version = "0.3.15"
version = "0.3.16"
edition = "2024"
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
license = "MIT"
@@ -19,6 +19,7 @@ async-stream = "0.3.6"
async-trait = "0.1"
bytes = "1.11.0"
base64 = "0.22.1"
axum = { version = "0.8.4", features = ["json"] }
clap = { version = "4.5.54", features = ["derive"] }
clap_complete = "4.5"
colored = "3.0.0"
@@ -54,7 +55,6 @@ portable-pty = "0.8"
zeroize = "1.8.2"
ignore = "0.4"
pdf-extract = "0.7"
meval = "0.2"
[dev-dependencies]
wiremock = "0.6"
+69 -6
View File
@@ -22,8 +22,8 @@ cargo install deepseek-tui --locked
# Or build from source
git clone https://github.com/Hmbown/DeepSeek-TUI.git
cd DeepSeek-TUI
cargo build --release
# binary is at ./target/release/deepseek
cargo install --path . --locked
# installs `deepseek` to ~/.cargo/bin (ensure it is on your PATH)
```
Prebuilt binaries are also available on [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases).
@@ -68,14 +68,16 @@ deepseek doctor
|-----|--------|
| `Enter` | Send message |
| `Alt+Enter` / `Ctrl+J` | Insert newline |
| `Tab` | Cycle modes (Plan / Agent / YOLO) |
| `Tab` | Autocomplete slash command (or cycle modes) |
| `Esc` | Cancel request / clear input |
| `Ctrl+C` | Cancel request or exit |
| `Ctrl+K` | Open command palette |
| `Ctrl+R` | Search past sessions |
| `F1` or `Ctrl+/` | Toggle help overlay |
| `PageUp` / `PageDown` | Scroll transcript |
| `Alt+Up` / `Alt+Down` | Scroll transcript (small) |
| `l` (empty input) | Open last message in pager |
| `v` (empty input) | Open selected/latest tool details |
## Modes
@@ -112,6 +114,7 @@ The model has access to 25+ tools across these categories:
- `todo_write` — create and track task lists with status
- `update_plan` — structured implementation plans
- `note` — persistent cross-session notes
- `/task add|list|show|cancel` — persistent background task queue with timeline visibility
### Sub-Agents
- `agent_spawn` / `agent_swarm` — launch background agents or dependency-aware swarms
@@ -124,7 +127,7 @@ The model has access to 25+ tools across these categories:
- `request_user_input` — ask the user structured or multiple-choice questions
- `multi_tool_use.parallel` — execute multiple read-only tools in parallel
All file tools respect the `--workspace` boundary unless `/trust` is enabled (YOLO enables trust automatically). MCP tools execute without TUI approval prompts, so only enable servers you trust.
All file tools respect the `--workspace` boundary unless `/trust` is enabled (YOLO enables trust automatically). MCP tools now use the same approval pipeline as built-in tools; only trusted MCP servers should be configured.
## Configuration
@@ -132,11 +135,13 @@ The TUI stores its config at `~/.deepseek/config.toml`:
```toml
api_key = "sk-..."
default_text_model = "deepseek-reasoner" # optional
default_text_model = "deepseek-v3.2" # optional
allow_shell = false # optional
max_subagents = 3 # optional (1-20)
```
Any valid DeepSeek model ID is accepted for `default_text_model` (for example, future IDs such as `deepseek-v4-mini` once available).
### Environment Variables
| Variable | Purpose |
@@ -146,7 +151,9 @@ max_subagents = 3 # optional (1-20)
| `DEEPSEEK_PROFILE` | Select a `[profiles.<name>]` section from config |
| `DEEPSEEK_CONFIG_PATH` | Override config file location |
Additional overrides: `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_MAX_SUBAGENTS`.
Additional overrides: `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_APPROVAL_POLICY`, `DEEPSEEK_SANDBOX_MODE`, `DEEPSEEK_MAX_SUBAGENTS`, `DEEPSEEK_ALLOW_INSECURE_HTTP`.
Optional local audit log (off by default): set `DEEPSEEK_TOOL_AUDIT_LOG=/path/to/audit.jsonl` to record tool approval decisions and tool outcomes as JSONL events.
See `config.example.toml` and `docs/CONFIGURATION.md` for the full reference.
@@ -159,6 +166,9 @@ deepseek
# One-shot prompt (non-interactive, prints and exits)
deepseek -p "Explain the borrow checker in two sentences"
# List models from the configured API endpoint
deepseek models
# Agentic execution with auto-approve
deepseek exec --auto "Fix all clippy warnings in this project"
@@ -178,8 +188,59 @@ deepseek sessions --limit 50
deepseek completions zsh > _deepseek
deepseek completions bash > deepseek.bash
deepseek completions fish > deepseek.fish
# Runtime API server (localhost by default)
deepseek serve --http --host 127.0.0.1 --port 7878
# MCP stdio server mode
deepseek serve --mcp
```
## Runtime API (HTTP/SSE)
`deepseek serve --http` starts a local runtime API for external clients.
Default bind: `127.0.0.1:7878`
Core endpoints:
- `GET /health`
- `GET /v1/sessions`
- `POST /v1/stream` (backward-compatible single-turn SSE wrapper)
- `POST /v1/threads`
- `GET /v1/threads`
- `GET /v1/threads/{id}`
- `POST /v1/threads/{id}/resume`
- `POST /v1/threads/{id}/fork`
- `POST /v1/threads/{id}/turns`
- `POST /v1/threads/{id}/turns/{turn_id}/steer`
- `POST /v1/threads/{id}/turns/{turn_id}/interrupt`
- `POST /v1/threads/{id}/compact`
- `GET /v1/threads/{id}/events` (SSE replay/live, optional `since_seq`)
- `GET /v1/tasks`
- `POST /v1/tasks`
- `GET /v1/tasks/{id}`
- `POST /v1/tasks/{id}/cancel`
Runtime semantics:
- explicit durable Thread/Turn/Item lifecycle with IDs and statuses
- multi-turn continuity on the same thread
- one active turn per thread (overlap rejected with `409`)
- interrupt transitions to terminal `interrupted` only after cleanup
- steer support for active turns
- compaction surfaced as first-class lifecycle items (`auto` + `manual`)
- replayable per-thread event timeline for API/TUI clients
Task queue semantics:
- durable task storage under `~/.deepseek/tasks` (override with `DEEPSEEK_TASKS_DIR`)
- restart-safe recovery (in-progress tasks are re-queued on startup)
- bounded worker pool via `deepseek serve --http --workers <1-8>`
- task execution linked to runtime thread/turn timelines
Security caveat:
- this server is local-first and assumes trusted local access
- no built-in auth/TLS/multi-user isolation
- do not expose it directly to untrusted networks without your own auth/proxy controls
## Troubleshooting
| Problem | Fix |
@@ -198,6 +259,8 @@ deepseek completions fish > deepseek.fish
- [Architecture](docs/ARCHITECTURE.md)
- [Mode Comparison](docs/MODES.md)
- [MCP Integration](docs/MCP.md)
- [Runtime API](docs/RUNTIME_API.md)
- [Operations Runbook](docs/OPERATIONS_RUNBOOK.md)
- [Contributing](CONTRIBUTING.md)
## Development
+16 -3
View File
@@ -20,7 +20,7 @@ base_url = "https://api.deepseek.com"
# ─────────────────────────────────────────────────────────────────────────────────
# Default Models
# ─────────────────────────────────────────────────────────────────────────────────
default_text_model = "deepseek-v3.2" # also: deepseek-reasoner, deepseek-chat, deepseek-r1, deepseek-v3
default_text_model = "deepseek-v3.2" # any valid model ID works (e.g. deepseek-chat, deepseek-reasoner, deepseek-r1, deepseek-v3, future deepseek-v4-mini)
# ─────────────────────────────────────────────────────────────────────────────────
# Paths
@@ -29,16 +29,23 @@ skills_dir = "~/.deepseek/skills"
mcp_config_path = "~/.deepseek/mcp.json"
notes_path = "~/.deepseek/notes.txt"
memory_path = "~/.deepseek/memory.md"
# Parsed but currently unused (reserved for future versions):
# tools_file = "./tools.json"
# memory_path = "~/.deepseek/memory.md"
# ─────────────────────────────────────────────────────────────────────────────────
# Security
# ─────────────────────────────────────────────────────────────────────────────────
allow_shell = false
approval_policy = "on-request" # on-request | untrusted | never
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
max_subagents = 5 # optional (1-20)
# Optional managed policy paths (defaults to /etc/deepseek/*.toml on unix):
# managed_config_path = "/etc/deepseek/managed_config.toml"
# requirements_path = "/etc/deepseek/requirements.toml"
# ─────────────────────────────────────────────────────────────────────────────────
# TUI
# ─────────────────────────────────────────────────────────────────────────────────
@@ -67,7 +74,7 @@ max_delay = 60.0
exponential_base = 2.0
# ─────────────────────────────────────────────────────────────────────────────────
# Context Compaction (PLANNED - not yet implemented)
# Context Compaction (config-level tuning not yet wired; use /set auto_compact on|off)
# ─────────────────────────────────────────────────────────────────────────────────
# [compaction]
# enabled = false # Enable auto-compaction
@@ -101,3 +108,9 @@ allow_shell = true
# [[hooks.hooks]]
# event = "session_start"
# command = "echo 'DeepSeek CLI session started'"
# ─────────────────────────────────────────────────────────────────────────────────
# Requirements (admin constraints) example file
# ─────────────────────────────────────────────────────────────────────────────────
# allowed_approval_policies = ["on-request", "untrusted", "never"]
# allowed_sandbox_modes = ["read-only", "workspace-write"]
+52 -1
View File
@@ -35,6 +35,15 @@ This document provides an overview of the DeepSeek CLI architecture for develope
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Runtime API + Task Management │
│ ┌─────────────────────────────┐ ┌──────────────────────────┐ │
│ │ HTTP/SSE Runtime API │ │ Persistent Task Manager │ │
│ │ (runtime_api.rs) │ │ (task_manager.rs) │ │
│ └─────────────────────────────┘ └──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ LLM Layer │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ LLM Client Abstraction (llm_client.rs) │ │
@@ -125,6 +134,9 @@ Responses API (with automatic fallback if needed).
- **`prompts.rs`** - System prompt templates
- **`project_doc.rs`** - Project documentation handling
- **`session.rs`** - Session serialization
- **`runtime_api.rs`** - HTTP/SSE runtime API (`deepseek serve --http`)
- **`runtime_threads.rs`** - Durable thread/turn/item store + replayable event timeline
- **`task_manager.rs`** - Durable queue, worker pool, task timelines and artifacts
## Data Flow
@@ -139,6 +151,14 @@ Responses API (with automatic fallback if needed).
7. Results aggregated and sent back to LLM
8. Final response rendered in TUI
### Crash Recovery + Offline Queue
1. Before sending user input, the TUI writes a checkpoint snapshot to `~/.deepseek/sessions/checkpoints/latest.json`
2. If the process crashes mid-turn, startup restores that checkpoint automatically (unless explicit `--resume` is used)
3. While degraded/offline, new prompts are queued in-memory and mirrored to `~/.deepseek/sessions/checkpoints/offline_queue.json`
4. Queue edits (`/queue ...`) are persisted continuously so drafts and queued prompts survive restarts
5. Successful turn completion clears the active checkpoint and writes a durable session snapshot
### Tool Execution
1. LLM requests tool via `tool_use` content block
@@ -149,6 +169,31 @@ Responses API (with automatic fallback if needed).
6. Post-execution hooks run
7. Result returned to agent loop
### Background Tasks
1. Client enqueues task (`/task add ...` or `POST /v1/tasks`)
2. `task_manager.rs` persists task + queue entry under `~/.deepseek/tasks`
3. Worker picks queued task (bounded pool), transitions to `running`
4. Task creates/uses a runtime thread and starts a runtime turn
5. `runtime_threads.rs` persists thread/turn/item records + monotonic event sequence
6. Timeline/tool summaries/artifact references are persisted incrementally
7. Final state (`completed|failed|canceled`) is durable and queryable via TUI/API
### Runtime Thread/Turn Timeline
1. API/TUI creates or resumes a thread (`/v1/threads*`)
2. Turn starts on the thread (`/v1/threads/{id}/turns`)
3. Engine events are mapped to item lifecycle events (`item.started|item.delta|item.completed`)
4. Interrupt/steer operations apply to the active turn only
5. Compaction (auto/manual) is emitted as `context_compaction` item lifecycle
6. Clients replay history and resume with `/v1/threads/{id}/events?since_seq=<n>`
### Durable Schema Gates
- `session_manager.rs`, `runtime_threads.rs`, and `task_manager.rs` embed `schema_version` on persisted records.
- On load, newer schema versions are rejected with explicit errors instead of silently truncating/overwriting data.
- This allows safe forward migrations and prevents corruption when binaries and stored state are out of sync.
## Extension Points
### Adding a New Tool
@@ -182,14 +227,20 @@ command = "echo 'Running tool: $TOOL_NAME'"
## Key Design Decisions
1. **Streaming-first**: All LLM responses stream for responsiveness
2. **Tool safety**: Non-yolo mode requires approval for destructive operations
2. **Tool safety**: Non-yolo mode requires approval for destructive operations, including side-effectful MCP tools
3. **Extensibility**: MCP, skills, and hooks allow customization without code changes
4. **Cross-platform**: Core works on Linux/macOS/Windows, sandboxing macOS-only
5. **Minimal dependencies**: Careful dependency selection for build speed
6. **Local-first runtime API**: HTTP/SSE endpoints are intended for trusted localhost access
## Configuration Files
- `~/.deepseek/config.toml` - Main configuration
- `/etc/deepseek/managed_config.toml` - Optional managed defaults layer (Unix)
- `/etc/deepseek/requirements.toml` - Optional allowed-policy constraints (Unix)
- `~/.deepseek/mcp.json` - MCP server configuration
- `~/.deepseek/skills/` - User skills directory
- `~/.deepseek/sessions/` - Session history
- `~/.deepseek/sessions/checkpoints/` - Crash checkpoint + offline queue persistence
- `~/.deepseek/tasks/` - Background task records, queue, timelines, artifacts
- `~/.deepseek/audit.log` - Append-only audit events for credential + approval/elevation actions
+33 -2
View File
@@ -24,7 +24,7 @@ You can define multiple profiles in the same file:
```toml
api_key = "PERSONAL_KEY"
default_text_model = "deepseek-reasoner"
default_text_model = "deepseek-v3.2"
[profiles.work]
api_key = "WORK_KEY"
@@ -49,7 +49,13 @@ These override config values:
- `DEEPSEEK_NOTES_PATH`
- `DEEPSEEK_MEMORY_PATH`
- `DEEPSEEK_ALLOW_SHELL` (`1`/`true` enables)
- `DEEPSEEK_APPROVAL_POLICY` (`on-request|untrusted|never`)
- `DEEPSEEK_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)
- `DEEPSEEK_MANAGED_CONFIG_PATH`
- `DEEPSEEK_REQUIREMENTS_PATH`
- `DEEPSEEK_MAX_SUBAGENTS` (clamped to `1..=20`)
- `DEEPSEEK_TASKS_DIR` (runtime task queue/artifact storage, default `~/.deepseek/tasks`)
- `DEEPSEEK_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)
## Settings File (Persistent UI Preferences)
@@ -77,8 +83,12 @@ Common settings keys:
- `api_key` (string, required): must be non-empty (or set `DEEPSEEK_API_KEY`).
- `base_url` (string, optional): defaults to `https://api.deepseek.com` (OpenAI-compatible Responses API).
- `default_text_model` (string, optional): defaults to `deepseek-reasoner`. Other available models include `deepseek-chat`, `deepseek-r1`, `deepseek-v3`, `deepseek-v3.2`. Check the DeepSeek API for the latest model list.
- `default_text_model` (string, optional): defaults to `deepseek-v3.2`. Any valid DeepSeek model ID is accepted; common IDs include `deepseek-chat`, `deepseek-reasoner`, `deepseek-r1`, `deepseek-v3`, and `deepseek-v3.2`. Check the DeepSeek API for the latest model list.
- `allow_shell` (bool, optional): defaults to `false`.
- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `/set approval_mode` also accepts `on-request` and `untrusted` aliases.
- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.
- `managed_config_path` (string, optional): managed config file loaded after user/env config.
- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.
- `max_subagents` (int, optional): defaults to `5` and is clamped to `1..=20`.
- `skills_dir` (string, optional): defaults to `~/.deepseek/skills` (each skill is a directory containing `SKILL.md`). Workspace-local `.agents/skills` or `./skills` are preferred when present.
- `mcp_config_path` (string, optional): defaults to `~/.deepseek/mcp.json`.
@@ -123,6 +133,27 @@ You can also override features for a single run:
Use `deepseek features list` to inspect known flags and their effective state.
## Managed Configuration and Requirements
DeepSeek CLI supports a policy layering model:
1. user config + profile + env overrides
2. managed config (if present)
3. requirements validation (if present)
By default on Unix:
- managed config: `/etc/deepseek/managed_config.toml`
- requirements: `/etc/deepseek/requirements.toml`
Requirements file shape:
```toml
allowed_approval_policies = ["on-request", "untrusted", "never"]
allowed_sandbox_modes = ["read-only", "workspace-write"]
```
If configured values violate requirements, startup fails with a descriptive error.
## Notes On `deepseek doctor`
`deepseek doctor` now follows the same config resolution rules as the rest of the CLI.
+25 -2
View File
@@ -2,6 +2,10 @@
DeepSeek CLI can load additional tools via MCP (Model Context Protocol). MCP servers are local processes that the CLI starts and communicates with over stdio.
Server mode note:
- `deepseek serve --mcp` runs the MCP stdio server.
- `deepseek serve --http` runs the runtime HTTP/SSE API (separate mode).
## Bootstrap MCP Config
Create a starter MCP config at your resolved MCP path:
@@ -12,6 +16,19 @@ deepseek mcp init
`deepseek 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
```
## Config File Location
Default path:
@@ -75,10 +92,16 @@ Per-server settings:
- `env` (object, optional)
- `connect_timeout`, `execute_timeout`, `read_timeout` (seconds, optional)
- `disabled` (bool, optional)
- `enabled` (bool, optional, default `true`)
- `required` (bool, optional): startup/connect validation fails if this server cannot initialize.
- `enabled_tools` (array, optional): allowlist of tool names for this server.
- `disabled_tools` (array, optional): denylist applied after `enabled_tools`.
## Safety Caveat (Important)
## Safety Notes
MCP tools currently execute without TUI approval prompts. Only configure MCP servers you trust, and treat MCP server configuration as equivalent to running code on your machine.
MCP tools now flow through the same tool-approval framework as built-in tools. Read-only MCP helpers (resource/prompt listing and reads) can run without prompts in suggestive approval modes, while side-effectful MCP tools require approval.
You should still only configure MCP servers you trust, and treat MCP server configuration as equivalent to running code on your machine.
## Troubleshooting
+3 -3
View File
@@ -7,7 +7,7 @@ DeepSeek CLI has two related concepts:
## TUI Modes
Press `Tab` to cycle: **Normal → Plan → Agent → YOLO → Normal**.
Press `Tab` to cycle: **Plan → Agent → YOLO → Plan**.
- **Normal**: chat-first. Approvals for file writes, shell, and paid tools.
- **Plan**: design-first prompting. Approvals match Normal.
@@ -38,9 +38,9 @@ By default, file tools are restricted to the `--workspace` directory. Enable tru
YOLO mode enables trust mode automatically.
## MCP Caveat (Important)
## MCP Behavior
MCP tools are exposed as `mcp_<server>_<tool>` and currently execute without TUI approval prompts. Only configure MCP servers you trust.
MCP tools are exposed as `mcp_<server>_<tool>` and use the same approval flow as built-in tools. Read-only MCP helpers may auto-run in suggestive approval modes; MCP tools with possible side effects require approval.
See `MCP.md`.
+95
View File
@@ -0,0 +1,95 @@
# DeepSeek CLI Operations Runbook
This runbook covers practical debugging and incident response for the local CLI/TUI runtime.
## Quick Triage
1. Confirm binary + config:
- `cargo run -- --version`
- `cat ~/.deepseek/config.toml` (or inspect configured profile)
2. Enable verbose logs:
- `RUST_LOG=deepseek_cli=debug cargo run`
- For HTTP retries/reconnects: `RUST_LOG=deepseek_cli::client=debug cargo run`
3. Capture current state:
- `ls ~/.deepseek/sessions`
- `ls ~/.deepseek/sessions/checkpoints`
- `ls ~/.deepseek/tasks`
## Incident: Turn Hangs or Stream Stops
Symptoms:
- TUI remains in loading state
- partial assistant output with no completion
Checks:
1. Inspect retry/health logs (`deepseek_cli::client`)
2. Verify endpoint connectivity:
- `curl -sS https://api.deepseek.com/v1/models -H "Authorization: Bearer $DEEPSEEK_API_KEY"`
3. Confirm no local sandbox/permission deadlock in tool output
Actions:
1. Cancel current turn (`Esc` in TUI)
2. Retry prompt; if still failing, restart TUI
3. On restart, verify crash checkpoint recovery message appears
## Incident: Network Outage / Offline Behavior
Expected behavior:
- New prompts are queued while offline mode is active
- Queue state persists to `~/.deepseek/sessions/checkpoints/offline_queue.json`
Checks:
1. Open queue in TUI: `/queue list`
2. Confirm persisted queue file exists and updates timestamp
Actions:
1. Restore connectivity
2. Re-send queued entries (from `/queue edit <n>` + Enter, or normal input flow)
3. Ensure queue file clears when queue is empty
## Incident: Crash Recovery Needed
Expected behavior:
- Checkpoint stored at `~/.deepseek/sessions/checkpoints/latest.json`
- Startup auto-restores checkpoint when no explicit `--resume` target is supplied
Actions:
1. Start TUI normally and verify "Recovered checkpoint session" status
2. If automatic recovery fails, inspect checkpoint JSON for schema mismatch
3. If schema is newer than binary supports, upgrade binary or remove stale checkpoint
## Incident: Persistent State Schema Errors
Symptoms:
- Errors like `schema vX is newer than supported vY`
Affected stores:
- sessions (`~/.deepseek/sessions/*.json`)
- runtime thread/turn/item records
- tasks (`~/.deepseek/tasks/tasks/*.json`)
Actions:
1. Confirm binary version and migration expectations
2. Back up the state directory before editing
3. Either:
- run with a newer compatible binary, or
- archive incompatible records and regenerate state
## Incident: MCP/Tool Execution Failures
Checks:
1. Validate `~/.deepseek/mcp.json` schema and server command paths
2. Confirm server process can start manually
3. Check sandbox denials in TUI history / logs
Actions:
1. Retry with required approvals (or YOLO only when appropriate)
2. Temporarily disable failing MCP server and isolate issue
3. Re-enable after verification with `/mcp` diagnostics
## Post-Incident Checklist
1. Preserve logs and relevant state files
2. Record trigger, impact, and mitigation
3. Add or update regression tests (retry/recovery/schema)
4. Update this runbook and architecture docs if behavior changed
+181
View File
@@ -0,0 +1,181 @@
# Runtime API (HTTP/SSE)
DeepSeek CLI can expose a local runtime API for external clients:
```bash
deepseek serve --http --host 127.0.0.1 --port 7878 --workers 2
```
Defaults:
- bind: `127.0.0.1:7878`
- workers: `2` (clamped to `1..8`)
## Security Model (Local-First)
- The server is designed for trusted local use.
- There is no built-in auth, user isolation, or TLS termination.
- Do not expose this API directly to untrusted networks.
- If remote access is required, place it behind your own authenticated reverse proxy/VPN.
## Runtime Data Model
The runtime uses a durable Thread/Turn/Item lifecycle.
- `ThreadRecord`
- `id`, `created_at`, `updated_at`
- `model`, `workspace`, `mode`
- `latest_turn_id`, `latest_response_bookmark`, `archived`
- `TurnRecord`
- `id`, `thread_id`
- `status`: `queued|in_progress|completed|failed|interrupted|canceled`
- timestamps, duration, usage, error summary
- `TurnItemRecord`
- `id`, `turn_id`
- `kind`: `user_message|agent_message|tool_call|file_change|command_execution|context_compaction|status|error`
- lifecycle `status`: `queued|in_progress|completed|failed|interrupted|canceled`
The event log is append-only with global monotonic `seq` for replay/resume.
## Endpoints
### Health and Session
- `GET /health`
- `GET /v1/sessions?limit=50&search=<substring>`
### Compatibility Stream (Single Turn)
- `POST /v1/stream`
Backwards-compatible one-shot SSE wrapper. Internally creates an archived runtime thread+turn.
Request body:
```json
{
"prompt": "Summarize recent commits",
"model": "deepseek-v3.2",
"mode": "agent",
"workspace": ".",
"allow_shell": false,
"trust_mode": false,
"auto_approve": true
}
```
Typical SSE events:
- `turn.started`
- `message.delta`
- `tool.started`
- `tool.progress`
- `tool.completed`
- `approval.required`
- `sandbox.denied`
- `status`
- `error`
- `turn.completed`
- `done`
### Thread Lifecycle
- `POST /v1/threads`
- `GET /v1/threads?limit=50&include_archived=false`
- `GET /v1/threads/{id}`
- `POST /v1/threads/{id}/resume`
- `POST /v1/threads/{id}/fork`
Create thread request example:
```json
{
"model": "deepseek-v3.2",
"workspace": ".",
"mode": "agent",
"allow_shell": false,
"trust_mode": false,
"auto_approve": true,
"archived": false
}
```
### Turn Lifecycle
- `POST /v1/threads/{id}/turns`
- `POST /v1/threads/{id}/turns/{turn_id}/steer`
- `POST /v1/threads/{id}/turns/{turn_id}/interrupt`
- `POST /v1/threads/{id}/compact`
Notes:
- Only one active turn is allowed per thread (`409 Conflict` on overlap).
- `interrupt` returns quickly and marks `turn.interrupt_requested`.
- Terminal turn status becomes `interrupted` only after cleanup completes.
- Manual compaction is exposed as a turn with `context_compaction` item lifecycle events.
### Replayable Events
- `GET /v1/threads/{id}/events?since_seq=<u64>`
Returns SSE replay backlog, then live events for that thread.
SSE payload shape:
```json
{
"seq": 42,
"timestamp": "2026-02-11T20:18:49.123Z",
"thread_id": "thr_1234abcd",
"turn_id": "turn_5678efgh",
"item_id": "item_90ab12cd",
"event": "item.delta",
"payload": {
"delta": "partial output",
"kind": "agent_message"
}
}
```
Common event names:
- `thread.started`
- `thread.forked`
- `turn.started`
- `turn.lifecycle`
- `turn.steered`
- `turn.interrupt_requested`
- `turn.completed`
- `item.started`
- `item.delta`
- `item.completed`
- `item.failed`
- `item.interrupted`
- `approval.required`
- `sandbox.denied`
Compaction visibility:
- auto compaction emits `item.started`/`item.completed` with item kind `context_compaction` and `auto=true`
- manual compaction emits the same with `auto=false`
### Background Tasks
- `GET /v1/tasks`
- `POST /v1/tasks`
- `GET /v1/tasks/{id}`
- `POST /v1/tasks/{id}/cancel`
Tasks execute through the same runtime thread/turn pipeline and include:
- linked `thread_id` / `turn_id`
- runtime event count
- timeline + tool summaries + artifact references
## Persistence
Runtime store (default under task data root):
- `runtime/threads/*.json`
- `runtime/turns/*.json`
- `runtime/items/*.json`
- `runtime/events/{thread_id}.jsonl`
- `runtime/state.json` (monotonic sequence)
Task store:
- default `~/.deepseek/tasks` (override with `DEEPSEEK_TASKS_DIR`)
Both runtime and task state are restart-safe.
+38
View File
@@ -0,0 +1,38 @@
//! Lightweight audit logging for sensitive operations.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use chrono::Utc;
use serde_json::{Value, json};
/// Append an audit event to `~/.deepseek/audit.log`.
///
/// This helper is best-effort by design: callers should not fail critical flows
/// if audit persistence fails.
pub fn log_sensitive_event(event: &str, details: Value) {
if let Err(err) = append_event(event, details) {
crate::logging::warn(format!("audit log write failed: {err}"));
}
}
fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
let path = default_audit_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let record = json!({
"ts": Utc::now().to_rfc3339(),
"event": event,
"details": details,
});
writeln!(file, "{}", serde_json::to_string(&record)?)?;
Ok(())
}
fn default_audit_path() -> anyhow::Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("home directory not found"))?;
Ok(home.join(".deepseek").join("audit.log"))
}
+764 -107
View File
File diff suppressed because it is too large Load Diff
+164 -9
View File
@@ -1,7 +1,6 @@
//! Config commands: config, set, settings, yolo, trust, logout
use super::CommandResult;
use crate::compaction::CompactionConfig;
use crate::config::clear_api_key;
use crate::palette;
use crate::settings::Settings;
@@ -22,6 +21,7 @@ pub fn show_config(app: &mut App) -> CommandResult {
Max sub-agents: {}\n\
Trust mode: {}\n\
Auto-compact: {}\n\
Sidebar width: {}%\n\
Total tokens: {}\n\
Project doc: {}",
app.mode.label(),
@@ -32,6 +32,7 @@ pub fn show_config(app: &mut App) -> CommandResult {
app.max_subagents,
if app.trust_mode { "yes" } else { "no" },
if app.auto_compact { "yes" } else { "no" },
app.sidebar_width_percent,
app.total_tokens,
if has_project_doc {
"loaded"
@@ -84,12 +85,18 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
match key.as_str() {
"model" => {
app.model = value.to_string();
return CommandResult::message(format!("model = {value}"));
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
return CommandResult::with_message_and_action(
format!("model = {value}"),
AppAction::UpdateCompaction(app.compaction_config()),
);
}
"approval_mode" | "approval" => {
let mode = match value.to_lowercase().as_str() {
"auto" => Some(ApprovalMode::Auto),
"suggest" | "suggested" => Some(ApprovalMode::Suggest),
"suggest" | "suggested" | "on-request" | "untrusted" => Some(ApprovalMode::Suggest),
"never" => Some(ApprovalMode::Never),
_ => None,
};
@@ -98,7 +105,9 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
app.approval_mode = m;
CommandResult::message(format!("approval_mode = {}", m.label()))
}
None => CommandResult::error("Invalid approval_mode. Use: auto, suggest, never"),
None => CommandResult::error(
"Invalid approval_mode. Use: auto, suggest/on-request/untrusted, never",
),
};
}
_ => {}
@@ -119,11 +128,7 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
match key.as_str() {
"auto_compact" | "compact" => {
app.auto_compact = settings.auto_compact;
let mut compaction = CompactionConfig::default();
compaction.enabled = app.auto_compact;
compaction.token_threshold = app.compact_threshold;
compaction.model = app.model.clone();
action = Some(AppAction::UpdateCompaction(compaction));
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
"show_thinking" | "thinking" => {
app.show_thinking = settings.show_thinking;
@@ -148,12 +153,20 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
"default_model" => {
if let Some(ref model) = settings.default_model {
app.model.clone_from(model);
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
}
"theme" => {
app.ui_theme = palette::ui_theme(&settings.theme);
app.mark_history_updated();
}
"sidebar_width" | "sidebar" => {
app.sidebar_width_percent = settings.sidebar_width_percent;
app.mark_history_updated();
}
_ => {}
}
@@ -237,4 +250,146 @@ mod tests {
assert_eq!(app.approval_mode, ApprovalMode::Auto);
assert_eq!(app.mode, AppMode::Yolo);
}
#[test]
fn test_show_config_displays_all_fields() {
let mut app = create_test_app();
app.total_tokens = 1234;
let result = show_config(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session Configuration"));
assert!(msg.contains("Mode:"));
assert!(msg.contains("Model:"));
assert!(msg.contains("Workspace:"));
assert!(msg.contains("Shell enabled:"));
assert!(msg.contains("Approval mode:"));
assert!(msg.contains("Max sub-agents:"));
assert!(msg.contains("Trust mode:"));
assert!(msg.contains("Auto-compact:"));
assert!(msg.contains("Sidebar width:"));
assert!(msg.contains("Total tokens:"));
assert!(msg.contains("Project doc:"));
}
#[test]
fn test_show_settings_loads_from_file() {
let mut app = create_test_app();
let result = show_settings(&mut app);
// Settings should load (may use defaults if file doesn't exist)
assert!(result.message.is_some());
}
#[test]
fn test_set_without_args_shows_usage() {
let mut app = create_test_app();
let result = set_config(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Usage: /set"));
assert!(msg.contains("Available settings:"));
}
#[test]
fn test_set_model_updates_app_state() {
let mut app = create_test_app();
let _old_model = app.model.clone();
let result = set_config(&mut app, Some("model deepseek-reasoner"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("model = deepseek-reasoner"));
assert_eq!(app.model, "deepseek-reasoner");
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
}
#[test]
fn test_set_model_with_save_flag() {
let mut app = create_test_app();
let _result = set_config(&mut app, Some("model deepseek-reasoner --save"));
// Note: This test may fail in environments where settings can't be saved
// The important thing is that the model is updated
assert_eq!(app.model, "deepseek-reasoner");
}
#[test]
fn test_set_approval_mode_valid_values() {
let mut app = create_test_app();
// Test auto
let result = set_config(&mut app, Some("approval_mode auto"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Auto);
// Test suggest
let result = set_config(&mut app, Some("approval_mode suggest"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
// Test never
let result = set_config(&mut app, Some("approval_mode never"));
assert!(result.message.is_some());
assert_eq!(app.approval_mode, ApprovalMode::Never);
}
#[test]
fn test_set_approval_mode_invalid_value() {
let mut app = create_test_app();
let result = set_config(&mut app, Some("approval_mode invalid"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Invalid approval_mode"));
}
#[test]
fn test_set_without_save_flag() {
let mut app = create_test_app();
let result = set_config(&mut app, Some("auto_compact true"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("(session only"));
}
#[test]
fn test_trust_enables_flag() {
let mut app = create_test_app();
assert!(!app.trust_mode);
let result = trust(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Trust mode enabled"));
assert!(app.trust_mode);
}
#[test]
fn test_logout_clears_api_key_state() {
let mut app = create_test_app();
// Note: This test may fail if API key is not set in environment
// but the state changes should still occur
let result = logout(&mut app);
assert!(result.message.is_some());
assert_eq!(app.onboarding, OnboardingState::ApiKey);
assert!(app.onboarding_needs_api_key);
assert!(app.api_key_input.is_empty());
assert_eq!(app.api_key_cursor, 0);
}
#[test]
fn test_set_invalid_setting() {
let mut app = create_test_app();
let _result = set_config(&mut app, Some("nonexistent value"));
// Should either error or handle as session setting
// The current implementation tries to set it in Settings
// which may succeed or fail depending on Settings implementation
}
#[test]
fn test_set_key_without_value() {
let mut app = create_test_app();
let result = set_config(&mut app, Some("model"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Usage: /set"));
}
}
+239 -18
View File
@@ -2,7 +2,7 @@
use std::fmt::Write;
use crate::tools::plan::PlanState;
use crate::config::COMMON_DEEPSEEK_MODELS;
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::views::{HelpView, ModalKind, SubAgentsView};
@@ -39,11 +39,21 @@ pub fn clear(app: &mut App) -> CommandResult {
app.api_messages.clear();
app.transcript_selection.clear();
app.total_conversation_tokens = 0;
app.clear_todos();
let mut plan = app.plan_state.blocking_lock();
*plan = PlanState::default();
let todos_cleared = app.clear_todos();
app.tool_log.clear();
CommandResult::message("Conversation cleared")
app.tool_cells.clear();
app.tool_details_by_cell.clear();
app.exploring_entries.clear();
app.ignored_tool_calls.clear();
app.pending_tool_uses.clear();
app.last_exec_wait_command = None;
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
if todos_cleared {
CommandResult::message("Conversation cleared")
} else {
CommandResult::message("Conversation cleared (plan state busy; run /clear again if needed)")
}
}
/// Exit the application
@@ -51,30 +61,32 @@ pub fn exit() -> CommandResult {
CommandResult::action(AppAction::Quit)
}
/// Available DeepSeek models
const AVAILABLE_MODELS: &[&str] = &[
"deepseek-v3.2",
"deepseek-reasoner",
"deepseek-chat",
"deepseek-r1",
"deepseek-v3",
];
/// Switch or view current model
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
if let Some(name) = model_name {
let old_model = app.model.clone();
app.model = name.to_string();
CommandResult::message(format!("Model changed: {old_model}{name}"))
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
CommandResult::with_message_and_action(
format!("Model changed: {old_model}{name}"),
AppAction::UpdateCompaction(app.compaction_config()),
)
} else {
let available = AVAILABLE_MODELS.join(", ");
let common = COMMON_DEEPSEEK_MODELS.join(", ");
CommandResult::message(format!(
"Current model: {}\nAvailable: {}",
app.model, available
"Current model: {}\nCommon models: {}\nAny valid DeepSeek model ID is accepted (for example: deepseek-v4-mini once released).",
app.model, common
))
}
}
/// Fetch and list available models from the configured API endpoint.
pub fn models(_app: &mut App) -> CommandResult {
CommandResult::action(AppAction::FetchModels)
}
/// List sub-agent status from the engine
pub fn subagents(app: &mut App) -> CommandResult {
if app.view_stack.top_kind() != Some(ModalKind::SubAgents) {
@@ -139,6 +151,7 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
let _ = writeln!(stats, "/settings - Show persistent settings");
let _ = writeln!(stats, "/model - Switch or view model");
let _ = writeln!(stats, "/subagents - List sub-agent status");
let _ = writeln!(stats, "/task list - Show background task queue");
let _ = writeln!(stats, "/help - Show help");
// Mode-specific tips
@@ -164,3 +177,211 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
CommandResult::message(stats)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::models::Message;
use crate::tui::app::{App, AppMode, TuiOptions};
use crate::tui::history::HistoryCell;
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: PathBuf::from("/tmp/test-skills"),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_help_unknown_command() {
let mut app = create_test_app();
let result = help(&mut app, Some("nonexistent"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Unknown command"));
assert!(result.action.is_none());
}
#[test]
fn test_help_known_command() {
let mut app = create_test_app();
let result = help(&mut app, Some("clear"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("clear"));
assert!(msg.contains("Clear conversation history"));
assert!(msg.contains("Usage: /clear"));
}
#[test]
fn test_help_pushes_overlay() {
let mut app = create_test_app();
assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help));
let result = help(&mut app, None);
assert_eq!(result.message, None);
assert_eq!(result.action, None);
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help));
}
#[test]
fn test_help_does_not_duplicate_overlay() {
let mut app = create_test_app();
help(&mut app, None);
let initial_kind = app.view_stack.top_kind();
help(&mut app, None);
assert_eq!(app.view_stack.top_kind(), initial_kind);
}
#[test]
fn test_clear_resets_all_state() {
let mut app = create_test_app();
// Set up some state
app.history.push(HistoryCell::User {
content: "test".to_string(),
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![],
});
app.total_conversation_tokens = 100;
app.tool_log.push("test".to_string());
let result = clear(&mut app);
assert!(result.message.is_some());
assert!(app.history.is_empty());
assert!(app.api_messages.is_empty());
assert_eq!(app.total_conversation_tokens, 0);
assert!(app.tool_log.is_empty());
assert!(app.tool_cells.is_empty());
assert!(app.tool_details_by_cell.is_empty());
}
#[test]
fn test_exit_returns_quit_action() {
let result = exit();
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::Quit)));
}
#[test]
fn test_model_change_updates_state() {
let mut app = create_test_app();
let old_model = app.model.clone();
let result = model(&mut app, Some("deepseek-reasoner"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains(&old_model));
assert!(msg.contains("deepseek-reasoner"));
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
assert_eq!(app.model, "deepseek-reasoner");
assert_eq!(app.last_prompt_tokens, None);
assert_eq!(app.last_completion_tokens, None);
}
#[test]
fn test_model_without_args_shows_info() {
let mut app = create_test_app();
let result = model(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Current model:"));
assert!(msg.contains("Common models:"));
assert!(result.action.is_none());
}
#[test]
fn test_models_triggers_fetch_action() {
let mut app = create_test_app();
let result = models(&mut app);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::FetchModels)));
}
#[test]
fn test_subagents_pushes_view_and_sets_status() {
let mut app = create_test_app();
let result = subagents(&mut app);
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::ListSubAgents)));
assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SubAgents));
assert_eq!(
app.status_message,
Some("Fetching sub-agent status...".to_string())
);
}
#[test]
fn test_deepseek_links() {
let result = deepseek_links();
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("DeepSeek Links"));
assert!(msg.contains("https://platform.deepseek.com"));
assert!(result.action.is_none());
}
#[test]
fn test_home_dashboard_includes_all_sections() {
let mut app = create_test_app();
app.total_conversation_tokens = 1234;
let result = home_dashboard(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("DeepSeek CLI Home Dashboard"));
assert!(msg.contains("Model:"));
assert!(msg.contains("Mode:"));
assert!(msg.contains("Workspace:"));
assert!(msg.contains("History:"));
assert!(msg.contains("Tokens:"));
assert!(msg.contains("Quick Actions"));
assert!(msg.contains("Mode Tips"));
assert!(result.action.is_none());
}
#[test]
fn test_home_dashboard_shows_queued_when_present() {
let mut app = create_test_app();
app.queued_messages
.push_back(crate::tui::app::QueuedMessage::new(
"test".to_string(),
None,
));
let result = home_dashboard(&mut app);
let msg = result.message.unwrap();
assert!(msg.contains("Queued:"));
}
#[test]
fn test_home_dashboard_mode_tips_for_each_mode() {
let modes = [
AppMode::Normal,
AppMode::Agent,
AppMode::Yolo,
AppMode::Plan,
];
for mode in modes {
let mut app = create_test_app();
app.mode = mode;
let result = home_dashboard(&mut app);
let msg = result.message.unwrap();
assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}");
}
}
}
+255 -5
View File
@@ -1,7 +1,10 @@
#![allow(clippy::items_after_test_module)]
//! Debug commands: tokens, cost, system, context, undo, retry
use super::CommandResult;
use crate::models::{SystemPrompt, context_window_for_model};
use crate::compaction::estimate_tokens;
use crate::models::{DEFAULT_CONTEXT_WINDOW_TOKENS, SystemPrompt, context_window_for_model};
use crate::tui::app::{App, AppAction};
use crate::tui::history::HistoryCell;
use crate::utils::estimate_message_chars;
@@ -75,20 +78,21 @@ pub fn system_prompt(app: &mut App) -> CommandResult {
/// Show context window usage
pub fn context(app: &mut App) -> CommandResult {
let mut total_chars = estimate_message_chars(&app.api_messages);
let mut estimated_tokens = estimate_tokens(&app.api_messages);
// System prompt
if let Some(SystemPrompt::Text(text)) = &app.system_prompt {
total_chars += text.len();
estimated_tokens = estimated_tokens.saturating_add(estimate_text_tokens(text));
} else if let Some(SystemPrompt::Blocks(blocks)) = &app.system_prompt {
for block in blocks {
total_chars += block.text.len();
estimated_tokens = estimated_tokens.saturating_add(estimate_text_tokens(&block.text));
}
}
// Rough token estimate (4 chars per token on average)
let estimated_tokens = total_chars / 4;
let context_size = context_window_for_model(&app.model).unwrap_or(128_000);
let context_size =
context_window_for_model(&app.model).unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS);
let estimated_tokens_u32 = u32::try_from(estimated_tokens).unwrap_or(u32::MAX);
let usage_pct = (f64::from(estimated_tokens_u32) / f64::from(context_size) * 100.0).min(100.0);
@@ -110,6 +114,247 @@ pub fn context(app: &mut App) -> CommandResult {
))
}
fn estimate_text_tokens(text: &str) -> usize {
text.chars().count().div_ceil(4)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::models::{ContentBlock, Message, SystemBlock};
use crate::tui::app::{App, TuiOptions};
use std::path::PathBuf;
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: PathBuf::from("/tmp/test-skills"),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_tokens_shows_usage_info() {
let mut app = create_test_app();
app.total_tokens = 1234;
app.session_cost = 0.05;
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![],
});
app.history.push(HistoryCell::User {
content: "test".to_string(),
});
let result = tokens(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Token Usage"));
assert!(msg.contains("Total tokens:"));
assert!(msg.contains("Session cost:"));
assert!(msg.contains("API messages:"));
assert!(msg.contains("Chat messages:"));
assert!(msg.contains("Model:"));
}
#[test]
fn test_cost_shows_spending_info() {
let mut app = create_test_app();
app.session_cost = 0.1234;
let result = cost(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session Cost"));
assert!(msg.contains("Total spent:"));
assert!(msg.contains("$0.1234"));
}
#[test]
fn test_system_prompt_displays_text() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Text("Test system prompt".to_string()));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("System Prompt"));
assert!(msg.contains("Test system prompt"));
}
#[test]
fn test_system_prompt_displays_blocks() {
let mut app = create_test_app();
app.system_prompt = Some(SystemPrompt::Blocks(vec![
SystemBlock {
block_type: "text".to_string(),
text: "Block 1".to_string(),
cache_control: None,
},
SystemBlock {
block_type: "text".to_string(),
text: "Block 2".to_string(),
cache_control: None,
},
]));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("System Prompt"));
assert!(msg.contains("Block 1"));
assert!(msg.contains("Block 2"));
}
#[test]
fn test_system_prompt_none() {
let mut app = create_test_app();
app.system_prompt = None;
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("(no system prompt)"));
}
#[test]
fn test_system_prompt_truncates_long_text() {
let mut app = create_test_app();
let long_text = "x".repeat(600);
app.system_prompt = Some(SystemPrompt::Text(long_text));
let result = system_prompt(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("..."));
assert!(msg.contains("chars total"));
}
#[test]
fn test_context_shows_usage_stats() {
let mut app = create_test_app();
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Hello".to_string(),
cache_control: None,
}],
});
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
let result = context(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Context Usage"));
assert!(msg.contains("Characters:"));
assert!(msg.contains("Estimated tokens:"));
assert!(msg.contains("Context window:"));
assert!(msg.contains("Usage:"));
assert!(msg.contains("Messages:"));
assert!(msg.contains("API messages:"));
}
#[test]
fn test_undo_removes_last_exchange() {
let mut app = create_test_app();
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Hi".to_string(),
streaming: false,
});
app.api_messages.push(Message {
role: "user".to_string(),
content: vec![],
});
app.api_messages.push(Message {
role: "assistant".to_string(),
content: vec![],
});
let initial_history_len = app.history.len();
let initial_api_len = app.api_messages.len();
let result = undo(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Removed"));
assert!(app.history.len() < initial_history_len);
assert!(app.api_messages.len() < initial_api_len);
}
#[test]
fn test_undo_nothing_to_undo() {
let mut app = create_test_app();
// Clear any default history
app.history.clear();
app.api_messages.clear();
let result = undo(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Nothing to undo") || msg.contains("Removed"));
}
#[test]
fn test_retry_with_previous_message() {
let mut app = create_test_app();
app.history.push(HistoryCell::User {
content: "Test message".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Response".to_string(),
streaming: false,
});
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Retrying"));
assert!(msg.contains("Test message"));
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
}
#[test]
fn test_retry_no_previous_message() {
let mut app = create_test_app();
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No previous request to retry"));
assert!(result.action.is_none());
}
#[test]
fn test_retry_truncates_long_input() {
let mut app = create_test_app();
let long_input = "x".repeat(100);
app.history.push(HistoryCell::User {
content: long_input.clone(),
});
app.history.push(HistoryCell::Assistant {
content: "Response".to_string(),
streaming: false,
});
let result = retry(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Retrying"));
assert!(msg.contains("..."));
}
}
/// Remove last message pair (user + assistant)
pub fn undo(app: &mut App) -> CommandResult {
// Remove from display history (up to the last user message)
@@ -133,6 +378,11 @@ pub fn undo(app: &mut App) -> CommandResult {
}
if removed_count > 0 {
// Keep tool/index mappings consistent after truncation.
app.tool_cells.clear();
app.tool_details_by_cell.clear();
app.exploring_entries.clear();
app.ignored_tool_calls.clear();
app.mark_history_updated();
CommandResult::message(format!("Removed {removed_count} message(s)"))
} else {
+119
View File
@@ -151,3 +151,122 @@ fn extract_cargo_name(content: &str) -> Option<String> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_init_creates_agents_md() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = init(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Created AGENTS.md"));
let agents_path = tmpdir.path().join("AGENTS.md");
assert!(agents_path.exists());
}
#[test]
fn test_init_fails_if_exists() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// Create file first
std::fs::write(tmpdir.path().join("AGENTS.md"), "existing").unwrap();
let result = init(&mut app);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("already exists"));
}
#[test]
fn test_detect_project_type_rust() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(
tmpdir.path().join("Cargo.toml"),
"[package]\nname = \"test\"",
)
.unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Rust"));
assert!(info.contains("cargo build"));
assert!(info.contains("cargo test"));
}
#[test]
fn test_detect_project_type_node() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(tmpdir.path().join("package.json"), "{}").unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Node.js"));
assert!(info.contains("npm install"));
}
#[test]
fn test_detect_project_type_python() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(tmpdir.path().join("pyproject.toml"), "[project]").unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Python"));
}
#[test]
fn test_detect_project_type_go() {
let tmpdir = TempDir::new().unwrap();
std::fs::write(tmpdir.path().join("go.mod"), "module test").unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Go"));
}
#[test]
fn test_detect_project_type_unknown() {
let tmpdir = TempDir::new().unwrap();
let info = detect_project_type(tmpdir.path());
assert!(info.contains("Project Type: Unknown"));
}
#[test]
fn test_extract_cargo_name() {
let cargo = r#"
[package]
name = "my-project"
version = "1.0.0"
"#;
assert_eq!(extract_cargo_name(cargo), Some("my-project".to_string()));
}
#[test]
fn test_extract_cargo_name_single_quotes() {
let cargo = r#"name = 'single-quoted'"#;
assert_eq!(extract_cargo_name(cargo), Some("single-quoted".to_string()));
}
#[test]
fn test_extract_cargo_name_not_found() {
let cargo = "[package]\nversion = \"1.0.0\"";
assert_eq!(extract_cargo_name(cargo), None);
}
}
+15
View File
@@ -12,6 +12,7 @@ mod queue;
mod review;
mod session;
mod skills;
mod task;
use crate::tui::app::{App, AppAction};
@@ -103,6 +104,12 @@ pub const COMMANDS: &[CommandInfo] = &[
description: "Switch or view current model",
usage: "/model [name]",
},
CommandInfo {
name: "models",
aliases: &[],
description: "List available models from API",
usage: "/models",
},
CommandInfo {
name: "queue",
aliases: &["queued"],
@@ -133,6 +140,12 @@ pub const COMMANDS: &[CommandInfo] = &[
description: "Append note to persistent notes file (.deepseek/notes.md)",
usage: "/note <text>",
},
CommandInfo {
name: "task",
aliases: &["tasks"],
description: "Manage background tasks",
usage: "/task [add <prompt>|list|show <id>|cancel <id>]",
},
// Session commands
CommandInfo {
name: "save",
@@ -280,11 +293,13 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"clear" => core::clear(app),
"exit" | "quit" | "q" => core::exit(),
"model" => core::model(app, arg),
"models" => core::models(app),
"queue" | "queued" => queue::queue(app, arg),
"subagents" | "agents" => core::subagents(app),
"deepseek" | "dashboard" | "api" => core::deepseek_links(),
"home" | "stats" | "overview" => core::home_dashboard(app),
"note" => note::note(app, arg),
"task" | "tasks" => task::task(app, arg),
// Session commands
"save" => session::save(app, arg),
+76
View File
@@ -48,3 +48,79 @@ pub fn note(app: &mut App, content: Option<&str>) -> CommandResult {
CommandResult::message(format!("Note appended to {}", notes_path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_note_without_content_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = note(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /note"));
}
#[test]
fn test_note_with_empty_content_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = note(&mut app, Some(" "));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("cannot be empty"));
}
#[test]
fn test_note_appends_to_file() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = note(&mut app, Some("Test note content"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Note appended to"));
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
assert!(notes_path.exists());
let content = std::fs::read_to_string(&notes_path).unwrap();
assert!(content.contains("Test note content"));
}
#[test]
fn test_note_multiple_appends() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
note(&mut app, Some("First note"));
note(&mut app, Some("Second note"));
let notes_path = tmpdir.path().join(".deepseek").join("notes.md");
let content = std::fs::read_to_string(&notes_path).unwrap();
assert!(content.contains("First note"));
assert!(content.contains("Second note"));
// Should have two separators
assert_eq!(content.matches("---").count(), 2);
}
}
+174
View File
@@ -127,3 +127,177 @@ fn truncate_preview(text: &str) -> String {
out.push_str("...");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, QueuedMessage, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_queue_list_empty() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No queued messages"));
}
#[test]
fn test_queue_list_with_messages() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("First message".to_string(), None));
app.queued_messages
.push_back(QueuedMessage::new("Second message".to_string(), None));
let result = queue(&mut app, Some("list"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Queued messages (2)"));
assert!(msg.contains("1. First message"));
assert!(msg.contains("2. Second message"));
}
#[test]
fn test_queue_edit_missing_index() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("Test".to_string(), None));
let result = queue(&mut app, Some("edit"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Missing index"));
}
#[test]
fn test_queue_edit_invalid_index() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, Some("edit abc"));
assert!(result.message.is_some());
assert!(
result
.message
.unwrap()
.contains("must be a positive number")
);
}
#[test]
fn test_queue_edit_not_found() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, Some("edit 1"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("not found"));
}
#[test]
fn test_queue_edit_already_editing() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("First".to_string(), None));
app.queued_messages
.push_back(QueuedMessage::new("Second".to_string(), None));
// Start editing
queue(&mut app, Some("edit 1"));
// Try to edit another
let result = queue(&mut app, Some("edit 2"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Already editing"));
}
#[test]
fn test_queue_edit_success() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("Original message".to_string(), None));
let result = queue(&mut app, Some("edit 1"));
assert!(result.message.is_some());
assert_eq!(app.input, "Original message");
assert_eq!(app.cursor_position, app.input.len());
assert!(app.queued_draft.is_some());
}
#[test]
fn test_queue_drop_success() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("To drop".to_string(), None));
let initial_count = app.queued_messages.len();
let result = queue(&mut app, Some("drop 1"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Dropped queued message"));
assert_eq!(app.queued_messages.len(), initial_count - 1);
}
#[test]
fn test_queue_clear() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.queued_messages
.push_back(QueuedMessage::new("Message 1".to_string(), None));
app.queued_messages
.push_back(QueuedMessage::new("Message 2".to_string(), None));
let result = queue(&mut app, Some("clear"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Queue cleared"));
assert!(app.queued_messages.is_empty());
}
#[test]
fn test_queue_clear_already_empty() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = queue(&mut app, Some("clear"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Queue already empty"));
}
#[test]
fn test_truncate_preview_short_text() {
let result = truncate_preview("Short text");
assert_eq!(result, "Short text");
}
#[test]
fn test_truncate_preview_long_text() {
let long_text = "x".repeat(200);
let result = truncate_preview(&long_text);
assert!(result.len() <= PREVIEW_LIMIT + 3);
assert!(result.ends_with("..."));
}
#[test]
fn test_truncate_preview_unicode() {
let text = "Hello 世界 🌍";
let result = truncate_preview(text);
assert_eq!(result, text);
}
}
+87 -2
View File
@@ -6,6 +6,14 @@ use crate::tui::history::HistoryCell;
use super::CommandResult;
fn warnings_suffix(registry: &SkillRegistry) -> String {
if registry.warnings().is_empty() {
return String::new();
}
format!("\n\nWarnings:\n- {}", registry.warnings().join("\n- "))
}
pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
let target = args.unwrap_or("").trim();
if target.is_empty() {
@@ -14,11 +22,17 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
let mut warnings = warnings_suffix(&registry);
let mut skill = registry.get("review").cloned();
let global_dir = default_skills_dir();
if skill.is_none() && global_dir != skills_dir {
let registry = SkillRegistry::discover(&global_dir);
if warnings.is_empty() {
warnings = warnings_suffix(&registry);
} else if !registry.warnings().is_empty() {
warnings.push_str(&format!("\n- {}", registry.warnings().join("\n- ")));
}
skill = registry.get("review").cloned();
}
@@ -27,9 +41,10 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
None => {
let global_display = global_dir.display();
return CommandResult::error(format!(
"Review skill not found in {} or {}. Create ~/.deepseek/skills/review/SKILL.md.",
"Review skill not found in {} or {}. Create ~/.deepseek/skills/review/SKILL.md.{}",
skills_dir.display(),
global_display
global_display,
warnings
));
}
};
@@ -46,3 +61,73 @@ pub fn review(app: &mut App, args: Option<&str>) -> CommandResult {
CommandResult::action(AppAction::SendMessage(target.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
fn create_review_skill_dir(tmpdir: &TempDir) {
let skill_dir = tmpdir.path().join("skills").join("review");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: review\ndescription: Code review skill\n---\nReview the code",
)
.unwrap();
}
#[test]
fn test_review_without_target() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = review(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /review"));
}
#[test]
fn test_review_without_skill_installed() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// Set skills dir to empty temp dir
app.skills_dir = tmpdir.path().join("nonexistent_skills");
let result = review(&mut app, Some("file.rs"));
// The command should either error about missing skill or work if global skill exists
assert!(result.message.is_some() || result.action.is_some());
}
#[test]
fn test_review_with_skill_activates_and_sends() {
let tmpdir = TempDir::new().unwrap();
create_review_skill_dir(&tmpdir);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = review(&mut app, Some("file.rs"));
assert!(result.message.is_none());
assert!(matches!(result.action, Some(AppAction::SendMessage(_))));
assert!(app.active_skill.is_some());
assert!(!app.history.is_empty());
}
}
+216 -6
View File
@@ -3,7 +3,6 @@
use std::fmt::Write;
use std::path::PathBuf;
use crate::compaction::CompactionConfig;
use crate::session_manager::create_saved_session_with_mode;
use crate::tui::app::{App, AppAction};
use crate::tui::history::{HistoryCell, history_cells_from_message};
@@ -91,9 +90,12 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
app.mark_history_updated();
app.transcript_selection.clear();
app.model.clone_from(&session.metadata.model);
app.update_model_compaction_budget();
app.workspace.clone_from(&session.metadata.workspace);
app.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
app.total_conversation_tokens = app.total_tokens;
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
app.current_session_id = Some(session.metadata.id.clone());
if let Some(sp) = session.system_prompt {
app.system_prompt = Some(crate::models::SystemPrompt::Text(sp));
@@ -119,17 +121,13 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
/// Toggle auto-compaction
pub fn compact(app: &mut App) -> CommandResult {
app.auto_compact = !app.auto_compact;
let mut compaction = CompactionConfig::default();
compaction.enabled = app.auto_compact;
compaction.token_threshold = app.compact_threshold;
compaction.model = app.model.clone();
CommandResult::with_message_and_action(
format!(
"Auto-compact: {}",
if app.auto_compact { "ON" } else { "OFF" }
),
AppAction::UpdateCompaction(compaction),
AppAction::UpdateCompaction(app.compaction_config()),
)
}
@@ -191,3 +189,215 @@ fn line_to_string(line: ratatui::text::Line<'static>) -> String {
.map(|span| span.content.to_string())
.collect::<String>()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn test_save_creates_file_and_sets_session_id() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let save_path = tmpdir.path().join("test_session.json");
let result = save(&mut app, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session saved to"));
assert!(msg.contains("ID:"));
assert!(app.current_session_id.is_some());
assert!(save_path.exists());
}
#[test]
fn test_save_with_default_path_uses_workspace() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = save(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
// Should create file in workspace with timestamp name
// Give it a moment to ensure file is written
std::thread::sleep(std::time::Duration::from_millis(10));
let entries: Vec<_> = std::fs::read_dir(tmpdir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("session_"))
.collect();
// Test passes if file was created or if save returned success message
assert!(!entries.is_empty() || msg.contains("Session saved"));
}
#[test]
fn test_save_serialization_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// This should work normally since SavedSession is serializable
// Testing error path would require mocking, which is complex
let save_path = tmpdir.path().join("test.json");
let result = save(&mut app, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
}
#[test]
fn test_load_without_path_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = load(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /load"));
}
#[test]
fn test_load_nonexistent_file_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = load(&mut app, Some("nonexistent.json"));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Failed to read"));
}
#[test]
fn test_load_invalid_json_returns_error() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let bad_file = tmpdir.path().join("bad.json");
std::fs::write(&bad_file, "not valid json").unwrap();
let result = load(&mut app, Some(bad_file.to_str().unwrap()));
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Failed to parse"));
}
#[test]
fn test_load_valid_session_restores_state() {
let tmpdir = TempDir::new().unwrap();
let mut app1 = create_test_app_with_tmpdir(&tmpdir);
// Set up some state to save
app1.api_messages.push(crate::models::Message {
role: "user".to_string(),
content: vec![crate::models::ContentBlock::Text {
text: "Hello".to_string(),
cache_control: None,
}],
});
app1.total_tokens = 500;
let save_path = tmpdir.path().join("test.json");
save(&mut app1, Some(save_path.to_str().unwrap()));
// Create new app and load
let mut app2 = create_test_app_with_tmpdir(&tmpdir);
let result = load(&mut app2, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session loaded from"));
assert!(msg.contains("ID:"));
assert!(msg.contains("messages"));
assert_eq!(app2.api_messages.len(), 1);
assert_eq!(app2.total_tokens, 500);
assert!(app2.current_session_id.is_some());
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
}
#[test]
fn test_compact_toggles_state() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let initial = app.auto_compact;
let result = compact(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Auto-compact:"));
assert!(msg.contains(if initial { "OFF" } else { "ON" }));
assert_eq!(app.auto_compact, !initial);
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
// Toggle back
let _result2 = compact(&mut app);
assert_eq!(app.auto_compact, initial);
}
#[test]
fn test_export_crees_markdown_file() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
app.history.push(HistoryCell::User {
content: "Hello".to_string(),
});
app.history.push(HistoryCell::Assistant {
content: "Hi there".to_string(),
streaming: false,
});
let export_path = tmpdir.path().join("export.md");
let result = export(&mut app, Some(export_path.to_str().unwrap()));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Exported to"));
assert!(export_path.exists());
let content = std::fs::read_to_string(&export_path).unwrap();
assert!(content.contains("# Chat Export"));
assert!(content.contains("**Model:**"));
assert!(content.contains("**You:**"));
assert!(content.contains("**Assistant:**"));
}
#[test]
fn test_export_with_default_path() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = export(&mut app, None);
assert!(result.message.is_some());
// Should create file with timestamp name in current dir
let entries: Vec<_> = std::fs::read_dir(".")
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("chat_export_"))
.collect();
// Clean up
for entry in &entries {
let _ = std::fs::remove_file(entry.path());
}
assert!(!entries.is_empty() || result.message.unwrap().contains("Exported to"));
}
#[test]
fn test_sessions_pushes_picker_view() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let initial_kind = app.view_stack.top_kind();
let result = sessions(&mut app);
assert_eq!(result.message, None);
assert!(result.action.is_none());
// View should have changed (session picker should be on top)
assert_ne!(app.view_stack.top_kind(), initial_kind);
}
}
+121 -6
View File
@@ -8,10 +8,24 @@ use crate::tui::history::HistoryCell;
use super::CommandResult;
fn render_skill_warnings(registry: &SkillRegistry) -> String {
if registry.warnings().is_empty() {
return String::new();
}
let mut out = String::new();
let _ = writeln!(out, "\nWarnings ({}):", registry.warnings().len());
for warning in registry.warnings() {
let _ = writeln!(out, " - {warning}");
}
out
}
/// List all available skills
pub fn list_skills(app: &mut App) -> CommandResult {
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
let warnings = render_skill_warnings(&registry);
if registry.is_empty() {
let msg = format!(
@@ -25,7 +39,7 @@ pub fn list_skills(app: &mut App) -> CommandResult {
description: What this skill does\n \
allowed-tools: read_file, list_dir\n \
---\n\n \
<instructions here>",
<instructions here>{warnings}",
skills_dir.display(),
skills_dir.display()
);
@@ -39,8 +53,9 @@ pub fn list_skills(app: &mut App) -> CommandResult {
}
let _ = write!(
output,
"\nUse /skill <name> to run a skill\nSkills location: {}",
skills_dir.display()
"\nUse /skill <name> to run a skill\nSkills location: {}{}",
skills_dir.display(),
warnings
);
CommandResult::message(output)
@@ -76,17 +91,117 @@ pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
))
} else {
let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect();
let warnings = render_skill_warnings(&registry);
if available.is_empty() {
CommandResult::error(format!(
"Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills."
"Skill '{name}' not found. No skills installed.\n\nUse /skills to see how to add skills.{warnings}"
))
} else {
CommandResult::error(format!(
"Skill '{}' not found.\n\nAvailable skills: {}",
"Skill '{}' not found.\n\nAvailable skills: {}{}",
name,
available.join(", ")
available.join(", "),
warnings
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use tempfile::TempDir;
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
let skill_dir = tmpdir.path().join("skills").join(skill_name);
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
}
#[test]
fn test_list_skills_empty_directory() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = list_skills(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No skills found"));
assert!(msg.contains("Skills location:"));
}
#[test]
fn test_list_skills_with_skills() {
let tmpdir = TempDir::new().unwrap();
create_skill_dir(
&tmpdir,
"test-skill",
"---\nname: test-skill\ndescription: A test skill\n---\nDo something",
);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = list_skills(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Available skills"));
assert!(msg.contains("/test-skill"));
}
#[test]
fn test_run_skill_without_name() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, None);
assert!(result.message.is_some());
assert!(result.message.unwrap().contains("Usage: /skill"));
}
#[test]
fn test_run_skill_not_found() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, Some("nonexistent"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("not found"));
}
#[test]
fn test_run_skill_activates() {
let tmpdir = TempDir::new().unwrap();
create_skill_dir(
&tmpdir,
"test-skill",
"---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, Some("test-skill"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Skill 'test-skill' activated"));
assert!(msg.contains("A test skill"));
assert!(app.active_skill.is_some());
assert!(!app.history.is_empty());
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Task commands: add/list/show/cancel
use crate::tui::app::{App, AppAction};
use super::CommandResult;
pub fn task(_app: &mut App, args: Option<&str>) -> CommandResult {
let raw = args.unwrap_or("").trim();
if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
return CommandResult::action(AppAction::TaskList);
}
let mut parts = raw.splitn(2, char::is_whitespace);
let action = parts.next().unwrap_or("").to_ascii_lowercase();
let remainder = parts.next().map(str::trim).filter(|s| !s.is_empty());
match action.as_str() {
"add" => {
let Some(prompt) = remainder else {
return CommandResult::error("Usage: /task add <prompt>");
};
CommandResult::action(AppAction::TaskAdd {
prompt: prompt.to_string(),
})
}
"list" => CommandResult::action(AppAction::TaskList),
"show" => {
let Some(id) = remainder else {
return CommandResult::error("Usage: /task show <id>");
};
CommandResult::action(AppAction::TaskShow { id: id.to_string() })
}
"cancel" | "stop" => {
let Some(id) = remainder else {
return CommandResult::error("Usage: /task cancel <id>");
};
CommandResult::action(AppAction::TaskCancel { id: id.to_string() })
}
_ => CommandResult::error("Usage: /task [add <prompt>|list|show <id>|cancel <id>]"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use std::path::PathBuf;
fn app() -> App {
App::new(
TuiOptions {
model: "deepseek-v3.2".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: false,
max_subagents: 2,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
},
&Config::default(),
)
}
#[test]
fn parses_add_and_cancel() {
let mut app = app();
let add = task(&mut app, Some("add write tests"));
assert!(matches!(
add.action,
Some(AppAction::TaskAdd { prompt }) if prompt == "write tests"
));
let cancel = task(&mut app, Some("cancel task_1234"));
assert!(matches!(
cancel.action,
Some(AppAction::TaskCancel { id }) if id == "task_1234"
));
}
#[test]
fn validates_usage() {
let mut app = app();
let result = task(&mut app, Some("add"));
assert!(result.message.is_some());
assert!(result.action.is_none());
}
}
+228 -13
View File
@@ -7,12 +7,23 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::json;
use crate::audit::log_sensitive_event;
use crate::features::{Features, FeaturesToml, is_known_feature_key};
use crate::hooks::HooksConfig;
pub const DEFAULT_MAX_SUBAGENTS: usize = 5;
pub const MAX_SUBAGENTS: usize = 20;
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v3.2";
const API_KEYRING_SENTINEL: &str = "__KEYRING__";
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[
"deepseek-v3.2",
"deepseek-chat",
"deepseek-reasoner",
"deepseek-r1",
"deepseek-v3",
];
// === Types ===
@@ -45,10 +56,13 @@ pub struct RetryPolicy {
impl RetryPolicy {
/// Compute the backoff delay for a retry attempt.
#[must_use]
#[allow(dead_code)] // used by runtime_api; will be wired into client retry loop
pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
let exponent = i32::try_from(attempt).unwrap_or(i32::MAX);
let delay = self.initial_delay * self.exponential_base.powi(exponent);
let delay = delay.min(self.max_delay);
// Clamp to a sane range to guard against NaN/negative from misconfigured values
let delay = delay.clamp(0.0, 300.0);
std::time::Duration::from_secs_f64(delay)
}
}
@@ -65,6 +79,10 @@ pub struct Config {
pub notes_path: Option<String>,
pub memory_path: Option<String>,
pub allow_shell: Option<bool>,
pub approval_policy: Option<String>,
pub sandbox_mode: Option<String>,
pub managed_config_path: Option<String>,
pub requirements_path: Option<String>,
pub max_subagents: Option<usize>,
pub retry: Option<RetryConfig>,
pub features: Option<FeaturesToml>,
@@ -84,6 +102,14 @@ struct ConfigFile {
profiles: Option<HashMap<String, Config>>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct RequirementsFile {
#[serde(default)]
allowed_approval_policies: Vec<String>,
#[serde(default)]
allowed_sandbox_modes: Vec<String>,
}
// === Config Loading ===
impl Config {
@@ -113,6 +139,8 @@ impl Config {
};
apply_env_overrides(&mut config);
apply_managed_overrides(&mut config)?;
apply_requirements(&mut config)?;
config.validate()?;
Ok(config)
}
@@ -131,6 +159,28 @@ impl Config {
}
}
}
if let Some(policy) = self.approval_policy.as_deref() {
let normalized = policy.trim().to_ascii_lowercase();
if !matches!(
normalized.as_str(),
"on-request" | "untrusted" | "never" | "auto" | "suggest"
) {
anyhow::bail!(
"Invalid approval_policy '{policy}': expected on-request, untrusted, never, auto, or suggest."
);
}
}
if let Some(mode) = self.sandbox_mode.as_deref() {
let normalized = mode.trim().to_ascii_lowercase();
if !matches!(
normalized.as_str(),
"read-only" | "workspace-write" | "danger-full-access" | "external-sandbox"
) {
anyhow::bail!(
"Invalid sandbox_mode '{mode}': expected read-only, workspace-write, danger-full-access, or external-sandbox."
);
}
}
if let Some(tui) = &self.tui
&& let Some(mode) = tui.alternate_screen.as_deref()
{
@@ -156,11 +206,28 @@ impl Config {
/// Read the `DeepSeek` API key from config/environment.
pub fn deepseek_api_key(&self) -> Result<String> {
self.api_key
.clone()
.context(
"Failed to load DeepSeek API key: DEEPSEEK_API_KEY missing. Set it in config.toml or environment.",
)
// First check environment variable (highest priority)
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
// Then check config file
if let Some(configured) = self.api_key.clone()
&& !configured.trim().is_empty()
&& configured != API_KEYRING_SENTINEL
{
return Ok(configured);
}
// Provide helpful error message with alternatives
anyhow::bail!(
"DeepSeek API key not found. Set it using one of these methods:\n\
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
)
}
/// Resolve the skills directory path.
@@ -278,6 +345,28 @@ fn default_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".deepseek").join("config.toml"))
}
fn default_managed_config_path() -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from("/etc/deepseek/managed_config.toml"))
}
#[cfg(not(unix))]
{
dirs::home_dir().map(|home| home.join(".deepseek").join("managed_config.toml"))
}
}
fn default_requirements_path() -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from("/etc/deepseek/requirements.toml"))
}
#[cfg(not(unix))]
{
dirs::home_dir().map(|home| home.join(".deepseek").join("requirements.toml"))
}
}
fn expand_path(path: &str) -> PathBuf {
let expanded = shellexpand::tilde(path);
PathBuf::from(expanded.as_ref())
@@ -323,6 +412,18 @@ fn apply_env_overrides(config: &mut Config) {
if let Ok(value) = std::env::var("DEEPSEEK_ALLOW_SHELL") {
config.allow_shell = Some(value == "1" || value.eq_ignore_ascii_case("true"));
}
if let Ok(value) = std::env::var("DEEPSEEK_APPROVAL_POLICY") {
config.approval_policy = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SANDBOX_MODE") {
config.sandbox_mode = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MANAGED_CONFIG_PATH") {
config.managed_config_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_REQUIREMENTS_PATH") {
config.requirements_path = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_MAX_SUBAGENTS")
&& let Ok(parsed) = value.parse::<usize>()
{
@@ -382,6 +483,12 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
notes_path: override_cfg.notes_path.or(base.notes_path),
memory_path: override_cfg.memory_path.or(base.memory_path),
allow_shell: override_cfg.allow_shell.or(base.allow_shell),
approval_policy: override_cfg.approval_policy.or(base.approval_policy),
sandbox_mode: override_cfg.sandbox_mode.or(base.sandbox_mode),
managed_config_path: override_cfg
.managed_config_path
.or(base.managed_config_path),
requirements_path: override_cfg.requirements_path.or(base.requirements_path),
max_subagents: override_cfg.max_subagents.or(base.max_subagents),
retry: override_cfg.retry.or(base.retry),
tui: override_cfg.tui.or(base.tui),
@@ -390,6 +497,82 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
}
}
fn load_single_config_file(path: &Path) -> Result<Config> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let parsed: ConfigFile = toml::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(parsed.base)
}
fn apply_managed_overrides(config: &mut Config) -> Result<()> {
let path = config
.managed_config_path
.as_deref()
.map(expand_path)
.or_else(default_managed_config_path);
let Some(path) = path else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let managed = load_single_config_file(&path)?;
*config = merge_config(config.clone(), managed);
Ok(())
}
fn apply_requirements(config: &mut Config) -> Result<()> {
let path = config
.requirements_path
.as_deref()
.map(expand_path)
.or_else(default_requirements_path);
let Some(path) = path else {
return Ok(());
};
if !path.exists() {
return Ok(());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read requirements file: {}", path.display()))?;
let requirements: RequirementsFile = toml::from_str(&contents)
.with_context(|| format!("Failed to parse requirements file: {}", path.display()))?;
if !requirements.allowed_approval_policies.is_empty() {
if let Some(policy) = config.approval_policy.as_ref() {
let policy = policy.to_ascii_lowercase();
if !requirements
.allowed_approval_policies
.iter()
.any(|p| p.eq_ignore_ascii_case(&policy))
{
anyhow::bail!(
"approval_policy '{policy}' is not allowed by requirements ({})",
requirements.allowed_approval_policies.join(", ")
);
}
}
}
if !requirements.allowed_sandbox_modes.is_empty() {
if let Some(mode) = config.sandbox_mode.as_ref() {
let mode = mode.to_ascii_lowercase();
if !requirements
.allowed_sandbox_modes
.iter()
.any(|m| m.eq_ignore_ascii_case(&mode))
{
anyhow::bail!(
"sandbox_mode '{mode}' is not allowed by requirements ({})",
requirements.allowed_sandbox_modes.join(", ")
);
}
}
}
Ok(())
}
fn merge_features(
base: Option<FeaturesToml>,
override_cfg: Option<FeaturesToml>,
@@ -429,6 +612,10 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
ensure_parent_dir(&config_path)?;
// Don't use keychain - just write directly to config file
// Keychain causes permission prompts on macOS for unsigned binaries
let key_to_write = api_key.to_string();
let content = if config_path.exists() {
// Read existing config and update the api_key line
let existing = fs::read_to_string(&config_path)?;
@@ -437,7 +624,7 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
let mut result = String::new();
for line in existing.lines() {
if is_api_key_assignment(line) {
let _ = writeln!(result, "api_key = \"{api_key}\"");
let _ = writeln!(result, "api_key = \"{key_to_write}\"");
} else {
result.push_str(line);
result.push('\n');
@@ -446,38 +633,59 @@ pub fn save_api_key(api_key: &str) -> Result<PathBuf> {
result
} else {
// Prepend api_key to existing config
format!("api_key = \"{api_key}\"\n{existing}")
format!("api_key = \"{key_to_write}\"\n{existing}")
}
} else {
// Create new minimal config
format!(
r#"# DeepSeek CLI Configuration
# Get your API key from https://platform.deepseek.com
# Or set DEEPSEEK_API_KEY environment variable
api_key = "{api_key}"
api_key = "{key_to_write}"
# Base URL (default: https://api.deepseek.com)
# base_url = "https://api.deepseek.com"
# Default model
default_text_model = "deepseek-v3.2"
"#
default_text_model = "{default_model}"
"#,
default_model = DEFAULT_TEXT_MODEL
)
};
fs::write(&config_path, content)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.save",
json!({
"backend": "config_file",
"config_path": config_path.display().to_string(),
}),
);
Ok(config_path)
}
/// Check if an API key is configured (either in config or environment)
pub fn has_api_key(config: &Config) -> bool {
config.api_key.is_some()
// Check environment variable first (highest priority)
if std::env::var("DEEPSEEK_API_KEY").is_ok_and(|k| !k.trim().is_empty()) {
return true;
}
// Then check config file
config
.api_key
.as_ref()
.is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL)
}
/// Clear the API key from the config file
pub fn clear_api_key() -> Result<()> {
// Don't clear keychain - we're not using it anymore
// Just clear from config file
let config_path = default_config_path()
.context("Failed to resolve config path: home directory not found.")?;
@@ -497,6 +705,13 @@ pub fn clear_api_key() -> Result<()> {
fs::write(&config_path, result)
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
log_sensitive_event(
"credential.clear",
json!({
"backend": "config_file",
"config_path": config_path.display().to_string(),
}),
);
Ok(())
}
@@ -601,7 +816,7 @@ mod tests {
assert_eq!(path, expected);
let contents = fs::read_to_string(&path)?;
assert!(contents.contains("api_key = \"test-key\""));
assert!(contents.contains("api_key = \""));
Ok(())
}
@@ -689,7 +904,7 @@ mod tests {
let contents = fs::read_to_string(&config_path)?;
assert!(contents.contains("api_key_backup = \"old\""));
assert!(contents.contains("api_key = \"new-key\""));
assert!(contents.contains("api_key = \""));
Ok(())
}
+475 -116
View File
@@ -10,7 +10,8 @@
use std::path::PathBuf;
use std::pin::pin;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use std::{fs::OpenOptions, io::Write};
use anyhow::Result;
use futures_util::StreamExt;
@@ -23,8 +24,7 @@ use crate::client::DeepSeekClient;
use crate::compaction::{
CompactionConfig, compact_messages_safe, merge_system_prompts, should_compact,
};
use crate::config::Config;
use crate::config::DEFAULT_MAX_SUBAGENTS;
use crate::config::{Config, DEFAULT_MAX_SUBAGENTS, DEFAULT_TEXT_MODEL};
use crate::features::{Feature, Features};
use crate::llm_client::LlmClient;
use crate::mcp::McpPool;
@@ -43,7 +43,7 @@ use crate::tools::user_input::{UserInputRequest, UserInputResponse};
use crate::tools::{ToolContext, ToolRegistryBuilder};
use crate::tui::app::AppMode;
use super::events::Event;
use super::events::{Event, TurnOutcomeStatus};
use super::ops::Op;
use super::session::Session;
use super::tool_parser;
@@ -83,7 +83,7 @@ pub struct EngineConfig {
impl Default for EngineConfig {
fn default() -> Self {
Self {
model: "deepseek-v3.2".to_string(),
model: DEFAULT_TEXT_MODEL.to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
trust_mode: false,
@@ -112,6 +112,8 @@ pub struct EngineHandle {
tx_approval: mpsc::Sender<ApprovalDecision>,
/// Send user input responses to the engine
tx_user_input: mpsc::Sender<UserInputDecision>,
/// Send steer input for an in-flight turn.
tx_steer: mpsc::Sender<String>,
}
impl EngineHandle {
@@ -185,6 +187,12 @@ impl EngineHandle {
.await?;
Ok(())
}
/// Steer an in-flight turn with additional user input.
pub async fn steer(&self, content: impl Into<String>) -> Result<()> {
self.tx_steer.send(content.into()).await?;
Ok(())
}
}
// === Engine ===
@@ -201,6 +209,7 @@ pub struct Engine {
rx_op: mpsc::Receiver<Op>,
rx_approval: mpsc::Receiver<ApprovalDecision>,
rx_user_input: mpsc::Receiver<UserInputDecision>,
rx_steer: mpsc::Receiver<String>,
tx_event: mpsc::Sender<Event>,
cancel_token: CancellationToken,
tool_exec_lock: Arc<RwLock<()>>,
@@ -302,6 +311,13 @@ enum ToolExecGuard<'a> {
Write(tokio::sync::RwLockWriteGuard<'a, ()>),
}
/// Maximum time to wait for a single stream chunk before assuming a stall.
const STREAM_CHUNK_TIMEOUT_SECS: u64 = 90;
/// Maximum total bytes of text/thinking content before aborting the stream.
const STREAM_MAX_CONTENT_BYTES: usize = 10 * 1024 * 1024; // 10 MB
/// Maximum wall-clock duration for a single streaming response.
const STREAM_MAX_DURATION_SECS: u64 = 300; // 5 minutes
const TOOL_CALL_START_MARKERS: [&str; 5] = [
"[TOOL_CALL]",
"<deepseek:tool_call",
@@ -484,6 +500,25 @@ fn mcp_tool_is_parallel_safe(name: &str) -> bool {
)
}
fn mcp_tool_is_read_only(name: &str) -> bool {
matches!(
name,
"list_mcp_resources"
| "list_mcp_resource_templates"
| "mcp_read_resource"
| "read_mcp_resource"
| "mcp_get_prompt"
)
}
fn mcp_tool_approval_description(name: &str) -> String {
if mcp_tool_is_read_only(name) {
format!("Read-only MCP tool '{name}'")
} else {
format!("MCP tool '{name}' may have side effects")
}
}
fn format_tool_error(err: &ToolError, tool_name: &str) -> String {
match err {
ToolError::InvalidInput { message } => {
@@ -509,6 +544,33 @@ fn format_tool_error(err: &ToolError, tool_name: &str) -> String {
}
}
fn summarize_text(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let take = limit.saturating_sub(3);
let mut out: String = text.chars().take(take).collect();
out.push_str("...");
out
}
fn emit_tool_audit(event: serde_json::Value) {
let Some(path) = std::env::var_os("DEEPSEEK_TOOL_AUDIT_LOG") else {
return;
};
let line = match serde_json::to_string(&event) {
Ok(line) => line,
Err(_) => return,
};
let path = PathBuf::from(path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(file, "{line}");
}
}
impl Engine {
/// Create a new engine with the given configuration
pub fn new(config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) {
@@ -516,6 +578,7 @@ impl Engine {
let (tx_event, rx_event) = mpsc::channel(256);
let (tx_approval, rx_approval) = mpsc::channel(64);
let (tx_user_input, rx_user_input) = mpsc::channel(32);
let (tx_steer, rx_steer) = mpsc::channel(64);
let cancel_token = CancellationToken::new();
let tool_exec_lock = Arc::new(RwLock::new(()));
@@ -558,6 +621,7 @@ impl Engine {
rx_op,
rx_approval,
rx_user_input,
rx_steer,
tx_event,
cancel_token: cancel_token.clone(),
tool_exec_lock,
@@ -569,6 +633,7 @@ impl Engine {
cancel_token,
tx_approval,
tx_user_input,
tx_steer,
};
(engine, handle)
@@ -720,6 +785,9 @@ impl Engine {
.send(Event::status("Session context synced".to_string()))
.await;
}
Op::CompactContext => {
self.handle_manual_compaction().await;
}
Op::Shutdown => {
break;
}
@@ -739,8 +807,19 @@ impl Engine {
// Reset cancel token for fresh turn (in case previous was cancelled)
self.cancel_token = CancellationToken::new();
// Drain stale steer messages from previous turns.
while self.rx_steer.try_recv().is_ok() {}
// Create turn context first so start event includes a stable turn id.
let mut turn = TurnContext::new(self.config.max_steps);
// Emit turn started event
let _ = self.tx_event.send(Event::TurnStarted).await;
let _ = self
.tx_event
.send(Event::TurnStarted {
turn_id: turn.id.clone(),
})
.await;
// Check if we have the appropriate client
if self.deepseek_client.is_none() {
@@ -749,7 +828,18 @@ impl Engine {
.as_deref()
.map(|err| format!("Failed to send message: {err}"))
.unwrap_or_else(|| "Failed to send message: API client not configured".to_string());
let _ = self.tx_event.send(Event::error(message, false)).await;
let _ = self
.tx_event
.send(Event::error(message.clone(), false))
.await;
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: turn.usage.clone(),
status: TurnOutcomeStatus::Failed,
error: Some(message),
})
.await;
return;
}
@@ -767,9 +857,6 @@ impl Engine {
};
self.session.add_message(user_msg);
// Create turn context
let mut turn = TurnContext::new(self.config.max_steps);
self.session.model = model;
self.config.model.clone_from(&self.session.model);
self.session.allow_shell = allow_shell;
@@ -868,7 +955,8 @@ impl Engine {
});
// Main turn loop
self.handle_deepseek_turn(&mut turn, tool_registry.as_ref(), tools, mode)
let (status, error) = self
.handle_deepseek_turn(&mut turn, tool_registry.as_ref(), tools, mode)
.await;
// Update session usage
@@ -877,10 +965,106 @@ impl Engine {
// Emit turn complete event
let _ = self
.tx_event
.send(Event::TurnComplete { usage: turn.usage })
.send(Event::TurnComplete {
usage: turn.usage,
status,
error,
})
.await;
}
async fn handle_manual_compaction(&mut self) {
let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
let Some(client) = self.deepseek_client.clone() else {
let message = "Manual compaction unavailable: API client not configured".to_string();
let _ = self
.tx_event
.send(Event::CompactionFailed {
id,
auto: false,
message: message.clone(),
})
.await;
let _ = self.tx_event.send(Event::error(message, false)).await;
return;
};
let start_message = "Manual context compaction started".to_string();
let _ = self
.tx_event
.send(Event::CompactionStarted {
id: id.clone(),
auto: false,
message: start_message,
})
.await;
let compaction_pins = self
.session
.working_set
.pinned_message_indices(&self.session.messages, &self.session.workspace);
let compaction_paths = self.session.working_set.top_paths(24);
match compact_messages_safe(
&client,
&self.session.messages,
&self.config.compaction,
Some(&self.session.workspace),
Some(&compaction_pins),
Some(&compaction_paths),
)
.await
{
Ok(result) => {
if !result.messages.is_empty() || self.session.messages.is_empty() {
self.session.messages = result.messages;
self.session.system_prompt = merge_system_prompts(
self.session.system_prompt.as_ref(),
result.summary_prompt,
);
let message = if result.retries_used > 0 {
format!(
"Manual context compaction completed (after {} retries)",
result.retries_used
)
} else {
"Manual context compaction completed".to_string()
};
let _ = self
.tx_event
.send(Event::CompactionCompleted {
id,
auto: false,
message,
})
.await;
} else {
let message = "Manual context compaction skipped: empty result".to_string();
let _ = self
.tx_event
.send(Event::CompactionFailed {
id,
auto: false,
message: message.clone(),
})
.await;
}
}
Err(err) => {
let message = format!("Manual context compaction failed: {err}");
let _ = self
.tx_event
.send(Event::CompactionFailed {
id,
auto: false,
message: message.clone(),
})
.await;
let _ = self.tx_event.send(Event::status(message)).await;
}
}
}
fn build_tool_context(&self, mode: AppMode) -> ToolContext {
ToolContext::with_auto_approve(
self.session.workspace.clone(),
@@ -1185,18 +1369,43 @@ impl Engine {
tool_registry: Option<&crate::tools::ToolRegistry>,
tools: Option<Vec<Tool>>,
_mode: AppMode,
) {
) -> (TurnOutcomeStatus, Option<String>) {
let client = self
.deepseek_client
.clone()
.expect("DeepSeek client should be configured");
let mut consecutive_tool_error_steps = 0u32;
let mut turn_error: Option<String> = None;
loop {
if self.cancel_token.is_cancelled() {
let _ = self.tx_event.send(Event::status("Request cancelled")).await;
break;
return (TurnOutcomeStatus::Interrupted, None);
}
while let Ok(steer) = self.rx_steer.try_recv() {
let steer = steer.trim().to_string();
if steer.is_empty() {
continue;
}
self.session
.working_set
.observe_user_message(&steer, &self.session.workspace);
self.session.add_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: steer.clone(),
cache_control: None,
}],
});
let _ = self
.tx_event
.send(Event::status(format!(
"Steer input accepted: {}",
summarize_text(&steer, 120)
)))
.await;
}
// Ensure system prompt is up to date with latest session states
@@ -1225,6 +1434,15 @@ impl Engine {
Some(&compaction_paths),
)
{
let compaction_id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
let _ = self
.tx_event
.send(Event::CompactionStarted {
id: compaction_id.clone(),
auto: true,
message: "Auto context compaction started".to_string(),
})
.await;
let _ = self
.tx_event
.send(Event::status("Auto-compacting context...".to_string()))
@@ -1255,22 +1473,40 @@ impl Engine {
} else {
"Auto-compaction complete".to_string()
};
let _ = self.tx_event.send(Event::status(status)).await;
} else {
let _ = self
.tx_event
.send(Event::status(
"Auto-compaction skipped: empty result".to_string(),
))
.send(Event::CompactionCompleted {
id: compaction_id.clone(),
auto: true,
message: status.clone(),
})
.await;
let _ = self.tx_event.send(Event::status(status)).await;
} else {
let message = "Auto-compaction skipped: empty result".to_string();
let _ = self
.tx_event
.send(Event::CompactionFailed {
id: compaction_id.clone(),
auto: true,
message: message.clone(),
})
.await;
let _ = self.tx_event.send(Event::status(message)).await;
}
}
Err(err) => {
// Log error but continue with original messages (never corrupt)
let message = format!("Auto-compaction failed: {err}");
let _ = self
.tx_event
.send(Event::status(format!("Auto-compaction failed: {err}")))
.send(Event::CompactionFailed {
id: compaction_id,
auto: true,
message: message.clone(),
})
.await;
let _ = self.tx_event.send(Event::status(message)).await;
}
}
}
@@ -1299,8 +1535,10 @@ impl Engine {
let stream = match stream_result {
Ok(s) => s,
Err(e) => {
let _ = self.tx_event.send(Event::error(e.to_string(), true)).await;
break;
let message = e.to_string();
turn_error = Some(message.clone());
let _ = self.tx_event.send(Event::error(message, true)).await;
return (TurnOutcomeStatus::Failed, turn_error);
}
};
let mut stream = pin!(stream);
@@ -1321,18 +1559,85 @@ impl Engine {
let mut pending_message_complete = false;
let mut last_text_index: Option<usize> = None;
let mut stream_errors = 0u32;
let mut pending_steers: Vec<String> = Vec::new();
let stream_start = Instant::now();
let mut stream_content_bytes: usize = 0;
let chunk_timeout = Duration::from_secs(STREAM_CHUNK_TIMEOUT_SECS);
let max_duration = Duration::from_secs(STREAM_MAX_DURATION_SECS);
// Process stream events
while let Some(event_result) = stream.next().await {
loop {
let poll_outcome = tokio::select! {
_ = self.cancel_token.cancelled() => None,
result = tokio::time::timeout(chunk_timeout, stream.next()) => {
match result {
Ok(Some(event_result)) => Some(event_result),
Ok(None) => None, // stream ended normally
Err(_) => {
let msg = format!(
"Stream stalled: no data received for {}s, closing stream",
STREAM_CHUNK_TIMEOUT_SECS,
);
crate::logging::warn(&msg);
let _ = self.tx_event.send(Event::error(msg, true)).await;
None
}
}
}
};
let Some(event_result) = poll_outcome else {
break;
};
while let Ok(steer) = self.rx_steer.try_recv() {
let steer = steer.trim().to_string();
if steer.is_empty() {
continue;
}
pending_steers.push(steer.clone());
let _ = self
.tx_event
.send(Event::status(format!(
"Steer input queued: {}",
summarize_text(&steer, 120)
)))
.await;
}
if self.cancel_token.is_cancelled() {
break;
}
// Guard: max wall-clock duration
if stream_start.elapsed() > max_duration {
let msg = format!(
"Stream exceeded maximum duration of {}s, closing",
STREAM_MAX_DURATION_SECS,
);
crate::logging::warn(&msg);
turn_error.get_or_insert(msg.clone());
let _ = self.tx_event.send(Event::error(msg, true)).await;
break;
}
// Guard: max accumulated content bytes
if stream_content_bytes > STREAM_MAX_CONTENT_BYTES {
let msg = format!(
"Stream exceeded maximum content size of {} bytes, closing",
STREAM_MAX_CONTENT_BYTES,
);
crate::logging::warn(&msg);
turn_error.get_or_insert(msg.clone());
let _ = self.tx_event.send(Event::error(msg, true)).await;
break;
}
let event = match event_result {
Ok(e) => e,
Err(e) => {
stream_errors = stream_errors.saturating_add(1);
let _ = self.tx_event.send(Event::error(e.to_string(), true)).await;
let message = e.to_string();
turn_error.get_or_insert(message.clone());
let _ = self.tx_event.send(Event::error(message, true)).await;
if stream_errors >= 3 {
break;
}
@@ -1399,6 +1704,7 @@ impl Engine {
},
StreamEvent::ContentBlockDelta { index, delta } => match delta {
Delta::TextDelta { text } => {
stream_content_bytes = stream_content_bytes.saturating_add(text.len());
current_text_raw.push_str(&text);
let filtered = filter_tool_call_delta(&text, &mut in_tool_call_block);
if !filtered.is_empty() {
@@ -1413,6 +1719,8 @@ impl Engine {
}
}
Delta::ThinkingDelta { thinking } => {
stream_content_bytes =
stream_content_bytes.saturating_add(thinking.len());
current_thinking.push_str(&thinking);
if !thinking.is_empty() {
let _ = self
@@ -1559,8 +1867,19 @@ impl Engine {
let _ = self.tx_event.send(Event::MessageComplete { index }).await;
}
// DeepSeek chat API rejects assistant messages that contain only
// reasoning/thinking content without visible text or tool calls.
// Keep thinking for UI stream events, but persist only sendable
// assistant turns in the conversation state.
let has_sendable_assistant_content = content_blocks.iter().any(|block| {
matches!(
block,
ContentBlock::Text { .. } | ContentBlock::ToolUse { .. }
)
});
// Add assistant message to session
if !content_blocks.is_empty() {
if has_sendable_assistant_content {
self.session.add_message(Message {
role: "assistant".to_string(),
content: content_blocks,
@@ -1569,6 +1888,22 @@ impl Engine {
// If no tool uses, we're done
if tool_uses.is_empty() {
if !pending_steers.is_empty() {
for steer in pending_steers.drain(..) {
self.session
.working_set
.observe_user_message(&steer, &self.session.workspace);
self.session.add_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: steer,
cache_control: None,
}],
});
}
turn.next_step();
continue;
}
break;
}
@@ -1611,16 +1946,18 @@ impl Engine {
let mut supports_parallel = false;
let mut read_only = false;
if !McpPool::is_mcp_tool(&tool_name) {
if let Some(registry) = tool_registry
&& let Some(spec) = registry.get(&tool_name)
{
approval_required =
spec.approval_requirement() != ApprovalRequirement::Auto;
approval_description = spec.description().to_string();
supports_parallel = spec.supports_parallel();
read_only = spec.is_read_only();
}
if McpPool::is_mcp_tool(&tool_name) {
read_only = mcp_tool_is_read_only(&tool_name);
supports_parallel = mcp_tool_is_parallel_safe(&tool_name);
approval_required = !read_only;
approval_description = mcp_tool_approval_description(&tool_name);
} else if let Some(registry) = tool_registry
&& let Some(spec) = registry.get(&tool_name)
{
approval_required = spec.approval_requirement() != ApprovalRequirement::Auto;
approval_description = spec.description().to_string();
supports_parallel = spec.supports_parallel();
read_only = spec.is_read_only();
}
plans.push(ToolExecutionPlan {
@@ -1776,6 +2113,11 @@ impl Engine {
Option<Result<ToolResult, ToolError>>,
Option<crate::tools::ToolContext>,
) = if plan.approval_required {
emit_tool_audit(json!({
"event": "tool.approval_required",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
}));
let _ = self
.tx_event
.send(Event::ApprovalRequired {
@@ -1786,14 +2128,37 @@ impl Engine {
.await;
match self.await_tool_approval(&tool_id).await {
Ok(ApprovalResult::Approved) => (None, None),
Ok(ApprovalResult::Denied) => (
Some(Err(ToolError::permission_denied(format!(
"Tool '{tool_name}' denied by user"
)))),
None,
),
Ok(ApprovalResult::Approved) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "approved",
}));
(None, None)
}
Ok(ApprovalResult::Denied) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "denied",
}));
(
Some(Err(ToolError::permission_denied(format!(
"Tool '{tool_name}' denied by user"
)))),
None,
)
}
Ok(ApprovalResult::RetryWithPolicy(policy)) => {
emit_tool_audit(json!({
"event": "tool.approval_decision",
"tool_id": tool_id.clone(),
"tool_name": tool_name.clone(),
"decision": "retry_with_policy",
"policy": format!("{policy:?}"),
}));
let elevated_context = tool_registry.map(|r| {
r.context().clone().with_elevated_sandbox_policy(policy)
});
@@ -1854,6 +2219,12 @@ impl Engine {
match outcome.result {
Ok(output) => {
emit_tool_audit(json!({
"event": "tool.result",
"tool_id": outcome.id.clone(),
"tool_name": outcome.name.clone(),
"success": output.success,
}));
let output_content = output.content;
tool_call.set_result(output_content.clone(), duration);
@@ -1872,6 +2243,13 @@ impl Engine {
});
}
Err(e) => {
emit_tool_audit(json!({
"event": "tool.result",
"tool_id": outcome.id.clone(),
"tool_name": outcome.name.clone(),
"success": false,
"error": e.to_string(),
}));
step_error_count += 1;
let error = format_tool_error(&e, &outcome.name);
tool_call.set_error(error.clone(), duration);
@@ -1894,6 +2272,21 @@ impl Engine {
turn.record_tool_call(tool_call);
}
if !pending_steers.is_empty() {
for steer in pending_steers.drain(..) {
self.session
.working_set
.observe_user_message(&steer, &self.session.workspace);
self.session.add_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: steer,
cache_control: None,
}],
});
}
}
if step_error_count > 0 {
consecutive_tool_error_steps = consecutive_tool_error_steps.saturating_add(1);
} else {
@@ -1912,6 +2305,14 @@ impl Engine {
turn.next_step();
}
if self.cancel_token.is_cancelled() {
return (TurnOutcomeStatus::Interrupted, None);
}
if let Some(err) = turn_error {
return (TurnOutcomeStatus::Failed, Some(err));
}
(TurnOutcomeStatus::Completed, None)
}
/// Get a reference to the session
@@ -1951,81 +2352,39 @@ pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::PathBuf;
use std::time::Instant;
pub(crate) struct MockEngineHandle {
pub handle: EngineHandle,
pub rx_op: mpsc::Receiver<Op>,
pub rx_steer: mpsc::Receiver<String>,
pub tx_event: mpsc::Sender<Event>,
pub cancel_token: CancellationToken,
}
fn make_plan(
read_only: bool,
supports_parallel: bool,
approval_required: bool,
interactive: bool,
) -> ToolExecutionPlan {
ToolExecutionPlan {
index: 0,
id: "tool-1".to_string(),
name: "grep_files".to_string(),
input: json!({"pattern": "test"}),
interactive,
approval_required,
approval_description: "desc".to_string(),
supports_parallel,
read_only,
}
}
#[cfg(test)]
pub(crate) fn mock_engine_handle() -> MockEngineHandle {
let (tx_op, rx_op) = mpsc::channel(32);
let (tx_event, rx_event) = mpsc::channel(256);
let (tx_approval, _rx_approval) = mpsc::channel(64);
let (tx_user_input, _rx_user_input) = mpsc::channel(32);
let (tx_steer, rx_steer) = mpsc::channel(64);
let cancel_token = CancellationToken::new();
let handle = EngineHandle {
tx_op,
rx_event: Arc::new(RwLock::new(rx_event)),
cancel_token: cancel_token.clone(),
tx_approval,
tx_user_input,
tx_steer,
};
#[test]
fn parallel_batch_requires_read_only_parallel_tools() {
let plans = vec![make_plan(true, true, false, false)];
assert!(should_parallelize_tool_batch(&plans));
let plans = vec![
make_plan(true, true, false, false),
make_plan(true, true, false, false),
];
assert!(should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(false, true, false, false)];
assert!(!should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(true, false, false, false)];
assert!(!should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(true, true, true, false)];
assert!(!should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(true, true, false, true)];
assert!(!should_parallelize_tool_batch(&plans));
}
#[test]
fn tool_error_messages_include_actionable_hints() {
let path_error = ToolError::path_escape(PathBuf::from("../escape.txt"));
let formatted = format_tool_error(&path_error, "read_file");
assert!(formatted.contains("escapes workspace"));
let missing_field = ToolError::missing_field("path");
let formatted = format_tool_error(&missing_field, "read_file");
assert!(formatted.contains("missing required field"));
let timeout = ToolError::Timeout { seconds: 5 };
let formatted = format_tool_error(&timeout, "exec_shell");
assert!(formatted.contains("timed out"));
}
#[test]
fn tool_exec_outcome_tracks_duration() {
let outcome = ToolExecOutcome {
index: 0,
id: "tool-1".to_string(),
name: "grep_files".to_string(),
input: json!({"pattern": "test"}),
started_at: Instant::now(),
result: Ok(ToolResult::success("ok")),
};
assert!(outcome.started_at.elapsed().as_nanos() > 0);
MockEngineHandle {
handle,
rx_op,
rx_steer,
tx_event,
cancel_token,
}
}
#[cfg(test)]
mod tests;
+77
View File
@@ -0,0 +1,77 @@
use super::*;
use serde_json::json;
use std::path::PathBuf;
use std::time::Instant;
fn make_plan(
read_only: bool,
supports_parallel: bool,
approval_required: bool,
interactive: bool,
) -> ToolExecutionPlan {
ToolExecutionPlan {
index: 0,
id: "tool-1".to_string(),
name: "grep_files".to_string(),
input: json!({"pattern": "test"}),
interactive,
approval_required,
approval_description: "desc".to_string(),
supports_parallel,
read_only,
}
}
#[test]
fn parallel_batch_requires_read_only_parallel_tools() {
let plans = vec![make_plan(true, true, false, false)];
assert!(should_parallelize_tool_batch(&plans));
let plans = vec![
make_plan(true, true, false, false),
make_plan(true, true, false, false),
];
assert!(should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(false, true, false, false)];
assert!(!should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(true, false, false, false)];
assert!(!should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(true, true, true, false)];
assert!(!should_parallelize_tool_batch(&plans));
let plans = vec![make_plan(true, true, false, true)];
assert!(!should_parallelize_tool_batch(&plans));
}
#[test]
fn tool_error_messages_include_actionable_hints() {
let path_error = ToolError::path_escape(PathBuf::from("../escape.txt"));
let formatted = format_tool_error(&path_error, "read_file");
assert!(formatted.contains("escapes workspace"));
let missing_field = ToolError::missing_field("path");
let formatted = format_tool_error(&missing_field, "read_file");
assert!(formatted.contains("missing required field"));
let timeout = ToolError::Timeout { seconds: 5 };
let formatted = format_tool_error(&timeout, "exec_shell");
assert!(formatted.contains("timed out"));
}
#[test]
fn tool_exec_outcome_tracks_duration() {
let outcome = ToolExecOutcome {
index: 0,
id: "tool-1".to_string(),
name: "grep_files".to_string(),
input: json!({"pattern": "test"}),
started_at: Instant::now(),
result: Ok(ToolResult::success("ok")),
};
assert!(outcome.started_at.elapsed().as_nanos() > 0);
}
+35 -2
View File
@@ -10,6 +10,14 @@ use crate::tools::spec::{ToolError, ToolResult};
use crate::tools::subagent::SubAgentResult;
use crate::tools::user_input::UserInputRequest;
/// Final status for a turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnOutcomeStatus {
Completed,
Interrupted,
Failed,
}
/// Events emitted by the engine to update the UI.
#[derive(Debug, Clone)]
pub enum Event {
@@ -52,10 +60,35 @@ pub enum Event {
// === Turn Lifecycle ===
/// A new turn has started (user sent a message)
TurnStarted,
TurnStarted { turn_id: String },
/// The turn is complete (no more tool calls)
TurnComplete { usage: Usage },
TurnComplete {
usage: Usage,
status: TurnOutcomeStatus,
error: Option<String>,
},
/// Context compaction started.
CompactionStarted {
id: String,
auto: bool,
message: String,
},
/// Context compaction completed.
CompactionCompleted {
id: String,
auto: bool,
message: String,
},
/// Context compaction failed.
CompactionFailed {
id: String,
auto: bool,
message: String,
},
// === Sub-Agent Events ===
/// A sub-agent has been spawned
+3
View File
@@ -52,6 +52,9 @@ pub enum Op {
workspace: PathBuf,
},
/// Run context compaction immediately.
CompactContext,
/// Shutdown the engine
Shutdown,
}
+202
View File
@@ -0,0 +1,202 @@
//! Shared error taxonomy across client, tools, runtime, and UI.
//!
//! Not yet wired into consumers; will be adopted incrementally.
#![allow(dead_code)]
use crate::llm_client::LlmError;
use crate::tools::spec::ToolError;
/// Broad category for typed error handling and policy decisions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCategory {
Network,
Authentication,
Authorization,
RateLimit,
Timeout,
InvalidInput,
Parse,
Tool,
State,
Internal,
}
/// Severity hint for UI and logs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorSeverity {
Info,
Warning,
Error,
Critical,
}
/// Unified envelope used when crossing subsystem boundaries.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ErrorEnvelope {
pub category: ErrorCategory,
pub severity: ErrorSeverity,
pub recoverable: bool,
pub code: String,
pub message: String,
}
impl ErrorEnvelope {
#[must_use]
pub fn new(
category: ErrorCategory,
severity: ErrorSeverity,
recoverable: bool,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
category,
severity,
recoverable,
code: code.into(),
message: message.into(),
}
}
}
impl From<LlmError> for ErrorEnvelope {
fn from(value: LlmError) -> Self {
match value {
LlmError::RateLimited { message, .. } => Self::new(
ErrorCategory::RateLimit,
ErrorSeverity::Warning,
true,
"llm_rate_limited",
message,
),
LlmError::ServerError { status, message } => Self::new(
ErrorCategory::Internal,
ErrorSeverity::Error,
true,
format!("llm_server_{status}"),
message,
),
LlmError::NetworkError(message) => Self::new(
ErrorCategory::Network,
ErrorSeverity::Error,
true,
"llm_network_error",
message,
),
LlmError::Timeout(duration) => Self::new(
ErrorCategory::Timeout,
ErrorSeverity::Warning,
true,
"llm_timeout",
format!("Request timed out after {duration:?}"),
),
LlmError::AuthenticationError(message) => Self::new(
ErrorCategory::Authentication,
ErrorSeverity::Critical,
false,
"llm_auth_error",
message,
),
LlmError::InvalidRequest { message, .. } => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"llm_invalid_request",
message,
),
LlmError::ModelError(message) => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"llm_model_error",
message,
),
LlmError::ContentPolicyError(message) => Self::new(
ErrorCategory::Authorization,
ErrorSeverity::Error,
false,
"llm_content_policy",
message,
),
LlmError::ParseError(message) => Self::new(
ErrorCategory::Parse,
ErrorSeverity::Error,
false,
"llm_parse_error",
message,
),
LlmError::ContextLengthError(message) => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"llm_context_length",
message,
),
LlmError::Other(message) => Self::new(
ErrorCategory::Internal,
ErrorSeverity::Error,
true,
"llm_other",
message,
),
}
}
}
impl From<ToolError> for ErrorEnvelope {
fn from(value: ToolError) -> Self {
match value {
ToolError::InvalidInput { message } => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"tool_invalid_input",
message,
),
ToolError::MissingField { field } => Self::new(
ErrorCategory::InvalidInput,
ErrorSeverity::Error,
false,
"tool_missing_field",
format!("Missing required field: {field}"),
),
ToolError::PathEscape { path } => Self::new(
ErrorCategory::Authorization,
ErrorSeverity::Error,
false,
"tool_path_escape",
format!("Path escapes workspace: {}", path.display()),
),
ToolError::ExecutionFailed { message } => Self::new(
ErrorCategory::Tool,
ErrorSeverity::Error,
true,
"tool_execution_failed",
message,
),
ToolError::Timeout { seconds } => Self::new(
ErrorCategory::Timeout,
ErrorSeverity::Warning,
true,
"tool_timeout",
format!("Tool timed out after {seconds}s"),
),
ToolError::NotAvailable { message } => Self::new(
ErrorCategory::State,
ErrorSeverity::Error,
false,
"tool_not_available",
message,
),
ToolError::PermissionDenied { message } => Self::new(
ErrorCategory::Authorization,
ErrorSeverity::Error,
false,
"tool_permission_denied",
message,
),
}
}
}
+14 -2
View File
@@ -319,7 +319,13 @@ impl HookContext {
if let Some(ref result) = self.tool_result {
// Truncate result to 10KB to avoid environment variable size limits
let truncated = if result.len() > 10000 {
format!("{}...[truncated]", &result[..10000])
let safe_end = result
.char_indices()
.take_while(|(i, _)| *i < 10000)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
format!("{}...[truncated]", &result[..safe_end])
} else {
result.clone()
};
@@ -343,7 +349,13 @@ impl HookContext {
if let Some(ref message) = self.message {
// Truncate message to prevent env var issues
let truncated = if message.len() > 5000 {
format!("{}...[truncated]", &message[..5000])
let safe_end = message
.char_indices()
.take_while(|(i, _)| *i < 5000)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
format!("{}...[truncated]", &message[..safe_end])
} else {
message.clone()
};
+5 -9
View File
@@ -29,6 +29,7 @@ use anyhow::Result;
use std::future::Future;
use std::pin::Pin;
use std::time::{Duration, Instant};
use uuid::Uuid;
// === LlmClient Trait ===
@@ -420,16 +421,11 @@ impl RetryConfig {
let final_delay = if self.jitter {
// Add random jitter to prevent thundering herd problem
// Uses a simple deterministic approach when rand is not available
let jitter_range = capped_delay * self.jitter_factor;
// Simple pseudo-random jitter based on current time
// This avoids adding the rand crate as a dependency
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
let random_factor = f64::from(nanos % 1000) / 1000.0; // 0.0 to 0.999
// Use UUID v4 entropy for jitter randomness.
let bytes = *Uuid::new_v4().as_bytes();
let sample = u16::from_le_bytes([bytes[0], bytes[1]]);
let random_factor = f64::from(sample) / f64::from(u16::MAX); // 0.0 to 1.0
let jitter = jitter_range * (2.0 * random_factor - 1.0); // -range to +range
(capped_delay + jitter).max(0.0)
+111 -10
View File
@@ -82,6 +82,18 @@ pub struct McpServerConfig {
pub read_timeout: Option<u64>,
#[serde(default)]
pub disabled: bool,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub enabled_tools: Vec<String>,
#[serde(default)]
pub disabled_tools: Vec<String>,
}
fn default_enabled() -> bool {
true
}
impl McpServerConfig {
@@ -96,6 +108,22 @@ impl McpServerConfig {
pub fn effective_read_timeout(&self, global: &McpTimeouts) -> u64 {
self.read_timeout.unwrap_or(global.read_timeout)
}
pub fn is_enabled(&self) -> bool {
self.enabled && !self.disabled
}
pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
let allowed = if self.enabled_tools.is_empty() {
true
} else {
self.enabled_tools.iter().any(|t| t == tool_name)
};
if !allowed {
return false;
}
!self.disabled_tools.iter().any(|t| t == tool_name)
}
}
// === MCP Tool Definition ===
@@ -217,19 +245,46 @@ pub struct SseTransport {
}
impl SseTransport {
pub async fn connect(client: reqwest::Client, url: String) -> Result<Self> {
pub async fn connect(
client: reqwest::Client,
url: String,
cancel_token: tokio_util::sync::CancellationToken,
) -> Result<Self> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let client_clone = client.clone();
let url_clone = url.clone();
// Start SSE background task
tokio::spawn(async move {
if let Err(e) = Self::run_sse_loop(client_clone, url_clone, tx).await {
tracing::error!("SSE loop error: {}", e);
if cancel_token.is_cancelled() {
return;
}
use futures_util::FutureExt;
let result = std::panic::AssertUnwindSafe(Self::run_sse_loop(
client_clone,
url_clone,
tx,
cancel_token,
))
.catch_unwind()
.await;
match result {
Ok(res) => {
if let Err(e) = res {
tracing::error!("SSE loop error: {}", e);
}
}
Err(panic_err) => {
if let Some(msg) = panic_err.downcast_ref::<&str>() {
tracing::error!("SSE loop panicked: {}", msg);
} else if let Some(msg) = panic_err.downcast_ref::<String>() {
tracing::error!("SSE loop panicked: {}", msg);
} else {
tracing::error!("SSE loop panicked with unknown error");
}
}
}
});
// The endpoint URL will be discovered from the first "endpoint" event
Ok(Self {
client,
base_url: url,
@@ -242,6 +297,7 @@ impl SseTransport {
client: reqwest::Client,
url: String,
tx: tokio::sync::mpsc::UnboundedSender<serde_json::Value>,
cancel_token: tokio_util::sync::CancellationToken,
) -> Result<()> {
let response = client.get(&url).send().await?;
if !response.status().is_success() {
@@ -252,7 +308,23 @@ impl SseTransport {
use futures_util::StreamExt;
let mut buffer = String::new();
while let Some(item) = stream.next().await {
loop {
if cancel_token.is_cancelled() {
tracing::debug!("SSE loop cancelled");
break;
}
let item = tokio::select! {
_ = cancel_token.cancelled() => {
tracing::debug!("SSE loop shutting down");
break;
}
item = stream.next() => {
match item {
Some(i) => i,
None => break,
}
}
};
let chunk = item?;
let s = String::from_utf8_lossy(&chunk);
buffer.push_str(&s);
@@ -339,6 +411,7 @@ pub struct McpConnection {
request_id: AtomicU64,
state: ConnectionState,
config: McpServerConfig,
cancel_token: tokio_util::sync::CancellationToken,
}
impl McpConnection {
@@ -349,12 +422,13 @@ impl McpConnection {
global_timeouts: &McpTimeouts,
) -> Result<Self> {
let connect_timeout_secs = config.effective_connect_timeout(global_timeouts);
let cancel_token = tokio_util::sync::CancellationToken::new();
let transport: Box<dyn McpTransport> = if let Some(url) = &config.url {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(connect_timeout_secs))
.build()?;
Box::new(SseTransport::connect(client, url.clone()).await?)
Box::new(SseTransport::connect(client, url.clone(), cancel_token.clone()).await?)
} else if let Some(command) = &config.command {
let mut cmd = tokio::process::Command::new(command);
cmd.args(&config.args)
@@ -396,6 +470,7 @@ impl McpConnection {
request_id: AtomicU64::new(1),
state: ConnectionState::Connecting,
config,
cancel_token,
};
// Initialize with timeout
@@ -716,13 +791,14 @@ impl McpConnection {
/// Gracefully close the connection
pub fn close(&mut self) {
self.cancel_token.cancel();
self.state = ConnectionState::Disconnected;
}
}
impl Drop for McpConnection {
fn drop(&mut self) {
// StdioTransport will be dropped and child killed
self.cancel_token.cancel();
}
}
@@ -779,7 +855,7 @@ impl McpPool {
.ok_or_else(|| anyhow::anyhow!("Failed to find MCP server: {server_name}"))?
.clone();
if server_config.disabled {
if !server_config.is_enabled() {
anyhow::bail!("Failed to connect MCP server '{server_name}': server is disabled");
}
@@ -803,7 +879,7 @@ impl McpPool {
.config
.servers
.keys()
.filter(|n| !self.config.servers[*n].disabled)
.filter(|n| self.config.servers[*n].is_enabled())
.cloned()
.collect();
@@ -813,6 +889,21 @@ impl McpPool {
}
}
for (name, server_cfg) in &self.config.servers {
if server_cfg.required
&& server_cfg.is_enabled()
&& !self
.connections
.get(name)
.is_some_and(McpConnection::is_ready)
{
errors.push((
name.clone(),
anyhow::anyhow!("required MCP server failed to initialize"),
));
}
}
errors
}
@@ -821,6 +912,9 @@ impl McpPool {
let mut tools = Vec::new();
for (server, conn) in &self.connections {
for tool in conn.tools() {
if !conn.config().is_tool_enabled(&tool.name) {
continue;
}
// Format: mcp_{server}_{tool}
tools.push((format!("mcp_{}_{}", server, tool.name), tool));
}
@@ -1140,6 +1234,9 @@ impl McpPool {
// Copy the global timeouts to avoid borrow conflict
let global_timeouts = self.config.timeouts;
let conn = self.get_or_connect(server_name).await?;
if !conn.config().is_tool_enabled(tool_name) {
anyhow::bail!("MCP tool '{tool_name}' is disabled for server '{server_name}'");
}
let timeout = conn.config().effective_execute_timeout(&global_timeouts);
conn.call_tool(tool_name, arguments, timeout).await
}
@@ -1564,6 +1661,10 @@ mod tests {
execute_timeout: None,
read_timeout: Some(180),
disabled: false,
enabled: true,
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
};
assert_eq!(server_with_override.effective_connect_timeout(&global), 20);
+81 -8
View File
@@ -2,6 +2,13 @@
use serde::{Deserialize, Serialize};
pub const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 50_000;
pub const DEFAULT_COMPACTION_MESSAGE_THRESHOLD: usize = 50;
const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
const COMPACTION_MESSAGE_DIVISOR: u32 = 1200;
const MAX_COMPACTION_MESSAGE_THRESHOLD: usize = 150;
// === Core Message Types ===
/// Request payload for sending a message to the API.
@@ -29,7 +36,7 @@ pub struct MessageRequest {
}
/// System prompt representation (plain text or structured blocks).
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
pub enum SystemPrompt {
Text(String),
@@ -37,7 +44,7 @@ pub enum SystemPrompt {
}
/// A structured system prompt block.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct SystemBlock {
#[serde(rename = "type")]
pub block_type: String,
@@ -47,14 +54,14 @@ pub struct SystemBlock {
}
/// A chat message with role and content blocks.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct Message {
pub role: String,
pub content: Vec<ContentBlock>,
}
/// A single content block inside a message.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "type")]
pub enum ContentBlock {
#[serde(rename = "text")]
@@ -79,7 +86,7 @@ pub enum ContentBlock {
}
/// Cache control metadata for tool definitions and blocks.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct CacheControl {
#[serde(rename = "type")]
pub cache_type: String,
@@ -120,16 +127,16 @@ pub struct Usage {
pub fn context_window_for_model(model: &str) -> Option<u32> {
let lower = model.to_lowercase();
if lower.contains("deepseek-v3.2") {
return Some(128_000);
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
}
if lower.contains("deepseek-chat")
|| lower.contains("deepseek-reasoner")
|| lower.contains("deepseek-r1")
{
return Some(128_000);
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
}
if lower.contains("deepseek") {
return Some(128_000);
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
}
if lower.contains("claude") {
return Some(200_000);
@@ -137,6 +144,35 @@ pub fn context_window_for_model(model: &str) -> Option<u32> {
None
}
/// Derive a compaction token threshold from model context window.
///
/// Keeps headroom for tool outputs and assistant completion by defaulting to 80%
/// of known context windows.
#[must_use]
pub fn compaction_threshold_for_model(model: &str) -> usize {
let Some(window) = context_window_for_model(model) else {
return DEFAULT_COMPACTION_TOKEN_THRESHOLD;
};
let threshold = (u64::from(window) * u64::from(COMPACTION_THRESHOLD_PERCENT)) / 100;
usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD)
}
/// Derive a compaction message-count threshold from model context window.
#[must_use]
pub fn compaction_message_threshold_for_model(model: &str) -> usize {
let Some(window) = context_window_for_model(model) else {
return DEFAULT_COMPACTION_MESSAGE_THRESHOLD;
};
let scaled = usize::try_from(window / COMPACTION_MESSAGE_DIVISOR)
.unwrap_or(DEFAULT_COMPACTION_MESSAGE_THRESHOLD);
scaled.clamp(
DEFAULT_COMPACTION_MESSAGE_THRESHOLD,
MAX_COMPACTION_MESSAGE_THRESHOLD,
)
}
// === Streaming Structures ===
#[allow(dead_code)]
@@ -204,3 +240,40 @@ pub struct MessageDelta {
pub stop_reason: Option<String>,
pub stop_sequence: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deepseek_models_map_to_128k_context_window() {
assert_eq!(
context_window_for_model("deepseek-reasoner"),
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
context_window_for_model("deepseek-v3.2"),
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
context_window_for_model("deepseek-v3.2-0324"),
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
);
}
#[test]
fn compaction_threshold_scales_with_context_window() {
assert_eq!(compaction_threshold_for_model("deepseek-reasoner"), 102_400);
assert_eq!(compaction_threshold_for_model("unknown-model"), 50_000);
}
#[test]
fn compaction_message_threshold_scales_with_context_window() {
assert_eq!(
compaction_message_threshold_for_model("deepseek-reasoner"),
106
);
assert_eq!(compaction_message_threshold_for_model("unknown-model"), 50);
assert_eq!(compaction_message_threshold_for_model("claude-3"), 150);
}
}
+26 -10
View File
@@ -141,17 +141,8 @@ pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext {
let mut current = workspace.parent();
while let Some(parent) = current {
// Stop at git root or filesystem root
if parent.join(".git").exists() {
let parent_ctx = load_project_context(parent);
if parent_ctx.has_instructions() {
ctx.instructions = parent_ctx.instructions;
ctx.source_path = parent_ctx.source_path;
}
break;
}
let parent_ctx = load_project_context(parent);
ctx.warnings.extend(parent_ctx.warnings.iter().cloned());
if parent_ctx.has_instructions() {
ctx.instructions = parent_ctx.instructions;
ctx.source_path = parent_ctx.source_path;
@@ -453,4 +444,29 @@ mod tests {
assert!(merged.contains("Instructions A"));
assert!(merged.contains("Instructions B"));
}
#[test]
fn test_load_with_parents_searches_above_git_root_when_needed() {
let tmp = tempdir().expect("tempdir");
// AGENTS.md exists above repository root.
fs::write(tmp.path().join("AGENTS.md"), "Organization instructions").expect("write");
// Mark repository root one level below.
let repo_root = tmp.path().join("repo");
fs::create_dir(&repo_root).expect("mkdir repo");
fs::create_dir(repo_root.join(".git")).expect("mkdir .git");
let workspace = repo_root.join("apps").join("client");
fs::create_dir_all(&workspace).expect("mkdir workspace");
let ctx = load_project_context_with_parents(&workspace);
assert!(ctx.has_instructions());
assert!(
ctx.instructions
.as_ref()
.unwrap()
.contains("Organization instructions")
);
}
}
+1458
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+254 -7
View File
@@ -17,6 +17,45 @@ use uuid::Uuid;
/// Maximum number of sessions to retain
const MAX_SESSIONS: usize = 50;
const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
const fn default_session_schema_version() -> u32 {
CURRENT_SESSION_SCHEMA_VERSION
}
const fn default_queue_schema_version() -> u32 {
CURRENT_QUEUE_SCHEMA_VERSION
}
/// Persisted queued message for offline/degraded mode.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueuedSessionMessage {
pub display: String,
#[serde(default)]
pub skill_instruction: Option<String>,
}
/// Persisted queue state for recovery after restart/crash.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OfflineQueueState {
#[serde(default = "default_queue_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub messages: Vec<QueuedSessionMessage>,
#[serde(default)]
pub draft: Option<QueuedSessionMessage>,
}
impl Default for OfflineQueueState {
fn default() -> Self {
Self {
schema_version: CURRENT_QUEUE_SCHEMA_VERSION,
messages: Vec::new(),
draft: None,
}
}
}
/// Session metadata stored with each saved session
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -45,6 +84,9 @@ pub struct SessionMetadata {
/// A saved session containing full conversation history
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedSession {
/// Schema version for migration compatibility
#[serde(default = "default_session_schema_version")]
pub schema_version: u32,
/// Session metadata
pub metadata: SessionMetadata,
/// Conversation messages
@@ -69,10 +111,7 @@ impl SessionManager {
/// Create a `SessionManager` using the default location (~/.deepseek/sessions)
pub fn default_location() -> std::io::Result<Self> {
let home = dirs::home_dir().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found")
})?;
Self::new(home.join(".deepseek").join("sessions"))
Self::new(default_sessions_dir()?)
}
/// Save a session to disk using atomic write (temp file + rename).
@@ -95,6 +134,98 @@ impl SessionManager {
Ok(path)
}
/// Save a crash-recovery checkpoint for in-flight turns.
pub fn save_checkpoint(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
let checkpoints = self.sessions_dir.join("checkpoints");
fs::create_dir_all(&checkpoints)?;
let path = checkpoints.join("latest.json");
let content = serde_json::to_string_pretty(session)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp_path = checkpoints.join(".latest.tmp");
fs::write(&tmp_path, &content)?;
fs::rename(&tmp_path, &path)?;
Ok(path)
}
/// Load the most recent crash-recovery checkpoint if present.
pub fn load_checkpoint(&self) -> std::io::Result<Option<SavedSession>> {
let path = self.sessions_dir.join("checkpoints").join("latest.json");
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)?;
let session: SavedSession = serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Checkpoint schema v{} is newer than supported v{}",
session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
),
));
}
Ok(Some(session))
}
/// Clear any crash-recovery checkpoint.
pub fn clear_checkpoint(&self) -> std::io::Result<()> {
let path = self.sessions_dir.join("checkpoints").join("latest.json");
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
/// Save offline queue state (queued + draft messages).
pub fn save_offline_queue_state(&self, state: &OfflineQueueState) -> std::io::Result<PathBuf> {
let checkpoints = self.sessions_dir.join("checkpoints");
fs::create_dir_all(&checkpoints)?;
let path = checkpoints.join("offline_queue.json");
let content = serde_json::to_string_pretty(state)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp_path = checkpoints.join(".offline_queue.tmp");
fs::write(&tmp_path, &content)?;
fs::rename(&tmp_path, &path)?;
Ok(path)
}
/// Load offline queue state if present.
pub fn load_offline_queue_state(&self) -> std::io::Result<Option<OfflineQueueState>> {
let path = self
.sessions_dir
.join("checkpoints")
.join("offline_queue.json");
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)?;
let state: OfflineQueueState = serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if state.schema_version > CURRENT_QUEUE_SCHEMA_VERSION {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Offline queue schema v{} is newer than supported v{}",
state.schema_version, CURRENT_QUEUE_SCHEMA_VERSION
),
));
}
Ok(Some(state))
}
/// Remove persisted offline queue state.
pub fn clear_offline_queue_state(&self) -> std::io::Result<()> {
let path = self
.sessions_dir
.join("checkpoints")
.join("offline_queue.json");
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
/// Load a session by ID
pub fn load_session(&self, id: &str) -> std::io::Result<SavedSession> {
let filename = format!("{id}.json");
@@ -103,6 +234,15 @@ impl SessionManager {
let content = fs::read_to_string(&path)?;
let session: SavedSession = serde_json::from_str(&content)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Session schema v{} is newer than supported v{}",
session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
),
));
}
Ok(session)
}
@@ -206,6 +346,14 @@ impl SessionManager {
}
}
/// Resolve the default session directory path (`~/.deepseek/sessions`).
pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found")
})?;
Ok(home.join(".deepseek").join("sessions"))
}
/// Create a new `SavedSession` from conversation state
pub fn create_saved_session(
messages: &[Message],
@@ -249,6 +397,7 @@ pub fn create_saved_session_with_mode(
.unwrap_or_else(|| "New Session".to_string());
SavedSession {
schema_version: CURRENT_SESSION_SCHEMA_VERSION,
metadata: SessionMetadata {
id,
title,
@@ -272,6 +421,7 @@ pub fn update_session(
total_tokens: u64,
system_prompt: Option<&SystemPrompt>,
) -> SavedSession {
session.schema_version = CURRENT_SESSION_SCHEMA_VERSION;
session.messages = messages.to_vec();
session.metadata.updated_at = Utc::now();
session.metadata.message_count = messages.len();
@@ -294,15 +444,17 @@ fn system_prompt_to_string(system_prompt: Option<&SystemPrompt>) -> Option<Strin
}
}
/// Truncate a string to create a title
/// Truncate a string to create a title (character-safe for UTF-8)
fn truncate_title(s: &str, max_len: usize) -> String {
let s = s.trim();
let first_line = s.lines().next().unwrap_or(s);
if first_line.len() <= max_len {
let char_count = first_line.chars().count();
if char_count <= max_len {
first_line.to_string()
} else {
format!("{}...", &first_line[..max_len - 3])
let truncated: String = first_line.chars().take(max_len - 3).collect();
format!("{truncated}...")
}
}
@@ -344,6 +496,7 @@ fn format_age(dt: &DateTime<Utc>) -> String {
mod tests {
use super::*;
use crate::models::ContentBlock;
use std::fs;
use tempfile::tempdir;
fn make_test_message(role: &str, text: &str) -> Message {
@@ -468,4 +621,98 @@ mod tests {
assert_eq!(updated.messages.len(), 2);
assert_eq!(updated.metadata.total_tokens, 100);
}
#[test]
fn test_checkpoint_round_trip_and_clear() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let messages = vec![make_test_message("user", "checkpoint me")];
let session = create_saved_session(&messages, "test-model", tmp.path(), 12, None);
manager.save_checkpoint(&session).expect("save checkpoint");
let loaded = manager
.load_checkpoint()
.expect("load checkpoint")
.expect("checkpoint exists");
assert_eq!(loaded.metadata.id, session.metadata.id);
manager.clear_checkpoint().expect("clear checkpoint");
assert!(
manager
.load_checkpoint()
.expect("load checkpoint")
.is_none()
);
}
#[test]
fn test_offline_queue_round_trip_and_clear() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let state = OfflineQueueState {
messages: vec![QueuedSessionMessage {
display: "queued message".to_string(),
skill_instruction: Some("Use skill".to_string()),
}],
draft: Some(QueuedSessionMessage {
display: "draft message".to_string(),
skill_instruction: None,
}),
..OfflineQueueState::default()
};
manager
.save_offline_queue_state(&state)
.expect("save queue state");
let loaded = manager
.load_offline_queue_state()
.expect("load queue state")
.expect("queue state exists");
assert_eq!(loaded.messages.len(), 1);
assert_eq!(loaded.messages[0].display, "queued message");
assert!(loaded.draft.is_some());
manager
.clear_offline_queue_state()
.expect("clear queue state");
assert!(
manager
.load_offline_queue_state()
.expect("load queue state")
.is_none()
);
}
#[test]
fn test_checkpoint_rejects_newer_schema() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let checkpoints = tmp.path().join("sessions").join("checkpoints");
fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
let path = checkpoints.join("latest.json");
fs::write(
&path,
r#"{
"schema_version": 999,
"metadata": {
"id": "sid",
"title": "bad",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"message_count": 0,
"total_tokens": 0,
"model": "m",
"workspace": "/tmp",
"mode": null
},
"messages": [],
"system_prompt": null
}"#,
)
.expect("write checkpoint");
let err = manager.load_checkpoint().expect_err("should reject schema");
assert!(err.to_string().contains("newer than supported"));
}
}
+58 -22
View File
@@ -4,6 +4,9 @@ use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use std::collections::HashMap;
use crate::logging;
// === Defaults ===
@@ -30,6 +33,7 @@ pub struct Skill {
#[derive(Debug, Clone, Default)]
pub struct SkillRegistry {
skills: Vec<Skill>,
warnings: Vec<String>,
}
impl SkillRegistry {
@@ -47,45 +51,72 @@ impl SkillRegistry {
&& ft.is_dir()
{
let skill_path = entry.path().join("SKILL.md");
if let Ok(content) = fs::read_to_string(&skill_path)
&& let Some(skill) = Self::parse_skill(&skill_path, &content)
{
registry.skills.push(skill);
match fs::read_to_string(&skill_path) {
Ok(content) => match Self::parse_skill(&skill_path, &content) {
Ok(skill) => registry.skills.push(skill),
Err(reason) => registry.push_warning(format!(
"Failed to parse {}: {reason}",
skill_path.display()
)),
},
Err(err) if skill_path.exists() => {
registry.push_warning(format!(
"Failed to read {}: {err}",
skill_path.display()
));
}
Err(_) => {}
}
}
}
} else {
registry.push_warning(format!("Failed to read skills directory {}", dir.display()));
}
registry
}
fn parse_skill(_path: &Path, content: &str) -> Option<Skill> {
fn push_warning(&mut self, warning: String) {
logging::warn(&warning);
self.warnings.push(warning);
}
fn parse_skill(_path: &Path, content: &str) -> std::result::Result<Skill, String> {
let trimmed = content.trim_start();
let (frontmatter, body) = if trimmed.starts_with("---") {
let start = content.find("---")?;
let start = content
.find("---")
.ok_or_else(|| "missing frontmatter opening delimiter".to_string())?;
let rest = &content[start + 3..];
let end = rest.find("---")?;
let end = rest
.find("---")
.ok_or_else(|| "missing frontmatter closing delimiter".to_string())?;
(&rest[..end], &rest[end + 3..])
} else {
let frontmatter_end = content.find("---")?;
(&content[..frontmatter_end], &content[frontmatter_end + 3..])
return Err("missing frontmatter opening delimiter '---'".to_string());
};
let name = frontmatter
.lines()
.find(|l| l.starts_with("name:"))
.and_then(|l| l.split(':').nth(1))?
.trim()
.to_string();
let description = frontmatter
.lines()
.find(|l| l.starts_with("description:"))
.and_then(|l| l.split(':').nth(1))
.map(|s| s.trim().to_string())
.unwrap_or_default();
let mut metadata = HashMap::new();
for raw in frontmatter.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once(':') {
metadata.insert(key.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let name = metadata
.get("name")
.filter(|name| !name.is_empty())
.cloned()
.ok_or_else(|| "missing required frontmatter field: name".to_string())?;
let description = metadata.get("description").cloned().unwrap_or_default();
let body = body.trim().to_string();
Some(Skill {
Ok(Skill {
name,
description,
body,
@@ -102,6 +133,11 @@ impl SkillRegistry {
&self.skills
}
/// Parse or I/O warnings encountered while discovering skills.
pub fn warnings(&self) -> &[String] {
&self.warnings
}
/// Check whether any skills were loaded.
#[must_use]
pub fn is_empty(&self) -> bool {
+1560
View File
File diff suppressed because it is too large Load Diff
+181 -2
View File
@@ -55,8 +55,13 @@ impl ToolSpec for CalculatorTool {
let prefix = optional_str(&input, "prefix").unwrap_or("");
let suffix = optional_str(&input, "suffix").unwrap_or("");
let value = meval::eval_str(expression)
let value = eval_expression(expression)
.map_err(|e| ToolError::invalid_input(format!("Invalid expression: {e}")))?;
if !value.is_finite() {
return Err(ToolError::invalid_input(
"Invalid expression: result is not finite".to_string(),
));
}
let rendered = format_value(value);
let result = format!("{prefix}{rendered}{suffix}");
@@ -78,13 +83,187 @@ fn format_value(value: f64) -> String {
}
}
fn eval_expression(expression: &str) -> std::result::Result<f64, String> {
let mut parser = ExpressionParser::new(expression);
let value = parser.parse_expression()?;
parser.skip_whitespace();
if !parser.is_eof() {
return Err(format!("unexpected token at byte {}", parser.position()));
}
Ok(value)
}
struct ExpressionParser<'a> {
input: &'a [u8],
pos: usize,
}
impl<'a> ExpressionParser<'a> {
fn new(input: &'a str) -> Self {
Self {
input: input.as_bytes(),
pos: 0,
}
}
fn position(&self) -> usize {
self.pos
}
fn is_eof(&self) -> bool {
self.pos >= self.input.len()
}
fn skip_whitespace(&mut self) {
while let Some(ch) = self.peek() {
if ch.is_ascii_whitespace() {
self.pos += 1;
} else {
break;
}
}
}
fn peek(&self) -> Option<u8> {
self.input.get(self.pos).copied()
}
fn consume(&mut self, ch: u8) -> bool {
self.skip_whitespace();
if self.peek() == Some(ch) {
self.pos += 1;
true
} else {
false
}
}
fn parse_expression(&mut self) -> std::result::Result<f64, String> {
let mut value = self.parse_term()?;
loop {
if self.consume(b'+') {
value += self.parse_term()?;
} else if self.consume(b'-') {
value -= self.parse_term()?;
} else {
break;
}
}
Ok(value)
}
fn parse_term(&mut self) -> std::result::Result<f64, String> {
let mut value = self.parse_power()?;
loop {
if self.consume(b'*') {
value *= self.parse_power()?;
} else if self.consume(b'/') {
let divisor = self.parse_power()?;
if divisor == 0.0 {
return Err("division by zero".to_string());
}
value /= divisor;
} else if self.consume(b'%') {
let divisor = self.parse_power()?;
if divisor == 0.0 {
return Err("modulo by zero".to_string());
}
value %= divisor;
} else {
break;
}
}
Ok(value)
}
fn parse_power(&mut self) -> std::result::Result<f64, String> {
let value = self.parse_unary()?;
if self.consume(b'^') {
let exponent = self.parse_power()?;
Ok(value.powf(exponent))
} else {
Ok(value)
}
}
fn parse_unary(&mut self) -> std::result::Result<f64, String> {
if self.consume(b'+') {
self.parse_unary()
} else if self.consume(b'-') {
Ok(-self.parse_unary()?)
} else {
self.parse_primary()
}
}
fn parse_primary(&mut self) -> std::result::Result<f64, String> {
self.skip_whitespace();
if self.consume(b'(') {
let value = self.parse_expression()?;
if !self.consume(b')') {
return Err("missing closing ')'".to_string());
}
return Ok(value);
}
self.parse_number()
}
fn parse_number(&mut self) -> std::result::Result<f64, String> {
self.skip_whitespace();
let start = self.pos;
let mut saw_digit = false;
let mut saw_dot = false;
let mut saw_exp = false;
while let Some(ch) = self.peek() {
if ch.is_ascii_digit() {
saw_digit = true;
self.pos += 1;
} else if ch == b'.' && !saw_dot && !saw_exp {
saw_dot = true;
self.pos += 1;
} else if (ch == b'e' || ch == b'E') && saw_digit && !saw_exp {
saw_exp = true;
self.pos += 1;
if matches!(self.peek(), Some(b'+') | Some(b'-')) {
self.pos += 1;
}
} else {
break;
}
}
if start == self.pos || !saw_digit {
return Err(format!("expected number at byte {}", start));
}
let number_text = std::str::from_utf8(&self.input[start..self.pos])
.map_err(|_| format!("invalid UTF-8 near byte {}", start))?;
number_text
.parse::<f64>()
.map_err(|_| format!("invalid number '{number_text}'"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn evaluates_expression() {
let value = meval::eval_str("2 + 2").unwrap();
let value = eval_expression("2 + 2").expect("expression should parse");
assert_eq!(format_value(value), "4");
}
#[test]
fn handles_precedence_and_parentheses() {
let value = eval_expression("2 + 3 * (4 - 1)").expect("expression should parse");
assert_eq!(format_value(value), "11");
}
#[test]
fn rejects_invalid_input() {
let err = eval_expression("2 +").expect_err("invalid expression should fail");
assert!(err.contains("expected number"));
}
}
+1
View File
@@ -18,6 +18,7 @@ pub mod registry;
pub mod review;
pub mod search;
pub mod shell;
mod shell_output;
pub mod spec;
pub mod sports;
pub mod subagent;
+13 -302
View File
@@ -21,6 +21,7 @@ use wait_timeout::ChildExt;
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use super::shell_output::{TruncationMeta, summarize_output, truncate_output, truncate_with_meta};
use crate::sandbox::{
CommandSpec,
ExecEnv,
@@ -29,12 +30,6 @@ use crate::sandbox::{
SandboxType,
};
/// Maximum output size before truncation (30KB like Claude Code)
const MAX_OUTPUT_SIZE: usize = 30_000;
/// Limits for summary strings in tool metadata.
const SUMMARY_MAX_LINES: usize = 3;
const SUMMARY_MAX_CHARS: usize = 240;
/// Status of a shell process
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ShellStatus {
@@ -364,6 +359,17 @@ impl BackgroundShell {
}
}
impl Drop for BackgroundShell {
fn drop(&mut self) {
if self.status == ShellStatus::Running {
if let Some(ref mut child) = self.child {
let _ = child.kill();
let _ = child.wait();
}
}
}
}
/// Manages background shell processes with optional sandboxing.
pub struct ShellManager {
processes: HashMap<String, BackgroundShell>,
@@ -1024,59 +1030,6 @@ impl ShellManager {
}
}
#[derive(Debug, Clone, Copy, Default)]
struct TruncationMeta {
original_len: usize,
omitted: usize,
truncated: bool,
}
fn truncate_with_meta(output: &str) -> (String, TruncationMeta) {
let original_len = output.len();
if original_len <= MAX_OUTPUT_SIZE {
return (
output.to_string(),
TruncationMeta {
original_len,
omitted: 0,
truncated: false,
},
);
}
let cut_index = char_boundary_at_or_before(output, MAX_OUTPUT_SIZE);
let truncated = &output[..cut_index];
let omitted = original_len.saturating_sub(cut_index);
let note =
format!("...\n\n[Output truncated at {MAX_OUTPUT_SIZE} bytes. {omitted} bytes omitted.]");
(
format!("{truncated}{note}"),
TruncationMeta {
original_len,
omitted,
truncated: true,
},
)
}
fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize {
if max_bytes >= text.len() {
return text.len();
}
let mut last_end = 0usize;
for (idx, ch) in text.char_indices() {
let end = idx.saturating_add(ch.len_utf8());
if end > max_bytes {
break;
}
last_end = end;
}
last_end.min(text.len())
}
fn take_delta_from_buffer(buffer: &Arc<Mutex<Vec<u8>>>, cursor: &mut usize) -> (Vec<u8>, usize) {
let data = buffer.lock().map(|d| d.clone()).unwrap_or_default();
let start = (*cursor).min(data.len());
@@ -1085,49 +1038,6 @@ fn take_delta_from_buffer(buffer: &Arc<Mutex<Vec<u8>>>, cursor: &mut usize) -> (
(delta, data.len())
}
fn strip_truncation_note(text: &str) -> &str {
text.split_once("\n\n[Output truncated at")
.map_or(text, |(prefix, _)| prefix)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut end = text.len();
for (count, (idx, _)) in text.char_indices().enumerate() {
if count == max_chars {
end = idx;
break;
}
}
format!("{}...", &text[..end])
}
fn summarize_output(text: &str) -> String {
let stripped = strip_truncation_note(text);
let summary = stripped
.lines()
.take(SUMMARY_MAX_LINES)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if summary.is_empty() {
String::new()
} else {
truncate_chars(&summary, SUMMARY_MAX_CHARS)
}
}
/// Truncate output to `MAX_OUTPUT_SIZE`
fn truncate_output(output: &str) -> String {
truncate_with_meta(output).0
}
/// Thread-safe wrapper for `ShellManager`
pub type SharedShellManager = Arc<Mutex<ShellManager>>;
@@ -1703,203 +1613,4 @@ impl ToolSpec for NoteTool {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::spec::ToolContext;
use serde_json::{Value, json};
use tempfile::tempdir;
fn echo_command(message: &str) -> String {
format!("echo {message}")
}
fn sleep_command(seconds: u64) -> String {
#[cfg(windows)]
{
let ping_count = seconds.saturating_add(1);
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
format!(
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}\" || ping 127.0.0.1 -n {ping_count} > NUL"
)
}
#[cfg(not(windows))]
{
format!("sleep {seconds}")
}
}
fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
#[cfg(windows)]
{
let ping_count = seconds.saturating_add(1);
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
format!(
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}; Write-Output {message}\" || (ping 127.0.0.1 -n {ping_count} > NUL && echo {message})"
)
}
#[cfg(not(windows))]
{
format!("sleep {seconds} && echo {message}")
}
}
fn echo_stdin_command() -> String {
#[cfg(windows)]
{
"more".to_string()
}
#[cfg(not(windows))]
{
"cat".to_string()
}
}
#[test]
fn test_sync_execution() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&echo_command("hello"), None, 5000, false)
.expect("execute");
assert_eq!(result.status, ShellStatus::Completed);
assert!(result.stdout.contains("hello"));
assert!(result.task_id.is_none());
}
#[test]
fn test_background_execution() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
.expect("execute");
assert_eq!(result.status, ShellStatus::Running);
assert!(result.task_id.is_some());
let task_id = result
.task_id
.expect("background execution should return task_id");
// Wait for completion
let final_result = manager
.get_output(&task_id, true, 5000)
.expect("get_output");
assert_eq!(final_result.status, ShellStatus::Completed);
assert!(final_result.stdout.contains("done"));
}
#[test]
fn test_timeout() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&sleep_command(10), None, 1000, false)
.expect("execute");
assert_eq!(result.status, ShellStatus::TimedOut);
}
#[test]
fn test_kill() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&sleep_command(60), None, 5000, true)
.expect("execute");
let task_id = result
.task_id
.expect("background execution should return task_id");
// Kill it
let killed = manager.kill(&task_id).expect("kill");
assert_eq!(killed.status, ShellStatus::Killed);
}
#[test]
fn test_write_stdin_streams_output() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute_with_options(&echo_stdin_command(), None, 5000, true, None, false, None)
.expect("execute");
let task_id = result
.task_id
.expect("background execution should return task_id");
manager
.write_stdin(&task_id, "hello\n", true)
.expect("write stdin");
let delta = manager
.get_output_delta(&task_id, true, 5000)
.expect("get_output_delta");
assert!(delta.result.stdout.contains("hello"));
let delta2 = manager
.get_output_delta(&task_id, false, 0)
.expect("get_output_delta");
assert!(delta2.result.stdout.is_empty());
}
#[test]
fn test_output_truncation() {
let long_output = "x".repeat(50_000);
let truncated = truncate_output(&long_output);
assert!(truncated.len() < long_output.len());
assert!(truncated.contains("truncated"));
}
#[test]
fn test_truncate_with_meta_reports_omission_counts() {
let long_output = format!("line1\nline2\n{}", "x".repeat(60_000));
let (truncated, meta) = truncate_with_meta(&long_output);
assert!(meta.truncated);
assert!(meta.original_len >= long_output.len());
assert!(meta.omitted > 0);
assert!(truncated.contains("bytes omitted"));
}
#[test]
fn test_summarize_output_strips_truncation_note() {
let long_output = "x".repeat(60_000);
let truncated = truncate_output(&long_output);
let summary = summarize_output(&truncated);
assert!(!summary.contains("Output truncated at"));
}
#[tokio::test]
async fn test_exec_shell_metadata_includes_summaries() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path());
let tool = ExecShellTool;
let result = tool
.execute(json!({"command": echo_command("hello")}), &ctx)
.await
.expect("execute");
assert!(result.success);
let meta = result.metadata.expect("metadata");
let summary = meta
.get("summary")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
assert!(summary.contains("hello"));
assert!(meta.get("stdout_len").is_some());
assert!(meta.get("stdout_truncated").is_some());
}
}
mod tests;
+199
View File
@@ -0,0 +1,199 @@
use super::*;
use crate::tools::spec::ToolContext;
use serde_json::{Value, json};
use tempfile::tempdir;
fn echo_command(message: &str) -> String {
format!("echo {message}")
}
fn sleep_command(seconds: u64) -> String {
#[cfg(windows)]
{
let ping_count = seconds.saturating_add(1);
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
format!(
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}\" || ping 127.0.0.1 -n {ping_count} > NUL"
)
}
#[cfg(not(windows))]
{
format!("sleep {seconds}")
}
}
fn sleep_then_echo_command(seconds: u64, message: &str) -> String {
#[cfg(windows)]
{
let ping_count = seconds.saturating_add(1);
let ps_path = r#"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"#;
format!(
"\"{ps_path}\" -NoProfile -Command \"Start-Sleep -Seconds {seconds}; Write-Output {message}\" || (ping 127.0.0.1 -n {ping_count} > NUL && echo {message})"
)
}
#[cfg(not(windows))]
{
format!("sleep {seconds} && echo {message}")
}
}
fn echo_stdin_command() -> String {
#[cfg(windows)]
{
"more".to_string()
}
#[cfg(not(windows))]
{
"cat".to_string()
}
}
#[test]
fn test_sync_execution() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&echo_command("hello"), None, 5000, false)
.expect("execute");
assert_eq!(result.status, ShellStatus::Completed);
assert!(result.stdout.contains("hello"));
assert!(result.task_id.is_none());
}
#[test]
fn test_background_execution() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&sleep_then_echo_command(1, "done"), None, 5000, true)
.expect("execute");
assert_eq!(result.status, ShellStatus::Running);
assert!(result.task_id.is_some());
let task_id = result
.task_id
.expect("background execution should return task_id");
// Wait for completion
let final_result = manager
.get_output(&task_id, true, 5000)
.expect("get_output");
assert_eq!(final_result.status, ShellStatus::Completed);
assert!(final_result.stdout.contains("done"));
}
#[test]
fn test_timeout() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&sleep_command(10), None, 1000, false)
.expect("execute");
assert_eq!(result.status, ShellStatus::TimedOut);
}
#[test]
fn test_kill() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute(&sleep_command(60), None, 5000, true)
.expect("execute");
let task_id = result
.task_id
.expect("background execution should return task_id");
// Kill it
let killed = manager.kill(&task_id).expect("kill");
assert_eq!(killed.status, ShellStatus::Killed);
}
#[test]
fn test_write_stdin_streams_output() {
let tmp = tempdir().expect("tempdir");
let mut manager = ShellManager::new(tmp.path().to_path_buf());
let result = manager
.execute_with_options(&echo_stdin_command(), None, 5000, true, None, false, None)
.expect("execute");
let task_id = result
.task_id
.expect("background execution should return task_id");
manager
.write_stdin(&task_id, "hello\n", true)
.expect("write stdin");
let delta = manager
.get_output_delta(&task_id, true, 5000)
.expect("get_output_delta");
assert!(delta.result.stdout.contains("hello"));
let delta2 = manager
.get_output_delta(&task_id, false, 0)
.expect("get_output_delta");
assert!(delta2.result.stdout.is_empty());
}
#[test]
fn test_output_truncation() {
let long_output = "x".repeat(50_000);
let truncated = truncate_output(&long_output);
assert!(truncated.len() < long_output.len());
assert!(truncated.contains("truncated"));
}
#[test]
fn test_truncate_with_meta_reports_omission_counts() {
let long_output = format!("line1\nline2\n{}", "x".repeat(60_000));
let (truncated, meta) = truncate_with_meta(&long_output);
assert!(meta.truncated);
assert!(meta.original_len >= long_output.len());
assert!(meta.omitted > 0);
assert!(truncated.contains("bytes omitted"));
}
#[test]
fn test_summarize_output_strips_truncation_note() {
let long_output = "x".repeat(60_000);
let truncated = truncate_output(&long_output);
let summary = summarize_output(&truncated);
assert!(!summary.contains("Output truncated at"));
}
#[tokio::test]
async fn test_exec_shell_metadata_includes_summaries() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path());
let tool = ExecShellTool;
let result = tool
.execute(json!({"command": echo_command("hello")}), &ctx)
.await
.expect("execute");
assert!(result.success);
let meta = result.metadata.expect("metadata");
let summary = meta
.get("summary")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
assert!(summary.contains("hello"));
assert!(meta.get("stdout_len").is_some());
assert!(meta.get("stdout_truncated").is_some());
}
+103
View File
@@ -0,0 +1,103 @@
//! Output truncation and summarization helpers for shell tools.
/// Maximum output size before truncation (30KB like Claude Code).
const MAX_OUTPUT_SIZE: usize = 30_000;
/// Limits for summary strings in tool metadata.
const SUMMARY_MAX_LINES: usize = 3;
const SUMMARY_MAX_CHARS: usize = 240;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct TruncationMeta {
pub(crate) original_len: usize,
pub(crate) omitted: usize,
pub(crate) truncated: bool,
}
pub(crate) fn truncate_with_meta(output: &str) -> (String, TruncationMeta) {
let original_len = output.len();
if original_len <= MAX_OUTPUT_SIZE {
return (
output.to_string(),
TruncationMeta {
original_len,
omitted: 0,
truncated: false,
},
);
}
let cut_index = char_boundary_at_or_before(output, MAX_OUTPUT_SIZE);
let truncated = &output[..cut_index];
let omitted = original_len.saturating_sub(cut_index);
let note =
format!("...\n\n[Output truncated at {MAX_OUTPUT_SIZE} bytes. {omitted} bytes omitted.]");
(
format!("{truncated}{note}"),
TruncationMeta {
original_len,
omitted,
truncated: true,
},
)
}
fn char_boundary_at_or_before(text: &str, max_bytes: usize) -> usize {
if max_bytes >= text.len() {
return text.len();
}
let mut last_end = 0usize;
for (idx, ch) in text.char_indices() {
let end = idx.saturating_add(ch.len_utf8());
if end > max_bytes {
break;
}
last_end = end;
}
last_end.min(text.len())
}
fn strip_truncation_note(text: &str) -> &str {
text.split_once("\n\n[Output truncated at")
.map_or(text, |(prefix, _)| prefix)
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut end = text.len();
for (count, (idx, _)) in text.char_indices().enumerate() {
if count == max_chars {
end = idx;
break;
}
}
format!("{}...", &text[..end])
}
pub(crate) fn summarize_output(text: &str) -> String {
let stripped = strip_truncation_note(text);
let summary = stripped
.lines()
.take(SUMMARY_MAX_LINES)
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if summary.is_empty() {
String::new()
} else {
truncate_chars(&summary, SUMMARY_MAX_CHARS)
}
}
/// Truncate output to `MAX_OUTPUT_SIZE`.
pub(crate) fn truncate_output(output: &str) -> String {
truncate_with_meta(output).0
}
+253 -8
View File
@@ -11,7 +11,9 @@ use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{Config, has_api_key, save_api_key};
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
use crate::models::{Message, SystemPrompt};
use crate::models::{
Message, SystemPrompt, compaction_message_threshold_for_model, compaction_threshold_for_model,
};
use crate::palette::{self, UiTheme};
use crate::settings::Settings;
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
@@ -102,6 +104,8 @@ fn sanitize_api_key_text(text: &str) -> String {
text.chars().filter(|c| !c.is_control()).collect()
}
const MAX_SUBMITTED_INPUT_CHARS: usize = 16_000;
impl AppMode {
/// Short label used in the UI footer.
pub fn label(self) -> &'static str {
@@ -177,6 +181,8 @@ pub struct App {
pub last_transcript_total: usize,
pub last_transcript_padding_top: usize,
pub is_loading: bool,
/// Degraded connectivity mode; new user inputs are queued for later retry.
pub offline_mode: bool,
pub status_message: Option<String>,
pub model: String,
pub workspace: PathBuf,
@@ -189,6 +195,7 @@ pub struct App {
pub auto_compact: bool,
pub show_thinking: bool,
pub show_tool_details: bool,
pub sidebar_width_percent: u16,
#[allow(dead_code)]
pub compact_threshold: usize,
pub max_input_history: usize,
@@ -239,6 +246,8 @@ pub struct App {
pub active_skill: Option<String>,
/// Tool call cells by tool id
pub tool_cells: HashMap<String, usize>,
/// Full tool input/output keyed by history cell index.
pub tool_details_by_cell: HashMap<usize, ToolDetailRecord>,
/// Active exploring cell index
pub exploring_cell: Option<usize>,
/// Mapping of exploring tool ids to (cell index, entry index)
@@ -263,19 +272,43 @@ pub struct App {
pub queued_draft: Option<QueuedMessage>,
/// Start time for current turn
pub turn_started_at: Option<Instant>,
/// Current runtime turn id (if known).
pub runtime_turn_id: Option<String>,
/// Current runtime turn status (if known).
pub runtime_turn_status: Option<String>,
/// Last prompt token usage
pub last_prompt_tokens: Option<u32>,
/// Last completion token usage
pub last_completion_tokens: Option<u32>,
/// Cached background tasks for sidebar rendering.
pub task_panel: Vec<TaskPanelEntry>,
}
/// Message queued while the engine is busy.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueuedMessage {
pub display: String,
pub skill_instruction: Option<String>,
}
/// Detailed tool payload attached to a history cell.
#[derive(Debug, Clone)]
pub struct ToolDetailRecord {
pub tool_id: String,
pub tool_name: String,
pub input: Value,
pub output: Option<String>,
}
/// Lightweight task view for sidebar rendering.
#[derive(Debug, Clone)]
pub struct TaskPanelEntry {
pub id: String,
pub status: String,
pub prompt_summary: String,
pub duration_ms: Option<u64>,
}
impl QueuedMessage {
pub fn new(display: String, skill_instruction: Option<String>) -> Self {
Self {
@@ -338,9 +371,11 @@ impl App {
let auto_compact = settings.auto_compact;
let show_thinking = settings.show_thinking;
let show_tool_details = settings.show_tool_details;
let sidebar_width_percent = settings.sidebar_width_percent;
let max_input_history = settings.max_input_history;
let ui_theme = palette::ui_theme(&settings.theme);
let model = settings.default_model.clone().unwrap_or_else(|| model);
let compact_threshold = compaction_threshold_for_model(&model);
// Start in YOLO mode if --yolo flag was passed
let preferred_mode = match settings.default_mode.as_str() {
@@ -403,6 +438,7 @@ impl App {
last_transcript_total: 0,
last_transcript_padding_top: 0,
is_loading: false,
offline_mode: false,
status_message: None,
model,
workspace,
@@ -414,7 +450,8 @@ impl App {
auto_compact,
show_thinking,
show_tool_details,
compact_threshold: 50000,
sidebar_width_percent,
compact_threshold,
max_input_history,
total_tokens: 0,
total_conversation_tokens: 0,
@@ -455,6 +492,7 @@ impl App {
session_cost: 0.0,
active_skill: None,
tool_cells: HashMap::new(),
tool_details_by_cell: HashMap::new(),
exploring_cell: None,
exploring_entries: HashMap::new(),
ignored_tool_calls: HashSet::new(),
@@ -467,8 +505,11 @@ impl App {
queued_messages: VecDeque::new(),
queued_draft: None,
turn_started_at: None,
runtime_turn_id: None,
runtime_turn_status: None,
last_prompt_tokens: None,
last_completion_tokens: None,
task_panel: Vec::new(),
}
}
@@ -764,7 +805,14 @@ impl App {
self.paste_burst.clear_after_explicit_paste();
return None;
}
let input = self.input.clone();
let mut input = self.input.clone();
if char_count(&input) > MAX_SUBMITTED_INPUT_CHARS {
input = input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect();
self.status_message = Some(format!(
"Input truncated to {} characters for safety",
MAX_SUBMITTED_INPUT_CHARS
));
}
if !input.starts_with('/') {
self.input_history.push(input.clone());
if self.max_input_history == 0 {
@@ -847,16 +895,32 @@ impl App {
}
}
pub fn clear_todos(&mut self) {
let mut plan = self.plan_state.blocking_lock();
*plan = crate::tools::plan::PlanState::default();
pub fn clear_todos(&mut self) -> bool {
if let Ok(mut plan) = self.plan_state.try_lock() {
*plan = crate::tools::plan::PlanState::default();
return true;
}
false
}
pub fn update_model_compaction_budget(&mut self) {
self.compact_threshold = compaction_threshold_for_model(&self.model);
}
pub fn compaction_config(&self) -> CompactionConfig {
let mut compaction = CompactionConfig::default();
compaction.enabled = self.auto_compact;
compaction.token_threshold = self.compact_threshold;
compaction.message_threshold = compaction_message_threshold_for_model(&self.model);
compaction.model = self.model.clone();
compaction
}
}
// === Actions ===
/// Actions emitted by the UI event loop.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum AppAction {
Quit,
#[allow(dead_code)] // For explicit /save command
@@ -871,13 +935,25 @@ pub enum AppAction {
},
SendMessage(String),
ListSubAgents,
FetchModels,
UpdateCompaction(CompactionConfig),
TaskAdd {
prompt: String,
},
TaskList,
TaskShow {
id: String,
},
TaskCancel {
id: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs};
fn test_options(yolo: bool) -> TuiOptions {
TuiOptions {
@@ -903,4 +979,173 @@ mod tests {
let app = App::new(test_options(true), &Config::default());
assert!(app.trust_mode);
}
#[test]
fn submit_input_truncates_oversized_payloads() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128);
app.cursor_position = app.input.chars().count();
let submitted = app.submit_input().expect("expected submitted input");
assert_eq!(submitted.chars().count(), MAX_SUBMITTED_INPUT_CHARS);
assert!(
app.status_message
.as_ref()
.is_some_and(|msg| msg.contains("Input truncated"))
);
}
#[test]
fn clear_todos_resets_plan_state() {
let mut app = App::new(test_options(false), &Config::default());
{
let mut plan = app
.plan_state
.try_lock()
.expect("plan lock should be available");
plan.update(UpdatePlanArgs {
explanation: Some("test plan".to_string()),
plan: vec![PlanItemArg {
step: "step 1".to_string(),
status: StepStatus::InProgress,
}],
});
assert!(!plan.is_empty());
}
assert!(app.clear_todos());
let plan = app
.plan_state
.try_lock()
.expect("plan lock should be available");
assert!(plan.is_empty());
}
#[test]
fn test_cycle_mode_transitions() {
let mut app = App::new(test_options(false), &Config::default());
// Default mode should be Agent based on settings
let initial_mode = app.mode;
app.cycle_mode();
// Mode should have changed
assert_ne!(app.mode, initial_mode);
}
#[test]
fn test_clear_input() {
let mut app = App::new(test_options(false), &Config::default());
app.input = "test input".to_string();
app.cursor_position = app.input.len();
app.clear_input();
assert!(app.input.is_empty());
assert_eq!(app.cursor_position, 0);
}
#[test]
fn test_queue_message() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("test message".to_string(), None));
assert_eq!(app.queued_message_count(), 1);
assert!(app.queued_messages.front().is_some());
}
#[test]
fn test_remove_queued_message() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("first".to_string(), None));
app.queue_message(QueuedMessage::new("second".to_string(), None));
// Remove first (index 0)
let removed = app.remove_queued_message(0);
assert!(removed.is_some());
assert_eq!(app.queued_message_count(), 1);
// Remove second (now at index 0)
let removed = app.remove_queued_message(0);
assert!(removed.is_some());
assert_eq!(app.queued_message_count(), 0);
}
#[test]
fn test_remove_queued_message_invalid_index() {
let mut app = App::new(test_options(false), &Config::default());
app.queue_message(QueuedMessage::new("test".to_string(), None));
// Try to remove non-existent index
let removed = app.remove_queued_message(100);
assert!(removed.is_none());
}
#[test]
fn test_set_mode_updates_state() {
let mut app = App::new(test_options(false), &Config::default());
let initial_mode = app.mode;
app.set_mode(AppMode::Yolo);
assert_eq!(app.mode, AppMode::Yolo);
assert_ne!(app.mode, initial_mode);
// Yolo mode should enable trust and shell
assert!(app.trust_mode);
assert!(app.allow_shell);
}
#[test]
fn test_mark_history_updated() {
let mut app = App::new(test_options(false), &Config::default());
let initial_version = app.history_version;
app.mark_history_updated();
assert!(app.history_version > initial_version);
}
#[test]
fn test_scroll_operations() {
let mut app = App::new(test_options(false), &Config::default());
// Just verify scroll methods can be called without panic
app.scroll_up(5);
app.scroll_down(3);
}
#[test]
fn test_add_message() {
let mut app = App::new(test_options(false), &Config::default());
let initial_len = app.history.len();
app.add_message(HistoryCell::User {
content: "test".to_string(),
});
assert_eq!(app.history.len(), initial_len + 1);
}
#[test]
fn test_compaction_config() {
let app = App::new(test_options(false), &Config::default());
let config = app.compaction_config();
// Config should be valid (just checking it returns something)
let _ = config.enabled;
}
#[test]
fn test_update_model_compaction_budget() {
let mut app = App::new(test_options(false), &Config::default());
let initial_threshold = app.compact_threshold;
app.model = "deepseek-reasoner".to_string();
app.update_model_compaction_budget();
// Threshold may have changed based on model
// deepseek-reasoner has 128k context, so threshold should be higher
assert!(app.compact_threshold >= initial_threshold);
}
#[test]
fn test_input_history_navigation() {
let mut app = App::new(test_options(false), &Config::default());
app.input_history.push("first".to_string());
app.input_history.push("second".to_string());
// Navigate up
app.history_up();
assert!(app.history_index.is_some());
// Navigate down
app.history_down();
}
}
+541 -1
View File
@@ -98,7 +98,7 @@ impl ApprovalRequest {
pub fn get_tool_category(name: &str) -> ToolCategory {
if matches!(name, "write_file" | "edit_file" | "apply_patch") {
ToolCategory::FileWrite
} else if name == "exec_shell" {
} else if name == "exec_shell" || name.starts_with("mcp_") || name.starts_with("list_mcp_") {
ToolCategory::Shell
} else {
// Default to safe (includes read/list/todo/note/update_plan and unknown tools)
@@ -151,6 +151,15 @@ impl ApprovalView {
})
}
fn emit_params_pager(&self) -> ViewAction {
let content = serde_json::to_string_pretty(&self.request.params)
.unwrap_or_else(|_| self.request.params.to_string());
ViewAction::Emit(ViewEvent::OpenTextPager {
title: format!("Tool Params: {}", self.request.tool_name),
content,
})
}
fn is_timed_out(&self) -> bool {
match self.timeout {
Some(timeout) => self.requested_at.elapsed() >= timeout,
@@ -178,6 +187,7 @@ impl ModalView for ApprovalView {
KeyCode::Char('y') => self.emit_decision(ReviewDecision::Approved, false),
KeyCode::Char('a') => self.emit_decision(ReviewDecision::ApprovedForSession, false),
KeyCode::Char('n') => self.emit_decision(ReviewDecision::Denied, false),
KeyCode::Char('v') | KeyCode::Char('V') => self.emit_params_pager(),
KeyCode::Esc => self.emit_decision(ReviewDecision::Abort, false),
_ => ViewAction::None,
}
@@ -432,3 +442,533 @@ impl ModalView for ElevationView {
elevation_widget.render(area, buf);
}
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyModifiers};
use serde_json::json;
fn create_key_event(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::empty(),
kind: crossterm::event::KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}
}
// ========================================================================
// Tool Category Tests
// ========================================================================
#[test]
fn test_get_tool_category_safe_tools() {
// Read-only operations should be Safe
assert_eq!(get_tool_category("read_file"), ToolCategory::Safe);
assert_eq!(get_tool_category("list_dir"), ToolCategory::Safe);
assert_eq!(get_tool_category("todo_write"), ToolCategory::Safe);
assert_eq!(get_tool_category("todo_read"), ToolCategory::Safe);
assert_eq!(get_tool_category("note"), ToolCategory::Safe);
assert_eq!(get_tool_category("update_plan"), ToolCategory::Safe);
assert_eq!(get_tool_category("unknown_tool"), ToolCategory::Safe);
}
#[test]
fn test_get_tool_category_file_write_tools() {
// File modification tools should be FileWrite
assert_eq!(get_tool_category("write_file"), ToolCategory::FileWrite);
assert_eq!(get_tool_category("edit_file"), ToolCategory::FileWrite);
assert_eq!(get_tool_category("apply_patch"), ToolCategory::FileWrite);
}
#[test]
fn test_get_tool_category_shell_tools() {
// Shell execution tools should be Shell
assert_eq!(get_tool_category("exec_shell"), ToolCategory::Shell);
assert_eq!(get_tool_category("mcp_tool"), ToolCategory::Shell);
assert_eq!(get_tool_category("list_mcp_tools"), ToolCategory::Shell);
}
// ========================================================================
// ApprovalRequest Tests
// ========================================================================
#[test]
fn test_approval_request_new() {
let params = json!({"path": "src/main.rs", "content": "test"});
let request = ApprovalRequest::new("test-id", "write_file", &params);
assert_eq!(request.id, "test-id");
assert_eq!(request.tool_name, "write_file");
assert_eq!(request.category, ToolCategory::FileWrite);
assert_eq!(request.params, params);
}
#[test]
fn test_approval_request_params_display_truncates() {
// Create params with a very long string
let long_content = "x".repeat(300);
let params = json!({"path": "src/main.rs", "content": long_content});
let request = ApprovalRequest::new("test-id", "write_file", &params);
let display = request.params_display();
// Should be truncated to around 200 chars
assert!(display.len() < 250);
assert!(display.contains("src/main.rs"));
}
#[test]
fn test_approval_request_params_display_short() {
let params = json!({"path": "src/main.rs"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let display = request.params_display();
assert!(display.contains("src/main.rs"));
}
// ========================================================================
// ApprovalView Tests
// ========================================================================
#[test]
fn test_approval_view_initial_state() {
let params = json!({"path": "src/main.rs"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let view = ApprovalView::new(request.clone());
assert_eq!(view.selected, 0);
assert!(view.timeout.is_none());
}
#[test]
fn test_approval_view_navigation() {
let params = json!({"path": "src/main.rs"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let mut view = ApprovalView::new(request);
// Initially at 0
assert_eq!(view.selected, 0);
// Navigate down
view.select_next();
assert_eq!(view.selected, 1);
view.select_next();
assert_eq!(view.selected, 2);
view.select_next();
assert_eq!(view.selected, 3);
// Should clamp at 3
view.select_next();
assert_eq!(view.selected, 3);
// Navigate up
view.select_prev();
assert_eq!(view.selected, 2);
}
#[test]
fn test_approval_view_keybindings_decisions() {
let params = json!({"path": "src/main.rs"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let mut view = ApprovalView::new(request.clone());
// Test 'y' -> Approved
let action = view.handle_key(create_key_event(KeyCode::Char('y')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
decision: ReviewDecision::Approved,
..
})
));
// Test 'n' -> Denied
let mut view = ApprovalView::new(request.clone());
let action = view.handle_key(create_key_event(KeyCode::Char('n')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
decision: ReviewDecision::Denied,
..
})
));
// Test 'a' -> ApprovedForSession
let mut view = ApprovalView::new(request.clone());
let action = view.handle_key(create_key_event(KeyCode::Char('a')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
decision: ReviewDecision::ApprovedForSession,
..
})
));
// Test Esc -> Abort
let mut view = ApprovalView::new(request);
let action = view.handle_key(create_key_event(KeyCode::Esc));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
decision: ReviewDecision::Abort,
..
})
));
}
#[test]
fn test_approval_view_enter_uses_selected_option() {
let params = json!({"path": "src/main.rs"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let mut view = ApprovalView::new(request);
// Navigate to index 2 (Denied)
view.select_next();
view.select_next();
assert_eq!(view.selected, 2);
// Press Enter - should use current selection
let action = view.handle_key(create_key_event(KeyCode::Enter));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ApprovalDecision {
decision: ReviewDecision::Denied,
..
})
));
}
#[test]
fn test_approval_view_navigation_keys() {
let params = json!({"path": "src/main.rs"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let mut view = ApprovalView::new(request);
// Test Up arrow
view.handle_key(create_key_event(KeyCode::Up));
assert_eq!(view.selected, 0); // Should clamp at 0
// Test Down arrow
view.handle_key(create_key_event(KeyCode::Down));
assert_eq!(view.selected, 1);
// Test 'j' for down
view.handle_key(create_key_event(KeyCode::Char('j')));
assert_eq!(view.selected, 2);
// Test 'k' for up
view.handle_key(create_key_event(KeyCode::Char('k')));
assert_eq!(view.selected, 1);
}
#[test]
fn test_approval_view_view_params() {
let params = json!({"path": "src/main.rs", "content": "test"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let mut view = ApprovalView::new(request.clone());
// Test 'v' to view params
let action = view.handle_key(create_key_event(KeyCode::Char('v')));
assert!(matches!(
action,
ViewAction::Emit(ViewEvent::OpenTextPager { .. })
));
// Test 'V' (uppercase) also works
let mut view = ApprovalView::new(request.clone());
let action = view.handle_key(create_key_event(KeyCode::Char('V')));
assert!(matches!(
action,
ViewAction::Emit(ViewEvent::OpenTextPager { .. })
));
}
#[test]
fn test_approval_view_current_decision_mapping() {
let params = json!({"path": "src/main.rs"});
let request = ApprovalRequest::new("test-id", "read_file", &params);
let mut view = ApprovalView::new(request);
// Index 0 -> Approved
view.selected = 0;
assert_eq!(view.current_decision(), ReviewDecision::Approved);
// Index 1 -> ApprovedForSession
view.selected = 1;
assert_eq!(view.current_decision(), ReviewDecision::ApprovedForSession);
// Index 2 -> Denied
view.selected = 2;
assert_eq!(view.current_decision(), ReviewDecision::Denied);
// Index 3 -> Abort
view.selected = 3;
assert_eq!(view.current_decision(), ReviewDecision::Abort);
}
// ========================================================================
// ElevationView Tests
// ========================================================================
#[test]
fn test_elevation_view_initial_state() {
let request =
ElevationRequest::for_shell("test-id", "cargo build", "network blocked", true, false);
let view = ElevationView::new(request);
assert_eq!(view.selected, 0);
}
#[test]
fn test_elevation_view_keybindings() {
let request =
ElevationRequest::for_shell("test-id", "cargo test", "write blocked", false, true);
let mut view = ElevationView::new(request);
// Test 'n' -> WithNetwork
let action = view.handle_key(create_key_event(KeyCode::Char('n')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::WithNetwork,
..
})
));
// Test 'w' -> WithWriteAccess
let request =
ElevationRequest::for_shell("test-id", "cargo build", "write blocked", false, true);
let mut view = ElevationView::new(request);
let action = view.handle_key(create_key_event(KeyCode::Char('w')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::WithWriteAccess(_),
..
})
));
// Test 'f' -> FullAccess
let request =
ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
let mut view = ElevationView::new(request);
let action = view.handle_key(create_key_event(KeyCode::Char('f')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::FullAccess,
..
})
));
// Test Esc -> Abort
let request =
ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
let mut view = ElevationView::new(request);
let action = view.handle_key(create_key_event(KeyCode::Esc));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::Abort,
..
})
));
// Test 'a' -> Abort (alternative)
let request =
ElevationRequest::for_shell("test-id", "cargo build", "blocked", false, false);
let mut view = ElevationView::new(request);
let action = view.handle_key(create_key_event(KeyCode::Char('a')));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::Abort,
..
})
));
}
#[test]
fn test_elevation_view_navigation() {
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
let mut view = ElevationView::new(request);
// Initially at 0
assert_eq!(view.selected, 0);
// Navigate down
view.handle_key(create_key_event(KeyCode::Down));
assert_eq!(view.selected, 1);
// Navigate up
view.handle_key(create_key_event(KeyCode::Up));
assert_eq!(view.selected, 0);
// Test 'j' and 'k'
view.handle_key(create_key_event(KeyCode::Char('j')));
assert_eq!(view.selected, 1);
view.handle_key(create_key_event(KeyCode::Char('k')));
assert_eq!(view.selected, 0);
}
#[test]
fn test_elevation_view_enter_uses_selected_option() {
let request = ElevationRequest::for_shell("test-id", "cargo build", "blocked", true, false);
let mut view = ElevationView::new(request);
// Navigate to index 1
view.handle_key(create_key_event(KeyCode::Down));
assert_eq!(view.selected, 1);
// Press Enter
let action = view.handle_key(create_key_event(KeyCode::Enter));
assert!(matches!(
action,
ViewAction::EmitAndClose(ViewEvent::ElevationDecision {
option: ElevationOption::FullAccess,
..
})
));
}
// ========================================================================
// ElevationOption Tests
// ========================================================================
#[test]
fn test_elevation_option_labels() {
assert_eq!(ElevationOption::WithNetwork.label(), "Allow network access");
assert_eq!(
ElevationOption::FullAccess.label(),
"Full access (no sandbox)"
);
assert!(
ElevationOption::WithWriteAccess(vec![])
.label()
.contains("write")
);
assert_eq!(ElevationOption::Abort.label(), "Abort");
}
#[test]
fn test_elevation_option_descriptions() {
assert!(
ElevationOption::WithNetwork
.description()
.contains("network")
);
assert!(
ElevationOption::FullAccess
.description()
.contains("dangerous")
);
assert!(ElevationOption::Abort.description().contains("Cancel"));
}
#[test]
fn test_elevation_option_to_policy() {
let cwd = PathBuf::from("/tmp/test");
let policy = ElevationOption::WithNetwork.to_policy(&cwd);
assert!(matches!(
policy,
SandboxPolicy::WorkspaceWrite {
network_access: true,
..
}
));
let policy = ElevationOption::FullAccess.to_policy(&cwd);
assert!(matches!(policy, SandboxPolicy::DangerFullAccess));
let paths = vec![PathBuf::from("/tmp/test/src")];
let policy = ElevationOption::WithWriteAccess(paths).to_policy(&cwd);
assert!(matches!(policy, SandboxPolicy::WorkspaceWrite { .. }));
}
// ========================================================================
// ElevationRequest Tests
// ========================================================================
#[test]
fn test_elevation_request_for_shell_with_network_block() {
let request = ElevationRequest::for_shell(
"test-id",
"curl example.com",
"network blocked",
true,
false,
);
assert_eq!(request.tool_id, "test-id");
assert_eq!(request.tool_name, "exec_shell");
assert!(request.command.is_some());
assert!(request.denial_reason.contains("network"));
assert!(
request
.options
.iter()
.any(|o| matches!(o, ElevationOption::WithNetwork))
);
}
#[test]
fn test_elevation_request_for_shell_with_write_block() {
let request =
ElevationRequest::for_shell("test-id", "rm -rf /tmp", "write blocked", false, true);
assert_eq!(request.tool_id, "test-id");
assert!(
request
.options
.iter()
.any(|o| matches!(o, ElevationOption::WithWriteAccess(_)))
);
}
#[test]
fn test_elevation_request_generic() {
let request = ElevationRequest::generic("test-id", "some_tool", "permission denied");
assert_eq!(request.tool_id, "test-id");
assert_eq!(request.tool_name, "some_tool");
assert!(request.command.is_none());
assert!(
request
.options
.iter()
.any(|o| matches!(o, ElevationOption::WithNetwork))
);
assert!(
request
.options
.iter()
.any(|o| matches!(o, ElevationOption::FullAccess))
);
assert!(
request
.options
.iter()
.any(|o| matches!(o, ElevationOption::Abort))
);
}
// ========================================================================
// ApprovalMode Tests
// ========================================================================
#[test]
fn test_approval_mode_labels() {
assert_eq!(ApprovalMode::Auto.label(), "AUTO");
assert_eq!(ApprovalMode::Suggest.label(), "SUGGEST");
assert_eq!(ApprovalMode::Never.label(), "NEVER");
}
}
+245
View File
@@ -0,0 +1,245 @@
//! Command palette modal for quick command/skill insertion.
use std::path::Path;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::Rect,
prelude::Stylize,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
};
use unicode_width::UnicodeWidthStr;
use crate::commands;
use crate::palette;
use crate::skills::SkillRegistry;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
#[derive(Debug, Clone)]
pub struct CommandPaletteEntry {
pub label: String,
pub description: String,
pub command: String,
}
pub struct CommandPaletteView {
entries: Vec<CommandPaletteEntry>,
filtered: Vec<usize>,
query: String,
selected: usize,
}
pub fn build_entries(skills_dir: &Path) -> Vec<CommandPaletteEntry> {
let mut entries = Vec::new();
for command in commands::COMMANDS {
let requires_args = command.usage.contains('<');
let command_text = if requires_args {
format!("/{} ", command.name)
} else {
format!("/{}", command.name)
};
entries.push(CommandPaletteEntry {
label: format!("/{}", command.name),
description: command.description.to_string(),
command: command_text,
});
}
let skills = SkillRegistry::discover(skills_dir);
for skill in skills.list() {
entries.push(CommandPaletteEntry {
label: format!("skill:{}", skill.name),
description: skill.description.clone(),
command: format!("/skill {}", skill.name),
});
}
entries.sort_by(|a, b| a.label.cmp(&b.label));
entries
}
impl CommandPaletteView {
pub fn new(entries: Vec<CommandPaletteEntry>) -> Self {
let mut view = Self {
entries,
filtered: Vec::new(),
query: String::new(),
selected: 0,
};
view.refilter();
view
}
fn refilter(&mut self) {
let query = self.query.trim().to_ascii_lowercase();
self.filtered = self
.entries
.iter()
.enumerate()
.filter_map(|(idx, entry)| {
if query.is_empty()
|| entry.label.to_ascii_lowercase().contains(&query)
|| entry.description.to_ascii_lowercase().contains(&query)
|| entry.command.to_ascii_lowercase().contains(&query)
{
Some(idx)
} else {
None
}
})
.collect();
if self.selected >= self.filtered.len() {
self.selected = 0;
}
}
fn move_selection(&mut self, delta: isize) {
if self.filtered.is_empty() {
self.selected = 0;
return;
}
let len = self.filtered.len() as isize;
let next = (self.selected as isize + delta).clamp(0, len - 1) as usize;
self.selected = next;
}
fn selected_entry(&self) -> Option<&CommandPaletteEntry> {
self.filtered
.get(self.selected)
.and_then(|idx| self.entries.get(*idx))
}
}
impl ModalView for CommandPaletteView {
fn kind(&self) -> ModalKind {
ModalKind::CommandPalette
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => ViewAction::Close,
KeyCode::Enter => {
if let Some(entry) = self.selected_entry() {
ViewAction::EmitAndClose(ViewEvent::CommandPaletteSelected {
command: entry.command.clone(),
})
} else {
ViewAction::None
}
}
KeyCode::Up | KeyCode::Char('k') => {
self.move_selection(-1);
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_selection(1);
ViewAction::None
}
KeyCode::PageUp => {
self.move_selection(-8);
ViewAction::None
}
KeyCode::PageDown => {
self.move_selection(8);
ViewAction::None
}
KeyCode::Backspace => {
self.query.pop();
self.refilter();
ViewAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
{
self.query.push(c);
self.refilter();
ViewAction::None
}
_ => ViewAction::None,
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup_width = 90.min(area.width.saturating_sub(4));
let popup_height = 22.min(area.height.saturating_sub(4));
let popup_area = Rect {
x: (area.width.saturating_sub(popup_width)) / 2,
y: (area.height.saturating_sub(popup_height)) / 2,
width: popup_width,
height: popup_height,
};
Clear.render(popup_area, buf);
let mut lines = Vec::new();
let query_label = if self.query.is_empty() {
"Type to filter…".to_string()
} else {
format!("Filter: {}", self.query)
};
lines.push(Line::from(Span::styled(
query_label,
Style::default().fg(palette::TEXT_MUTED),
)));
lines.push(Line::from(""));
let visible = popup_height.saturating_sub(5) as usize;
if self.filtered.is_empty() {
lines.push(Line::from(Span::styled(
"No matches.",
Style::default().fg(palette::TEXT_MUTED).italic(),
)));
} else {
let start = self.selected.saturating_sub(visible.saturating_sub(1));
let end = (start + visible).min(self.filtered.len());
for (slot, idx) in self.filtered[start..end].iter().enumerate() {
let absolute = start + slot;
let is_selected = absolute == self.selected;
let entry = &self.entries[*idx];
let style = if is_selected {
Style::default()
.fg(palette::DEEPSEEK_SKY)
.add_modifier(Modifier::REVERSED)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let mut line = format!("{:<24}", entry.label);
let desc = if entry.description.width() > 56 {
let mut shortened = String::new();
for ch in entry.description.chars() {
if shortened.width() >= 53 {
break;
}
shortened.push(ch);
}
format!("{shortened}...")
} else {
entry.description.clone()
};
line.push_str(" ");
line.push_str(&desc);
lines.push(Line::from(Span::styled(line, style)));
}
}
let block = Block::default()
.title(" Command Palette ")
.title_bottom(Line::from(vec![
Span::styled(" Enter insert ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled("Esc close", Style::default().fg(palette::TEXT_MUTED)),
]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::DEEPSEEK_SKY));
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.render(popup_area, buf);
}
}
+70 -33
View File
@@ -53,7 +53,7 @@ impl HistoryCell {
match self {
HistoryCell::User { content } => render_message("You", content, user_style(), width),
HistoryCell::Assistant { content, streaming } => {
let mut lines = render_message("DeepSeek", content, assistant_style(), width);
let mut lines = render_message("Answer", content, assistant_style(), width);
if *streaming {
// Add blinking cursor to last line
if let Some(last) = lines.last_mut() {
@@ -69,7 +69,7 @@ impl HistoryCell {
render_message("System", content, system_style(), width)
}
HistoryCell::Thinking { content, streaming } => {
let mut lines = render_thinking(content, width);
let mut lines = render_thinking(content, width, *streaming);
if *streaming {
if let Some(last) = lines.last_mut() {
last.spans.push(Span::styled(
@@ -123,40 +123,66 @@ impl HistoryCell {
#[must_use]
pub fn history_cells_from_message(msg: &Message) -> Vec<HistoryCell> {
let mut cells = Vec::new();
let mut text_blocks = Vec::new();
let mut thinking_blocks = Vec::new();
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => text_blocks.push(text.clone()),
ContentBlock::Thinking { thinking } => thinking_blocks.push(thinking.clone()),
_ => {}
}
}
if !text_blocks.is_empty() {
let content = text_blocks.join("\n");
match msg.role.as_str() {
"user" => cells.push(HistoryCell::User { content }),
"assistant" => {
cells.push(HistoryCell::Assistant {
content,
streaming: false,
});
ContentBlock::Text { text, .. } => match msg.role.as_str() {
"user" => {
if let Some(HistoryCell::User { content }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(text);
} else {
cells.push(HistoryCell::User {
content: text.clone(),
});
}
}
"assistant" => {
if let Some(HistoryCell::Assistant { content, .. }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(text);
} else {
cells.push(HistoryCell::Assistant {
content: text.clone(),
streaming: false,
});
}
}
"system" => {
if let Some(HistoryCell::System { content }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(text);
} else {
cells.push(HistoryCell::System {
content: text.clone(),
});
}
}
_ => {}
},
ContentBlock::Thinking { thinking } => {
if let Some(HistoryCell::Thinking { content, .. }) = cells.last_mut() {
if !content.is_empty() {
content.push('\n');
}
content.push_str(thinking);
} else {
cells.push(HistoryCell::Thinking {
content: thinking.clone(),
streaming: false,
});
}
}
"system" => cells.push(HistoryCell::System { content }),
_ => {}
}
}
if !thinking_blocks.is_empty() {
let reasoning = thinking_blocks.join("\n");
cells.push(HistoryCell::Thinking {
content: reasoning,
streaming: false,
});
}
cells
}
@@ -1012,14 +1038,25 @@ pub fn extract_reasoning_summary(text: &str) -> Option<String> {
}
}
fn render_thinking(content: &str, width: u16) -> Vec<Line<'static>> {
fn render_thinking(content: &str, width: u16, streaming: bool) -> Vec<Line<'static>> {
let style = thinking_style();
let prefix = " ";
let prefix = " ";
let label = if streaming {
"[THINKING LIVE]"
} else {
"[THINKING]"
};
let content_width = usize::from(width.saturating_sub(2).max(1));
let rendered = markdown_render::render_markdown(content, content_width as u16, style);
let mut lines = Vec::new();
lines.push(Line::from(Span::styled(
label,
Style::default()
.fg(palette::STATUS_WARNING)
.add_modifier(Modifier::BOLD),
)));
for line in rendered {
let mut spans = vec![Span::styled(prefix, style)];
@@ -1240,7 +1277,7 @@ fn status_symbol(started_at: Option<Instant>, status: ToolStatus) -> String {
match status {
ToolStatus::Running => {
let elapsed_ms = started_at.map_or(0, |t| t.elapsed().as_millis());
if (elapsed_ms / 400).is_multiple_of(2) {
if (elapsed_ms / 900).is_multiple_of(2) {
"*".to_string()
} else {
".".to_string()
@@ -1279,8 +1316,8 @@ fn system_style() -> Style {
fn thinking_style() -> Style {
Style::default()
.fg(palette::TEXT_MUTED)
.add_modifier(Modifier::ITALIC | Modifier::DIM)
.fg(palette::TEXT_PRIMARY)
.add_modifier(Modifier::ITALIC)
}
#[cfg(test)]
+2
View File
@@ -5,6 +5,7 @@
pub mod app;
pub mod approval;
pub mod clipboard;
pub mod command_palette;
pub mod diff_render;
pub mod event_broker;
pub mod history;
@@ -18,6 +19,7 @@ pub mod session_picker;
pub mod streaming;
pub mod transcript;
pub mod ui;
mod ui_text;
pub mod user_input;
pub mod views;
pub mod widgets;
+5 -2
View File
@@ -149,7 +149,10 @@ impl SessionPickerView {
self.sessions.retain(|s| s.id != session.id);
self.apply_sort_and_filter();
self.refresh_preview();
self.status = Some(format!("Deleted session {}", &session.id[..8]));
self.status = Some(format!(
"Deleted session {}",
&session.id[..8.min(session.id.len())]
));
Some(ViewEvent::SessionDeleted {
session_id: session.id,
title: session.title,
@@ -405,7 +408,7 @@ fn format_session_line(session: &SessionMetadata) -> String {
.to_ascii_lowercase();
format!(
"{} | {} | {} msgs | {} | {}",
&session.id[..8],
&session.id[..8.min(session.id.len())],
title,
session.message_count,
mode,
+241
View File
@@ -0,0 +1,241 @@
use super::*;
use crate::config::Config;
use std::path::PathBuf;
#[test]
fn selection_point_from_position_ignores_top_padding() {
let area = Rect {
x: 10,
y: 20,
width: 30,
height: 5,
};
// Content is bottom-aligned: 2 transcript lines in a 5-row viewport.
let padding_top = 3;
let transcript_top = 0;
let transcript_total = 2;
// Click in padding area -> no selection
assert!(
selection_point_from_position(
area,
area.x + 1,
area.y,
transcript_top,
transcript_total,
padding_top,
)
.is_none()
);
// First transcript line is at row `padding_top`
let p0 = selection_point_from_position(
area,
area.x + 2,
area.y + u16::try_from(padding_top).expect("padding should fit"),
transcript_top,
transcript_total,
padding_top,
)
.expect("point");
assert_eq!(p0.line_index, 0);
assert_eq!(p0.column, 2);
// Second transcript line is one row below
let p1 = selection_point_from_position(
area,
area.x,
area.y + u16::try_from(padding_top + 1).expect("padding should fit"),
transcript_top,
transcript_total,
padding_top,
)
.expect("point");
assert_eq!(p1.line_index, 1);
assert_eq!(p1.column, 0);
}
#[test]
fn parse_plan_choice_accepts_numbers() {
assert_eq!(parse_plan_choice("1"), Some(PlanChoice::ImplementAgent));
assert_eq!(parse_plan_choice("2"), Some(PlanChoice::ImplementYolo));
assert_eq!(parse_plan_choice("3"), Some(PlanChoice::RevisePlan));
assert_eq!(parse_plan_choice("4"), Some(PlanChoice::ExitPlan));
}
#[test]
fn parse_plan_choice_accepts_aliases() {
assert_eq!(parse_plan_choice("agent"), Some(PlanChoice::ImplementAgent));
assert_eq!(parse_plan_choice("yolo"), Some(PlanChoice::ImplementYolo));
assert_eq!(parse_plan_choice("revise"), Some(PlanChoice::RevisePlan));
assert_eq!(parse_plan_choice("exit"), Some(PlanChoice::ExitPlan));
assert_eq!(parse_plan_choice("unknown"), None);
}
#[test]
fn transcript_scroll_percent_is_clamped_and_relative() {
assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));
assert_eq!(transcript_scroll_percent(50, 20, 120), Some(50));
assert_eq!(transcript_scroll_percent(200, 20, 120), Some(100));
assert_eq!(transcript_scroll_percent(0, 20, 20), None);
}
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-reasoner".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: true,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
notes_path: PathBuf::from("notes.txt"),
mcp_config_path: PathBuf::from("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: false,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn format_token_count_compact_formats_units() {
assert_eq!(format_token_count_compact(999), "999");
assert_eq!(format_token_count_compact(1_200), "1.2k");
assert_eq!(format_token_count_compact(1_000_000), "1.0M");
}
#[test]
fn should_auto_compact_before_send_respects_threshold_and_setting() {
let mut app = create_test_app();
app.last_prompt_tokens = Some(123_000);
app.auto_compact = true;
assert!(should_auto_compact_before_send(&app));
app.auto_compact = false;
assert!(!should_auto_compact_before_send(&app));
app.auto_compact = true;
app.last_prompt_tokens = Some(10_000);
assert!(!should_auto_compact_before_send(&app));
}
// ============================================================================
// Streaming Cancel Behavior Tests
// ============================================================================
#[test]
fn test_esc_cancels_streaming_sets_is_loading_false() {
let mut app = create_test_app();
app.is_loading = true;
app.mode = AppMode::Agent;
// Simulate what happens in ui.rs when Esc is pressed during loading:
// engine_handle.cancel() is called (can't test directly - private)
// Then these state changes occur:
app.is_loading = false;
app.status_message = Some("Request cancelled".to_string());
assert!(!app.is_loading);
assert_eq!(app.status_message, Some("Request cancelled".to_string()));
}
#[test]
fn test_esc_with_input_clears_input_when_not_loading() {
let mut app = create_test_app();
app.is_loading = false;
app.input = "some draft input".to_string();
app.cursor_position = app.input.chars().count();
// Simulate Esc key press when not loading but input not empty
app.clear_input();
assert!(app.input.is_empty());
assert_eq!(app.cursor_position, 0);
assert!(!app.is_loading);
}
#[test]
fn test_esc_switches_to_normal_mode_when_idle() {
let mut app = create_test_app();
app.is_loading = false;
app.input.clear();
app.cursor_position = 0;
app.mode = AppMode::Agent;
// Simulate Esc key press when not loading and input empty
app.set_mode(AppMode::Normal);
assert_eq!(app.mode, AppMode::Normal);
assert!(!app.is_loading);
assert!(app.input.is_empty());
}
#[test]
fn test_ctrl_c_cancels_streaming_sets_status() {
let mut app = create_test_app();
app.is_loading = true;
// Simulate Ctrl+C during loading state
// engine_handle.cancel() is called (can't test directly - private)
app.is_loading = false;
app.status_message = Some("Request cancelled".to_string());
assert!(!app.is_loading);
assert_eq!(app.status_message, Some("Request cancelled".to_string()));
}
#[test]
fn test_ctrl_c_exits_when_not_loading() {
let mut app = create_test_app();
app.is_loading = false;
// Ctrl+C when not loading should trigger shutdown
// We can't test the actual shutdown, but verify the state is correct
// for the shutdown path to be taken
assert!(!app.is_loading);
}
#[test]
fn test_ctrl_d_exits_when_input_empty() {
let mut app = create_test_app();
app.input.clear();
// Ctrl+D when input empty should trigger shutdown
assert!(app.input.is_empty());
}
#[test]
fn test_ctrl_d_does_nothing_when_input_not_empty() {
let mut app = create_test_app();
app.input = "some input".to_string();
// Ctrl+D when input not empty should not trigger shutdown
assert!(!app.input.is_empty());
}
#[test]
fn test_esc_priority_order_loading_then_input_then_mode() {
// Test 1: Loading state takes priority
let mut app = create_test_app();
app.is_loading = true;
app.input = "draft".to_string();
app.mode = AppMode::Yolo;
// Should cancel request (not clear input or change mode)
assert!(app.is_loading);
// Test 2: Input not empty takes priority when not loading
app.is_loading = false;
assert!(!app.input.is_empty());
// Should clear input (not change mode)
// Test 3: Change mode when not loading and input empty
app.input.clear();
app.mode = AppMode::Yolo;
assert!(app.input.is_empty());
assert_eq!(app.mode, AppMode::Yolo);
// Should change to Normal mode
}
+42
View File
@@ -0,0 +1,42 @@
//! Shared text helpers for TUI selection and clipboard workflows.
use ratatui::text::Line;
use crate::tui::history::HistoryCell;
pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
cell.lines(width)
.into_iter()
.map(line_to_string)
.collect::<Vec<_>>()
.join("\n")
}
fn line_to_string(line: Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
}
pub(super) fn line_to_plain(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
pub(super) fn slice_text(text: &str, start: usize, end: usize) -> String {
let mut out = String::new();
let mut idx = 0usize;
for ch in text.chars() {
if idx >= start && idx < end {
out.push(ch);
}
idx += 1;
if idx >= end {
break;
}
}
out
}
+11 -1
View File
@@ -12,6 +12,7 @@ pub enum ModalKind {
Approval,
Elevation,
UserInput,
CommandPalette,
Help,
SubAgents,
Pager,
@@ -20,6 +21,13 @@ pub enum ModalKind {
#[derive(Debug, Clone)]
pub enum ViewEvent {
CommandPaletteSelected {
command: String,
},
OpenTextPager {
title: String,
content: String,
},
ApprovalDecision {
tool_id: String,
tool_name: String,
@@ -244,14 +252,16 @@ impl ModalView for HelpView {
Line::from(" Esc - Cancel request / clear input"),
Line::from(" Ctrl+C - Cancel request or exit application"),
Line::from(" Ctrl+D - Exit when input is empty"),
Line::from(" Ctrl+K - Open command palette"),
Line::from(" l - Open pager for last message (when input empty)"),
Line::from(" v - Open tool details (when input empty)"),
Line::from(" Enter (selection) - Open pager for selected text"),
Line::from(""),
Line::from(vec![Span::styled(
"=== Modes ===",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]),
Line::from(" Tab - Cycle through modes"),
Line::from(" Tab - Complete /command or cycle modes"),
Line::from(" Ctrl+X - Toggle between Agent and Normal modes"),
Line::from(""),
Line::from(vec![Span::styled(
+21 -9
View File
@@ -31,12 +31,14 @@ pub struct HeaderData<'a> {
pub mode: AppMode,
pub is_streaming: bool,
pub background: ratatui::style::Color,
/// Total tokens used in this session.
/// Total tokens used in this session (cumulative, for display).
pub total_tokens: u32,
/// Context window size for the model (if known).
pub context_window: Option<u32>,
/// Accumulated session cost in USD.
pub session_cost: f64,
/// Input tokens from the most recent API call (current context utilization).
pub last_prompt_tokens: Option<u32>,
}
impl<'a> HeaderData<'a> {
@@ -56,6 +58,7 @@ impl<'a> HeaderData<'a> {
total_tokens: 0,
context_window: None,
session_cost: 0.0,
last_prompt_tokens: None,
}
}
@@ -66,10 +69,12 @@ impl<'a> HeaderData<'a> {
total_tokens: u32,
context_window: Option<u32>,
session_cost: f64,
last_prompt_tokens: Option<u32>,
) -> Self {
self.total_tokens = total_tokens;
self.context_window = context_window;
self.session_cost = session_cost;
self.last_prompt_tokens = last_prompt_tokens;
self
}
}
@@ -109,9 +114,10 @@ impl<'a> HeaderWidget<'a> {
/// Build the model name span.
fn model_span(&self) -> Span<'static> {
// Truncate long model names
let display_name = if self.data.model.len() > 25 {
format!("{}...", &self.data.model[..22])
// Truncate long model names (char-safe to avoid panics on multi-byte UTF-8)
let display_name = if self.data.model.chars().count() > 25 {
let truncated: String = self.data.model.chars().take(22).collect();
format!("{truncated}...")
} else {
self.data.model.to_string()
};
@@ -144,10 +150,15 @@ impl<'a> HeaderWidget<'a> {
// Token count with context window percentage
if self.data.total_tokens > 0 {
let token_str = format_token_count(self.data.total_tokens);
// Use last_prompt_tokens for % (current context utilization)
if let Some(ctx_window) = self.data.context_window {
let pct = (self.data.total_tokens as f64 / ctx_window as f64 * 100.0) as u32;
let pct_str = format!("{token_str} ({pct}%)");
parts.push(pct_str);
if let Some(prompt_tokens) = self.data.last_prompt_tokens {
let pct = (prompt_tokens as f64 / ctx_window as f64 * 100.0) as u32;
let pct_str = format!("{token_str} ({pct}%)");
parts.push(pct_str);
} else {
parts.push(token_str);
}
} else {
parts.push(token_str);
}
@@ -233,8 +244,9 @@ impl Renderable for HeaderWidget<'_> {
spans.push(Span::raw(" "));
// Truncate model if needed
let model_str = self.data.model;
let display_model = if model_str.len() > 10 {
format!("{}...", &model_str[..7])
let display_model = if model_str.chars().count() > 10 {
let truncated: String = model_str.chars().take(7).collect();
format!("{truncated}...")
} else {
model_str.to_string()
};
+62 -83
View File
@@ -8,6 +8,7 @@ use crate::palette;
use crate::tui::app::App;
use crate::tui::approval::{ApprovalRequest, ElevationOption, ElevationRequest, ToolCategory};
use crate::tui::scrolling::TranscriptScroll;
use crate::{commands, config::COMMON_DEEPSEEK_MODELS};
use ratatui::{
buffer::Buffer,
layout::Rect,
@@ -21,16 +22,12 @@ use unicode_width::UnicodeWidthStr;
pub struct ChatWidget {
content_area: Rect,
scrollbar_area: Option<Rect>,
lines: Vec<Line<'static>>,
total_lines: usize,
visible_lines: usize,
top: usize,
}
impl ChatWidget {
pub fn new(app: &mut App, area: Rect) -> Self {
let mut content_area = area;
let content_area = area;
let visible_lines = content_area.height as usize;
let render_options = app.transcript_render_options();
@@ -41,25 +38,7 @@ impl ChatWidget {
render_options,
);
let mut total_lines = app.transcript_cache.total_lines();
let mut scrollbar_area = None;
if total_lines > visible_lines && content_area.width > 1 {
scrollbar_area = Some(Rect {
x: content_area.x + content_area.width.saturating_sub(1),
y: content_area.y,
width: 1,
height: content_area.height,
});
content_area.width = content_area.width.saturating_sub(1).max(1);
app.transcript_cache.ensure(
&app.history,
content_area.width.max(1),
app.history_version,
render_options,
);
total_lines = app.transcript_cache.total_lines();
}
let total_lines = app.transcript_cache.total_lines();
let line_meta = app.transcript_cache.line_meta();
@@ -98,11 +77,7 @@ impl ChatWidget {
Self {
content_area,
scrollbar_area,
lines,
total_lines,
visible_lines,
top,
}
}
}
@@ -111,10 +86,6 @@ impl Renderable for ChatWidget {
fn render(&self, _area: Rect, buf: &mut Buffer) {
let paragraph = Paragraph::new(self.lines.clone());
paragraph.render(self.content_area, buf);
if let Some(area) = self.scrollbar_area {
render_scrollbar(buf, area, self.total_lines, self.visible_lines, self.top);
}
}
fn desired_height(&self, _width: u16) -> u16 {
@@ -122,53 +93,6 @@ impl Renderable for ChatWidget {
}
}
fn render_scrollbar(
buf: &mut Buffer,
area: Rect,
total_lines: usize,
visible_lines: usize,
top: usize,
) {
if area.width == 0 || area.height == 0 || total_lines == 0 {
return;
}
let height = area.height as usize;
let track_style = Style::default().fg(palette::TEXT_DIM);
let thumb_style = Style::default().fg(palette::DEEPSEEK_SKY);
for row in 0..height {
if let Some(cell) = buf.cell_mut((area.x, area.y + row as u16)) {
cell.set_symbol("").set_style(track_style);
}
}
if total_lines <= visible_lines {
return;
}
let thumb_height = ((visible_lines as f32 / total_lines as f32) * height as f32)
.ceil()
.clamp(1.0, height as f32) as usize;
let max_top = total_lines.saturating_sub(visible_lines);
let thumb_top = if max_top == 0 {
0
} else {
let available = height.saturating_sub(thumb_height);
let ratio = top as f32 / max_top as f32;
(ratio * available as f32).round() as usize
};
for row in thumb_top..thumb_top.saturating_add(thumb_height) {
if row >= height {
break;
}
if let Some(cell) = buf.cell_mut((area.x, area.y + row as u16)) {
cell.set_symbol("").set_style(thumb_style);
}
}
}
pub struct ComposerWidget<'a> {
app: &'a App,
prompt: &'a str,
@@ -187,10 +111,12 @@ impl<'a> ComposerWidget<'a> {
impl Renderable for ComposerWidget<'_> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let command_hints = slash_completion_hints(&self.app.input, 5);
let prompt_width = self.prompt.width();
let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX);
let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1));
let max_height = usize::from(area.height);
let hint_lines = usize::from(!command_hints.is_empty());
let max_height = usize::from(area.height).saturating_sub(hint_lines).max(1);
let continuation = " ".repeat(prompt_width);
let (visible_lines, _cursor_row, _cursor_col) = layout_input(
@@ -231,19 +157,41 @@ impl Renderable for ComposerWidget<'_> {
}
}
if !command_hints.is_empty() {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().fg(palette::TEXT_MUTED)),
Span::styled(
"Tab complete: ",
Style::default().fg(palette::TEXT_MUTED).italic(),
),
Span::styled(
command_hints.join(" "),
Style::default().fg(palette::DEEPSEEK_SKY),
),
]));
}
let paragraph = Paragraph::new(lines).style(background);
paragraph.render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
composer_height(&self.app.input, width, self.max_height, self.prompt)
let hint_lines = usize::from(!slash_completion_hints(&self.app.input, 5).is_empty());
composer_height(
&self.app.input,
width,
self.max_height,
self.prompt,
hint_lines,
)
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let hint_lines = usize::from(!slash_completion_hints(&self.app.input, 5).is_empty());
let prompt_width = self.prompt.width();
let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX);
let content_width = usize::from(area.width.saturating_sub(prompt_width_u16).max(1));
let max_height = usize::from(area.height);
let max_height = usize::from(area.height).saturating_sub(hint_lines).max(1);
let (_visible_lines, cursor_row, cursor_col) = layout_input(
&self.app.input,
@@ -356,6 +304,7 @@ impl Renderable for ApprovalWidget<'_> {
("y", "Approve (this time)"),
("a", "Approve for session"),
("n", "Deny"),
("v", "View full params"),
("Esc", "Abort turn"),
];
@@ -623,7 +572,13 @@ fn apply_selection_to_line(
result
}
fn composer_height(input: &str, width: u16, available_height: u16, prompt: &str) -> u16 {
fn composer_height(
input: &str,
width: u16,
available_height: u16,
prompt: &str,
extra_lines: usize,
) -> u16 {
let prompt_width = prompt.width();
let prompt_width_u16 = u16::try_from(prompt_width).unwrap_or(u16::MAX);
let content_width = usize::from(width.saturating_sub(prompt_width_u16).max(1));
@@ -631,10 +586,34 @@ fn composer_height(input: &str, width: u16, available_height: u16, prompt: &str)
if line_count == 0 {
line_count = 1;
}
line_count = line_count.saturating_add(extra_lines);
let max_height = usize::from(available_height.clamp(1, 8));
line_count.clamp(1, max_height).try_into().unwrap_or(1)
}
fn slash_completion_hints(input: &str, limit: usize) -> Vec<String> {
if !input.starts_with('/') || input.contains(char::is_whitespace) {
return Vec::new();
}
let prefix = input.trim_start_matches('/');
let mut hints = commands::commands_matching(prefix)
.into_iter()
.map(|info| format!("/{}", info.name))
.collect::<Vec<_>>();
if hints.is_empty() && prefix.eq_ignore_ascii_case("model") {
hints = COMMON_DEEPSEEK_MODELS
.iter()
.map(|name| format!("/model {name}"))
.collect();
}
hints.sort();
hints.dedup();
hints.into_iter().take(limit).collect()
}
fn layout_input(
input: &str,
cursor: usize,