mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6bc351da | ||
|
|
ebdda09c29 | ||
|
|
f42f94207c | ||
|
|
d79178a926 | ||
|
|
bcf6ba9a8e | ||
|
|
7a85f182e2 | ||
|
|
f7fe5e09a5 | ||
|
|
24b8945010 | ||
|
|
82e4a564aa | ||
|
|
07ae792068 | ||
|
|
ba40ae4aac | ||
|
|
7f2c382343 | ||
|
|
017ac97d0d | ||
|
|
0a394e1587 | ||
|
|
fafb76063d | ||
|
|
9e7cfc951a | ||
|
|
1be18e691b | ||
|
|
ccc9554ef4 | ||
|
|
97f245d5ac | ||
|
|
19f8d83d3b | ||
|
|
67b232b063 | ||
|
|
41c54f08aa | ||
|
|
56ea63e2c6 | ||
|
|
94834b2eb4 |
No files matched your search
@@ -11,6 +11,18 @@ env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
jobs:
|
||||
versions:
|
||||
name: Version drift
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Check version drift
|
||||
run: ./scripts/release/check-versions.sh
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+48
-1
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.2] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`/model` opens a Pro/Flash + thinking-effort picker (#39).** Typing `/model` with no argument now pops a two-pane modal: model on the left (`deepseek-v4-pro` flagship, `deepseek-v4-flash` fast/cheap, plus a "current (custom)" row when the active id isn't one of the listed defaults), and thinking effort on the right. Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter applies both selections, Esc cancels. The effort pane intentionally exposes only **Off / High / Max** because [DeepSeek's Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model) state `low`/`medium` are mapped to `high` server-side and `xhigh` is mapped to `max` — the legacy variants stay valid in `~/.deepseek/settings.toml` for back-compat, the picker just doesn't surface them. Apply path persists `default_model` and `reasoning_effort` to settings, forwards `Op::SetModel` + `Op::SetCompaction` to the running engine so the next turn picks up the change without a restart, and resets the per-turn token gauges (cache, replay) so the footer numbers reflect the new model. `/model <id>` keeps working unchanged for power users.
|
||||
|
||||
## [0.5.1] - 2026-04-25
|
||||
|
||||
### Added
|
||||
- **`fetch_url` tool** for direct HTTP GET on a known URL — complements `web_search` for cases where the link is already known. Supports `format` (`markdown` / `text` / `raw`), `max_bytes` (default 1 MB, hard cap 10 MB), `timeout_ms` (default 15 s, max 60 s), redirect following, and structured `{url, status, content_type, content, truncated}` responses. 4xx/5xx bodies are returned (with `success: false`) so the caller can read JSON error envelopes. (#33)
|
||||
- **PDF support in `read_file`.** PDFs are auto-detected by extension or `%PDF-` magic bytes and extracted via `pdftotext -layout` (poppler) when available. New optional `pages` arg (`"5"` or `"1-10"`) reads page slices. Without `pdftotext`, returns a structured `{type: "binary_unavailable", kind: "pdf", reason, hint}` with install commands for macOS/Debian. (#34)
|
||||
- **Reasoning-content replay telemetry, end-to-end (#30).** The chat-completions sanitizer now estimates replayed `reasoning_content` tokens (~4 chars/token), threads the value through the streaming `Usage` payload, stores it on the App, and renders an `rsn N.Nk` chip in the footer next to the cache hit-rate. The chip turns warning-coloured when replay tokens exceed 50% of the input budget, so users on long thinking-mode loops can see at a glance how much of their context window is going to V4's "Interleaved Thinking" replay (paper §5.1.1). Logged at `RUST_LOG=deepseek_tui=info` for tail-friendly diagnosis.
|
||||
- **`@file` Tab-completion (#28).** Typing `@<partial>` and pressing Tab now resolves the mention against the workspace using the existing `ignore::WalkBuilder`. A unique match is spliced into the input; multiple matches with a longer common prefix extend the partial; remaining ambiguity is surfaced via the status line. The mention-expansion path that ships file contents to the model is unchanged — this is purely a discovery aid for typing the path. Inline-contents and a fuzzy popup picker are queued for v0.5.2.
|
||||
- **Per-workspace external trust list (#29).** `~/.deepseek/workspace-trust.json` now records, for each workspace, the absolute paths the user has opted into reading/writing from outside that workspace. The new `/trust` slash command supports `add <path>`, `remove <path>`, `list`, `on`, `off`, and a status read with no args; the engine consults the list when constructing every `ToolContext` so changes apply on the next tool call without restart. `/diagnostics` surfaces the list. The interactive "Allow once / Always allow / Deny" approval prompt is deferred — for now grant access ahead of the turn with `/trust add <path>`.
|
||||
|
||||
### Fixed
|
||||
- **TUI sidebar gutter bleed regression test (#36).** Snapshot tests now lock in that long single-line tool results — including a `todo_write` echo of a multi-kilobyte JSON payload — never write any cells outside `chat_area` at the widths reported in the bug (80, 120, 165, 200 cols). A second test verifies the scrollbar coexists with content along the right edge instead of overdrawing the penultimate column.
|
||||
- **Version drift caught in CI.** New `versions` job in `.github/workflows/ci.yml` runs `scripts/release/check-versions.sh` on every push/PR, verifying every per-crate `Cargo.toml` inherits the workspace version, the npm wrapper matches the workspace version, and `Cargo.lock` is in sync. The release runbook now lists `check-versions.sh` as the first preflight step. (#31)
|
||||
- **Per-mode soft context budget for V4 compaction trigger** (#27).
|
||||
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool surface (#25).
|
||||
- **Unused import + `cargo fmt` drift** that landed with `feat(#27)` and broke Build / Test / npm wrapper smoke under `-Dwarnings`.
|
||||
|
||||
## [0.5.0] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- Multi-turn tool calls on thinking-mode models no longer return HTTP 400. Every assistant message in the conversation now carries `reasoning_content` when thinking is enabled — not just tool-call rounds — matching DeepSeek's actual API validation, which rejects any assistant message missing the field even though the docs describe non-tool-call reasoning as "ignored".
|
||||
- Added a final-pass wire-payload sanitizer in the chat-completions client that forces a non-empty `reasoning_content` placeholder onto any assistant message still missing one at request time. This is the last line of defense after engine-side and build-side substitution, so sessions restored from older checkpoints, sub-agents that append messages directly, and cached prefix mismatches all produce a valid request.
|
||||
- On a `reasoning_content`-related 400, the client now logs the offending message indices to make future regressions diagnosable.
|
||||
- Stripped phantom `web.run` references from prompts and the `web_search` tool surface ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
|
||||
|
||||
### Changed
|
||||
- Header/UI widget refactor in the TUI (`crates/tui/src/tui/ui.rs`, `widgets/header.rs`) — internal cleanup, no user-visible behavior change.
|
||||
|
||||
## [0.4.9] - 2026-04-27
|
||||
|
||||
### Fixed
|
||||
- DeepSeek thinking-mode tool-call rounds now always replay `reasoning_content` in all subsequent requests (including across new user turns), matching DeepSeek's documented API contract that assistant messages with tool calls must retain their reasoning content forever.
|
||||
- Missing `reasoning_content` on a tool-call assistant message now substitutes a safe placeholder (`"(reasoning omitted)"`) instead of dropping the tool calls and their matching tool results, preventing orphaned conversation chains and API 400 errors.
|
||||
- Session checkpoint now persists a Thinking-block placeholder for tool-call turns that produced no streamed reasoning text, keeping on-disk sessions structurally correct so subsequent requests avoid HTTP 400 rejections.
|
||||
- Token estimation for compaction now counts thinking tokens across all tool-call rounds (not just the current user turn), aligning with the updated reasoning_content replay rule.
|
||||
|
||||
## [0.4.8] - 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- DeepSeek V4 Pro cost estimates now use DeepSeek's current limited-time 75% discount until 2026-05-05 15:59 UTC, then automatically fall back to the base Pro rates.
|
||||
|
||||
## [0.4.5] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
@@ -485,7 +530,9 @@ 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.33...HEAD
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.9...HEAD
|
||||
[0.4.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.4.8...v0.4.9
|
||||
[0.4.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.33...v0.4.8
|
||||
[0.3.33]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...v0.3.33
|
||||
[0.3.32]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...v0.3.32
|
||||
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.31
|
||||
|
||||
Generated
+13
-13
@@ -806,7 +806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
@@ -814,7 +814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -837,7 +837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -848,7 +848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -867,7 +867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -876,7 +876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -890,7 +890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -900,7 +900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -908,7 +908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -920,7 +920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -933,7 +933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -987,7 +987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1005,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.7"
|
||||
version = "0.5.2"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
|
||||
@@ -1,152 +1,193 @@
|
||||
# DeepSeek TUI
|
||||
|
||||
`npm i -g deepseek-tui`
|
||||
> **A terminal-native coding agent for [DeepSeek V4](https://platform.deepseek.com) models — with 1M-token context, thinking-mode reasoning, and full tool-use.**
|
||||
|
||||
A coding agent for [DeepSeek](https://platform.deepseek.com) models that runs in your terminal.
|
||||
```bash
|
||||
npm i -g deepseek-tui
|
||||
```
|
||||
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
[](https://www.npmjs.com/package/deepseek-tui)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## What is it?
|
||||
|
||||
DeepSeek TUI is a coding agent that runs entirely in your terminal. It gives DeepSeek's frontier models direct access to your workspace — reading and editing files, running shell commands, searching the web, managing git, and orchestrating sub-agents — all through a fast, keyboard-driven TUI.
|
||||
|
||||
**Built for DeepSeek V4** (`deepseek-v4-pro` / `deepseek-v4-flash`) with 1M-token context windows and native thinking-mode (chain-of-thought) streaming. See the model's reasoning unfold in real time as it works through your tasks.
|
||||
|
||||
### Key Features
|
||||
|
||||
- 🧠 **Thinking-mode streaming** — watch DeepSeek's chain-of-thought as it reasons about your code
|
||||
- 🔧 **Full tool suite** — file ops, shell execution, git, web search/browse, apply-patch, sub-agents, MCP servers, and more
|
||||
- 🪟 **1M-token context** — feed entire codebases; automatic intelligent compaction when context fills up
|
||||
- 🎛️ **Three interaction modes** — Plan (read-only explore), Agent (interactive with approval), YOLO (auto-approved)
|
||||
- ⚡ **Reasoning-effort tiers** — cycle through `off → high → max` with Shift+Tab
|
||||
- 🔄 **Session save/resume** — checkpoint and resume long sessions, fork conversations
|
||||
- 🌐 **HTTP/SSE runtime API** — `deepseek serve --http` for headless agent workflows
|
||||
- 📦 **MCP protocol** — connect to Model Context Protocol servers for extended tooling
|
||||
- 💰 **Live cost tracking** — per-turn and session-level token usage and cost estimates
|
||||
- 🎨 **Dark & light themes** — with a DeepSeek-blue branded palette
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm install -g deepseek-tui
|
||||
```
|
||||
|
||||
Start the TUI:
|
||||
|
||||
```bash
|
||||
deepseek
|
||||
```
|
||||
|
||||
On first launch, it will prompt for your API key if one is not already configured.
|
||||
The package also installs `deepseek-tui`; both commands share the same
|
||||
`~/.deepseek/config.toml` for DeepSeek auth and default model settings.
|
||||
|
||||
You can also set auth ahead of time with either of these:
|
||||
On first launch you'll be prompted for your [DeepSeek API key](https://platform.deepseek.com/api_keys). You can also set it ahead of time:
|
||||
|
||||
```bash
|
||||
# via CLI
|
||||
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
|
||||
deepseek-tui login --api-key "YOUR_DEEPSEEK_API_KEY"
|
||||
DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY" deepseek-tui
|
||||
|
||||
# via env var
|
||||
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
|
||||
deepseek
|
||||
```
|
||||
|
||||
To use NVIDIA NIM-hosted DeepSeek V4 Pro instead:
|
||||
### Using NVIDIA NIM
|
||||
|
||||
```bash
|
||||
deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
|
||||
deepseek --provider nvidia-nim
|
||||
|
||||
# or for one process:
|
||||
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="YOUR_NVIDIA_API_KEY" deepseek
|
||||
# or per-process:
|
||||
DEEPSEEK_PROVIDER=nvidia-nim NVIDIA_API_KEY="..." deepseek
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Other install methods</summary>
|
||||
<summary>Install from source</summary>
|
||||
|
||||
```bash
|
||||
# From crates.io (requires Rust 1.85+)
|
||||
cargo install deepseek-tui --locked # TUI
|
||||
cargo install deepseek-tui-cli --locked # deepseek CLI facade
|
||||
|
||||
# From source
|
||||
git clone https://github.com/Hmbown/DeepSeek-TUI.git
|
||||
cd DeepSeek-TUI
|
||||
cargo install --path crates/tui --locked
|
||||
cargo install --path crates/tui --locked # requires Rust 1.85+
|
||||
```
|
||||
|
||||
The canonical crates.io packages for this repository are `deepseek-tui` and
|
||||
`deepseek-tui-cli`. The unrelated `deepseek-cli` crate is not part of this
|
||||
project. crates.io publication can lag the repository workspace version and the
|
||||
npm wrapper, so use npm or install from source if you need the newest release
|
||||
surface immediately.
|
||||
|
||||
</details>
|
||||
|
||||
## What it does
|
||||
---
|
||||
|
||||
A terminal coding agent for DeepSeek models with file editing, shell execution, `web.run` browsing, git operations, session resume, and [MCP](https://modelcontextprotocol.io) server integration.
|
||||
## What's new in v0.5.0
|
||||
|
||||
Three visible modes (**Tab** to cycle):
|
||||
- **Multi-turn tool calls no longer 400 on thinking-mode models.** Every assistant message now replays `reasoning_content` (with a safe placeholder when the round produced none), and a final-pass sanitizer guarantees the wire payload satisfies DeepSeek's thinking-mode contract — even for sessions restored from older checkpoints or sub-agents that bypass the engine path.
|
||||
- **Phantom `web.run` references stripped** from prompts and the `web_search` tool ([#25](https://github.com/Hmbown/DeepSeek-TUI/issues/25)).
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| **Plan** | Review a plan before the agent starts making changes |
|
||||
| **Agent** | Default interactive mode with multi-step tool use |
|
||||
| **YOLO** | Auto-approve tools in a trusted workspace |
|
||||
Full history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
**Shift+Tab** cycles the reasoning-effort tier for DeepSeek thinking mode:
|
||||
`off` → `high` → `max`. The current tier is shown as a ⚡ chip in the header.
|
||||
Set a default in config with `reasoning_effort = "max"` (or `off` / `low` /
|
||||
`medium` / `high`).
|
||||
---
|
||||
|
||||
## Models & pricing
|
||||
## Models & Pricing
|
||||
|
||||
| Model | Thinking | Context | Input cache hit | Input cache miss | Output |
|
||||
|---|---|---|---|---|---|
|
||||
| `deepseek-v4-pro` | default | 1M | $0.145 / 1M | $1.74 / 1M | $3.48 / 1M |
|
||||
| `deepseek-v4-flash` | default | 1M | $0.028 / 1M | $0.14 / 1M | $0.28 / 1M |
|
||||
| `deepseek-ai/deepseek-v4-pro` via NVIDIA NIM | default | 1M | NVIDIA account terms | NVIDIA account terms | NVIDIA account terms |
|
||||
| `deepseek-ai/deepseek-v4-flash` via NVIDIA NIM | default | 1M | NVIDIA account terms | NVIDIA account terms | NVIDIA account terms |
|
||||
DeepSeek TUI targets **DeepSeek V4** models with 1M-token context windows by default.
|
||||
|
||||
Legacy `deepseek-chat` and `deepseek-reasoner` remain as silent aliases for
|
||||
`deepseek-v4-flash` (priced identically). Pricing is per 1M tokens as published
|
||||
by DeepSeek and is subject to change.
|
||||
| Model | Context | Input (cache hit) | Input (cache miss) | Output |
|
||||
|---|---|---|---|---|
|
||||
| `deepseek-v4-pro` | 1M | $0.03625 / 1M* | $0.435 / 1M* | $0.87 / 1M* |
|
||||
| `deepseek-v4-flash` | 1M | $0.028 / 1M | $0.14 / 1M | $0.28 / 1M |
|
||||
|
||||
Legacy aliases `deepseek-chat` and `deepseek-reasoner` silently map to `deepseek-v4-flash`.
|
||||
|
||||
**NVIDIA NIM** hosted variants (`deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash`) use your NVIDIA account terms — no DeepSeek platform billing.
|
||||
|
||||
*\*DeepSeek lists the Pro rates above as a limited-time 75% discount valid until 2026-05-05 15:59 UTC; the TUI cost estimator falls back to base Pro rates after that timestamp.*
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
deepseek # interactive TUI
|
||||
deepseek "explain this in 2 sentences" # one-shot prompt
|
||||
deepseek --model deepseek-v4-flash "summarize" # one-shot with model override
|
||||
deepseek --yolo # YOLO mode
|
||||
deepseek login --api-key "..." # save API key to shared config
|
||||
deepseek doctor # check setup
|
||||
deepseek models # list live DeepSeek API models
|
||||
deepseek "explain this function" # one-shot prompt
|
||||
deepseek --model deepseek-v4-flash "summarize" # model override
|
||||
deepseek --yolo # YOLO mode (auto-approve tools)
|
||||
deepseek login --api-key "..." # save API key
|
||||
deepseek doctor # check setup & connectivity
|
||||
deepseek models # list live API models
|
||||
deepseek sessions # list saved sessions
|
||||
deepseek resume --last # resume the latest session
|
||||
deepseek resume --last # resume latest session
|
||||
deepseek serve --http # HTTP/SSE API server
|
||||
```
|
||||
|
||||
Controls: `F1` help, `Esc` backs out of the current action, `Ctrl+K` command palette.
|
||||
In the composer, `@path/to/file` adds local text file or directory context to
|
||||
the next message. Use `/attach <path>` for local image/video media references.
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Cycle mode: Plan → Agent → YOLO |
|
||||
| `Shift+Tab` | Cycle reasoning-effort: off → high → max |
|
||||
| `F1` | Help |
|
||||
| `Esc` | Back / dismiss |
|
||||
| `Ctrl+K` | Command palette |
|
||||
| `@path` | Attach file/directory context in composer |
|
||||
| `/attach <path>` | Attach image/video media references |
|
||||
|
||||
---
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| **Plan** 🔍 | Read-only investigation — model explores and proposes a plan before making changes |
|
||||
| **Agent** 🤖 | Default interactive mode — multi-step tool use with approval gates |
|
||||
| **YOLO** ⚡ | Auto-approve all tools in a trusted workspace (use with caution) |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`~/.deepseek/config.toml` — see [config.example.toml](config.example.toml) for all options.
|
||||
`~/.deepseek/config.toml` — see [config.example.toml](config.example.toml) for every option.
|
||||
|
||||
Key environment overrides: `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`,
|
||||
`DEEPSEEK_MODEL`, `DEEPSEEK_PROFILE`, `DEEPSEEK_PROVIDER`.
|
||||
For NVIDIA NIM, use `DEEPSEEK_PROVIDER=nvidia-nim` plus `NVIDIA_API_KEY`
|
||||
or `NVIDIA_NIM_API_KEY` (with `DEEPSEEK_API_KEY` as a compatibility fallback);
|
||||
the default model is `deepseek-ai/deepseek-v4-pro` and the default base URL is
|
||||
`https://integrate.api.nvidia.com/v1`. With `--provider nvidia-nim`,
|
||||
`--model deepseek-v4-flash` maps to `deepseek-ai/deepseek-v4-flash`.
|
||||
Key environment overrides:
|
||||
|
||||
Quick checks and scaffolding:
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DEEPSEEK_API_KEY` | API key |
|
||||
| `DEEPSEEK_BASE_URL` | API base URL |
|
||||
| `DEEPSEEK_MODEL` | Default model |
|
||||
| `DEEPSEEK_PROVIDER` | Provider: `deepseek` (default) or `nvidia-nim` |
|
||||
| `DEEPSEEK_PROFILE` | Config profile name |
|
||||
| `NVIDIA_API_KEY` | NVIDIA NIM API key |
|
||||
|
||||
- `deepseek-tui setup --status` — read-only, network-free status of API key,
|
||||
MCP/skills/tools/plugins, sandbox, and `.env`.
|
||||
- `deepseek-tui setup --tools --plugins` — scaffold `~/.deepseek/tools/` and
|
||||
`~/.deepseek/plugins/` with self-describing example templates.
|
||||
- `deepseek-tui doctor --json` — machine-readable doctor output for CI.
|
||||
Quick diagnostics:
|
||||
|
||||
The client targets DeepSeek's documented OpenAI-compatible Chat Completions API
|
||||
(`/chat/completions`). DeepSeek context caching is automatic; when the API
|
||||
returns cache hit/miss token fields, the TUI includes them in usage and cost
|
||||
tracking.
|
||||
```bash
|
||||
deepseek-tui setup --status # read-only status check (API key, MCP, sandbox, .env)
|
||||
deepseek-tui doctor --json # machine-readable doctor output for CI
|
||||
deepseek-tui setup --tools --plugins # scaffold tools/ and plugins/ directories
|
||||
```
|
||||
|
||||
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
DeepSeek context caching is automatic — when the API returns cache hit/miss token fields, the TUI includes them in usage and cost tracking.
|
||||
|
||||
## Docs
|
||||
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
|
||||
|
||||
[docs/](docs/) — configuration, modes, MCP integration, runtime API, and release runbooks.
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Topic |
|
||||
|---|---|
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Codebase internals |
|
||||
| [CONFIGURATION.md](docs/CONFIGURATION.md) | Full config reference |
|
||||
| [MODES.md](docs/MODES.md) | Plan / Agent / YOLO modes |
|
||||
| [MCP.md](docs/MCP.md) | Model Context Protocol integration |
|
||||
| [RUNTIME_API.md](docs/RUNTIME_API.md) | HTTP/SSE API server |
|
||||
| [RELEASE_RUNBOOK.md](docs/RELEASE_RUNBOOK.md) | Release process |
|
||||
| [OPERATIONS_RUNBOOK.md](docs/OPERATIONS_RUNBOOK.md) | Ops & recovery |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Not affiliated with DeepSeek Inc.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md). Pull requests welcome!
|
||||
|
||||
*Not affiliated with DeepSeek Inc.*
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 755 KiB |
+29
-9
@@ -7,21 +7,19 @@
|
||||
# See `docs/CONFIGURATION.md` for how config is loaded (profiles, env overrides, etc.).
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# API Keys
|
||||
# Active provider + DeepSeek defaults
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Choose which provider to use by default. Per-provider credentials live in the
|
||||
# `[providers.deepseek]` and `[providers.nvidia_nim]` sections near the bottom of
|
||||
# this file — keeping both stored at once means `/provider deepseek` and
|
||||
# `/provider nvidia-nim` (or `--provider nvidia-nim`) toggle without having to
|
||||
# re-enter keys. Top-level `api_key` / `base_url` are still read as DeepSeek
|
||||
# defaults when `[providers.deepseek]` is absent (backward compatibility).
|
||||
provider = "deepseek" # deepseek | nvidia-nim
|
||||
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Base URLs
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
base_url = "https://api.deepseek.com"
|
||||
# base_url = "https://api.deepseeki.com" # China users
|
||||
# base_url = "https://api.deepseek.com/beta" # DeepSeek beta features such as strict tool mode
|
||||
# For NVIDIA NIM-hosted DeepSeek:
|
||||
# provider = "nvidia-nim"
|
||||
# api_key = "YOUR_NVIDIA_API_KEY"
|
||||
# base_url = "https://integrate.api.nvidia.com/v1"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Default Models
|
||||
@@ -70,6 +68,28 @@ max_subagents = 5 # optional (1-20)
|
||||
# managed_config_path = "/etc/deepseek/managed_config.toml"
|
||||
# requirements_path = "/etc/deepseek/requirements.toml"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Per-provider credentials (peer providers — NIM is first-class, not a flag)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# Both providers can be stored at once; `provider = "..."` (top of file) or
|
||||
# `/provider deepseek` / `/provider nvidia-nim` switches between them without
|
||||
# having to re-enter keys. Env vars override anything set here:
|
||||
# DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL
|
||||
# NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL
|
||||
# (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL
|
||||
|
||||
# DeepSeek Platform (https://platform.deepseek.com)
|
||||
[providers.deepseek]
|
||||
# api_key = "YOUR_DEEPSEEK_API_KEY"
|
||||
# base_url = "https://api.deepseek.com"
|
||||
# model = "deepseek-v4-pro"
|
||||
|
||||
# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
|
||||
[providers.nvidia_nim]
|
||||
# api_key = "YOUR_NVIDIA_API_KEY"
|
||||
# base_url = "https://integrate.api.nvidia.com/v1"
|
||||
# model = "deepseek-ai/deepseek-v4-pro" # or deepseek-ai/deepseek-v4-flash
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
# TUI
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -7,5 +7,5 @@ repository.workspace = true
|
||||
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
|
||||
|
||||
[dependencies]
|
||||
deepseek-config = { path = "../config", version = "0.4.5" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.4.5" }
|
||||
deepseek-config = { path = "../config", version = "0.4.5" }
|
||||
deepseek-core = { path = "../core", version = "0.4.5" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.5" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.4.5" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.4.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
|
||||
deepseek-state = { path = "../state", version = "0.4.5" }
|
||||
deepseek-tools = { path = "../tools", version = "0.4.5" }
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-core = { path = "../core", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
deepseek-tools = { path = "../tools", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -14,12 +14,12 @@ path = "src/main.rs"
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
clap_complete.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.4.5" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.4.5" }
|
||||
deepseek-config = { path = "../config", version = "0.4.5" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.5" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.4.5" }
|
||||
deepseek-state = { path = "../state", version = "0.4.5" }
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-app-server = { path = "../app-server", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
chrono.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-agent = { path = "../agent", version = "0.4.5" }
|
||||
deepseek-config = { path = "../config", version = "0.4.5" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.4.5" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.4.5" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.4.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
|
||||
deepseek-state = { path = "../state", version = "0.4.5" }
|
||||
deepseek-tools = { path = "../tools", version = "0.4.5" }
|
||||
deepseek-agent = { path = "../agent", version = "0.5.0" }
|
||||
deepseek-config = { path = "../config", version = "0.5.0" }
|
||||
deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" }
|
||||
deepseek-hooks = { path = "../hooks", version = "0.5.0" }
|
||||
deepseek-mcp = { path = "../mcp", version = "0.5.0" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
deepseek-state = { path = "../state", version = "0.5.0" }
|
||||
deepseek-tools = { path = "../tools", version = "0.5.0" }
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
chrono.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
|
||||
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
+363
-92
@@ -801,6 +801,20 @@ impl LlmClient for DeepSeekClient {
|
||||
self.api_provider,
|
||||
);
|
||||
|
||||
// Bulletproof final sanitizer: walk the wire payload and force
|
||||
// `reasoning_content` onto any assistant message that has tool_calls
|
||||
// but no reasoning_content. DeepSeek's thinking-mode API rejects
|
||||
// such messages with a 400. This is the last line of defense after
|
||||
// engine-side and build-side substitution; if either upstream path
|
||||
// misses a case (e.g. a session restored from disk, a sub-agent
|
||||
// adding messages directly, or a cached prefix mismatch), this pass
|
||||
// still produces a valid request.
|
||||
let replay_input_tokens = sanitize_thinking_mode_messages(
|
||||
&mut body,
|
||||
&request.model,
|
||||
request.reasoning_effort.as_deref(),
|
||||
);
|
||||
|
||||
let url = api_url(&self.base_url, "chat/completions");
|
||||
let response = self
|
||||
.send_with_retry(|| self.http_client.post(&url).json(&body))
|
||||
@@ -809,6 +823,12 @@ impl LlmClient for DeepSeekClient {
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
|
||||
// If DeepSeek rejected for missing reasoning_content despite the
|
||||
// sanitizer, dump the offending indices so we can diagnose where
|
||||
// they came from on the next failure.
|
||||
if error_text.contains("reasoning_content") {
|
||||
log_thinking_mode_violations(&body);
|
||||
}
|
||||
anyhow::bail!("SSE stream request failed: HTTP {status}: {error_text}");
|
||||
}
|
||||
|
||||
@@ -887,7 +907,7 @@ impl LlmClient for DeepSeekClient {
|
||||
// Stream complete
|
||||
} else if let Ok(chunk_json) = serde_json::from_str::<Value>(&data) {
|
||||
// Parse the SSE chunk into stream events
|
||||
for event in parse_sse_chunk(
|
||||
for mut event in parse_sse_chunk(
|
||||
&chunk_json,
|
||||
&mut content_index,
|
||||
&mut text_started,
|
||||
@@ -895,6 +915,19 @@ impl LlmClient for DeepSeekClient {
|
||||
&mut tool_indices,
|
||||
is_reasoning_model,
|
||||
) {
|
||||
// Stamp the client-side replay-token estimate
|
||||
// onto the final usage so the UI can surface
|
||||
// it (#30). We compute it pre-request and
|
||||
// overlay it on the server-reported usage at
|
||||
// stream completion.
|
||||
if let Some(tokens) = replay_input_tokens
|
||||
&& let StreamEvent::MessageDelta {
|
||||
usage: Some(usage),
|
||||
..
|
||||
} = &mut event
|
||||
{
|
||||
usage.reasoning_replay_tokens = Some(tokens);
|
||||
}
|
||||
yield Ok(event);
|
||||
}
|
||||
}
|
||||
@@ -1351,7 +1384,6 @@ fn build_chat_messages_with_reasoning(
|
||||
) -> Vec<Value> {
|
||||
let mut out = Vec::new();
|
||||
let mut pending_tool_calls: HashSet<String> = HashSet::new();
|
||||
let current_turn_start = messages.iter().rposition(is_text_user_message);
|
||||
|
||||
if let Some(instructions) = system_to_instructions(system.cloned())
|
||||
&& !instructions.trim().is_empty()
|
||||
@@ -1362,7 +1394,7 @@ fn build_chat_messages_with_reasoning(
|
||||
}));
|
||||
}
|
||||
|
||||
for (message_index, message) in messages.iter().enumerate() {
|
||||
for message in messages.iter() {
|
||||
let role = message.role.as_str();
|
||||
let mut text_parts = Vec::new();
|
||||
let mut thinking_parts = Vec::new();
|
||||
@@ -1421,32 +1453,27 @@ fn build_chat_messages_with_reasoning(
|
||||
|
||||
if role == "assistant" {
|
||||
let content = text_parts.join("\n");
|
||||
let reasoning_content = thinking_parts.join("\n");
|
||||
let mut reasoning_content = thinking_parts.join("\n");
|
||||
let has_text = !content.trim().is_empty();
|
||||
let mut has_tool_calls = !tool_calls.is_empty();
|
||||
let include_reasoning_for_turn = include_reasoning
|
||||
&& has_tool_calls
|
||||
&& current_turn_start.is_some_and(|start| message_index > start)
|
||||
&& !has_later_assistant_text(messages, message_index);
|
||||
let has_reasoning = include_reasoning_for_turn && !reasoning_content.trim().is_empty();
|
||||
|
||||
// DeepSeek thinking-mode tool turns are stateful within the
|
||||
// stateless Chat Completions transcript: if an assistant performed
|
||||
// a tool call in the current user turn, its `reasoning_content`
|
||||
// must be replayed while continuing that tool round. Once a new
|
||||
// user text turn starts, DeepSeek recommends clearing historical
|
||||
// reasoning content so the context is not dominated by old CoT.
|
||||
// Older checkpoints could lose the current-round field because the
|
||||
// UI display stream had no visible text block. Do not forward those
|
||||
// malformed current tool calls; dropping that round is better than
|
||||
// guaranteeing a provider-side 400.
|
||||
let has_tool_calls = !tool_calls.is_empty();
|
||||
// DeepSeek thinking-mode rule: every assistant message in the
|
||||
// conversation must carry its `reasoning_content` when thinking
|
||||
// is enabled. The docs say non-tool-call messages' reasoning is
|
||||
// "ignored", but the API still validates presence and rejects
|
||||
// with a 400 if any assistant message is missing it. If reasoning
|
||||
// was lost (e.g. a session checkpoint from before this rule was
|
||||
// enforced, or a sub-turn with no streamed reasoning text),
|
||||
// substitute a non-empty placeholder so the API accepts the
|
||||
// request.
|
||||
let include_reasoning_for_turn = include_reasoning;
|
||||
let mut has_reasoning =
|
||||
include_reasoning_for_turn && !reasoning_content.trim().is_empty();
|
||||
if include_reasoning_for_turn && !has_reasoning {
|
||||
logging::warn(
|
||||
"Dropping DeepSeek tool_calls with missing reasoning_content from assistant message",
|
||||
"Substituting placeholder reasoning_content for DeepSeek tool-call assistant message",
|
||||
);
|
||||
tool_calls.clear();
|
||||
tool_call_ids.clear();
|
||||
has_tool_calls = false;
|
||||
reasoning_content = String::from("(reasoning omitted)");
|
||||
has_reasoning = true;
|
||||
}
|
||||
|
||||
// DeepSeek rejects assistant messages where both `content` and
|
||||
@@ -1618,33 +1645,6 @@ fn build_chat_messages_with_reasoning(
|
||||
out
|
||||
}
|
||||
|
||||
fn is_text_user_message(message: &Message) -> bool {
|
||||
message.role == "user"
|
||||
&& message.content.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::Text { text, .. } if !text.trim().is_empty()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn has_later_assistant_text(messages: &[Message], message_index: usize) -> bool {
|
||||
messages
|
||||
.iter()
|
||||
.skip(message_index.saturating_add(1))
|
||||
.any(is_text_assistant_message)
|
||||
}
|
||||
|
||||
fn is_text_assistant_message(message: &Message) -> bool {
|
||||
message.role == "assistant"
|
||||
&& message.content.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::Text { text, .. } if !text.trim().is_empty()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_to_chat(tool: &Tool) -> Value {
|
||||
let mut value = json!({
|
||||
"type": "function",
|
||||
@@ -1692,6 +1692,124 @@ fn map_tool_choice_for_chat(choice: &Value) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Final-pass sanitizer over the outgoing chat-completions JSON payload.
|
||||
/// Forces a non-empty `reasoning_content` onto every `assistant` message that
|
||||
/// carries `tool_calls`, when the model + effort combination requires it.
|
||||
/// DeepSeek's thinking-mode API rejects such messages with a 400 error;
|
||||
/// substituting a placeholder keeps the conversation chain intact.
|
||||
///
|
||||
/// Also tallies the size of all replayed `reasoning_content` and logs it, so
|
||||
/// users on `RUST_LOG=deepseek_tui=debug` can see how much of their input
|
||||
/// budget is being spent re-sending prior thinking traces (V4 §5.1.1
|
||||
/// "Interleaved Thinking" requires the full trace to be replayed across user
|
||||
/// message boundaries in tool-calling sessions).
|
||||
fn sanitize_thinking_mode_messages(
|
||||
body: &mut Value,
|
||||
model: &str,
|
||||
effort: Option<&str>,
|
||||
) -> Option<u32> {
|
||||
if !should_replay_reasoning_content(model, effort) {
|
||||
return None;
|
||||
}
|
||||
let messages = body.get_mut("messages").and_then(Value::as_array_mut)?;
|
||||
let mut substitutions: u32 = 0;
|
||||
let mut replay_chars: u64 = 0;
|
||||
let mut replay_messages: u32 = 0;
|
||||
for (idx, msg) in messages.iter_mut().enumerate() {
|
||||
if msg.get("role").and_then(Value::as_str) != Some("assistant") {
|
||||
continue;
|
||||
}
|
||||
let needs_placeholder = msg
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|s| s.trim().is_empty());
|
||||
if needs_placeholder {
|
||||
msg["reasoning_content"] = json!("(reasoning omitted)");
|
||||
substitutions = substitutions.saturating_add(1);
|
||||
logging::warn(format!(
|
||||
"Final sanitizer: forced reasoning_content placeholder on assistant[{idx}]",
|
||||
));
|
||||
}
|
||||
if let Some(reasoning) = msg.get("reasoning_content").and_then(Value::as_str) {
|
||||
let len = reasoning.len() as u64;
|
||||
if len > 0 {
|
||||
replay_chars = replay_chars.saturating_add(len);
|
||||
replay_messages = replay_messages.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if substitutions > 0 {
|
||||
logging::warn(format!(
|
||||
"Final sanitizer: {substitutions} assistant message(s) needed reasoning_content placeholder",
|
||||
));
|
||||
}
|
||||
if replay_messages == 0 {
|
||||
return None;
|
||||
}
|
||||
// ~4 chars/token is the standard rough estimate; DeepSeek tokens skew
|
||||
// a touch shorter on Chinese/code but this is order-of-magnitude info.
|
||||
let approx_tokens = (replay_chars / 4).min(u64::from(u32::MAX)) as u32;
|
||||
logging::info(format!(
|
||||
"Reasoning-content replay: {replay_messages} assistant message(s), ~{approx_tokens} input tokens ({replay_chars} chars) being re-sent in this request",
|
||||
));
|
||||
Some(approx_tokens)
|
||||
}
|
||||
|
||||
/// Sums the byte length of `reasoning_content` across all assistant messages in
|
||||
/// an outgoing chat-completions body. Used by tests; the production sanitizer
|
||||
/// computes the same number inline and logs it.
|
||||
#[cfg(test)]
|
||||
fn count_reasoning_replay_chars(body: &Value) -> u64 {
|
||||
let Some(messages) = body.get("messages").and_then(Value::as_array) else {
|
||||
return 0;
|
||||
};
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.filter_map(|m| m.get("reasoning_content").and_then(Value::as_str))
|
||||
.map(|s| s.len() as u64)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Diagnostic logger fired when DeepSeek rejects the request despite the
|
||||
/// sanitizer. Walks the body and logs which assistant messages have tool_calls
|
||||
/// but no `reasoning_content` — useful to track down a code path that bypasses
|
||||
/// the sanitizer entirely.
|
||||
fn log_thinking_mode_violations(body: &Value) {
|
||||
let Some(messages) = body.get("messages").and_then(Value::as_array) else {
|
||||
logging::warn("400-after-sanitizer: body has no `messages` array");
|
||||
return;
|
||||
};
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
for (idx, msg) in messages.iter().enumerate() {
|
||||
if msg.get("role").and_then(Value::as_str) != Some("assistant") {
|
||||
continue;
|
||||
}
|
||||
let reasoning = msg
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let has_tc = msg.get("tool_calls").is_some();
|
||||
if reasoning.trim().is_empty() {
|
||||
violations.push(format!(
|
||||
"assistant[{idx}] (reasoning_content missing, tool_calls={})",
|
||||
has_tc
|
||||
));
|
||||
}
|
||||
}
|
||||
if violations.is_empty() {
|
||||
logging::warn(
|
||||
"400-after-sanitizer: all assistant messages have reasoning_content — DeepSeek rejected for a different reason",
|
||||
);
|
||||
} else {
|
||||
logging::warn(format!(
|
||||
"400-after-sanitizer: {} assistant message(s) lack reasoning_content despite sanitizer: {}",
|
||||
violations.len(),
|
||||
violations.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_reasoning_content(model: &str) -> bool {
|
||||
let lower = model.to_lowercase();
|
||||
lower.contains("deepseek-v3.2")
|
||||
@@ -1933,6 +2051,7 @@ fn parse_usage(usage: Option<&Value>) -> Usage {
|
||||
prompt_cache_hit_tokens,
|
||||
prompt_cache_miss_tokens,
|
||||
reasoning_tokens,
|
||||
reasoning_replay_tokens: None,
|
||||
server_tool_use,
|
||||
}
|
||||
}
|
||||
@@ -2320,7 +2439,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_strip_reasoning_content_from_final_answer() {
|
||||
fn chat_messages_keep_reasoning_content_on_all_assistant_messages() {
|
||||
let message = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![
|
||||
@@ -2342,11 +2461,15 @@ mod tests {
|
||||
assistant.get("content").and_then(Value::as_str),
|
||||
Some("done")
|
||||
);
|
||||
assert!(assistant.get("reasoning_content").is_none());
|
||||
assert_eq!(
|
||||
assistant.get("reasoning_content").and_then(Value::as_str),
|
||||
Some("plan"),
|
||||
"thinking-mode models must keep reasoning_content on ALL assistant messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_drop_thinking_only_assistant_for_chat_model() {
|
||||
fn chat_messages_keep_thinking_only_assistant_for_v4_flash() {
|
||||
let message = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Thinking {
|
||||
@@ -2354,14 +2477,18 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
let out = build_chat_messages(None, &[message], "deepseek-v4-flash");
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
let assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("thinking-only assistant kept for V4 model");
|
||||
assert_eq!(
|
||||
assistant.get("reasoning_content").and_then(Value::as_str),
|
||||
Some("plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_drop_thinking_only_assistant_for_reasoner_model() {
|
||||
fn chat_messages_keep_thinking_only_assistant_for_v4_pro() {
|
||||
let message = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Thinking {
|
||||
@@ -2369,14 +2496,18 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
let out = build_chat_messages(None, &[message], "deepseek-v4-pro");
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
let assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("thinking-only assistant kept for V4 model");
|
||||
assert_eq!(
|
||||
assistant.get("reasoning_content").and_then(Value::as_str),
|
||||
Some("plan")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_drop_thinking_only_assistant_for_r_series_model() {
|
||||
fn chat_messages_keep_thinking_only_assistant_for_r_series_model() {
|
||||
let message = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Thinking {
|
||||
@@ -2384,9 +2515,13 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
let out = build_chat_messages(None, &[message], "deepseek-r2-lite-preview");
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
let assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("thinking-only assistant kept for R-series model");
|
||||
assert_eq!(
|
||||
assistant.get("reasoning_content").and_then(Value::as_str),
|
||||
Some("plan")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2437,7 +2572,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_clear_prior_tool_round_reasoning_after_new_user_turn() {
|
||||
fn chat_messages_replay_prior_tool_round_reasoning_after_new_user_turn() {
|
||||
let messages = vec![
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
@@ -2485,16 +2620,24 @@ mod tests {
|
||||
},
|
||||
];
|
||||
let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
|
||||
let assistant = out
|
||||
let tool_assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("assistant message");
|
||||
assert!(assistant.get("tool_calls").is_some());
|
||||
assert!(assistant.get("reasoning_content").is_none());
|
||||
.find(|value| {
|
||||
value.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& value.get("tool_calls").is_some()
|
||||
})
|
||||
.expect("tool-call assistant message");
|
||||
assert_eq!(
|
||||
tool_assistant
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str),
|
||||
Some("Need to call a tool"),
|
||||
"DeepSeek thinking mode requires reasoning_content to be replayed for tool-call rounds across all subsequent user turns"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_clear_completed_tool_round_reasoning_after_final_answer() {
|
||||
fn chat_messages_replay_completed_tool_round_reasoning_after_final_answer() {
|
||||
let messages = vec![
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
@@ -2535,16 +2678,34 @@ mod tests {
|
||||
},
|
||||
];
|
||||
let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
|
||||
let assistant = out
|
||||
let tool_assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("assistant message");
|
||||
assert!(assistant.get("tool_calls").is_some());
|
||||
assert!(assistant.get("reasoning_content").is_none());
|
||||
.find(|value| {
|
||||
value.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& value.get("tool_calls").is_some()
|
||||
})
|
||||
.expect("tool-call assistant message");
|
||||
assert_eq!(
|
||||
tool_assistant
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str),
|
||||
Some("Need to call a tool")
|
||||
);
|
||||
let final_assistant = out
|
||||
.iter()
|
||||
.rfind(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("final assistant message");
|
||||
assert!(
|
||||
final_assistant
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.trim().is_empty()),
|
||||
"all assistant messages must carry reasoning_content in thinking mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_clear_v4_tool_round_reasoning_after_new_user_turn() {
|
||||
fn chat_messages_replay_v4_tool_round_reasoning_after_new_user_turn() {
|
||||
let messages = vec![
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
@@ -2593,16 +2754,23 @@ mod tests {
|
||||
];
|
||||
|
||||
let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
|
||||
let assistant = out
|
||||
let tool_assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("assistant message");
|
||||
assert!(assistant.get("tool_calls").is_some());
|
||||
assert!(assistant.get("reasoning_content").is_none());
|
||||
.find(|value| {
|
||||
value.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& value.get("tool_calls").is_some()
|
||||
})
|
||||
.expect("tool-call assistant message");
|
||||
assert_eq!(
|
||||
tool_assistant
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str),
|
||||
Some("Need a tool for this")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_drop_v4_tool_round_missing_reasoning() {
|
||||
fn chat_messages_substitute_placeholder_when_v4_tool_round_missing_reasoning() {
|
||||
let messages = vec![
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
@@ -2633,15 +2801,24 @@ mod tests {
|
||||
|
||||
let out = build_chat_messages(None, &messages, "deepseek-v4-pro");
|
||||
|
||||
let assistant = out
|
||||
.iter()
|
||||
.find(|value| {
|
||||
value.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
&& value.get("tool_calls").is_some()
|
||||
})
|
||||
.expect("tool-call assistant message should be retained with placeholder");
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")),
|
||||
"malformed assistant tool round should be removed"
|
||||
assistant
|
||||
.get("reasoning_content")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
"missing reasoning_content should be substituted with a non-empty placeholder so the API accepts the request"
|
||||
);
|
||||
assert!(
|
||||
!out.iter()
|
||||
out.iter()
|
||||
.any(|value| value.get("role").and_then(Value::as_str) == Some("tool")),
|
||||
"tool result tied to missing reasoning should be removed"
|
||||
"matching tool_result must remain so the conversation chain stays intact"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2852,10 +3029,11 @@ mod tests {
|
||||
thinking: "plan".to_string(),
|
||||
}],
|
||||
};
|
||||
let out = build_chat_messages(None, &[message], "deepseek-v4-mini");
|
||||
let out = build_chat_messages(None, &[message], "some-non-deepseek-model");
|
||||
assert!(
|
||||
!out.iter()
|
||||
.any(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")),
|
||||
"non-reasoning model should drop thinking-only assistant"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3274,6 +3452,99 @@ mod tests {
|
||||
assert_eq!(usage.reasoning_tokens, Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_thinking_mode_counts_reasoning_replay_across_assistant_turns() {
|
||||
// Multi-turn body that mimics two prior tool-calling rounds: each
|
||||
// assistant message carries its `reasoning_content`. The sanitizer
|
||||
// should keep all of them and the count helper should tally bytes
|
||||
// across every assistant message.
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "you are helpful" },
|
||||
{ "role": "user", "content": "step 1" },
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "I need to call tool A first.",
|
||||
"tool_calls": [{ "id": "1", "type": "function" }]
|
||||
},
|
||||
{ "role": "tool", "tool_call_id": "1", "content": "ok" },
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"reasoning_content": "Now I call tool B.",
|
||||
"tool_calls": [{ "id": "2", "type": "function" }]
|
||||
},
|
||||
{ "role": "tool", "tool_call_id": "2", "content": "ok" },
|
||||
{ "role": "user", "content": "step 2" }
|
||||
]
|
||||
});
|
||||
|
||||
let approx_tokens =
|
||||
sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max"))
|
||||
.expect("multi-turn thinking-mode conversation should report replay tokens");
|
||||
// ~4 chars/token; 46 bytes of reasoning -> 11 tokens.
|
||||
assert_eq!(approx_tokens, 11);
|
||||
|
||||
let chars = count_reasoning_replay_chars(&body);
|
||||
// "I need to call tool A first." (28) + "Now I call tool B." (18) = 46
|
||||
assert_eq!(chars, 46);
|
||||
|
||||
// No assistant messages should have lost or had their reasoning_content blanked.
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
let assistant_with_reasoning: usize = messages
|
||||
.iter()
|
||||
.filter(|m| m["role"] == "assistant")
|
||||
.filter(|m| {
|
||||
m["reasoning_content"]
|
||||
.as_str()
|
||||
.is_some_and(|s| !s.is_empty())
|
||||
})
|
||||
.count();
|
||||
assert_eq!(assistant_with_reasoning, 2);
|
||||
}
|
||||
|
||||
/// Issue #30: when no thinking-mode replay applies (non-thinking model or
|
||||
/// empty conversation), the sanitizer returns `None` so the footer chip
|
||||
/// stays hidden.
|
||||
#[test]
|
||||
fn sanitize_thinking_mode_returns_none_for_non_thinking_model() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-chat",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "hi" }
|
||||
]
|
||||
});
|
||||
let result = sanitize_thinking_mode_messages(&mut body, "deepseek-chat", None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_thinking_mode_counts_substituted_placeholder() {
|
||||
// An assistant tool-call message is missing reasoning_content; the
|
||||
// sanitizer must inject the placeholder, and the count helper must
|
||||
// include the placeholder in the total (since it's in the wire
|
||||
// payload that ships to DeepSeek).
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "hi" },
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{ "id": "1", "type": "function" }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
sanitize_thinking_mode_messages(&mut body, "deepseek-v4-pro", Some("max"));
|
||||
|
||||
let chars = count_reasoning_replay_chars(&body);
|
||||
// "(reasoning omitted)" is 19 bytes.
|
||||
assert_eq!(chars, 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_bucket_enforces_delay_when_empty() {
|
||||
let now = Instant::now();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Config commands: config, settings, mode switches, trust, logout
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name};
|
||||
use crate::palette;
|
||||
@@ -222,10 +224,117 @@ pub fn plan_mode(app: &mut App) -> CommandResult {
|
||||
)
|
||||
}
|
||||
|
||||
/// Enable trust mode (file access outside workspace)
|
||||
pub fn trust(app: &mut App) -> CommandResult {
|
||||
app.trust_mode = true;
|
||||
CommandResult::message("Trust mode enabled - can access files outside workspace")
|
||||
/// Manage workspace-level trust and the per-path allowlist.
|
||||
///
|
||||
/// Subcommands:
|
||||
/// - `/trust` – show current state and trusted external paths
|
||||
/// - `/trust on` – legacy: trust the entire workspace (turn off all path checks)
|
||||
/// - `/trust off` – disable workspace-level trust mode
|
||||
/// - `/trust add <path>` – add a directory to the allowlist (#29)
|
||||
/// - `/trust remove <path>` (alias `rm`) – remove a path from the allowlist
|
||||
/// - `/trust list` – list trusted external paths for this workspace
|
||||
pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let raw = arg.map(str::trim).unwrap_or("");
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let sub = parts.next().unwrap_or("").to_lowercase();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
let workspace = app.workspace.clone();
|
||||
|
||||
match sub.as_str() {
|
||||
"" | "status" | "list" => trust_status(&workspace, app, sub == "list"),
|
||||
"on" | "enable" | "yes" | "y" => {
|
||||
app.trust_mode = true;
|
||||
CommandResult::message(
|
||||
"Workspace trust mode enabled — agent file tools can now read/write any path. \
|
||||
Use `/trust off` to revert; prefer `/trust add <path>` for a narrower opt-in.",
|
||||
)
|
||||
}
|
||||
"off" | "disable" | "no" | "n" => {
|
||||
app.trust_mode = false;
|
||||
CommandResult::message("Workspace trust mode disabled.")
|
||||
}
|
||||
"add" => trust_add(&workspace, rest),
|
||||
"remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest),
|
||||
other => CommandResult::error(format!(
|
||||
"Unknown /trust action `{other}`. Use `/trust`, `/trust on|off`, `/trust add <path>`, or `/trust remove <path>`."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult {
|
||||
let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace);
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!(
|
||||
"Workspace trust mode: {}",
|
||||
if app.trust_mode {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
));
|
||||
if trust.paths().is_empty() {
|
||||
if force_paths {
|
||||
lines.push("No external paths trusted from this workspace.".to_string());
|
||||
} else {
|
||||
lines.push(
|
||||
"No external paths trusted yet. Use `/trust add <path>` to allow a directory."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push(format!("Trusted external paths ({}):", trust.paths().len()));
|
||||
for path in trust.paths() {
|
||||
lines.push(format!(" • {}", path.display()));
|
||||
}
|
||||
}
|
||||
CommandResult::message(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn trust_add(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error(
|
||||
"Usage: /trust add <path>. Supply an absolute path or a path relative to the workspace.",
|
||||
);
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
if !path.exists() {
|
||||
return CommandResult::error(format!(
|
||||
"Path not found: {} — supply an existing directory or file.",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
match crate::workspace_trust::add(workspace, &path) {
|
||||
Ok(stored) => CommandResult::message(format!(
|
||||
"Added to trust list for this workspace: {}",
|
||||
stored.display()
|
||||
)),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn trust_remove(workspace: &Path, raw: &str) -> CommandResult {
|
||||
if raw.is_empty() {
|
||||
return CommandResult::error("Usage: /trust remove <path>");
|
||||
}
|
||||
let path = PathBuf::from(expand_tilde(raw));
|
||||
match crate::workspace_trust::remove(workspace, &path) {
|
||||
Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())),
|
||||
Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())),
|
||||
Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_tilde(raw: &str) -> String {
|
||||
if let Some(rest) = raw.strip_prefix("~/")
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.join(rest).to_string_lossy().into_owned();
|
||||
} else if raw == "~"
|
||||
&& let Some(home) = dirs::home_dir()
|
||||
{
|
||||
return home.to_string_lossy().into_owned();
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Logout - clear API key and return to onboarding
|
||||
@@ -509,16 +618,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_enables_flag() {
|
||||
fn test_trust_on_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"));
|
||||
let result = trust(&mut app, Some("on"));
|
||||
let msg = result.message.expect("message");
|
||||
assert!(msg.contains("Workspace trust mode enabled"));
|
||||
assert!(app.trust_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_status_default_lists_state() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, None);
|
||||
let msg = result.message.expect("status message");
|
||||
assert!(msg.contains("Workspace trust mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_add_requires_path() {
|
||||
let mut app = create_test_app();
|
||||
let result = trust(&mut app, Some("add"));
|
||||
let msg = result.message.expect("error message");
|
||||
assert!(msg.starts_with("Error:"), "got {msg:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logout_clears_api_key_state() {
|
||||
let _lock = lock_test_env();
|
||||
|
||||
@@ -74,7 +74,9 @@ pub fn exit() -> CommandResult {
|
||||
CommandResult::action(AppAction::Quit)
|
||||
}
|
||||
|
||||
/// Switch or view current model
|
||||
/// Switch or view current model. With no argument, open the two-pane
|
||||
/// picker (Pro/Flash + thinking effort) per #39 — gives users a discoverable
|
||||
/// way to flip both knobs without memorising the docs.
|
||||
pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
if let Some(name) = model_name {
|
||||
let Some(model_id) = normalize_model_name(name) else {
|
||||
@@ -93,11 +95,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult {
|
||||
AppAction::UpdateCompaction(app.compaction_config()),
|
||||
)
|
||||
} else {
|
||||
let common = COMMON_DEEPSEEK_MODELS.join(", ");
|
||||
CommandResult::message(format!(
|
||||
"Current model: {}\nUsage: /model <name>\nCommon models: {}\nTip: any valid DeepSeek model ID is accepted. Run /models to fetch live IDs from your API endpoint.",
|
||||
app.model, common
|
||||
))
|
||||
CommandResult::action(AppAction::OpenModelPicker)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,15 +369,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_without_args_shows_info() {
|
||||
fn test_model_without_args_opens_picker() {
|
||||
let mut app = create_test_app();
|
||||
let result = model(&mut app, None);
|
||||
assert!(result.message.is_some());
|
||||
let msg = result.message.unwrap();
|
||||
assert!(msg.contains("Current model:"));
|
||||
assert!(msg.contains("Common models:"));
|
||||
assert!(msg.contains("any valid DeepSeek model ID"));
|
||||
assert!(result.action.is_none());
|
||||
assert_eq!(result.message, None);
|
||||
assert_eq!(result.action, Some(AppAction::OpenModelPicker));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -241,8 +241,8 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "trust",
|
||||
aliases: &[],
|
||||
description: "Enable trust mode (access files outside workspace)",
|
||||
usage: "/trust",
|
||||
description: "Manage workspace trust and per-path allowlist (`/trust add <path>`, `/trust list`, `/trust on|off`)",
|
||||
usage: "/trust [on|off|add <path>|remove <path>|list]",
|
||||
},
|
||||
CommandInfo {
|
||||
name: "logout",
|
||||
@@ -358,7 +358,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
|
||||
"yolo" => config::yolo(app),
|
||||
"agent" => config::agent_mode(app),
|
||||
"plan" => config::plan_mode(app),
|
||||
"trust" => config::trust(app),
|
||||
"trust" => config::trust(app, arg),
|
||||
"logout" => config::logout(app),
|
||||
|
||||
// Debug commands
|
||||
|
||||
@@ -523,30 +523,15 @@ fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usi
|
||||
}
|
||||
|
||||
pub fn estimate_tokens(messages: &[Message]) -> usize {
|
||||
// Rough estimate: ~4 chars per token
|
||||
let current_turn_start = messages.iter().rposition(is_text_user_message);
|
||||
// Rough estimate: ~4 chars per token. DeepSeek thinking-mode rule: any
|
||||
// assistant message with tool_calls keeps its reasoning_content forever
|
||||
// (replayed in all subsequent requests). Final text-only answers drop it.
|
||||
messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, message)| {
|
||||
let include_thinking = current_turn_start.is_some_and(|start| index > start)
|
||||
&& message_has_tool_use(message)
|
||||
&& !has_later_assistant_text(messages, index);
|
||||
estimate_tokens_for_message(message, include_thinking)
|
||||
})
|
||||
.map(|message| estimate_tokens_for_message(message, message_has_tool_use(message)))
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn is_text_user_message(message: &Message) -> bool {
|
||||
message.role == "user"
|
||||
&& message.content.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::Text { text, .. } if !text.trim().is_empty()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn message_has_tool_use(message: &Message) -> bool {
|
||||
message
|
||||
.content
|
||||
@@ -554,23 +539,6 @@ fn message_has_tool_use(message: &Message) -> bool {
|
||||
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
|
||||
}
|
||||
|
||||
fn has_later_assistant_text(messages: &[Message], message_index: usize) -> bool {
|
||||
messages
|
||||
.iter()
|
||||
.skip(message_index.saturating_add(1))
|
||||
.any(is_text_assistant_message)
|
||||
}
|
||||
|
||||
fn is_text_assistant_message(message: &Message) -> bool {
|
||||
message.role == "assistant"
|
||||
&& message.content.iter().any(|block| {
|
||||
matches!(
|
||||
block,
|
||||
ContentBlock::Text { text, .. } if !text.trim().is_empty()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn estimate_text_tokens_conservative(text: &str) -> usize {
|
||||
text.chars().count().div_ceil(3)
|
||||
}
|
||||
@@ -1158,7 +1126,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_tokens_counts_current_tool_round_thinking_only() {
|
||||
fn estimate_tokens_counts_tool_round_thinking_across_turns() {
|
||||
// Per DeepSeek thinking-mode rules, any assistant message that
|
||||
// performed a tool call keeps its reasoning_content in the request
|
||||
// forever, including across new user turns. Token estimates must
|
||||
// count those bytes.
|
||||
let thinking = "reasoning ".repeat(800);
|
||||
let current_messages = vec![
|
||||
Message {
|
||||
@@ -1222,9 +1194,10 @@ mod tests {
|
||||
messages
|
||||
};
|
||||
|
||||
assert!(estimate_tokens(¤t_messages) > thinking.len() / 5);
|
||||
assert!(estimate_tokens(&completed_messages) < thinking.len() / 8);
|
||||
assert!(estimate_tokens(&historical_messages) < thinking.len() / 8);
|
||||
let lower_bound = thinking.len() / 5;
|
||||
assert!(estimate_tokens(¤t_messages) > lower_bound);
|
||||
assert!(estimate_tokens(&completed_messages) > lower_bound);
|
||||
assert!(estimate_tokens(&historical_messages) > lower_bound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2020,6 +2020,11 @@ impl Engine {
|
||||
}
|
||||
|
||||
fn build_tool_context(&self, mode: AppMode, auto_approve: bool) -> ToolContext {
|
||||
// Load the per-workspace trusted-paths list (#29) on every tool-context
|
||||
// build. Cheap (a small JSON file) and always reflects the latest
|
||||
// `/trust add` / `/trust remove` mutations without an explicit cache
|
||||
// refresh hook.
|
||||
let trusted = crate::workspace_trust::WorkspaceTrust::load_for(&self.session.workspace);
|
||||
let ctx = ToolContext::with_auto_approve(
|
||||
self.session.workspace.clone(),
|
||||
self.session.trust_mode,
|
||||
@@ -2029,7 +2034,8 @@ impl Engine {
|
||||
)
|
||||
.with_state_namespace(self.session.id.clone())
|
||||
.with_features(self.config.features.clone())
|
||||
.with_shell_manager(self.shell_manager.clone());
|
||||
.with_shell_manager(self.shell_manager.clone())
|
||||
.with_trusted_external_paths(trusted.paths().to_vec());
|
||||
|
||||
if mode == AppMode::Yolo {
|
||||
ctx.with_elevated_sandbox_policy(crate::sandbox::SandboxPolicy::WorkspaceWrite {
|
||||
@@ -2888,11 +2894,24 @@ impl Engine {
|
||||
// Update turn usage
|
||||
turn.add_usage(&usage);
|
||||
|
||||
// Build content blocks
|
||||
if !current_thinking.is_empty() {
|
||||
content_blocks.push(ContentBlock::Thinking {
|
||||
thinking: current_thinking.clone(),
|
||||
});
|
||||
// Build content blocks. If this assistant turn produced tool
|
||||
// calls, ensure a Thinking block is present even when the model
|
||||
// didn't stream any reasoning text — DeepSeek's thinking-mode
|
||||
// API requires `reasoning_content` to accompany every tool-call
|
||||
// assistant message in the conversation history. Saving a
|
||||
// placeholder here keeps the on-disk session structurally
|
||||
// correct so subsequent requests won't 400.
|
||||
let needs_thinking_block =
|
||||
!tool_uses.is_empty() || tool_parser::has_tool_call_markers(¤t_text_raw);
|
||||
let thinking_to_persist = if !current_thinking.is_empty() {
|
||||
Some(current_thinking.clone())
|
||||
} else if needs_thinking_block {
|
||||
Some(String::from("(reasoning omitted)"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(thinking) = thinking_to_persist {
|
||||
content_blocks.push(ContentBlock::Thinking { thinking });
|
||||
}
|
||||
let mut final_text = current_text_visible.clone();
|
||||
if tool_uses.is_empty() && tool_parser::has_tool_call_markers(¤t_text_raw) {
|
||||
|
||||
+20
-4
@@ -51,6 +51,7 @@ mod tui;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod working_set;
|
||||
mod workspace_trust;
|
||||
|
||||
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
|
||||
use crate::eval::{EvalHarness, EvalHarnessConfig, ScenarioStepKind};
|
||||
@@ -1118,10 +1119,25 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
" {} api_key: set via config",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
),
|
||||
ApiKeySource::Missing => println!(
|
||||
" {} api_key: missing (set DEEPSEEK_API_KEY or run `deepseek login`)",
|
||||
"✗".truecolor(red_r, red_g, red_b)
|
||||
),
|
||||
ApiKeySource::Missing => {
|
||||
let (env_var, login_hint) = match config.api_provider() {
|
||||
crate::config::ApiProvider::NvidiaNim => (
|
||||
"NVIDIA_API_KEY",
|
||||
"deepseek auth set --provider nvidia-nim --api-key \"...\"",
|
||||
),
|
||||
crate::config::ApiProvider::Deepseek => {
|
||||
("DEEPSEEK_API_KEY", "deepseek login --api-key \"...\"")
|
||||
}
|
||||
};
|
||||
println!(
|
||||
" {} api_key: missing (set {env_var} or `[providers.{}].api_key` in ~/.deepseek/config.toml; or run `{login_hint}`)",
|
||||
"✗".truecolor(red_r, red_g, red_b),
|
||||
match config.api_provider() {
|
||||
crate::config::ApiProvider::NvidiaNim => "nvidia_nim",
|
||||
crate::config::ApiProvider::Deepseek => "deepseek",
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" · base_url: {}",
|
||||
|
||||
@@ -191,6 +191,12 @@ pub struct Usage {
|
||||
pub prompt_cache_miss_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_tokens: Option<u32>,
|
||||
/// Approximate input tokens spent re-sending prior `reasoning_content`
|
||||
/// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling
|
||||
/// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at
|
||||
/// ~4 chars/token from the outgoing request body, before the model sees it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_replay_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server_tool_use: Option<ServerToolUsage>,
|
||||
}
|
||||
@@ -268,6 +274,49 @@ pub fn compaction_threshold_for_model(model: &str) -> usize {
|
||||
usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD)
|
||||
}
|
||||
|
||||
/// Mode-aware soft context caps for V4 models.
|
||||
///
|
||||
/// DeepSeek V4 paper Figure 9 shows retrieval quality (MRCR MMR) collapses as
|
||||
/// context grows: 0.90 at 8K, 0.94 at 32K, 0.92 at 128K, 0.66 at 512K, 0.49
|
||||
/// at 1M. The paper's own eval harness uses budget tiers per §5.3.1:
|
||||
///
|
||||
/// | Mode / Reasoning tier | Soft cap | Paper eval window |
|
||||
/// |-----------------------|----------|-------------------|
|
||||
/// | Plan / Non-Think (off) | 64,000 | 8K-128K |
|
||||
/// | Agent / High | 192,000 | 128K |
|
||||
/// | YOLO / Max | 384,000 | 384K-512K |
|
||||
///
|
||||
/// These caps keep the agent inside the regime DeepSeek tuned for, triggering
|
||||
/// compaction *before* recall quality degrades. The 1M hard ceiling remains —
|
||||
/// users can override via config or by declining the /compact suggestion.
|
||||
pub const V4_PLAN_SOFT_CAP: usize = 64_000;
|
||||
pub const V4_AGENT_SOFT_CAP: usize = 192_000;
|
||||
pub const V4_YOLO_SOFT_CAP: usize = 384_000;
|
||||
|
||||
/// Compaction threshold keyed by model and caller-supplied effort tier.
|
||||
///
|
||||
/// For V4-family models the threshold is a mode-aware soft cap (see constants
|
||||
/// above). For all other models the legacy 80%-of-window rule applies.
|
||||
#[must_use]
|
||||
pub fn compaction_threshold_for_model_and_effort(
|
||||
model: &str,
|
||||
reasoning_effort: Option<&str>,
|
||||
) -> usize {
|
||||
let lower = model.to_lowercase();
|
||||
if !lower.contains("deepseek")
|
||||
|| !(lower.contains("v4") || is_current_deepseek_v4_alias(&lower))
|
||||
{
|
||||
return compaction_threshold_for_model(model);
|
||||
}
|
||||
|
||||
match reasoning_effort.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some("off" | "disabled" | "none" | "false") => V4_PLAN_SOFT_CAP,
|
||||
Some("low" | "medium" | "high") => V4_AGENT_SOFT_CAP,
|
||||
Some("max" | "maximum" | "xhigh") => V4_YOLO_SOFT_CAP,
|
||||
_ => V4_AGENT_SOFT_CAP,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a compaction message-count threshold from model context window.
|
||||
#[must_use]
|
||||
pub fn compaction_message_threshold_for_model(model: &str) -> usize {
|
||||
@@ -446,12 +495,50 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn compaction_scales_for_deepseek_v4_1m_context() {
|
||||
// 80% of 1M = 800k tokens before token-based compaction.
|
||||
assert_eq!(compaction_threshold_for_model("deepseek-v4-pro"), 800_000);
|
||||
// 1M / 500 = 2k messages before message-count compaction.
|
||||
assert_eq!(
|
||||
compaction_message_threshold_for_model("deepseek-v4-pro"),
|
||||
2_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_mode_aware_soft_caps() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("off")),
|
||||
V4_PLAN_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("high")),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("max")),
|
||||
V4_YOLO_SOFT_CAP
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_soft_caps_only_apply_to_v4_models() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v3.2-128k", Some("max")),
|
||||
102_400
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("unknown-model", Some("max")),
|
||||
50_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_soft_cap_defaults_to_agent_when_effort_unknown() {
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", None),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
assert_eq!(
|
||||
compaction_threshold_for_model_and_effort("deepseek-v4-pro", Some("unknown")),
|
||||
V4_AGENT_SOFT_CAP
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! Pricing based on DeepSeek's published rates (per million tokens).
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
use crate::models::Usage;
|
||||
|
||||
/// Per-million-token pricing for a model.
|
||||
@@ -11,8 +13,18 @@ struct ModelPricing {
|
||||
output_per_million: f64,
|
||||
}
|
||||
|
||||
fn v4_pro_discount_ends_at() -> DateTime<Utc> {
|
||||
Utc.with_ymd_and_hms(2026, 5, 5, 15, 59, 0)
|
||||
.single()
|
||||
.expect("valid DeepSeek V4 Pro discount end timestamp")
|
||||
}
|
||||
|
||||
/// Look up pricing for a model name.
|
||||
fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
||||
pricing_for_model_at(model, Utc::now())
|
||||
}
|
||||
|
||||
fn pricing_for_model_at(model: &str, now: DateTime<Utc>) -> Option<ModelPricing> {
|
||||
let lower = model.to_lowercase();
|
||||
if lower.starts_with("deepseek-ai/") {
|
||||
// NVIDIA NIM-hosted DeepSeek uses NVIDIA's catalog/account terms, not
|
||||
@@ -23,6 +35,15 @@ fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
||||
return None;
|
||||
}
|
||||
if lower.contains("v4-pro") || lower.contains("v4pro") {
|
||||
if now <= v4_pro_discount_ends_at() {
|
||||
// DeepSeek lists these as a limited-time 75% discount through
|
||||
// 2026-05-05 15:59 UTC.
|
||||
return Some(ModelPricing {
|
||||
input_cache_hit_per_million: 0.03625,
|
||||
input_cache_miss_per_million: 0.435,
|
||||
output_per_million: 0.87,
|
||||
});
|
||||
}
|
||||
Some(ModelPricing {
|
||||
input_cache_hit_per_million: 0.145,
|
||||
input_cache_miss_per_million: 1.74,
|
||||
@@ -44,15 +65,31 @@ fn pricing_for_model(model: &str) -> Option<ModelPricing> {
|
||||
#[allow(dead_code)]
|
||||
pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
|
||||
let pricing = pricing_for_model(model)?;
|
||||
Some(calculate_turn_cost_with_pricing(
|
||||
pricing,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
))
|
||||
}
|
||||
|
||||
fn calculate_turn_cost_with_pricing(
|
||||
pricing: ModelPricing,
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
) -> f64 {
|
||||
let input_cost = (input_tokens as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million;
|
||||
let output_cost = (output_tokens as f64 / 1_000_000.0) * pricing.output_per_million;
|
||||
Some(input_cost + output_cost)
|
||||
input_cost + output_cost
|
||||
}
|
||||
|
||||
/// Calculate cost from provider usage, honoring DeepSeek context-cache fields.
|
||||
#[must_use]
|
||||
pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64> {
|
||||
let pricing = pricing_for_model(model)?;
|
||||
Some(calculate_turn_cost_from_usage_with_pricing(pricing, usage))
|
||||
}
|
||||
|
||||
fn calculate_turn_cost_from_usage_with_pricing(pricing: ModelPricing, usage: &Usage) -> f64 {
|
||||
let hit_tokens = usage.prompt_cache_hit_tokens.unwrap_or(0);
|
||||
let miss_tokens = usage
|
||||
.prompt_cache_miss_tokens
|
||||
@@ -64,7 +101,7 @@ pub fn calculate_turn_cost_from_usage(model: &str, usage: &Usage) -> Option<f64>
|
||||
let miss_cost = ((miss_tokens.saturating_add(uncategorized_input)) as f64 / 1_000_000.0)
|
||||
* pricing.input_cache_miss_per_million;
|
||||
let output_cost = (usage.output_tokens as f64 / 1_000_000.0) * pricing.output_per_million;
|
||||
Some(hit_cost + miss_cost + output_cost)
|
||||
hit_cost + miss_cost + output_cost
|
||||
}
|
||||
|
||||
/// Format a USD cost for compact display.
|
||||
@@ -90,4 +127,37 @@ mod tests {
|
||||
fn nvidia_nim_deepseek_model_does_not_use_deepseek_platform_pricing() {
|
||||
assert!(calculate_turn_cost("deepseek-ai/deepseek-v4-pro", 1_000, 1_000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_pro_uses_limited_time_discount_before_expiry() {
|
||||
let before_expiry = Utc
|
||||
.with_ymd_and_hms(2026, 5, 5, 15, 58, 59)
|
||||
.single()
|
||||
.unwrap();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", before_expiry).unwrap();
|
||||
|
||||
assert_eq!(pricing.input_cache_hit_per_million, 0.03625);
|
||||
assert_eq!(pricing.input_cache_miss_per_million, 0.435);
|
||||
assert_eq!(pricing.output_per_million, 0.87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_pro_returns_to_base_rates_after_discount_expiry() {
|
||||
let after_expiry = Utc.with_ymd_and_hms(2026, 5, 5, 16, 0, 0).single().unwrap();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-pro", after_expiry).unwrap();
|
||||
|
||||
assert_eq!(pricing.input_cache_hit_per_million, 0.145);
|
||||
assert_eq!(pricing.input_cache_miss_per_million, 1.74);
|
||||
assert_eq!(pricing.output_per_million, 3.48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_flash_keeps_current_published_rates() {
|
||||
let now = Utc.with_ymd_and_hms(2026, 4, 25, 0, 0, 0).single().unwrap();
|
||||
let pricing = pricing_for_model_at("deepseek-v4-flash", now).unwrap();
|
||||
|
||||
assert_eq!(pricing.input_cache_hit_per_million, 0.028);
|
||||
assert_eq!(pricing.input_cache_miss_per_million, 0.14);
|
||||
assert_eq!(pricing.output_per_million, 0.28);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,28 @@ use crate::project_context::{ProjectContext, load_project_context_with_parents};
|
||||
use crate::tui::app::AppMode;
|
||||
use std::path::Path;
|
||||
|
||||
/// Conventional location for the structured session-handoff artifact (#32).
|
||||
/// A previous session writes it on exit / `/compact`; the next session reads
|
||||
/// it back on startup and prepends it to the system prompt so a fresh agent
|
||||
/// doesn't have to re-discover open blockers from scratch.
|
||||
pub const HANDOFF_RELATIVE_PATH: &str = ".deepseek/handoff.md";
|
||||
|
||||
/// Read the workspace-local handoff artifact, if present, and format it as a
|
||||
/// system-prompt block. Returns `None` when the file is absent or empty so
|
||||
/// callers can keep the default-uncluttered prompt for fresh workspaces.
|
||||
fn load_handoff_block(workspace: &Path) -> Option<String> {
|
||||
let path = workspace.join(HANDOFF_RELATIVE_PATH);
|
||||
let raw = std::fs::read_to_string(&path).ok()?;
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"## Previous Session Handoff\n\nThe previous session in this workspace left a handoff at `{}`. Consider it the first artifact to read on this turn — open blockers, in-flight changes, and recent decisions live there. Update or rewrite it before exiting if state changes materially.\n\n{}",
|
||||
HANDOFF_RELATIVE_PATH, trimmed
|
||||
))
|
||||
}
|
||||
|
||||
// Prompt files loaded at compile time
|
||||
pub const BASE_PROMPT: &str = include_str!("prompts/base.txt");
|
||||
#[allow(dead_code)]
|
||||
@@ -64,6 +86,10 @@ pub fn system_prompt_for_mode_with_context(
|
||||
full_prompt = format!("{full_prompt}\n\n{summary}");
|
||||
}
|
||||
|
||||
if let Some(handoff_block) = load_handoff_block(workspace) {
|
||||
full_prompt = format!("{full_prompt}\n\n{handoff_block}");
|
||||
}
|
||||
|
||||
// Add compaction instruction for agent modes
|
||||
if matches!(mode, AppMode::Agent | AppMode::Yolo) {
|
||||
full_prompt.push_str(
|
||||
@@ -113,6 +139,7 @@ pub fn plan_system_prompt() -> SystemPrompt {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn plan_prompt_prefers_best_effort_plans_over_clarifying_loops() {
|
||||
@@ -127,4 +154,53 @@ mod tests {
|
||||
assert!(prompt.contains("Do not ask clarifying questions for straightforward requests"));
|
||||
assert!(prompt.contains("If the user asks for \"a 3-step plan\""));
|
||||
}
|
||||
|
||||
/// Discriminator unique to the injected handoff block (not present in the
|
||||
/// agent prompt's own discussion of the convention).
|
||||
const HANDOFF_BLOCK_MARKER: &str = "left a handoff at `.deepseek/handoff.md`";
|
||||
|
||||
#[test]
|
||||
fn handoff_artifact_is_prepended_to_system_prompt_when_present() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let workspace = tmp.path();
|
||||
let handoff_dir = workspace.join(".deepseek");
|
||||
std::fs::create_dir_all(&handoff_dir).unwrap();
|
||||
std::fs::write(
|
||||
handoff_dir.join("handoff.md"),
|
||||
"# Session handoff — prior\n\n## Active task\nFinish #32.\n\n## Open blockers\n- [ ] write the basic version\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let prompt = match system_prompt_for_mode_with_context(AppMode::Agent, workspace, None) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
|
||||
assert!(prompt.contains(HANDOFF_BLOCK_MARKER));
|
||||
assert!(prompt.contains("Finish #32."));
|
||||
assert!(prompt.contains("write the basic version"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_handoff_does_not_inject_block() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let prompt = match system_prompt_for_mode_with_context(AppMode::Agent, tmp.path(), None) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
assert!(!prompt.contains(HANDOFF_BLOCK_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_handoff_file_does_not_inject_block() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let dir = tmp.path().join(".deepseek");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("handoff.md"), " \n\n ").unwrap();
|
||||
let prompt = match system_prompt_for_mode_with_context(AppMode::Agent, tmp.path(), None) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
assert!(!prompt.contains(HANDOFF_BLOCK_MARKER));
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,11 @@ 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.run when local context is insufficient or time-sensitive, and cite sources as [cite:ref_id].
|
||||
- Use web_search when local context is insufficient or time-sensitive, and cite sources as (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).
|
||||
- Use web_search when info might have changed or you are unsure.
|
||||
- Cite non-trivial factual claims using (ref_id) (the ref_id returned by web_search).
|
||||
- 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.
|
||||
@@ -33,18 +33,27 @@ Testing and stop conditions:
|
||||
Step budgeting:
|
||||
- Budget attempts. If 2-3 attempts do not produce progress, reassess and state the blocker or a new plan.
|
||||
|
||||
Session handoff (`.deepseek/handoff.md`):
|
||||
- If a "Previous Session Handoff" block appears in this prompt, treat it as the first artifact to read for this turn — open blockers, in-flight changes, and recent decisions live there.
|
||||
- Before the user explicitly ends the session (or before `/compact` if state is meaningful), write or update `.deepseek/handoff.md` via `write_file`. Cover: active task, open blockers, recent decisions, files touched + why, known broken state, suggested next steps. Keep it short — it's a hand-off, not a transcript.
|
||||
|
||||
Available tools:
|
||||
|
||||
FILE OPERATIONS:
|
||||
- list_dir: List directory contents
|
||||
- read_file: Read file contents
|
||||
- write_file: Create or overwrite a file
|
||||
- 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.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
|
||||
FILE OPERATIONS (prefer these over `exec_shell` equivalents — they return structured output):
|
||||
- read_file: Read a file. PDFs are auto-extracted via pdftotext; pass `pages: "1-5"` to slice.
|
||||
- list_dir: List directory contents (structured, gitignore-aware).
|
||||
- write_file: Create or overwrite a file.
|
||||
- edit_file: Search-and-replace inside a single file. Cheaper than rewriting.
|
||||
- apply_patch: Apply a unified diff patch — the right tool for multi-hunk edits.
|
||||
|
||||
SEARCH:
|
||||
- grep_files: Regex search file contents within the workspace; returns matches + context lines.
|
||||
- file_search: Fuzzy-match filenames (NOT contents). Use to locate a file when you know roughly the name.
|
||||
- web_search: DuckDuckGo/Bing search; returns ranked snippets with ref_ids for citation.
|
||||
- fetch_url: Direct HTTP GET on a known URL (faster than web_search when the link is already known). HTML is stripped to text by default.
|
||||
|
||||
USER:
|
||||
- request_user_input: Ask the user a short multiple-choice question.
|
||||
|
||||
PARALLEL TOOL USE:
|
||||
- Issue independent tool calls in parallel by emitting multiple tool_calls in one assistant turn (the model API supports this natively). Do not wrap them in any meta-tool or pseudo-XML.
|
||||
@@ -80,21 +89,18 @@ TASK MANAGEMENT:
|
||||
- note: Record important information
|
||||
|
||||
SUB-AGENTS:
|
||||
- spawn_agent: Spawn a background sub-agent (agent_type, message/items)
|
||||
- agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools)
|
||||
- spawn_agents_on_csv: Batch-process CSV rows with one worker sub-agent per row
|
||||
- report_agent_job_result: Worker-only job row report tool for spawn_agents_on_csv
|
||||
- agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context)
|
||||
- swarm_status: Check status for a previously started swarm (swarm_id)
|
||||
- swarm_result: Get full results for a previously started swarm (swarm_id, optional block/timeout)
|
||||
- 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/items, interrupt)
|
||||
- agent_assign / assign_agent: Update assignment objective/role and optionally push immediate guidance
|
||||
- wait: Wait for one or more sub-agents to complete (ids optional, wait_mode:any|all, timeout_ms)
|
||||
- agent_cancel: Cancel a running sub-agent (agent_id)
|
||||
- close_agent: Close a running sub-agent (alias for cancel)
|
||||
- resume_agent: Resume a previously closed/completed sub-agent
|
||||
- agent_list: List all sub-agents and their status
|
||||
- agent_spawn: Spawn a background sub-agent (type, prompt, allowed_tools).
|
||||
- spawn_agents_on_csv: Batch-process CSV rows with one worker sub-agent per row.
|
||||
- report_agent_job_result: Worker-only job row report tool for spawn_agents_on_csv.
|
||||
- agent_swarm: Spawn a dependency-aware swarm of sub-agents (tasks, shared_context).
|
||||
- swarm_status / swarm_result: Inspect a swarm by swarm_id (status; or full results, with optional block/timeout).
|
||||
- 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/items, interrupt).
|
||||
- agent_assign: Update assignment objective/role and optionally push immediate guidance.
|
||||
- wait: Wait for one or more sub-agents to complete (ids optional, wait_mode:any|all, timeout_ms).
|
||||
- agent_cancel: Cancel a running sub-agent (agent_id).
|
||||
- resume_agent: Resume a previously closed/completed sub-agent.
|
||||
- agent_list: List all sub-agents and their status.
|
||||
Delegation protocol:
|
||||
- Delegate only bounded, parallelizable work with a clear input, expected output, and tool limits.
|
||||
- Prefer multiple sub-agents for independent steps to maximize parallelism.
|
||||
|
||||
@@ -12,7 +12,7 @@ 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.run for time-sensitive or uncertain facts; include citations as [cite:ref_id].
|
||||
- Use web_search for time-sensitive or uncertain facts; include citations as (ref_id).
|
||||
- Issue independent tool calls in parallel (emit multiple tool_calls in a single turn) instead of serializing them.
|
||||
- Use request_user_input to ask short multiple-choice questions when needed.
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.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
|
||||
|
||||
@@ -49,7 +48,7 @@ Tool selection guidance:
|
||||
- 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].
|
||||
- When you need up-to-date or uncertain info, use web_search and cite sources as (ref_id).
|
||||
|
||||
Testing and stop conditions (after approval to edit/run commands):
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
|
||||
@@ -27,7 +27,6 @@ EXPLORATION:
|
||||
- list_dir: Browse directories in the workspace
|
||||
- read_file: Read file contents to understand context
|
||||
- grep_files: Search files by regex
|
||||
- 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
|
||||
|
||||
@@ -47,7 +46,7 @@ PARALLEL TOOL USE:
|
||||
Guidelines:
|
||||
- Prefer tool-centric planning for complex or implementation-grounded requests: use grep_files/list_dir/read_file to ground the plan in the actual codebase when that grounding materially improves the plan.
|
||||
- Do not explore the repo just to produce a straightforward quick/high-level plan.
|
||||
- Use web.run for time-sensitive or uncertain facts, and cite sources as [cite:ref_id].
|
||||
- Use web_search for time-sensitive or uncertain facts, and cite sources as (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.
|
||||
|
||||
@@ -15,11 +15,11 @@ 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.run when local context is insufficient or time-sensitive, and cite sources as [cite:ref_id].
|
||||
- Use web_search when local context is insufficient or time-sensitive, and cite sources as (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).
|
||||
- Use web_search when info might have changed or you are unsure.
|
||||
- Cite non-trivial factual claims using (ref_id) (the ref_id returned by web_search).
|
||||
- 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.
|
||||
@@ -42,7 +42,6 @@ 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.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
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ struct DiagnosticsOutput {
|
||||
sandbox_type: Option<String>,
|
||||
rustc_version: Option<String>,
|
||||
cargo_version: Option<String>,
|
||||
/// User-trusted external paths the agent may access from this workspace
|
||||
/// (`/trust add <path>` from the slash command, persisted in
|
||||
/// `~/.deepseek/workspace-trust.json`). See issue #29.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
trusted_external_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -81,6 +86,11 @@ impl ToolSpec for DiagnosticsTool {
|
||||
let sandbox_type = crate::sandbox::get_platform_sandbox().map(|s| s.to_string());
|
||||
let sandbox_available = sandbox_type.is_some();
|
||||
|
||||
let trusted_external_paths = context
|
||||
.trusted_external_paths
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect();
|
||||
let diagnostics = DiagnosticsOutput {
|
||||
workspace_root,
|
||||
current_dir,
|
||||
@@ -92,6 +102,7 @@ impl ToolSpec for DiagnosticsTool {
|
||||
sandbox_type,
|
||||
rustc_version: probe_version("rustc", &["--version"], &context.workspace),
|
||||
cargo_version: probe_version("cargo", &["--version"], &context.workspace),
|
||||
trusted_external_paths,
|
||||
};
|
||||
|
||||
ToolResult::json(&diagnostics).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user
|
||||
//! already knows the URL — a known repo, a blog post, a spec page — and
|
||||
//! search is overkill or actively unhelpful.
|
||||
//!
|
||||
//! Returns a structured `{url, status, content_type, content, truncated}`
|
||||
//! payload. HTML responses are stripped to readable text by default
|
||||
//! (`format = "markdown"`); pass `format = "raw"` to keep the bytes intact
|
||||
//! when the model wants to do its own parsing.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_MAX_BYTES: u64 = 1_000_000;
|
||||
const HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 15_000;
|
||||
const HARD_MAX_TIMEOUT_MS: u64 = 60_000;
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
const USER_AGENT: &str =
|
||||
"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)";
|
||||
|
||||
static SCRIPT_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static STYLE_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static TAG_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static WHITESPACE_RE: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
fn script_re() -> &'static Regex {
|
||||
SCRIPT_RE.get_or_init(|| Regex::new(r"(?is)<script[^>]*>.*?</script>").expect("script re"))
|
||||
}
|
||||
fn style_re() -> &'static Regex {
|
||||
STYLE_RE.get_or_init(|| Regex::new(r"(?is)<style[^>]*>.*?</style>").expect("style re"))
|
||||
}
|
||||
fn tag_re() -> &'static Regex {
|
||||
TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag re"))
|
||||
}
|
||||
fn whitespace_re() -> &'static Regex {
|
||||
WHITESPACE_RE.get_or_init(|| Regex::new(r"\s+").expect("ws re"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Format {
|
||||
Text,
|
||||
Markdown,
|
||||
Raw,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
fn parse(value: Option<&str>) -> Result<Self, ToolError> {
|
||||
match value
|
||||
.unwrap_or("markdown")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"text" | "txt" | "plain" => Ok(Self::Text),
|
||||
"markdown" | "md" => Ok(Self::Markdown),
|
||||
"raw" | "html" | "bytes" => Ok(Self::Raw),
|
||||
other => Err(ToolError::invalid_input(format!(
|
||||
"unknown format `{other}` (allowed: text, markdown, raw)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FetchResponse {
|
||||
url: String,
|
||||
status: u16,
|
||||
content_type: String,
|
||||
content: String,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
pub struct FetchUrlTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for FetchUrlTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"fetch_url"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Absolute HTTP/HTTPS URL to fetch."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["text", "markdown", "raw"],
|
||||
"description": "Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is."
|
||||
},
|
||||
"max_bytes": {
|
||||
"type": "integer",
|
||||
"description": "Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760)."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Request timeout in milliseconds (default 15,000; max 60,000)."
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Network]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let url = input
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| ToolError::invalid_input("`url` is required"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if url.is_empty() {
|
||||
return Err(ToolError::invalid_input("`url` cannot be empty"));
|
||||
}
|
||||
let scheme_ok = url.starts_with("http://") || url.starts_with("https://");
|
||||
if !scheme_ok {
|
||||
return Err(ToolError::invalid_input(
|
||||
"only http:// and https:// URLs are supported",
|
||||
));
|
||||
}
|
||||
|
||||
let format = Format::parse(input.get("format").and_then(Value::as_str))?;
|
||||
let max_bytes = optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);
|
||||
let timeout_ms =
|
||||
optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.user_agent(USER_AGENT)
|
||||
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("failed to build HTTP client: {e}"))
|
||||
})?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "text/html,text/plain,application/json,*/*;q=0.5")
|
||||
.header("Accept-Language", "en-US,en;q=0.5")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("request failed: {e}")))?;
|
||||
|
||||
let final_url = resp.url().to_string();
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("failed to read body: {e}")))?;
|
||||
let total_bytes = bytes.len() as u64;
|
||||
let truncated = total_bytes > max_bytes;
|
||||
let usable = if truncated {
|
||||
&bytes[..max_bytes as usize]
|
||||
} else {
|
||||
&bytes[..]
|
||||
};
|
||||
|
||||
let body_text = String::from_utf8_lossy(usable).to_string();
|
||||
let processed = match format {
|
||||
Format::Raw => body_text,
|
||||
Format::Text | Format::Markdown => {
|
||||
if content_type.contains("text/html") || body_text.contains("<html") {
|
||||
html_to_text(&body_text)
|
||||
} else {
|
||||
body_text
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let response = FetchResponse {
|
||||
url: final_url,
|
||||
status: status.as_u16(),
|
||||
content_type,
|
||||
content: processed,
|
||||
truncated,
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
// Don't `Err` on 4xx/5xx — the caller often wants to see the body
|
||||
// (e.g. a JSON error envelope). Mark the result as a failure so the
|
||||
// engine renders it as such.
|
||||
return Ok(ToolResult {
|
||||
content: serde_json::to_string_pretty(&response).map_err(|e| {
|
||||
ToolError::execution_failed(format!("failed to serialize response: {e}"))
|
||||
})?,
|
||||
success: false,
|
||||
metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
ToolResult::json(&response)
|
||||
.map_err(|e| ToolError::execution_failed(format!("failed to serialize response: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip `<script>` / `<style>` blocks, drop remaining tags, and collapse
|
||||
/// whitespace. Good enough for "let the model read this page" — not a full
|
||||
/// HTML-to-Markdown converter.
|
||||
fn html_to_text(html: &str) -> String {
|
||||
let no_script = script_re().replace_all(html, "");
|
||||
let no_style = style_re().replace_all(&no_script, "");
|
||||
let no_tags = tag_re().replace_all(&no_style, " ");
|
||||
let decoded = decode_entities(&no_tags);
|
||||
whitespace_re()
|
||||
.replace_all(&decoded, " ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Decode the handful of HTML entities we expect to hit in stripped text.
|
||||
/// Pulling in `html-escape` for the long tail isn't worth the dep weight.
|
||||
fn decode_entities(s: &str) -> String {
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::spec::ToolContext;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn ctx() -> ToolContext {
|
||||
ToolContext::new(PathBuf::from("."))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_to_text_strips_scripts_styles_and_tags() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<style>body { color: red; }</style>
|
||||
<script>alert("nope");</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello & welcome</h1>
|
||||
<p>This is <b>important</b>.</p>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
let text = html_to_text(html);
|
||||
assert!(text.contains("Hello & welcome"));
|
||||
assert!(text.contains("This is important"));
|
||||
assert!(!text.contains("alert"));
|
||||
assert!(!text.contains("color: red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_parse_accepts_aliases_and_rejects_unknown() {
|
||||
assert_eq!(Format::parse(Some("markdown")).unwrap(), Format::Markdown);
|
||||
assert_eq!(Format::parse(Some("MD")).unwrap(), Format::Markdown);
|
||||
assert_eq!(Format::parse(Some("text")).unwrap(), Format::Text);
|
||||
assert_eq!(Format::parse(Some("raw")).unwrap(), Format::Raw);
|
||||
assert_eq!(Format::parse(None).unwrap(), Format::Markdown);
|
||||
assert!(Format::parse(Some("yaml")).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_non_http_schemes() {
|
||||
let tool = FetchUrlTool;
|
||||
let res = tool
|
||||
.execute(json!({"url": "file:///etc/passwd"}), &ctx())
|
||||
.await;
|
||||
let err = res.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("http"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_url() {
|
||||
let tool = FetchUrlTool;
|
||||
let res = tool.execute(json!({"url": " "}), &ctx()).await;
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_url() {
|
||||
let tool = FetchUrlTool;
|
||||
let res = tool.execute(json!({}), &ctx()).await;
|
||||
assert!(res.is_err());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use super::spec::{
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
// === ReadFileTool ===
|
||||
|
||||
@@ -23,7 +25,7 @@ impl ToolSpec for ReadFileTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Read a UTF-8 file from the workspace."
|
||||
"Read a file from the workspace. Plain text is returned as-is; PDFs are auto-extracted via `pdftotext` (poppler) when available."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -33,6 +35,10 @@ impl ToolSpec for ReadFileTool {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file (relative to workspace or absolute)"
|
||||
},
|
||||
"pages": {
|
||||
"type": "string",
|
||||
"description": "PDF only: page range to extract, e.g. \"1-5\" or \"10\". Ignored for non-PDF files."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
@@ -50,6 +56,11 @@ impl ToolSpec for ReadFileTool {
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let path_str = required_str(&input, "path")?;
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let pages = optional_str(&input, "pages");
|
||||
|
||||
if is_pdf(&file_path)? {
|
||||
return read_pdf(&file_path, pages);
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&file_path).map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e))
|
||||
@@ -59,6 +70,114 @@ impl ToolSpec for ReadFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a PDF by extension OR by sniffing the `%PDF-` magic bytes.
|
||||
/// Files without an extension are still recognized as PDFs when the header
|
||||
/// matches.
|
||||
fn is_pdf(path: &Path) -> Result<bool, ToolError> {
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
// Sniff first 4 bytes. Don't error if the file doesn't exist — let the
|
||||
// caller's `read_to_string` produce the canonical not-found error.
|
||||
let mut buf = [0u8; 4];
|
||||
let result = match fs::File::open(path) {
|
||||
Ok(mut f) => {
|
||||
use std::io::Read;
|
||||
f.read_exact(&mut buf).map(|_| buf)
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
Ok(matches!(result, Ok(b) if &b == b"%PDF"))
|
||||
}
|
||||
|
||||
fn parse_pages_arg(spec: &str) -> Option<(u32, u32)> {
|
||||
let trimmed = spec.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some((a, b)) = trimmed.split_once('-') {
|
||||
let start: u32 = a.trim().parse().ok()?;
|
||||
let end: u32 = b.trim().parse().ok()?;
|
||||
if start == 0 || end < start {
|
||||
return None;
|
||||
}
|
||||
Some((start, end))
|
||||
} else {
|
||||
let n: u32 = trimmed.parse().ok()?;
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((n, n))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_pdf(path: &Path, pages: Option<&str>) -> Result<ToolResult, ToolError> {
|
||||
// Try pdftotext (from the poppler suite). Other extractors (mutool,
|
||||
// pdfminer) could be added later behind the same dispatch.
|
||||
let mut cmd = Command::new("pdftotext");
|
||||
cmd.arg("-layout");
|
||||
|
||||
if let Some(spec) = pages {
|
||||
match parse_pages_arg(spec) {
|
||||
Some((start, end)) => {
|
||||
cmd.arg("-f").arg(start.to_string());
|
||||
cmd.arg("-l").arg(end.to_string());
|
||||
}
|
||||
None => {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"invalid `pages` value `{spec}` (expected `N` or `N-M`, e.g. `1-5`)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(path).arg("-"); // output to stdout
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Structured "binary unavailable" — caller knows what to suggest.
|
||||
return ToolResult::json(&json!({
|
||||
"type": "binary_unavailable",
|
||||
"path": path.display().to_string(),
|
||||
"kind": "pdf",
|
||||
"reason": "pdftotext not installed",
|
||||
"hint": "install poppler (macOS: `brew install poppler`; Debian/Ubuntu: `apt install poppler-utils`)"
|
||||
}))
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("failed to serialize response: {e}"))
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"failed to launch pdftotext: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| ToolError::execution_failed(format!("pdftotext failed to complete: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"pdftotext failed (exit {:?}): {stderr}",
|
||||
output.status.code()
|
||||
)));
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(ToolResult::success(text))
|
||||
}
|
||||
|
||||
// === WriteFileTool ===
|
||||
|
||||
/// Tool for writing UTF-8 files to the workspace.
|
||||
@@ -330,6 +449,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_detected_by_extension() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("paper.PDF");
|
||||
fs::write(&path, b"not really a pdf, but extension says yes").unwrap();
|
||||
assert!(is_pdf(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_detected_by_magic_bytes_without_extension() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("blob");
|
||||
fs::write(&path, b"%PDF-1.7\nrest of bytes").unwrap();
|
||||
assert!(is_pdf(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_pdf_not_detected() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("notes.txt");
|
||||
fs::write(&path, "hello").unwrap();
|
||||
assert!(!is_pdf(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_arg_parses_single_and_range() {
|
||||
assert_eq!(parse_pages_arg("5"), Some((5, 5)));
|
||||
assert_eq!(parse_pages_arg("1-10"), Some((1, 10)));
|
||||
assert_eq!(parse_pages_arg(" 3 - 7 "), Some((3, 7)));
|
||||
assert_eq!(parse_pages_arg("0"), None);
|
||||
assert_eq!(parse_pages_arg("10-3"), None);
|
||||
assert_eq!(parse_pages_arg(""), None);
|
||||
assert_eq!(parse_pages_arg("abc"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_returns_binary_unavailable_when_pdftotext_missing() {
|
||||
// We can't reliably remove pdftotext from $PATH in a test, but if
|
||||
// it's missing on the runner this test exercises that branch. If
|
||||
// it's installed, the test exits early — covered by the parse_pages
|
||||
// and is_pdf tests above.
|
||||
if Command::new("pdftotext")
|
||||
.arg("-v")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("doc.pdf");
|
||||
fs::write(&path, b"%PDF-1.7\n%%EOF").unwrap();
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let result = ReadFileTool
|
||||
.execute(json!({"path": "doc.pdf"}), &ctx)
|
||||
.await
|
||||
.expect("structured response, not error");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("binary_unavailable"));
|
||||
assert!(result.content.contains("pdftotext"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_file_tool() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod file;
|
||||
pub mod file_search;
|
||||
pub mod finance;
|
||||
|
||||
pub mod fetch_url;
|
||||
pub mod git;
|
||||
pub mod git_history;
|
||||
pub mod parallel;
|
||||
|
||||
@@ -345,10 +345,12 @@ impl ToolRegistryBuilder {
|
||||
/// Include web search tools.
|
||||
#[must_use]
|
||||
pub fn with_web_tools(self) -> Self {
|
||||
use super::fetch_url::FetchUrlTool;
|
||||
use super::finance::FinanceTool;
|
||||
use super::web_run::WebRunTool;
|
||||
use super::web_search::WebSearchTool;
|
||||
self.with_tool(Arc::new(WebSearchTool))
|
||||
.with_tool(Arc::new(FetchUrlTool))
|
||||
.with_tool(Arc::new(FinanceTool::new()))
|
||||
.with_tool(Arc::new(WebRunTool))
|
||||
}
|
||||
|
||||
@@ -198,6 +198,11 @@ pub struct ToolContext {
|
||||
pub features: Features,
|
||||
/// Namespace for tool state that should be scoped to the current session/thread.
|
||||
pub state_namespace: String,
|
||||
/// User-trusted external paths the agent may read/write even when they
|
||||
/// fall outside `workspace`. Loaded from `~/.deepseek/workspace-trust.json`
|
||||
/// and refreshed when the user runs `/trust add <path>`. Distinct from
|
||||
/// `trust_mode`, which is the all-or-nothing legacy switch (#29).
|
||||
pub trusted_external_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl ToolContext {
|
||||
@@ -219,6 +224,7 @@ impl ToolContext {
|
||||
auto_approve: false,
|
||||
features: Features::with_defaults(),
|
||||
state_namespace: "workspace".to_string(),
|
||||
trusted_external_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +249,7 @@ impl ToolContext {
|
||||
auto_approve: false,
|
||||
features: Features::with_defaults(),
|
||||
state_namespace: "workspace".to_string(),
|
||||
trusted_external_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,9 +274,19 @@ impl ToolContext {
|
||||
auto_approve,
|
||||
features: Features::with_defaults(),
|
||||
state_namespace: "workspace".to_string(),
|
||||
trusted_external_paths: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the user's trusted external paths (loaded from
|
||||
/// `~/.deepseek/workspace-trust.json`). See [`Self::resolve_path`] for
|
||||
/// how the list is consulted.
|
||||
#[must_use]
|
||||
pub fn with_trusted_external_paths(mut self, paths: Vec<PathBuf>) -> Self {
|
||||
self.trusted_external_paths = paths;
|
||||
self
|
||||
}
|
||||
|
||||
/// Resolve a path relative to workspace, validating it doesn't escape.
|
||||
///
|
||||
/// This handles both existing files (using canonicalize) and non-existent files
|
||||
@@ -317,7 +334,10 @@ impl ToolContext {
|
||||
// hasn't been canonicalized yet
|
||||
let workspace_plain = normalize_path(&self.workspace);
|
||||
let candidate_normalized = normalize_path(&candidate);
|
||||
if !candidate_normalized.starts_with(&workspace_plain) {
|
||||
if !candidate_normalized.starts_with(&workspace_plain)
|
||||
&& !self.is_trusted_external_path(&candidate_canonical)
|
||||
&& !self.is_trusted_external_path(&candidate_normalized)
|
||||
{
|
||||
return Err(ToolError::PathEscape {
|
||||
path: candidate_canonical,
|
||||
});
|
||||
@@ -334,7 +354,9 @@ impl ToolContext {
|
||||
))
|
||||
})?;
|
||||
|
||||
if !canonical.starts_with(&workspace_canonical) {
|
||||
if !canonical.starts_with(&workspace_canonical)
|
||||
&& !self.is_trusted_external_path(&canonical)
|
||||
{
|
||||
return Err(ToolError::PathEscape { path: canonical });
|
||||
}
|
||||
|
||||
@@ -376,9 +398,12 @@ impl ToolContext {
|
||||
}
|
||||
let canonical = normalize_path(&canonical);
|
||||
|
||||
// Validate it's under workspace
|
||||
// Validate it's under workspace, OR is under a user-trusted external
|
||||
// path (`/trust add <path>` from the slash command, persisted in
|
||||
// `~/.deepseek/workspace-trust.json`).
|
||||
if !canonical.starts_with(&workspace_canonical)
|
||||
&& !canonical.starts_with(&workspace_normalized)
|
||||
&& !self.is_trusted_external_path(&canonical)
|
||||
{
|
||||
return Err(ToolError::PathEscape { path: canonical });
|
||||
}
|
||||
@@ -386,6 +411,14 @@ impl ToolContext {
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
/// Whether `path` is under any of the user-trusted external roots. The
|
||||
/// caller should pass an already-canonicalized (or normalized) path.
|
||||
fn is_trusted_external_path(&self, path: &Path) -> bool {
|
||||
self.trusted_external_paths
|
||||
.iter()
|
||||
.any(|trusted| path.starts_with(trusted))
|
||||
}
|
||||
|
||||
/// Set the trust mode.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_trust_mode(mut self, trust: bool) -> Self {
|
||||
@@ -650,6 +683,39 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// Issue #29: paths under a user-trusted external directory resolve
|
||||
/// successfully even though they fall outside the workspace, while
|
||||
/// untrusted external paths still error with `PathEscape`.
|
||||
#[test]
|
||||
fn test_tool_context_trusted_external_path_allows_escape() {
|
||||
let workspace = tempdir().expect("workspace tempdir");
|
||||
let trusted_root = tempdir().expect("trusted tempdir");
|
||||
let trusted_file = trusted_root.path().join("notes.md");
|
||||
std::fs::write(&trusted_file, "shared notes").unwrap();
|
||||
|
||||
let ctx =
|
||||
ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![
|
||||
trusted_root
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| trusted_root.path().to_path_buf()),
|
||||
]);
|
||||
|
||||
let resolved = ctx
|
||||
.resolve_path(trusted_file.to_str().unwrap())
|
||||
.expect("trusted path should resolve");
|
||||
assert!(resolved.ends_with("notes.md"));
|
||||
|
||||
// Path outside workspace AND outside the trust list should still fail.
|
||||
let other = tempdir().expect("untrusted tempdir");
|
||||
let other_file = other.path().join("secret.md");
|
||||
std::fs::write(&other_file, "x").unwrap();
|
||||
let err = ctx
|
||||
.resolve_path(other_file.to_str().unwrap())
|
||||
.expect_err("untrusted path must error");
|
||||
assert!(matches!(err, ToolError::PathEscape { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_required_str() {
|
||||
let input = json!({"name": "test", "count": 42});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Compatibility web search helper backed by DuckDuckGo HTML results.
|
||||
//! Web search tool backed by DuckDuckGo HTML results (with Bing fallback).
|
||||
//!
|
||||
//! Prefer `web.run` for new browsing calls. This legacy surface remains available
|
||||
//! for older prompts and configs that still reference `web_search`.
|
||||
//! This is the primary web search surface for agents. For browsing workflows
|
||||
//! (page open, click, screenshot) use a direct URL approach instead.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
|
||||
@@ -93,7 +93,7 @@ impl ToolSpec for WebSearchTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Compatibility web search helper. Prefer web.run for canonical browsing workflows."
|
||||
"Search the web using DuckDuckGo or Bing and return structured results with URLs and snippets."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -106,11 +106,11 @@ impl ToolSpec for WebSearchTool {
|
||||
},
|
||||
"q": {
|
||||
"type": "string",
|
||||
"description": "Search query alias accepted for compatibility with web.run-style calls."
|
||||
"description": "Search query."
|
||||
},
|
||||
"search_query": {
|
||||
"type": "array",
|
||||
"description": "Compatibility form from web.run: [{\"q\":\"...\", \"max_results\": 5}]",
|
||||
"description": "Array form for advanced queries: [{\"q\":\"...\", \"max_results\": 5}]",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -510,9 +510,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_search_query_accepts_web_run_shape() {
|
||||
fn extract_search_query_accepts_array_form() {
|
||||
let input = json!({"search_query": [{"q": "deepseek api", "max_results": 3}]});
|
||||
let query = extract_search_query(&input).expect("web.run shape should parse");
|
||||
let query = extract_search_query(&input).expect("array form should parse");
|
||||
assert_eq!(query, "deepseek api");
|
||||
assert_eq!(optional_search_max_results(&input), 3);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::config::{ApiProvider, Config, has_api_key, save_api_key};
|
||||
use crate::core::coherence::CoherenceState;
|
||||
use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult};
|
||||
use crate::models::{
|
||||
Message, SystemPrompt, compaction_message_threshold_for_model, compaction_threshold_for_model,
|
||||
Message, SystemPrompt, compaction_message_threshold_for_model,
|
||||
compaction_threshold_for_model_and_effort,
|
||||
};
|
||||
use crate::palette::{self, UiTheme};
|
||||
use crate::settings::Settings;
|
||||
@@ -505,6 +506,10 @@ pub struct App {
|
||||
pub last_prompt_cache_hit_tokens: Option<u32>,
|
||||
/// DeepSeek context-cache miss tokens from the last API call.
|
||||
pub last_prompt_cache_miss_tokens: Option<u32>,
|
||||
/// Approximate input tokens spent re-sending prior `reasoning_content` on
|
||||
/// the last thinking-mode tool-calling turn (V4 §5.1.1 "Interleaved
|
||||
/// Thinking"). Computed client-side at ~4 chars/token.
|
||||
pub last_reasoning_replay_tokens: Option<u32>,
|
||||
/// Cached git context snapshot for the footer.
|
||||
pub workspace_context: Option<String>,
|
||||
/// Timestamp for cached workspace context.
|
||||
@@ -622,7 +627,8 @@ impl App {
|
||||
let max_input_history = settings.max_input_history;
|
||||
let ui_theme = palette::ui_theme(&settings.theme);
|
||||
let model = settings.default_model.clone().unwrap_or(model);
|
||||
let compact_threshold = compaction_threshold_for_model(&model);
|
||||
let compact_threshold =
|
||||
compaction_threshold_for_model_and_effort(&model, config.reasoning_effort());
|
||||
|
||||
// Start in YOLO mode if --yolo flag was passed
|
||||
let preferred_mode = AppMode::from_setting(&settings.default_mode);
|
||||
@@ -776,6 +782,7 @@ impl App {
|
||||
last_completion_tokens: None,
|
||||
last_prompt_cache_hit_tokens: None,
|
||||
last_prompt_cache_miss_tokens: None,
|
||||
last_reasoning_replay_tokens: None,
|
||||
workspace_context: None,
|
||||
workspace_context_refreshed_at: None,
|
||||
task_panel: Vec::new(),
|
||||
@@ -1073,9 +1080,18 @@ impl App {
|
||||
// Invalidate transcript cache (will be rebuilt on next render)
|
||||
self.transcript_cache = TranscriptViewCache::new();
|
||||
|
||||
// Reset scroll to bottom to avoid invalid anchors
|
||||
// (anchored cell indices may be invalid at new width)
|
||||
self.transcript_scroll = TranscriptScroll::ToBottom;
|
||||
// Preserve cell-level anchor through resize: line_in_cell depends
|
||||
// on the old width so reset it to 0 (start of the same cell).
|
||||
// ToBottom stays ToBottom; SpacerBeforeCell keeps its cell_index.
|
||||
match self.transcript_scroll {
|
||||
TranscriptScroll::Scrolled { cell_index, .. } => {
|
||||
self.transcript_scroll = TranscriptScroll::Scrolled {
|
||||
cell_index,
|
||||
line_in_cell: 0,
|
||||
};
|
||||
}
|
||||
TranscriptScroll::ToBottom | TranscriptScroll::ScrolledSpacerBeforeCell { .. } => {}
|
||||
}
|
||||
|
||||
// Clear pending scroll delta
|
||||
self.pending_scroll_delta = 0;
|
||||
@@ -1381,7 +1397,10 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn update_model_compaction_budget(&mut self) {
|
||||
self.compact_threshold = compaction_threshold_for_model(&self.model);
|
||||
self.compact_threshold = compaction_threshold_for_model_and_effort(
|
||||
&self.model,
|
||||
self.reasoning_effort.api_value(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn compaction_config(&self) -> CompactionConfig {
|
||||
@@ -1425,6 +1444,8 @@ pub enum AppAction {
|
||||
workspace: PathBuf,
|
||||
},
|
||||
OpenConfigView,
|
||||
/// Open the `/model` two-pane picker (Pro/Flash + Off/High/Max).
|
||||
OpenModelPicker,
|
||||
SendMessage(String),
|
||||
ListSubAgents,
|
||||
FetchModels,
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod diff_render;
|
||||
pub mod event_broker;
|
||||
pub mod history;
|
||||
pub mod markdown_render;
|
||||
pub mod model_picker;
|
||||
pub mod onboarding;
|
||||
pub mod pager;
|
||||
pub mod paste_burst;
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier
|
||||
//! and apply both at once (#39).
|
||||
//!
|
||||
//! Two side-by-side panes — Models on the left, Thinking effort on the
|
||||
//! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies
|
||||
//! both and closes the modal, Esc cancels.
|
||||
//!
|
||||
//! The effort pane intentionally only exposes `Off / High / Max`. Per
|
||||
//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model),
|
||||
//! `low`/`medium` are silently mapped to `high` server-side and `xhigh` is
|
||||
//! mapped to `max`, so surfacing them as separate choices would be misleading.
|
||||
//! The legacy variants remain valid in `~/.deepseek/settings.toml` for
|
||||
//! back-compat — the picker just doesn't offer them.
|
||||
//!
|
||||
//! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved
|
||||
//! model id and effort tier; the UI handler updates `App` state, persists
|
||||
//! the choice via [`Settings`], and forwards `Op::SetModel` so the running
|
||||
//! engine picks up the change without a restart.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
prelude::Stylize,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::app::{App, ReasoningEffort};
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
|
||||
/// Models the picker exposes by default. Kept short on purpose — power
|
||||
/// users can still type `/model <id>` for anything else.
|
||||
const PICKER_MODELS: &[(&str, &str)] = &[
|
||||
("deepseek-v4-pro", "flagship"),
|
||||
("deepseek-v4-flash", "fast / cheap"),
|
||||
];
|
||||
|
||||
/// Thinking-effort rows shown in the picker, in the order DeepSeek
|
||||
/// behaviorally distinguishes them.
|
||||
const PICKER_EFFORTS: &[ReasoningEffort] = &[
|
||||
ReasoningEffort::Off,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::Max,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pane {
|
||||
Model,
|
||||
Effort,
|
||||
}
|
||||
|
||||
pub struct ModelPickerView {
|
||||
initial_model: String,
|
||||
initial_effort: ReasoningEffort,
|
||||
/// Working selection (separate from the initial values so we can offer a
|
||||
/// clean Esc-to-cancel without mutating App state).
|
||||
selected_model_idx: usize,
|
||||
selected_effort_idx: usize,
|
||||
focus: Pane,
|
||||
/// True when the active model is one we don't list — we still show it
|
||||
/// so the picker doesn't quietly forget the user's chosen IDs.
|
||||
show_custom_model_row: bool,
|
||||
}
|
||||
|
||||
impl ModelPickerView {
|
||||
#[must_use]
|
||||
pub fn new(app: &App) -> Self {
|
||||
let initial_model = app.model.clone();
|
||||
let mut selected_model_idx = PICKER_MODELS
|
||||
.iter()
|
||||
.position(|(id, _)| *id == initial_model);
|
||||
let show_custom_model_row = selected_model_idx.is_none();
|
||||
if show_custom_model_row {
|
||||
// Custom row sits at the end; precompute its index.
|
||||
selected_model_idx = Some(PICKER_MODELS.len());
|
||||
}
|
||||
let selected_model_idx = selected_model_idx.unwrap_or(0);
|
||||
|
||||
let initial_effort = app.reasoning_effort;
|
||||
// Map low/medium → high, xhigh → max for picker purposes.
|
||||
let normalized = match initial_effort {
|
||||
ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High,
|
||||
other => other,
|
||||
};
|
||||
let selected_effort_idx = PICKER_EFFORTS
|
||||
.iter()
|
||||
.position(|e| *e == normalized)
|
||||
.unwrap_or(1); // default to High if somehow unknown
|
||||
|
||||
Self {
|
||||
initial_model,
|
||||
initial_effort,
|
||||
selected_model_idx,
|
||||
selected_effort_idx,
|
||||
focus: Pane::Model,
|
||||
show_custom_model_row,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_row_count(&self) -> usize {
|
||||
PICKER_MODELS.len() + if self.show_custom_model_row { 1 } else { 0 }
|
||||
}
|
||||
|
||||
/// Resolve the currently highlighted model row to a model id. If the
|
||||
/// custom row is selected we return the original model from the App so
|
||||
/// "Apply" doesn't blow away an unrecognised id.
|
||||
fn resolved_model(&self) -> String {
|
||||
if self.show_custom_model_row && self.selected_model_idx == PICKER_MODELS.len() {
|
||||
self.initial_model.clone()
|
||||
} else {
|
||||
PICKER_MODELS[self.selected_model_idx].0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_effort(&self) -> ReasoningEffort {
|
||||
PICKER_EFFORTS[self.selected_effort_idx]
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.focus {
|
||||
Pane::Model => {
|
||||
if self.selected_model_idx > 0 {
|
||||
self.selected_model_idx -= 1;
|
||||
}
|
||||
}
|
||||
Pane::Effort => {
|
||||
if self.selected_effort_idx > 0 {
|
||||
self.selected_effort_idx -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.focus {
|
||||
Pane::Model => {
|
||||
let max = self.model_row_count().saturating_sub(1);
|
||||
if self.selected_model_idx < max {
|
||||
self.selected_model_idx += 1;
|
||||
}
|
||||
}
|
||||
Pane::Effort => {
|
||||
let max = PICKER_EFFORTS.len().saturating_sub(1);
|
||||
if self.selected_effort_idx < max {
|
||||
self.selected_effort_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Pane::Model => Pane::Effort,
|
||||
Pane::Effort => Pane::Model,
|
||||
};
|
||||
}
|
||||
|
||||
fn build_event(&self) -> ViewEvent {
|
||||
ViewEvent::ModelPickerApplied {
|
||||
model: self.resolved_model(),
|
||||
effort: self.resolved_effort(),
|
||||
previous_model: self.initial_model.clone(),
|
||||
previous_effort: self.initial_effort,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pane(
|
||||
&self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
title: &str,
|
||||
rows: Vec<(String, String)>,
|
||||
selected: usize,
|
||||
focused: bool,
|
||||
) {
|
||||
let border_style = if focused {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY)
|
||||
} else {
|
||||
Style::default().fg(palette::BORDER_COLOR)
|
||||
};
|
||||
let block = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default().fg(palette::TEXT_PRIMARY).bold(),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut lines = Vec::with_capacity(rows.len());
|
||||
for (idx, (label, hint)) in rows.iter().enumerate() {
|
||||
let is_selected = idx == selected;
|
||||
let marker = if is_selected { "▸" } else { " " };
|
||||
let label_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(palette::SELECTION_TEXT)
|
||||
.bg(palette::SELECTION_BG)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let hint_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(palette::SELECTION_TEXT)
|
||||
.bg(palette::SELECTION_BG)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_MUTED)
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(marker, label_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(label.clone(), label_style),
|
||||
];
|
||||
if !hint.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!("({hint})"), hint_style));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for ModelPickerView {
|
||||
fn kind(&self) -> ModalKind {
|
||||
ModalKind::ModelPicker
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => ViewAction::Close,
|
||||
KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
|
||||
KeyCode::Up => {
|
||||
self.move_up();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.move_down();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.toggle_focus();
|
||||
ViewAction::None
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
|
||||
let popup_height = 14.min(area.height.saturating_sub(4)).max(10);
|
||||
let popup_area = Rect {
|
||||
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
|
||||
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
|
||||
width: popup_width,
|
||||
height: popup_height,
|
||||
};
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
// Outer chrome with title + footer hint.
|
||||
let outer = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
" Model & thinking ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.title_bottom(Line::from(vec![
|
||||
Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("move "),
|
||||
Span::styled(" Tab ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("switch "),
|
||||
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("apply "),
|
||||
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("cancel "),
|
||||
]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
let inner = outer.inner(popup_area);
|
||||
outer.render(popup_area, buf);
|
||||
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(inner);
|
||||
|
||||
let mut model_rows: Vec<(String, String)> = PICKER_MODELS
|
||||
.iter()
|
||||
.map(|(id, hint)| ((*id).to_string(), (*hint).to_string()))
|
||||
.collect();
|
||||
if self.show_custom_model_row {
|
||||
model_rows.push((self.initial_model.clone(), "current (custom)".to_string()));
|
||||
}
|
||||
self.render_pane(
|
||||
columns[0],
|
||||
buf,
|
||||
"Model",
|
||||
model_rows,
|
||||
self.selected_model_idx,
|
||||
self.focus == Pane::Model,
|
||||
);
|
||||
|
||||
let effort_rows: Vec<(String, String)> = PICKER_EFFORTS
|
||||
.iter()
|
||||
.map(|effort| {
|
||||
let label = effort.short_label().to_string();
|
||||
let hint = match effort {
|
||||
ReasoningEffort::Off => "thinking disabled".to_string(),
|
||||
ReasoningEffort::High => "thinking enabled (default)".to_string(),
|
||||
ReasoningEffort::Max => "thinking enabled, max effort".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(label, hint)
|
||||
})
|
||||
.collect();
|
||||
self.render_pane(
|
||||
columns[1],
|
||||
buf,
|
||||
"Thinking",
|
||||
effort_rows,
|
||||
self.selected_effort_idx,
|
||||
self.focus == Pane::Effort,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
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: true,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_initial_selection_matches_app_state() {
|
||||
let mut app = create_test_app();
|
||||
app.model = "deepseek-v4-flash".to_string();
|
||||
app.reasoning_effort = ReasoningEffort::Max;
|
||||
let view = ModelPickerView::new(&app);
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_normalizes_low_medium_to_high() {
|
||||
let mut app = create_test_app();
|
||||
app.reasoning_effort = ReasoningEffort::Medium;
|
||||
let view = ModelPickerView::new(&app);
|
||||
assert_eq!(
|
||||
view.resolved_effort(),
|
||||
ReasoningEffort::High,
|
||||
"medium should map to high in the picker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_preserves_unknown_model_via_custom_row() {
|
||||
let mut app = create_test_app();
|
||||
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
|
||||
let view = ModelPickerView::new(&app);
|
||||
assert!(view.show_custom_model_row);
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_within_focused_pane() {
|
||||
let app = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
// Default focus is Model; move down then up.
|
||||
let initial = view.selected_model_idx;
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial + 1);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Up,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_switches_focus_and_arrow_now_moves_effort() {
|
||||
let mut app = create_test_app();
|
||||
// Default is Max (index 2 = last); pin to Off so the Down arrow has
|
||||
// somewhere to go.
|
||||
app.reasoning_effort = ReasoningEffort::Off;
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
let initial_effort_idx = view.selected_effort_idx;
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.focus, Pane::Effort);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert!(view.selected_effort_idx > initial_effort_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_emits_apply_event_with_selection() {
|
||||
let mut app = create_test_app();
|
||||
app.reasoning_effort = ReasoningEffort::High;
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
let action = view.handle_key(KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
|
||||
model,
|
||||
effort,
|
||||
previous_effort,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(model, "deepseek-v4-pro");
|
||||
assert_eq!(effort, ReasoningEffort::Max);
|
||||
assert_eq!(previous_effort, ReasoningEffort::High);
|
||||
}
|
||||
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_closes_without_emitting() {
|
||||
let app = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
let action = view.handle_key(KeyEvent::new(
|
||||
KeyCode::Esc,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert!(matches!(action, ViewAction::Close));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_only_exposes_off_high_max() {
|
||||
let labels: Vec<&str> = PICKER_EFFORTS
|
||||
.iter()
|
||||
.map(|effort| effort.short_label())
|
||||
.collect();
|
||||
assert_eq!(labels, vec!["off", "high", "max"]);
|
||||
}
|
||||
}
|
||||
+334
-101
@@ -16,6 +16,7 @@ use crossterm::{
|
||||
execute,
|
||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||
};
|
||||
use ignore::WalkBuilder;
|
||||
use ratatui::{
|
||||
Frame, Terminal,
|
||||
backend::CrosstermBackend,
|
||||
@@ -164,6 +165,7 @@ pub async fn run_tui(config: &Config, options: TuiOptions) -> Result<()> {
|
||||
app.last_completion_tokens = None;
|
||||
app.last_prompt_cache_hit_tokens = None;
|
||||
app.last_prompt_cache_miss_tokens = None;
|
||||
app.last_reasoning_replay_tokens = None;
|
||||
if let Some(prompt) = saved.system_prompt {
|
||||
app.system_prompt = Some(SystemPrompt::Text(prompt));
|
||||
}
|
||||
@@ -540,6 +542,7 @@ async fn run_event_loop(
|
||||
app.last_completion_tokens = Some(usage.output_tokens);
|
||||
app.last_prompt_cache_hit_tokens = usage.prompt_cache_hit_tokens;
|
||||
app.last_prompt_cache_miss_tokens = usage.prompt_cache_miss_tokens;
|
||||
app.last_reasoning_replay_tokens = usage.reasoning_replay_tokens;
|
||||
if let Some(error) = error {
|
||||
app.status_message = Some(format!("Turn failed: {error}"));
|
||||
}
|
||||
@@ -1266,6 +1269,9 @@ async fn run_event_loop(
|
||||
if try_autocomplete_slash_command(app) {
|
||||
continue;
|
||||
}
|
||||
if try_autocomplete_file_mention(app) {
|
||||
continue;
|
||||
}
|
||||
app.cycle_mode();
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
@@ -1610,6 +1616,167 @@ fn try_autocomplete_slash_command(app: &mut App) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Maximum file-mention completion candidates to consider per keypress. Caps
|
||||
/// the cost of walking large workspaces; subsequent keystrokes narrow further.
|
||||
const FILE_MENTION_COMPLETION_LIMIT: usize = 64;
|
||||
|
||||
/// Maximum directory depth walked when completing a file mention. Mirrors the
|
||||
/// existing `project_tree` cutoff and keeps Tab snappy in deep monorepos.
|
||||
const FILE_MENTION_COMPLETION_DEPTH: usize = 6;
|
||||
|
||||
/// If the cursor sits inside a `@<partial>` token in the input, return the
|
||||
/// byte offset where the `@` starts (so we can splice in a completion) and
|
||||
/// the partial path the user has typed so far. The token stops at whitespace
|
||||
/// or the end of input. Returns `None` when the cursor is outside any mention
|
||||
/// or the token is empty (`@` with nothing after it).
|
||||
fn partial_file_mention_at_cursor(input: &str, cursor_chars: usize) -> Option<(usize, String)> {
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
if cursor_chars > chars.len() {
|
||||
return None;
|
||||
}
|
||||
// Walk left from the cursor until we find an `@` or a whitespace; if
|
||||
// whitespace comes first the cursor isn't inside a mention.
|
||||
let mut start_chars = cursor_chars;
|
||||
while start_chars > 0 {
|
||||
let prev = chars[start_chars - 1];
|
||||
if prev == '@' {
|
||||
start_chars -= 1;
|
||||
break;
|
||||
}
|
||||
if prev.is_whitespace() {
|
||||
return None;
|
||||
}
|
||||
start_chars -= 1;
|
||||
}
|
||||
if start_chars == cursor_chars || chars.get(start_chars) != Some(&'@') {
|
||||
return None;
|
||||
}
|
||||
// Confirm the `@` itself is at a valid mention boundary.
|
||||
if !is_file_mention_start(&chars, start_chars) {
|
||||
return None;
|
||||
}
|
||||
// Consume from the `@` to the next whitespace (the end of the token).
|
||||
let mut end_chars = start_chars + 1;
|
||||
while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
|
||||
end_chars += 1;
|
||||
}
|
||||
let partial: String = chars[start_chars + 1..end_chars].iter().collect();
|
||||
let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
|
||||
Some((byte_start, partial))
|
||||
}
|
||||
|
||||
/// Walk the workspace and return relative paths whose representation matches
|
||||
/// the partial mention. A file matches when its case-insensitive relative
|
||||
/// path either starts with the partial or contains it as a substring; the
|
||||
/// former rank earlier so a partial like `docs/de` resolves to
|
||||
/// `docs/deepseek_v4.pdf` before any path that merely contains those bytes.
|
||||
fn find_file_mention_completions(workspace: &Path, partial: &str, limit: usize) -> Vec<String> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let needle = partial.to_lowercase();
|
||||
let mut prefix_hits: Vec<String> = Vec::new();
|
||||
let mut substring_hits: Vec<String> = Vec::new();
|
||||
|
||||
let mut builder = WalkBuilder::new(workspace);
|
||||
builder
|
||||
.hidden(true)
|
||||
.follow_links(false)
|
||||
.max_depth(Some(FILE_MENTION_COMPLETION_DEPTH));
|
||||
|
||||
for entry in builder.build().flatten() {
|
||||
if prefix_hits.len() + substring_hits.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Ok(rel) = path.strip_prefix(workspace) else {
|
||||
continue;
|
||||
};
|
||||
let rel_str = rel.to_string_lossy().replace('\\', "/");
|
||||
if rel_str.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
|
||||
let candidate = if is_dir {
|
||||
format!("{rel_str}/")
|
||||
} else {
|
||||
rel_str.clone()
|
||||
};
|
||||
let lower = candidate.to_lowercase();
|
||||
if needle.is_empty() || lower.starts_with(&needle) {
|
||||
prefix_hits.push(candidate);
|
||||
} else if lower.contains(&needle) {
|
||||
substring_hits.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
prefix_hits.sort();
|
||||
substring_hits.sort();
|
||||
prefix_hits.extend(substring_hits);
|
||||
prefix_hits.truncate(limit);
|
||||
prefix_hits
|
||||
}
|
||||
|
||||
/// Tab-completion handler for `@file` mentions. Mirrors the slash-command
|
||||
/// flow: a single match is applied directly; multiple matches with a longer
|
||||
/// shared prefix extend the partial; otherwise the first few candidates are
|
||||
/// surfaced via the status line. Returns true when the input was modified or
|
||||
/// a suggestion was offered, so the caller can short-circuit other handlers.
|
||||
fn try_autocomplete_file_mention(app: &mut App) -> bool {
|
||||
let Some((byte_start, partial)) =
|
||||
partial_file_mention_at_cursor(&app.input, app.cursor_position)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let workspace = app.workspace.clone();
|
||||
let candidates =
|
||||
find_file_mention_completions(&workspace, &partial, FILE_MENTION_COMPLETION_LIMIT);
|
||||
if candidates.is_empty() {
|
||||
app.status_message = Some(format!("No files match @{partial}"));
|
||||
return true;
|
||||
}
|
||||
if candidates.len() == 1 {
|
||||
replace_file_mention(app, byte_start, &partial, &candidates[0]);
|
||||
app.status_message = Some(format!("Attached @{}", candidates[0]));
|
||||
return true;
|
||||
}
|
||||
let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
|
||||
let shared = longest_common_prefix(&candidate_refs);
|
||||
if shared.len() > partial.len() {
|
||||
replace_file_mention(app, byte_start, &partial, shared);
|
||||
app.status_message = Some(format!("@{shared}…"));
|
||||
return true;
|
||||
}
|
||||
let preview = candidates
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|c| format!("@{c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
app.status_message = Some(format!("Matches: {preview}"));
|
||||
true
|
||||
}
|
||||
|
||||
/// Splice a completion into the input, replacing the `@<partial>` token at
|
||||
/// `byte_start` with `@<replacement>`. Cursor moves to the end of the new
|
||||
/// token so further keystrokes extend (or escape via space) naturally.
|
||||
fn replace_file_mention(app: &mut App, byte_start: usize, partial: &str, replacement: &str) {
|
||||
let original_token_len = '@'.len_utf8() + partial.len();
|
||||
let original_token_end = byte_start + original_token_len;
|
||||
let mut new_input =
|
||||
String::with_capacity(app.input.len() - original_token_len + 1 + replacement.len());
|
||||
new_input.push_str(&app.input[..byte_start]);
|
||||
new_input.push('@');
|
||||
new_input.push_str(replacement);
|
||||
if original_token_end < app.input.len() {
|
||||
new_input.push_str(&app.input[original_token_end..]);
|
||||
}
|
||||
let new_cursor_chars =
|
||||
app.input[..byte_start].chars().count() + 1 + replacement.chars().count();
|
||||
app.input = new_input;
|
||||
app.cursor_position = new_cursor_chars;
|
||||
}
|
||||
|
||||
fn longest_common_prefix<'a>(values: &[&'a str]) -> &'a str {
|
||||
let Some(first) = values.first().copied() else {
|
||||
return "";
|
||||
@@ -2152,6 +2319,7 @@ async fn dispatch_user_message(
|
||||
app.last_completion_tokens = None;
|
||||
app.last_prompt_cache_hit_tokens = None;
|
||||
app.last_prompt_cache_miss_tokens = None;
|
||||
app.last_reasoning_replay_tokens = None;
|
||||
// Persist immediately so abrupt termination can recover this in-flight turn.
|
||||
persist_checkpoint(app);
|
||||
|
||||
@@ -2184,6 +2352,90 @@ async fn apply_model_and_compaction_update(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Apply the choice made in the `/model` picker (#39): mutate App state so
|
||||
/// the next turn uses the new model/effort, persist the selection to
|
||||
/// `~/.deepseek/settings.toml` so it survives a restart, push the change to
|
||||
/// the running engine via `Op::SetModel`/`Op::SetCompaction`, and surface
|
||||
/// a one-line status describing what changed.
|
||||
async fn apply_model_picker_choice(
|
||||
app: &mut App,
|
||||
engine_handle: &EngineHandle,
|
||||
model: String,
|
||||
effort: crate::tui::app::ReasoningEffort,
|
||||
previous_model: String,
|
||||
previous_effort: crate::tui::app::ReasoningEffort,
|
||||
) {
|
||||
let model_changed = model != previous_model;
|
||||
let effort_changed = effort != previous_effort;
|
||||
if !model_changed && !effort_changed {
|
||||
app.status_message = Some(format!(
|
||||
"Model unchanged: {model} · thinking {}",
|
||||
effort.short_label()
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if model_changed {
|
||||
app.model = model.clone();
|
||||
app.update_model_compaction_budget();
|
||||
app.last_prompt_tokens = None;
|
||||
app.last_completion_tokens = None;
|
||||
app.last_prompt_cache_hit_tokens = None;
|
||||
app.last_prompt_cache_miss_tokens = None;
|
||||
app.last_reasoning_replay_tokens = None;
|
||||
}
|
||||
if effort_changed {
|
||||
app.reasoning_effort = effort;
|
||||
}
|
||||
|
||||
// Best-effort persist; surface a status warning if the settings file
|
||||
// can't be written rather than aborting the in-memory change.
|
||||
let mut persist_warning: Option<String> = None;
|
||||
match crate::settings::Settings::load() {
|
||||
Ok(mut settings) => {
|
||||
if model_changed {
|
||||
let _ = settings.set("default_model", &model);
|
||||
}
|
||||
if effort_changed {
|
||||
let _ = settings.set("reasoning_effort", effort.as_setting());
|
||||
}
|
||||
if let Err(err) = settings.save() {
|
||||
persist_warning = Some(format!("(not persisted: {err})"));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
persist_warning = Some(format!("(not persisted: {err})"));
|
||||
}
|
||||
}
|
||||
|
||||
if model_changed {
|
||||
apply_model_and_compaction_update(engine_handle, app.compaction_config()).await;
|
||||
}
|
||||
|
||||
let mut summary = match (model_changed, effort_changed) {
|
||||
(true, true) => format!(
|
||||
"Model: {previous_model} → {model} · thinking: {} → {}",
|
||||
previous_effort.short_label(),
|
||||
effort.short_label()
|
||||
),
|
||||
(true, false) => format!(
|
||||
"Model: {previous_model} → {model} · thinking {}",
|
||||
effort.short_label()
|
||||
),
|
||||
(false, true) => format!(
|
||||
"Thinking: {} → {} · model {model}",
|
||||
previous_effort.short_label(),
|
||||
effort.short_label()
|
||||
),
|
||||
(false, false) => unreachable!(),
|
||||
};
|
||||
if let Some(warning) = persist_warning {
|
||||
summary.push(' ');
|
||||
summary.push_str(&warning);
|
||||
}
|
||||
app.status_message = Some(summary);
|
||||
}
|
||||
|
||||
/// Apply a `/provider` switch by mutating the in-memory config, validating
|
||||
/// that credentials exist for the new provider, then respawning the engine
|
||||
/// so the API client picks up the new base URL/key. When `model_override`
|
||||
@@ -2372,6 +2624,12 @@ async fn apply_command_result(
|
||||
app.view_stack.push(ConfigView::new_for_app(app));
|
||||
}
|
||||
}
|
||||
AppAction::OpenModelPicker => {
|
||||
if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
|
||||
app.view_stack
|
||||
.push(crate::tui::model_picker::ModelPickerView::new(app));
|
||||
}
|
||||
}
|
||||
AppAction::CompactContext => {
|
||||
app.status_message = Some("Compacting context...".to_string());
|
||||
let _ = engine_handle.send(Op::CompactContext).await;
|
||||
@@ -2726,6 +2984,10 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("workspace");
|
||||
let effort_label = app.reasoning_effort.short_label();
|
||||
let provider_label = match app.api_provider {
|
||||
crate::config::ApiProvider::Deepseek => None,
|
||||
crate::config::ApiProvider::NvidiaNim => Some("NIM"),
|
||||
};
|
||||
let header_data = HeaderData::new(
|
||||
app.mode,
|
||||
&app.model,
|
||||
@@ -2739,7 +3001,8 @@ fn render(f: &mut Frame, app: &mut App) {
|
||||
app.session_cost,
|
||||
sanitized_prompt_tokens,
|
||||
)
|
||||
.with_reasoning_effort(Some(effort_label));
|
||||
.with_reasoning_effort(Some(effort_label))
|
||||
.with_provider(provider_label);
|
||||
let header_widget = HeaderWidget::new(header_data);
|
||||
let buf = f.buffer_mut();
|
||||
header_widget.render(chunks[0], buf);
|
||||
@@ -3342,6 +3605,22 @@ async fn handle_view_events(
|
||||
app.status_message = Some("Refreshing sub-agents...".to_string());
|
||||
let _ = engine_handle.send(Op::ListSubAgents).await;
|
||||
}
|
||||
ViewEvent::ModelPickerApplied {
|
||||
model,
|
||||
effort,
|
||||
previous_model,
|
||||
previous_effort,
|
||||
} => {
|
||||
apply_model_picker_choice(
|
||||
app,
|
||||
engine_handle,
|
||||
model,
|
||||
effort,
|
||||
previous_model,
|
||||
previous_effort,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3585,8 +3864,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
}
|
||||
|
||||
fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
// Context % is already shown in the header signal bar — don't
|
||||
// duplicate it in the footer. The footer carries unique info only:
|
||||
// coherence state, reasoning replay tokens, cache hit rate, and session cost.
|
||||
let coherence_spans = footer_coherence_spans(app);
|
||||
let context_spans = footer_context_spans(app);
|
||||
let replay_spans = footer_reasoning_replay_spans(app);
|
||||
let cache_spans = footer_cache_spans(app);
|
||||
let cost_spans = if app.session_cost > 0.001 {
|
||||
vec![Span::styled(
|
||||
@@ -3597,94 +3879,41 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if !coherence_spans.is_empty()
|
||||
&& !context_spans.is_empty()
|
||||
&& !cache_spans.is_empty()
|
||||
&& !cost_spans.is_empty()
|
||||
{
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(context_spans.clone());
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !context_spans.is_empty() && !cache_spans.is_empty() && !cost_spans.is_empty() {
|
||||
let mut combined = context_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !context_spans.is_empty() && !cache_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(context_spans.clone());
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !context_spans.is_empty() && !cache_spans.is_empty() {
|
||||
let mut combined = context_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !context_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(context_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !context_spans.is_empty() && !cost_spans.is_empty() {
|
||||
let mut combined = context_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !cache_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !cost_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() {
|
||||
candidates.push(coherence_spans);
|
||||
}
|
||||
if !context_spans.is_empty() {
|
||||
candidates.push(context_spans);
|
||||
}
|
||||
if !cache_spans.is_empty() {
|
||||
candidates.push(cache_spans);
|
||||
}
|
||||
if !cost_spans.is_empty() {
|
||||
candidates.push(cost_spans);
|
||||
}
|
||||
candidates.push(Vec::new());
|
||||
let parts: Vec<&Vec<Span<'static>>> =
|
||||
[&coherence_spans, &replay_spans, &cache_spans, &cost_spans]
|
||||
.iter()
|
||||
.filter(|spans| !spans.is_empty())
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|spans| spans_width(spans) <= max_width)
|
||||
.unwrap_or_default()
|
||||
// Try to fit as many parts as possible, dropping from the end.
|
||||
for end in (0..=parts.len()).rev() {
|
||||
let mut combined = Vec::new();
|
||||
for (i, part) in parts[..end].iter().enumerate() {
|
||||
if i > 0 {
|
||||
combined.push(Span::raw(" "));
|
||||
}
|
||||
combined.extend(part.iter().cloned());
|
||||
}
|
||||
if spans_width(&combined) <= max_width {
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn footer_coherence_spans(app: &App) -> Vec<Span<'static>> {
|
||||
// Only show coherence when it's NOT healthy — normal operation
|
||||
// needs no label; anomalies stand out. Renamed "crowded" to
|
||||
// "high load" because the capacity model measures tool/action
|
||||
// complexity, not context-window fullness, and "crowded" is
|
||||
// confusing when the header shows 6% context.
|
||||
let (label, color) = match app.coherence_state {
|
||||
CoherenceState::Healthy => ("coherence healthy", palette::DEEPSEEK_SKY),
|
||||
CoherenceState::GettingCrowded => ("coherence crowded", palette::STATUS_WARNING),
|
||||
CoherenceState::RefreshingContext => ("coherence refreshing", palette::STATUS_WARNING),
|
||||
CoherenceState::VerifyingRecentWork => ("coherence verifying", palette::DEEPSEEK_SKY),
|
||||
CoherenceState::ResettingPlan => ("coherence resetting", palette::STATUS_ERROR),
|
||||
CoherenceState::Healthy => return Vec::new(),
|
||||
CoherenceState::GettingCrowded => ("high load", palette::STATUS_WARNING),
|
||||
CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING),
|
||||
CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY),
|
||||
CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR),
|
||||
};
|
||||
|
||||
vec![Span::styled(label.to_string(), Style::default().fg(color))]
|
||||
@@ -3707,24 +3936,28 @@ fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
)]
|
||||
}
|
||||
|
||||
fn footer_context_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let (_, _, percent) = match context_usage_snapshot(app) {
|
||||
Some(snapshot) => snapshot,
|
||||
None => return Vec::new(),
|
||||
/// Render a footer chip showing the size of the `reasoning_content` block
|
||||
/// replayed on the most recent thinking-mode tool-calling turn (#30).
|
||||
///
|
||||
/// Stays hidden when the count is zero (non-thinking models, first turn, or
|
||||
/// turns with no tool calls). When replay tokens dominate the input budget
|
||||
/// (>50%), the chip turns warning-coloured so users notice that thinking
|
||||
/// replay is the main consumer of context.
|
||||
fn footer_reasoning_replay_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let Some(replay) = app.last_reasoning_replay_tokens else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let color = if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
|
||||
palette::STATUS_ERROR
|
||||
} else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
|
||||
palette::STATUS_WARNING
|
||||
} else {
|
||||
palette::DEEPSEEK_SKY
|
||||
if replay == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let label = format!("rsn {}", format_token_count_compact(u64::from(replay)));
|
||||
let color = match app.last_prompt_tokens {
|
||||
Some(input) if input > 0 && f64::from(replay) / f64::from(input) > 0.5 => {
|
||||
palette::STATUS_WARNING
|
||||
}
|
||||
_ => palette::TEXT_MUTED,
|
||||
};
|
||||
|
||||
vec![Span::styled(
|
||||
format!("ctx {:.0}%", percent),
|
||||
Style::default().fg(color),
|
||||
)]
|
||||
vec![Span::styled(label, Style::default().fg(color))]
|
||||
}
|
||||
|
||||
fn footer_toast_spans(
|
||||
|
||||
+170
-20
@@ -386,28 +386,31 @@ fn footer_status_line_spans_truncate_long_model_names() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_coherence_chip_snapshots_plain_language_ladder() {
|
||||
fn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
app.coherence_state = crate::core::coherence::CoherenceState::Healthy;
|
||||
assert!(
|
||||
footer_coherence_spans(&app).is_empty(),
|
||||
"healthy state should produce no footer chip"
|
||||
);
|
||||
|
||||
let cases = [
|
||||
(
|
||||
crate::core::coherence::CoherenceState::Healthy,
|
||||
"coherence healthy",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::GettingCrowded,
|
||||
"coherence crowded",
|
||||
"high load",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::RefreshingContext,
|
||||
"coherence refreshing",
|
||||
"refreshing context",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::VerifyingRecentWork,
|
||||
"coherence verifying",
|
||||
"verifying",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::ResettingPlan,
|
||||
"coherence resetting",
|
||||
"resetting plan",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -418,7 +421,7 @@ fn footer_coherence_chip_snapshots_plain_language_ladder() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_prioritize_context_when_busy() {
|
||||
fn footer_auxiliary_spans_show_cache_when_compact() {
|
||||
let mut app = create_test_app();
|
||||
app.is_loading = true;
|
||||
app.last_prompt_tokens = Some(48_000);
|
||||
@@ -426,19 +429,13 @@ fn footer_auxiliary_spans_prioritize_context_when_busy() {
|
||||
app.last_prompt_cache_miss_tokens = Some(12_000);
|
||||
app.session_cost = 12.34;
|
||||
|
||||
let compact = spans_text(&footer_auxiliary_spans(&app, 8));
|
||||
assert!(compact.contains("ctx"));
|
||||
assert!(compact.contains('%'));
|
||||
let compact = spans_text(&footer_auxiliary_spans(&app, 12));
|
||||
assert!(compact.contains("cache"));
|
||||
assert!(!compact.contains('$'));
|
||||
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 20));
|
||||
assert!(roomy.contains("ctx"));
|
||||
assert!(roomy.contains('%'));
|
||||
assert!(roomy.contains("cache"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_can_display_cache_and_cost_when_roomy() {
|
||||
fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
|
||||
let mut app = create_test_app();
|
||||
app.last_prompt_tokens = Some(48_000);
|
||||
app.last_prompt_cache_hit_tokens = Some(36_000);
|
||||
@@ -446,9 +443,40 @@ fn footer_auxiliary_spans_can_display_cache_and_cost_when_roomy() {
|
||||
app.session_cost = 12.34;
|
||||
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
|
||||
assert!(roomy.contains("ctx"));
|
||||
assert!(roomy.contains("cache 75%"));
|
||||
assert!(roomy.contains("$12.34"));
|
||||
assert!(
|
||||
!roomy.contains("ctx"),
|
||||
"context % removed from footer — shown in header only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_show_reasoning_replay_chip() {
|
||||
// Issue #30: when a thinking-mode tool-calling turn replays prior
|
||||
// reasoning_content, the footer surfaces the approximate input-token
|
||||
// cost so users can see why their context filled up.
|
||||
let mut app = create_test_app();
|
||||
app.last_prompt_tokens = Some(48_000);
|
||||
app.last_reasoning_replay_tokens = Some(8_200);
|
||||
|
||||
let spans = footer_auxiliary_spans(&app, 64);
|
||||
let text = spans_text(&spans);
|
||||
assert!(
|
||||
text.contains("rsn 8.2k"),
|
||||
"expected replay chip, got {text:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {
|
||||
let mut app = create_test_app();
|
||||
app.last_prompt_tokens = Some(48_000);
|
||||
app.last_reasoning_replay_tokens = Some(0);
|
||||
|
||||
let spans = footer_auxiliary_spans(&app, 64);
|
||||
let text = spans_text(&spans);
|
||||
assert!(!text.contains("rsn"), "zero replay must not render chip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -895,3 +923,125 @@ fn jump_to_adjacent_tool_cell_finds_next_and_previous() {
|
||||
SearchDirection::Backward
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_mention_finds_token_under_cursor() {
|
||||
// Cursor in middle of `@docs/de` should be detected as a partial mention.
|
||||
let input = "look at @docs/de please";
|
||||
let cursor = "look at @docs/de".chars().count();
|
||||
let (start, partial) = partial_file_mention_at_cursor(input, cursor)
|
||||
.expect("cursor inside mention should yield a partial");
|
||||
assert_eq!(start, "look at ".len(), "byte_start of @ in input");
|
||||
assert_eq!(partial, "docs/de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_mention_returns_none_when_cursor_outside() {
|
||||
let input = "look at @docs/de please";
|
||||
// Cursor after "please" — past the whitespace following the mention.
|
||||
let cursor = input.chars().count();
|
||||
assert!(partial_file_mention_at_cursor(input, cursor).is_none());
|
||||
|
||||
// Cursor before the `@` — not inside any mention either.
|
||||
let early_cursor = "look".chars().count();
|
||||
assert!(partial_file_mention_at_cursor(input, early_cursor).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_file_mention_handles_email_addresses() {
|
||||
// The `@` in `[email protected]` is preceded by a non-boundary char so
|
||||
// it's not treated as a file-mention.
|
||||
let input = "ping [email protected] now";
|
||||
let cursor = "ping [email protected]".chars().count();
|
||||
assert!(partial_file_mention_at_cursor(input, cursor).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_mention_completion_finds_unique_match() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
std::fs::write(tmpdir.path().join("README.md"), "readme").unwrap();
|
||||
std::fs::create_dir_all(tmpdir.path().join("docs")).unwrap();
|
||||
std::fs::write(tmpdir.path().join("docs/deepseek_v4.pdf"), b"%PDF-").unwrap();
|
||||
|
||||
let matches = find_file_mention_completions(tmpdir.path(), "docs/de", 16);
|
||||
assert_eq!(matches, vec!["docs/deepseek_v4.pdf".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_mention_completion_ranks_prefix_before_substring() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
std::fs::write(tmpdir.path().join("README.md"), "x").unwrap();
|
||||
std::fs::create_dir_all(tmpdir.path().join("nested")).unwrap();
|
||||
std::fs::write(tmpdir.path().join("nested/README.md"), "x").unwrap();
|
||||
|
||||
let matches = find_file_mention_completions(tmpdir.path(), "README", 16);
|
||||
// Top-level README (prefix match) outranks the nested one (substring).
|
||||
assert_eq!(matches.first().map(String::as_str), Some("README.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_autocomplete_file_mention_unique_replaces_partial() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
std::fs::create_dir_all(tmpdir.path().join("docs")).unwrap();
|
||||
std::fs::write(tmpdir.path().join("docs/deepseek_v4.pdf"), b"%PDF-").unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
app.workspace = tmpdir.path().to_path_buf();
|
||||
app.input = "summarize @docs/de".to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
|
||||
assert!(try_autocomplete_file_mention(&mut app));
|
||||
assert_eq!(app.input, "summarize @docs/deepseek_v4.pdf");
|
||||
assert_eq!(app.cursor_position, app.input.chars().count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_autocomplete_file_mention_extends_to_common_prefix() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
std::fs::create_dir_all(tmpdir.path().join("crates/tui")).unwrap();
|
||||
std::fs::write(tmpdir.path().join("crates/tui/lib.rs"), "//").unwrap();
|
||||
std::fs::write(tmpdir.path().join("crates/tui/main.rs"), "//").unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
app.workspace = tmpdir.path().to_path_buf();
|
||||
app.input = "@crates/tui/".to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
|
||||
assert!(try_autocomplete_file_mention(&mut app));
|
||||
// Both files share the `crates/tui/` prefix and one more letter is
|
||||
// not unique (`l` vs `m`), so the partial extends to the common prefix
|
||||
// unchanged here, with the status surfacing both candidates.
|
||||
assert!(app.input.starts_with("@crates/tui/"));
|
||||
let preview = app
|
||||
.status_message
|
||||
.as_deref()
|
||||
.expect("status message should describe candidates");
|
||||
assert!(preview.contains("@crates/tui/lib.rs"));
|
||||
assert!(preview.contains("@crates/tui/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_autocomplete_file_mention_no_match_reports_status() {
|
||||
let tmpdir = TempDir::new().expect("tempdir");
|
||||
std::fs::write(tmpdir.path().join("README.md"), "x").unwrap();
|
||||
|
||||
let mut app = create_test_app();
|
||||
app.workspace = tmpdir.path().to_path_buf();
|
||||
app.input = "@nonexistent_xyz".to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
|
||||
assert!(try_autocomplete_file_mention(&mut app));
|
||||
assert_eq!(app.input, "@nonexistent_xyz");
|
||||
assert_eq!(
|
||||
app.status_message.as_deref(),
|
||||
Some("No files match @nonexistent_xyz")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_autocomplete_file_mention_returns_false_outside_mention() {
|
||||
let mut app = create_test_app();
|
||||
app.input = "no mention here".to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
assert!(!try_autocomplete_file_mention(&mut app));
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub enum ModalKind {
|
||||
Pager,
|
||||
SessionPicker,
|
||||
Config,
|
||||
ModelPicker,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -79,6 +80,17 @@ pub enum ViewEvent {
|
||||
session_id: String,
|
||||
title: String,
|
||||
},
|
||||
/// Emitted by the `/model` picker on Enter — carries both the chosen
|
||||
/// model id and reasoning effort tier so the UI handler can update App
|
||||
/// state, persist via `Settings`, and forward `Op::SetModel` to the
|
||||
/// running engine. `previous_*` fields let the handler skip work when
|
||||
/// nothing changed and craft a clear status message.
|
||||
ModelPickerApplied {
|
||||
model: String,
|
||||
effort: crate::tui::app::ReasoningEffort,
|
||||
previous_model: String,
|
||||
previous_effort: crate::tui::app::ReasoningEffort,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -36,6 +36,11 @@ pub struct HeaderData<'a> {
|
||||
/// Short label for the current reasoning-effort tier (e.g. "max", "high",
|
||||
/// "off"). Rendered as a chip when space allows.
|
||||
pub reasoning_effort_label: Option<&'a str>,
|
||||
/// Short label for the active provider (e.g. "NIM"). When `None` (the
|
||||
/// default-DeepSeek case), no provider chip is rendered. Surfaces the
|
||||
/// fact that requests are going somewhere other than DeepSeek's API so
|
||||
/// it's visible at a glance after a `/provider nvidia-nim`.
|
||||
pub provider_label: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> HeaderData<'a> {
|
||||
@@ -59,6 +64,7 @@ impl<'a> HeaderData<'a> {
|
||||
session_cost: 0.0,
|
||||
last_prompt_tokens: None,
|
||||
reasoning_effort_label: None,
|
||||
provider_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +75,14 @@ impl<'a> HeaderData<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a short provider label for the header chip. Pass `None` when on
|
||||
/// the default DeepSeek provider so the chip is hidden.
|
||||
#[must_use]
|
||||
pub fn with_provider(mut self, label: Option<&'a str>) -> Self {
|
||||
self.provider_label = label;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set token/cost fields.
|
||||
#[must_use]
|
||||
pub fn with_usage(
|
||||
@@ -202,6 +216,22 @@ impl<'a> HeaderWidget<'a> {
|
||||
)]
|
||||
}
|
||||
|
||||
fn provider_chip_spans(&self) -> Vec<Span<'static>> {
|
||||
let Some(label) = self.data.provider_label else {
|
||||
return Vec::new();
|
||||
};
|
||||
let trimmed = label.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Span::styled(
|
||||
trimmed.to_string(),
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)]
|
||||
}
|
||||
|
||||
fn effort_chip_spans(&self, include_prefix: bool) -> Vec<Span<'static>> {
|
||||
let Some(label) = self.data.reasoning_effort_label else {
|
||||
return Vec::new();
|
||||
@@ -210,15 +240,18 @@ impl<'a> HeaderWidget<'a> {
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let color = if trimmed.eq_ignore_ascii_case("off") {
|
||||
let is_off = trimmed.eq_ignore_ascii_case("off");
|
||||
let color = if is_off {
|
||||
palette::TEXT_HINT
|
||||
} else {
|
||||
palette::DEEPSEEK_SKY
|
||||
};
|
||||
let body = if include_prefix {
|
||||
format!("⚡{trimmed}")
|
||||
} else {
|
||||
let body = if !include_prefix {
|
||||
trimmed.to_string()
|
||||
} else if trimmed.eq_ignore_ascii_case("max") || trimmed.eq_ignore_ascii_case("maximum") {
|
||||
format!("\u{1F433} {trimmed}")
|
||||
} else {
|
||||
format!("\u{00B7} {trimmed}")
|
||||
};
|
||||
vec![Span::styled(body, Style::default().fg(color))]
|
||||
}
|
||||
@@ -231,14 +264,23 @@ impl<'a> HeaderWidget<'a> {
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let provider_spans = self.provider_chip_spans();
|
||||
let has_provider = !provider_spans.is_empty();
|
||||
if has_provider {
|
||||
spans.extend(provider_spans);
|
||||
}
|
||||
|
||||
let effort_spans = self.effort_chip_spans(true);
|
||||
let has_effort = !effort_spans.is_empty();
|
||||
if has_effort {
|
||||
if has_provider {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.extend(effort_spans);
|
||||
}
|
||||
|
||||
if self.data.is_streaming {
|
||||
if has_effort {
|
||||
if has_effort || has_provider {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
@@ -550,4 +592,39 @@ mod tests {
|
||||
assert!(rendered.contains("100%"));
|
||||
assert!(!rendered.contains("250%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_shows_provider_chip_when_set() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Agent,
|
||||
"deepseek-ai/deepseek-v4-flash",
|
||||
"deepseek-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
)
|
||||
.with_provider(Some("NIM")),
|
||||
72,
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("NIM"),
|
||||
"expected NIM chip in header, got: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_hides_provider_chip_when_default_deepseek() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Agent,
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
),
|
||||
72,
|
||||
);
|
||||
// Sanity: no `NIM` text leaks in when provider is None.
|
||||
assert!(!rendered.contains("NIM"));
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,9 @@ impl Renderable for ChatWidget {
|
||||
paragraph.render(self.content_area, buf);
|
||||
|
||||
if let Some(scrollbar) = self.scrollbar {
|
||||
let mut state = ScrollbarState::new(scrollbar.total)
|
||||
.position(scrollbar.top)
|
||||
let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible);
|
||||
let mut state = ScrollbarState::new(scrollable_range)
|
||||
.position(scrollbar.top.min(scrollable_range))
|
||||
.viewport_content_length(scrollbar.visible);
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
@@ -1057,7 +1058,7 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
COMPOSER_PANEL_HEIGHT, ComposerWidget, Renderable, apply_selection_to_line,
|
||||
COMPOSER_PANEL_HEIGHT, ChatWidget, ComposerWidget, Renderable, apply_selection_to_line,
|
||||
composer_height, composer_max_height, composer_min_input_rows, composer_top_padding,
|
||||
cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines,
|
||||
should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text,
|
||||
@@ -1065,7 +1066,9 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::palette;
|
||||
use crate::tui::app::{App, ComposerDensity, TuiOptions};
|
||||
use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
@@ -1434,4 +1437,140 @@ mod tests {
|
||||
});
|
||||
assert!(!should_render_empty_state(&app));
|
||||
}
|
||||
|
||||
/// Probe: confirm `cell.lines_with_motion` returns no Line whose total
|
||||
/// visual width exceeds the requested area width, even for pathological
|
||||
/// long single-line tool results.
|
||||
#[test]
|
||||
fn long_tool_result_lines_fit_requested_width() {
|
||||
let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "todo_write".to_string(),
|
||||
status: ToolStatus::Success,
|
||||
input_summary: Some("items: <2 items>".to_string()),
|
||||
output: Some("hello world ".repeat(420)),
|
||||
}));
|
||||
for width in [40u16, 80, 111, 165] {
|
||||
let lines = cell.lines(width);
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
let visual: usize = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
|
||||
.sum();
|
||||
assert!(
|
||||
visual <= usize::from(width),
|
||||
"line {idx} at width {width} has visual width {visual} > {width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: a long single-line tool result must not write any cells
|
||||
/// outside the chat content area (issue #36 — sidebar gutter bleed).
|
||||
///
|
||||
/// We render `ChatWidget` into a buffer that is wider than the chat area
|
||||
/// (simulating the sidebar split) and assert every cell to the right of
|
||||
/// `chat_area` is still the default empty cell.
|
||||
#[test]
|
||||
fn chat_widget_does_not_bleed_into_sidebar_for_long_tool_result() {
|
||||
// Reproduces the actual `todo_write` output shape: a status line,
|
||||
// a newline, then a pretty-printed JSON payload with long string
|
||||
// values. Run at several widths since the leak in the issue was
|
||||
// observed at ~165 cols.
|
||||
let cases: Vec<(u16, u16)> = vec![(80, 50), (120, 80), (165, 111), (200, 140)];
|
||||
for (total_width, chat_width) in cases {
|
||||
let mut app = create_test_app();
|
||||
let long_value: String = "hello world ".repeat(420);
|
||||
let json_payload = format!(
|
||||
"{{\n \"items\": [\n {{ \"id\": 1, \"content\": \"{long_value}\", \"status\": \"pending\" }}\n ]\n}}"
|
||||
);
|
||||
let output = format!("Todo list updated (1 items, 0% complete)\n{json_payload}");
|
||||
app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "todo_write".to_string(),
|
||||
status: ToolStatus::Success,
|
||||
input_summary: Some("todos: <1 items>".to_string()),
|
||||
output: Some(output),
|
||||
})));
|
||||
|
||||
let height: u16 = 30;
|
||||
let chat_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: chat_width,
|
||||
height,
|
||||
};
|
||||
let full_area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: total_width,
|
||||
height,
|
||||
};
|
||||
let mut buf = Buffer::empty(full_area);
|
||||
|
||||
let widget = ChatWidget::new(&mut app, chat_area);
|
||||
widget.render(chat_area, &mut buf);
|
||||
|
||||
// Every cell outside chat_area should remain at default. If the
|
||||
// widget bled, we'll see leftover symbols.
|
||||
let default_symbol = " ";
|
||||
for y in 0..height {
|
||||
for x in chat_width..total_width {
|
||||
let cell = &buf[(x, y)];
|
||||
let sym = cell.symbol();
|
||||
assert!(
|
||||
sym == default_symbol || sym.is_empty(),
|
||||
"[{total_width}x{height}, chat={chat_width}] cell ({x},{y}) leaked content {sym:?} outside chat_area"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: when the transcript scrollbar is visible, the rightmost
|
||||
/// content column must remain readable (the scrollbar gets its own
|
||||
/// 1-column gutter rather than overdrawing chat content).
|
||||
#[test]
|
||||
fn chat_widget_reserves_scrollbar_gutter_when_scrollbar_visible() {
|
||||
let mut app = create_test_app();
|
||||
// Many short messages → forces the scrollbar to be visible.
|
||||
for i in 0..200 {
|
||||
app.add_message(HistoryCell::User {
|
||||
content: format!("user message {i}"),
|
||||
});
|
||||
}
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 80,
|
||||
height: 8,
|
||||
};
|
||||
let mut buf = Buffer::empty(area);
|
||||
let widget = ChatWidget::new(&mut app, area);
|
||||
widget.render(area, &mut buf);
|
||||
|
||||
// The rightmost column should host the scrollbar track/thumb.
|
||||
// The penultimate column should still hold normal content (a digit,
|
||||
// letter, or space — never the scrollbar glyph).
|
||||
let scrollbar_track = "│";
|
||||
let scrollbar_thumb = "┃";
|
||||
let mut scrollbar_seen = false;
|
||||
for y in 0..area.height {
|
||||
let last = buf[(area.width - 1, y)].symbol();
|
||||
let penult = buf[(area.width - 2, y)].symbol();
|
||||
if last == scrollbar_track || last == scrollbar_thumb {
|
||||
scrollbar_seen = true;
|
||||
}
|
||||
assert!(
|
||||
penult != scrollbar_track && penult != scrollbar_thumb,
|
||||
"scrollbar leaked into column {} (cell {:?}) at row {y}",
|
||||
area.width - 2,
|
||||
penult
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
scrollbar_seen,
|
||||
"scrollbar should be visible for a long history"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Per-workspace trust list of external paths the agent may read/write
|
||||
//! without triggering a `PathEscape` error (#29).
|
||||
//!
|
||||
//! Storage: `~/.deepseek/workspace-trust.json`. The file is a JSON object
|
||||
//! mapping each workspace's canonical path to a sorted list of canonical
|
||||
//! paths the user has explicitly trusted from that workspace. Trust granted
|
||||
//! in workspace A does not apply when running from workspace B.
|
||||
//!
|
||||
//! Threat model: this is a deliberate user opt-in to a path the workspace
|
||||
//! sandbox would otherwise refuse. The only access the trust list grants is
|
||||
//! through DeepSeek-TUI's own file tools (`read_file`, `write_file`, etc.) —
|
||||
//! it does not loosen the OS sandbox profile (Seatbelt/Landlock) used for
|
||||
//! shell commands. Sandbox-profile expansion is tracked separately so a
|
||||
//! shell tool can opt into the same paths in a future release.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const TRUST_FILE_NAME: &str = "workspace-trust.json";
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
struct TrustFile {
|
||||
/// Map workspace canonical path → sorted unique trusted paths.
|
||||
#[serde(default)]
|
||||
workspaces: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// In-memory trust list for a single workspace, snapshotted at load time.
|
||||
/// Tools consult this snapshot to decide whether an out-of-workspace path
|
||||
/// is permitted; the engine refreshes it after `/trust` mutations.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WorkspaceTrust {
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl WorkspaceTrust {
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn empty() -> Self {
|
||||
Self { paths: Vec::new() }
|
||||
}
|
||||
|
||||
/// Load the trusted-paths snapshot for `workspace` from disk. Missing or
|
||||
/// malformed files yield an empty list rather than an error so a corrupt
|
||||
/// trust file never wedges the TUI; the next mutation rewrites it.
|
||||
#[must_use]
|
||||
pub fn load_for(workspace: &Path) -> Self {
|
||||
match trust_file_path() {
|
||||
Some(path) => Self::load_from_file(workspace, &path),
|
||||
None => Self::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_file(workspace: &Path, file_path: &Path) -> Self {
|
||||
let key = workspace_key(workspace);
|
||||
let file = read_trust_file_at(file_path).unwrap_or_default();
|
||||
let paths = file
|
||||
.workspaces
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
Self { paths }
|
||||
}
|
||||
|
||||
/// Return the trusted paths in canonical form.
|
||||
#[must_use]
|
||||
pub fn paths(&self) -> &[PathBuf] {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
/// Whether the candidate is trusted: the candidate (after canonical
|
||||
/// normalization) starts with one of the trusted prefixes. Directory
|
||||
/// trust grants access to anything under the directory.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn permits(&self, candidate: &Path) -> bool {
|
||||
let canonical = candidate
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| candidate.to_path_buf());
|
||||
self.paths
|
||||
.iter()
|
||||
.any(|trusted| canonical.starts_with(trusted))
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `path` to `workspace`'s trust list and persist. Returns the canonical
|
||||
/// trusted path that was actually stored, so callers can echo it back to the
|
||||
/// user.
|
||||
pub fn add(workspace: &Path, path: &Path) -> Result<PathBuf> {
|
||||
let trust_path = trust_file_path()
|
||||
.context("home directory not available; cannot persist workspace trust list")?;
|
||||
add_at(workspace, path, &trust_path)
|
||||
}
|
||||
|
||||
fn add_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result<PathBuf> {
|
||||
let canonical = canonicalize_or_keep(path);
|
||||
let key = workspace_key(workspace);
|
||||
let mut file = read_trust_file_at(trust_path).unwrap_or_default();
|
||||
let entry = file.workspaces.entry(key).or_default();
|
||||
let stored = canonical.to_string_lossy().to_string();
|
||||
if !entry.iter().any(|p| p == &stored) {
|
||||
entry.push(stored.clone());
|
||||
entry.sort();
|
||||
entry.dedup();
|
||||
}
|
||||
write_trust_file_at(&file, trust_path)?;
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
/// Remove `path` from `workspace`'s trust list. Returns true when an entry
|
||||
/// was actually removed.
|
||||
pub fn remove(workspace: &Path, path: &Path) -> Result<bool> {
|
||||
let Some(trust_path) = trust_file_path() else {
|
||||
return Ok(false);
|
||||
};
|
||||
remove_at(workspace, path, &trust_path)
|
||||
}
|
||||
|
||||
fn remove_at(workspace: &Path, path: &Path, trust_path: &Path) -> Result<bool> {
|
||||
let canonical = canonicalize_or_keep(path);
|
||||
let key = workspace_key(workspace);
|
||||
let mut file = read_trust_file_at(trust_path).unwrap_or_default();
|
||||
let stored = canonical.to_string_lossy().to_string();
|
||||
let removed = match file.workspaces.get_mut(&key) {
|
||||
Some(entry) => {
|
||||
let len_before = entry.len();
|
||||
entry.retain(|p| p != &stored);
|
||||
let changed = entry.len() != len_before;
|
||||
if entry.is_empty() {
|
||||
file.workspaces.remove(&key);
|
||||
}
|
||||
changed
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if removed {
|
||||
write_trust_file_at(&file, trust_path)?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn workspace_key(workspace: &Path) -> String {
|
||||
canonicalize_or_keep(workspace)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn canonicalize_or_keep(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
fn trust_file_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".deepseek").join(TRUST_FILE_NAME))
|
||||
}
|
||||
|
||||
fn read_trust_file_at(path: &Path) -> Result<TrustFile> {
|
||||
if !path.exists() {
|
||||
return Ok(TrustFile::default());
|
||||
}
|
||||
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
|
||||
}
|
||||
|
||||
fn write_trust_file_at(file: &TrustFile, path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create dir {}", parent.display()))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(file).context("serialize trust file")?;
|
||||
std::fs::write(path, json).with_context(|| format!("write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Set up an isolated fake `~/.deepseek/workspace-trust.json` location.
|
||||
/// Returns the tmpdir (kept alive for the test) plus the explicit trust
|
||||
/// file path passed to the `*_at` helpers — avoids touching `$HOME` so
|
||||
/// tests run safely in parallel.
|
||||
fn isolated_trust_path() -> (TempDir, PathBuf) {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let trust_path = tmp.path().join(".deepseek").join("workspace-trust.json");
|
||||
(tmp, trust_path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_trust_for_unknown_workspace() {
|
||||
let (tmp, trust_path) = isolated_trust_path();
|
||||
let workspace = tmp.path().join("ws");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
|
||||
assert!(trust.paths().is_empty());
|
||||
assert!(!trust.permits(Path::new("/anywhere")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_persists_and_load_returns_path() {
|
||||
let (tmp, trust_path) = isolated_trust_path();
|
||||
let workspace = tmp.path().join("ws");
|
||||
let other = tmp.path().join("data/notes");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
|
||||
let stored = add_at(&workspace, &other, &trust_path).expect("add");
|
||||
// On macOS, /var/folders is a symlink to /private/var/folders so the
|
||||
// canonical form may live under that prefix. Compare using
|
||||
// canonicalize on both ends.
|
||||
let canonical_other = other.canonicalize().unwrap_or(other.clone());
|
||||
assert_eq!(stored, canonical_other);
|
||||
|
||||
let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
|
||||
assert_eq!(trust.paths().len(), 1);
|
||||
// Create the file so canonicalize resolves through any symlinks; the
|
||||
// stored trust path uses the canonical form.
|
||||
let inner = other.join("file.md");
|
||||
std::fs::write(&inner, "x").unwrap();
|
||||
assert!(trust.permits(&inner));
|
||||
assert!(!trust.permits(Path::new("/etc/passwd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_is_idempotent() {
|
||||
let (tmp, trust_path) = isolated_trust_path();
|
||||
let workspace = tmp.path().join("ws");
|
||||
let other = tmp.path().join("data/notes");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
|
||||
let _ = add_at(&workspace, &other, &trust_path).unwrap();
|
||||
let _ = add_at(&workspace, &other, &trust_path).unwrap();
|
||||
let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
|
||||
assert_eq!(trust.paths().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_is_workspace_scoped() {
|
||||
let (tmp, trust_path) = isolated_trust_path();
|
||||
let ws_a = tmp.path().join("ws-a");
|
||||
let ws_b = tmp.path().join("ws-b");
|
||||
let other = tmp.path().join("data/notes");
|
||||
std::fs::create_dir_all(&ws_a).unwrap();
|
||||
std::fs::create_dir_all(&ws_b).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
|
||||
add_at(&ws_a, &other, &trust_path).unwrap();
|
||||
assert_eq!(
|
||||
WorkspaceTrust::load_from_file(&ws_a, &trust_path)
|
||||
.paths()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
WorkspaceTrust::load_from_file(&ws_b, &trust_path)
|
||||
.paths()
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_path() {
|
||||
let (tmp, trust_path) = isolated_trust_path();
|
||||
let workspace = tmp.path().join("ws");
|
||||
let other = tmp.path().join("data/notes");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
|
||||
add_at(&workspace, &other, &trust_path).unwrap();
|
||||
let removed = remove_at(&workspace, &other, &trust_path).unwrap();
|
||||
assert!(removed);
|
||||
|
||||
let trust = WorkspaceTrust::load_from_file(&workspace, &trust_path);
|
||||
assert!(trust.paths().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ Current packaging note:
|
||||
Run these from the repository root before cutting a tag:
|
||||
|
||||
```bash
|
||||
./scripts/release/check-versions.sh # version drift between workspace, npm, lockfile
|
||||
cargo fmt --all -- --check
|
||||
cargo check --workspace --all-targets --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
@@ -50,6 +51,11 @@ cargo publish --dry-run --locked --allow-dirty -p deepseek-tui
|
||||
./scripts/release/publish-crates.sh dry-run
|
||||
```
|
||||
|
||||
`check-versions.sh` also runs in CI on every push/PR (the `versions` job in
|
||||
`.github/workflows/ci.yml`), so drift between `Cargo.toml`, the per-crate
|
||||
manifests, `npm/deepseek-tui/package.json`, and `Cargo.lock` is caught before
|
||||
release time rather than at it.
|
||||
|
||||
`publish-crates.sh dry-run` performs a full `cargo publish --dry-run` for crates
|
||||
without unpublished workspace dependencies and a packaging preflight for dependent
|
||||
workspace crates. That avoids false negatives from crates.io not yet containing the
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Tool surface
|
||||
|
||||
Why these specific tools, in this groupings, and how each one is meant to be
|
||||
chosen over the available shell equivalent. Companion to `crates/tui/src/prompts/agent.txt`.
|
||||
|
||||
## Design stance
|
||||
|
||||
- **Dedicated tools over `exec_shell` whenever the dedicated tool returns
|
||||
structured output.** Bash escaping is error-prone and platform behavior
|
||||
varies (GNU vs BSD `grep`, `rg` is not always installed). Structured
|
||||
output also frees the model from re-parsing free-form text.
|
||||
- **`exec_shell` for everything else.** Build, test, format, lint, ad-hoc
|
||||
commands, anything platform-specific. We don't try to wrap the long tail.
|
||||
- **Drop tools that don't beat their shell equivalent.** Two-tool aliases
|
||||
for the same backing operation are a model trap — the LLM will alternate
|
||||
between them and the cache hit rate suffers.
|
||||
|
||||
## Final surface (v0.5.1)
|
||||
|
||||
### File operations
|
||||
|
||||
| Tool | Niche |
|
||||
|---|---|
|
||||
| `read_file` | Read a UTF-8 file. PDFs auto-extracted via `pdftotext` (poppler) when available; `pages: "1-5"` slices large docs. |
|
||||
| `list_dir` | Structured, gitignore-aware listing. Preferred over `exec_shell("ls")`. |
|
||||
| `write_file` | Create or overwrite a file. |
|
||||
| `edit_file` | Search-and-replace inside a single file. Cheaper than a full rewrite. |
|
||||
| `apply_patch` | Apply a unified diff. The right tool for multi-hunk edits. |
|
||||
|
||||
### Search
|
||||
|
||||
| Tool | Niche |
|
||||
|---|---|
|
||||
| `grep_files` | Regex search file contents within the workspace; structured matches + context lines. Pure-Rust (`regex` crate), no `rg`/`grep` shell-out. |
|
||||
| `file_search` | Fuzzy-match filenames (not contents). Use when you know roughly the name. |
|
||||
| `web_search` | DuckDuckGo (with Bing fallback); ranked snippets + `ref_id` for citation. |
|
||||
| `fetch_url` | Direct HTTP GET on a known URL. Faster than `web_search` when the link is already known. HTML stripped to text by default. |
|
||||
|
||||
### Shell
|
||||
|
||||
| Tool | Niche |
|
||||
|---|---|
|
||||
| `exec_shell` | Run a shell command. Foreground or background (`background: true` returns a `task_id`). |
|
||||
| `exec_shell_wait` | Poll a background task for incremental output. |
|
||||
| `exec_shell_interact` | Send stdin to a running background task and read incremental output. |
|
||||
|
||||
### Git / diagnostics / testing
|
||||
|
||||
| Tool | Niche |
|
||||
|---|---|
|
||||
| `git_status` | Inspect repo status without running shell. |
|
||||
| `git_diff` | Inspect working-tree or staged diffs. |
|
||||
| `diagnostics` | Workspace, git, sandbox, and toolchain info in one call. |
|
||||
| `run_tests` | `cargo test` with optional args. |
|
||||
|
||||
### Task management
|
||||
|
||||
| Tool | Niche |
|
||||
|---|---|
|
||||
| `todo_write` | Granular per-item progress. |
|
||||
| `update_plan` | Structured checklist for complex multi-step work. |
|
||||
| `note` | One-off important fact for later. |
|
||||
|
||||
### Sub-agents
|
||||
|
||||
`agent_spawn`, `agent_swarm`, `spawn_agents_on_csv`, plus the supporting
|
||||
tools (`agent_result` / `swarm_result` / `wait` / `send_input` /
|
||||
`agent_assign` / `agent_cancel` / `resume_agent` / `agent_list` /
|
||||
`report_agent_job_result` / `swarm_status`). See `agent.txt` for the
|
||||
delegation protocol.
|
||||
|
||||
## Recently consolidated (v0.5.1)
|
||||
|
||||
Removed from the prompt as duplicates of equivalent tools (the underlying
|
||||
dispatchers still resolve them, so existing sessions don't break — they just
|
||||
no longer pollute the model's tool list):
|
||||
|
||||
- `spawn_agent` → use `agent_spawn`.
|
||||
- `close_agent` → use `agent_cancel`.
|
||||
- `assign_agent` → use `agent_assign`.
|
||||
|
||||
## Why we don't ship a single `bash` tool
|
||||
|
||||
Single-`bash` agents (Claude Code's design) are powerful but hand the model
|
||||
all the foot-guns of shell scripting: quoting, platform divergence,
|
||||
side-effects from misread cwd, `cd` not persisting between calls, etc. Our
|
||||
file tools are also significantly cheaper to render in the transcript
|
||||
(structured JSON-shaped output collapses better than `ls -la` walls of text).
|
||||
|
||||
The model can always fall back to `exec_shell` when something is missing.
|
||||
The dedicated tools just take the common 80% off the shell escape-hatch.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deepseek-tui",
|
||||
"version": "0.4.7",
|
||||
"deepseekBinaryVersion": "0.4.7",
|
||||
"version": "0.5.2",
|
||||
"deepseekBinaryVersion": "0.5.2",
|
||||
"description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.",
|
||||
"author": "Hmbown",
|
||||
"license": "MIT",
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fails CI if version state is inconsistent across the workspace, npm
|
||||
# wrapper, and Cargo.lock. Run on every push/PR so silent drift can't ship.
|
||||
#
|
||||
# Checks performed:
|
||||
# 1. No `crates/*/Cargo.toml` carries a literal `version = "x.y.z"`; every
|
||||
# crate must inherit `version.workspace = true`.
|
||||
# 2. `npm/deepseek-tui/package.json` `version` matches the workspace
|
||||
# `version` in the root `Cargo.toml`.
|
||||
# 3. `Cargo.lock` is in sync with the manifests (`cargo metadata --locked`
|
||||
# fails if not).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
fail=0
|
||||
|
||||
# 1) Literal versions in crate manifests.
|
||||
literals="$(grep -nE '^version = "' crates/*/Cargo.toml || true)"
|
||||
if [[ -n "${literals}" ]]; then
|
||||
echo "::error::Crate manifests must use 'version.workspace = true', not literal versions:" >&2
|
||||
echo "${literals}" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# 2) Workspace ↔ npm package.json.
|
||||
workspace_version="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
|
||||
npm_version="$(node -p "require('./npm/deepseek-tui/package.json').version")"
|
||||
if [[ "${workspace_version}" != "${npm_version}" ]]; then
|
||||
echo "::error::npm/deepseek-tui/package.json version (${npm_version}) does not match workspace Cargo.toml (${workspace_version})." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# 3) Cargo.lock in sync.
|
||||
if ! cargo metadata --locked --format-version 1 --no-deps >/dev/null 2>&1; then
|
||||
echo "::error::Cargo.lock is out of sync with the manifests. Run 'cargo update -p deepseek-tui' or 'cargo build' and commit the result." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "${fail}" -eq 0 ]]; then
|
||||
echo "Version state OK: workspace=${workspace_version}, npm=${npm_version}, lockfile in sync."
|
||||
fi
|
||||
|
||||
exit "${fail}"
|
||||
Reference in new issue
Block a user