Compare commits

..
24 Commits
Author SHA1 Message Date
Hunter BownandClaude Opus 4.7 ba6bc351da release: v0.5.2 — /model picker
Single-feature release that lands #39 (the /model two-pane picker)
on top of the v0.5.1 quality-of-life batch. Bumps workspace +
npm wrapper + Cargo.lock in lockstep; check-versions.sh verifies.

Closes #39.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 15:00:19 -05:00
Hunter BownandClaude Opus 4.7 ebdda09c29 feat(#39): /model opens Pro/Flash + Off/High/Max picker
`/model` with no argument now opens a two-pane modal: model on the left
(deepseek-v4-pro flagship vs deepseek-v4-flash fast-and-cheap, with the
current id appearing as a "current (custom)" row when it isn't one of
the listed defaults), and thinking effort on the right (Off, High,
Max). Tab/←/→ swaps panes, ↑/↓ moves within the focused pane, Enter
applies both, Esc cancels.

Effort exposes only the three rows DeepSeek behaviorally distinguishes
per the Thinking Mode docs — `low`/`medium` are mapped server-side to
`high`, and `xhigh` to `max`, so listing them as separate choices would
mislead. The legacy variants stay valid in `~/.deepseek/settings.toml`
for back-compat (the existing `cycle_next` already only visits
Off→High→Max), the picker just doesn't surface them.

Apply path:
 * mutates app.model and app.reasoning_effort
 * resets last_*_tokens / cache / replay-token gauges so the next-turn
   footer numbers reflect the new model rather than stale ones
 * persists `default_model` and `reasoning_effort` to settings via the
   existing Settings::set/save flow so the choice survives restart
 * forwards Op::SetModel + Op::SetCompaction to the engine so the
   running session picks up the new compaction budget
 * surfaces a one-line summary describing what changed
 * if persistence fails, the in-memory change still applies and a
   "(not persisted: ...)" suffix is appended to the status line

`/model <id>` keeps working unchanged for power users; only the
no-argument branch was redirected to the new modal.

Files:
 * tui/model_picker.rs — new ModelPickerView struct + ModalView impl,
   plus eight unit tests (initial state, low/medium normalisation,
   custom model preservation, arrow navigation, focus toggle, Enter
   emits ModelPickerApplied with the right values, Esc closes silently,
   and a guard that the picker exposes exactly off/high/max).
 * tui/views/mod.rs — adds ModalKind::ModelPicker and
   ViewEvent::ModelPickerApplied carrying both new and previous
   model+effort so the handler can describe the diff.
 * tui/app.rs — adds AppAction::OpenModelPicker.
 * commands/core.rs — `/model` no-arg branch now returns
   AppAction::OpenModelPicker; `/model <id>` shortcut is unchanged.
 * tui/ui.rs — pushes ModelPickerView on the action and adds
   apply_model_picker_choice() that handles persistence + engine sync
   when ViewEvent::ModelPickerApplied fires.
 * tui/mod.rs — registers the new submodule.

Closes #39 (against v0.5.2).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:50:49 -05:00
Hunter BownandClaude Opus 4.7 f42f94207c release: v0.5.1 — telemetry, completion, and trust quality-of-life
Bumps workspace version to 0.5.1 and finalises the changelog with the
issues that landed since v0.5.0:
 * #25, #27, #31, #33, #34 (already on main)
 * #28 @file Tab-completion
 * #29 per-workspace trust list with /trust slash command
 * #30 reasoning-replay token chip in the footer
 * #36 regression tests for sidebar gutter bleed

scripts/release/check-versions.sh is green: workspace=0.5.1, npm=0.5.1,
Cargo.lock in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:40:06 -05:00
Hunter BownandClaude Opus 4.7 d79178a926 feat(#28,#30): @file Tab-completion + reasoning replay footer chip
Two related TUI affordances bundled because they share ui.rs and the
ui/tests.rs file.

#30 — Reasoning-content replay telemetry, end-to-end:
 * models.rs — Usage gains reasoning_replay_tokens: Option<u32>.
 * client.rs — sanitize_thinking_mode_messages now returns the
   approximate replay-token count (~4 chars/token); the streaming
   pipeline overlays it onto the parsed MessageDelta usage so the
   server-reported and client-estimated numbers reach the engine
   together.
 * app.rs — App stores last_reasoning_replay_tokens.
 * ui.rs — TurnComplete handler copies the value into the App; new
   footer_reasoning_replay_spans renders an `rsn N.Nk` chip in the
   footer next to the cache hit-rate, warning-coloured when replay
   tokens exceed 50% of the input budget.
 * ui/tests.rs — covers chip-on, chip-hidden-when-zero, and the
   sanitizer's None-on-non-thinking-model path.

#28 — Tab-complete @file mentions against the workspace:
 * ui.rs — adds partial_file_mention_at_cursor (with a guard against
   `[email protected]`-style false positives) and
   try_autocomplete_file_mention. Walks the workspace via the
   existing ignore::WalkBuilder, ranks prefix matches above
   substring matches, applies the unique match outright, extends to
   the longest common prefix when multiple match, and surfaces
   ambiguous candidates via the status line. Wired into the existing
   Tab handler after the slash-command branch.
 * ui/tests.rs — covers cursor-inside-mention extraction, email
   guard, prefix vs substring ranking, single-match application,
   common-prefix extension, no-match status, and the
   no-mention-no-op path.

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.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:39:59 -05:00
Hunter BownandClaude Opus 4.7 bcf6ba9a8e feat(#29): per-workspace trust list with /trust slash command
Adds a persistent allowlist of external paths the agent may read/write
from outside the current workspace, scoped to the workspace it was
granted in. The list lives in ~/.deepseek/workspace-trust.json with
schema {"workspaces": {"<ws>": ["<trusted>", ...]}}; canonical paths on
both sides keep symlink-aliased macOS tempdirs sane.

Surface area:
 * crates/tui/src/workspace_trust.rs — new module: load_for / add /
   remove plus *_at variants for tests that need an explicit file path
   rather than HOME mutation.
 * tools/spec.rs — ToolContext gains trusted_external_paths and
   resolve_path consults it before returning PathEscape, both for the
   existing-path branch and the to-be-created (parent-canonical) branch.
 * core/engine.rs — build_tool_context loads the trust snapshot on every
   tool dispatch so /trust mutations apply on the next call.
 * commands/config.rs — /trust now takes subcommands (add, remove,
   list, on, off, status) instead of being a single all-or-nothing
   toggle. Tilde expansion handled in-line.
 * commands/mod.rs — registry entry updated with the new usage string
   and a dispatcher that forwards args.
 * tools/diagnostics.rs — adds trusted_external_paths to the JSON
   output so the agent and the user can see the list at a glance.

The interactive "Allow once / Always allow / Deny" prompt that the
issue describes is deferred — for v0.5.1 the workflow is "grant
ahead with /trust add". A future change will add a hook in
ToolContext::resolve_path that surfaces an ApprovalRequest when an
escape path is hit, so the slash-command remains the durable
mechanism while the prompt becomes the discovery one.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:39:37 -05:00
Hunter BownandClaude Opus 4.7 7a85f182e2 test(#36): regression tests for sidebar gutter bleed
Adds two snapshot tests against ChatWidget rendering to lock in that long
single-line tool results never write any cells outside chat_area at the
widths reported in the bug (80, 120, 165, 200 cols), and that the
scrollbar coexists with content along the right edge instead of
overdrawing the penultimate column. The acceptance criterion in the
issue specifically requires this regression coverage; the tests pass
against current code, so existing rendering is the baseline being
guarded.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-25 14:39:19 -05:00
Hunter Bown f7fe5e09a5 fix(#37): make NIM a peer provider in config.example.toml + setup status
A user couldn't find an `NVIDIA_API_KEY` block in `~/.deepseek/config.toml`
because the example file only mentioned NIM as commented-out alternates
to the top-level keys. Two fixes:

- `config.example.toml` now has explicit `[providers.deepseek]` and
  `[providers.nvidia_nim]` sections (placed after all top-level keys so
  the TOML still parses cleanly), each documenting `api_key` /
  `base_url` / `model` plus the env vars that override them. Both
  providers can be stored at once and toggled via `/provider` or
  `--provider` without re-entering keys.
- `setup --status` "missing api_key" message is now provider-aware: on
  `nvidia-nim` it points at `NVIDIA_API_KEY` + `[providers.nvidia_nim]`
  + `deepseek auth set --provider nvidia-nim`, instead of the
  DeepSeek-only hint.

Audit verified: the v0.5.0 multi-turn replay fix path
(`should_replay_reasoning_content` → `requires_reasoning_content` in
`crates/tui/src/client.rs:1796`) keys off the model name (matches
`deepseek-v4`), not the provider, so NIM-hosted V4 models get the
replay automatically. No NIM-specific 400-class regression there.

Closes #37 (docs/UX); the live multi-turn-against-NIM verification
remains a manual smoke step listed in the issue (no NIM creds in CI).
2026-04-25 13:52:44 -05:00
Hunter Bown 24b8945010 feat(#32): basic session-handoff convention via .deepseek/handoff.md
Minimum-viable version of the handoff artifact described in #32:

- New `HANDOFF_RELATIVE_PATH = ".deepseek/handoff.md"` convention.
- `system_prompt_for_mode_with_context` now reads that path on every
  prompt rebuild and prepends a `## Previous Session Handoff` block to
  the system prompt when the file is non-empty. A fresh agent gets the
  prior session's blockers/decisions/files-touched in turn-1 context
  with zero discovery cost.
- Agent prompt updated to make the convention explicit: "if the block
  appears, read it first; before exit/`/compact`, write or update it
  via `write_file`."
- `.deepseek/` is already gitignored, so the handoff travels with the
  workspace but doesn't pollute commits unless the user opts in.

Tests cover: present-and-non-empty (block injected with file content),
missing file (no block), empty/whitespace-only file (no block). A
unique marker in the injected block (`"left a handoff at .deepseek/..."`)
discriminates the actual block from the agent prompt's own description
of the convention.

Out of scope for v0.5.1: a `/handoff` slash command, a startup banner
toast, automatic write on exit, and the diff-against-HEAD-on-resume
mechanism. The agent can already write the file via `write_file` when
the user types `write a session handoff`.

Closes #32.
2026-04-25 13:48:22 -05:00
Hunter Bown 82e4a564aa refactor(#35): tighten agent prompt tool descriptions, drop alias dupes
Tool-surface audit pass:

- FILE OPERATIONS rewritten so each line states the niche, not just the
  verb. read_file mentions PDF auto-extraction + `pages` slicing.
- New SEARCH section consolidates grep_files / file_search / web_search /
  fetch_url so the model sees them next to each other and picks the
  right one. fetch_url (#33) added; previously absent from the prompt.
- request_user_input pulled out of FILE OPERATIONS into its own USER
  section — it never belonged there.
- SUB-AGENTS list shrinks by 3: drops `spawn_agent` (use `agent_spawn`),
  `close_agent` (use `agent_cancel`), and the `agent_assign /
  assign_agent` dual-name. The underlying dispatchers still resolve those
  names, so existing sessions don't break — they just no longer
  pollute the model's tool list.

Adds `docs/TOOL_SURFACE.md` with the rationale, the v0.5.1 final
surface, and the dropped aliases. Calls out that grep_files is pure-Rust
(no rg/grep shell-out, so the "fall back to grep" AC from #35 is
vacuously satisfied — the tool has no shell dependency to fall back from).

Closes #35.
2026-04-25 13:44:43 -05:00
Hunter Bown 07ae792068 fix(#38): show provider chip in header when not on default DeepSeek
The reasoning-effort tier (`max` chip + whale icon) and the live/context
indicators were the only signals on the right of the header. Switching
to nvidia-nim left the right-hand side identical to a DeepSeek session,
so it wasn't obvious at a glance that requests were going to a different
backend.

Now: when `app.api_provider != Deepseek`, the header surfaces a bold
`NIM` chip on the right, leftmost in the chip cluster (so it survives
the narrow-width fallback variants in `right_spans`). Default-DeepSeek
sessions are unchanged — `provider_label = None` short-circuits the
chip.

Closes #38.
2026-04-25 13:41:52 -05:00
Hunter Bown ba40ae4aac feat(#34): auto-extract text from PDFs in read_file
`read_file` now detects PDFs by extension or `%PDF-` magic bytes and
shells out to `pdftotext -layout` (poppler) to return plain text
directly to the model. New optional `pages` arg accepts `N` or `N-M`
slices so big papers can be read in pieces without burning context.

When `pdftotext` isn't on `$PATH`, the tool returns a structured
`{type: "binary_unavailable", kind: "pdf", reason, hint}` payload with
install hints (`brew install poppler` / `apt install poppler-utils`)
instead of crashing or returning UTF-8 garbage from a binary file.

Tests cover extension detection (case-insensitive), magic-byte sniffing
on extension-less files, the negative case for plain text, the pages
arg parser (single, range, whitespace, invalid forms), and the
binary_unavailable branch when `pdftotext` is absent.

.docx / .epub / .html stripping deferred — same dispatch can take more
extractors later.

Closes #34.
2026-04-25 13:36:30 -05:00
Hunter Bown 7f2c382343 feat(#33): add fetch_url tool for direct HTTP GET
Complements `web_search` for cases where the URL is already known —
GitHub repos, blog posts, spec pages — and a search-engine round trip is
overkill or actively unhelpful (which #25 had been making worse).

Surface:
- `fetch_url(url, format?, max_bytes?, timeout_ms?)`
- `format`: `markdown` (default), `text`, `raw`
- HTTPS preferred, http:// allowed; non-http schemes rejected up front
- Follows up to 5 redirects; 1 MB default cap (10 MB hard ceiling); 15 s
  default timeout (60 s ceiling)
- HTML responses are stripped to readable text via the same regex
  pattern used by `web_search` (script/style strip → tag strip → entity
  decode → whitespace collapse)
- 4xx / 5xx responses still return the body (with `success: false`) so
  the caller can read JSON error envelopes

Capabilities: `ReadOnly + Network`. Approval: `Auto` (matches
`web_search`). Registered in `with_web_tools` so it's available wherever
`web_search` is.

Tests cover: format parsing aliases, scheme rejection, missing/empty
url validation, html-to-text stripping. The over-the-wire cases
(redirect chains, oversized truncation) are exercised by integration
tests once the test suite is wired to a local mock HTTP server —
deferring that since the unit tests already lock in the input
validation and HTML processing.

Closes #33.
2026-04-25 13:33:22 -05:00
Hunter Bown 017ac97d0d feat(#30): debug-log reasoning_content replay size per request
The thinking-mode sanitizer now sums the byte size of every replayed
`reasoning_content` field in the outgoing chat-completions body and
emits an `info`-level log line:

  Reasoning-content replay: 7 assistant message(s), ~3.2K input tokens (12,884 chars) being re-sent in this request

This is visible under `RUST_LOG=deepseek_tui=info` (or higher). It's the
first step toward the footer/status-line indicator described in #30 —
the model's input-side reasoning replay is now observable per turn,
even before it gets a dedicated UI surface.

Tests cover both branches: bodies that already have reasoning_content
(count is summed across all assistant turns) and bodies where the
sanitizer had to inject the `(reasoning omitted)` placeholder (the
placeholder bytes are included in the count since they ship over the
wire).

Footer integration deferred — that needs a new event from client → engine
→ TUI to surface the count alongside `cache N%` / `$X.XX`. Part of #30
remains open.
2026-04-25 13:28:44 -05:00
Hunter Bown 0a394e1587 fix(#31): catch version drift in CI, not at release time
Adds scripts/release/check-versions.sh and a `versions` CI job that runs
on every push/PR. Verifies:
- no per-crate Cargo.toml carries a literal version (must inherit the
  workspace version)
- npm/deepseek-tui/package.json matches the workspace version
- Cargo.lock is in sync with the manifests

Closes #31.
2026-04-25 13:25:55 -05:00
Hunter Bown fafb76063d ci: fix unused import + cargo fmt drift from #27
The #27 per-mode context budget commit (1be18e69) replaced calls to
compaction_threshold_for_model with compaction_threshold_for_model_and_effort
but left the old name in the import list, which fails under -Dwarnings on
Build, Test, and the npm wrapper smoke job. Also re-runs cargo fmt over
the four files the lint job flagged.
2026-04-25 13:21:16 -05:00
Hunter Bown 9e7cfc951a docs: README v0.5.0 callout + CHANGELOG entry, drop cargo install
- README: drop the `cargo install deepseek-tui` / `deepseek-tui-cli`
  block and the crates.io badge — those packages have been lagging the
  workspace version for several releases. Source install (`cargo install
  --path crates/tui`) remains documented for hackers.
- README: add a "What's new in v0.5.0" section pointing at the
  thinking-mode tool-call 400 fix and the #25 web.run cleanup.
- CHANGELOG: add the [0.5.0] - 2026-04-25 entry covering the per-message
  reasoning_content rule, the wire-payload sanitizer, and #25.
2026-04-25 13:15:35 -05:00
Hunter Bown 1be18e691b feat(#27): per-mode soft context budget for V4 compaction trigger
Add compaction_threshold_for_model_and_effort() with mode-aware soft
caps based on DeepSeek V4 paper Figure 9 recall-quality data:

  Plan / off   ->  64K (paper eval: 8K-128K)
  Agent / high -> 192K (paper eval: 128K)
  YOLO / max   -> 384K (paper eval: 384K-512K)

Previously, the 80%-of-window rule gave 800K for V4's 1M window,
which is well past the point where MRCR MMR collapses (0.49 at 1M).

Non-V4 models keep the legacy 80% rule. None/unknown effort defaults
to agent-tier (192K).
2026-04-25 12:58:35 -05:00
Hunter Bown ccc9554ef4 fix(#25): strip phantom web.run references from prompts and web_search tool
Replace all web.run mentions with web_search in prompt files (base,
agent, yolo, plan, normal) and update web_search.rs description.
Model was trying web.run which doesn't exist, wasting turns on
validation errors. Also remove [cite:ref_id] citation format which
required web.run's ref_id system.

Partial fix for #25 — web_search reliability improvements (real
search provider) still needed.
2026-04-25 12:51:12 -05:00
Hunter Bown 97f245d5ac docs: add TUI screenshot to README 2026-04-25 12:48:42 -05:00
Hunter Bown 19f8d83d3b release: v0.5.0 — fix multi-turn tool call 400 error (missing reasoning_content on assistant messages with tool_calls) 2026-04-25 12:27:53 -05:00
Hunter Bown 67b232b063 Release v0.4.9: thinking-mode reasoning_content fix + README refresh
### Fixed
- DeepSeek thinking-mode tool-call rounds now always replay reasoning_content
  in all subsequent requests (including across new user turns), matching the
  documented API contract that assistant tool-call messages must retain their
  reasoning content forever. Previously, reasoning_content was cleared after
  the current user turn completed, which could cause HTTP 400 errors.
- 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 rejections.
- Session checkpoint now persists a Thinking-block placeholder for tool-call
  turns that produced no streamed reasoning text, keeping on-disk sessions
  structurally correct for subsequent requests.
- 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.

### Changed
- Internal crate dependency pins bumped 0.4.5 → 0.4.9 to match workspace.
- npm wrapper version and deepseekBinaryVersion bumped to 0.4.9.
- README fully rewritten: clearer feature highlights, V4 model focus,
  keyboard shortcut table, improved docs index, and more engaging layout.
- CHANGELOG entry for 0.4.9 with comparison URLs.
2026-04-25 12:00:08 -05:00
Hunter Bown 41c54f08aa release: deepseek-tui 0.4.8 2026-04-25 10:48:00 -05:00
Hunter Bown 56ea63e2c6 Merge pull request #20 from Hmbown/codex/v4-pro-discount-pricing
[codex] Track DeepSeek V4 Pro discount pricing
2026-04-25 10:44:22 -05:00
Hunter Bown 94834b2eb4 fix: track DeepSeek V4 Pro discount pricing 2026-04-25 10:34:02 -05:00
50 changed files with 3366 additions and 494 deletions

No files matched your search

+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+126 -85
View File
@@ -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
```
[![CI](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/deepseek-tui)](https://crates.io/crates/deepseek-tui)
[![npm](https://img.shields.io/npm/v/deepseek-tui)](https://www.npmjs.com/package/deepseek-tui)
![DeepSeek TUI screenshot](assets/screenshot.png)
---
## 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
View File
@@ -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
# ─────────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -7,5 +7,5 @@ repository.workspace = true
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
[dependencies]
deepseek-config = { path = "../config", version = "0.4.5" }
deepseek-config = { path = "../config", version = "0.5.0" }
serde.workspace = true
+9 -9
View File
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
deepseek-agent = { path = "../agent", version = "0.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
+6 -6
View File
@@ -14,12 +14,12 @@ path = "src/main.rs"
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
deepseek-agent = { path = "../agent", version = "0.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
+8 -8
View File
@@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
deepseek-agent = { path = "../agent", version = "0.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
+1 -1
View File
@@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
serde.workspace = true
+1 -1
View File
@@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc
anyhow.workspace = true
async-trait.workspace = true
chrono.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
+1 -1
View File
@@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo
[dependencies]
anyhow.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
serde.workspace = true
serde_json.workspace = true
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.4.5" }
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+363 -92
View File
@@ -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();
+133 -9
View File
@@ -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();
+7 -13
View File
@@ -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]
+3 -3
View File
@@ -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
+13 -40
View File
@@ -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(&current_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(&current_messages) > lower_bound);
assert!(estimate_tokens(&completed_messages) > lower_bound);
assert!(estimate_tokens(&historical_messages) > lower_bound);
}
#[test]
+25 -6
View File
@@ -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(&current_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(&current_text_raw) {
+20 -4
View File
@@ -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: {}",
+89 -2
View File
@@ -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
);
}
}
+72 -2
View File
@@ -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);
}
}
+76
View File
@@ -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));
}
}
+34 -28
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -2
View File
@@ -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.
+1 -2
View File
@@ -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.
+3 -4
View File
@@ -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
+11
View File
@@ -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()))
+315
View File
@@ -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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
}
#[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 &amp; 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());
}
}
+183 -1
View File
@@ -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");
+1
View File
@@ -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;
+2
View File
@@ -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))
}
+69 -3
View File
@@ -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});
+8 -8
View File
@@ -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);
}
+27 -6
View File
@@ -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,
+1
View File
@@ -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;
+484
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
+12
View File
@@ -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)]
+82 -5
View File
@@ -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"));
}
}
+142 -3
View File
@@ -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"
);
}
}
+284
View File
@@ -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());
}
}
+6
View File
@@ -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
+91
View File
@@ -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.
+2 -2
View File
@@ -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",
+44
View File
@@ -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}"