Compare commits

...
36 Commits
Author SHA1 Message Date
Hunter Bown 91721d8a17 Fix context usage display and config path/session overrides 2026-02-17 16:24:57 -06:00
Hunter Bown 1a04659a95 Add capacity memory controller and smoother TUI streaming 2026-02-17 16:09:07 -06:00
Hunter Bown 87884a1e84 Improve model handling, context recovery, and stabilize config tests 2026-02-16 17:57:22 -06:00
Hunter Bown ccefab8d44 docs: clean up README, remove roadmap.md
- Rewrite README to be concise and user-focused
- Replace verbose tool inventory with capability summary
- Move Runtime API details to docs link
- Trim troubleshooting, config, and keyboard shortcuts
- Remove roadmap.md
2026-02-16 12:24:01 -06:00
Hunter Bown 5f90e1685e fix: resolve CI failures — fmt + remove unused function
- Run cargo fmt to fix trailing whitespace and formatting
- Remove unused `get_compaction_instruction()` that triggered -D warnings
2026-02-16 12:22:14 -06:00
Hunter Bown b556559fb4 chore: v0.3.18 — expand docs, remove PARITY.md, clean up gitignore
- Remove PARITY.md (no longer needed)
- Expand README tools section with full tool inventory and troubleshooting entries
- Add known-issue notes to AGENTS.md (finance tool, token tracking, web.run naming)
- Update .gitignore with additional temp file patterns
- Bump version to 0.3.18
2026-02-16 12:19:50 -06:00
Hunter Bown 19a9724008 fix: reload engine after onboarding API key save 2026-02-16 11:43:14 -06:00
Hunter Bown 4212ba9f26 fix: restore API key config resolution and clean runtime artifacts 2026-02-16 11:37:14 -06:00
Hunter Bown 6c05c3b3ac feat: agent-controlled context compaction (Claude Code style)
- Add agent-facing instructions about /compact command in system prompt
- Enhance compaction summary with workflow context (files, tools, tasks)
- Add 'What to Do Next' guidance after compaction
- Change /compact to trigger immediate compaction (not toggle)
- Extract workflow context from tool usage and messages
- Lower auto-compaction threshold to 85% for earlier triggering

The agent now knows it can use /compact when context gets long,
similar to Claude Code's approach.
2026-02-16 10:58:11 -06:00
Hunter Bown ab2c708ca7 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
2026-02-16 10:51:39 -06:00
Hunter Bown fa3905041d fix: auto-compaction not triggering and doctor API key check
- Fix doctor command to properly validate DEEPSEEK_API_KEY (check non-empty)
- Lower auto-compaction threshold from 95% to 85% to trigger earlier
- Fix should_compact() to always trigger when over token threshold
  (even with few unpinned messages)

This fixes the issue where auto-compaction wasn't working and users
would hit context length errors.
2026-02-16 10:50:25 -06:00
Hunter Bown ab87765c26 fix: use is_multiple_of() to satisfy clippy 1.93 2026-02-08 12:50:44 -06:00
Hunter Bown b20937ba55 fix: remove extra blank line to pass cargo fmt check 2026-02-08 12:48:16 -06:00
Hunter Bown 6c55d80dea feat: world-class TUI improvements + deepseek-v3.2 default (v0.3.15)
- Real SSE streaming (token-by-token display via Chat Completions API)
- Token cost tracking with real DeepSeek pricing in header
- Context window usage percentage display
- Atomic session writes (crash safety via temp+rename)
- Responses API recovery (periodic probe instead of permanent fallback)
- Tool parse failure user-visible warnings
- Default model changed to deepseek-v3.2 everywhere
- V3.2 reasoning/thinking support
- V3.2 pricing and context window entries
2026-02-08 12:46:51 -06:00
Hunter Bown 4e516a7c1f Simplify UI and improve footer display (v0.3.14) (#2)
* Polish UI, fix bugs, and rewrite README for clarity

- Fix welcome banner generating a fake session ID (random UUID that
  didn't match the actual session); now shows tips and omits the
  misleading session line
- Fix footer redundancy: mode and model were shown in both the header
  and footer; footer now shows session ID, token count, and help hint
- Fix STATUS_SUCCESS and STATUS_WARNING being the same color
  (both DEEPSEEK_SKY), making success/warning states indistinguishable;
  success is now green, warning is amber
- Remove unprofessional thinking tagline ("You're absolutely right!
  ... maybe."); replaced with professional labels
- Fix README mode cycle documentation (said Normal->Plan->Agent->YOLO
  but code actually cycles Plan->Agent->YOLO->Plan)
- Fix README tool miscategorization: web.run, web_search,
  request_user_input, and multi_tool_use.parallel were listed under
  "File Operations" instead of their own categories
- Rewrite README with numbered getting-started steps, keyboard
  shortcuts table, cleaner tool categories, environment variable table,
  and compact troubleshooting table
- Remove unused imports (chrono::Local, uuid::Uuid, Color, Modifier)
  and dead code (mode_color, mode_badge_style)

https://claude.ai/code/session_011XiGZRhainhyvZ8BKKrfzj

* Update src/tui/app.rs
* Update README.md
2026-02-05 13:29:38 -06:00
Hunter Bown 702f9c353c Release v0.3.14
- Add image_query support to web.run (DuckDuckGo image search)
- Encode tool-call function names for Chat Completions history rebuild
- Allow safe MCP meta tools in multi_tool_use.parallel
- Update prompts for stronger citation placement and quote-limit guidance
2026-02-05 09:28:39 -06:00
Hunter Bown 4ded9e2a4f Restore transcript scrollbar 2026-02-03 19:05:44 -06:00
Hunter Bown 9f47d78a03 Release v0.3.12 2026-02-03 18:52:55 -06:00
Hunter Bown 67b371ca95 Encode tool names for DeepSeek API 2026-02-03 18:52:00 -06:00
Hunter Bown 858649d458 Fix tool name mapping for DeepSeek API 2026-02-03 18:45:49 -06:00
Hunter Bown 433d7cb6e9 Release v0.3.10 2026-02-03 18:31:53 -06:00
Hunter Bown e0bccecd5c Remove RLM/Duo modes and restore footer scroll 2026-02-03 18:29:36 -06:00
Hunter Bown 02ecc51e9c Fix clippy warnings 2026-02-03 17:42:48 -06:00
Hunter Bown 72d1c43d50 Apply rustfmt 2026-02-03 17:38:52 -06:00
Hunter Bown 9e3aafeabb Release 0.3.7 2026-02-03 17:36:17 -06:00
Hunter Bown 325aaefc00 Update tool parity and skills docs 2026-02-03 17:34:55 -06:00
Hunter Bown 62d2877b3b Release v0.3.6: UI polish - welcome banner, context progress bar, remove custom scrollbar 2026-02-02 12:05:48 -06:00
Hunter Bown 3f8d04178c feat: v0.3.5 — smart context offloading, full MCP protocol, Duo mode, project_map tool
- Intelligent context offloading: large tool results (>15k chars) auto-moved to RLM memory
- Persistent history context: compacted messages offloaded to RLM history variable
- Full MCP protocol: SSE transport, Resources (resources/list, resources/read), Prompts (prompts/list, prompts/get)
- mcp_read_resource and mcp_get_prompt virtual tools exposed to the model
- Dialectical Duo mode with Player/Coach TUI rendering
- Dynamic system prompt refresh at each turn for up-to-date RLM/Duo/working-set context
- project_map tool for automatic codebase structure discovery
- delegate_to_agent alias for streamlined sub-agent delegation
- Default theme changed to Whale with updated color palette
- Fix MCP test compilation for updated McpServerConfig struct shape
- Fix clippy warnings (strip_prefix, inspect_err, flatten, is_some_and)
2026-01-30 06:02:43 -06:00
Hunter Bown 18ce9f96f1 chore: update Cargo.lock for v0.3.4 2026-01-29 12:50:53 -06:00
Hunter Bown 5466cd7a19 fix: compaction tool-call pairing, safety net scanning, and double-dispatch race
- Set is_loading immediately in dispatch_user_message to prevent
  a race condition where the user could dispatch two messages before
  the TurnStarted event arrived
- Raise fixpoint loop cap in enforce_tool_call_pairs from 10 to
  messages.len() so long conversations converge; add warn logging
  on non-convergence
- Scan non-contiguous tool results in build_chat_messages safety net
  and drain orphaned results that aren't immediately adjacent
- Log specific missing tool IDs when stripping orphaned tool_calls
- Add tests for partial tool results and long-chain convergence
- Bump version to 0.3.4
2026-01-29 12:50:31 -06:00
Hunter Bown 3c3c3f49ce feat: polish TUI with Kimi-style footer, streaming thinking blocks, and cleanup
- Redesign footer: live clock, lowercase mode badges, color-coded context %
- Stream thinking/reasoning blocks in real-time with sidebar style and cursor
- Replace ThinkingSummary with richer Thinking variant in history cells
- Remove dead code (unused footer helpers, context bar, copy hint)
- Bump version to 0.3.3
2026-01-29 11:27:38 -06:00
Hunter Bown a7c5ef9374 Fix formatting 2026-01-28 10:05:25 -06:00
Hunter Bown 55148906f2 Fix clippy warnings
- Remove unnecessary to_string() in client.rs
- Derive Default for SubAgentType instead of manual impl
- Remove redundant let binding in header.rs
2026-01-28 10:01:50 -06:00
Hunter Bown df45aece8f Apply cargo fmt formatting fixes 2026-01-28 09:59:35 -06:00
Hunter Bown 32443ae91b Fix CI: Remove redundant toolchain input from dtolnay/rust-toolchain@stable
The dtolnay/rust-toolchain action already specifies the toolchain version
in the action reference (@stable). Adding toolchain: stable as an input
was causing 'toolchain is a required input' errors.

Fixed in:
- ci.yml
- release.yml
- crates-publish.yml
2026-01-28 09:58:28 -06:00
Hunter Bown 4ae8c8a3fa Fix CI toolchain inputs and normalize working set paths 2026-01-28 09:56:34 -06:00
104 changed files with 26428 additions and 7135 deletions

No files matched your search

View File
Whitespace-only changes.
+8
View File
@@ -50,7 +50,15 @@ TODO*.md
todo*.md
CLAUDE.md
NEXT_SESSION.md
AI_HANDOFF.md
result.json
count_deps.py
project_overhaul_prompt.md
.codex/
docs/rlm-paper.txt
.context/
# Local runtime state
.deepseek/
session_*.json
+48 -5
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.
@@ -21,13 +21,56 @@ See README.md for project overview.
This project uses Git. See .gitignore for excluded files.
## Advanced Capabilities
### Model Context Protocol (MCP)
This CLI supports MCP for extending tool access.
- Use `mcp_read_resource` to read context from external servers.
- Use `mcp_get_prompt` to leverage pre-defined expert prompts from servers.
- You can connect to HTTP/SSE servers by adding their URL to `mcp.json`.
### Multi-Agent Orchestration
For complex, multi-step tasks, you should delegate work:
- **Sub-agents**: Use `agent_spawn` (or its alias `delegate_to_agent`) to launch a background assistant for a specific sub-task. Use `agent_result` to get their output.
- **Swarms**: Use `agent_swarm` to orchestrate multiple sub-agents with dependencies. This is ideal for parallel exploration or complex refactoring where different parts of the project can be analyzed concurrently.
### Project Mapping
- Use `project_map` to get a comprehensive view of the codebase structure. This tool respects `.gitignore` and provides a summary of key files.
## Guidelines
- Follow existing code style and patterns
- Write tests for new functionality
- Keep changes focused and atomic
- Document public APIs
- **Proactive Investigation**: Always start by exploring the codebase using `project_map` and `file_search`.
- **Parallelism**: When you need to read multiple files or search across different areas, use parallel tool calls if possible.
- **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`.
- **Testing**: Rigorously verify changes using `cargo test` and `cargo check`.
## Important Notes
<!-- Add project-specific notes here -->
- **Finance tool currently unavailable**: The finance tool relies on Stooq which frequently returns no data. As a workaround, use `web.run` to fetch financial data from web sources.
- **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate.
- **Web.run tool name**: Note that the tool is named `web.run` (single dot), not `web..run`. Some earlier versions of the CLI may have had this typo.
### 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 OpenAI‑compatible 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 up‑to‑date 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 well‑tested.
Remember to run `cargo test` and `cargo check` after any changes.
+152 -4
View File
@@ -5,7 +5,143 @@ 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.17] - 2026-02-16
### Fixed
- Config loading now expands `~` in `DEEPSEEK_CONFIG_PATH` and `--config` paths.
- When `DEEPSEEK_CONFIG_PATH` points to a missing file, config loading now falls back to `~/.deepseek/config.toml` if it exists.
### Changed
- Removed committed transient runtime artifacts (`session_*.json`, `.deepseek/trusted`) and added ignore rules to prevent re-commit.
## [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
### Added
- `web.run` now supports `image_query` (DuckDuckGo image search)
- `multi_tool_use.parallel` now supports safe MCP meta tools (`list_mcp_resources`, `mcp_read_resource`, etc.)
### Fixed
- Encode tool-call function names when rebuilding Chat Completions history (keeps dotted tool names API-safe)
### Changed
- Prompts: stronger `web.run` citation placement and quote-limit guidance
## [0.3.13] - 2026-02-04
### Fixed
- Restore an in-app scrollbar for the transcript view
## [0.3.12] - 2026-02-04
### Fixed
- 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
- Always enable mouse wheel scrolling in the TUI (even without alt screen)
## [0.3.9] - 2026-02-04
### Removed
- RLM mode, tools, and documentation pending a faithful implementation of the MIT RLM design
- Duo mode tools and prompts pending a citable research spec
### Fixed
- Footer context usage bar remains visible while status toasts are shown
### Changed
- Updated prompts and docs to reflect the simplified mode/tool surface
## [0.3.8] - 2026-02-03
### Fixed
- Resolve clippy warnings (CI `-D warnings`) in new tool implementations
## [0.3.7] - 2026-02-03
### Added
- Tooling parity updates: `weather`, `finance`, `sports`, `time`, `calculator`, `request_user_input`, `multi_tool_use.parallel`, `web.run`
- Shell streaming helpers: `exec_shell_wait` and `exec_shell_interact`
- Sub-agent controls: `send_input` and `wait` (with aliases)
- MCP resource helpers: `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` alias
### Changed
- Skills directory selection now prefers workspace `.agents/skills`, then `./skills`, then global
- Docs and prompts updated to reflect new tool surface and parity notes
## [0.3.6] - 2026-02-02
### Added
- New welcome banner on startup showing "Welcome to DeepSeek TUI!" with directory, session ID, and model info
- Visual context progress bar in footer showing usage with block characters [████░░░░░░] and percentage
### Changed
- Removed custom block-character scrollbar from chat area - now uses terminal's native scroll
- Simplified header bar: removed context percentage indicator (moved to footer as progress bar)
## [0.3.5] - 2026-01-30
### Added
- Intelligent context offloading: large tool results (>15k chars) are automatically moved to RLM memory to preserve the context window
- Persistent history context: compacted messages are offloaded to RLM `history` variable for recall
- Full MCP protocol support: SSE transport, Resources (`resources/list`, `resources/read`), and Prompts (`prompts/list`, `prompts/get`)
- `mcp_read_resource` and `mcp_get_prompt` virtual tools exposed to the model
- Dialectical Duo mode with specialized TUI rendering (`Player` / `Coach` history cells)
- Dynamic system prompt refreshing at each turn for up-to-date RLM/Duo/working-set context
- `project_map` tool for automatic codebase structure discovery
- `delegate_to_agent` alias for streamlined sub-agent delegation
### Changed
- Default theme changed to 'Whale' with updated color palette
- `with_agent_tools` now includes `project_map`, `test_runner`, and conditionally RLM tools for all agent modes
- MCP `McpServerConfig.command` is now `Option<String>` to support URL-only (SSE) servers
### Fixed
- MCP test compilation errors for updated `McpServerConfig` struct shape
## [0.3.4] - 2026-01-29
### Changed
- Updated Cargo.lock dependencies
### Fixed
- Compaction tool-call pairing: enforce bidirectional tool-call/tool-result integrity with fixpoint convergence
- Safety net scanning to drop orphan tool results in the request builder
- Double-dispatch race in parallel tool execution
## [0.3.3] - 2026-01-28
### Added
- TUI polish: Kimi-style footer with mode/model/token display
- Streaming thinking blocks with dedicated rendering
- Loading animation improvements
## [0.3.2] - 2026-01-28
@@ -145,12 +281,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.2...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
+458 -1
View File
@@ -18,6 +18,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "adobe-cmap-parser"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3"
dependencies = [
"pom",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -233,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"
@@ -272,6 +333,15 @@ version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -571,6 +641,16 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "ctor"
version = "0.1.26"
@@ -646,12 +726,13 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.3.2"
version = "0.3.19"
dependencies = [
"anyhow",
"arboard",
"async-stream",
"async-trait",
"axum",
"base64",
"bytes",
"chrono",
@@ -666,6 +747,8 @@ dependencies = [
"indicatif",
"libc",
"multimap",
"pdf-extract",
"portable-pty",
"pretty_assertions",
"ratatui",
"regex",
@@ -693,6 +776,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
dependencies = [
"powerfmt",
]
[[package]]
name = "derivative"
version = "2.2.0"
@@ -732,6 +824,16 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -811,6 +913,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dupe"
version = "0.9.1"
@@ -852,6 +960,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endian-type"
version = "0.1.2"
@@ -889,6 +1006,15 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "euclid"
version = "0.20.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad"
dependencies = [
"num-traits",
]
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -935,6 +1061,17 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.7"
@@ -1091,6 +1228,16 @@ dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1561,6 +1708,15 @@ dependencies = [
"rustversion",
]
[[package]]
name = "ioctl-rs"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
dependencies = [
"libc",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -1737,6 +1893,24 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "lopdf"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff"
dependencies = [
"encoding_rs",
"flate2",
"indexmap",
"itoa",
"log",
"md-5",
"nom",
"rangemap",
"time",
"weezl",
]
[[package]]
name = "lru"
version = "0.12.5"
@@ -1765,6 +1939,22 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.7.6"
@@ -1796,6 +1986,12 @@ dependencies = [
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1869,6 +2065,20 @@ dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
dependencies = [
"autocfg",
"bitflags 1.3.2",
"cfg-if",
"libc",
"memoffset",
"pin-utils",
]
[[package]]
name = "nix"
version = "0.28.0"
@@ -1893,6 +2103,16 @@ dependencies = [
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -1903,6 +2123,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -2095,6 +2321,21 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pdf-extract"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575"
dependencies = [
"adobe-cmap-parser",
"encoding_rs",
"euclid",
"lopdf",
"postscript",
"type1-encoding-parser",
"unicode-normalization",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2151,12 +2392,45 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "pom"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6"
[[package]]
name = "portable-atomic"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
[[package]]
name = "portable-pty"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
dependencies = [
"anyhow",
"bitflags 1.3.2",
"downcast-rs",
"filedescriptor",
"lazy_static",
"libc",
"log",
"nix 0.25.1",
"serial",
"shared_library",
"shell-words",
"winapi",
"winreg",
]
[[package]]
name = "postscript"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2166,6 +2440,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2231,6 +2511,12 @@ dependencies = [
"nibble_vec",
]
[[package]]
name = "rangemap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]]
name = "ratatui"
version = "0.29.0"
@@ -2641,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"
@@ -2661,6 +2958,76 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
dependencies = [
"serial-core",
"serial-unix",
"serial-windows",
]
[[package]]
name = "serial-core"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
dependencies = [
"libc",
]
[[package]]
name = "serial-unix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
dependencies = [
"ioctl-rs",
"libc",
"serial-core",
"termios",
]
[[package]]
name = "serial-windows"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
dependencies = [
"libc",
"serial-core",
]
[[package]]
name = "shared_library"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11"
dependencies = [
"lazy_static",
"libc",
]
[[package]]
name = "shell-words"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shellexpand"
version = "3.1.1"
@@ -2961,6 +3328,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "termios"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
dependencies = [
"libc",
]
[[package]]
name = "textwrap"
version = "0.11.0"
@@ -3024,6 +3400,37 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "time"
version = "0.3.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@@ -3055,6 +3462,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.49.0"
@@ -3179,6 +3601,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -3217,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",
@@ -3248,6 +3672,21 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "type1-encoding-parser"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b"
dependencies = [
"pom",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
@@ -3260,6 +3699,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
@@ -3846,6 +4294,15 @@ version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "wiremock"
version = "0.6.5"
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "deepseek-tui"
version = "0.3.2"
version = "0.3.19"
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"
@@ -50,8 +51,10 @@ multimap = "0.10.0"
shlex = "1.3.0"
starlark = "0.13.0"
tiny_http = "0.12"
portable-pty = "0.8"
zeroize = "1.8.2"
ignore = "0.4"
pdf-extract = "0.7"
[dev-dependencies]
wiremock = "0.6"
-191
View File
@@ -1,191 +0,0 @@
# Parity Spec: Codex vs Claude Code
This document defines "parity" as measurable behavior in this repository.
It is intended to be short, testable, and easy to run during reviews.
## Scope
Parity is evaluated on:
- Instruction following (including `AGENTS.md` and task constraints)
- Rust/Cargo workflow discipline
- Change quality and scope control
- Safety and repo hygiene
- Clear, audit-friendly reporting
Unless a task says otherwise, parity targets the default Rust workflow:
1) search with `rg` 2) edit minimally 3) validate with Cargo commands.
## Parity Behaviors (Measurable)
An agent is considered at parity when it reliably exhibits the following
behaviors on eval tasks.
### 1) Instruction and Scope Compliance
Required behaviors:
- Respects path constraints (for example: "do not edit `src/*`")
- Does not revert or disturb unrelated user changes
- Avoids destructive git commands (for example: `git reset --hard`)
- Stops and reports if unexpected repo changes appear mid-task
Suggested metrics:
- `scope_violations = 0` (no edits outside allowed paths)
- `destructive_git_cmds = 0`
- `unrelated_reverts = 0`
### 2) Rust/Cargo Workflow Discipline
Required behaviors:
- Uses Cargo as the source of truth for validation
- Chooses appropriate checks for the task size/scope
- Reports validation outcomes clearly (pass/fail + command)
Suggested metrics (binary unless noted):
- `cargo_check_pass`
- `cargo_test_pass` (required for most parity gates)
- `cargo_fmt_check_pass` (when formatting could be affected)
- `cargo_clippy_pass` (recommended for non-trivial code edits)
- `validation_reported = 1` (commands + outcomes are stated)
### 3) Change Quality and Minimality
Required behaviors:
- Keeps edits focused and atomic
- Preserves existing style and patterns
- Updates documentation when public behavior changes
Suggested metrics:
- `task_acceptance_pass = 1` (task-specific checks succeed)
- `files_touched_within_expectation = 1`
- `style_regressions = 0` (via `fmt`/`clippy`/review)
### 4) Reporting Quality
Required behaviors:
- States what changed, where, and why
- Provides clickable file references
- Separates results from speculation
Suggested metrics:
- `changed_files_listed = 1`
- `key_paths_cited = 1`
- `claims_match_repo_state = 1`
## Parity Metrics and Gates
Use these gates for pass/fail decisions.
### Hard Gates (must pass)
- No scope violations
- No destructive git commands
- `cargo test` exits 0
- Task-specific acceptance checks pass
### Soft Gates (should pass; track as %)
- `cargo check` exits 0
- `cargo fmt --check` exits 0
- `cargo clippy --all-targets --all-features` exits 0
- Edits are minimal and well-scoped
- Reporting is complete and auditable
A simple parity score can be computed as:
- Fail immediately on any hard-gate violation
- Otherwise: `score = soft_gates_passed / soft_gates_total`
Target: `score >= 0.8` over a representative eval set.
## Evaluation Rubric (Short)
Score each dimension 0-2. Parity requires both conditions:
- No hard-gate violations
- Total score >= 7/8
Dimensions:
- Correctness: solution satisfies the task and acceptance checks
- Scope/Safety: constraints honored; no risky repo operations
- Rust Workflow: appropriate Cargo validation is used and reported
- Communication: changes and evidence are clear and well-referenced
Suggested anchors:
- 2 = consistently strong, no notable gaps
- 1 = acceptable but with minor gaps or ambiguity
- 0 = missing, incorrect, or risky
## Rust/Cargo Eval Task Categories
Use a small mix from each category to assess parity.
### A. Cargo Validation Loops
- Fix a failing test, then run `cargo test`
- Resolve a compiler error, validate with `cargo check`
- Address a lint warning, validate with `cargo clippy`
### B. Tests and Behavior Lock-In
- Add unit tests for a small module
- Add an integration test under `tests/`
- Convert a bug report into a regression test + fix
### C. Dependencies and Features
- Add a small crate and wire it into `Cargo.toml`
- Gate behavior behind a feature flag
- Make code compile cleanly with `--all-features`
### D. CLI and Config Surface
- Adjust a Clap flag/help string and update docs
- Add/modify a config field and update documentation
- Ensure `--help` output remains accurate
### E. Repo-Safe Documentation Tasks
- Update `README.md` or `docs/*` without touching `src/*`
- Add a short spec doc (like this one) and validate with tests
- Reconcile docs with current Cargo commands and project norms
## Milestone Checklist
Track parity progress in small, observable steps.
### M1: Safety + Docs Parity
- [ ] No scope violations on doc-only tasks
- [ ] No destructive git commands across evals
- [ ] `cargo test` is run and reported
### M2: Core Rust Workflow Parity
- [ ] `cargo check`/`test` used appropriately by default
- [ ] Formatting and linting considered when relevant
- [ ] Changes remain minimal and consistent with repo patterns
### M3: Feature and Regression Parity
- [ ] Bugs are captured with tests before or with fixes
- [ ] `--all-features` and integration tests are handled cleanly
- [ ] Public behavior changes include doc updates
### M4: Review-Ready Parity
- [ ] Reports include commands, outcomes, and key file refs
- [ ] Soft-gate score >= 0.8 across the eval set
- [ ] Maintainers can reproduce validation steps quickly
+106 -219
View File
@@ -1,288 +1,175 @@
# DeepSeek CLI 🤖
# DeepSeek CLI
Your AI-powered terminal companion for DeepSeek models
An unofficial terminal UI and CLI for the [DeepSeek platform](https://platform.deepseek.com).
[![CI](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/deepseek-tui)](https://crates.io/crates/deepseek-tui)
Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.deepseek.com) — chat with DeepSeek models and collaborate with AI assistants that can read, write, execute, and plan with approval-gated tool access.
Chat with DeepSeek models directly from your terminal. The assistant can read and write files, run shell commands, search the web, manage tasks, and coordinate sub-agents — all with configurable approval gating.
**Not affiliated with DeepSeek Inc.**
## ✨ Features
## Getting Started
- **Interactive TUI** with multiple modes (Normal, Plan, Agent, YOLO, RLM, Duo)
- **Comprehensive tool access** – File operations, shell execution, task management, and sub-agent systems
- **File operations**: List directories, read/write/edit files, apply patches, search files with regex
- **Shell execution**: Run commands with timeout support, background execution with task management
- **Task management**: Todo lists, implementation plans, persistent notes
- **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms)
- **Web search**: Integrated web search with DuckDuckGo
- **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models
- **Context‑aware** – loads project‑specific instructions from `AGENTS.md`
- **Session management** – resume, fork, and search past conversations
- **Skills system** – reusable workflows stored as `SKILL.md` directories
- **Model Context Protocol (MCP)** – integrate external tool servers
- **Sandboxed execution** (macOS) for safe shell commands
- **Git integration** – code review, patch application, diff analysis
- **Cross‑platform** – works on macOS, Linux, and Windows
## 🚀 Quick Start
1. **Get an API key** from [https://platform.deepseek.com](https://platform.deepseek.com)
2. **Install and run**:
### 1. Install
```bash
# Install via Cargo
# From crates.io (requires Rust 1.85+)
cargo install deepseek-tui --locked
# Set your API key
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
# Bootstrap MCP + skills templates (recommended)
deepseek setup
# Start chatting
deepseek
```
3. Press `F1` or type `/help` for the in‑app command list.
If anything looks off, run `deepseek doctor` to diagnose configuration issues.
## 📦 Installation
### From crates.io
```bash
cargo install deepseek-tui --locked
```
### Build from source
```bash
# Or build from source
git clone https://github.com/Hmbown/DeepSeek-TUI.git
cd DeepSeek-TUI
cargo build --release
./target/release/deepseek --help
cargo install --path . --locked
```
### Direct download
Prebuilt binaries are also available on [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases).
Download a prebuilt binary from [GitHub Releases](https://github.com/Hmbown/DeepSeek-TUI/releases) and put it on your `PATH` as `deepseek`.
### 2. Set your API key
## ⚙️ Configuration
On first run, the TUI can prompt for your API key and save it to `~/.deepseek/config.toml`. You can also create the file manually:
Get a key from [platform.deepseek.com](https://platform.deepseek.com). On first run the TUI will prompt you to enter and save it, or create the config manually:
```toml
# ~/.deepseek/config.toml
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non‑empty
default_text_model = "deepseek-reasoner" # optional
allow_shell = false # optional
max_subagents = 3 # optional (1‑20)
api_key = "YOUR_DEEPSEEK_API_KEY"
```
Useful environment variables:
- `DEEPSEEK_API_KEY` (overrides `api_key`)
- `DEEPSEEK_BASE_URL` (default: `https://api.deepseek.com`; China users may use `https://api.deepseeki.com`)
- `DEEPSEEK_PROFILE` (selects `[profiles.<name>]` from the config; errors if missing)
- `DEEPSEEK_CONFIG_PATH` (override config path)
- `DEEPSEEK_MCP_CONFIG`, `DEEPSEEK_SKILLS_DIR`, `DEEPSEEK_NOTES_PATH`, `DEEPSEEK_MEMORY_PATH`, `DEEPSEEK_ALLOW_SHELL`, `DEEPSEEK_MAX_SUBAGENTS`
To bootstrap MCP and skills at their resolved locations, run `deepseek setup`. To
only create an MCP template, run `deepseek mcp init`.
See `config.example.toml` and `docs/CONFIGURATION.md` for a full reference.
## 🎮 Modes
In the TUI, press `Tab` to cycle modes: **Normal → Plan → Agent → YOLO → RLM → Duo → Normal**.
| Mode | Description | Approval Behavior |
|------|-------------|-------------------|
| **Normal** | Chat; asks before file writes or shell | Manual approval for writes & shell |
| **Plan** | Design‑first prompting; same approvals as Normal | Manual approval for writes & shell |
| **Agent** | Multi‑step tool use; asks before shell | Manual approval for shell, auto‑approve file writes |
| **YOLO** | Enables shell + trust + auto‑approves all tools (dangerous) | Auto‑approve all tools |
| **RLM** | Externalized context + REPL helpers; auto‑approves tools (best for large files) | Auto‑approve tools |
| **Duo** | Player‑coach autocoding with iterative validation (based on g3 paper) | Depends on phase |
Approval behavior is mode‑dependent, but you can also override it at runtime with `/set approval_mode auto|suggest|never`.
## 🛠️ Tools
DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categories, with 16+ individual tools available, all with approval gating based on the current mode.
### Tool Categories
#### File Operations
- **`list_dir`** – List directory contents with file/directory metadata
- **`read_file`** – Read UTF‑8 files from the workspace
- **`write_file`** – Create or overwrite files
- **`edit_file`** – Search and replace text in files
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
- **`grep_files`** – Search files by regex pattern with context lines
- **`web_search`** – Search the web and return concise results
#### Shell Execution
- **`exec_shell`** – Run shell commands with timeout support
- **Background execution** – Run commands in background with task ID return
#### Task Management
- **`todo_write`** – Create and update todo lists with status tracking
- **`update_plan`** – Manage structured implementation plans
- **`note`** – Append persistent notes across sessions
#### Sub‑Agents
- **`agent_spawn`** – Create background sub‑agents for focused tasks
- **`agent_swarm`** – Launch a dependency‑aware swarm of sub‑agents
- **`agent_result`** – Retrieve results from sub‑agents
- **`agent_list`** – List all active and completed agents
- **`agent_cancel`** – Cancel running sub‑agents
### System Behavior
- **Workspace boundary**: File tools are restricted to `--workspace` unless you enable `/trust` (YOLO enables trust automatically).
- **Approvals**: The TUI requests approval depending on mode and tool category (file writes, shell).
- **Web search**: `web_search` uses DuckDuckGo HTML results and is auto‑approved.
- **Skills**: Reusable workflows stored as `SKILL.md` directories (default: `~/.deepseek/skills`, or `./skills` per workspace). Use `/skills` and `/skill <name>`. Bootstrap with `deepseek setup --skills` (add `--local` for `./skills`).
- **MCP**: Load external tool servers via `~/.deepseek/mcp.json` (supports `servers` and `mcpServers`). MCP tools currently execute without TUI approval prompts, so only enable servers you trust. See `docs/MCP.md`.
## 🧠 RLM (Reasoning & Large‑scale Memory)
RLM mode is designed for "too big for context" tasks: large files, whole‑doc sweeps, and big pasted blocks.
- Auto‑switch triggers: "largest file", explicit "RLM", large file requests, and large pastes.
- Shortcut: `/rlm` (or `/aleph`) enters RLM mode directly.
- In **RLM mode**, `/load @path` loads a file into the external context store (outside RLM mode, `/load` loads a saved chat JSON).
- Use `/repl` to enter expression mode (e.g. `search("pattern")`, `lines(1, 80)`).
- Power tools: `rlm_load`, `rlm_exec`, `rlm_status`, `rlm_query`.
`rlm_query` can be expensive: prefer batching and check `/status` if you're doing lots of sub‑queries.
## 👥 Duo Mode
> **Note:** Duo mode is experimental and may not work correctly in all cases. Use with caution.
Duo mode implements the player‑coach autocoding paradigm for iterative development with built‑in validation:
- **Player**: implements requirements (builder role)
- **Coach**: validates implementation against requirements (critic role)
- Tools: `duo_init`, `duo_player`, `duo_coach`, `duo_advance`, `duo_status`
Workflow: `init → player → coach → advance → (repeat until approved)`
## 📚 Examples
### Interactive chat
### 3. Run
```bash
deepseek
```
### One‑shot prompt (non‑interactive)
Press **Tab** to switch modes, **F1** for help, and **Esc** to cancel a running request.
## Modes
| Mode | Description | Approvals |
|------|-------------|-----------|
| **Plan** | Design-first prompting; produces a plan before implementing | Manual for writes and shell |
| **Agent** | Multi-step autonomous tool use | Auto-approve file writes, manual for shell |
| **YOLO** | Full auto-approve (use with caution) | All tools auto-approved |
Press `Tab` to cycle modes. Normal mode (manual approval for everything) is also available via `/set mode normal`.
## What It Can Do
The assistant has access to 30+ tools:
- **File operations** — read, write, edit, patch, search, and grep across your workspace
- **Shell execution** — run commands with timeout, background execution, and interactive I/O
- **Web browsing** — search the web, open pages, screenshot, and extract content with citations
- **Git** — inspect repo status, diffs, and staged changes
- **Code review** — structured review for files, diffs, or GitHub PRs
- **Sub-agents** — spawn background agents or coordinate agent swarms for parallel work
- **Task management** — to-do lists, implementation plans, persistent notes, and a background task queue
- **Structured data** — weather, finance, sports scores, time zones, and a calculator
- **MCP integration** — connect external tool servers via the [Model Context Protocol](docs/MCP.md)
All file tools respect the `--workspace` boundary unless `/trust` is enabled.
## Examples
```bash
deepseek -p "Write a haiku about Rust"
```
# Interactive chat
deepseek
### Agentic execution with tool access
# One-shot prompt
deepseek -p "Explain the borrow checker in two sentences"
```bash
deepseek exec --auto "Fix lint errors in the current directory"
```
# Agentic execution with auto-approve
deepseek exec --auto "Fix all clippy warnings in this project"
### Resume latest session
```bash
# Resume latest session
deepseek --continue
```
### Work on a specific project
```bash
# Work on a specific project
deepseek --workspace /path/to/project
```
### Review staged git changes
```bash
# Review staged git changes
deepseek review --staged
# Start the runtime API server
deepseek serve --http
# Verify your environment
deepseek doctor
```
### Apply a patch file
## Configuration
```bash
deepseek apply patch.diff
Config lives at `~/.deepseek/config.toml`:
```toml
api_key = "sk-..."
default_text_model = "deepseek-reasoner" # optional (or "deepseek-chat")
allow_shell = true # optional (sandboxed by default)
max_subagents = 3 # optional (1-20)
```
### List saved sessions
Key environment variables:
```bash
deepseek sessions --limit 50
```
| Variable | Purpose |
|----------|---------|
| `DEEPSEEK_API_KEY` | API key (overrides config file) |
| `DEEPSEEK_BASE_URL` | API endpoint (default: `https://api.deepseek.com`) |
### Generate shell completions
See [`config.example.toml`](config.example.toml) and [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) for the full reference.
```bash
deepseek completions zsh > _deepseek
deepseek completions bash > deepseek.bash
deepseek completions fish > deepseek.fish
```
## Runtime API
## 🔧 Troubleshooting
`deepseek serve --http` starts a local HTTP/SSE API on `127.0.0.1:7878` for external clients. Supports threads, multi-turn conversations, task queues, and live event streaming.
### No API key
Set `DEEPSEEK_API_KEY` environment variable or run `deepseek` and complete onboarding.
See [`docs/RUNTIME_API.md`](docs/RUNTIME_API.md) for endpoints and usage.
### Config not found
Check `~/.deepseek/config.toml` (or `DEEPSEEK_CONFIG_PATH`).
## Keyboard Shortcuts
### Wrong region / base URL
Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China).
| Key | Action |
|-----|--------|
| `Enter` | Send message |
| `Alt+Enter` / `Ctrl+J` | Insert newline |
| `Tab` | Autocomplete or cycle modes |
| `Esc` | Cancel request / clear input |
| `Ctrl+C` | Cancel or exit |
| `Ctrl+K` | Command palette |
| `Ctrl+R` | Search past sessions |
| `F1` | Help overlay |
| `PageUp` / `PageDown` | Scroll transcript |
### Session issues
Run `deepseek sessions` and try `deepseek --resume latest`.
## Troubleshooting
### Skills missing
Run `deepseek setup --skills` to create a global skills directory, or add `--local`
to create `./skills` for the current workspace. Then run `deepseek doctor` to see
which skills directory is selected.
| Problem | Fix |
|---------|-----|
| No API key | Set `DEEPSEEK_API_KEY` or run `deepseek` to complete onboarding |
| Config not found | Check `~/.deepseek/config.toml` (or set `DEEPSEEK_CONFIG_PATH`) |
| Wrong region | Set `DEEPSEEK_BASE_URL` to `https://api.deepseeki.com` (China) |
| Sandbox errors (macOS) | Run `deepseek doctor` |
### MCP tools missing
Run `deepseek mcp init` (or `deepseek setup --mcp`), then restart. `deepseek doctor`
now checks the MCP path resolved from your config/env overrides.
## Documentation
### Sandbox errors (macOS)
Run `deepseek doctor` to confirm sandbox availability. On macOS, ensure
`/usr/bin/sandbox-exec` exists. For other platforms, sandboxing is limited.
- [Configuration Reference](docs/CONFIGURATION.md)
- [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)
## 📖 Documentation
- `docs/CONFIGURATION.md` – Complete configuration reference
- `docs/MCP.md` – Model Context Protocol guide
- `docs/ARCHITECTURE.md` – Project architecture
- `docs/RLM.md` – RLM mode deep‑dive
- `docs/MODES.md` – Mode comparison and usage
- `CONTRIBUTING.md` – How to contribute to the project
## 🧪 Development
## Development
```bash
cargo build
cargo test
cargo fmt
cargo clippy
cargo fmt
```
See `CONTRIBUTING.md` for detailed guidelines.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## 📄 License
## License
MIT
---
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial project.
DeepSeek is a trademark of DeepSeek Inc. This is an unofficial, community-driven project.
+33 -14
View File
@@ -20,7 +20,7 @@ base_url = "https://api.deepseek.com"
# ─────────────────────────────────────────────────────────────────────────────────
# Default Models
# ─────────────────────────────────────────────────────────────────────────────────
default_text_model = "deepseek-reasoner" # also: deepseek-chat, deepseek-r1, deepseek-v3, deepseek-v3.2
default_text_model = "deepseek-reasoner" # supported: deepseek-reasoner, deepseek-chat
# ─────────────────────────────────────────────────────────────────────────────────
# 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
allow_shell = true
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
# ─────────────────────────────────────────────────────────────────────────────────
@@ -51,11 +58,9 @@ alternate_screen = "auto" # auto | always | never
[features]
shell_tool = true
subagents = true
web_search = true
web_search = true # enables web.run and web_search
apply_patch = true
mcp = true
rlm = true
duo = true
exec_policy = true
# ─────────────────────────────────────────────────────────────────────────────────
@@ -69,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
@@ -79,14 +84,22 @@ exponential_base = 2.0
# cache_summary = true # Cache the summary block
# ─────────────────────────────────────────────────────────────────────────────────
# RLM Sandbox Configuration (PLANNED - not yet implemented)
# Capacity Controller (runtime pressure guardrails)
# ─────────────────────────────────────────────────────────────────────────────────
# [rlm]
# max_context_chars = 10000000 # Max characters for context (10MB)
# max_search_results = 100 # Max search results
# default_chunk_size = 2000 # Default chunk size
# default_overlap = 200 # Default chunk overlap
# session_dir = "~/.deepseek/rlm" # Directory for RLM sessions
[capacity]
enabled = true
low_risk_max = 0.34
medium_risk_max = 0.62
severe_min_slack = -0.25
severe_violation_ratio = 0.40
refresh_cooldown_turns = 2
replan_cooldown_turns = 5
max_replay_per_turn = 1
min_turns_before_guardrail = 2
profile_window = 8
deepseek_v3_2_chat_prior = 3.9
deepseek_v3_2_reasoner_prior = 4.1
fallback_default_prior = 3.8
# ─────────────────────────────────────────────────────────────────────────────────
# Profile Example (for multiple environments)
@@ -113,3 +126,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 -2
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) │ │
@@ -121,11 +130,13 @@ Responses API (with automatic fallback if needed).
- **`utils.rs`** - Common utilities
- **`logging.rs`** - Logging infrastructure
- **`compaction.rs`** - Context compaction for long conversations
- **`rlm.rs`** - Reflection/reasoning utilities
- **`pricing.rs`** - Cost estimation
- **`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
@@ -140,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. Startup remains fresh by default; prior sessions are resumed explicitly via `--resume`/`--continue` (or `Ctrl+R` in TUI)
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
@@ -150,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
@@ -183,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
+65 -7
View File
@@ -49,7 +49,26 @@ 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)
- `DEEPSEEK_CAPACITY_ENABLED`
- `DEEPSEEK_CAPACITY_LOW_RISK_MAX`
- `DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX`
- `DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK`
- `DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO`
- `DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS`
- `DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS`
- `DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN`
- `DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL`
- `DEEPSEEK_CAPACITY_PROFILE_WINDOW`
- `DEEPSEEK_CAPACITY_PRIOR_CHAT`
- `DEEPSEEK_CAPACITY_PRIOR_REASONER`
- `DEEPSEEK_CAPACITY_PRIOR_FALLBACK`
## Settings File (Persistent UI Preferences)
@@ -67,7 +86,7 @@ Common settings keys:
- `auto_compact` (on/off)
- `show_thinking` (on/off)
- `show_tool_details` (on/off)
- `default_mode` (normal, agent, plan, yolo, rlm, duo)
- `default_mode` (normal, agent, plan, yolo)
- `max_history` (number of input history entries)
- `default_model` (model name override)
@@ -77,10 +96,14 @@ 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.
- `allow_shell` (bool, optional): defaults to `false`.
- `default_text_model` (string, optional): defaults to `deepseek-reasoner`. Supported IDs are `deepseek-reasoner` and `deepseek-chat`.
- `allow_shell` (bool, optional): defaults to `true` (sandboxed).
- `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`).
- `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`.
- `notes_path` (string, optional): defaults to `~/.deepseek/notes.txt` and is used by the `note` tool.
- `memory_path` (string, optional): defaults to `~/.deepseek/memory.md`.
@@ -90,6 +113,20 @@ Common settings keys:
- `[retry].initial_delay` (float seconds, default `1.0`)
- `[retry].max_delay` (float seconds, default `60.0`)
- `[retry].exponential_base` (float, default `2.0`)
- `capacity.*` (optional): runtime context-capacity controller:
- `[capacity].enabled` (bool, default `true`)
- `[capacity].low_risk_max` (float, default `0.34`)
- `[capacity].medium_risk_max` (float, default `0.62`)
- `[capacity].severe_min_slack` (float, default `-0.25`)
- `[capacity].severe_violation_ratio` (float, default `0.40`)
- `[capacity].refresh_cooldown_turns` (int, default `2`)
- `[capacity].replan_cooldown_turns` (int, default `5`)
- `[capacity].max_replay_per_turn` (int, default `1`)
- `[capacity].min_turns_before_guardrail` (int, default `2`)
- `[capacity].profile_window` (int, default `8`)
- `[capacity].deepseek_v3_2_chat_prior` (float, default `3.9`)
- `[capacity].deepseek_v3_2_reasoner_prior` (float, default `4.1`)
- `[capacity].fallback_default_prior` (float, default `3.8`)
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. `auto` disables the alternate screen in Zellij; `--no-alt-screen` forces inline mode.
- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).
- `features.*` (optional): feature flag overrides (see below).
@@ -110,11 +147,9 @@ want to force on or off.
[features]
shell_tool = true
subagents = true
web_search = true
web_search = true # enables web.run and web_search
apply_patch = true
mcp = true
rlm = true
duo = true
exec_policy = true
```
@@ -125,6 +160,29 @@ 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.
See `docs/capacity_controller.md` for formulas, intervention behavior, and telemetry.
## Notes On `deepseek doctor`
`deepseek doctor` now follows the same config resolution rules as the rest of the CLI.
+34 -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:
@@ -35,6 +52,15 @@ Discovered MCP tools are exposed to the model as:
Example: a server named `git` with a tool named `status` becomes `mcp_git_status`.
## Resource and Prompt Helpers
The CLI also exposes helper tools when MCP is enabled:
- `list_mcp_resources` (optional `server` filter)
- `list_mcp_resource_templates` (optional `server` filter)
- `mcp_read_resource` / `read_mcp_resource` (aliases)
- `mcp_get_prompt`
## Minimal Example
```json
@@ -66,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
+5 -6
View File
@@ -2,18 +2,17 @@
DeepSeek CLI has two related concepts:
- **TUI mode**: what kind of interaction you’re in (Normal/Plan/Agent/YOLO/RLM).
- **TUI mode**: what kind of interaction you’re in (Normal/Plan/Agent/YOLO).
- **Approval mode**: how aggressively the UI asks before executing tools.
## TUI Modes
Press `Tab` to cycle: **Normal → Plan → Agent → YOLO → RLM → 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.
- **Agent**: multi-step tool use. Approvals for shell and paid tools (file writes are allowed without a prompt).
- **YOLO**: enables shell + trust mode and auto-approves all tools. Use only in trusted repos.
- **RLM**: externalized context store + REPL helpers. Tools are auto-approved (best for large files and long-context work).
## Approval Mode
@@ -26,7 +25,7 @@ You can override approval behavior at runtime:
```
- `suggest` (default): uses the per-mode rules above.
- `auto`: auto-approves all tools (similar to YOLO/RLM approval behavior, but without forcing YOLO mode).
- `auto`: auto-approves all tools (similar to YOLO approval behavior, but without forcing YOLO mode).
- `never`: blocks any tool that isn’t considered safe/read-only.
## Workspace Boundary and Trust Mode
@@ -39,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 begins a fresh session unless `--resume`/`--continue` is supplied
Actions:
1. Resume prior work explicitly via `deepseek --resume <id>` or `Ctrl+R` in TUI
2. If checkpoint inspection is needed, inspect `latest.json` for schema mismatch/details
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
-54
View File
@@ -1,54 +0,0 @@
# RLM Mode
RLM mode (“Recursive Language Model” mode) is DeepSeek CLI’s long-context workflow: it stores large context externally (Aleph-style external memory) and provides REPL-like tools to explore and query it without stuffing everything into the model’s context window.
If you’re curious about the research inspiration and implementation notes, see:
- `docs/rlm-paper.txt`
- `docs/rlm_gap_analysis.md`
## When To Use It
RLM mode is best for:
- “Analyze this large file / doc”
- “Summarize the whole repository”
- “Search for every occurrence of X and explain it”
- Big pasted blocks of text
The UI may auto-switch to RLM for large file requests, “largest file”, explicit “RLM” requests, and large pastes.
## How To Use It
### Switch modes
- Press `Tab` until you reach **RLM**
- Or type `/rlm` (or `/aleph`) to jump directly into RLM mode
### Load context
In RLM mode, `/load` loads external context (in other modes, `/load` loads a saved chat JSON):
```text
/load @path/to/file.rs
```
`@path` is workspace-relative.
### Inspect and query
- `/status` shows which contexts are loaded and basic usage totals.
- `/repl` toggles expression input mode.
Typical REPL helpers include:
- `lines(1, 80)` (show a slice of the context)
- `search("pattern")`
- `chunk(2000)` (create fixed-size chunks for later querying)
Under the hood, the model uses tools like `rlm_load`, `rlm_exec`, `rlm_status`, and `rlm_query`.
## Cost and Safety Notes
- `rlm_query` can be expensive because it triggers additional model calls. Prefer batching related questions.
- RLM mode auto-approves tools; keep `--workspace` scoped to the repo you want it to access.
+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-reasoner",
"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-reasoner",
"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.
+136
View File
@@ -0,0 +1,136 @@
# Capacity Controller
`deepseek-tui` includes a capacity-aware context controller that keeps active prompt context near coherent operating range while preserving full history on disk.
## Policy Overview
Each checkpoint computes:
- `H_hat` (runtime pressure proxy)
- `C_hat` (model capacity prior)
- `slack = C_hat - H_hat`
- dynamic slack profile over last `N=8` observations
### Runtime Pressure Proxy (`H_hat`)
- `action_complexity_bits = log2(1 + action_count_this_turn)`
- `tool_complexity_bits = log2(1 + tool_calls_recent_window)`
- `ref_complexity_bits = log2(1 + unique_reference_ids_recent_window)`
- `context_pressure_bits = 6.0 * context_used_ratio`
Formula:
`H_hat = 0.35*action_complexity_bits + 0.30*tool_complexity_bits + 0.20*ref_complexity_bits + 0.15*context_pressure_bits`
### Capacity Prior (`C_hat`)
Per-model priors:
- `deepseek_v3_2_chat = 3.9`
- `deepseek_v3_2_reasoner = 4.1`
- fallback `3.8`
### Failure Probability
Using rolling profile fields:
- `final_slack`
- `min_slack`
- `violation_ratio`
- `slack_volatility`
- `slack_drop`
Formula:
`z = -1.65*final_slack -0.85*min_slack +1.35*violation_ratio +0.70*slack_volatility +0.28*slack_drop -0.12`
`p_fail = sigmoid(z)` clamped to `[0,1]`.
Risk bands:
- low: `p_fail <= low_risk_max`
- medium: `p_fail <= medium_risk_max`
- high: otherwise
Action mapping:
- low -> `NoIntervention`
- medium -> `TargetedContextRefresh`
- high + severe dynamics (`min_slack <= severe_min_slack` or `violation_ratio >= severe_violation_ratio`) -> `VerifyAndReplan`
- otherwise high -> `VerifyWithToolReplay`
## Checkpoints
The engine evaluates controller policy at:
1. Pre-request checkpoint (before `MessageRequest` assembly).
2. Post-tool checkpoint (after tool result append).
3. Error-escalation checkpoint (tool error streak path).
## Interventions
### `TargetedContextRefresh`
- Runs compaction (`compact_messages_safe`) when possible.
- Falls back to local trim if compaction path fails.
- Persists canonical state.
- Replaces long-tail active context with compact canonical prompt + memory pointer.
### `VerifyWithToolReplay`
- Replays one read-only critical tool call from recent turn context.
- Appends verification note with pass/fail + diff summary.
- On replay conflict/error, marks escalation candidate and disables replay for current turn.
### `VerifyAndReplan`
- Persists canonical snapshot.
- Clears volatile prompt tail while preserving latest user ask and latest verification note.
- Injects canonical replan instruction into system prompt.
- Continues turn loop from compact canonical state.
## Safety Controls
- Max one intervention per turn.
- Cooldowns for refresh and replan.
- Replay budget per turn (`max_replay_per_turn`).
- Fail-open behavior when controller inputs are unavailable.
- Compaction/replay failures are logged; turn continues.
## Memory Store
Path:
- `DEEPSEEK_CAPACITY_MEMORY_DIR` (if set)
- otherwise `~/.deepseek/memory/<session_id>.jsonl`
- fallback: `<workspace>/.deepseek/memory/<session_id>.jsonl` when home path is unavailable/unwritable
Record fields:
- `id`, `ts`, `turn_index`, `action_trigger`
- `h_hat`, `c_hat`, `slack`, `risk_band`
- `canonical_state`
- `source_message_ids`
- optional `replay_info`
Loader utility supports fetching last `K` snapshots for rehydration.
## Configuration
`[capacity]` keys:
- `enabled`
- `low_risk_max`
- `medium_risk_max`
- `severe_min_slack`
- `severe_violation_ratio`
- `refresh_cooldown_turns`
- `replan_cooldown_turns`
- `max_replay_per_turn`
- `min_turns_before_guardrail`
- `profile_window`
- `deepseek_v3_2_chat_prior`
- `deepseek_v3_2_reasoner_prior`
- `fallback_default_prior`
Equivalent environment overrides are available with `DEEPSEEK_CAPACITY_*`.
+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"))
}
+1436 -102
View File
File diff suppressed because it is too large. Load diff
+275 -16
View File
@@ -1,8 +1,7 @@
//! Config commands: config, set, settings, yolo, trust, logout
use super::CommandResult;
use crate::compaction::CompactionConfig;
use crate::config::clear_api_key;
use crate::config::{COMMON_DEEPSEEK_MODELS, canonical_model_name, clear_api_key};
use crate::palette;
use crate::settings::Settings;
use crate::tui::app::{App, AppAction, AppMode, OnboardingState};
@@ -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"
@@ -83,13 +84,25 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
// Handle session-only settings first
match key.as_str() {
"model" => {
app.model = value.to_string();
return CommandResult::message(format!("model = {value}"));
let Some(model) = canonical_model_name(value) else {
return CommandResult::error(format!(
"Invalid model '{value}'. Supported models: {}",
COMMON_DEEPSEEK_MODELS.join(", ")
));
};
app.model = model.to_string();
app.update_model_compaction_budget();
app.last_prompt_tokens = None;
app.last_completion_tokens = None;
return CommandResult::with_message_and_action(
format!("model = {model}"),
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 +111,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",
),
};
}
_ => {}
@@ -107,6 +122,12 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
// Load and update persistent settings
let mut settings = match Settings::load() {
Ok(s) => s,
Err(e) if !should_save => {
app.status_message = Some(format!(
"Settings unavailable; applying session-only override ({e})"
));
Settings::default()
}
Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")),
};
@@ -119,14 +140,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));
}
"auto_rlm" => {
app.auto_rlm = settings.auto_rlm;
action = Some(AppAction::UpdateCompaction(app.compaction_config()));
}
"show_thinking" | "thinking" => {
app.show_thinking = settings.show_thinking;
@@ -141,8 +155,6 @@ pub fn set_config(app: &mut App, args: Option<&str>) -> CommandResult {
"agent" | "normal" => AppMode::Agent,
"plan" => AppMode::Plan,
"yolo" => AppMode::Yolo,
"rlm" => AppMode::Rlm,
"duo" => AppMode::Duo,
_ => AppMode::Agent,
};
app.set_mode(mode);
@@ -153,12 +165,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();
}
_ => {}
}
@@ -208,9 +228,85 @@ pub fn logout(app: &mut App) -> CommandResult {
mod tests {
use super::*;
use crate::config::Config;
use crate::test_support::lock_test_env;
use crate::tui::app::{App, TuiOptions};
use crate::tui::approval::ApprovalMode;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
struct EnvGuard {
home: Option<OsString>,
userprofile: Option<OsString>,
deepseek_config_path: Option<OsString>,
}
impl EnvGuard {
fn new(home: &Path) -> Self {
let home_str = OsString::from(home.as_os_str());
let config_path = home.join(".deepseek").join("config.toml");
let config_str = OsString::from(config_path.as_os_str());
let home_prev = env::var_os("HOME");
let userprofile_prev = env::var_os("USERPROFILE");
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("HOME", &home_str);
env::set_var("USERPROFILE", &home_str);
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
}
Self {
home: home_prev,
userprofile: userprofile_prev,
deepseek_config_path: deepseek_config_prev,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = self.home.take() {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("HOME", value);
}
} else {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::remove_var("HOME");
}
}
if let Some(value) = self.userprofile.take() {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("USERPROFILE", value);
}
} else {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::remove_var("USERPROFILE");
}
}
if let Some(value) = self.deepseek_config_path.take() {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_CONFIG_PATH", value);
}
} else {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::remove_var("DEEPSEEK_CONFIG_PATH");
}
}
}
}
fn create_test_app() -> App {
let options = TuiOptions {
@@ -242,4 +338,167 @@ 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 _lock = lock_test_env();
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 _lock = lock_test_env();
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 _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-cli-logout-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, "api_key = \"test-key\"\n").unwrap();
let mut app = create_test_app();
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);
let updated = fs::read_to_string(config_path).unwrap();
assert!(!updated.contains("api_key"));
}
#[test]
fn test_set_invalid_setting() {
let _lock = lock_test_env();
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"));
}
}
+274 -27
View File
@@ -2,7 +2,7 @@
use std::fmt::Write;
use crate::tools::plan::PlanState;
use crate::config::{COMMON_DEEPSEEK_MODELS, canonical_model_name};
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::views::{HelpView, ModalKind, SubAgentsView};
@@ -37,13 +37,36 @@ pub fn clear(app: &mut App) -> CommandResult {
app.history.clear();
app.mark_history_updated();
app.api_messages.clear();
app.system_prompt = None;
app.transcript_selection.clear();
app.queued_messages.clear();
app.queued_draft = None;
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;
app.current_session_id = None;
let message = if todos_cleared {
"Conversation cleared".to_string()
} else {
"Conversation cleared (plan state busy; run /clear again if needed)".to_string()
};
CommandResult::with_message_and_action(
message,
AppAction::SyncSession {
messages: Vec::new(),
system_prompt: None,
model: app.model.clone(),
workspace: app.workspace.clone(),
},
)
}
/// Exit the application
@@ -51,30 +74,38 @@ pub fn exit() -> CommandResult {
CommandResult::action(AppAction::Quit)
}
/// Available DeepSeek models
const AVAILABLE_MODELS: &[&str] = &[
"deepseek-reasoner",
"deepseek-chat",
"deepseek-r1",
"deepseek-v3",
"deepseek-v3.2",
];
/// Switch or view current model
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
if let Some(name) = model_name {
let Some(canonical) = canonical_model_name(name) else {
return CommandResult::error(format!(
"Invalid model '{name}'. Supported models: {}",
COMMON_DEEPSEEK_MODELS.join(", ")
));
};
let old_model = app.model.clone();
app.model = name.to_string();
CommandResult::message(format!("Model changed: {old_model} → {name}"))
app.model = canonical.to_string();
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} → {canonical}"),
AppAction::UpdateCompaction(app.compaction_config()),
)
} else {
let available = AVAILABLE_MODELS.join(", ");
let supported = COMMON_DEEPSEEK_MODELS.join(", ");
CommandResult::message(format!(
"Current model: {}\nAvailable: {}",
app.model, available
"Current model: {}\nSupported models: {}",
app.model, supported
))
}
}
/// 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 +170,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
@@ -160,15 +192,230 @@ pub fn home_dashboard(app: &mut App) -> CommandResult {
let _ = writeln!(stats, "Plan mode - Design before implementing");
let _ = writeln!(stats, " Use /plan to create structured checklists");
}
AppMode::Rlm => {
let _ = writeln!(stats, "RLM mode - Recursive language model sandbox");
let _ = writeln!(stats, " Use /repl to toggle REPL input");
}
AppMode::Duo => {
let _ = writeln!(stats, "Duo mode - Dialectical autocoding");
let _ = writeln!(stats, " Player-coach loop for complex tasks");
}
}
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-reasoner".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());
app.current_session_id = Some("existing-session".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());
assert!(app.current_session_id.is_none());
assert!(matches!(result.action, Some(AppAction::SyncSession { .. })));
}
#[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_change_rejects_invalid_model() {
let mut app = create_test_app();
let result = model(&mut app, Some("gpt-4"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Invalid model"));
assert!(msg.contains("deepseek-chat"));
assert!(msg.contains("deepseek-reasoner"));
assert!(result.action.is_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("Supported 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:?}");
}
}
}
+250 -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_input_tokens_conservative;
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,6 +78,8 @@ 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 estimated_tokens =
estimate_input_tokens_conservative(&app.api_messages, app.system_prompt.as_ref());
// System prompt
if let Some(SystemPrompt::Text(text)) = &app.system_prompt {
@@ -85,10 +90,8 @@ pub fn context(app: &mut App) -> CommandResult {
}
}
// 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 +113,243 @@ pub fn context(app: &mut App) -> CommandResult {
))
}
#[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 +373,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);
}
}
+20 -94
View File
@@ -10,11 +10,11 @@ mod init;
mod note;
mod queue;
mod review;
pub mod rlm;
mod session;
mod skills;
mod task;
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::app::{App, AppAction};
/// Result of executing a command
#[derive(Debug, Clone)]
@@ -104,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"],
@@ -134,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",
@@ -150,43 +162,13 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "load",
aliases: &[],
description: "Load session from file (or RLM context in RLM mode)",
description: "Load session from file",
usage: "/load [path]",
},
CommandInfo {
name: "rlm",
aliases: &[],
description: "Enter RLM (Aleph) mode and show quickstart",
usage: "/rlm",
},
CommandInfo {
name: "aleph",
aliases: &[],
description: "Alias for /rlm (external memory quickstart)",
usage: "/aleph",
},
CommandInfo {
name: "save-session",
aliases: &["save_session"],
description: "Save RLM session to file",
usage: "/save-session [path]",
},
CommandInfo {
name: "status",
aliases: &[],
description: "Show RLM context status",
usage: "/status",
},
CommandInfo {
name: "repl",
aliases: &[],
description: "Toggle RLM REPL mode",
usage: "/repl",
},
CommandInfo {
name: "compact",
aliases: &[],
description: "Toggle auto-compaction",
description: "Trigger context compaction to free up space",
usage: "/compact",
},
CommandInfo {
@@ -311,27 +293,18 @@ 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),
"sessions" | "resume" => session::sessions(app),
"load" => {
let force_rlm = arg.is_some_and(|raw| raw.trim_start().starts_with('@'));
if app.mode == AppMode::Rlm || force_rlm {
rlm::load(app, arg)
} else {
session::load(app, arg)
}
}
"rlm" | "aleph" => rlm::enter(app),
"save-session" | "save_session" => rlm::save_session(app, arg),
"status" => rlm::status(app),
"repl" => rlm::repl(app),
"load" => session::load(app, arg),
"compact" => session::compact(app),
"export" => session::export(app, arg),
@@ -387,52 +360,5 @@ pub fn commands_matching(prefix: &str) -> Vec<&'static CommandInfo> {
#[cfg(test)]
mod tests {
use super::execute;
use crate::config::Config;
use crate::tui::app::{App, AppMode, TuiOptions};
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
fn make_test_app(workspace: PathBuf) -> App {
let skills_dir = workspace.join("skills");
let _ = fs::create_dir_all(&skills_dir);
let options = TuiOptions {
model: "test-model".to_string(),
workspace,
allow_shell: false,
use_alt_screen: false,
max_subagents: 1,
skills_dir,
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 load_at_path_uses_rlm_outside_rlm_mode() {
let tmp = tempdir().expect("tempdir");
let file = tmp.path().join("example.txt");
fs::write(&file, "hello").expect("write");
let mut app = make_test_app(tmp.path().to_path_buf());
app.mode = AppMode::Normal;
let result = execute("/load @example.txt", &mut app);
let message = result.message.unwrap_or_default();
assert!(
message.starts_with("Loaded "),
"expected RLM load message, got: {message}"
);
let session = app.rlm_session.lock().expect("lock session");
assert!(!session.contexts.is_empty());
}
// No unit tests currently required for command routing.
}
+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());
}
}
-254
View File
@@ -1,254 +0,0 @@
//! RLM commands for the TUI (load/status/repl/save-session).
use std::fs;
use std::path::{Path, PathBuf};
use crate::rlm::{context_id_from_path, unique_context_id};
use crate::tui::app::{App, AppMode};
use super::CommandResult;
const DEFAULT_CHUNK_SIZE: usize = 2000;
const DEFAULT_CHUNK_OVERLAP: usize = 200;
pub fn welcome_message() -> String {
[
"DeepSeek RLM / Aleph Sandbox",
"Commands: /rlm, /aleph, /load <file>, /repl, /status, /save-session",
"Press Tab to exit RLM mode",
"Use /repl to toggle expression mode (chat is the default)",
"Tip: /load @path forces workspace-relative paths (e.g. @docs/rlm-paper.txt)",
"",
"Expressions:",
" len(ctx)",
" search(\"pattern\")",
" lines(1, 20)",
" chunk(2000, 200)",
" chunk_sections(20000)",
" chunk_auto(20000)",
" vars(), get(\"name\"), set(\"name\", \"value\")",
"",
"Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.",
"Tip: /save-session <path> persists the current RLM session.",
]
.join("\n")
}
pub fn overview_message() -> String {
[
"RLM / Aleph Quickstart",
"Use /rlm or /aleph to enter external-memory mode.",
"Use /load @path to load a file into the RLM context store.",
"Use /status to list contexts and usage totals.",
"Use /repl to toggle expression mode (chat is default).",
"Tip: rlm_query auto_chunks runs the same question over chunk_auto slices.",
]
.join("\n")
}
pub fn enter(app: &mut App) -> CommandResult {
if app.mode != AppMode::Rlm {
app.set_mode(AppMode::Rlm);
}
app.rlm_repl_active = false;
CommandResult::message(overview_message())
}
pub fn repl(app: &mut App) -> CommandResult {
if app.mode != AppMode::Rlm {
app.set_mode(AppMode::Rlm);
}
if app.rlm_repl_active {
app.rlm_repl_active = false;
return CommandResult::message("Exited RLM REPL mode. Chat is active.");
}
app.rlm_repl_active = true;
CommandResult::message(welcome_message())
}
pub fn status(app: &mut App) -> CommandResult {
let session = match app.rlm_session.lock() {
Ok(session) => session,
Err(_) => return CommandResult::error("Failed to access RLM session"),
};
if session.contexts.is_empty() {
return CommandResult::message("No RLM contexts loaded. Use /load <path>.");
}
let mut lines = Vec::new();
lines.push("RLM Session".to_string());
lines.push(format!("Active context: {}", session.active_context));
lines.push(format!("Loaded contexts: {}", session.contexts.len()));
lines.push(format!(
"Queries: {} | Input tokens: {} | Output tokens: {}",
session.usage.queries, session.usage.input_tokens, session.usage.output_tokens
));
let mut ids: Vec<_> = session.contexts.keys().collect();
ids.sort();
for id in ids {
if let Some(ctx) = session.contexts.get(id) {
let source = ctx
.source_path
.as_ref()
.map(|s| format!(" (source: {s})"))
.unwrap_or_default();
let chunk_count = ctx.chunk(DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP).len();
let section_count = ctx.chunk_sections(20_000).len();
lines.push(format!(
"- {id}: {} lines, {} chars, {} chunks, {} sections{source}",
ctx.line_count, ctx.char_count, chunk_count, section_count
));
if !ctx.variables.is_empty() {
lines.push(format!(" variables: {}", ctx.variables.len()));
}
}
}
CommandResult::message(lines.join("\n"))
}
pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
let Some(raw) = path else {
return CommandResult::error("Usage: /load <path>");
};
let resolved = match resolve_path(app, raw) {
Ok(path) => path,
Err(err) => return CommandResult::error(err),
};
let mut session = match app.rlm_session.lock() {
Ok(session) => session,
Err(_) => return CommandResult::error("Failed to access RLM session"),
};
let base_id = context_id_from_path(&resolved);
let id = unique_context_id(&session, &base_id);
let (line_count, char_count) = match session.load_file(&id, &resolved) {
Ok(stats) => stats,
Err(err) => {
return CommandResult::error(format!("Failed to load {}: {err}", resolved.display()));
}
};
CommandResult::message(format!(
"Loaded {} ({} lines, {} chars)",
resolved.display(),
line_count,
char_count
))
}
pub fn save_session(app: &mut App, path: Option<&str>) -> CommandResult {
let save_path = if let Some(p) = path {
PathBuf::from(p)
} else {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
PathBuf::from(format!("rlm_session_{timestamp}.json"))
};
let parent_dir = save_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(std::path::Path::to_path_buf);
if let Some(dir) = parent_dir
&& let Err(err) = fs::create_dir_all(&dir)
{
return CommandResult::error(format!(
"Failed to create directory {}: {err}",
dir.display()
));
}
let session = match app.rlm_session.lock() {
Ok(session) => session,
Err(_) => return CommandResult::error("Failed to access RLM session"),
};
let json = match serde_json::to_string_pretty(&*session) {
Ok(json) => json,
Err(err) => return CommandResult::error(format!("Failed to serialize session: {err}")),
};
match fs::write(&save_path, json) {
Ok(()) => CommandResult::message(format!("RLM session saved to {}", save_path.display())),
Err(err) => CommandResult::error(format!("Failed to save session: {err}")),
}
}
fn resolve_path(app: &App, raw: &str) -> Result<PathBuf, String> {
let raw = raw.trim();
let (raw, force_workspace) = if let Some(stripped) = raw.strip_prefix('@') {
(stripped.trim(), true)
} else {
(raw, false)
};
if raw.is_empty() {
return Err("Usage: /load <path> (use @ for workspace-relative paths)".to_string());
}
let candidate = if force_workspace {
app.workspace.join(raw.trim_start_matches(['/', '\\']))
} else if Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
app.workspace.join(raw)
};
let canonical = candidate.canonicalize().map_err(|err| {
let mut message = format!("Failed to resolve path {}: {err}", candidate.display());
if !force_workspace {
message.push_str("\nTip: use /load @path to resolve relative to the workspace.");
}
message
})?;
let workspace_root = app
.workspace
.canonicalize()
.unwrap_or_else(|_| app.workspace.clone());
if !app.trust_mode && !canonical.starts_with(&workspace_root) {
return Err("Path is outside workspace. Use /trust to allow access.".to_string());
}
Ok(canonical)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::{App, TuiOptions};
use std::fs;
fn make_app(workspace: PathBuf) -> App {
let options = TuiOptions {
model: "test-model".to_string(),
workspace,
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 resolve_path_with_at_prefix_uses_workspace_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let docs_dir = tmp.path().join("docs");
fs::create_dir_all(&docs_dir).expect("create docs dir");
let file_path = docs_dir.join("rlm-paper.txt");
fs::write(&file_path, "hello").expect("write file");
let app = make_app(tmp.path().to_path_buf());
let resolved = resolve_path(&app, "@/docs/rlm-paper.txt").expect("resolve path with @");
assert_eq!(resolved, file_path.canonicalize().expect("canonicalize"));
}
}
+211 -15
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));
@@ -116,20 +118,12 @@ 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();
/// Trigger context compaction
pub fn compact(_app: &mut App) -> CommandResult {
// Trigger immediate compaction via engine
CommandResult::with_message_and_action(
format!(
"Auto-compact: {}",
if app.auto_compact { "ON" } else { "OFF" }
),
AppAction::UpdateCompaction(compaction),
"Context compaction triggered...".to_string(),
AppAction::CompactContext,
)
}
@@ -158,7 +152,7 @@ pub fn export(app: &mut App, path: Option<&str>) -> CommandResult {
HistoryCell::User { content } => ("**You:**", content.clone()),
HistoryCell::Assistant { content, .. } => ("**Assistant:**", content.clone()),
HistoryCell::System { content } => ("*System:*", content.clone()),
HistoryCell::ThinkingSummary { summary } => ("*Thinking:*", summary.clone()),
HistoryCell::Thinking { content, .. } => ("*Thinking:*", content.clone()),
HistoryCell::Tool(tool) => ("**Tool:**", render_tool_cell(tool, 80)),
};
@@ -191,3 +185,205 @@ 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 result = compact(&mut app);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("compaction") || msg.contains("Compact"));
assert!(matches!(result.action, Some(AppAction::CompactContext)));
}
#[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());
}
}
+852 -37
View File
File diff suppressed because it is too large. Load diff
+552 -32
View File
@@ -7,12 +7,27 @@ 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-reasoner";
const API_KEYRING_SENTINEL: &str = "__KEYRING__";
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &["deepseek-chat", "deepseek-reasoner"];
/// Canonicalize supported model aliases to one of the two supported IDs.
#[must_use]
pub fn canonical_model_name(model: &str) -> Option<&'static str> {
match model.trim().to_ascii_lowercase().as_str() {
"deepseek-chat" | "deepseek-v3" | "deepseek-v3.2" => Some("deepseek-chat"),
"deepseek-reasoner" | "deepseek-r1" => Some("deepseek-reasoner"),
_ => None,
}
}
// === Types ===
@@ -42,13 +57,34 @@ pub struct RetryPolicy {
pub exponential_base: f64,
}
/// Capacity-controller config loaded from config files/environment.
#[derive(Debug, Clone, Deserialize)]
pub struct CapacityConfig {
pub enabled: Option<bool>,
pub low_risk_max: Option<f64>,
pub medium_risk_max: Option<f64>,
pub severe_min_slack: Option<f64>,
pub severe_violation_ratio: Option<f64>,
pub refresh_cooldown_turns: Option<u64>,
pub replan_cooldown_turns: Option<u64>,
pub max_replay_per_turn: Option<usize>,
pub min_turns_before_guardrail: Option<u64>,
pub profile_window: Option<usize>,
pub deepseek_v3_2_chat_prior: Option<f64>,
pub deepseek_v3_2_reasoner_prior: Option<f64>,
pub fallback_default_prior: Option<f64>,
}
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,8 +101,13 @@ 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 capacity: Option<CapacityConfig>,
pub features: Option<FeaturesToml>,
/// TUI configuration (alternate screen, etc.)
@@ -84,6 +125,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 {
@@ -97,7 +146,7 @@ impl Config {
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn load(path: Option<PathBuf>, profile: Option<&str>) -> Result<Self> {
let path = path.or_else(default_config_path);
let path = resolve_load_config_path(path);
let mut config = if let Some(path) = path.as_ref() {
if path.exists() {
let contents = fs::read_to_string(path)
@@ -113,6 +162,9 @@ impl Config {
};
apply_env_overrides(&mut config);
apply_managed_overrides(&mut config)?;
apply_requirements(&mut config)?;
normalize_model_config(&mut config);
config.validate()?;
Ok(config)
}
@@ -131,6 +183,35 @@ impl Config {
}
}
}
if let Some(model) = self.default_text_model.as_deref()
&& canonical_model_name(model).is_none()
{
anyhow::bail!(
"Invalid default_text_model '{model}': expected deepseek-chat or deepseek-reasoner."
);
}
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()
{
@@ -141,6 +222,36 @@ impl Config {
);
}
}
if let Some(capacity) = &self.capacity {
if let Some(v) = capacity.low_risk_max
&& !(0.0..=1.0).contains(&v)
{
anyhow::bail!(
"Invalid capacity.low_risk_max '{v}': expected a value in [0.0, 1.0]."
);
}
if let Some(v) = capacity.medium_risk_max
&& !(0.0..=1.0).contains(&v)
{
anyhow::bail!(
"Invalid capacity.medium_risk_max '{v}': expected a value in [0.0, 1.0]."
);
}
if let (Some(low), Some(medium)) = (capacity.low_risk_max, capacity.medium_risk_max)
&& low > medium
{
anyhow::bail!(
"Invalid capacity thresholds: low_risk_max ({low}) must be <= medium_risk_max ({medium})."
);
}
if let Some(v) = capacity.severe_violation_ratio
&& !(0.0..=1.0).contains(&v)
{
anyhow::bail!(
"Invalid capacity.severe_violation_ratio '{v}': expected a value in [0.0, 1.0]."
);
}
}
Ok(())
}
@@ -156,11 +267,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.
@@ -206,7 +334,7 @@ impl Config {
/// Return whether shell execution is allowed.
#[must_use]
pub fn allow_shell(&self) -> bool {
self.allow_shell.unwrap_or(false)
self.allow_shell.unwrap_or(true)
}
/// Return the maximum number of concurrent sub-agents.
@@ -270,33 +398,135 @@ impl Config {
// === Defaults ===
fn default_config_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH")
&& !path.trim().is_empty()
env_config_path().or_else(home_config_path)
}
fn effective_home_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("HOME") {
let path = PathBuf::from(path);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
if let Some(path) = std::env::var_os("USERPROFILE") {
let path = PathBuf::from(path);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
#[cfg(windows)]
{
return Some(PathBuf::from(path));
if let (Some(drive), Some(homepath)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut path = PathBuf::from(drive);
path.push(homepath);
if !path.as_os_str().is_empty() {
return Some(path);
}
}
}
dirs::home_dir()
}
fn home_config_path() -> Option<PathBuf> {
effective_home_dir().map(|home| home.join(".deepseek").join("config.toml"))
}
fn env_config_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(expand_path(trimmed));
}
}
None
}
fn expand_pathbuf(path: PathBuf) -> PathBuf {
if let Some(raw) = path.to_str() {
return expand_path(raw);
}
path
}
fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf> {
if let Some(path) = path {
return Some(expand_pathbuf(path));
}
if let Some(path) = env_config_path() {
if path.exists() {
return Some(path);
}
if let Some(home_path) = home_config_path()
&& home_path.exists()
{
return Some(home_path);
}
return Some(path);
}
home_config_path()
}
fn default_managed_config_path() -> Option<PathBuf> {
#[cfg(unix)]
{
Some(PathBuf::from("/etc/deepseek/managed_config.toml"))
}
#[cfg(not(unix))]
{
effective_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))]
{
effective_home_dir().map(|home| home.join(".deepseek").join("requirements.toml"))
}
dirs::home_dir().map(|home| home.join(".deepseek").join("config.toml"))
}
fn expand_path(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix('~')
&& (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\'))
&& let Some(mut home) = effective_home_dir()
{
let suffix = stripped.trim_start_matches(|ch| ch == '/' || ch == '\\');
if !suffix.is_empty() {
home.push(suffix);
}
return home;
}
let expanded = shellexpand::tilde(path);
PathBuf::from(expanded.as_ref())
}
fn default_skills_dir() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".deepseek").join("skills"))
effective_home_dir().map(|home| home.join(".deepseek").join("skills"))
}
fn default_mcp_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".deepseek").join("mcp.json"))
effective_home_dir().map(|home| home.join(".deepseek").join("mcp.json"))
}
fn default_notes_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".deepseek").join("notes.txt"))
effective_home_dir().map(|home| home.join(".deepseek").join("notes.txt"))
}
fn default_memory_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".deepseek").join("memory.md"))
effective_home_dir().map(|home| home.join(".deepseek").join("memory.md"))
}
// === Environment Overrides ===
@@ -323,11 +553,130 @@ 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>()
{
config.max_subagents = Some(parsed.clamp(1, MAX_SUBAGENTS));
}
let capacity = config.capacity.get_or_insert(CapacityConfig {
enabled: None,
low_risk_max: None,
medium_risk_max: None,
severe_min_slack: None,
severe_violation_ratio: None,
refresh_cooldown_turns: None,
replan_cooldown_turns: None,
max_replay_per_turn: None,
min_turns_before_guardrail: None,
profile_window: None,
deepseek_v3_2_chat_prior: None,
deepseek_v3_2_reasoner_prior: None,
fallback_default_prior: None,
});
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_ENABLED") {
let val = value.trim().to_ascii_lowercase();
capacity.enabled = Some(matches!(val.as_str(), "1" | "true" | "yes" | "on"));
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_LOW_RISK_MAX")
&& let Ok(parsed) = value.parse::<f64>()
{
capacity.low_risk_max = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MEDIUM_RISK_MAX")
&& let Ok(parsed) = value.parse::<f64>()
{
capacity.medium_risk_max = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_SEVERE_MIN_SLACK")
&& let Ok(parsed) = value.parse::<f64>()
{
capacity.severe_min_slack = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_SEVERE_VIOLATION_RATIO")
&& let Ok(parsed) = value.parse::<f64>()
{
capacity.severe_violation_ratio = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_REFRESH_COOLDOWN_TURNS")
&& let Ok(parsed) = value.parse::<u64>()
{
capacity.refresh_cooldown_turns = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_REPLAN_COOLDOWN_TURNS")
&& let Ok(parsed) = value.parse::<u64>()
{
capacity.replan_cooldown_turns = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MAX_REPLAY_PER_TURN")
&& let Ok(parsed) = value.parse::<usize>()
{
capacity.max_replay_per_turn = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_MIN_TURNS_BEFORE_GUARDRAIL")
&& let Ok(parsed) = value.parse::<u64>()
{
capacity.min_turns_before_guardrail = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PROFILE_WINDOW")
&& let Ok(parsed) = value.parse::<usize>()
{
capacity.profile_window = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_CHAT")
&& let Ok(parsed) = value.parse::<f64>()
{
capacity.deepseek_v3_2_chat_prior = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_REASONER")
&& let Ok(parsed) = value.parse::<f64>()
{
capacity.deepseek_v3_2_reasoner_prior = Some(parsed);
}
if let Ok(value) = std::env::var("DEEPSEEK_CAPACITY_PRIOR_FALLBACK")
&& let Ok(parsed) = value.parse::<f64>()
{
capacity.fallback_default_prior = Some(parsed);
}
if config.capacity.as_ref().is_some_and(|c| {
c.enabled.is_none()
&& c.low_risk_max.is_none()
&& c.medium_risk_max.is_none()
&& c.severe_min_slack.is_none()
&& c.severe_violation_ratio.is_none()
&& c.refresh_cooldown_turns.is_none()
&& c.replan_cooldown_turns.is_none()
&& c.max_replay_per_turn.is_none()
&& c.min_turns_before_guardrail.is_none()
&& c.profile_window.is_none()
&& c.deepseek_v3_2_chat_prior.is_none()
&& c.deepseek_v3_2_reasoner_prior.is_none()
&& c.fallback_default_prior.is_none()
}) {
config.capacity = None;
}
}
fn normalize_model_config(config: &mut Config) {
if let Some(model) = config.default_text_model.as_deref()
&& let Some(canonical) = canonical_model_name(model)
{
config.default_text_model = Some(canonical.to_string());
}
}
fn normalize_base_url(base: &str) -> String {
@@ -382,14 +731,97 @@ 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),
capacity: override_cfg.capacity.or(base.capacity),
tui: override_cfg.tui.or(base.tui),
hooks: override_cfg.hooks.or(base.hooks),
features: merge_features(base.features, override_cfg.features),
}
}
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 +861,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 +873,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 +882,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-reasoner"
"#
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 +954,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(())
}
@@ -504,10 +968,10 @@ pub fn clear_api_key() -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::lock_test_env;
use std::collections::HashMap;
use std::env;
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
struct EnvGuard {
@@ -576,14 +1040,9 @@ mod tests {
}
}
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[test]
fn save_api_key_writes_config() -> Result<()> {
let _lock = env_lock().lock().unwrap();
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@@ -601,13 +1060,13 @@ 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(())
}
#[test]
fn test_tilde_expansion_in_paths() -> Result<()> {
let _lock = env_lock().lock().unwrap();
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@@ -635,6 +1094,67 @@ mod tests {
Ok(())
}
#[test]
fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-cli-load-tilde-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".custom-deepseek").join("config.toml");
ensure_parent_dir(&config_path)?;
fs::write(&config_path, "api_key = \"test-key\"\n")?;
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_CONFIG_PATH", "~/.custom-deepseek/config.toml");
}
let config = Config::load(None, None)?;
assert_eq!(config.api_key.as_deref(), Some("test-key"));
Ok(())
}
#[test]
fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-cli-load-fallback-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let home_config = temp_root.join(".deepseek").join("config.toml");
ensure_parent_dir(&home_config)?;
fs::write(&home_config, "api_key = \"home-key\"\n")?;
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var(
"DEEPSEEK_CONFIG_PATH",
temp_root.join("missing-config.toml").as_os_str(),
);
}
let config = Config::load(None, None)?;
assert_eq!(config.api_key.as_deref(), Some("home-key"));
Ok(())
}
#[test]
fn test_nonexistent_profile_error() {
let mut profiles = HashMap::new();
@@ -664,7 +1184,7 @@ mod tests {
#[test]
fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {
let _lock = env_lock().lock().unwrap();
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
@@ -689,7 +1209,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(())
}
+610
View File
@@ -0,0 +1,610 @@
//! Capacity-aware guardrail controller for context pressure management.
use std::collections::{HashMap, VecDeque};
/// Controller settings.
#[derive(Debug, Clone, PartialEq)]
pub struct CapacityControllerConfig {
pub enabled: bool,
pub low_risk_max: f64,
pub medium_risk_max: f64,
pub severe_min_slack: f64,
pub severe_violation_ratio: f64,
pub refresh_cooldown_turns: u64,
pub replan_cooldown_turns: u64,
pub max_replay_per_turn: usize,
pub min_turns_before_guardrail: u64,
pub profile_window: usize,
pub model_priors: HashMap<String, f64>,
pub fallback_default: f64,
}
impl Default for CapacityControllerConfig {
fn default() -> Self {
let mut model_priors = HashMap::new();
model_priors.insert("deepseek_v3_2_chat".to_string(), 3.9);
model_priors.insert("deepseek_v3_2_reasoner".to_string(), 4.1);
Self {
enabled: true,
low_risk_max: 0.34,
medium_risk_max: 0.62,
severe_min_slack: -0.25,
severe_violation_ratio: 0.40,
refresh_cooldown_turns: 2,
replan_cooldown_turns: 5,
max_replay_per_turn: 1,
min_turns_before_guardrail: 2,
profile_window: 8,
model_priors,
fallback_default: 3.8,
}
}
}
impl CapacityControllerConfig {
/// Build effective capacity config from app config.
#[must_use]
pub fn from_app_config(config: &crate::config::Config) -> Self {
let mut out = Self::default();
let Some(capacity) = config.capacity.as_ref() else {
return out;
};
if let Some(v) = capacity.enabled {
out.enabled = v;
}
if let Some(v) = capacity.low_risk_max {
out.low_risk_max = v;
}
if let Some(v) = capacity.medium_risk_max {
out.medium_risk_max = v;
}
if let Some(v) = capacity.severe_min_slack {
out.severe_min_slack = v;
}
if let Some(v) = capacity.severe_violation_ratio {
out.severe_violation_ratio = v;
}
if let Some(v) = capacity.refresh_cooldown_turns {
out.refresh_cooldown_turns = v;
}
if let Some(v) = capacity.replan_cooldown_turns {
out.replan_cooldown_turns = v;
}
if let Some(v) = capacity.max_replay_per_turn {
out.max_replay_per_turn = v;
}
if let Some(v) = capacity.min_turns_before_guardrail {
out.min_turns_before_guardrail = v;
}
if let Some(v) = capacity.profile_window {
out.profile_window = v.max(2);
}
if let Some(v) = capacity.deepseek_v3_2_chat_prior {
out.model_priors.insert("deepseek_v3_2_chat".to_string(), v);
}
if let Some(v) = capacity.deepseek_v3_2_reasoner_prior {
out.model_priors
.insert("deepseek_v3_2_reasoner".to_string(), v);
}
if let Some(v) = capacity.fallback_default_prior {
out.fallback_default = v;
}
out
}
}
/// Guardrail decision output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GuardrailAction {
NoIntervention,
TargetedContextRefresh,
VerifyWithToolReplay,
VerifyAndReplan,
}
impl GuardrailAction {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
GuardrailAction::NoIntervention => "no_intervention",
GuardrailAction::TargetedContextRefresh => "targeted_context_refresh",
GuardrailAction::VerifyWithToolReplay => "verify_with_tool_replay",
GuardrailAction::VerifyAndReplan => "verify_and_replan",
}
}
}
/// Coarse failure risk band.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskBand {
Low,
Medium,
High,
}
impl RiskBand {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
RiskBand::Low => "low",
RiskBand::Medium => "medium",
RiskBand::High => "high",
}
}
}
/// Input used to observe current turn pressure.
#[derive(Debug, Clone)]
pub struct CapacityObservationInput {
pub turn_index: u64,
pub model: String,
pub action_count_this_turn: usize,
pub tool_calls_recent_window: usize,
pub unique_reference_ids_recent_window: usize,
pub context_used_ratio: f64,
}
/// Rolling slack profile.
#[derive(Debug, Clone, Copy, Default)]
pub struct DynamicSlackProfile {
pub final_slack: f64,
pub min_slack: f64,
pub violation_ratio: f64,
pub slack_volatility: f64,
pub slack_drop: f64,
}
/// Per-checkpoint capacity snapshot.
#[derive(Debug, Clone)]
pub struct CapacitySnapshot {
pub turn_index: u64,
pub h_hat: f64,
pub c_hat: f64,
pub slack: f64,
pub profile: DynamicSlackProfile,
pub p_fail: f64,
pub risk_band: RiskBand,
pub severe: bool,
}
/// Full controller decision including reason and block flags.
#[derive(Debug, Clone)]
pub struct CapacityDecision {
pub action: GuardrailAction,
pub reason: String,
pub cooldown_blocked: bool,
}
#[derive(Debug, Clone, Default)]
struct GuardrailRuntimeState {
last_refresh_turn: Option<u64>,
last_replan_turn: Option<u64>,
replay_count_this_turn: usize,
replay_disabled_turn: Option<u64>,
intervention_applied_turn: Option<u64>,
}
/// Capacity controller.
#[derive(Debug, Clone)]
pub struct CapacityController {
config: CapacityControllerConfig,
slack_window: VecDeque<f64>,
recent_tool_counts: VecDeque<usize>,
recent_ref_counts: VecDeque<usize>,
state: GuardrailRuntimeState,
last_snapshot: Option<CapacitySnapshot>,
}
impl CapacityController {
#[must_use]
pub fn new(config: CapacityControllerConfig) -> Self {
Self {
config,
slack_window: VecDeque::new(),
recent_tool_counts: VecDeque::new(),
recent_ref_counts: VecDeque::new(),
state: GuardrailRuntimeState::default(),
last_snapshot: None,
}
}
#[must_use]
pub fn config(&self) -> &CapacityControllerConfig {
&self.config
}
pub fn observe_pre_turn(
&mut self,
input: CapacityObservationInput,
) -> Option<CapacitySnapshot> {
self.observe(input)
}
pub fn observe_post_tool(
&mut self,
input: CapacityObservationInput,
) -> Option<CapacitySnapshot> {
self.observe(input)
}
/// Decide intervention from the latest snapshot, with cooldown and safety gates.
#[must_use]
pub fn decide(
&mut self,
turn_index: u64,
snapshot: Option<&CapacitySnapshot>,
) -> CapacityDecision {
if !self.config.enabled {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "capacity_controller_disabled".to_string(),
cooldown_blocked: false,
};
}
let Some(snapshot) = snapshot else {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "missing_capacity_data_fail_open".to_string(),
cooldown_blocked: false,
};
};
if turn_index < self.config.min_turns_before_guardrail {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "min_turns_before_guardrail_not_reached".to_string(),
cooldown_blocked: false,
};
}
let proposed = decide_policy(&self.config, snapshot);
if proposed == GuardrailAction::NoIntervention {
return CapacityDecision {
action: proposed,
reason: "low_risk_no_intervention".to_string(),
cooldown_blocked: false,
};
}
if self
.state
.intervention_applied_turn
.is_some_and(|t| t == turn_index)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "intervention_already_applied_this_turn".to_string(),
cooldown_blocked: true,
};
}
match proposed {
GuardrailAction::TargetedContextRefresh => {
if self
.state
.last_refresh_turn
.is_some_and(|last| turn_index <= last + self.config.refresh_cooldown_turns)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "refresh_cooldown_active".to_string(),
cooldown_blocked: true,
};
}
}
GuardrailAction::VerifyWithToolReplay => {
if self
.state
.replay_disabled_turn
.is_some_and(|t| t == turn_index)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "replay_disabled_for_turn".to_string(),
cooldown_blocked: true,
};
}
if self.state.replay_count_this_turn >= self.config.max_replay_per_turn {
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "max_replay_per_turn_reached".to_string(),
cooldown_blocked: true,
};
}
}
GuardrailAction::VerifyAndReplan => {
if self
.state
.last_replan_turn
.is_some_and(|last| turn_index <= last + self.config.replan_cooldown_turns)
{
return CapacityDecision {
action: GuardrailAction::NoIntervention,
reason: "replan_cooldown_active".to_string(),
cooldown_blocked: true,
};
}
}
GuardrailAction::NoIntervention => {}
}
CapacityDecision {
action: proposed,
reason: "policy_selected_action".to_string(),
cooldown_blocked: false,
}
}
pub fn mark_turn_start(&mut self, turn_index: u64) {
let new_turn = match self.last_snapshot.as_ref() {
None => true,
Some(snapshot) => snapshot.turn_index != turn_index,
};
if new_turn {
self.state.replay_count_this_turn = 0;
self.state.replay_disabled_turn = None;
self.state.intervention_applied_turn = None;
}
}
pub fn mark_intervention_applied(&mut self, turn_index: u64, action: GuardrailAction) {
self.state.intervention_applied_turn = Some(turn_index);
match action {
GuardrailAction::TargetedContextRefresh => {
self.state.last_refresh_turn = Some(turn_index);
}
GuardrailAction::VerifyWithToolReplay => {
self.state.replay_count_this_turn =
self.state.replay_count_this_turn.saturating_add(1);
}
GuardrailAction::VerifyAndReplan => {
self.state.last_replan_turn = Some(turn_index);
}
GuardrailAction::NoIntervention => {}
}
}
pub fn mark_replay_failed(&mut self, turn_index: u64) {
self.state.replay_disabled_turn = Some(turn_index);
}
#[must_use]
pub fn last_snapshot(&self) -> Option<&CapacitySnapshot> {
self.last_snapshot.as_ref()
}
fn observe(&mut self, input: CapacityObservationInput) -> Option<CapacitySnapshot> {
if !self.config.enabled {
return None;
}
let context_used_ratio = input.context_used_ratio.clamp(0.0, 2.0);
let action_complexity_bits = log2_1p(input.action_count_this_turn);
let tool_complexity_bits = log2_1p(input.tool_calls_recent_window);
let ref_complexity_bits = log2_1p(input.unique_reference_ids_recent_window);
let context_pressure_bits = 6.0 * context_used_ratio;
let h_hat = (0.35 * action_complexity_bits)
+ (0.30 * tool_complexity_bits)
+ (0.20 * ref_complexity_bits)
+ (0.15 * context_pressure_bits);
let c_hat = self.model_prior(&input.model);
let slack = c_hat - h_hat;
push_window(&mut self.slack_window, slack, self.config.profile_window);
push_window(
&mut self.recent_tool_counts,
input.tool_calls_recent_window,
self.config.profile_window,
);
push_window(
&mut self.recent_ref_counts,
input.unique_reference_ids_recent_window,
self.config.profile_window,
);
let profile = compute_profile(&self.slack_window);
let z = (-1.65 * profile.final_slack)
+ (-0.85 * profile.min_slack)
+ (1.35 * profile.violation_ratio)
+ (0.70 * profile.slack_volatility)
+ (0.28 * profile.slack_drop)
- 0.12;
let p_fail = sigmoid(z).clamp(0.0, 1.0);
let risk_band = if p_fail <= self.config.low_risk_max {
RiskBand::Low
} else if p_fail <= self.config.medium_risk_max {
RiskBand::Medium
} else {
RiskBand::High
};
let severe = profile.min_slack <= self.config.severe_min_slack
|| profile.violation_ratio >= self.config.severe_violation_ratio;
let snapshot = CapacitySnapshot {
turn_index: input.turn_index,
h_hat,
c_hat,
slack,
profile,
p_fail,
risk_band,
severe,
};
self.last_snapshot = Some(snapshot.clone());
Some(snapshot)
}
fn model_prior(&self, model: &str) -> f64 {
let normalized = normalize_model_prior_key(model);
self.config
.model_priors
.get(normalized)
.copied()
.unwrap_or(self.config.fallback_default)
}
}
/// Pure policy mapping for snapshot -> action.
#[must_use]
pub fn decide_policy(
_config: &CapacityControllerConfig,
snapshot: &CapacitySnapshot,
) -> GuardrailAction {
match snapshot.risk_band {
RiskBand::Low => GuardrailAction::NoIntervention,
RiskBand::Medium => GuardrailAction::TargetedContextRefresh,
RiskBand::High if snapshot.severe => GuardrailAction::VerifyAndReplan,
RiskBand::High => GuardrailAction::VerifyWithToolReplay,
}
}
fn normalize_model_prior_key(model: &str) -> &str {
let lower = model.to_ascii_lowercase();
if lower.contains("reasoner") || lower.contains("r1") {
"deepseek_v3_2_reasoner"
} else if lower.contains("chat") || lower.contains("v3") {
"deepseek_v3_2_chat"
} else {
"fallback_default"
}
}
fn log2_1p(v: usize) -> f64 {
(1.0 + (v as f64)).log2()
}
fn push_window<T>(window: &mut VecDeque<T>, value: T, max_len: usize) {
window.push_back(value);
while window.len() > max_len {
window.pop_front();
}
}
fn compute_profile(window: &VecDeque<f64>) -> DynamicSlackProfile {
if window.is_empty() {
return DynamicSlackProfile::default();
}
let values: Vec<f64> = window.iter().copied().collect();
let final_slack = *values.last().unwrap_or(&0.0);
let min_slack = values.iter().copied().fold(f64::INFINITY, f64::min);
let violations = values.iter().filter(|v| **v <= 0.0).count() as f64;
let violation_ratio = violations / (values.len() as f64);
let deltas: Vec<f64> = values.windows(2).map(|w| w[1] - w[0]).collect();
let slack_drop = if values.len() >= 2 {
(values[values.len() - 2] - values[values.len() - 1]).max(0.0)
} else {
0.0
};
let slack_volatility = if deltas.is_empty() {
0.0
} else {
let mean = deltas.iter().sum::<f64>() / (deltas.len() as f64);
let var = deltas
.iter()
.map(|delta| {
let centered = *delta - mean;
centered * centered
})
.sum::<f64>()
/ (deltas.len() as f64);
var.sqrt()
};
DynamicSlackProfile {
final_slack,
min_slack,
violation_ratio,
slack_volatility,
slack_drop,
}
}
fn sigmoid(z: f64) -> f64 {
if z >= 0.0 {
let ez = (-z).exp();
1.0 / (1.0 + ez)
} else {
let ez = z.exp();
ez / (1.0 + ez)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_snapshot(p_fail: f64, severe: bool, risk_band: RiskBand) -> CapacitySnapshot {
CapacitySnapshot {
turn_index: 3,
h_hat: 1.0,
c_hat: 3.8,
slack: 2.8,
profile: DynamicSlackProfile {
final_slack: 2.8,
min_slack: if severe { -0.5 } else { 0.2 },
violation_ratio: if severe { 0.6 } else { 0.1 },
slack_volatility: 0.2,
slack_drop: 0.1,
},
p_fail,
risk_band,
severe,
}
}
#[test]
fn low_risk_maps_to_no_intervention() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.2, false, RiskBand::Low);
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::NoIntervention);
}
#[test]
fn medium_risk_maps_to_refresh() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.5, false, RiskBand::Medium);
assert_eq!(
decide_policy(&cfg, &snap),
GuardrailAction::TargetedContextRefresh
);
}
#[test]
fn high_non_severe_maps_to_replay() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.8, false, RiskBand::High);
assert_eq!(
decide_policy(&cfg, &snap),
GuardrailAction::VerifyWithToolReplay
);
}
#[test]
fn high_severe_maps_to_replan() {
let cfg = CapacityControllerConfig::default();
let snap = make_snapshot(0.9, true, RiskBand::High);
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::VerifyAndReplan);
}
#[test]
fn cooldown_blocks_repeated_action() {
let mut controller = CapacityController::new(CapacityControllerConfig::default());
let turn_index = 5;
controller.mark_turn_start(turn_index);
controller.mark_intervention_applied(turn_index, GuardrailAction::TargetedContextRefresh);
let snapshot = make_snapshot(0.5, false, RiskBand::Medium);
let decision = controller.decide(turn_index + 1, Some(&snapshot));
assert_eq!(decision.action, GuardrailAction::NoIntervention);
assert!(decision.cooldown_blocked);
}
}
+337
View File
@@ -0,0 +1,337 @@
//! Persistent memory snapshots for capacity controller interventions.
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use serde::{Deserialize, Serialize};
/// Canonical compact state persisted by interventions.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CanonicalState {
pub goal: String,
pub constraints: Vec<String>,
pub confirmed_facts: Vec<String>,
pub open_loops: Vec<String>,
pub pending_actions: Vec<String>,
pub critical_refs: Vec<String>,
}
/// Replay verification metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReplayInfo {
pub tool_id: String,
pub tool_name: String,
pub pass: bool,
pub diff_summary: String,
}
/// JSONL record written for each intervention.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapacityMemoryRecord {
pub id: String,
pub ts: String,
pub turn_index: u64,
pub action_trigger: String,
pub h_hat: f64,
pub c_hat: f64,
pub slack: f64,
pub risk_band: String,
pub canonical_state: CanonicalState,
pub source_message_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub replay_info: Option<ReplayInfo>,
}
/// Resolve `~/.deepseek/memory`.
#[must_use]
pub fn default_capacity_memory_dir() -> PathBuf {
capacity_memory_dirs()
.into_iter()
.next()
.unwrap_or_else(|| PathBuf::from(".").join(".deepseek").join("memory"))
}
fn capacity_memory_dirs() -> Vec<PathBuf> {
if let Ok(raw) = std::env::var("DEEPSEEK_CAPACITY_MEMORY_DIR") {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return vec![PathBuf::from(shellexpand::tilde(trimmed).as_ref())];
}
}
let mut dirs = Vec::new();
if let Some(home) = dirs::home_dir() {
dirs.push(home.join(".deepseek").join("memory"));
}
let cwd = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".deepseek")
.join("memory");
dirs.push(cwd);
dirs.dedup();
dirs
}
#[must_use]
pub fn session_memory_path(session_id: &str) -> PathBuf {
default_capacity_memory_dir().join(format!("{session_id}.jsonl"))
}
pub fn append_capacity_record(session_id: &str, record: &CapacityMemoryRecord) -> Result<PathBuf> {
let candidates = candidate_session_memory_paths(session_id);
append_capacity_record_to_candidates(&candidates, record)
}
pub fn append_capacity_record_to_path(path: &Path, record: &CapacityMemoryRecord) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create memory directory {}", parent.display()))?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
let line =
serde_json::to_string(record).context("Failed to serialize capacity memory record")?;
writeln!(file, "{line}")
.with_context(|| format!("Failed to write memory record {}", path.display()))?;
Ok(())
}
pub fn load_last_k_capacity_records(
session_id: &str,
k: usize,
) -> Result<Vec<CapacityMemoryRecord>> {
let candidates = candidate_session_memory_paths(session_id);
load_last_k_capacity_records_from_candidates(&candidates, k)
}
pub fn load_last_k_capacity_records_from_path(
path: &Path,
k: usize,
) -> Result<Vec<CapacityMemoryRecord>> {
if k == 0 || !path.exists() {
return Ok(Vec::new());
}
let file = OpenOptions::new()
.read(true)
.open(path)
.with_context(|| format!("Failed to open memory log {}", path.display()))?;
let reader = BufReader::new(file);
let mut records = Vec::new();
for line in reader.lines() {
let line = line.with_context(|| format!("Failed reading {}", path.display()))?;
if line.trim().is_empty() {
continue;
}
if let Ok(record) = serde_json::from_str::<CapacityMemoryRecord>(&line) {
records.push(record);
}
}
if records.len() > k {
Ok(records.split_off(records.len() - k))
} else {
Ok(records)
}
}
fn candidate_session_memory_paths(session_id: &str) -> Vec<PathBuf> {
capacity_memory_dirs()
.into_iter()
.map(|dir| dir.join(format!("{session_id}.jsonl")))
.collect()
}
fn append_capacity_record_to_candidates(
paths: &[PathBuf],
record: &CapacityMemoryRecord,
) -> Result<PathBuf> {
let mut last_err: Option<anyhow::Error> = None;
for path in paths {
match append_capacity_record_to_path(path, record) {
Ok(()) => return Ok(path.clone()),
Err(err) => last_err = Some(err),
}
}
Err(last_err.unwrap_or_else(|| anyhow!("No capacity memory path candidates available")))
}
fn load_last_k_capacity_records_from_candidates(
paths: &[PathBuf],
k: usize,
) -> Result<Vec<CapacityMemoryRecord>> {
if k == 0 {
return Ok(Vec::new());
}
let mut newest: Option<(SystemTime, Vec<CapacityMemoryRecord>)> = None;
let mut last_err: Option<anyhow::Error> = None;
for path in paths {
if !path.exists() {
continue;
}
match load_last_k_capacity_records_from_path(path, k) {
Ok(records) => {
if records.is_empty() {
continue;
}
let modified = fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let should_replace = newest
.as_ref()
.map(|(current, _)| modified >= *current)
.unwrap_or(true);
if should_replace {
newest = Some((modified, records));
}
}
Err(err) => last_err = Some(err),
}
}
if let Some((_, records)) = newest {
return Ok(records);
}
if let Some(err) = last_err {
return Err(err);
}
Ok(Vec::new())
}
#[must_use]
pub fn new_record_id() -> String {
format!("cap_{}", &uuid::Uuid::new_v4().to_string()[..8])
}
#[must_use]
pub fn now_rfc3339() -> String {
Utc::now().to_rfc3339()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn memory_jsonl_round_trip() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join("session.jsonl");
let record = CapacityMemoryRecord {
id: "cap_1".to_string(),
ts: now_rfc3339(),
turn_index: 2,
action_trigger: "targeted_context_refresh".to_string(),
h_hat: 1.2,
c_hat: 3.8,
slack: 2.6,
risk_band: "medium".to_string(),
canonical_state: CanonicalState {
goal: "Ship feature".to_string(),
..CanonicalState::default()
},
source_message_ids: vec!["m1".to_string()],
replay_info: None,
};
append_capacity_record_to_path(&path, &record).expect("append");
let records = load_last_k_capacity_records_from_path(&path, 1).expect("load");
assert_eq!(records.len(), 1);
assert_eq!(records[0].canonical_state.goal, "Ship feature");
}
#[test]
fn append_falls_back_to_next_candidate_path() {
let tmp = tempdir().expect("tempdir");
let blocked_root = tmp.path().join("blocked");
fs::write(&blocked_root, "file").expect("create blocking file");
let blocked_path = blocked_root.join("session.jsonl");
let fallback_path = tmp.path().join("fallback").join("session.jsonl");
let record = CapacityMemoryRecord {
id: "cap_fallback".to_string(),
ts: now_rfc3339(),
turn_index: 1,
action_trigger: "targeted_context_refresh".to_string(),
h_hat: 1.0,
c_hat: 3.8,
slack: 2.8,
risk_band: "medium".to_string(),
canonical_state: CanonicalState::default(),
source_message_ids: vec!["m1".to_string()],
replay_info: None,
};
let chosen = append_capacity_record_to_candidates(
&[blocked_path.clone(), fallback_path.clone()],
&record,
)
.expect("append with fallback");
assert_eq!(chosen, fallback_path);
assert!(chosen.exists());
}
#[test]
fn load_prefers_newest_candidate_records() {
let tmp = tempdir().expect("tempdir");
let older = tmp.path().join("older.jsonl");
let newer = tmp.path().join("newer.jsonl");
let old_record = CapacityMemoryRecord {
id: "cap_old".to_string(),
ts: now_rfc3339(),
turn_index: 1,
action_trigger: "targeted_context_refresh".to_string(),
h_hat: 1.0,
c_hat: 3.8,
slack: 2.8,
risk_band: "medium".to_string(),
canonical_state: CanonicalState {
goal: "old".to_string(),
..CanonicalState::default()
},
source_message_ids: vec!["m1".to_string()],
replay_info: None,
};
let new_record = CapacityMemoryRecord {
id: "cap_new".to_string(),
ts: now_rfc3339(),
turn_index: 2,
action_trigger: "verify_and_replan".to_string(),
h_hat: 1.4,
c_hat: 3.8,
slack: 2.4,
risk_band: "high".to_string(),
canonical_state: CanonicalState {
goal: "new".to_string(),
..CanonicalState::default()
},
source_message_ids: vec!["m2".to_string()],
replay_info: None,
};
append_capacity_record_to_path(&older, &old_record).expect("write older");
std::thread::sleep(std::time::Duration::from_millis(10));
append_capacity_record_to_path(&newer, &new_record).expect("write newer");
let records = load_last_k_capacity_records_from_candidates(&[older, newer], 1)
.expect("load newest records");
assert_eq!(records.len(), 1);
assert_eq!(records[0].canonical_state.goal, "new");
}
}
+2098 -212
View File
File diff suppressed because it is too large. Load diff
+313
View File
@@ -0,0 +1,313 @@
use super::*;
use serde_json::json;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tempfile::tempdir;
fn build_engine_with_capacity(capacity: CapacityControllerConfig) -> Engine {
let mut engine_config = EngineConfig::default();
engine_config.capacity = capacity;
let (engine, _handle) = Engine::new(engine_config, &Config::default());
engine
}
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 engine_handle_cancel_tracks_latest_turn_token() {
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
let stale_token = engine.cancel_token.clone();
engine.reset_cancel_token();
handle.cancel();
assert!(engine.cancel_token.is_cancelled());
assert!(handle.is_cancelled());
assert!(!stale_token.is_cancelled());
}
#[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);
}
#[test]
fn detects_context_length_errors_from_provider_payloads() {
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
assert!(is_context_length_error_message(msg));
assert!(!is_context_length_error_message(
"SSE stream request failed: HTTP 400 Bad Request: model not found"
));
}
#[test]
fn context_budget_reserves_output_and_headroom() {
let budget = context_input_budget("deepseek-reasoner", TURN_MAX_OUTPUT_TOKENS)
.expect("deepseek models should have known context window");
let expected = 128_000usize - 4_096usize - 1_024usize;
assert_eq!(budget, expected);
}
#[tokio::test]
async fn pre_request_refresh_invoked_when_medium_risk() {
let mut capacity = CapacityControllerConfig::default();
capacity.enabled = true;
capacity.low_risk_max = 0.0;
capacity.medium_risk_max = 1.0;
capacity.min_turns_before_guardrail = 0;
let mut engine = build_engine_with_capacity(capacity.clone());
engine.config.capacity = capacity.clone();
engine.capacity_controller = CapacityController::new(capacity);
engine.turn_counter = 5;
engine
.capacity_controller
.mark_turn_start(engine.turn_counter);
let long = "x".repeat(5_000);
for _ in 0..200 {
engine.session.messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: long.clone(),
cache_control: None,
}],
});
}
let before = engine.estimated_input_tokens();
let turn = TurnContext::new(10);
let applied = engine
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
.await;
let after = engine.estimated_input_tokens();
assert!(applied);
assert!(after < before);
}
#[tokio::test]
async fn post_tool_replay_invoked_when_high_non_severe_risk() {
let tmp = tempdir().expect("tempdir");
fs::write(tmp.path().join("sample.txt"), "hello replay").expect("write");
let mut capacity = CapacityControllerConfig::default();
capacity.enabled = true;
capacity.low_risk_max = 0.0;
capacity.medium_risk_max = 0.0;
capacity.severe_min_slack = -10.0;
capacity.severe_violation_ratio = 2.0;
capacity.min_turns_before_guardrail = 0;
let mut engine = build_engine_with_capacity(capacity.clone());
engine.session.workspace = tmp.path().to_path_buf();
engine.config.workspace = tmp.path().to_path_buf();
engine.config.capacity = capacity.clone();
engine.capacity_controller = CapacityController::new(capacity);
engine.turn_counter = 4;
engine
.capacity_controller
.mark_turn_start(engine.turn_counter);
let mut turn = TurnContext::new(10);
let mut tool_call = TurnToolCall::new(
"tool_read_1".to_string(),
"read_file".to_string(),
json!({ "path": "sample.txt" }),
);
tool_call.set_result(
"hello replay".to_string(),
std::time::Duration::from_millis(1),
);
turn.record_tool_call(tool_call);
let registry = ToolRegistryBuilder::new()
.with_read_only_file_tools()
.build(engine.build_tool_context(AppMode::Agent));
let restarted = engine
.run_capacity_post_tool_checkpoint(
&turn,
AppMode::Agent,
Some(&registry),
Arc::new(RwLock::new(())),
None,
0,
0,
)
.await;
assert!(!restarted);
let has_verification_note = engine.session.messages.iter().any(|msg| {
msg.content.iter().any(|block| match block {
ContentBlock::ToolResult { content, .. } => content.contains("[verification replay]"),
_ => false,
})
});
assert!(has_verification_note);
}
#[tokio::test]
async fn error_escalation_triggers_replan_when_severe_or_repeated_failures() {
let tmp = tempdir().expect("tempdir");
// Safety: scoped to test process; reset at end.
unsafe {
std::env::set_var(
"DEEPSEEK_CAPACITY_MEMORY_DIR",
tmp.path().to_string_lossy().to_string(),
);
}
let mut capacity = CapacityControllerConfig::default();
capacity.enabled = true;
capacity.low_risk_max = 0.0;
capacity.medium_risk_max = 0.0;
capacity.min_turns_before_guardrail = 0;
let mut engine = build_engine_with_capacity(capacity.clone());
engine.config.capacity = capacity.clone();
engine.capacity_controller = CapacityController::new(capacity);
engine.turn_counter = 6;
engine
.capacity_controller
.mark_turn_start(engine.turn_counter);
for i in 0..10 {
engine.session.messages.push(Message {
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: vec![ContentBlock::Text {
text: format!("noise message {i}"),
cache_control: None,
}],
});
}
engine.session.messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Please finish task".to_string(),
cache_control: None,
}],
});
let before_len = engine.session.messages.len();
let turn = TurnContext::new(10);
let restarted = engine
.run_capacity_error_escalation_checkpoint(&turn, AppMode::Agent, 2, 2)
.await;
assert!(restarted);
assert!(engine.session.messages.len() < before_len);
assert!(engine.session.messages.len() <= 2);
let records = load_last_k_capacity_records(&engine.session.id, 1).expect("load memory");
assert!(!records.is_empty());
assert!(!records[0].canonical_state.goal.is_empty());
unsafe {
std::env::remove_var("DEEPSEEK_CAPACITY_MEMORY_DIR");
}
}
#[tokio::test]
async fn controller_disabled_keeps_behavior_unchanged() {
let mut capacity = CapacityControllerConfig::default();
capacity.enabled = false;
let mut engine = build_engine_with_capacity(capacity.clone());
engine.config.capacity = capacity.clone();
engine.capacity_controller = CapacityController::new(capacity);
engine.turn_counter = 3;
engine
.capacity_controller
.mark_turn_start(engine.turn_counter);
let long = "y".repeat(5_000);
for _ in 0..120 {
engine.session.messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: long.clone(),
cache_control: None,
}],
});
}
let before = engine.estimated_input_tokens();
let before_len = engine.session.messages.len();
let turn = TurnContext::new(10);
let applied = engine
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
.await;
let after = engine.estimated_input_tokens();
let after_len = engine.session.messages.len();
assert!(!applied);
assert_eq!(before, after);
assert_eq!(before_len, after_len);
}
+79 -3
View File
@@ -8,6 +8,15 @@ use serde_json::Value;
use crate::models::Usage;
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)]
@@ -51,12 +60,73 @@ 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>,
},
// === Sub-Agent Events (for RLM mode) ===
/// 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,
},
/// Capacity decision telemetry.
CapacityDecision {
session_id: String,
turn_id: String,
h_hat: f64,
c_hat: f64,
slack: f64,
min_slack: f64,
violation_ratio: f64,
p_fail: f64,
risk_band: String,
action: String,
cooldown_blocked: bool,
reason: String,
},
/// Capacity intervention telemetry.
CapacityIntervention {
session_id: String,
turn_id: String,
action: String,
before_prompt_tokens: usize,
after_prompt_tokens: usize,
compaction_size_reduction: usize,
replay_outcome: Option<String>,
replan_performed: bool,
},
/// Capacity memory persistence failure telemetry.
CapacityMemoryPersistFailed {
session_id: String,
turn_id: String,
action: String,
error: String,
},
// === Sub-Agent Events ===
/// A sub-agent has been spawned
AgentSpawned { id: String, prompt: String },
@@ -89,6 +159,12 @@ pub enum Event {
description: String,
},
/// Request user input for a tool call
UserInputRequired {
id: String,
request: UserInputRequest,
},
/// Request user decision after sandbox denial
ElevationRequired {
tool_id: String,
+2
View File
@@ -11,6 +11,8 @@
#![allow(dead_code)]
pub mod capacity;
pub mod capacity_memory;
pub mod engine;
pub mod events;
pub mod ops;
+4 -1
View File
@@ -29,7 +29,7 @@ pub enum Op {
/// Deny a tool call that requires permission
DenyToolCall { id: String },
/// Spawn a sub-agent (for RLM mode)
/// Spawn a sub-agent
SpawnSubAgent { prompt: String },
/// List current sub-agents and their status
@@ -52,6 +52,9 @@ pub enum Op {
workspace: PathBuf,
},
/// Run context compaction immediately.
CompactContext,
/// Shutdown the engine
Shutdown,
}
+4
View File
@@ -18,6 +18,8 @@ pub struct Session {
/// System prompt (optional)
pub system_prompt: Option<SystemPrompt>,
/// Persisted summary blocks generated by context compaction.
pub compaction_summary_prompt: Option<SystemPrompt>,
/// Conversation history (API format)
pub messages: Vec<Message>,
@@ -88,6 +90,7 @@ impl Session {
model,
workspace,
system_prompt: None,
compaction_summary_prompt: None,
messages: Vec::new(),
total_usage: SessionUsage::default(),
allow_shell,
@@ -125,6 +128,7 @@ impl Session {
/// Clear the conversation history
pub fn clear(&mut self) {
self.messages.clear();
self.compaction_summary_prompt = None;
}
/// Get the message count
-802
View File
@@ -1,802 +0,0 @@
//! Duo mode state machine for hegelion's autocoding (player-coach adversarial cooperation).
//!
//! Implements the g3 paper's coach-player paradigm where:
//! - Player: implements requirements (builder role)
//! - Coach: validates implementation against requirements (critic role)
//!
//! The loop continues until the coach approves or max turns are reached.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// === Phase & Status Enums ===
/// The current phase in the autocoding loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DuoPhase {
/// Session initialized, ready to start player phase
Init,
/// Player is implementing requirements
Player,
/// Coach is validating the implementation
Coach,
/// Coach approved the implementation
Approved,
/// Maximum turns reached without approval
Timeout,
}
impl std::fmt::Display for DuoPhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DuoPhase::Init => write!(f, "init"),
DuoPhase::Player => write!(f, "player"),
DuoPhase::Coach => write!(f, "coach"),
DuoPhase::Approved => write!(f, "approved"),
DuoPhase::Timeout => write!(f, "timeout"),
}
}
}
/// The overall status of the autocoding session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DuoStatus {
/// Session is actively running
Active,
/// Coach has approved the implementation
Approved,
/// Coach has rejected (used for explicit rejection, not just iteration)
Rejected,
/// Maximum turns exhausted without approval
Timeout,
}
impl std::fmt::Display for DuoStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DuoStatus::Active => write!(f, "active"),
DuoStatus::Approved => write!(f, "approved"),
DuoStatus::Rejected => write!(f, "rejected"),
DuoStatus::Timeout => write!(f, "timeout"),
}
}
}
// === Turn History ===
/// Record of a single turn in the autocoding loop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnRecord {
/// Turn number (1-indexed)
pub turn: u32,
/// The phase this record is for
pub phase: DuoPhase,
/// Summary of what happened (player implementation or coach feedback)
pub summary: String,
/// Quality score from coach (0.0 to 1.0), if applicable
pub quality_score: Option<f64>,
/// Timestamp when this turn was recorded
#[serde(default = "chrono::Utc::now")]
pub timestamp: chrono::DateTime<chrono::Utc>,
}
impl TurnRecord {
/// Create a new turn record.
#[must_use]
pub fn new(turn: u32, phase: DuoPhase, summary: String, quality_score: Option<f64>) -> Self {
Self {
turn,
phase,
summary,
quality_score,
timestamp: chrono::Utc::now(),
}
}
}
// === Main State ===
/// The complete state of a Duo autocoding session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DuoState {
/// Unique session identifier
pub session_id: String,
/// Optional human-readable session name
pub session_name: Option<String>,
/// The requirements document (source of truth for validation)
pub requirements: String,
/// Current turn number (1-indexed)
pub current_turn: u32,
/// Maximum allowed turns before timeout
pub max_turns: u32,
/// Current phase in the autocoding loop
pub phase: DuoPhase,
/// Overall session status
pub status: DuoStatus,
/// History of all turns
pub turn_history: Vec<TurnRecord>,
/// Last feedback from the coach (used in next player prompt)
pub last_coach_feedback: Option<String>,
/// Quality scores from each coach review
pub quality_scores: Vec<f64>,
/// Threshold score needed for approval (0.0 to 1.0)
pub approval_threshold: f64,
/// Timestamp when session was created
#[serde(default = "chrono::Utc::now")]
pub created_at: chrono::DateTime<chrono::Utc>,
/// Timestamp of last update
#[serde(default = "chrono::Utc::now")]
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl DuoState {
/// Create a new Duo session with the given requirements.
///
/// # Arguments
/// * `requirements` - The requirements document (source of truth)
/// * `session_name` - Optional human-readable name
/// * `max_turns` - Maximum turns before timeout (default: 10)
/// * `approval_threshold` - Score needed for approval (default: 0.9)
#[must_use]
pub fn create(
requirements: String,
session_name: Option<String>,
max_turns: Option<u32>,
approval_threshold: Option<f64>,
) -> Self {
let now = chrono::Utc::now();
Self {
session_id: Uuid::new_v4().to_string(),
session_name,
requirements,
current_turn: 1,
max_turns: max_turns.unwrap_or(10),
phase: DuoPhase::Init,
status: DuoStatus::Active,
turn_history: Vec::new(),
last_coach_feedback: None,
quality_scores: Vec::new(),
approval_threshold: approval_threshold.unwrap_or(0.9),
created_at: now,
updated_at: now,
}
}
/// Transition from Init or Player phase to Coach phase.
///
/// Records the player's implementation summary in turn history.
///
/// # Arguments
/// * `player_summary` - Summary of what the player implemented
///
/// # Returns
/// `Ok(())` on success, `Err` if not in a valid phase for this transition
pub fn advance_to_coach(&mut self, player_summary: String) -> Result<(), DuoError> {
match self.phase {
DuoPhase::Init | DuoPhase::Player => {
// Record player turn
let record =
TurnRecord::new(self.current_turn, DuoPhase::Player, player_summary, None);
self.turn_history.push(record);
self.phase = DuoPhase::Coach;
self.updated_at = chrono::Utc::now();
Ok(())
}
_ => Err(DuoError::InvalidPhaseTransition {
from: self.phase,
to: DuoPhase::Coach,
}),
}
}
/// Process coach feedback and determine the next phase.
///
/// # Arguments
/// * `coach_feedback` - The coach's feedback text
/// * `approved` - Whether the coach approved the implementation
/// * `compliance_score` - Optional compliance score (0.0 to 1.0)
///
/// # Returns
/// `Ok(())` on success, `Err` if not in coach phase
pub fn advance_turn(
&mut self,
coach_feedback: String,
approved: bool,
compliance_score: Option<f64>,
) -> Result<(), DuoError> {
if self.phase != DuoPhase::Coach {
return Err(DuoError::InvalidPhaseTransition {
from: self.phase,
to: DuoPhase::Player,
});
}
// Record coach turn
let record = TurnRecord::new(
self.current_turn,
DuoPhase::Coach,
coach_feedback.clone(),
compliance_score,
);
self.turn_history.push(record);
// Track quality score if provided
if let Some(score) = compliance_score {
self.quality_scores.push(score);
}
self.last_coach_feedback = Some(coach_feedback);
self.updated_at = chrono::Utc::now();
if approved {
// Coach approved - session complete
self.phase = DuoPhase::Approved;
self.status = DuoStatus::Approved;
} else if self.current_turn >= self.max_turns {
// Max turns reached - timeout
self.phase = DuoPhase::Timeout;
self.status = DuoStatus::Timeout;
} else {
// Continue to next turn
self.current_turn += 1;
self.phase = DuoPhase::Player;
}
Ok(())
}
/// Check if the session is complete (approved or timed out).
#[must_use]
pub fn is_complete(&self) -> bool {
matches!(
self.status,
DuoStatus::Approved | DuoStatus::Rejected | DuoStatus::Timeout
)
}
/// Get the number of turns remaining before timeout.
#[must_use]
pub fn turns_remaining(&self) -> u32 {
self.max_turns.saturating_sub(self.current_turn)
}
/// Get the average quality score across all coach reviews.
#[must_use]
pub fn average_quality_score(&self) -> Option<f64> {
if self.quality_scores.is_empty() {
None
} else {
let sum: f64 = self.quality_scores.iter().sum();
Some(sum / self.quality_scores.len() as f64)
}
}
/// Generate a human-readable summary of the session state.
#[must_use]
pub fn summary(&self) -> String {
let name = self
.session_name
.as_deref()
.unwrap_or(&self.session_id[..8]);
let avg_score = self
.average_quality_score()
.map(|s| format!("{:.1}%", s * 100.0))
.unwrap_or_else(|| "N/A".to_string());
let status_icon = match self.status {
DuoStatus::Active => "🔄",
DuoStatus::Approved => "✅",
DuoStatus::Rejected => "❌",
DuoStatus::Timeout => "⏰",
};
format!(
"{status_icon} Duo Session: {name}\n\
Phase: {} | Turn: {}/{} | Status: {}\n\
Avg Quality: {} | Threshold: {:.0}%\n\
History: {} records",
self.phase,
self.current_turn,
self.max_turns,
self.status,
avg_score,
self.approval_threshold * 100.0,
self.turn_history.len()
)
}
}
// === Error Types ===
/// Errors that can occur during Duo session operations.
#[derive(Debug, Clone)]
pub enum DuoError {
/// Invalid phase transition attempted
InvalidPhaseTransition { from: DuoPhase, to: DuoPhase },
/// Session not found (reserved for future multi-session management)
#[allow(dead_code)]
SessionNotFound { session_id: String },
/// Session already complete (reserved for future session validation)
#[allow(dead_code)]
SessionAlreadyComplete { session_id: String },
}
impl std::fmt::Display for DuoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DuoError::InvalidPhaseTransition { from, to } => {
write!(f, "Invalid phase transition from {} to {}", from, to)
}
DuoError::SessionNotFound { session_id } => {
write!(f, "Session not found: {}", session_id)
}
DuoError::SessionAlreadyComplete { session_id } => {
write!(f, "Session already complete: {}", session_id)
}
}
}
}
impl std::error::Error for DuoError {}
// === Session Container ===
/// Container for managing multiple Duo sessions.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DuoSession {
/// The currently active session state
pub active_state: Option<DuoState>,
/// Saved/completed session states indexed by session_id
pub saved_states: HashMap<String, DuoState>,
}
impl DuoSession {
/// Create a new empty session container.
#[must_use]
pub fn new() -> Self {
Self {
active_state: None,
saved_states: HashMap::new(),
}
}
/// Start a new Duo session.
pub fn start_session(
&mut self,
requirements: String,
session_name: Option<String>,
max_turns: Option<u32>,
approval_threshold: Option<f64>,
) -> &DuoState {
// Save any existing active session
if let Some(state) = self.active_state.take() {
self.saved_states.insert(state.session_id.clone(), state);
}
// Create new session
let state = DuoState::create(requirements, session_name, max_turns, approval_threshold);
self.active_state = Some(state);
self.active_state.as_ref().expect("just set active_state")
}
/// Get the active session state.
#[must_use]
pub fn get_active(&self) -> Option<&DuoState> {
self.active_state.as_ref()
}
/// Get a mutable reference to the active session state.
pub fn get_active_mut(&mut self) -> Option<&mut DuoState> {
self.active_state.as_mut()
}
/// Get a saved session by ID (reserved for future multi-session management).
#[must_use]
#[allow(dead_code)]
pub fn get_saved(&self, session_id: &str) -> Option<&DuoState> {
self.saved_states.get(session_id)
}
/// Save the current active session and clear it (reserved for future session management).
#[allow(dead_code)]
pub fn save_active(&mut self) -> Option<String> {
if let Some(state) = self.active_state.take() {
let id = state.session_id.clone();
self.saved_states.insert(id.clone(), state);
Some(id)
} else {
None
}
}
/// Restore a saved session as the active session (reserved for future session management).
#[allow(dead_code)]
pub fn restore_session(&mut self, session_id: &str) -> Result<(), DuoError> {
let state =
self.saved_states
.remove(session_id)
.ok_or_else(|| DuoError::SessionNotFound {
session_id: session_id.to_string(),
})?;
// Save current active if any
if let Some(current) = self.active_state.take() {
self.saved_states
.insert(current.session_id.clone(), current);
}
self.active_state = Some(state);
Ok(())
}
/// List all session IDs (active and saved, reserved for future session management).
#[must_use]
#[allow(dead_code)]
pub fn list_sessions(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self.saved_states.keys().map(String::as_str).collect();
if let Some(ref active) = self.active_state {
ids.push(&active.session_id);
}
ids.sort();
ids
}
}
/// Thread-safe shared Duo session.
pub type SharedDuoSession = Arc<Mutex<DuoSession>>;
/// Create a new shared Duo session.
#[must_use]
pub fn new_shared_duo_session() -> SharedDuoSession {
Arc::new(Mutex::new(DuoSession::new()))
}
// === Prompt Generation ===
/// Generate the player (implementation) prompt for the current state.
///
/// The player focuses on implementing requirements and should NOT declare success.
#[must_use]
pub fn generate_player_prompt(state: &DuoState) -> String {
let mut prompt = String::new();
prompt.push_str("# Player Phase - Implementation\n\n");
prompt.push_str("You are the PLAYER in an autocoding session. Your role is to IMPLEMENT the requirements.\n\n");
prompt.push_str("## Requirements (Source of Truth)\n\n");
prompt.push_str(&state.requirements);
prompt.push_str("\n\n");
prompt.push_str(&format!(
"## Session Info\n\n\
- Turn: {}/{}\n\
- Approval Threshold: {:.0}%\n",
state.current_turn,
state.max_turns,
state.approval_threshold * 100.0
));
if let Some(ref feedback) = state.last_coach_feedback {
prompt.push_str("\n## Previous Coach Feedback\n\n");
prompt.push_str("Address these issues from the last review:\n\n");
prompt.push_str(feedback);
prompt.push('\n');
}
prompt.push_str("\n## Instructions\n\n");
prompt.push_str(
"1. Implement the requirements above using available tools\n\
2. Focus on making incremental progress\n\
3. DO NOT declare success or claim completion\n\
4. DO NOT evaluate your own work\n\
5. The Coach will verify your implementation\n\n\
Begin implementation now.\n",
);
prompt
}
/// Generate the coach (validation) prompt for the current state.
///
/// The coach verifies the implementation against requirements and ignores player self-assessment.
#[must_use]
pub fn generate_coach_prompt(state: &DuoState) -> String {
let mut prompt = String::new();
prompt.push_str("# Coach Phase - Validation\n\n");
prompt.push_str("You are the COACH in an autocoding session. Your role is to VERIFY the implementation.\n\n");
prompt.push_str("## Requirements (Source of Truth)\n\n");
prompt.push_str(&state.requirements);
prompt.push_str("\n\n");
prompt.push_str(&format!(
"## Session Info\n\n\
- Turn: {}/{}\n\
- Approval Threshold: {:.0}%\n\
- Turns Remaining: {}\n",
state.current_turn,
state.max_turns,
state.approval_threshold * 100.0,
state.turns_remaining()
));
if !state.quality_scores.is_empty() {
let avg = state.average_quality_score().unwrap_or(0.0);
prompt.push_str(&format!("- Average Quality: {:.1}%\n", avg * 100.0));
}
prompt.push_str("\n## Instructions\n\n");
prompt.push_str(
"1. Review the current implementation against the requirements\n\
2. Create a COMPLIANCE CHECKLIST:\n\
- [ ] or [x] for each requirement item\n\
- Note any missing or incorrect implementations\n\
3. Calculate a COMPLIANCE SCORE (0.0 to 1.0)\n\
4. IGNORE any player self-assessment or claims of completion\n\
5. If score >= threshold AND all critical items pass:\n\
- Output: COACH APPROVED\n\
6. Otherwise, provide specific feedback:\n\
- What is missing\n\
- What needs to be fixed\n\
- Actionable next steps\n\n\
Begin validation now.\n",
);
prompt
}
/// Generate a summary of the session for system prompt injection.
#[must_use]
pub fn session_summary(session: &DuoSession) -> String {
let mut lines = Vec::new();
if let Some(ref state) = session.active_state {
lines.push(format!("Active Duo Session: {}", state.summary()));
} else {
lines.push("No active Duo session.".to_string());
}
if !session.saved_states.is_empty() {
lines.push(format!("Saved sessions: {}", session.saved_states.len()));
for (id, state) in &session.saved_states {
let name = state
.session_name
.as_deref()
.unwrap_or(&id[..8.min(id.len())]);
lines.push(format!(" - {}: {} ({})", name, state.status, state.phase));
}
}
lines.join("\n")
}
// === Tests ===
#[cfg(test)]
mod tests {
use super::*;
fn sample_requirements() -> String {
"## Requirements\n\
- [ ] Create a function `add(a, b)` that returns the sum\n\
- [ ] Add unit tests for the function\n\
- [ ] Document the function with comments"
.to_string()
}
#[test]
fn test_create_session() {
let state = DuoState::create(
sample_requirements(),
Some("test-session".to_string()),
None,
None,
);
assert_eq!(state.session_name, Some("test-session".to_string()));
assert_eq!(state.current_turn, 1);
assert_eq!(state.max_turns, 10);
assert_eq!(state.phase, DuoPhase::Init);
assert_eq!(state.status, DuoStatus::Active);
assert!(state.turn_history.is_empty());
assert!(state.last_coach_feedback.is_none());
assert_eq!(state.approval_threshold, 0.9);
}
#[test]
fn test_advance_to_coach() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
assert!(
state
.advance_to_coach("Implemented add function".to_string())
.is_ok()
);
assert_eq!(state.phase, DuoPhase::Coach);
assert_eq!(state.turn_history.len(), 1);
assert_eq!(state.turn_history[0].phase, DuoPhase::Player);
}
#[test]
fn test_advance_turn_approved() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
state
.advance_to_coach("Implemented everything".to_string())
.unwrap();
assert!(
state
.advance_turn(
"COACH APPROVED - All requirements met".to_string(),
true,
Some(0.95)
)
.is_ok()
);
assert_eq!(state.phase, DuoPhase::Approved);
assert_eq!(state.status, DuoStatus::Approved);
assert!(state.is_complete());
assert_eq!(state.quality_scores, vec![0.95]);
}
#[test]
fn test_advance_turn_continue() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
state
.advance_to_coach("Partial implementation".to_string())
.unwrap();
assert!(
state
.advance_turn("Missing tests".to_string(), false, Some(0.5))
.is_ok()
);
assert_eq!(state.phase, DuoPhase::Player);
assert_eq!(state.status, DuoStatus::Active);
assert_eq!(state.current_turn, 2);
assert!(!state.is_complete());
assert_eq!(state.last_coach_feedback, Some("Missing tests".to_string()));
}
#[test]
fn test_timeout() {
let mut state = DuoState::create(sample_requirements(), None, Some(2), None);
// Turn 1
state.advance_to_coach("Attempt 1".to_string()).unwrap();
state
.advance_turn("Not good enough".to_string(), false, Some(0.3))
.unwrap();
// Turn 2 (max)
state.advance_to_coach("Attempt 2".to_string()).unwrap();
state
.advance_turn("Still not good enough".to_string(), false, Some(0.4))
.unwrap();
assert_eq!(state.phase, DuoPhase::Timeout);
assert_eq!(state.status, DuoStatus::Timeout);
assert!(state.is_complete());
}
#[test]
fn test_invalid_phase_transition() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
state.phase = DuoPhase::Approved;
let result = state.advance_to_coach("Should fail".to_string());
assert!(result.is_err());
}
#[test]
fn test_turns_remaining() {
let state = DuoState::create(sample_requirements(), None, Some(10), None);
assert_eq!(state.turns_remaining(), 9);
}
#[test]
fn test_average_quality_score() {
let mut state = DuoState::create(sample_requirements(), None, None, None);
assert!(state.average_quality_score().is_none());
state.quality_scores = vec![0.5, 0.7, 0.9];
let avg = state.average_quality_score().unwrap();
assert!((avg - 0.7).abs() < 0.001);
}
#[test]
fn test_session_container() {
let mut session = DuoSession::new();
// Start first session
session.start_session(
sample_requirements(),
Some("session-1".to_string()),
None,
None,
);
assert!(session.get_active().is_some());
// Start second session (first gets saved)
session.start_session(
"Other requirements".to_string(),
Some("session-2".to_string()),
None,
None,
);
assert_eq!(session.saved_states.len(), 1);
// Get active
let active = session.get_active().unwrap();
assert_eq!(active.session_name, Some("session-2".to_string()));
}
#[test]
fn test_generate_player_prompt() {
let state = DuoState::create(sample_requirements(), None, None, None);
let prompt = generate_player_prompt(&state);
assert!(prompt.contains("Player Phase"));
assert!(prompt.contains("Requirements (Source of Truth)"));
assert!(prompt.contains("Turn: 1/10"));
assert!(prompt.contains("DO NOT declare success"));
}
#[test]
fn test_generate_coach_prompt() {
let state = DuoState::create(sample_requirements(), None, None, None);
let prompt = generate_coach_prompt(&state);
assert!(prompt.contains("Coach Phase"));
assert!(prompt.contains("COMPLIANCE CHECKLIST"));
assert!(prompt.contains("COACH APPROVED"));
assert!(prompt.contains("IGNORE any player self-assessment"));
}
#[test]
fn test_shared_session() {
let shared = new_shared_duo_session();
{
let mut session = shared.lock().unwrap();
session.start_session(sample_requirements(), None, None, None);
}
{
let session = shared.lock().unwrap();
assert!(session.get_active().is_some());
}
}
#[test]
fn test_summary() {
let state = DuoState::create(sample_requirements(), Some("test".to_string()), None, None);
let summary = state.summary();
assert!(summary.contains("Duo Session: test"));
assert!(summary.contains("Phase: init"));
assert!(summary.contains("Turn: 1/10"));
}
#[test]
fn test_session_summary() {
let mut session = DuoSession::new();
session.start_session(
sample_requirements(),
Some("active-session".to_string()),
None,
None,
);
let summary = session_summary(&session);
assert!(summary.contains("Active Duo Session"));
assert!(summary.contains("active-session"));
}
}
+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,
),
}
}
}
-16
View File
@@ -28,10 +28,6 @@ pub enum Feature {
ApplyPatch,
/// Enable MCP tools.
Mcp,
/// Enable RLM tools.
Rlm,
/// Enable Duo tools.
Duo,
/// Enable execpolicy integration/tooling.
ExecPolicy,
}
@@ -171,18 +167,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::Rlm,
key: "rlm",
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::Duo,
key: "duo",
stage: Stage::Experimental,
default_enabled: true,
},
FeatureSpec {
id: Feature::ExecPolicy,
key: "exec_policy",
+15 -3
View File
@@ -77,7 +77,7 @@ pub enum HookCondition {
},
/// Only run in specific modes
Mode {
/// Mode: "plan", "agent", "yolo", "rlm", "duo"
/// Mode: "plan", "agent", "yolo"
mode: String,
},
/// Only run when exit code matches (for `ToolCallAfter`)
@@ -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)
+430 -54
View File
@@ -26,13 +26,14 @@ use dotenvy::dotenv;
use tempfile::NamedTempFile;
use wait_timeout::ChildExt;
mod audit;
mod client;
mod command_safety;
mod commands;
mod compaction;
mod config;
mod core;
mod duo;
mod error_taxonomy;
mod eval;
mod execpolicy;
mod features;
@@ -48,18 +49,22 @@ mod project_context;
mod project_doc;
mod prompts;
mod responses_api_proxy;
mod rlm;
mod runtime_api;
mod runtime_threads;
mod sandbox;
mod session_manager;
mod settings;
mod skills;
mod task_manager;
#[cfg(test)]
mod test_support;
mod tools;
mod tui;
mod ui;
mod utils;
mod working_set;
use crate::config::{Config, MAX_SUBAGENTS};
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
use crate::features::Feature;
use crate::llm_client::LlmClient;
@@ -161,6 +166,8 @@ enum Commands {
},
/// Remove the saved API key
Logout,
/// List available models from the configured API endpoint
Models(ModelsArgs),
/// Run a non-interactive prompt
Exec(ExecArgs),
/// Run a code review over a git diff
@@ -215,6 +222,9 @@ struct ExecArgs {
/// Enable agentic mode with tool access and auto-approvals
#[arg(long, default_value_t = false)]
auto: bool,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone, Default)]
@@ -255,6 +265,13 @@ struct EvalArgs {
json: bool,
}
#[derive(Args, Debug, Clone, Default)]
struct ModelsArgs {
/// Print models as pretty JSON
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Default, Clone)]
struct FeatureToggles {
/// Enable a feature (repeatable). Equivalent to `features.<name>=true`.
@@ -295,6 +312,9 @@ struct ReviewArgs {
/// Maximum diff characters to include
#[arg(long, default_value_t = 200_000)]
max_chars: usize,
/// Emit machine-readable JSON output
#[arg(long, default_value_t = false)]
json: bool,
}
#[derive(Args, Debug, Clone)]
@@ -309,6 +329,18 @@ struct ServeArgs {
/// Start MCP server over stdio
#[arg(long)]
mcp: bool,
/// Start runtime HTTP/SSE API server
#[arg(long)]
http: bool,
/// Bind host for HTTP server (default localhost)
#[arg(long, default_value = "127.0.0.1")]
host: String,
/// Bind port for HTTP server
#[arg(long, default_value_t = 7878)]
port: u16,
/// Background task worker count (1-8)
#[arg(long, default_value_t = 2)]
workers: usize,
}
#[derive(Subcommand, Debug, Clone)]
@@ -333,6 +365,37 @@ enum McpCommand {
#[arg(value_name = "SERVER")]
server: Option<String>,
},
/// Add an MCP server entry
Add {
/// Server name
name: String,
/// Command to launch stdio server
#[arg(long, conflicts_with = "url")]
command: Option<String>,
/// URL for streamable HTTP/SSE server
#[arg(long, conflicts_with = "command")]
url: Option<String>,
/// Arguments for command-based servers
#[arg(long = "arg")]
args: Vec<String>,
},
/// Remove an MCP server entry
Remove {
/// Server name
name: String,
},
/// Enable an MCP server
Enable {
/// Server name
name: String,
},
/// Disable an MCP server
Disable {
/// Server name
name: String,
},
/// Validate MCP config and required servers
Validate,
}
#[derive(Args, Debug, Clone)]
@@ -424,12 +487,16 @@ async fn main() -> Result<()> {
Commands::Init => init_project(),
Commands::Login { api_key } => run_login(api_key),
Commands::Logout => run_logout(),
Commands::Models(args) => {
let config = load_config_from_cli(&cli)?;
run_models(&config, args).await
}
Commands::Exec(args) => {
let config = load_config_from_cli(&cli)?;
let model = args
.model
.or_else(|| config.default_text_model.clone())
.unwrap_or_else(|| "deepseek-reasoner".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
if args.auto || cli.yolo {
let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
@@ -447,8 +514,11 @@ async fn main() -> Result<()> {
max_subagents,
true,
auto_mode,
args.json,
)
.await
} else if args.json {
run_one_shot_json(&config, &model, &args.prompt).await
} else {
run_one_shot(&config, &model, &args.prompt).await
}
@@ -473,10 +543,25 @@ async fn main() -> Result<()> {
let workspace = cli.workspace.clone().unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
});
if args.mcp && args.http {
bail!("Choose exactly one server mode: --mcp or --http");
}
if args.mcp {
mcp_server::run_mcp_server(workspace)
} else if args.http {
let config = load_config_from_cli(&cli)?;
runtime_api::run_http_server(
config,
workspace,
runtime_api::RuntimeApiOptions {
host: args.host,
port: args.port,
workers: args.workers.clamp(1, 8),
},
)
.await
} else {
bail!("No server mode specified. Use --mcp.")
bail!("No server mode specified. Use --mcp or --http.")
}
}
Commands::Resume { session_id, last } => {
@@ -502,7 +587,7 @@ async fn main() -> Result<()> {
let model = config
.default_text_model
.clone()
.unwrap_or_else(|| "deepseek-reasoner".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
return run_one_shot(&config, &model, &prompt).await;
}
@@ -608,9 +693,8 @@ fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create directory for {}", parent.display())
})?;
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory for {}", parent.display()))?;
}
Ok(())
}
@@ -639,13 +723,18 @@ fn mcp_template_json() -> Result<String> {
cfg.servers.insert(
"example".to_string(),
McpServerConfig {
command: "node".to_string(),
command: Some("node".to_string()),
args: vec!["./path/to/your-mcp-server.js".to_string()],
env: std::collections::HashMap::new(),
url: None,
connect_timeout: None,
execute_timeout: None,
read_timeout: None,
disabled: true,
enabled: true,
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
},
);
serde_json::to_string_pretty(&cfg)
@@ -701,9 +790,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
println!(
"{}",
"DeepSeek Setup"
.truecolor(aqua_r, aqua_g, aqua_b)
.bold()
"DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
);
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
println!("Workspace: {}", workspace.display());
@@ -740,7 +827,10 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
println!(" ✓ Overwrote example skill at {}", skill_path.display());
}
WriteStatus::SkippedExists => {
println!(" · Example skill already exists at {}", skill_path.display());
println!(
" · Example skill already exists at {}",
skill_path.display()
);
}
}
if args.local {
@@ -795,7 +885,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
dirs::home_dir().map_or_else(|| PathBuf::from(".deepseek"), |h| h.join(".deepseek"));
let config_path = config_path_override
.map(PathBuf::from)
.or_else(|| std::env::var("DEEPSEEK_CONFIG_PATH").ok().map(PathBuf::from))
.or_else(|| {
std::env::var("DEEPSEEK_CONFIG_PATH")
.ok()
.map(PathBuf::from)
})
.unwrap_or_else(|| default_config_dir.join("config.toml"));
if config_path.exists() {
@@ -816,7 +910,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
// Check API keys
println!();
println!("{}", "API Keys:".bold());
let has_api_key = if std::env::var("DEEPSEEK_API_KEY").is_ok() {
let has_api_key = if std::env::var("DEEPSEEK_API_KEY")
.ok()
.filter(|k| !k.trim().is_empty())
.is_some()
{
println!(
" {} DEEPSEEK_API_KEY is set",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
@@ -885,9 +983,15 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!("{}", "MCP Servers:".bold());
let features = config.features();
if features.enabled(Feature::Mcp) {
println!(" {} MCP feature flag enabled", "✓".truecolor(aqua_r, aqua_g, aqua_b));
println!(
" {} MCP feature flag enabled",
"✓".truecolor(aqua_r, aqua_g, aqua_b)
);
} else {
println!(" {} MCP feature flag disabled", "!".truecolor(sky_r, sky_g, sky_b));
println!(
" {} MCP feature flag disabled",
"!".truecolor(sky_r, sky_g, sky_b)
);
}
let mcp_config_path = config.mcp_config_path();
@@ -932,8 +1036,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!();
println!("{}", "Skills:".bold());
let global_skills_dir = config.skills_dir();
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let selected_skills_dir = if local_skills_dir.exists() {
let selected_skills_dir = if agents_skills_dir.exists() {
&agents_skills_dir
} else if local_skills_dir.exists() {
&local_skills_dir
} else {
&global_skills_dir
@@ -960,6 +1067,21 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
);
}
if agents_skills_dir.exists() {
println!(
" {} .agents skills dir found at {} ({} items)",
"✓".truecolor(aqua_r, aqua_g, aqua_b),
agents_skills_dir.display(),
describe_dir(&agents_skills_dir)
);
} else {
println!(
" {} .agents skills dir not found at {}",
"·".dimmed(),
agents_skills_dir.display()
);
}
if global_skills_dir.exists() {
println!(
" {} global skills dir found at {} ({} items)",
@@ -975,8 +1097,12 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
);
}
println!(" {} selected skills dir: {}", "·".dimmed(), selected_skills_dir.display());
if !local_skills_dir.exists() && !global_skills_dir.exists() {
println!(
" {} selected skills dir: {}",
"·".dimmed(),
selected_skills_dir.display()
);
if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() {
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
}
@@ -1041,6 +1167,41 @@ fn run_features_list(config: &Config) -> Result<()> {
Ok(())
}
async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
use crate::client::DeepSeekClient;
let client = DeepSeekClient::new(config)?;
let mut models = client.list_models().await?;
models.sort_by(|a, b| a.id.cmp(&b.id));
if args.json {
println!("{}", serde_json::to_string_pretty(&models)?);
return Ok(());
}
if models.is_empty() {
println!("No models returned by the API.");
return Ok(());
}
let default_model = config
.default_text_model
.clone()
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
println!("Available models (default: {default_model})");
for model in models {
let marker = if model.id == default_model { "*" } else { " " };
if let Some(owner) = model.owned_by {
println!("{marker} {} ({owner})", model.id);
} else {
println!("{marker} {}", model.id);
}
}
Ok(())
}
/// Test API connectivity by making a minimal request
async fn test_api_connectivity() -> Result<String> {
use crate::client::DeepSeekClient;
@@ -1324,7 +1485,7 @@ async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
let model = args
.model
.or_else(|| config.default_text_model.clone())
.unwrap_or_else(|| "deepseek-reasoner".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
let system = SystemPrompt::Text(
"You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
@@ -1336,7 +1497,7 @@ Provide findings ordered by severity with file references, then open questions,
let client = DeepSeekClient::new(config)?;
let request = MessageRequest {
model,
model: model.clone(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
@@ -1356,11 +1517,25 @@ Provide findings ordered by severity with file references, then open questions,
};
let response = client.create_message(request).await?;
let mut output = String::new();
for block in response.content {
if let ContentBlock::Text { text, .. } = block {
println!("{text}");
output.push_str(&text);
}
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"mode": "review",
"model": model,
"success": true,
"content": output
}))?
);
} else {
println!("{output}");
}
Ok(())
}
@@ -1461,17 +1636,25 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
}
println!("MCP servers ({}):", cfg.servers.len());
for (name, server) in cfg.servers {
let status = if server.disabled {
"disabled"
} else {
let status = if server.enabled && !server.disabled {
"enabled"
} else {
"disabled"
};
let args = if server.args.is_empty() {
"".to_string()
} else {
format!(" {}", server.args.join(" "))
};
println!(" - {name} [{status}] {}{}", server.command, args);
let cmd_str = if let Some(cmd) = server.command {
format!("{cmd}{args}")
} else if let Some(url) = server.url {
url
} else {
"unknown".to_string()
};
let required = if server.required { " required" } else { "" };
println!(" - {name} [{status}{required}] {cmd_str}");
}
Ok(())
}
@@ -1530,6 +1713,83 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> {
}
Ok(())
}
McpCommand::Add {
name,
command,
url,
args,
} => {
if command.is_none() && url.is_none() {
bail!("Provide either --command or --url for `mcp add`.");
}
let mut cfg = load_mcp_config(&config_path)?;
cfg.servers.insert(
name.clone(),
McpServerConfig {
command,
args,
env: std::collections::HashMap::new(),
url,
connect_timeout: None,
execute_timeout: None,
read_timeout: None,
disabled: false,
enabled: true,
required: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
},
);
save_mcp_config(&config_path, &cfg)?;
println!("Added MCP server '{name}' in {}", config_path.display());
Ok(())
}
McpCommand::Remove { name } => {
let mut cfg = load_mcp_config(&config_path)?;
if cfg.servers.remove(&name).is_none() {
bail!("MCP server '{name}' not found");
}
save_mcp_config(&config_path, &cfg)?;
println!("Removed MCP server '{name}'");
Ok(())
}
McpCommand::Enable { name } => {
let mut cfg = load_mcp_config(&config_path)?;
let server = cfg
.servers
.get_mut(&name)
.ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
server.enabled = true;
server.disabled = false;
save_mcp_config(&config_path, &cfg)?;
println!("Enabled MCP server '{name}'");
Ok(())
}
McpCommand::Disable { name } => {
let mut cfg = load_mcp_config(&config_path)?;
let server = cfg
.servers
.get_mut(&name)
.ok_or_else(|| anyhow!("MCP server '{name}' not found"))?;
server.enabled = false;
server.disabled = true;
save_mcp_config(&config_path, &cfg)?;
println!("Disabled MCP server '{name}'");
Ok(())
}
McpCommand::Validate => {
let mut pool = McpPool::from_config_path(&config_path)?;
let errors = pool.connect_all().await;
if errors.is_empty() {
println!("MCP config is valid. All enabled servers connected.");
return Ok(());
}
eprintln!("MCP validation failed:");
for (name, err) in errors {
eprintln!(" - {name}: {err}");
}
bail!("one or more MCP servers failed validation");
}
}
}
@@ -1544,6 +1804,19 @@ fn load_mcp_config(path: &Path) -> Result<McpConfig> {
Ok(cfg)
}
fn save_mcp_config(path: &Path, cfg: &McpConfig) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create MCP config directory {}", parent.display())
})?;
}
let rendered = serde_json::to_string_pretty(cfg)
.map_err(|e| anyhow!("Failed to serialize MCP config: {e}"))?;
std::fs::write(path, rendered)
.map_err(|e| anyhow!("Failed to write MCP config {}: {}", path.display(), e))?;
Ok(())
}
fn run_sandbox_command(args: SandboxArgs) -> Result<()> {
use crate::sandbox::{CommandSpec, SandboxManager};
@@ -1708,7 +1981,7 @@ async fn run_interactive(
let model = config
.default_text_model
.clone()
.unwrap_or_else(|| "deepseek-reasoner".to_string());
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
let max_subagents = cli.max_subagents.map_or_else(
|| config.max_subagents(),
|value| value.clamp(1, MAX_SUBAGENTS),
@@ -1774,6 +2047,53 @@ async fn run_one_shot(config: &Config, model: &str, prompt: &str) -> Result<()>
Ok(())
}
async fn run_one_shot_json(config: &Config, model: &str, prompt: &str) -> Result<()> {
use crate::client::DeepSeekClient;
use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt};
let client = DeepSeekClient::new(config)?;
let request = MessageRequest {
model: model.to_string(),
messages: vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: prompt.to_string(),
cache_control: None,
}],
}],
max_tokens: 4096,
system: Some(SystemPrompt::Text(
"You are a coding assistant. Give concise, actionable responses.".to_string(),
)),
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
stream: Some(false),
temperature: Some(0.2),
top_p: Some(0.9),
};
let response = client.create_message(request).await?;
let mut output = String::new();
for block in response.content {
if let ContentBlock::Text { text, .. } = block {
output.push_str(&text);
}
}
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"mode": "one-shot",
"model": model,
"success": true,
"output": output
}))?
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn run_exec_agent(
config: &Config,
model: &str,
@@ -1782,19 +2102,23 @@ async fn run_exec_agent(
max_subagents: usize,
auto_approve: bool,
trust_mode: bool,
json_output: bool,
) -> Result<()> {
use std::sync::{Arc, Mutex};
use crate::compaction::CompactionConfig;
use crate::core::engine::{EngineConfig, spawn_engine};
use crate::core::events::Event;
use crate::core::ops::Op;
use crate::duo::DuoSession;
use crate::rlm::RlmSession;
use crate::models::{compaction_message_threshold_for_model, compaction_threshold_for_model};
use crate::tools::plan::new_shared_plan_state;
use crate::tools::todo::new_shared_todo_list;
use crate::tui::app::AppMode;
let mut compaction = CompactionConfig::default();
compaction.enabled = true;
compaction.model = model.to_string();
compaction.token_threshold = compaction_threshold_for_model(model);
compaction.message_threshold = compaction_message_threshold_for_model(model);
let engine_config = EngineConfig {
model: model.to_string(),
workspace: workspace.clone(),
@@ -1805,9 +2129,8 @@ async fn run_exec_agent(
max_steps: 100,
max_subagents,
features: config.features(),
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
duo_session: Arc::new(Mutex::new(DuoSession::new())),
compaction: CompactionConfig::default(),
compaction,
capacity: crate::core::capacity::CapacityControllerConfig::from_app_config(config),
todos: new_shared_todo_list(),
plan_state: new_shared_plan_state(),
};
@@ -1829,6 +2152,29 @@ async fn run_exec_agent(
))
.await?;
#[derive(serde::Serialize)]
struct ExecToolEntry {
name: String,
success: bool,
output: String,
}
#[derive(serde::Serialize, Default)]
struct ExecSummary {
mode: String,
model: String,
prompt: String,
output: String,
tools: Vec<ExecToolEntry>,
status: Option<String>,
error: Option<String>,
}
let mut summary = ExecSummary {
mode: "agent".to_string(),
model: model.to_string(),
prompt: prompt.to_string(),
..ExecSummary::default()
};
let mut stdout = io::stdout();
let mut ends_with_newline = false;
loop {
@@ -1843,35 +2189,49 @@ async fn run_exec_agent(
match event {
Event::MessageDelta { content, .. } => {
print!("{content}");
stdout.flush()?;
summary.output.push_str(&content);
if !json_output {
print!("{content}");
stdout.flush()?;
}
ends_with_newline = content.ends_with('\n');
}
Event::MessageComplete { .. } => {
if !ends_with_newline {
if !json_output && !ends_with_newline {
println!();
}
}
Event::ToolCallStarted { name, input, .. } => {
let summary = summarize_tool_args(&input);
if let Some(summary) = summary {
eprintln!("tool: {name} ({summary})");
} else {
eprintln!("tool: {name}");
if !json_output {
let summary = summarize_tool_args(&input);
if let Some(summary) = summary {
eprintln!("tool: {name} ({summary})");
} else {
eprintln!("tool: {name}");
}
}
}
Event::ToolCallProgress { id, output } => {
eprintln!("tool {id}: {}", summarize_tool_output(&output));
if !json_output {
eprintln!("tool {id}: {}", summarize_tool_output(&output));
}
}
Event::ToolCallComplete { name, result, .. } => match result {
Ok(output) => {
summary.tools.push(ExecToolEntry {
name: name.clone(),
success: output.success,
output: output.content.clone(),
});
if name == "exec_shell" && !output.content.trim().is_empty() {
eprintln!("tool {name} completed");
eprintln!(
"--- stdout/stderr ---\n{}\n---------------------",
output.content
);
} else {
if !json_output {
eprintln!("tool {name} completed");
eprintln!(
"--- stdout/stderr ---\n{}\n---------------------",
output.content
);
}
} else if !json_output {
eprintln!(
"tool {name} completed: {}",
summarize_tool_output(&output.content)
@@ -1879,7 +2239,14 @@ async fn run_exec_agent(
}
}
Err(err) => {
eprintln!("tool {name} failed: {err}");
summary.tools.push(ExecToolEntry {
name: name.clone(),
success: false,
output: err.to_string(),
});
if !json_output {
eprintln!("tool {name} failed: {err}");
}
}
},
Event::AgentSpawned { id, prompt } => {
@@ -1920,9 +2287,14 @@ async fn run_exec_agent(
message,
recoverable: _,
} => {
eprintln!("error: {message}");
summary.error = Some(message.clone());
if !json_output {
eprintln!("error: {message}");
}
}
Event::TurnComplete { .. } => {
Event::TurnComplete { status, error, .. } => {
summary.status = Some(format!("{status:?}").to_lowercase());
summary.error = error;
let _ = engine_handle.send(Op::Shutdown).await;
break;
}
@@ -1930,5 +2302,9 @@ async fn run_exec_agent(
}
}
if json_output {
println!("{}", serde_json::to_string_pretty(&summary)?);
}
Ok(())
}
+827 -75
View File
File diff suppressed because it is too large. Load diff
+87 -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,
@@ -119,11 +126,17 @@ pub struct Usage {
#[must_use]
pub fn context_window_for_model(model: &str) -> Option<u32> {
let lower = model.to_lowercase();
if lower.contains("deepseek-chat") || lower.contains("deepseek-reasoner") {
return Some(128_000);
if lower.contains("deepseek-v3.2") {
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
}
if lower.contains("deepseek-chat")
|| lower.contains("deepseek-reasoner")
|| lower.contains("deepseek-r1")
{
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);
@@ -131,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)]
@@ -198,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);
}
}
+31 -9
View File
@@ -12,6 +12,9 @@ pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38);
pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46);
pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);
// New semantic colors
pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F
pub const DEEPSEEK_BLUE: Color = Color::Rgb(
DEEPSEEK_BLUE_RGB.0,
DEEPSEEK_BLUE_RGB.1,
@@ -42,22 +45,35 @@ pub const DEEPSEEK_RED: Color =
Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2);
pub const TEXT_PRIMARY: Color = Color::White;
pub const TEXT_MUTED: Color = Color::DarkGray;
pub const TEXT_DIM: Color = Color::Gray;
pub const TEXT_MUTED: Color = Color::Rgb(192, 192, 192); // #C0C0C0
pub const TEXT_DIM: Color = Color::Rgb(160, 160, 160); // #A0A0A0
// New semantic colors for UI theming
pub const BORDER_COLOR: Color =
Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2);
#[allow(dead_code)]
pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5
#[allow(dead_code)]
pub const ACCENT_SECONDARY: Color = DEEPSEEK_SKY; // #6AAEF2
#[allow(dead_code)]
pub const BACKGROUND_LIGHT: Color = Color::Rgb(30, 47, 71); // #1E2F47
#[allow(dead_code)]
pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30
#[allow(dead_code)]
pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0
// Legacy status colors - keep for backward compatibility
pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;
pub const STATUS_WARNING: Color = DEEPSEEK_SKY;
pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); // Amber
pub const STATUS_ERROR: Color = DEEPSEEK_RED;
#[allow(dead_code)]
pub const STATUS_INFO: Color = DEEPSEEK_BLUE;
// Mode-specific accent colors for mode badges
pub const MODE_NORMAL: Color = Color::Gray;
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
pub const MODE_RLM: Color = Color::Rgb(180, 100, 255); // Purple (was INK!)
pub const MODE_DUO: Color = Color::Rgb(100, 220, 180); // Teal
pub const MODE_NORMAL: Color = Color::Rgb(192, 192, 192); // #C0C0C0
pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue
pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red
pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange
pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74);
pub const COMPOSER_BG: Color = DEEPSEEK_SLATE;
@@ -84,6 +100,12 @@ pub fn ui_theme(name: &str) -> UiTheme {
selection_bg: Color::Rgb(38, 64, 112),
header_bg: DEEPSEEK_SLATE,
},
"whale" => UiTheme {
name: "whale",
composer_bg: DEEPSEEK_SLATE,
selection_bg: DEEPSEEK_NAVY,
header_bg: DEEPSEEK_INK,
},
_ => UiTheme {
name: "default",
composer_bg: COMPOSER_BG,
+63 -3
View File
@@ -1,10 +1,47 @@
//! Cost estimation placeholders for tool executions.
//! Cost estimation for DeepSeek API usage.
//!
//! DeepSeek CLI focuses on text-only workflows; no paid multimedia tools are exposed
//! by default, so cost estimates are currently unavailable.
//! Pricing based on DeepSeek's published rates (per million tokens).
use serde_json::Value;
/// Per-million-token pricing for a model.
struct ModelPricing {
input_per_million: f64,
output_per_million: f64,
}
/// Look up pricing for a model name.
fn pricing_for_model(model: &str) -> Option<ModelPricing> {
let lower = model.to_lowercase();
if lower.contains("deepseek-reasoner") || lower.contains("deepseek-r1") {
// DeepSeek-R1: $0.55/M input, $2.19/M output
Some(ModelPricing {
input_per_million: 0.55,
output_per_million: 2.19,
})
} else if lower.contains("deepseek-v3.2") {
// DeepSeek-V3.2 (with reasoning): same pricing tier as V3
Some(ModelPricing {
input_per_million: 0.27,
output_per_million: 1.10,
})
} else if lower.contains("deepseek-chat") || lower.contains("deepseek-v3") {
// DeepSeek-V3: $0.27/M input, $1.10/M output
Some(ModelPricing {
input_per_million: 0.27,
output_per_million: 1.10,
})
} else if lower.contains("deepseek") {
// Generic DeepSeek fallback (V3 pricing)
Some(ModelPricing {
input_per_million: 0.27,
output_per_million: 1.10,
})
} else {
None
}
}
/// Estimated cost for a tool execution
#[derive(Debug, Clone)]
pub struct CostEstimate {
@@ -50,3 +87,26 @@ pub fn estimate_tool_cost(tool_name: &str, params: &Value) -> Option<CostEstimat
let _ = (tool_name, params);
None
}
/// Calculate cost for a turn given token usage and model.
#[must_use]
pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
let pricing = pricing_for_model(model)?;
let input_cost = (input_tokens as f64 / 1_000_000.0) * pricing.input_per_million;
let output_cost = (output_tokens as f64 / 1_000_000.0) * pricing.output_per_million;
Some(input_cost + output_cost)
}
/// Format a USD cost for compact display.
#[must_use]
pub fn format_cost(cost: f64) -> String {
if cost < 0.0001 {
"<$0.01".to_string()
} else if cost < 0.01 {
format!("${:.4}", cost)
} else if cost < 1.0 {
format!("${:.3}", cost)
} else {
format!("${:.2}", cost)
}
}
+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")
);
}
}
+19 -17
View File
@@ -13,8 +13,6 @@ pub const BASE_PROMPT: &str = include_str!("prompts/base.txt");
pub const NORMAL_PROMPT: &str = include_str!("prompts/normal.txt");
pub const AGENT_PROMPT: &str = include_str!("prompts/agent.txt");
pub const PLAN_PROMPT: &str = include_str!("prompts/plan.txt");
pub const RLM_PROMPT: &str = include_str!("prompts/rlm.txt");
pub const DUO_PROMPT: &str = include_str!("prompts/duo.txt");
/// Get the system prompt for a specific mode
pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
@@ -22,8 +20,6 @@ pub fn system_prompt_for_mode(mode: AppMode) -> SystemPrompt {
AppMode::Normal => NORMAL_PROMPT,
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
AppMode::Plan => PLAN_PROMPT,
AppMode::Rlm => RLM_PROMPT,
AppMode::Duo => DUO_PROMPT,
};
SystemPrompt::Text(text.trim().to_string())
}
@@ -33,15 +29,11 @@ pub fn system_prompt_for_mode_with_context(
mode: AppMode,
workspace: &Path,
working_set_summary: Option<&str>,
rlm_summary: Option<&str>,
duo_summary: Option<&str>,
) -> SystemPrompt {
let base_prompt = match mode {
AppMode::Normal => NORMAL_PROMPT,
AppMode::Agent | AppMode::Yolo => AGENT_PROMPT,
AppMode::Plan => PLAN_PROMPT,
AppMode::Rlm => RLM_PROMPT,
AppMode::Duo => DUO_PROMPT,
};
// Load project context from workspace
@@ -51,7 +43,15 @@ pub fn system_prompt_for_mode_with_context(
let mut full_prompt = if let Some(project_block) = project_context.as_system_block() {
format!("{}\n\n{}", base_prompt.trim(), project_block)
} else {
base_prompt.trim().to_string()
// Fallback: Generate an automatic project map summary
let summary = crate::utils::summarize_project(workspace);
let tree = crate::utils::project_tree(workspace, 2); // Shallow tree for prompt
format!(
"{}\n\n### Project Structure (Automatic Map)\n**Summary:** {}\n\n**Tree:**\n```\n{}\n```",
base_prompt.trim(),
summary,
tree
)
};
if let Some(summary) = working_set_summary
@@ -60,14 +60,16 @@ pub fn system_prompt_for_mode_with_context(
full_prompt = format!("{full_prompt}\n\n{summary}");
}
if mode == AppMode::Rlm {
let summary = rlm_summary.unwrap_or("No RLM contexts loaded.");
full_prompt = format!("{full_prompt}\n\nRLM Context Summary:\n{summary}");
}
if mode == AppMode::Duo {
let summary = duo_summary.unwrap_or("No Duo contexts loaded.");
full_prompt = format!("{full_prompt}\n\nDuo Context Summary:\n{summary}");
// Add compaction instruction for agent modes
if matches!(mode, AppMode::Agent | AppMode::Yolo) {
full_prompt.push_str(
"\n\n## Context Management\n\n\
When the conversation gets long (you'll see a context usage indicator), you can:\n\
1. Use `/compact` to summarize earlier context and free up space\n\
2. The system will preserve important information (files you're working on, recent messages, tool results)\n\
3. After compaction, you'll see a summary of what was discussed and can continue seamlessly\n\n\
If you notice context is getting long (>80%), proactively suggest using `/compact` to the user."
);
}
SystemPrompt::Text(full_prompt)
+27 -2
View File
@@ -15,7 +15,14 @@ Tool selection guidance:
- Use read_file to confirm context; do not assume file contents.
- Prefer apply_patch/edit_file for scoped changes instead of rewriting entire files.
- Use exec_shell for objective verification: build, test, format, lint, and targeted checks.
- Use web_search only when local context is insufficient or time-sensitive.
- Use web.run when local context is insufficient or time-sensitive, and cite sources as [cite:ref_id].
Web browsing and citations:
- Use web.run when info might have changed or you are unsure.
- Cite non-trivial factual claims using [cite:ref_id] (the ref_id returned by web.run).
- Place citations at the end of the sentence/paragraph they support; do not dump all citations at the end.
- Quote limits: do not quote more than 25 words verbatim from a single non-lyrical source (10 words for lyrics).
- Avoid reproducing full articles or large excerpts; prefer short quotes + paraphrase.
Testing and stop conditions:
- After any change, run the most relevant tests/checks before declaring success.
@@ -35,7 +42,17 @@ FILE OPERATIONS:
- edit_file: Search and replace text in a file
- apply_patch: Apply a unified diff patch to a file
- grep_files: Search files by regex
- web_search: Search the web for up-to-date information
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
- weather: Get a daily weather forecast for a location
- finance: Get the latest price for a stock, fund, index, or crypto
- sports: Get schedules or standings for a league
- time: Get current time for a UTC offset
- calculator: Evaluate a basic arithmetic expression
- list_mcp_resources: List MCP resources (optionally filtered by server)
- list_mcp_resource_templates: List MCP resource templates
GIT AND DIAGNOSTICS:
- git_status: Inspect repo status safely
@@ -50,6 +67,10 @@ SHELL EXECUTION:
- command: The command to execute
- timeout_ms: Timeout in milliseconds (default: 120000, max: 600000)
- background: Set true to run in background, returns task_id
- stdin: Optional stdin data to send before waiting
- tty: Allocate a pseudo-terminal (implies background)
- exec_shell_wait: Poll a background task for incremental output
- exec_shell_interact: Send stdin to a background task and read incremental output
TASK MANAGEMENT:
- todo_write: Write or update the todo list
@@ -60,6 +81,8 @@ SUB-AGENTS:
- agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools)
- agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context)
- agent_result: Get result from a sub-agent (agent_id, block, timeout_ms)
- send_input: Send input to a running sub-agent (agent_id, message, interrupt)
- wait: Wait for one or more sub-agents to complete (ids, timeout_ms)
- agent_cancel: Cancel a running sub-agent (agent_id)
- agent_list: List all sub-agents and their status
If you spawn a sub-agent, always follow up with agent_result (block: true) and incorporate its result before responding to the user.
@@ -80,3 +103,5 @@ Git hygiene:
BACKGROUND EXECUTION:
For long-running commands (build, test, server), use exec_shell with background: true.
This returns a task_id immediately in the tool output.
Use exec_shell_wait to poll for output, and exec_shell_interact to send stdin (or close stdin).
Use tty: true for interactive programs that require a TTY.
+8 -1
View File
@@ -12,7 +12,10 @@ Tool selection guidance:
- Use read tools to confirm context; avoid guessing about file contents.
- Prefer targeted edits (apply_patch/edit) over full rewrites when possible.
- Use shell tools for build/test/format/lint and other objective verification.
- Use web search only when the answer may be time-sensitive or unclear locally.
- Use web.run for time-sensitive or uncertain facts; include citations as [cite:ref_id].
- Use multi_tool_use.parallel for multiple read-only tool calls that can run together.
- Use request_user_input to ask short multiple-choice questions when needed.
- Use weather/finance/sports/time/calculator tools for their respective domains when applicable.
Planning and progress:
- For non-trivial tasks, publish a checklist with update_plan.
@@ -36,3 +39,7 @@ Approval etiquette:
- In autonomous modes, warn before risky or irreversible actions.
Tone: competent, warm, and concise. Use light humor sparingly when it fits; a rare example is "You're absolutely right! ... maybe."
Context Management:
- You have a finite context window. Keep responses concise and prefer targeted file reads or searches.
- Long conversations may be compacted into summaries; ask for clarification if critical details are missing.
-18
View File
@@ -1,18 +0,0 @@
You are in Duo mode for requirements-driven development.
Workflow:
- Start with duo_init using a clear requirements checklist and acceptance criteria.
- Alternate duo_player (implement) and duo_coach (verify) until approved.
- In duo_player phases, work tool-first: search, read, make targeted edits, then verify.
- In duo_coach phases, prioritize objective verification and requirement coverage.
Tool selection and verification:
- Use search/read tools to ground work in the current codebase before editing.
- Prefer targeted diffs over broad rewrites when possible.
- After any change, run the most relevant tests/checks before handing off to duo_coach.
- If verification fails, report it concisely, fix it, and re-run.
Budgeting and hygiene:
- Budget attempts. If 2-3 attempts fail to make progress, reassess and state the blocker.
- Check git status early and again before finishing; do not revert unrelated changes.
- Provide brief progress updates at phase boundaries and major checkpoints.
+15 -1
View File
@@ -11,12 +11,24 @@ Available tools in this mode:
- edit_file: Search and replace text in a file (ask first)
- apply_patch: Apply a unified diff patch (ask first)
- grep_files: Search files by regex
- web_search: Search the web for up-to-date information
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
- weather: Get a daily weather forecast for a location
- finance: Get the latest price for a stock, fund, index, or crypto
- sports: Get schedules or standings for a league
- time: Get current time for a UTC offset
- calculator: Evaluate a basic arithmetic expression
- list_mcp_resources: List MCP resources (optionally filtered by server)
- list_mcp_resource_templates: List MCP resource templates
- git_status: Inspect repository status safely
- git_diff: Inspect diffs (working tree or staged)
- diagnostics: Report workspace, git, sandbox, and toolchain info
- run_tests: Run `cargo test` with optional args
- exec_shell: Run shell commands (ask first, if enabled)
- exec_shell_wait: Poll a background shell task for incremental output
- exec_shell_interact: Send stdin to a background shell task (supports TTY sessions)
- note: Record important information
- todo_write: Write or update the todo list
- update_plan: Publish a structured plan
@@ -34,6 +46,8 @@ Tool selection guidance:
- Use read_file to ground your answer in the actual code.
- When approved to edit, prefer apply_patch/edit_file for targeted diffs.
- When approved to run commands, use exec_shell for build/test/format/lint and other objective checks.
- For long-running or interactive commands, use exec_shell with background: true, then exec_shell_wait/exec_shell_interact for output/input. Use tty: true when a program requires a TTY.
- When you need up-to-date or uncertain info, use web.run and cite sources as [cite:ref_id].
Testing and stop conditions (after approval to edit/run commands):
- After any change, run the most relevant tests/checks before declaring success.
+12 -1
View File
@@ -18,13 +18,24 @@ EXPLORATION:
- list_dir: Browse directories in the workspace
- read_file: Read file contents to understand context
- grep_files: Search files by regex
- web_search: Search the web for up-to-date information (if enabled)
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
- web_search: Quick web search (fallback when citations are not needed)
- request_user_input: Ask the user short multiple-choice questions
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
- weather: Get a daily weather forecast for a location
- finance: Get the latest price for a stock, fund, index, or crypto
- sports: Get schedules or standings for a league
- time: Get current time for a UTC offset
- calculator: Evaluate a basic arithmetic expression
- list_mcp_resources: List MCP resources (optionally filtered by server)
- list_mcp_resource_templates: List MCP resource templates
- git_status: Inspect repository status safely
- git_diff: Inspect diffs to understand current changes
- diagnostics: Report workspace, git, sandbox, and toolchain info
Guidelines:
- Prefer tool-centric planning: use grep_files/list_dir/read_file to ground the plan in the actual codebase.
- Use web.run for time-sensitive or uncertain facts, and cite sources as [cite:ref_id].
- Use update_plan to create structured plans with one step in_progress at a time.
- Each step should be specific, actionable, and include expected outcomes.
- Include explicit verification steps (tests/checks) after each planned change.
-13
View File
@@ -1,13 +0,0 @@
You are in RLM mode for working with large files that exceed context limits.
Work tool-first and chunk-aware:
- Use rlm_* tools to load files, explore content, and run focused queries over chunks.
- Prefer search-then-read: locate relevant sections before loading large spans.
- Summarize the relevant chunks in your own words before editing.
- Make targeted edits; avoid full-file rewrites unless necessary.
Verification, budgeting, and hygiene:
- After any change, run the most relevant tests/checks before declaring success.
- Check git status early and again before finishing; do not revert unrelated changes.
- Budget attempts. If 2-3 chunking/search attempts fail to surface the needed context, pause and state the blocker or request clarification.
- Provide concise progress updates at key checkpoints (what chunk/area you inspected, what changed, what verified).
-1303
View File
File diff suppressed because it is too large. Load diff
+1492
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+260 -9
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,13 +111,10 @@ 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
/// Save a session to disk using atomic write (temp file + rename).
pub fn save_session(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
let filename = format!("{}.json", session.metadata.id);
let path = self.sessions_dir.join(&filename);
@@ -83,7 +122,11 @@ impl SessionManager {
let content = serde_json::to_string_pretty(session)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
fs::write(&path, content)?;
// Atomic write: write to temp file then rename to avoid corruption
let tmp_filename = format!(".{}.tmp", session.metadata.id);
let tmp_path = self.sessions_dir.join(&tmp_filename);
fs::write(&tmp_path, &content)?;
fs::rename(&tmp_path, &path)?;
// Clean up old sessions if we have too many
self.cleanup_old_sessions()?;
@@ -91,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");
@@ -99,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)
}
@@ -202,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],
@@ -245,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,
@@ -268,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();
@@ -290,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}...")
}
}
@@ -340,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 {
@@ -464,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"));
}
}
+24 -20
View File
@@ -7,6 +7,8 @@ use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::canonical_model_name;
/// User settings with defaults
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
@@ -15,13 +17,11 @@ pub struct Settings {
pub theme: String,
/// Auto-compact conversations when they get long
pub auto_compact: bool,
/// Auto-switch to RLM mode when large inputs are detected
pub auto_rlm: bool,
/// Show thinking blocks from the model
pub show_thinking: bool,
/// Show detailed tool output
pub show_tool_details: bool,
/// Default mode: "agent", "plan", "yolo", "rlm", "duo"
/// Default mode: "agent", "plan", "yolo"
pub default_mode: String,
/// Sidebar width as percentage of terminal width
pub sidebar_width_percent: u16,
@@ -34,9 +34,8 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
theme: "default".to_string(),
theme: "whale".to_string(),
auto_compact: true,
auto_rlm: false,
show_thinking: true,
show_tool_details: true,
default_mode: "agent".to_string(),
@@ -68,6 +67,11 @@ impl Settings {
let mut settings: Settings = toml::from_str(&content)
.with_context(|| format!("Failed to parse settings from {}", path.display()))?;
settings.default_mode = normalize_mode(&settings.default_mode).to_string();
settings.default_model = settings
.default_model
.as_deref()
.and_then(canonical_model_name)
.map(ToString::to_string);
Ok(settings)
}
@@ -92,9 +96,9 @@ impl Settings {
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
match key {
"theme" => {
if !["default", "dark", "light"].contains(&value) {
if !["default", "dark", "light", "whale"].contains(&value) {
anyhow::bail!(
"Failed to update setting: invalid theme '{value}'. Expected: default, dark, light."
"Failed to update setting: invalid theme '{value}'. Expected: default, dark, light, whale."
);
}
self.theme = value.to_string();
@@ -102,9 +106,6 @@ impl Settings {
"auto_compact" | "compact" => {
self.auto_compact = parse_bool(value)?;
}
"auto_rlm" => {
self.auto_rlm = parse_bool(value)?;
}
"show_thinking" | "thinking" => {
self.show_thinking = parse_bool(value)?;
}
@@ -113,9 +114,9 @@ impl Settings {
}
"default_mode" | "mode" => {
let normalized = normalize_mode(value);
if !["agent", "plan", "yolo", "rlm", "duo"].contains(&normalized) {
if !["agent", "plan", "yolo"].contains(&normalized) {
anyhow::bail!(
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo, rlm, duo."
"Failed to update setting: invalid mode '{value}'. Expected: agent, plan, yolo."
);
}
self.default_mode = normalized.to_string();
@@ -144,7 +145,12 @@ impl Settings {
self.max_input_history = max;
}
"default_model" | "model" => {
self.default_model = Some(value.to_string());
let Some(model) = canonical_model_name(value) else {
anyhow::bail!(
"Failed to update setting: invalid model '{value}'. Expected: deepseek-chat or deepseek-reasoner."
);
};
self.default_model = Some(model.to_string());
}
_ => {
anyhow::bail!("Failed to update setting: unknown setting '{key}'.");
@@ -160,7 +166,6 @@ impl Settings {
lines.push("─────────────────────────────".to_string());
lines.push(format!(" theme: {}", self.theme));
lines.push(format!(" auto_compact: {}", self.auto_compact));
lines.push(format!(" auto_rlm: {}", self.auto_rlm));
lines.push(format!(" show_thinking: {}", self.show_thinking));
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
lines.push(format!(" default_mode: {}", self.default_mode));
@@ -186,16 +191,15 @@ impl Settings {
vec![
("theme", "Color theme: default, dark, light"),
("auto_compact", "Auto-compact conversations: on/off"),
(
"auto_rlm",
"Auto-switch to RLM mode for large inputs: on/off",
),
("show_thinking", "Show model thinking: on/off"),
("show_tool_details", "Show detailed tool output: on/off"),
("default_mode", "Default mode: agent, plan, yolo, rlm, duo"),
("default_mode", "Default mode: agent, plan, yolo"),
("sidebar_width", "Sidebar width percentage: 10-50"),
("max_history", "Max input history entries"),
("default_model", "Default model name"),
(
"default_model",
"Default model: deepseek-chat or deepseek-reasoner",
),
]
}
}
+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
+19
View File
@@ -0,0 +1,19 @@
//! Shared test-only helpers.
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// Acquire the process-wide env-var mutex.
///
/// If a prior test panicked while holding the lock, recover the guard instead
/// of cascading failures across unrelated tests.
pub(crate) fn lock_test_env() -> MutexGuard<'static, ()> {
match env_lock().lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
+269
View File
@@ -0,0 +1,269 @@
//! Calculator tool for evaluating arithmetic expressions.
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, required_str,
};
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize)]
struct CalculatorResponse {
value: String,
result: String,
}
pub struct CalculatorTool;
#[async_trait]
impl ToolSpec for CalculatorTool {
fn name(&self) -> &'static str {
"calculator"
}
fn description(&self) -> &'static str {
"Evaluate a basic arithmetic expression."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"expression": { "type": "string" },
"prefix": { "type": "string" },
"suffix": { "type": "string" }
},
"required": ["expression"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn supports_parallel(&self) -> bool {
true
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let expression = required_str(&input, "expression")?;
let prefix = optional_str(&input, "prefix").unwrap_or("");
let suffix = optional_str(&input, "suffix").unwrap_or("");
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}");
ToolResult::json(&CalculatorResponse {
value: rendered,
result,
})
.map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
fn format_value(value: f64) -> String {
if value.fract() == 0.0 {
format!("{:.0}", value)
} else {
let rendered = format!("{value}");
rendered
}
}
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 = 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"));
}
}
-468
View File
@@ -1,468 +0,0 @@
//! Tools for Duo mode: Player-Coach autocoding workflow.
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::duo::{
DuoPhase, SharedDuoSession, generate_coach_prompt, generate_player_prompt, session_summary,
};
use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, required_str,
};
/// Initialize an autocoding session with requirements.
pub struct DuoInitTool {
session: SharedDuoSession,
}
impl DuoInitTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoInitTool {
fn name(&self) -> &'static str {
"duo_init"
}
fn description(&self) -> &'static str {
"Initialize a Duo autocoding session with requirements. Returns session summary."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"requirements": {
"type": "string",
"description": "The requirements document (source of truth). Should be structured as a checklist."
},
"max_turns": {
"type": "integer",
"description": "Maximum turns before timeout (default: 10)"
},
"session_name": {
"type": "string",
"description": "Optional human-readable session name (e.g., 'auth-feature')"
},
"approval_threshold": {
"type": "number",
"description": "Minimum compliance score for approval (0-1, default: 0.9)"
}
},
"required": ["requirements"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let requirements = required_str(&input, "requirements")?;
let max_turns = input
.get("max_turns")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let session_name = optional_str(&input, "session_name").map(str::to_string);
let approval_threshold = input.get("approval_threshold").and_then(|v| v.as_f64());
let mut session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session.start_session(
requirements.to_string(),
session_name,
max_turns,
approval_threshold,
);
let summary = state.summary();
Ok(ToolResult::success(format!(
"Duo session initialized. Ready for player phase.\n\n{}",
summary
)))
}
}
/// Generate the player prompt for implementation.
pub struct DuoPlayerTool {
session: SharedDuoSession,
}
impl DuoPlayerTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoPlayerTool {
fn name(&self) -> &'static str {
"duo_player"
}
fn description(&self) -> &'static str {
"Generate the player prompt for implementation. Must be in Init or Player phase. Call after implementing to advance to Coach phase."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"implementation_summary": {
"type": "string",
"description": "Optional summary of implementation work done (recorded in history)"
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let implementation_summary = optional_str(&input, "implementation_summary")
.map(str::to_string)
.unwrap_or_else(|| "Implementation in progress".to_string());
let mut session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session
.get_active_mut()
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
// Check we're in a valid phase for player
match state.phase {
DuoPhase::Init | DuoPhase::Player => {
// Generate prompt first
let prompt = generate_player_prompt(state);
// Advance to Coach phase
state
.advance_to_coach(implementation_summary)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
Ok(ToolResult::success(format!(
"=== PLAYER PROMPT ===\n\n{}\n\n---\nAdvanced to Coach phase. Use duo_coach for verification.",
prompt
)))
}
DuoPhase::Coach => Err(ToolError::invalid_input(
"Already in Coach phase. Use duo_coach to get verification prompt.",
)),
DuoPhase::Approved => Err(ToolError::invalid_input(
"Session already approved. Start a new session with duo_init.",
)),
DuoPhase::Timeout => Err(ToolError::invalid_input(
"Session timed out. Start a new session with duo_init.",
)),
}
}
}
/// Generate the coach prompt for validation.
pub struct DuoCoachTool {
session: SharedDuoSession,
}
impl DuoCoachTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoCoachTool {
fn name(&self) -> &'static str {
"duo_coach"
}
fn description(&self) -> &'static str {
"Generate the coach prompt for validation. Must be in Coach phase. Does NOT advance state."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session
.get_active()
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
if state.phase != DuoPhase::Coach {
return Err(ToolError::invalid_input(format!(
"Expected Coach phase, but current phase is {}. Use duo_player first.",
state.phase
)));
}
let prompt = generate_coach_prompt(state);
Ok(ToolResult::success(format!(
"=== COACH PROMPT ===\n\n{}\n\n---\nAfter verification, use duo_advance with feedback and approval status.",
prompt
)))
}
}
/// Advance the session after coach review.
pub struct DuoAdvanceTool {
session: SharedDuoSession,
}
impl DuoAdvanceTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoAdvanceTool {
fn name(&self) -> &'static str {
"duo_advance"
}
fn description(&self) -> &'static str {
"Advance the session after coach review. Updates turn count and records feedback. Returns new status."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"feedback": {
"type": "string",
"description": "The coach's feedback text (compliance checklist and actions needed)"
},
"approved": {
"type": "boolean",
"description": "Whether the coach approved the implementation (look for 'COACH APPROVED')"
},
"compliance_score": {
"type": "number",
"description": "Optional compliance score (0-1) based on checklist items satisfied"
}
},
"required": ["feedback", "approved"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let feedback = required_str(&input, "feedback")?;
let approved = input
.get("approved")
.and_then(|v| v.as_bool())
.ok_or_else(|| ToolError::missing_field("approved"))?;
let compliance_score = input.get("compliance_score").and_then(|v| v.as_f64());
let mut session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
let state = session
.get_active_mut()
.ok_or_else(|| ToolError::invalid_input("No active session. Call duo_init first."))?;
if state.phase != DuoPhase::Coach {
return Err(ToolError::invalid_input(format!(
"Expected Coach phase, but current phase is {}",
state.phase
)));
}
// Advance the turn
state
.advance_turn(feedback.to_string(), approved, compliance_score)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
// Determine status message based on new phase
let status_msg = match state.phase {
DuoPhase::Approved => "🎉 APPROVED! All requirements verified.",
DuoPhase::Timeout => "⏰ TIMEOUT. Max turns reached without approval.",
DuoPhase::Player => "🔄 Continuing to next player turn...",
_ => "Session updated.",
};
let summary = state.summary();
let mut result = ToolResult::success(format!("{}\n\n{}", status_msg, summary));
result.metadata = Some(json!({
"phase": state.phase.to_string(),
"status": state.status.to_string(),
"turn": state.current_turn,
"max_turns": state.max_turns,
"approved": approved,
"compliance_score": compliance_score,
"is_complete": state.is_complete(),
}));
Ok(result)
}
}
/// Show the current session status.
pub struct DuoStatusTool {
session: SharedDuoSession,
}
impl DuoStatusTool {
#[must_use]
pub fn new(session: SharedDuoSession) -> Self {
Self { session }
}
}
#[async_trait]
impl ToolSpec for DuoStatusTool {
fn name(&self) -> &'static str {
"duo_status"
}
fn description(&self) -> &'static str {
"Show the current Duo session status including phase, turn count, and requirements."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let session = self
.session
.lock()
.map_err(|_| ToolError::execution_failed("Failed to lock Duo session"))?;
Ok(ToolResult::success(session_summary(&session)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::duo::new_shared_duo_session;
#[test]
fn test_duo_init_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoInitTool::new(session);
assert_eq!(tool.name(), "duo_init");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
let schema = tool.input_schema();
assert!(schema.get("properties").is_some());
assert!(
schema["required"]
.as_array()
.unwrap()
.contains(&json!("requirements"))
);
}
#[test]
fn test_duo_player_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoPlayerTool::new(session);
assert_eq!(tool.name(), "duo_player");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
}
#[test]
fn test_duo_coach_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoCoachTool::new(session);
assert_eq!(tool.name(), "duo_coach");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
}
#[test]
fn test_duo_advance_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoAdvanceTool::new(session);
assert_eq!(tool.name(), "duo_advance");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
let schema = tool.input_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("feedback")));
assert!(required.contains(&json!("approved")));
}
#[test]
fn test_duo_status_tool_schema() {
let session = new_shared_duo_session();
let tool = DuoStatusTool::new(session);
assert_eq!(tool.name(), "duo_status");
assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
}
}
+350
View File
@@ -0,0 +1,350 @@
//! Finance tool for stock/crypto pricing.
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
};
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use std::time::Duration;
const TIMEOUT_MS: u64 = 15_000;
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
#[derive(Debug, Clone, Serialize)]
struct FinanceRequest {
ticker: String,
instrument_type: String,
market: String,
}
#[derive(Debug, Clone, Serialize)]
struct FinanceResult {
ticker: String,
instrument_type: String,
market: String,
source: String,
price: Option<f64>,
currency: Option<String>,
as_of: Option<String>,
details: Value,
}
#[derive(Debug, Clone, Serialize)]
struct FinanceResponse {
results: Vec<FinanceResult>,
}
pub struct FinanceTool;
#[async_trait]
impl ToolSpec for FinanceTool {
fn name(&self) -> &'static str {
"finance"
}
fn description(&self) -> &'static str {
"Get the latest price for a stock, fund, index, or cryptocurrency."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"ticker": { "type": "string" },
"type": { "type": "string", "enum": ["equity", "fund", "crypto", "index"] },
"market": { "type": "string" },
"finance": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ticker": { "type": "string" },
"type": { "type": "string" },
"market": { "type": "string" }
},
"required": ["ticker", "type"]
}
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Network]
}
fn supports_parallel(&self) -> bool {
true
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let requests = parse_finance_requests(&input)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(TIMEOUT_MS))
.user_agent(USER_AGENT)
.build()
.map_err(|e| {
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
})?;
let mut results = Vec::with_capacity(requests.len());
for req in requests {
let instrument_type = req.instrument_type.to_lowercase();
let result = if instrument_type == "crypto" {
fetch_crypto_price(&client, &req).await?
} else {
fetch_stooq_price(&client, &req).await?
};
results.push(result);
}
ToolResult::json(&FinanceResponse { results })
.map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
fn parse_finance_requests(input: &Value) -> Result<Vec<FinanceRequest>, ToolError> {
if let Some(list) = input.get("finance").and_then(|v| v.as_array()) {
let mut requests = Vec::new();
for item in list {
let ticker = required_str(item, "ticker")?.to_string();
let instrument_type = required_str(item, "type")?.to_string();
let market = item
.get("market")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
requests.push(FinanceRequest {
ticker,
instrument_type,
market,
});
}
if requests.is_empty() {
return Err(ToolError::invalid_input("finance list is empty"));
}
return Ok(requests);
}
let ticker = required_str(input, "ticker")?.to_string();
let instrument_type = required_str(input, "type")?.to_string();
let market = input
.get("market")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(vec![FinanceRequest {
ticker,
instrument_type,
market,
}])
}
async fn fetch_crypto_price(
client: &reqwest::Client,
req: &FinanceRequest,
) -> Result<FinanceResult, ToolError> {
let search_url = format!(
"https://api.coingecko.com/api/v3/search?query={}",
url_encode(&req.ticker)
);
let search_resp = client
.get(&search_url)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("CoinGecko search failed: {e}")))?;
let status = search_resp.status();
let body = search_resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"CoinGecko search failed: HTTP {}",
status.as_u16()
)));
}
let json: Value = serde_json::from_str(&body)
.map_err(|e| ToolError::execution_failed(format!("Invalid CoinGecko JSON: {e}")))?;
let coins = json
.get("coins")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::execution_failed("CoinGecko returned no coins"))?;
let ticker_lower = req.ticker.to_lowercase();
let selected = coins
.iter()
.find(|coin| {
coin.get("symbol")
.and_then(|v| v.as_str())
.map(|s| s.eq_ignore_ascii_case(&ticker_lower))
.unwrap_or(false)
})
.or_else(|| coins.first())
.ok_or_else(|| ToolError::execution_failed("CoinGecko returned no results"))?;
let id = selected
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::execution_failed("Missing CoinGecko id"))?;
let symbol = selected
.get("symbol")
.and_then(|v| v.as_str())
.unwrap_or(&req.ticker)
.to_string();
let name = selected
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(&req.ticker)
.to_string();
let price_url = format!(
"https://api.coingecko.com/api/v3/simple/price?ids={id}&vs_currencies=usd&include_last_updated_at=true"
);
let price_resp = client
.get(&price_url)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("CoinGecko price failed: {e}")))?;
let status = price_resp.status();
let body = price_resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"CoinGecko price failed: HTTP {}",
status.as_u16()
)));
}
let json: Value = serde_json::from_str(&body)
.map_err(|e| ToolError::execution_failed(format!("Invalid CoinGecko price JSON: {e}")))?;
let price = json
.get(id)
.and_then(|v| v.get("usd"))
.and_then(|v| v.as_f64());
let last_updated = json
.get(id)
.and_then(|v| v.get("last_updated_at"))
.and_then(|v| v.as_i64())
.map(|ts| format!("{ts}"));
Ok(FinanceResult {
ticker: req.ticker.clone(),
instrument_type: req.instrument_type.clone(),
market: req.market.clone(),
source: "coingecko".to_string(),
price,
currency: Some("USD".to_string()),
as_of: last_updated,
details: json!({
"id": id,
"symbol": symbol,
"name": name,
}),
})
}
async fn fetch_stooq_price(
client: &reqwest::Client,
req: &FinanceRequest,
) -> Result<FinanceResult, ToolError> {
let symbol = normalize_stooq_symbol(&req.ticker, &req.market);
let url = format!("https://stooq.com/q/l/?s={symbol}&f=sd2t2ohlcv&h&e=csv");
let resp = client
.get(&url)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Stooq request failed: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Stooq failed: HTTP {}",
status.as_u16()
)));
}
let mut lines = body.lines();
let _header = lines.next();
let data = lines
.next()
.ok_or_else(|| ToolError::execution_failed("Stooq returned no data"))?;
let fields: Vec<&str> = data.split(',').collect();
if fields.len() < 8 {
return Err(ToolError::execution_failed("Stooq data malformed"));
}
if fields[1] == "N/D" {
return Err(ToolError::execution_failed("Stooq returned no data"));
}
let date = fields[1].to_string();
let time = fields[2].to_string();
let open = parse_f64(fields[3]);
let high = parse_f64(fields[4]);
let low = parse_f64(fields[5]);
let close = parse_f64(fields[6]);
let volume = parse_f64(fields[7]);
Ok(FinanceResult {
ticker: req.ticker.clone(),
instrument_type: req.instrument_type.clone(),
market: req.market.clone(),
source: "stooq".to_string(),
price: close,
currency: None,
as_of: Some(format!("{date} {time}")),
details: json!({
"symbol": symbol,
"open": open,
"high": high,
"low": low,
"close": close,
"volume": volume,
"date": date,
"time": time,
}),
})
}
fn normalize_stooq_symbol(ticker: &str, market: &str) -> String {
if ticker.contains('.') {
return ticker.to_lowercase();
}
let suffix = match market.to_lowercase().as_str() {
"usa" | "us" => ".us",
"uk" | "gb" => ".uk",
"jp" | "japan" => ".jp",
"de" | "germany" => ".de",
"fr" | "france" => ".fr",
"ca" | "canada" => ".ca",
_ => "",
};
format!("{}{}", ticker.to_lowercase(), suffix)
}
fn parse_f64(input: &str) -> Option<f64> {
input.parse::<f64>().ok()
}
fn url_encode(input: &str) -> String {
let mut encoded = String::new();
for ch in input.bytes() {
match ch {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(ch as char)
}
b' ' => encoded.push('+'),
_ => encoded.push_str(&format!("%{ch:02X}")),
}
}
encoded
}
+24 -5
View File
@@ -5,22 +5,30 @@
// === Modules ===
pub mod apply_patch;
pub mod calculator;
pub mod diagnostics;
pub mod duo;
pub mod file;
pub mod file_search;
pub mod finance;
pub mod git;
pub mod parallel;
pub mod plan;
pub mod project;
pub mod registry;
pub mod review;
pub mod rlm;
pub mod search;
pub mod shell;
mod shell_output;
pub mod spec;
pub mod sports;
pub mod subagent;
pub mod swarm;
pub mod test_runner;
pub mod time;
pub mod todo;
pub mod user_input;
pub mod weather;
pub mod web_run;
pub mod web_search;
// === Re-exports ===
@@ -35,7 +43,15 @@ pub use registry::{ToolRegistry, ToolRegistryBuilder};
pub use file_search::FileSearchTool;
pub use search::GrepFilesTool;
// Re-export structured data tools
pub use calculator::CalculatorTool;
pub use finance::FinanceTool;
pub use sports::SportsTool;
pub use time::TimeTool;
pub use weather::WeatherTool;
// Re-export web search tools
pub use web_run::WebRunTool;
pub use web_search::WebSearchTool;
// Re-export patch tools
@@ -54,7 +70,7 @@ pub use diagnostics::DiagnosticsTool;
pub use git::{GitDiffTool, GitStatusTool};
// Re-export shell types
pub use shell::ExecShellTool;
pub use shell::{ExecShellTool, ShellInteractTool, ShellWaitTool};
// Re-export subagent types
pub use subagent::SubAgent;
@@ -68,5 +84,8 @@ pub use todo::TodoWriteTool;
// Re-export plan types
pub use plan::UpdatePlanTool;
// Re-export RLM tools
pub use rlm::{RlmExecTool, RlmLoadTool, RlmQueryTool, RlmStatusTool};
// Re-export parallel/multi-tool types
pub use parallel::MultiToolUseParallelTool;
// Re-export user input tool/types
pub use user_input::{RequestUserInputTool, UserInputAnswer, UserInputRequest, UserInputResponse};
+58
View File
@@ -0,0 +1,58 @@
//! Tool wrapper for executing multiple tool calls in parallel.
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
use async_trait::async_trait;
use serde_json::{Value, json};
pub struct MultiToolUseParallelTool;
#[async_trait]
impl ToolSpec for MultiToolUseParallelTool {
fn name(&self) -> &'static str {
"multi_tool_use.parallel"
}
fn description(&self) -> &'static str {
"Execute multiple tool calls in parallel and return their results."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"tool_uses": {
"type": "array",
"items": {
"type": "object",
"properties": {
"recipient_name": { "type": "string" },
"parameters": { "type": "object" }
},
"required": ["recipient_name", "parameters"]
}
}
},
"required": ["tool_uses"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
Err(ToolError::execution_failed(
"multi_tool_use.parallel must be handled by the engine",
))
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Project mapping tool for understanding codebase structure.
use crate::utils::{is_key_file, project_tree, summarize_project};
use anyhow::Result;
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
};
pub struct ProjectMapTool;
#[derive(Debug, Serialize)]
struct ProjectMap {
tree: String,
summary: String,
key_files: Vec<String>,
}
#[async_trait]
impl ToolSpec for ProjectMapTool {
fn name(&self) -> &'static str {
"project_map"
}
fn description(&self) -> &'static str {
"Get a high-level map of the project structure, including key files and a tree view."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"max_depth": {
"type": "integer",
"description": "Maximum depth for the tree view (default: 3)."
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let max_depth = optional_u64(&input, "max_depth", 3) as usize;
let map = generate_project_map(&context.workspace, max_depth)?;
ToolResult::json(&map).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
fn generate_project_map(root: &std::path::Path, max_depth: usize) -> Result<ProjectMap, ToolError> {
let tree = project_tree(root, max_depth);
let summary = summarize_project(root);
// For key_files, we can just do a quick scan since summarize_project doesn't return them directly anymore
let mut key_files = Vec::new();
let mut builder = ignore::WalkBuilder::new(root);
builder.hidden(false).follow_links(true).max_depth(Some(2));
let walker = builder.build();
for entry in walker.flatten() {
if is_key_file(entry.path()) {
if let Ok(rel) = entry.path().strip_prefix(root) {
key_files.push(rel.to_string_lossy().to_string());
}
}
}
Ok(ProjectMap {
tree,
summary,
key_files,
})
}
+61 -31
View File
@@ -12,9 +12,7 @@ use std::sync::Arc;
use serde_json::Value;
use crate::client::DeepSeekClient;
use crate::duo::SharedDuoSession;
use crate::models::Tool;
use crate::rlm::SharedRlmSession;
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
@@ -280,8 +278,12 @@ impl ToolRegistryBuilder {
/// Include shell execution tool.
#[must_use]
pub fn with_shell_tools(self) -> Self {
use super::shell::ExecShellTool;
use super::shell::{ExecShellTool, ShellInteractTool, ShellWaitTool};
self.with_tool(Arc::new(ExecShellTool))
.with_tool(Arc::new(ShellWaitTool::new("exec_shell_wait")))
.with_tool(Arc::new(ShellInteractTool::new("exec_shell_interact")))
.with_tool(Arc::new(ShellWaitTool::new("exec_wait")))
.with_tool(Arc::new(ShellInteractTool::new("exec_interact")))
}
/// Include search tools (`grep_files`).
@@ -308,6 +310,13 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(DiagnosticsTool))
}
/// Include project mapping tools.
#[must_use]
pub fn with_project_tools(self) -> Self {
use super::project::ProjectMapTool;
self.with_tool(Arc::new(ProjectMapTool))
}
/// Include cargo test runner tool.
#[must_use]
pub fn with_test_runner_tool(self) -> Self {
@@ -318,8 +327,35 @@ impl ToolRegistryBuilder {
/// Include web search tools.
#[must_use]
pub fn with_web_tools(self) -> Self {
use super::web_run::WebRunTool;
use super::web_search::WebSearchTool;
self.with_tool(Arc::new(WebSearchTool))
.with_tool(Arc::new(WebRunTool))
}
/// Include multi-tool parallel wrapper.
#[must_use]
pub fn with_parallel_tool(self) -> Self {
use super::parallel::MultiToolUseParallelTool;
self.with_tool(Arc::new(MultiToolUseParallelTool))
}
/// Include request_user_input tool.
#[must_use]
pub fn with_user_input_tool(self) -> Self {
use super::user_input::RequestUserInputTool;
self.with_tool(Arc::new(RequestUserInputTool))
}
/// Include structured data tools (weather/finance/sports/time/calculator).
#[must_use]
pub fn with_structured_data_tools(self) -> Self {
use super::{CalculatorTool, FinanceTool, SportsTool, TimeTool, WeatherTool};
self.with_tool(Arc::new(WeatherTool))
.with_tool(Arc::new(FinanceTool))
.with_tool(Arc::new(SportsTool))
.with_tool(Arc::new(TimeTool))
.with_tool(Arc::new(CalculatorTool))
}
/// Include patch tools (`apply_patch`).
@@ -351,9 +387,13 @@ impl ToolRegistryBuilder {
.with_note_tool()
.with_search_tools()
.with_web_tools()
.with_user_input_tool()
.with_parallel_tool()
.with_structured_data_tools()
.with_patch_tools()
.with_git_tools()
.with_diagnostics_tool()
.with_project_tools()
.with_test_runner_tool();
if allow_shell {
@@ -393,33 +433,6 @@ impl ToolRegistryBuilder {
.with_plan_tool(plan_state)
}
/// Include RLM tools for context execution and sub-queries.
#[must_use]
pub fn with_rlm_tools(
self,
session: SharedRlmSession,
client: Option<DeepSeekClient>,
model: String,
) -> Self {
self.with_tool(Arc::new(super::rlm::RlmExecTool::new(session.clone())))
.with_tool(Arc::new(super::rlm::RlmLoadTool::new(session.clone())))
.with_tool(Arc::new(super::rlm::RlmStatusTool::new(session.clone())))
.with_tool(Arc::new(super::rlm::RlmQueryTool::new(
session, client, model,
)))
}
/// Include Duo tools for dialectical autocoding.
#[must_use]
pub fn with_duo_tools(self, session: SharedDuoSession) -> Self {
use super::duo::{DuoAdvanceTool, DuoCoachTool, DuoInitTool, DuoPlayerTool, DuoStatusTool};
self.with_tool(Arc::new(DuoInitTool::new(session.clone())))
.with_tool(Arc::new(DuoPlayerTool::new(session.clone())))
.with_tool(Arc::new(DuoCoachTool::new(session.clone())))
.with_tool(Arc::new(DuoAdvanceTool::new(session.clone())))
.with_tool(Arc::new(DuoStatusTool::new(session)))
}
/// Include sub-agent management tools.
#[must_use]
pub fn with_subagent_tools(
@@ -427,15 +440,32 @@ impl ToolRegistryBuilder {
manager: super::subagent::SharedSubAgentManager,
runtime: super::subagent::SubAgentRuntime,
) -> Self {
use super::subagent::{AgentCancelTool, AgentListTool, AgentResultTool, AgentSpawnTool};
use super::subagent::{
AgentCancelTool, AgentListTool, AgentResultTool, AgentSendInputTool, AgentSpawnTool,
AgentWaitTool, DelegateToAgentTool,
};
use super::swarm::AgentSwarmTool;
self.with_tool(Arc::new(AgentSpawnTool::new(
manager.clone(),
runtime.clone(),
)))
.with_tool(Arc::new(DelegateToAgentTool::new(
manager.clone(),
runtime.clone(),
)))
.with_tool(Arc::new(AgentSwarmTool::new(manager.clone(), runtime)))
.with_tool(Arc::new(AgentResultTool::new(manager.clone())))
.with_tool(Arc::new(AgentSendInputTool::new(
manager.clone(),
"send_input",
)))
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "wait")))
.with_tool(Arc::new(AgentSendInputTool::new(
manager.clone(),
"agent_send_input",
)))
.with_tool(Arc::new(AgentWaitTool::new(manager.clone(), "agent_wait")))
.with_tool(Arc::new(AgentCancelTool::new(manager.clone())))
.with_tool(Arc::new(AgentListTool::new(manager)))
}
-1047
View File
File diff suppressed because it is too large. Load diff
+600
View File
@@ -0,0 +1,600 @@
//! Semantic search tool with hybrid lexical + semantic ranking.
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use ignore::WalkBuilder;
use serde::Serialize;
use serde_json::{Value, json};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_bool, optional_u64, required_str,
};
const DEFAULT_TOP_K: usize = 8;
const MAX_TOP_K: usize = 64;
const DEFAULT_CHUNK_SIZE: usize = 800;
const MIN_CHUNK_SIZE: usize = 200;
const MAX_CHUNK_SIZE: usize = 4_000;
const DEFAULT_MAX_FILES: usize = 300;
const MAX_MAX_FILES: usize = 2_000;
const MAX_FILE_BYTES: u64 = 1_500_000;
const MAX_CANDIDATE_CHUNKS: usize = 4_000;
const EXCERPT_CHARS: usize = 280;
const EMBED_DIM: usize = 256;
const DISABLE_SEMANTIC_ENV: &str = "DEEPSEEK_DISABLE_SEMANTIC_SEARCH";
#[derive(Debug, Clone, Serialize)]
struct SemanticSearchHit {
path: String,
line: usize,
excerpt: String,
score: f64,
lexical_score: f64,
#[serde(skip_serializing_if = "Option::is_none")]
semantic_score: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
struct SemanticSearchResponse {
query: String,
results: Vec<SemanticSearchHit>,
scanned_files: usize,
candidate_chunks: usize,
fallback_used: bool,
backend: String,
truncated: bool,
}
#[derive(Debug, Clone)]
struct ChunkCandidate {
path: PathBuf,
line: usize,
text: String,
lexical_score: f64,
semantic_score: f64,
score: f64,
}
/// Tool for hybrid lexical + semantic code search.
pub struct SemanticSearchTool;
#[async_trait]
impl ToolSpec for SemanticSearchTool {
fn name(&self) -> &'static str {
"semantic_search"
}
fn description(&self) -> &'static str {
"Search code/doc chunks using hybrid lexical and semantic ranking."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Natural-language query to search for"
},
"path": {
"type": "string",
"description": "Optional base path (relative to workspace)"
},
"paths": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of base paths (relative to workspace)"
},
"top_k": {
"type": "integer",
"description": "Maximum number of ranked results (default: 8)"
},
"chunk_size": {
"type": "integer",
"description": "Approximate characters per chunk (default: 800)"
},
"max_files": {
"type": "integer",
"description": "Maximum files to scan (default: 300)"
},
"include_hidden": {
"type": "boolean",
"description": "Include hidden files/directories (default: false)"
}
},
"required": ["query"],
"additionalProperties": false
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
fn supports_parallel(&self) -> bool {
true
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let query = required_str(&input, "query")?.trim();
if query.is_empty() {
return Err(ToolError::invalid_input("query cannot be empty"));
}
let top_k = usize::try_from(optional_u64(&input, "top_k", DEFAULT_TOP_K as u64))
.unwrap_or(DEFAULT_TOP_K)
.clamp(1, MAX_TOP_K);
let chunk_size = usize::try_from(optional_u64(
&input,
"chunk_size",
DEFAULT_CHUNK_SIZE as u64,
))
.unwrap_or(DEFAULT_CHUNK_SIZE)
.clamp(MIN_CHUNK_SIZE, MAX_CHUNK_SIZE);
let max_files = usize::try_from(optional_u64(&input, "max_files", DEFAULT_MAX_FILES as u64))
.unwrap_or(DEFAULT_MAX_FILES)
.clamp(1, MAX_MAX_FILES);
let include_hidden = optional_bool(&input, "include_hidden", false);
let roots = resolve_roots(context, &input)?;
let files = collect_files(&roots, include_hidden, max_files)?;
let query_tokens = tokenize(query);
let query_phrase = normalize_text(query);
let mut candidates = Vec::new();
let mut scanned_files = 0usize;
for file in files {
if candidates.len() >= MAX_CANDIDATE_CHUNKS {
break;
}
let Ok(metadata) = fs::metadata(&file) else {
continue;
};
if metadata.len() > MAX_FILE_BYTES {
continue;
}
let Ok(content) = fs::read_to_string(&file) else {
continue;
};
if !looks_like_text(&content) {
continue;
}
scanned_files += 1;
let chunks = split_into_chunks(&content, chunk_size);
if chunks.is_empty() {
continue;
}
let mut best_for_file: Option<ChunkCandidate> = None;
for (line, chunk_text) in chunks {
if candidates.len() >= MAX_CANDIDATE_CHUNKS {
break;
}
let lexical = lexical_score(&chunk_text, &query_tokens, &query_phrase);
let candidate = ChunkCandidate {
path: file.clone(),
line,
text: chunk_text,
lexical_score: lexical,
semantic_score: 0.0,
score: lexical,
};
if lexical > 0.0 {
candidates.push(candidate);
} else if best_for_file
.as_ref()
.is_none_or(|best| candidate.text.len() > best.text.len())
{
best_for_file = Some(candidate);
}
}
if candidates.iter().all(|c| c.path != file)
&& let Some(fallback_chunk) = best_for_file
{
candidates.push(fallback_chunk);
}
}
let mut fallback_used = false;
let semantic_enabled = semantic_backend_enabled();
let query_embedding = if semantic_enabled {
embed(query)
} else {
None
};
if query_embedding.is_none() {
fallback_used = true;
}
if let Some(query_vec) = query_embedding {
for candidate in &mut candidates {
let semantic = embed(&candidate.text)
.map(|chunk_vec| cosine_similarity(&query_vec, &chunk_vec))
.unwrap_or(0.0);
candidate.semantic_score = semantic;
candidate.score = (candidate.lexical_score * 0.35) + (semantic * 0.65);
}
} else {
for candidate in &mut candidates {
candidate.semantic_score = 0.0;
candidate.score = candidate.lexical_score;
}
}
candidates.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.line.cmp(&b.line))
});
let truncated = candidates.len() > top_k;
let hits: Vec<SemanticSearchHit> = candidates
.into_iter()
.take(top_k)
.map(|item| SemanticSearchHit {
path: item
.path
.strip_prefix(&context.workspace)
.unwrap_or(&item.path)
.to_string_lossy()
.to_string(),
line: item.line,
excerpt: compact_excerpt(&item.text, EXCERPT_CHARS),
score: round_score(item.score),
lexical_score: round_score(item.lexical_score),
semantic_score: if fallback_used {
None
} else {
Some(round_score(item.semantic_score))
},
})
.collect();
let response = SemanticSearchResponse {
query: query.to_string(),
scanned_files,
candidate_chunks: hits.len(),
results: hits,
fallback_used,
backend: if fallback_used {
"lexical-only".to_string()
} else {
"local-hash-embed".to_string()
},
truncated,
};
ToolResult::json(&response).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
fn resolve_roots(context: &ToolContext, input: &Value) -> Result<Vec<PathBuf>, ToolError> {
let mut roots = Vec::new();
if let Some(path) = input.get("path").and_then(|v| v.as_str())
&& !path.trim().is_empty()
{
roots.push(context.resolve_path(path)?);
}
if let Some(paths) = input.get("paths").and_then(|v| v.as_array()) {
for value in paths {
if let Some(path) = value.as_str() {
let trimmed = path.trim();
if !trimmed.is_empty() {
roots.push(context.resolve_path(trimmed)?);
}
}
}
}
if roots.is_empty() {
roots.push(context.workspace.clone());
}
let mut deduped = Vec::new();
let mut seen = HashSet::new();
for root in roots {
if seen.insert(root.clone()) {
deduped.push(root);
}
}
Ok(deduped)
}
fn collect_files(
roots: &[PathBuf],
include_hidden: bool,
max_files: usize,
) -> Result<Vec<PathBuf>, ToolError> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for root in roots {
if out.len() >= max_files {
break;
}
if root.is_file() {
if seen.insert(root.clone()) {
out.push(root.clone());
}
continue;
}
let mut builder = WalkBuilder::new(root);
builder
.hidden(!include_hidden)
.follow_links(true)
.require_git(false);
for entry in builder.build() {
if out.len() >= max_files {
break;
}
let entry = entry
.map_err(|e| ToolError::execution_failed(format!("File walk failed: {e}")))?;
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path().to_path_buf();
if seen.insert(path.clone()) {
out.push(path);
}
}
}
Ok(out)
}
fn split_into_chunks(content: &str, target_chars: usize) -> Vec<(usize, String)> {
let mut chunks = Vec::new();
let mut current = String::new();
let mut start_line = 1usize;
let mut current_line = 1usize;
for line in content.lines() {
let next_len = current.len() + line.len() + 1;
if !current.is_empty() && next_len > target_chars {
chunks.push((start_line, current.trim().to_string()));
current.clear();
start_line = current_line;
}
current.push_str(line);
current.push('\n');
current_line += 1;
}
if !current.trim().is_empty() {
chunks.push((start_line, current.trim().to_string()));
}
chunks
}
fn looks_like_text(content: &str) -> bool {
!content.contains('\u{0}')
}
fn normalize_text(text: &str) -> String {
text.to_ascii_lowercase()
}
fn tokenize(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
current.push(ch.to_ascii_lowercase());
} else if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
if !current.is_empty() {
tokens.push(current);
}
let mut counts: HashMap<String, usize> = HashMap::new();
for token in tokens {
*counts.entry(token).or_insert(0) += 1;
}
counts
.into_iter()
.filter(|(token, _)| token.len() > 1)
.map(|(token, _)| token)
.collect()
}
fn lexical_score(chunk: &str, query_tokens: &[String], query_phrase: &str) -> f64 {
if query_tokens.is_empty() {
return 0.0;
}
let chunk_norm = normalize_text(chunk);
let mut hits = 0usize;
for token in query_tokens {
if chunk_norm.contains(token) {
hits += 1;
}
}
let token_score = hits as f64 / query_tokens.len() as f64;
let phrase_bonus = if !query_phrase.is_empty() && chunk_norm.contains(query_phrase) {
0.2
} else {
0.0
};
(token_score + phrase_bonus).min(1.0)
}
fn semantic_backend_enabled() -> bool {
!std::env::var(DISABLE_SEMANTIC_ENV)
.ok()
.as_deref()
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
}
fn embed(text: &str) -> Option<Vec<f64>> {
let tokens = tokenize(text);
if tokens.is_empty() {
return None;
}
let mut vec = vec![0.0_f64; EMBED_DIM];
for token in tokens {
let idx = (stable_hash(&token) as usize) % EMBED_DIM;
vec[idx] += 1.0;
}
let norm = vec.iter().map(|v| v * v).sum::<f64>().sqrt();
if norm <= f64::EPSILON {
return None;
}
for value in &mut vec {
*value /= norm;
}
Some(vec)
}
fn stable_hash(text: &str) -> u64 {
// FNV-1a 64-bit
let mut hash = 0xcbf29ce484222325_u64;
for byte in text.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
if a.len() != b.len() {
return 0.0;
}
let mut sum = 0.0;
for (x, y) in a.iter().zip(b.iter()) {
sum += x * y;
}
sum.clamp(0.0, 1.0)
}
fn compact_excerpt(text: &str, max_chars: usize) -> String {
let normalized = text.replace('\n', " ").trim().to_string();
if normalized.chars().count() <= max_chars {
return normalized;
}
let head: String = normalized.chars().take(max_chars).collect();
format!("{}...", head.trim_end())
}
fn round_score(score: f64) -> f64 {
(score * 10_000.0).round() / 10_000.0
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tempfile::tempdir;
use crate::tools::spec::{ToolContext, ToolSpec};
use super::SemanticSearchTool;
#[tokio::test]
async fn semantic_search_finds_relevant_chunk() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("src")).expect("mkdir");
std::fs::write(
root.join("src/lib.rs"),
"pub fn analyze() {\n // ownership and lifetimes are central\n}\n",
)
.expect("write");
std::fs::write(root.join("README.md"), "terminal color theme settings\n").expect("write");
let ctx = ToolContext::new(root.to_path_buf());
let tool = SemanticSearchTool;
let result = tool
.execute(json!({"query": "ownership lifetimes", "top_k": 3}), &ctx)
.await
.expect("execute");
assert!(result.success);
assert!(result.content.contains("src/lib.rs"));
}
#[tokio::test]
async fn semantic_search_respects_path_filter() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("src")).expect("mkdir");
std::fs::create_dir_all(root.join("docs")).expect("mkdir");
std::fs::write(root.join("src/lib.rs"), "fn alpha() {}\n").expect("write");
std::fs::write(root.join("docs/guide.md"), "alpha beta gamma\n").expect("write");
let ctx = ToolContext::new(root.to_path_buf());
let tool = SemanticSearchTool;
let result = tool
.execute(
json!({"query": "alpha", "paths": ["docs"], "top_k": 3}),
&ctx,
)
.await
.expect("execute");
assert!(result.success);
assert!(result.content.contains("docs/guide.md"));
assert!(!result.content.contains("src/lib.rs"));
}
#[tokio::test]
async fn semantic_search_reports_fallback_when_disabled() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::write(root.join("notes.txt"), "offline semantic fallback\n").expect("write");
// Safety: test-only environment mutation scoped to this test.
unsafe {
std::env::set_var("DEEPSEEK_DISABLE_SEMANTIC_SEARCH", "1");
}
let ctx = ToolContext::new(root.to_path_buf());
let tool = SemanticSearchTool;
let result = tool
.execute(json!({"query": "offline fallback"}), &ctx)
.await
.expect("execute");
// Safety: test-only environment cleanup scoped to this test.
unsafe {
std::env::remove_var("DEEPSEEK_DISABLE_SEMANTIC_SEARCH");
}
assert!(result.success);
assert!(result.content.contains("\"fallback_used\": true"));
}
}
+719 -328
View File
File diff suppressed because it is too large. Load diff
+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
}
+20 -2
View File
@@ -13,6 +13,8 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
/// Capabilities that a tool may have or require.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ToolCapability {
@@ -177,6 +179,8 @@ pub enum SandboxPolicy {
pub struct ToolContext {
/// The workspace root directory
pub workspace: PathBuf,
/// Shared shell manager for background tasks and streaming IO.
pub shell_manager: SharedShellManager,
/// Whether to allow paths outside workspace
pub trust_mode: bool,
/// Current sandbox policy
@@ -198,10 +202,12 @@ impl ToolContext {
#[must_use]
pub fn new(workspace: impl Into<PathBuf>) -> Self {
let workspace = workspace.into();
let shell_manager = new_shared_shell_manager(workspace.clone());
let notes_path = workspace.join(".deepseek").join("notes.md");
let mcp_config_path = workspace.join(".deepseek").join("mcp.json");
Self {
workspace,
shell_manager,
trust_mode: false,
sandbox_policy: SandboxPolicy::None,
notes_path,
@@ -218,8 +224,11 @@ impl ToolContext {
notes_path: impl Into<PathBuf>,
mcp_config_path: impl Into<PathBuf>,
) -> Self {
let workspace = workspace.into();
let shell_manager = new_shared_shell_manager(workspace.clone());
Self {
workspace: workspace.into(),
workspace,
shell_manager,
trust_mode,
sandbox_policy: SandboxPolicy::None,
notes_path: notes_path.into(),
@@ -237,8 +246,11 @@ impl ToolContext {
mcp_config_path: impl Into<PathBuf>,
auto_approve: bool,
) -> Self {
let workspace = workspace.into();
let shell_manager = new_shared_shell_manager(workspace.clone());
Self {
workspace: workspace.into(),
workspace,
shell_manager,
trust_mode,
sandbox_policy: SandboxPolicy::None,
notes_path: notes_path.into(),
@@ -376,6 +388,12 @@ impl ToolContext {
self
}
/// Override the shared shell manager.
pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self {
self.shell_manager = shell_manager;
self
}
/// Set the elevated sandbox policy override.
///
/// This is used when retrying a tool after a sandbox denial, to run
+418
View File
@@ -0,0 +1,418 @@
//! Sports tool for schedules and standings (ESPN public APIs).
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, optional_u64, required_str,
};
use async_trait::async_trait;
use chrono::{NaiveDate, Utc};
use serde::Serialize;
use serde_json::{Value, json};
use std::time::Duration;
const TIMEOUT_MS: u64 = 15_000;
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
#[derive(Debug, Clone, Serialize)]
struct SportsGameTeam {
name: String,
abbreviation: String,
score: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct SportsGame {
id: String,
date: String,
status: String,
home: SportsGameTeam,
away: SportsGameTeam,
}
#[derive(Debug, Clone, Serialize)]
struct SportsScheduleResponse {
league: String,
games: Vec<SportsGame>,
}
#[derive(Debug, Clone, Serialize)]
struct SportsStandingEntry {
name: String,
abbreviation: String,
stats: Value,
}
#[derive(Debug, Clone, Serialize)]
struct SportsStandingsResponse {
league: String,
entries: Vec<SportsStandingEntry>,
}
pub struct SportsTool;
#[async_trait]
impl ToolSpec for SportsTool {
fn name(&self) -> &'static str {
"sports"
}
fn description(&self) -> &'static str {
"Get sports schedules or standings for a league."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"fn": { "type": "string", "enum": ["schedule", "standings"] },
"league": { "type": "string" },
"team": { "type": "string" },
"opponent": { "type": "string" },
"date_from": { "type": "string", "description": "YYYY-MM-DD" },
"date_to": { "type": "string", "description": "YYYY-MM-DD" },
"num_games": { "type": "integer" },
"locale": { "type": "string" }
},
"required": ["fn", "league"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Network]
}
fn supports_parallel(&self) -> bool {
true
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let action = required_str(&input, "fn")?.to_lowercase();
let league = required_str(&input, "league")?.to_lowercase();
let team = optional_str(&input, "team").map(|s| s.to_string());
let opponent = optional_str(&input, "opponent").map(|s| s.to_string());
let date_from = optional_str(&input, "date_from").map(|s| s.to_string());
let date_to = optional_str(&input, "date_to").map(|s| s.to_string());
let num_games = optional_u64(&input, "num_games", 20) as usize;
let (sport, league_code) = map_league(&league)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(TIMEOUT_MS))
.user_agent(USER_AGENT)
.build()
.map_err(|e| {
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
})?;
match action.as_str() {
"schedule" => {
let schedule = fetch_schedule(
&client,
&sport,
&league_code,
date_from.as_deref(),
date_to.as_deref(),
team.as_deref(),
opponent.as_deref(),
num_games,
)
.await?;
ToolResult::json(&schedule).map_err(|e| ToolError::execution_failed(e.to_string()))
}
"standings" => {
let standings = fetch_standings(&client, &sport, &league_code).await?;
ToolResult::json(&standings).map_err(|e| ToolError::execution_failed(e.to_string()))
}
_ => Err(ToolError::invalid_input("fn must be schedule or standings")),
}
}
}
fn map_league(league: &str) -> Result<(String, String), ToolError> {
match league {
"nba" => Ok(("basketball".to_string(), "nba".to_string())),
"wnba" => Ok(("basketball".to_string(), "wnba".to_string())),
"nfl" => Ok(("football".to_string(), "nfl".to_string())),
"nhl" => Ok(("hockey".to_string(), "nhl".to_string())),
"mlb" => Ok(("baseball".to_string(), "mlb".to_string())),
"epl" => Ok(("soccer".to_string(), "eng.1".to_string())),
"ncaamb" => Ok((
"basketball".to_string(),
"mens-college-basketball".to_string(),
)),
"ncaawb" => Ok((
"basketball".to_string(),
"womens-college-basketball".to_string(),
)),
"ipl" => Ok(("cricket".to_string(), "ipl".to_string())),
_ => Err(ToolError::invalid_input("Unsupported league")),
}
}
#[allow(clippy::too_many_arguments)]
async fn fetch_schedule(
client: &reqwest::Client,
sport: &str,
league: &str,
date_from: Option<&str>,
date_to: Option<&str>,
team: Option<&str>,
opponent: Option<&str>,
num_games: usize,
) -> Result<SportsScheduleResponse, ToolError> {
let date_param = build_date_param(date_from, date_to)?;
let url = if let Some(dates) = date_param {
format!(
"https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/scoreboard?dates={dates}"
)
} else {
format!("https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/scoreboard")
};
let resp = client
.get(&url)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Schedule request failed: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Schedule failed: HTTP {}",
status.as_u16()
)));
}
let json: Value = serde_json::from_str(&body)
.map_err(|e| ToolError::execution_failed(format!("Invalid schedule JSON: {e}")))?;
let events = json
.get("events")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut games = Vec::new();
for event in events.iter() {
let competitions = match event.get("competitions").and_then(|v| v.as_array()) {
Some(list) => list,
None => continue,
};
let competition = match competitions.first() {
Some(comp) => comp,
None => continue,
};
let competitors = match competition.get("competitors").and_then(|v| v.as_array()) {
Some(list) => list,
None => continue,
};
if competitors.len() < 2 {
continue;
}
let (home, away) = split_competitors(competitors);
if let (Some(home), Some(away)) = (home, away) {
if let Some(team_filter) = team {
if !team_matches(&home, team_filter) && !team_matches(&away, team_filter) {
continue;
}
}
if let Some(opponent_filter) = opponent {
if !team_matches(&home, opponent_filter) && !team_matches(&away, opponent_filter) {
continue;
}
}
let status = competition
.get("status")
.and_then(|v| v.get("type"))
.and_then(|v| v.get("description"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let game = SportsGame {
id: event
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
date: event
.get("date")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
status,
home,
away,
};
games.push(game);
}
if games.len() >= num_games {
break;
}
}
Ok(SportsScheduleResponse {
league: league.to_string(),
games,
})
}
fn split_competitors(competitors: &[Value]) -> (Option<SportsGameTeam>, Option<SportsGameTeam>) {
let mut home = None;
let mut away = None;
for comp in competitors {
let team = comp.get("team").and_then(|v| v.as_object());
let name = team
.and_then(|t| t.get("displayName"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let abbreviation = team
.and_then(|t| t.get("abbreviation"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let score = comp
.get("score")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let home_away = comp.get("homeAway").and_then(|v| v.as_str()).unwrap_or("");
let team_info = SportsGameTeam {
name,
abbreviation,
score,
};
if home_away == "home" {
home = Some(team_info);
} else {
away = Some(team_info);
}
}
(home, away)
}
fn team_matches(team: &SportsGameTeam, filter: &str) -> bool {
let req = filter.to_lowercase();
team.abbreviation.to_lowercase() == req || team.name.to_lowercase().contains(&req)
}
async fn fetch_standings(
client: &reqwest::Client,
sport: &str,
league: &str,
) -> Result<SportsStandingsResponse, ToolError> {
let url = format!("https://site.web.api.espn.com/apis/v2/sports/{sport}/{league}/standings");
let resp = client
.get(&url)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Standings request failed: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Standings failed: HTTP {}",
status.as_u16()
)));
}
let json: Value = serde_json::from_str(&body)
.map_err(|e| ToolError::execution_failed(format!("Invalid standings JSON: {e}")))?;
let entries = collect_standings_entries(&json);
let mut output = Vec::new();
for entry in entries {
let team = entry.get("team").and_then(|v| v.as_object());
let name = team
.and_then(|t| t.get("displayName"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let abbreviation = team
.and_then(|t| t.get("abbreviation"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let stats = entry
.get("stats")
.and_then(|v| v.as_array())
.map(|stats| {
let mut map = serde_json::Map::new();
for stat in stats {
if let Some(name) = stat.get("name").and_then(|v| v.as_str()) {
if let Some(val) = stat.get("displayValue").or_else(|| stat.get("value")) {
map.insert(name.to_string(), val.clone());
}
}
}
Value::Object(map)
})
.unwrap_or_else(|| json!({}));
output.push(SportsStandingEntry {
name,
abbreviation,
stats,
});
}
Ok(SportsStandingsResponse {
league: league.to_string(),
entries: output,
})
}
fn collect_standings_entries(value: &Value) -> Vec<Value> {
if let Some(entries) = value
.get("standings")
.and_then(|v| v.get("entries"))
.and_then(|v| v.as_array())
{
return entries.clone();
}
if let Some(children) = value.get("children").and_then(|v| v.as_array()) {
let mut entries = Vec::new();
for child in children {
entries.extend(collect_standings_entries(child));
}
return entries;
}
Vec::new()
}
fn build_date_param(
date_from: Option<&str>,
date_to: Option<&str>,
) -> Result<Option<String>, ToolError> {
let start = match date_from {
Some(date) => NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map_err(|_| ToolError::invalid_input("date_from must be YYYY-MM-DD"))?,
None => Utc::now().date_naive(),
};
if let Some(date_to) = date_to {
let end = NaiveDate::parse_from_str(date_to, "%Y-%m-%d")
.map_err(|_| ToolError::invalid_input("date_to must be YYYY-MM-DD"))?;
if end < start {
return Err(ToolError::invalid_input("date_to must be >= date_from"));
}
let diff = (end - start).num_days();
if diff > 7 {
return Ok(Some(start.format("%Y%m%d").to_string()));
}
return Ok(Some(format!(
"{}-{}",
start.format("%Y%m%d"),
end.format("%Y%m%d")
)));
}
Ok(Some(start.format("%Y%m%d").to_string()))
}
+364 -13
View File
@@ -4,7 +4,7 @@
//! and retrieve results. Sub-agents run with a filtered toolset and
//! inherit the workspace configuration from the main session.
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -39,10 +39,11 @@ const RESULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
// === Types ===
/// Sub-agent execution types with specialized behavior and tool access.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SubAgentType {
/// General purpose - full tool access for multi-step tasks.
#[default]
General,
/// Fast exploration - read-only tools for codebase search.
Explore,
@@ -92,8 +93,19 @@ impl SubAgentType {
"apply_patch",
"grep_files",
"file_search",
"web.run",
"web_search",
"multi_tool_use.parallel",
"weather",
"finance",
"sports",
"time",
"calculator",
"exec_shell",
"exec_shell_wait",
"exec_shell_interact",
"exec_wait",
"exec_interact",
"note",
"todo_write",
"todo_add",
@@ -106,15 +118,32 @@ impl SubAgentType {
"read_file",
"grep_files",
"file_search",
"web.run",
"web_search",
"multi_tool_use.parallel",
"weather",
"finance",
"sports",
"time",
"calculator",
"exec_shell",
"exec_shell_wait",
"exec_shell_interact",
"exec_wait",
"exec_interact",
],
Self::Plan => vec![
"list_dir",
"read_file",
"grep_files",
"file_search",
"web.run",
"note",
"weather",
"finance",
"sports",
"time",
"calculator",
"update_plan",
"todo_write",
"todo_add",
@@ -127,12 +156,6 @@ impl SubAgentType {
}
}
impl Default for SubAgentType {
fn default() -> Self {
Self::General
}
}
/// Status of a sub-agent execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SubAgentStatus {
@@ -153,6 +176,12 @@ pub struct SubAgentResult {
pub duration_ms: u64,
}
#[derive(Debug, Clone)]
struct SubAgentInput {
text: String,
interrupt: bool,
}
/// Runtime configuration for spawning sub-agents.
#[derive(Clone)]
pub struct SubAgentRuntime {
@@ -193,12 +222,18 @@ pub struct SubAgent {
pub steps_taken: u32,
pub started_at: Instant,
pub allowed_tools: Vec<String>,
input_tx: Option<mpsc::UnboundedSender<SubAgentInput>>,
task_handle: Option<JoinHandle<()>>,
}
impl SubAgent {
/// Create a new sub-agent.
fn new(agent_type: SubAgentType, prompt: String, allowed_tools: Vec<String>) -> Self {
fn new(
agent_type: SubAgentType,
prompt: String,
allowed_tools: Vec<String>,
input_tx: mpsc::UnboundedSender<SubAgentInput>,
) -> Self {
let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]);
Self {
@@ -210,6 +245,7 @@ impl SubAgent {
steps_taken: 0,
started_at: Instant::now(),
allowed_tools,
input_tx: Some(input_tx),
task_handle: None,
}
}
@@ -285,7 +321,8 @@ impl SubAgentManager {
}
let tools = build_allowed_tools(&agent_type, allowed_tools, runtime.allow_shell)?;
let mut agent = SubAgent::new(agent_type.clone(), prompt.clone(), tools.clone());
let (input_tx, input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(agent_type.clone(), prompt.clone(), tools.clone(), input_tx);
let agent_id = agent.id.clone();
let started_at = agent.started_at;
let max_steps = self.max_steps;
@@ -306,6 +343,7 @@ impl SubAgentManager {
allowed_tools: tools,
started_at,
max_steps,
input_rx,
};
let handle = tokio::spawn(run_subagent_task(task));
agent.task_handle = Some(handle);
@@ -344,6 +382,28 @@ impl SubAgentManager {
Ok(agent.snapshot())
}
/// Send input to a running sub-agent.
pub fn send_input(&mut self, agent_id: &str, text: String, interrupt: bool) -> Result<()> {
let agent = self
.agents
.get_mut(agent_id)
.ok_or_else(|| anyhow!("Agent {agent_id} not found"))?;
if agent.status != SubAgentStatus::Running {
return Err(anyhow!("Agent {agent_id} is not running"));
}
let tx = agent
.input_tx
.as_ref()
.ok_or_else(|| anyhow!("Agent {agent_id} cannot accept input"))?;
tx.send(SubAgentInput { text, interrupt })
.map_err(|_| anyhow!("Failed to send input to agent {agent_id}"))?;
Ok(())
}
/// List all agents and their status.
#[must_use]
pub fn list(&self) -> Vec<SubAgentResult> {
@@ -664,6 +724,250 @@ impl ToolSpec for AgentListTool {
}
}
/// Tool to send input to a running sub-agent.
pub struct AgentSendInputTool {
manager: SharedSubAgentManager,
name: &'static str,
}
impl AgentSendInputTool {
/// Create a new send-input tool.
#[must_use]
pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self {
Self { manager, name }
}
}
#[async_trait]
impl ToolSpec for AgentSendInputTool {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"Send input to a running sub-agent."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "ID returned by agent_spawn"
},
"id": {
"type": "string",
"description": "Alias for agent_id"
},
"message": {
"type": "string",
"description": "Message to deliver to the agent"
},
"input": {
"type": "string",
"description": "Alias for message"
},
"interrupt": {
"type": "boolean",
"description": "Prioritize this message over pending inputs"
}
},
"required": ["message"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![]
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let agent_id = input
.get("agent_id")
.or_else(|| input.get("id"))
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::missing_field("agent_id"))?;
let message = input
.get("message")
.or_else(|| input.get("input"))
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::missing_field("message"))?;
let interrupt = optional_bool(&input, "interrupt", false);
let mut manager = self.manager.lock().await;
manager
.send_input(agent_id, message.to_string(), interrupt)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
let snapshot = manager
.get_result(agent_id)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
ToolResult::json(&snapshot).map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
/// Tool to wait for sub-agents to complete.
pub struct AgentWaitTool {
manager: SharedSubAgentManager,
name: &'static str,
}
impl AgentWaitTool {
/// Create a new wait tool.
#[must_use]
pub fn new(manager: SharedSubAgentManager, name: &'static str) -> Self {
Self { manager, name }
}
}
#[async_trait]
impl ToolSpec for AgentWaitTool {
fn name(&self) -> &'static str {
self.name
}
fn description(&self) -> &'static str {
"Wait for one or more sub-agents to reach a terminal status."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"ids": {
"type": "array",
"items": { "type": "string" },
"description": "Agent IDs to wait on"
},
"agent_id": {
"type": "string",
"description": "Single agent ID"
},
"timeout_ms": {
"type": "integer",
"description": "Max wait time in milliseconds (default: 30000)"
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let timeout_ms = optional_u64(&input, "timeout_ms", 30_000).clamp(1000, 300_000);
let mut ids: Vec<String> = Vec::new();
if let Some(list) = input.get("ids").and_then(|v| v.as_array()) {
ids.extend(
list.iter()
.filter_map(|item| item.as_str().map(str::to_string)),
);
}
if ids.is_empty() {
if let Some(id) = input.get("agent_id").and_then(|v| v.as_str()) {
ids.push(id.to_string());
}
}
if ids.is_empty() {
return Err(ToolError::missing_field("ids"));
}
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
loop {
let snapshots = {
let manager = self.manager.lock().await;
ids.iter()
.map(|id| {
manager
.get_result(id)
.map_err(|e| ToolError::execution_failed(e.to_string()))
})
.collect::<Result<Vec<_>, _>>()?
};
let any_done = snapshots
.iter()
.any(|snapshot| snapshot.status != SubAgentStatus::Running);
if any_done || Instant::now() >= deadline {
return ToolResult::json(&snapshots)
.map_err(|e| ToolError::execution_failed(e.to_string()));
}
tokio::time::sleep(RESULT_POLL_INTERVAL).await;
}
}
}
/// Tool to delegate a task to a specialized agent (alias for agent_spawn).
pub struct DelegateToAgentTool {
manager: SharedSubAgentManager,
runtime: SubAgentRuntime,
}
impl DelegateToAgentTool {
/// Create a new delegation tool.
#[must_use]
pub fn new(manager: SharedSubAgentManager, runtime: SubAgentRuntime) -> Self {
Self { manager, runtime }
}
}
#[async_trait]
impl ToolSpec for DelegateToAgentTool {
fn name(&self) -> &'static str {
"delegate_to_agent"
}
fn description(&self) -> &'static str {
"Delegate a task to a specialized sub-agent. This is an alias for agent_spawn."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "Name or type of the agent: general, explore, plan, review"
},
"objective": {
"type": "string",
"description": "The goal or task description for the agent"
}
},
"required": ["objective"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![
ToolCapability::ExecutesCode,
ToolCapability::RequiresApproval,
]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let objective = required_str(&input, "objective")?.to_string();
let agent_type_str = input
.get("agent_name")
.and_then(|v| v.as_str())
.unwrap_or("general");
let spawn_input = json!({
"prompt": objective,
"type": agent_type_str
});
let spawn_tool = AgentSpawnTool::new(self.manager.clone(), self.runtime.clone());
spawn_tool.execute(spawn_input, context).await
}
}
// === Sub-agent Execution ===
struct SubAgentTask {
@@ -675,6 +979,7 @@ struct SubAgentTask {
allowed_tools: Vec<String>,
started_at: Instant,
max_steps: u32,
input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
}
#[allow(clippy::too_many_lines)]
@@ -687,6 +992,7 @@ async fn run_subagent_task(task: SubAgentTask) {
task.allowed_tools,
task.started_at,
task.max_steps,
task.input_rx,
)
.await;
@@ -708,7 +1014,7 @@ async fn run_subagent_task(task: SubAgentTask) {
}
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn run_subagent(
runtime: &SubAgentRuntime,
agent_id: String,
@@ -717,6 +1023,7 @@ async fn run_subagent(
allowed_tools: Vec<String>,
started_at: Instant,
max_steps: u32,
mut input_rx: mpsc::UnboundedReceiver<SubAgentInput>,
) -> Result<SubAgentResult> {
let system_prompt = agent_type.system_prompt();
let tool_registry = SubAgentToolRegistry::new(
@@ -738,10 +1045,30 @@ async fn run_subagent(
let mut steps = 0;
let mut final_result: Option<String> = None;
let mut pending_inputs: VecDeque<SubAgentInput> = VecDeque::new();
for _step in 0..max_steps {
steps += 1;
while let Ok(input) = input_rx.try_recv() {
if input.interrupt {
pending_inputs.clear();
}
pending_inputs.push_back(input);
}
while let Some(input) = pending_inputs.pop_front() {
if !input.text.trim().is_empty() {
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: input.text,
cache_control: None,
}],
});
}
}
let request = MessageRequest {
model: runtime.model.clone(),
messages: messages.clone(),
@@ -779,7 +1106,16 @@ async fn run_subagent(
});
if tool_uses.is_empty() {
break;
while let Ok(input) = input_rx.try_recv() {
if input.interrupt {
pending_inputs.clear();
}
pending_inputs.push_back(input);
}
if pending_inputs.is_empty() {
break;
}
continue;
}
let mut tool_results: Vec<ContentBlock> = Vec::new();
@@ -917,7 +1253,16 @@ fn build_allowed_tools(
}
if !allow_shell {
tools.retain(|tool| tool != "exec_shell");
tools.retain(|tool| {
!matches!(
tool.as_str(),
"exec_shell"
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_wait"
| "exec_interact"
)
});
}
Ok(tools)
@@ -1045,6 +1390,10 @@ mod tests {
fn test_allowed_tools_shell_filter() {
let tools = build_allowed_tools(&SubAgentType::General, None, false).unwrap();
assert!(!tools.contains(&"exec_shell".to_string()));
assert!(!tools.contains(&"exec_shell_wait".to_string()));
assert!(!tools.contains(&"exec_shell_interact".to_string()));
assert!(!tools.contains(&"exec_wait".to_string()));
assert!(!tools.contains(&"exec_interact".to_string()));
}
#[test]
@@ -1056,10 +1405,12 @@ mod tests {
#[test]
fn test_running_count_respects_limit() {
let mut manager = SubAgentManager::new(PathBuf::from("."), 1);
let (input_tx, _input_rx) = mpsc::unbounded_channel();
let mut agent = SubAgent::new(
SubAgentType::Explore,
"prompt".to_string(),
vec!["read_file".to_string()],
input_tx,
);
agent.status = SubAgentStatus::Running;
manager.agents.insert(agent.id.clone(), agent);
+1 -4
View File
@@ -634,10 +634,7 @@ fn validate_swarm_tasks(tasks: &[SwarmTaskSpec]) -> Result<(), ToolError> {
)));
}
if matches!(task.agent_type, Some(SubAgentType::Custom)) {
let tools = task
.allowed_tools
.as_deref()
.unwrap_or(&[]);
let tools = task.allowed_tools.as_deref().unwrap_or(&[]);
if tools.is_empty() {
return Err(ToolError::invalid_input(format!(
"task '{id}' requires allowed_tools for custom type"
+153
View File
@@ -0,0 +1,153 @@
//! Time tool for returning the current time at a given UTC offset.
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
};
use async_trait::async_trait;
use chrono::{DateTime, FixedOffset, Utc};
use serde::Serialize;
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize)]
struct TimeRequest {
utc_offset: String,
}
#[derive(Debug, Clone, Serialize)]
struct TimeResult {
utc_offset: String,
datetime: String,
date: String,
time: String,
}
#[derive(Debug, Clone, Serialize)]
struct TimeResponse {
results: Vec<TimeResult>,
}
pub struct TimeTool;
#[async_trait]
impl ToolSpec for TimeTool {
fn name(&self) -> &'static str {
"time"
}
fn description(&self) -> &'static str {
"Get the current time for a given UTC offset."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"utc_offset": {
"type": "string",
"description": "UTC offset like +03:00 or -07:00"
},
"time": {
"type": "array",
"items": {
"type": "object",
"properties": {
"utc_offset": { "type": "string" }
},
"required": ["utc_offset"]
}
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn supports_parallel(&self) -> bool {
true
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let requests = parse_time_requests(&input)?;
let mut results = Vec::with_capacity(requests.len());
for req in requests {
let offset = parse_offset(&req.utc_offset)?;
let now: DateTime<FixedOffset> = Utc::now().with_timezone(&offset);
results.push(TimeResult {
utc_offset: req.utc_offset,
datetime: now.to_rfc3339(),
date: now.format("%Y-%m-%d").to_string(),
time: now.format("%H:%M:%S").to_string(),
});
}
ToolResult::json(&TimeResponse { results })
.map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
fn parse_time_requests(input: &Value) -> Result<Vec<TimeRequest>, ToolError> {
if let Some(list) = input.get("time").and_then(|v| v.as_array()) {
let mut requests = Vec::new();
for item in list {
let offset = required_str(item, "utc_offset")?.to_string();
requests.push(TimeRequest { utc_offset: offset });
}
if requests.is_empty() {
return Err(ToolError::invalid_input("time list is empty"));
}
return Ok(requests);
}
let offset = required_str(input, "utc_offset")?.to_string();
Ok(vec![TimeRequest { utc_offset: offset }])
}
fn parse_offset(raw: &str) -> Result<FixedOffset, ToolError> {
let raw = raw.trim();
if raw.len() != 6 {
return Err(ToolError::invalid_input(
"utc_offset must be formatted like +03:00",
));
}
let sign = match &raw[0..1] {
"+" => 1,
"-" => -1,
_ => {
return Err(ToolError::invalid_input(
"utc_offset must start with + or -",
));
}
};
let hours: i32 = raw[1..3]
.parse()
.map_err(|_| ToolError::invalid_input("Invalid utc_offset hours"))?;
let minutes: i32 = raw[4..6]
.parse()
.map_err(|_| ToolError::invalid_input("Invalid utc_offset minutes"))?;
if &raw[3..4] != ":" {
return Err(ToolError::invalid_input(
"utc_offset must include ':' separator",
));
}
let total_seconds = sign * (hours * 3600 + minutes * 60);
FixedOffset::east_opt(total_seconds)
.ok_or_else(|| ToolError::invalid_input("Invalid utc_offset range"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_offset() {
let offset = parse_offset("+03:00").expect("offset");
assert_eq!(offset.local_minus_utc(), 3 * 3600);
}
}
+260
View File
@@ -0,0 +1,260 @@
//! Tool and types for requesting user input via the TUI.
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInputOption {
pub label: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInputQuestion {
pub header: String,
pub id: String,
pub question: String,
pub options: Vec<UserInputOption>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInputRequest {
pub questions: Vec<UserInputQuestion>,
}
impl UserInputRequest {
pub fn from_value(value: &Value) -> Result<Self, ToolError> {
let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| {
ToolError::invalid_input(format!("Invalid request_user_input payload: {e}"))
})?;
request.validate()?;
Ok(request)
}
pub fn validate(&self) -> Result<(), ToolError> {
if self.questions.is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions must be non-empty",
));
}
if self.questions.len() > 3 {
return Err(ToolError::invalid_input(
"request_user_input.questions must contain 1 to 3 items",
));
}
for q in &self.questions {
if q.header.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions.header cannot be empty",
));
}
if q.id.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions.id cannot be empty",
));
}
if q.question.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input.questions.question cannot be empty",
));
}
if q.options.len() < 2 || q.options.len() > 3 {
return Err(ToolError::invalid_input(
"request_user_input.questions.options must contain 2 or 3 items",
));
}
for opt in &q.options {
if opt.label.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input option label cannot be empty",
));
}
if opt.description.trim().is_empty() {
return Err(ToolError::invalid_input(
"request_user_input option description cannot be empty",
));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInputAnswer {
pub id: String,
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserInputResponse {
pub answers: Vec<UserInputAnswer>,
}
pub struct RequestUserInputTool;
#[async_trait]
impl ToolSpec for RequestUserInputTool {
fn name(&self) -> &'static str {
"request_user_input"
}
fn description(&self) -> &'static str {
"Ask the user 1-3 short questions and return their selections."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"questions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"header": { "type": "string" },
"id": { "type": "string" },
"question": { "type": "string" },
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": { "type": "string" },
"description": { "type": "string" }
},
"required": ["label", "description"]
},
"minItems": 2,
"maxItems": 3
}
},
"required": ["header", "id", "question", "options"]
},
"minItems": 1,
"maxItems": 3
}
},
"required": ["questions"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(
&self,
_input: Value,
_context: &ToolContext,
) -> Result<ToolResult, ToolError> {
Err(ToolError::execution_failed(
"request_user_input must be handled by the engine",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_request_shape() {
let request = UserInputRequest {
questions: vec![UserInputQuestion {
header: "Pick".to_string(),
id: "choice".to_string(),
question: "Which option?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "Option A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "Option B".to_string(),
},
],
}],
};
assert!(request.validate().is_ok());
}
#[test]
fn rejects_too_many_questions() {
let request = UserInputRequest {
questions: vec![
UserInputQuestion {
header: "Q1".to_string(),
id: "q1".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
UserInputQuestion {
header: "Q2".to_string(),
id: "q2".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
UserInputQuestion {
header: "Q3".to_string(),
id: "q3".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
UserInputQuestion {
header: "Q4".to_string(),
id: "q4".to_string(),
question: "?".to_string(),
options: vec![
UserInputOption {
label: "A".to_string(),
description: "A".to_string(),
},
UserInputOption {
label: "B".to_string(),
description: "B".to_string(),
},
],
},
],
};
assert!(request.validate().is_err());
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Weather tool backed by Open-Meteo (no API key required).
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
optional_str, optional_u64, required_str,
};
use async_trait::async_trait;
use chrono::{NaiveDate, Utc};
use serde::Serialize;
use serde_json::{Value, json};
use std::time::Duration;
const DEFAULT_DAYS: u64 = 7;
const MAX_DAYS: u64 = 14;
const TIMEOUT_MS: u64 = 15_000;
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
#[derive(Debug, Clone, Serialize)]
struct WeatherRequest {
location: String,
start: String,
duration: u64,
}
#[derive(Debug, Clone, Serialize)]
struct WeatherDay {
date: String,
temp_max_c: f64,
temp_min_c: f64,
temp_max_f: f64,
temp_min_f: f64,
precip_mm: f64,
}
#[derive(Debug, Clone, Serialize)]
struct WeatherResult {
location: String,
resolved_name: String,
latitude: f64,
longitude: f64,
timezone: String,
source: String,
days: Vec<WeatherDay>,
}
#[derive(Debug, Clone, Serialize)]
struct WeatherResponse {
results: Vec<WeatherResult>,
}
pub struct WeatherTool;
#[async_trait]
impl ToolSpec for WeatherTool {
fn name(&self) -> &'static str {
"weather"
}
fn description(&self) -> &'static str {
"Get a daily weather forecast for a location."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"location": { "type": "string" },
"start": { "type": "string", "description": "YYYY-MM-DD" },
"duration": { "type": "integer", "description": "Number of days" },
"weather": {
"type": "array",
"items": {
"type": "object",
"properties": {
"location": { "type": "string" },
"start": { "type": "string" },
"duration": { "type": "integer" }
},
"required": ["location"]
}
}
}
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Network]
}
fn supports_parallel(&self) -> bool {
true
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let requests = parse_weather_requests(&input)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(TIMEOUT_MS))
.user_agent(USER_AGENT)
.build()
.map_err(|e| {
ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
})?;
let mut results = Vec::with_capacity(requests.len());
for req in requests {
let geo = geocode_location(&client, &req.location).await?;
let forecast = fetch_forecast(&client, &geo, &req.start, req.duration).await?;
results.push(WeatherResult {
location: req.location,
resolved_name: geo.name,
latitude: geo.latitude,
longitude: geo.longitude,
timezone: forecast.timezone,
source: "open-meteo".to_string(),
days: forecast.days,
});
}
ToolResult::json(&WeatherResponse { results })
.map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
fn parse_weather_requests(input: &Value) -> Result<Vec<WeatherRequest>, ToolError> {
if let Some(list) = input.get("weather").and_then(|v| v.as_array()) {
let mut requests = Vec::new();
for item in list {
let location = required_str(item, "location")?.to_string();
let start = optional_str(item, "start")
.map(|s| s.to_string())
.unwrap_or_else(|| today_string());
let duration = optional_u64(item, "duration", DEFAULT_DAYS).clamp(1, MAX_DAYS);
requests.push(WeatherRequest {
location,
start,
duration,
});
}
if requests.is_empty() {
return Err(ToolError::invalid_input("weather list is empty"));
}
return Ok(requests);
}
let location = required_str(input, "location")?.to_string();
let start = optional_str(input, "start")
.map(|s| s.to_string())
.unwrap_or_else(|| today_string());
let duration = optional_u64(input, "duration", DEFAULT_DAYS).clamp(1, MAX_DAYS);
Ok(vec![WeatherRequest {
location,
start,
duration,
}])
}
fn today_string() -> String {
Utc::now().date_naive().format("%Y-%m-%d").to_string()
}
#[derive(Debug)]
struct GeoResult {
name: String,
latitude: f64,
longitude: f64,
}
async fn geocode_location(
client: &reqwest::Client,
location: &str,
) -> Result<GeoResult, ToolError> {
let encoded = url_encode(location);
let url = format!(
"https://geocoding-api.open-meteo.com/v1/search?name={encoded}&count=1&language=en&format=json"
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Geocoding request failed: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Geocoding failed: HTTP {}",
status.as_u16()
)));
}
let json: Value = serde_json::from_str(&body)
.map_err(|e| ToolError::execution_failed(format!("Invalid geocoding JSON: {e}")))?;
let results = json
.get("results")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::execution_failed("No geocoding results"))?;
let first = results
.first()
.ok_or_else(|| ToolError::execution_failed("No geocoding results"))?;
let name = first
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(location)
.to_string();
let latitude = first
.get("latitude")
.and_then(|v| v.as_f64())
.ok_or_else(|| ToolError::execution_failed("Missing latitude"))?;
let longitude = first
.get("longitude")
.and_then(|v| v.as_f64())
.ok_or_else(|| ToolError::execution_failed("Missing longitude"))?;
Ok(GeoResult {
name,
latitude,
longitude,
})
}
#[derive(Debug)]
struct ForecastResult {
timezone: String,
days: Vec<WeatherDay>,
}
async fn fetch_forecast(
client: &reqwest::Client,
geo: &GeoResult,
start: &str,
duration: u64,
) -> Result<ForecastResult, ToolError> {
let start_date = NaiveDate::parse_from_str(start, "%Y-%m-%d")
.map_err(|_| ToolError::invalid_input("start must be YYYY-MM-DD"))?;
let end_date = start_date
.checked_add_signed(chrono::Duration::days((duration as i64).saturating_sub(1)))
.ok_or_else(|| ToolError::invalid_input("Invalid duration"))?;
let url = format!(
"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto&start_date={start}&end_date={end}",
lat = geo.latitude,
lon = geo.longitude,
start = start_date.format("%Y-%m-%d"),
end = end_date.format("%Y-%m-%d"),
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Forecast request failed: {e}")))?;
let status = resp.status();
let body = resp
.text()
.await
.map_err(|e| ToolError::execution_failed(format!("Failed to read response: {e}")))?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Forecast failed: HTTP {}",
status.as_u16()
)));
}
let json: Value = serde_json::from_str(&body)
.map_err(|e| ToolError::execution_failed(format!("Invalid forecast JSON: {e}")))?;
let timezone = json
.get("timezone")
.and_then(|v| v.as_str())
.unwrap_or("UTC")
.to_string();
let daily = json
.get("daily")
.and_then(|v| v.as_object())
.ok_or_else(|| ToolError::execution_failed("Missing daily forecast"))?;
let dates = daily
.get("time")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::execution_failed("Missing daily time"))?;
let maxes = daily
.get("temperature_2m_max")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::execution_failed("Missing temperature_2m_max"))?;
let mins = daily
.get("temperature_2m_min")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::execution_failed("Missing temperature_2m_min"))?;
let precips = daily
.get("precipitation_sum")
.and_then(|v| v.as_array())
.ok_or_else(|| ToolError::execution_failed("Missing precipitation_sum"))?;
let mut days = Vec::new();
for idx in 0..dates.len() {
let date = dates
.get(idx)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let max_c = maxes.get(idx).and_then(|v| v.as_f64()).unwrap_or(0.0);
let min_c = mins.get(idx).and_then(|v| v.as_f64()).unwrap_or(0.0);
let precip = precips.get(idx).and_then(|v| v.as_f64()).unwrap_or(0.0);
days.push(WeatherDay {
date,
temp_max_c: max_c,
temp_min_c: min_c,
temp_max_f: c_to_f(max_c),
temp_min_f: c_to_f(min_c),
precip_mm: precip,
});
}
Ok(ForecastResult { timezone, days })
}
fn c_to_f(c: f64) -> f64 {
c * 9.0 / 5.0 + 32.0
}
fn url_encode(input: &str) -> String {
let mut encoded = String::new();
for ch in input.bytes() {
match ch {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(ch as char)
}
b' ' => encoded.push('+'),
_ => encoded.push_str(&format!("%{ch:02X}")),
}
}
encoded
}
+1361
View File
File diff suppressed because it is too large. Load diff
+297 -64
View File
@@ -10,11 +10,11 @@ use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{Config, has_api_key, save_api_key};
use crate::duo::{SharedDuoSession, new_shared_duo_session};
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::rlm::{RlmSession, SharedRlmSession};
use crate::settings::Settings;
use crate::tools::plan::{SharedPlanState, new_shared_plan_state};
use crate::tools::subagent::SubAgentResult;
@@ -27,7 +27,24 @@ use crate::tui::scrolling::{MouseScrollState, TranscriptScroll};
use crate::tui::selection::TranscriptSelection;
use crate::tui::transcript::TranscriptViewCache;
use crate::tui::views::ViewStack;
use std::sync::{Arc, Mutex};
/// Format a nice welcome banner.
fn format_welcome_banner(model: &str, workspace: &PathBuf, yolo: bool) -> String {
let mode_line = if yolo {
"\nYOLO mode — shell + trust + auto-approve enabled\n"
} else {
""
};
format!(
"Tips: Tab to switch modes, F1 or /help for commands, Esc to cancel\n\
{mode_line}\
Directory: {}\n\
Model: {}",
workspace.display(),
model
)
}
// === Types ===
@@ -48,8 +65,6 @@ pub enum AppMode {
Agent,
Yolo,
Plan,
Rlm,
Duo,
}
fn char_count(text: &str) -> usize {
@@ -89,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 {
@@ -97,8 +114,6 @@ impl AppMode {
AppMode::Agent => "AGENT",
AppMode::Yolo => "YOLO",
AppMode::Plan => "PLAN",
AppMode::Rlm => "RLM",
AppMode::Duo => "DUO",
}
}
@@ -110,8 +125,6 @@ impl AppMode {
AppMode::Agent => "Agent mode - autonomous task execution with tools",
AppMode::Yolo => "YOLO mode - full tool access without approvals",
AppMode::Plan => "Plan mode - design before implementing",
AppMode::Rlm => "RLM mode - recursive language model sandbox",
AppMode::Duo => "Duo mode - dialectical autocoding with player-coach loop",
}
}
}
@@ -163,12 +176,13 @@ pub struct App {
pub transcript_cache: TranscriptViewCache,
pub transcript_selection: TranscriptSelection,
pub last_transcript_area: Option<Rect>,
pub last_scrollbar_area: Option<Rect>,
pub last_transcript_top: usize,
pub last_transcript_visible: usize,
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,
@@ -179,9 +193,9 @@ pub struct App {
pub input_history: Vec<String>,
pub history_index: Option<usize>,
pub auto_compact: bool,
pub auto_rlm: 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,
@@ -221,12 +235,6 @@ pub struct App {
pub plan_prompt_pending: bool,
/// Whether update_plan was called during the current turn
pub plan_tool_used_in_turn: bool,
/// RLM sandbox session state
pub rlm_session: SharedRlmSession,
/// Duo mode session state (player-coach autocoding loop)
pub duo_session: SharedDuoSession,
/// Whether RLM REPL input mode is active.
pub rlm_repl_active: bool,
/// Todo list for `TodoWriteTool`
#[allow(dead_code)] // For future engine integration
pub todos: SharedTodoList,
@@ -238,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)
@@ -262,19 +272,45 @@ 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>,
/// Whether the UI needs to be redrawn.
pub needs_redraw: bool,
}
/// 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 {
@@ -293,14 +329,6 @@ impl QueuedMessage {
self.display.clone()
}
}
pub fn content_with_query(&self, query: &str) -> String {
if let Some(skill_instruction) = self.skill_instruction.as_ref() {
format!("{skill_instruction}\n\n---\n\nUser request: {query}")
} else {
query.to_string()
}
}
}
// === Errors ===
@@ -343,20 +371,19 @@ impl App {
let needs_onboarding = !skip_onboarding && (!was_onboarded || needs_api_key);
let settings = Settings::load().unwrap_or_else(|_| Settings::default());
let auto_compact = settings.auto_compact;
let auto_rlm = settings.auto_rlm;
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() {
"plan" => AppMode::Plan,
"agent" | "normal" => AppMode::Agent,
"yolo" => AppMode::Yolo,
"rlm" => AppMode::Rlm,
"duo" => AppMode::Duo,
_ => AppMode::Agent,
};
let initial_mode = if yolo {
@@ -370,18 +397,8 @@ impl App {
let history = if needs_onboarding {
Vec::new() // No welcome message during onboarding
} else {
let mode_msg = if yolo {
" | YOLO MODE (shell + trust + auto-approve)"
} else {
""
};
vec![HistoryCell::System {
content: format!(
"Welcome to DeepSeek! Model: {} | Workspace: {}{}",
model,
workspace.display(),
mode_msg
),
content: format_welcome_banner(&model, &workspace, yolo),
}]
};
@@ -394,8 +411,11 @@ impl App {
let history_len = history.len() as u64;
let agents_skills_dir = workspace.join(".agents").join("skills");
let local_skills_dir = workspace.join("skills");
let skills_dir = if local_skills_dir.exists() {
let skills_dir = if agents_skills_dir.exists() {
agents_skills_dir
} else if local_skills_dir.exists() {
local_skills_dir
} else {
global_skills_dir
@@ -415,12 +435,12 @@ impl App {
transcript_cache: TranscriptViewCache::new(),
transcript_selection: TranscriptSelection::default(),
last_transcript_area: None,
last_scrollbar_area: None,
last_transcript_top: 0,
last_transcript_visible: 0,
last_transcript_total: 0,
last_transcript_padding_top: 0,
is_loading: false,
offline_mode: false,
status_message: None,
model,
workspace,
@@ -430,10 +450,10 @@ impl App {
input_history: Vec::new(),
history_index: None,
auto_compact,
auto_rlm,
show_thinking,
show_tool_details,
compact_threshold: 50000,
sidebar_width_percent,
compact_threshold,
max_input_history,
total_tokens: 0,
total_conversation_tokens: 0,
@@ -469,14 +489,12 @@ impl App {
plan_state,
plan_prompt_pending: false,
plan_tool_used_in_turn: false,
rlm_session: Arc::new(Mutex::new(RlmSession::default())),
duo_session: new_shared_duo_session(),
rlm_repl_active: false,
todos: new_shared_todo_list(),
tool_log: Vec::new(),
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(),
@@ -489,8 +507,12 @@ 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(),
needs_redraw: true,
}
}
@@ -517,11 +539,7 @@ impl App {
self.status_message = Some(format!("Failed to mark onboarding: {err}"));
}
self.add_message(HistoryCell::System {
content: format!(
"Welcome to DeepSeek CLI! Model: {} | Workspace: {}",
self.model,
self.workspace.display()
),
content: format_welcome_banner(&self.model, &self.workspace, self.yolo),
});
}
@@ -537,7 +555,6 @@ impl App {
} else {
ApprovalMode::Suggest
};
self.rlm_repl_active = false;
if mode != AppMode::Plan {
self.plan_prompt_pending = false;
self.plan_tool_used_in_turn = false;
@@ -550,16 +567,15 @@ impl App {
.with_workspace(self.workspace.clone())
.with_model(&self.model);
let _ = self.hooks.execute(HookEvent::ModeChange, &context);
self.needs_redraw = true;
}
/// Cycle through modes: Plan → Agent → YOLO → RLM → Duo → Plan
/// Cycle through modes: Plan -> Agent -> YOLO -> Plan
pub fn cycle_mode(&mut self) {
let next = match self.mode {
AppMode::Plan => AppMode::Agent,
AppMode::Agent => AppMode::Yolo,
AppMode::Yolo => AppMode::Rlm,
AppMode::Rlm => AppMode::Duo,
AppMode::Duo | AppMode::Normal => AppMode::Plan,
AppMode::Yolo | AppMode::Normal => AppMode::Plan,
};
self.set_mode(next);
}
@@ -591,6 +607,7 @@ impl App {
pub fn mark_history_updated(&mut self) {
self.history_version = self.history_version.wrapping_add(1);
self.needs_redraw = true;
}
pub fn transcript_render_options(&self) -> TranscriptRenderOptions {
@@ -620,7 +637,6 @@ impl App {
// Clear stale layout info
self.last_transcript_area = None;
self.last_scrollbar_area = None;
self.last_transcript_top = 0;
self.last_transcript_visible = 0;
self.last_transcript_total = 0;
@@ -642,6 +658,7 @@ impl App {
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert_str(byte_index, text);
self.cursor_position = cursor + char_count(text);
self.needs_redraw = true;
}
pub fn insert_paste_text(&mut self, text: &str) {
@@ -724,16 +741,19 @@ impl App {
pub fn scroll_up(&mut self, amount: usize) {
let delta = i32::try_from(amount).unwrap_or(i32::MAX);
self.pending_scroll_delta = self.pending_scroll_delta.saturating_sub(delta);
self.needs_redraw = true;
}
pub fn scroll_down(&mut self, amount: usize) {
let delta = i32::try_from(amount).unwrap_or(i32::MAX);
self.pending_scroll_delta = self.pending_scroll_delta.saturating_add(delta);
self.needs_redraw = true;
}
pub fn scroll_to_bottom(&mut self) {
self.transcript_scroll = TranscriptScroll::ToBottom;
self.pending_scroll_delta = 0;
self.needs_redraw = true;
}
pub fn insert_char(&mut self, c: char) {
@@ -741,6 +761,7 @@ impl App {
let byte_index = byte_index_at_char(&self.input, cursor);
self.input.insert(byte_index, c);
self.cursor_position = cursor + 1;
self.needs_redraw = true;
}
pub fn delete_char(&mut self) {
@@ -751,6 +772,7 @@ impl App {
let removed = remove_char_at(&mut self.input, target);
if removed {
self.cursor_position = target;
self.needs_redraw = true;
}
}
@@ -763,30 +785,36 @@ impl App {
if !removed {
self.cursor_position = char_count(&self.input);
}
self.needs_redraw = true;
}
pub fn move_cursor_left(&mut self) {
self.cursor_position = self.cursor_position.saturating_sub(1);
self.needs_redraw = true;
}
pub fn move_cursor_right(&mut self) {
if self.cursor_position < char_count(&self.input) {
self.cursor_position += 1;
self.needs_redraw = true;
}
}
pub fn move_cursor_start(&mut self) {
self.cursor_position = 0;
self.needs_redraw = true;
}
pub fn move_cursor_end(&mut self) {
self.cursor_position = char_count(&self.input);
self.needs_redraw = true;
}
pub fn clear_input(&mut self) {
self.input.clear();
self.cursor_position = 0;
self.paste_burst.clear_after_explicit_paste();
self.needs_redraw = true;
}
pub fn submit_input(&mut self) -> Option<String> {
@@ -794,7 +822,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 {
@@ -877,16 +912,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
@@ -901,13 +952,26 @@ pub enum AppAction {
},
SendMessage(String),
ListSubAgents,
FetchModels,
UpdateCompaction(CompactionConfig),
CompactContext,
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 {
@@ -933,4 +997,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::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)
.bg(palette::SELECTION_BG)
} 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::BORDER_COLOR));
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.render(popup_area, buf);
}
}
+102 -46
View File
@@ -19,6 +19,8 @@ use crate::tui::markdown_render;
const TOOL_COMMAND_LINE_LIMIT: usize = 5;
const TOOL_OUTPUT_LINE_LIMIT: usize = 12;
const TOOL_TEXT_LIMIT: usize = 240;
const TOOL_RUNNING_SYMBOLS: [&str; 3] = ["◌", "◍", "◉"];
const TOOL_STATUS_SYMBOL_MS: u64 = 900;
// === History Cells ===
@@ -28,7 +30,7 @@ pub enum HistoryCell {
User { content: String },
Assistant { content: String, streaming: bool },
System { content: String },
ThinkingSummary { summary: String },
Thinking { content: String, streaming: bool },
Tool(ToolCell),
}
@@ -52,24 +54,14 @@ impl HistoryCell {
pub fn lines(&self, width: u16) -> Vec<Line<'static>> {
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);
if *streaming {
// Add blinking cursor to last line
if let Some(last) = lines.last_mut() {
last.spans.push(Span::styled(
"▋",
Style::default().fg(palette::DEEPSEEK_SKY),
));
}
}
lines
HistoryCell::Assistant { content, .. } => {
render_message("Answer", content, assistant_style(), width)
}
HistoryCell::System { content } => {
render_message("System", content, system_style(), width)
}
HistoryCell::ThinkingSummary { summary } => {
render_message("Thinking", summary, thinking_style(), width)
HistoryCell::Thinking { content, streaming } => {
render_thinking(content, width, *streaming)
}
HistoryCell::Tool(cell) => cell.lines(width),
}
@@ -81,7 +73,7 @@ impl HistoryCell {
options: TranscriptRenderOptions,
) -> Vec<Line<'static>> {
match self {
HistoryCell::ThinkingSummary { .. } if !options.show_thinking => Vec::new(),
HistoryCell::Thinking { .. } if !options.show_thinking => Vec::new(),
HistoryCell::Tool(cell) if !options.show_tool_details => {
let mut lines = cell.lines(width);
if lines.len() > 2 {
@@ -114,37 +106,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()),
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,
});
}
}
_ => {}
}
}
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,
}),
"system" => cells.push(HistoryCell::System { content }),
_ => {}
}
}
if !thinking_blocks.is_empty() {
let reasoning = thinking_blocks.join("\n");
if let Some(summary) = extract_reasoning_summary(&reasoning) {
cells.push(HistoryCell::ThinkingSummary { summary });
}
}
cells
}
@@ -958,6 +979,7 @@ pub fn output_is_image(output: &str) -> bool {
.any(|ext| lower.contains(ext))
}
#[cfg(test)]
#[must_use]
pub fn extract_reasoning_summary(text: &str) -> Option<String> {
let mut lines = text.lines().peekable();
@@ -999,6 +1021,38 @@ pub fn extract_reasoning_summary(text: &str) -> Option<String> {
}
}
fn render_thinking(content: &str, width: u16, streaming: bool) -> Vec<Line<'static>> {
let style = thinking_style();
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)];
spans.extend(line.spans);
lines.push(Line::from(spans));
}
if lines.is_empty() {
lines.push(Line::from(vec![Span::styled(prefix, style)]));
}
lines
}
fn render_message(prefix: &str, content: &str, style: Style, width: u16) -> Vec<Line<'static>> {
let prefix_width = UnicodeWidthStr::width(prefix);
let prefix_width_u16 = u16::try_from(prefix_width.saturating_add(2)).unwrap_or(u16::MAX);
@@ -1206,11 +1260,13 @@ 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) {
"*".to_string()
let cycle = u128::from(TOOL_STATUS_SYMBOL_MS);
let idx = if cycle == 0 {
0
} else {
".".to_string()
}
(elapsed_ms / cycle) % (TOOL_RUNNING_SYMBOLS.len() as u128)
};
TOOL_RUNNING_SYMBOLS[usize::try_from(idx).unwrap_or_default()].to_string()
}
ToolStatus::Success => "o".to_string(),
ToolStatus::Failed => "x".to_string(),
@@ -1245,8 +1301,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)]
+3
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,8 @@ 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;
+1 -3
View File
@@ -55,9 +55,7 @@ pub fn tips_lines() -> Vec<ratatui::text::Line<'static>> {
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::raw(
" - Tab cycles modes (Plan → Agent → YOLO → RLM → Duo)",
)),
Line::from(Span::raw(" - Tab cycles modes (Plan → Agent → YOLO)")),
Line::from(Span::raw(" - Ctrl+R opens the session picker")),
Line::from(Span::raw(" - l opens the pager for the last message")),
Line::from(Span::raw(" - Ctrl+C cancels or exits")),
+30
View File
@@ -130,6 +130,20 @@ impl PasteBurst {
}
}
/// Return the remaining delay before a pending char/paste buffer must flush.
///
/// This lets the UI event loop avoid sleeping past the flush deadline.
#[must_use]
pub fn next_flush_delay(&self, now: Instant) -> Option<Duration> {
let last = self.last_plain_char_time?;
let timeout = if self.is_active_internal() {
PASTE_BURST_ACTIVE_IDLE_TIMEOUT
} else {
PASTE_BURST_CHAR_INTERVAL
};
Some(timeout.saturating_sub(now.duration_since(last)))
}
pub fn append_newline_if_active(&mut self, now: Instant) -> bool {
if self.is_active() {
self.buffer.push('\n');
@@ -295,4 +309,20 @@ mod tests {
assert_eq!(burst.flush_before_modified_input(), Some("a".to_string()));
assert!(!burst.is_active());
}
#[test]
fn next_flush_delay_counts_down_to_zero() {
let mut burst = PasteBurst::default();
let t0 = Instant::now();
let _ = burst.on_plain_char('a', t0);
let almost_due = t0 + Duration::from_millis(7);
let remaining = burst
.next_flush_delay(almost_due)
.expect("delay should exist");
assert!(remaining <= Duration::from_millis(1));
let due = t0 + Duration::from_millis(20);
assert_eq!(burst.next_flush_delay(due), Some(Duration::ZERO));
}
}
+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,
+1720 -1259
View File
File diff suppressed because it is too large. Load diff
+267
View File
@@ -0,0 +1,267 @@
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 format_context_budget_caps_overflow_display() {
assert_eq!(format_context_budget(5_000, 128_000), "5.0k/128.0k");
assert_eq!(format_context_budget(250_000, 128_000), ">128.0k/128.0k");
}
#[test]
fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {
let mut app = create_test_app();
app.last_prompt_tokens = Some(320_000);
app.api_messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "hello".to_string(),
cache_control: None,
}],
}];
let (used, max, percent) =
context_usage_snapshot(&app).expect("context usage should be available");
assert_eq!(max, 128_000);
assert!(used > 0);
assert!(used <= i64::from(max));
assert!(percent < 100.0);
}
#[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
}
+266
View File
@@ -0,0 +1,266 @@
//! Modal for request_user_input tool prompts.
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::palette;
use crate::tools::user_input::{
UserInputAnswer, UserInputQuestion, UserInputRequest, UserInputResponse,
};
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputMode {
Selecting,
OtherInput,
}
#[derive(Debug, Clone)]
pub struct UserInputView {
tool_id: String,
request: UserInputRequest,
question_index: usize,
selected: usize,
mode: InputMode,
other_input: String,
answers: Vec<UserInputAnswer>,
}
impl UserInputView {
pub fn new(tool_id: impl Into<String>, request: UserInputRequest) -> Self {
Self {
tool_id: tool_id.into(),
request,
question_index: 0,
selected: 0,
mode: InputMode::Selecting,
other_input: String::new(),
answers: Vec::new(),
}
}
fn current_question(&self) -> &UserInputQuestion {
&self.request.questions[self.question_index]
}
fn option_count(&self) -> usize {
self.current_question().options.len() + 1
}
fn is_other_selected(&self) -> bool {
self.selected + 1 == self.option_count()
}
fn advance_question(&mut self, answer: UserInputAnswer) -> ViewAction {
self.answers.push(answer);
if self.question_index + 1 >= self.request.questions.len() {
let response = UserInputResponse {
answers: self.answers.clone(),
};
return ViewAction::EmitAndClose(ViewEvent::UserInputSubmitted {
tool_id: self.tool_id.clone(),
response,
});
}
self.question_index += 1;
self.selected = 0;
self.mode = InputMode::Selecting;
self.other_input.clear();
ViewAction::None
}
fn handle_selecting_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
ViewAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1));
ViewAction::None
}
KeyCode::Enter => {
if self.is_other_selected() {
self.mode = InputMode::OtherInput;
self.other_input.clear();
ViewAction::None
} else {
let question = self.current_question();
let option = &question.options[self.selected];
let answer = UserInputAnswer {
id: question.id.clone(),
label: option.label.clone(),
value: option.label.clone(),
};
self.advance_question(answer)
}
}
KeyCode::Esc => ViewAction::EmitAndClose(ViewEvent::UserInputCancelled {
tool_id: self.tool_id.clone(),
}),
_ => ViewAction::None,
}
}
fn handle_other_input_key(&mut self, key: KeyEvent) -> ViewAction {
match key.code {
KeyCode::Esc => {
self.mode = InputMode::Selecting;
self.other_input.clear();
ViewAction::None
}
KeyCode::Enter => {
let question = self.current_question();
let answer = UserInputAnswer {
id: question.id.clone(),
label: "Other".to_string(),
value: self.other_input.trim().to_string(),
};
self.advance_question(answer)
}
KeyCode::Backspace => {
self.other_input.pop();
ViewAction::None
}
KeyCode::Char(ch) => {
if !ch.is_control() {
self.other_input.push(ch);
}
ViewAction::None
}
_ => ViewAction::None,
}
}
}
impl ModalView for UserInputView {
fn kind(&self) -> ModalKind {
ModalKind::UserInput
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
match self.mode {
InputMode::Selecting => self.handle_selecting_key(key),
InputMode::OtherInput => self.handle_other_input_key(key),
}
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let question = self.current_question();
let total = self.request.questions.len();
let header = format!(
" {} ({}/{}) ",
question.header,
self.question_index + 1,
total
);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![Span::styled(
question.question.clone(),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)]));
lines.push(Line::from(""));
for (idx, option) in question.options.iter().enumerate() {
let selected = self.selected == idx;
let prefix = if selected { ">" } else { " " };
let style = if selected {
Style::default().fg(palette::DEEPSEEK_SKY).bold()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
lines.push(Line::from(vec![
Span::raw(format!("{prefix} ")),
Span::styled(option.label.clone(), style),
Span::raw(" - "),
Span::styled(
option.description.clone(),
Style::default().fg(palette::TEXT_MUTED),
),
]));
}
let other_index = question.options.len();
let other_selected = self.selected == other_index;
let other_style = if other_selected {
Style::default().fg(palette::DEEPSEEK_SKY).bold()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
lines.push(Line::from(vec![
Span::raw(if other_selected { "> " } else { " " }),
Span::styled("Other", other_style),
Span::raw(" - "),
Span::styled(
"Provide a custom response",
Style::default().fg(palette::TEXT_MUTED),
),
]));
if self.mode == InputMode::OtherInput {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Other:", Style::default().fg(palette::TEXT_PRIMARY)),
Span::raw(" "),
Span::styled(
if self.other_input.is_empty() {
"(type your response)".to_string()
} else {
self.other_input.clone()
},
Style::default().fg(palette::DEEPSEEK_BLUE),
),
]));
}
lines.push(Line::from(""));
let hint = if self.mode == InputMode::OtherInput {
"Enter=submit, Esc=back"
} else {
"Up/Down=select, Enter=confirm, Esc=cancel"
};
lines.push(Line::from(Span::styled(
hint,
Style::default().fg(palette::TEXT_MUTED),
)));
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Line::from(vec![Span::styled(
header,
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::DEEPSEEK_SKY)),
);
let popup_area = centered_rect(80, 60, area);
paragraph.render(popup_area, buf);
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1]);
horizontal[1]
}
+44 -15
View File
@@ -3,6 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect};
use std::fmt;
use crate::palette;
use crate::tools::UserInputResponse;
use crate::tools::subagent::{SubAgentResult, SubAgentStatus, SubAgentType};
use crate::tui::approval::{ElevationOption, ReviewDecision};
@@ -10,6 +11,8 @@ use crate::tui::approval::{ElevationOption, ReviewDecision};
pub enum ModalKind {
Approval,
Elevation,
UserInput,
CommandPalette,
Help,
SubAgents,
Pager,
@@ -18,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,
@@ -29,6 +39,13 @@ pub enum ViewEvent {
tool_name: String,
option: ElevationOption,
},
UserInputSubmitted {
tool_id: String,
response: UserInputResponse,
},
UserInputCancelled {
tool_id: String,
},
SubAgentsRefresh,
SessionSelected {
session_id: String,
@@ -235,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(
@@ -275,7 +294,7 @@ impl ModalView for HelpView {
"Modes:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]),
Line::from(" Tab cycles modes: Plan → Agent → YOLO → RLM → Duo"),
Line::from(" Tab cycles modes: Plan → Agent → YOLO"),
Line::from(""),
Line::from(vec![Span::styled(
"Commands:",
@@ -290,25 +309,35 @@ impl ModalView for HelpView {
)));
}
help_lines.push(Line::from(""));
help_lines.push(Line::from(vec![Span::styled(
"RLM / Aleph:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]));
help_lines.push(Line::from(" /rlm or /aleph - Enter external memory mode"));
help_lines.push(Line::from(
" /load @path - Load a file into RLM context",
));
help_lines.push(Line::from(" /repl - Toggle expression mode"));
help_lines.push(Line::from(" /status - Show contexts and usage"));
help_lines.push(Line::from(""));
help_lines.push(Line::from(vec![Span::styled(
"Tools:",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]));
help_lines.push(Line::from(
" web_search - Search the web (DuckDuckGo; MCP optional)",
" web.run - Browse the web (search/open/click/find/screenshot)",
));
help_lines.push(Line::from(
" web_search - Quick web search (DuckDuckGo; MCP optional)",
));
help_lines.push(Line::from(
" request_user_input - Ask the user to choose from short prompts",
));
help_lines.push(Line::from(
" multi_tool_use.parallel - Execute multiple tools in parallel",
));
help_lines.push(Line::from(" weather - Daily forecast for a location"));
help_lines.push(Line::from(" finance - Stock/crypto price lookup"));
help_lines.push(Line::from(" sports - League schedules/standings"));
help_lines.push(Line::from(" time - Current time for UTC offsets"));
help_lines.push(Line::from(
" calculator - Evaluate arithmetic expressions",
));
help_lines.push(Line::from(
" list_mcp_resources - List MCP resources (optionally by server)",
));
help_lines.push(Line::from(
" list_mcp_resource_templates - List MCP resource templates",
));
help_lines.push(Line::from(" mcp_* - Tools exposed by MCP servers"));
help_lines.push(Line::from(""));
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more