Compare commits

..
50 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
Hunter Bown dc8f8496d6 Merge pull request #19 from Hmbown/codex/release-0.4.7
release: deepseek-tui 0.4.7 + Devin .env empty-key fix
2026-04-25 08:04:10 -05:00
Hunter Bown 5c1086fe9e release: deepseek-tui 0.4.7
Bump workspace version 0.4.6 -> 0.4.7 and ship the bug fix flagged by Devin
on PR #18: an uncommented `DEEPSEEK_API_KEY=` line in .env.example caused
`cp .env.example .env` to load an empty key, which `apply_env_overrides`
then propagated into the config and `Config::validate()` rejected on
startup with "api_key cannot be empty string".

- .env.example: comment out the empty `DEEPSEEK_API_KEY=` placeholder.
- crates/tui/src/config.rs: skip empty `DEEPSEEK_API_KEY` env values in
  `apply_env_overrides`, matching the facade's empty-string filter.
- Add `apply_env_overrides_ignores_empty_api_key` regression test.
- Bump Cargo.toml workspace version, npm wrapper version + binary version,
  and Cargo.lock.
2026-04-25 07:59:01 -05:00
Hunter Bown dff592bc6d Merge pull request #18 from Hmbown/codex/deepseek-tui-app-working
Verify app smoke and add NIM env support
2026-04-25 07:46:41 -05:00
Hunter Bown 29141bc89b Add NIM env support and .env.example template 2026-04-25 07:21:43 -05:00
Hunter Bown fe4f261a5d release: deepseek tui 0.4.6
Bundles new work since v0.4.4: NIM provider support, file mention
attachments, provider switch UX, setup --status/--clean/--tools/--plugins,
doctor --json, and protocol-recovery hardening that strips fake
tool-call wrappers from streaming model output.
2026-04-25 02:01:04 -05:00
Hunter Bown 7f0444d26b fix: re-add DEFAULT_TEXT_MODEL import dropped by merge
Local main's unpushed commits had removed DEFAULT_TEXT_MODEL from the
crate::config import in main.rs, but the merged branch's new code at
two call sites still uses it. Textual three-way merge took the local
import line and the branch's call sites, producing a build break.
Re-add the symbol to the import.
2026-04-25 01:57:17 -05:00
Hunter Bown 298f5c6c51 Merge branch 'claude/improve-deepseek-v4-harness-NxBpS' into main 2026-04-25 01:55:44 -05:00
Hunter Bown 853a39138c feat: setup status/clean/dirs and protocol-recovery hardening
Adds a compact `setup --status` view, a `setup --clean` for regenerable
session checkpoints, and `--tools`/`--plugins` scaffolding for
~/.deepseek/{tools,plugins} so the extension model has a documented home
that doctor can count. `doctor --json` lands as a CI-safe alternative to
the human-readable doctor (skips the live API probe).

Also locks down the engine's hostility to fake tool-call wrappers:
filter_tool_call_delta and the marker constants are now testable, the
streaming loop emits one compact status notice per turn when it strips
a wrapper, and a new protocol_recovery integration test asserts that
the legacy text parser never turns <function_calls> into a real tool
call. Adds 23 unit tests + 14 integration tests covering both slices.
2026-04-25 06:26:07 +00:00
Hunter Bown 6ad3727fa0 Add provider switch and file mention attachments 2026-04-24 23:09:48 -05:00
Hunter Bown 16f62f7abf Fix reasoning replay and context accounting for NIM 2026-04-24 18:42:18 -05:00
Hunter Bown d0dc26ce25 Add NVIDIA NIM provider support for DeepSeek 2026-04-24 18:29:19 -05:00
Hunter Bown 7d8c40893f chore: map github web committer 2026-04-24 16:29:01 -05:00
Hunter Bown 411153c549 chore: normalize contributor identities 2026-04-24 16:27:54 -05:00
Hunter Bown e06071c177 docs: remove stale readme screenshot 2026-04-24 16:23:50 -05:00
Hunter Bown f377b0f9d2 ci: add npm publish retry path 2026-04-24 16:15:48 -05:00
Hunter Bown f3df8f5f26 ci: publish npm with trusted publishing 2026-04-24 16:13:59 -05:00
Hunter Bown 8323bedfb7 fix: restore default tui mouse scrolling 2026-04-24 11:41:31 -05:00
Hunter Bown 6c14e2e1f4 release: deepseek tui 0.4.4 2026-04-24 11:02:07 -05:00
Hunter Bown c4f9078712 release: deepseek tui 0.4.3 2026-04-24 10:48:35 -05:00
Hunter Bown d713032b9f ci: allow token-backed npm publish 2026-04-24 00:21:35 -05:00
Hunter Bown d89b33330f fix: preserve DeepSeek V4 reasoning tool turns 2026-04-24 00:01:10 -05:00
Hunter Bown ffa75f07e5 fix: relax v4 compaction and transcript scroll 2026-04-23 23:31:18 -05:00
Hunter Bown 35595f8edc fix: normalize legacy DeepSeek aliases to V4 flash 2026-04-23 23:08:44 -05:00
Hunter Bown b7bd02d814 feat: DeepSeek V4 support with reasoning-effort control (0.4.0)
Adds first-class DeepSeek V4 Pro and Flash support, updates the default model to deepseek-v4-pro, aligns legacy aliases with the current V4 1M context behavior, and fixes thinking-mode request handling.

Key fixes:
- Send DeepSeek's raw Chat Completions `thinking` parameter at the top level instead of SDK-only `extra_body`.
- Preserve assistant `reasoning_content` for all prior thinking-mode tool-call turns so subsequent requests satisfy DeepSeek V4's replay requirement.
- Fix npm wrapper concurrent first-run downloads by using per-process temporary download paths.
- Add `.mailmap` so historical bot-attributed commits aggregate under Hunter Bown where mailmap is honored.

Verified with the full local Rust gate, live DeepSeek V4 smoke, npm wrapper temp-install smoke, and green PR CI across Linux, macOS, and Windows.
2026-04-23 22:53:20 -05:00
Hunter Bown dc8e94d705 docs: update documentation and cleanup for v0.3.33 2026-04-22 22:36:45 -05:00
Hunter Bown f4dbf828c9 Footer polish: remove FOOTER_HINT, simplify footer rendering
- Remove FOOTER_HINT color constant from palette
- Drop footer clock label and related synchronization logic
- Simplify footer status line layout and narrow-terminal handling
- Update tests to align with simplified footer logic
- Remove empty state placeholder text for cleaner UI
- Bump version to 0.3.33
2026-04-11 20:20:18 -05:00
114 changed files with 11310 additions and 1886 deletions

No files matched your search

-35
View File
@@ -1,35 +0,0 @@
# /init-trimtab
Bootstrap or retune the Trimtab closed-loop workflow for this repository.
**Canonical protocol:** `.trimtab/init-trimtab-protocol.md`
## What this command does
1. Reads the canonical protocol from `.trimtab/init-trimtab-protocol.md`
2. Inspects the repo's current state (docs, CI, task surfaces, dependency graph)
3. Decides whether to bootstrap, upgrade, or retune
4. Aligns all workflow surfaces to the protocol
## Invocation
Run `/init-trimtab` in Claude Code to initialize or retune the workflow.
## Surfaces managed
| File | Purpose |
|------|---------|
| `.trimtab/init-trimtab-protocol.md` | Canonical shared protocol |
| `.claude/commands/init-trimtab.md` | This file (Claude Code entrypoint) |
| `.codex/skills/init-trimtab/SKILL.md` | Codex skill registration |
| `DEPENDENCY_GRAPH.md` | Crate + task dependency graph |
| `AI_HANDOFF.md` | Operative task queue and context for next agent |
| `CLAUDE.md` | Build/dev instructions (read-only — do not overwrite) |
| `AGENTS.md` | Project instructions for AI assistants |
## Rules
- Do not overwrite CLAUDE.md — it is the source of truth for build commands
- Do not flatten existing strong instructions into boilerplate
- The no-self-verdict rule is non-negotiable
- Keep Claude and Codex entrypoints thin; logic lives in the shared protocol
+111
View File
@@ -0,0 +1,111 @@
You are working in /Volumes/VIXinSSD/deepseek-tui.
Goal:
Continue improving deepseek-tui as a first-class DeepSeek V4 coding harness, building directly on the previous overnight slice (commit f190ab74 + merge aa356e8b + import fix f9ede57c, plus release commit d7944421 / tag v0.4.6). This time, ACTUALLY read /Volumes/VIXinSSD/mathcode — the previous run claimed it wasn't mounted and adapted patterns from the work-order description instead. mathcode IS mounted; the directory exists and is readable.
What already shipped (do not redo):
- Slice A: setup --status, setup --clean (with --force gate), setup --tools, setup --plugins, doctor --json, init_tools_dir / init_plugins_dir scaffolding with frontmatter examples (# name: / # description: / # usage: in tools/example.sh; ----delimited YAML in plugins/example/PLUGIN.md). Code in crates/tui/src/main.rs.
- Slice B: fake-wrapper hostility — TOOL_CALL_START_MARKERS/END_MARKERS + filter_tool_call_delta promoted to pub(crate); FAKE_WRAPPER_NOTICE + contains_fake_tool_wrapper(); per-turn fake_wrapper_notice_emitted flag in handle_deepseek_turn. Code in crates/tui/src/core/engine.rs. Locked by crates/tui/tests/protocol_recovery.rs.
- Slice C: docs in docs/CONFIGURATION.md (Setup status, clean, and extension dirs section + Why the engine strips XML/[TOOL_CALL] text section) and README.md quick-checks block.
- 37 new tests landed (23 unit + 14 integration). All green.
Read first (in this order):
1. AGENTS.md
2. git status --short --branch (expect clean tree on main, 0 ahead of origin/main; a stash from a prior session may exist — leave it alone)
3. README.md
4. docs/ARCHITECTURE.md
5. docs/CONFIGURATION.md (note the new Setup status / extension dirs / Why the engine strips ... sections)
6. docs/RUNTIME_API.md
7. crates/tui/src/main.rs (large file; pay attention to the new run_setup_status, run_setup_clean, init_tools_dir, init_plugins_dir, run_doctor, run_doctor_json helpers — do not duplicate them)
8. crates/tui/src/core/engine.rs (new: TOOL_CALL_START_MARKERS, TOOL_CALL_END_MARKERS, filter_tool_call_delta, FAKE_WRAPPER_NOTICE, contains_fake_tool_wrapper)
9. crates/tui/src/core/engine/tests.rs
10. crates/tui/tests/protocol_recovery.rs
11. crates/tui/src/client.rs
12. crates/tui/src/tui/widgets/mod.rs
13. crates/tui/src/core/capacity.rs (existing compaction decisions — relevant for issue #6)
Then ACTUALLY read mathcode (it IS mounted):
14. /Volumes/VIXinSSD/mathcode/README.md
15. /Volumes/VIXinSSD/mathcode/setup.sh (~10KB; full status/clean/help flag matrix and error messages worth borrowing)
16. /Volumes/VIXinSSD/mathcode/run (763B; thin wrapper that loads .env and dispatches)
17. /Volumes/VIXinSSD/mathcode/.env.example (~6KB; clear provider/backend comments — note our codebase only loads .env via dotenvy::dotenv() in crates/tui/src/main.rs:475 and has no .env.example)
18. /Volumes/VIXinSSD/mathcode/skills/README.md
19. /Volumes/VIXinSSD/mathcode/plugins/README.md
20. /Volumes/VIXinSSD/mathcode/tools/ (list contents; look for self-describing tool scripts)
21. /Volumes/VIXinSSD/mathcode/bin/ (list contents)
Open GitHub issue spine (use as roadmap):
- #6 Coherence as plain-language session health ← previous slice's recommended next step
- #7 Long-session evals for coherence/context handling
- #8 Workspace extraction/runtime seams
- #9 Thread/turn/item protocol stability
- #10 Sandboxing, approvals, server safety
- #11 Skills/plugins/MCP installability and management (partly done; doctor + status now report tools/plugins; install/manage UX still missing)
- #12 DeepSeek API key setup/provider drift (partly done; setup --status surfaces source — but no .env.example, no provider-aware setup wizard)
- #13 Observability for agent quality/coherence events (partly done; fake-wrapper strip emits a status event — coherence ladder still missing)
- #14 Whale/DeepSeek TUI design system
- #15 Thin IDE companions over runtime API
- #16 Public roadmap/docs rewrite
Pick 1-3 high-leverage slices that can be finished and tested tonight. Strongly preferred (in priority order):
Slice 1 (recommended — issue #6 + #13): Plain-language coherence ladder.
- The runtime already emits compaction events (CompactionStarted/Completed/Failed) and capacity decisions live in crates/tui/src/core/capacity.rs.
- Add an Event::CoherenceState variant with a small fixed ladder: Healthy / GettingCrowded / RefreshingContext / VerifyingRecentWork / ResettingPlan.
- Emit it from one place (engine), driven by existing capacity decisions and compaction events. Do NOT add a new background task.
- Surface it in the TUI footer as a single chip (right-aligned, terminal-native, no emoji) and on the runtime API thread shape.
- Snapshot-test the footer chip for each state and unit-test the state transitions from a synthetic capacity event log.
- Land docs in docs/RUNTIME_API.md (CoherenceState shape) and docs/CONFIGURATION.md (footer chip).
Slice 2 (issue #12 + mathcode reference): .env.example + setup wizard messages.
- Adapt /Volumes/VIXinSSD/mathcode/.env.example structure (provider/backend grouping, comment style) to a deepseek-tui .env.example at repo root.
- Cover: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL (global vs china), DEEPSEEK_MODEL, NVIDIA_API_KEY (NIM), NIM_BASE_URL, RUST_LOG, sandbox toggles.
- Update setup --status to point at .env.example when no .env is found ("Run `cp .env.example .env` and edit").
- Do NOT add a `run` wrapper — dotenvy handles .env loading inside the binary, the wrapper is redundant and introduces drift.
- Lock the .env.example shape with a test that asserts every documented variable is referenced from at least one source file.
Slice 3 (issue #14 / TUI render polish): Whale/DeepSeek design tokens.
- Extract the current TUI color/border/padding choices into a single deepseek_theme module (light + dark variants), without changing any visible output yet.
- Add a snapshot test or two that lock the existing rendering of one plan cell and one tool cell.
- Out of scope for tonight: actually changing colors. Goal is to make a future skin-swap a 5-line change.
DO NOT pick:
- Anything that re-touches NIM provider, V4 reasoning context accounting, or tool-call routing — that work is finished and protected by tests.
- Anything that re-introduces multi_tool_use.parallel.
- Anything that introduces an alternate fake-wrapper code path (XML, [TOOL_CALL], <function_calls>, ```tool_code, ```python, etc.). The engine's contract is: API tool channel only, all five wrapper shapes get stripped + emit a notice.
- Broad rewrites of main.rs, the engine, or the TUI app loop.
- A `run` shell wrapper. (.env loading is already in-binary via dotenvy.)
Hard rules / hostility budget:
- Prompts must never instruct or imply that the assistant should emit XML/markdown/Replit-style fake tool calls. If you touch crates/tui/src/prompts/*.txt, the rule is: API tool channel only, never wrapped tool calls in assistant text.
- Protocol recovery must remain visible. If you change how filter_tool_call_delta or FAKE_WRAPPER_NOTICE works, update crates/tui/tests/protocol_recovery.rs in the same commit and keep at least one assertion that all 5 marker pairs are present.
- UI must stay dense and terminal-native. No emoji, no decorative box drawing for data, no gratuitous color. DeepSeek-specific copy (Whale, V4) is welcome.
- Show, don't hide, runtime problems. New events should be compact (single-line where possible) and machine-readable as structured fields.
Stop rules:
- Do not revert any user/unrelated work; the previous run already merged and pushed (current main HEAD: d7944421, tag v0.4.6).
- Do not push tags or trigger releases. Local commits + push to a feature branch is fine; the maintainer handles release decisions.
- Do not delete or modify the existing stash (if any).
- If a live provider is down/unpaid, use fixtures/mocks and say so explicitly in the report.
- If a gate fails from pre-existing unrelated work, isolate it with evidence — don't paper over.
- Pre-existing test cluster (8 tests in tools::git*, tools::diagnostics, tui::ui::tests::workspace_context_refresh*) fails because of a sandbox commit-signing issue with `git commit ... -S`. Document but do not "fix" by globally setting commit.gpgsign=false — fix narrowly inside init_git_repo at crates/tui/src/tui/ui/tests.rs:158 if you touch it.
Required verification before final report:
- cargo fmt --all -- --check
- cargo check --workspace --all-targets --locked
- cargo test --workspace --all-features --locked
- cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
Live smoke (optional, time-boxed at 60s):
- If DEEPSEEK_API_KEY or NVIDIA_API_KEY is set in the env, run a single bounded turn against the relevant provider with a hard token cap and report the first 30 lines of output. Otherwise skip and say so.
Final report headings (use exactly these):
1. Summary
2. Implemented Changes
3. GitHub Issues Advanced
4. MathCode Reference Points Used (be honest: list which mathcode files you actually read and which patterns you adapted vs deliberately skipped, with one-sentence rationale per skip)
5. Tests/Gates
6. Live Smoke Result
7. Remaining Risks
8. Next Best Slice
+44
View File
@@ -0,0 +1,44 @@
# DeepSeek TUI environment
# Shell-exported variables override values in this file.
# Copy this file to `.env`, then uncomment only the values you want to use.
# DeepSeek API (default provider)
# Get an API key from DeepSeek, then keep it local in `.env`.
# DEEPSEEK_API_KEY=
# Global endpoint:
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# China endpoint:
# DEEPSEEK_BASE_URL=https://api.deepseeki.com
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
# to the current V4 flash model in the TUI.
# DEEPSEEK_MODEL=deepseek-v4-pro
# DEEPSEEK_MODEL=deepseek-v4-flash
# NVIDIA NIM-hosted DeepSeek V4
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
# DEEPSEEK_PROVIDER=nvidia-nim
# NVIDIA_API_KEY=
# NVIDIA_NIM_API_KEY=
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
# Logging
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
# TUI's lightweight verbose logs for info/debug/trace directives.
# DEEPSEEK_LOG_LEVEL=debug
# RUST_LOG=deepseek_tui=debug
# Agent safety defaults
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
# inside the workspace unless a sandbox elevation path is explicitly used.
# DEEPSEEK_APPROVAL_POLICY=on-request
# DEEPSEEK_SANDBOX_MODE=workspace-write
# DEEPSEEK_ALLOW_SHELL=true
# Optional extension paths
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
+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
+40
View File
@@ -0,0 +1,40 @@
name: Publish npm
on:
workflow_dispatch:
inputs:
version:
description: 'Package/release version to publish, without the leading v'
required: true
type: string
env:
CARGO_TERM_COLOR: always
jobs:
publish:
runs-on: ubuntu-latest
# Prefer the npm-only workflow_dispatch path in release.yml. This workflow
# works only if npm Trusted Publishing is explicitly configured for
# `publish-npm.yml` instead of `release.yml`.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Verify package version
working-directory: npm/deepseek-tui
run: |
actual="$(node -p "require('./package.json').version")"
expected="${{ inputs.version }}"
if [ "${actual}" != "${expected}" ]; then
echo "package.json version ${actual} does not match requested ${expected}" >&2
exit 1
fi
- name: Publish wrapper to npm
working-directory: npm/deepseek-tui
run: npm publish --access public
+61
View File
@@ -3,6 +3,12 @@ name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Package/release version to publish to npm, without the leading v'
required: true
type: string
env:
CARGO_TERM_COLOR: always
@@ -10,6 +16,7 @@ env:
jobs:
parity:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -115,3 +122,57 @@ jobs:
with:
files: artifacts/*/*
prerelease: false
publish-npm:
needs: release
runs-on: ubuntu-latest
# Trusted Publishing via OIDC. Configure npm to trust this workflow
# filename (`release.yml`) for Hmbown/DeepSeek-TUI.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Verify package version
working-directory: npm/deepseek-tui
run: |
actual="$(node -p "require('./package.json').version")"
expected="${GITHUB_REF_NAME#v}"
if [ "${actual}" != "${expected}" ]; then
echo "package.json version ${actual} does not match tag ${expected}" >&2
exit 1
fi
- name: Publish wrapper to npm
working-directory: npm/deepseek-tui
run: npm publish --access public
publish-npm-manual:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
# npm can trust only one workflow filename; keep npm-only retries here so
# the trusted publisher can remain `release.yml`.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Verify package version
working-directory: npm/deepseek-tui
run: |
actual="$(node -p "require('./package.json').version")"
expected="${{ inputs.version }}"
if [ "${actual}" != "${expected}" ]; then
echo "package.json version ${actual} does not match requested ${expected}" >&2
exit 1
fi
- name: Publish wrapper to npm
working-directory: npm/deepseek-tui
run: npm publish --access public
+4 -7
View File
@@ -11,6 +11,7 @@
# Development
.env
.env.*
!.env.example
node_modules/
.vscode/
.idea/
@@ -34,16 +35,13 @@ dist/
# Logs
*.log
firebase-debug.log
# Generated
outputs/
# Rust
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
firebase-debug.log
tmp/
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
# Local dev scripts and temp files
*.sh
!scripts/**
@@ -57,12 +55,11 @@ result.json
count_deps.py
project_overhaul_prompt.md
.codex/
docs/rlm-paper.txt
.context/
# Local runtime state
.deepseek/
session_*.json
**/session_*.json
*.db
# Companion app (tracked separately)
+16
View File
@@ -0,0 +1,16 @@
Hunter Bown <[email protected]> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> GitHub <[email protected]>
Hunter Bown <[email protected]> Claude <[email protected]>
Hunter Bown <[email protected]> Claude Opus 4.6 <[email protected]>
Hunter Bown <[email protected]> Claude Opus 4.6 (1M context) <[email protected]>
Hunter Bown <[email protected]> Claude Opus 4.7 (1M context) <[email protected]>
Hunter Bown <[email protected]> Copilot <[email protected]>
Hunter Bown <[email protected]> Copilot <1693627+github-copilot-cli[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> Copilot <946600+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> Qwen-Coder <[email protected]>
Hunter Bown <[email protected]> qwencoder <[email protected]>
Hunter Bown <[email protected]> Cursor Agent <[email protected]>
Hunter Bown <[email protected]> cursoragent <[email protected]>
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
-151
View File
@@ -1,151 +0,0 @@
# Trimtab Protocol: deepseek-tui
> Canonical workflow protocol for closed-loop, self-verifying agentic development.
> Claude Code and Codex entrypoints both delegate here.
## Topology
```
Claude Code (Opus) — Orchestrator
/ | \
Sub-Opus agents Codex MCP Direct work
(UI, research, (infra, backend, (small edits,
review, plan) Rust systems) conversation)
|
Fresh Codex context
(Closure verifier / Coach)
```
- **Orchestrator / Player:** Claude Code (Opus)
- **Workers:** Claude sub-agents (Agent tool) + Codex MCP sessions
- **Closure Verifier / Coach:** Fresh Codex context (never the same context that wrote the code)
## No-Self-Verdict Rule
The agent that wrote or modified code MUST NOT be the one to declare it passes.
Every batch — including review-only or zero-edit batches — goes to an independent
verifier (fresh Codex context or separate sub-agent) before anyone says PASS.
## Task Surface
This repo uses **Linear** as the canonical issue tracker.
- **Project:** https://linear.app/shannon-labs/project/deepseek-tui-6213bbbeaa26
- **Team:** Shannon Labs (SHA)
Operative task sources (priority order):
1. **Linear issues** — canonical for all tracked work (SHA-2794 through SHA-2803)
2. **DEPENDENCY_GRAPH.md** — local mirror of the task graph with ready queue
3. **AI_HANDOFF.md** — implementation notes and architecture context
4. **todo.md** — high-level goals
When starting a session:
1. List Linear issues for the project (filter by state: not Done/Canceled)
2. Identify the highest-priority unblocked issue
3. Read the issue body directly — it is the task packet
4. Execute using the waterfall rule
The issue body contains: Goal, Files, Pass/Fail Criteria, Boundary, Dependencies.
Do not invent a second prompt when the issue already has the packet.
## Waterfall Rule
After a verified issue closes, the player continues to the next unblocked issue
unless the operator explicitly reprioritizes. Do not stop and ask "what next?"
when the answer is visible in the task graph.
## Work Packet Structure
Each task should be expressed as a packet with:
```
GOAL: One sentence describing the desired end state
FILES: List of files expected to change
VERIFY: Concrete acceptance criteria (tests pass, clippy clean, visual check)
BOUNDARY: What is out of scope for this packet
```
## Build / Test / Lint Commands
```bash
cargo build # Debug build
cargo build --release # Release build
cargo test --workspace --all-features # Full test suite
cargo fmt --all -- --check # Format check
cargo clippy --workspace --all-targets --all-features # Full lint
cargo doc --workspace --no-deps # Build docs
```
CI runs: fmt check, clippy, tests (Ubuntu/macOS/Windows), build.
## Delegation Guidelines
### Use Codex MCP for:
- Rust systems work, infrastructure, CI/CD, backend, shell scripting
- Second opinions on architecture or tradeoffs
- Debugging infra/backend issues
- Config: `approval-policy: "never"`, `sandbox: "workspace-write"`, `cwd: "/Volumes/VIXinSSD/deepseek-tui"`
- For advice-only: `sandbox: "read-only"`
### Use Claude sub-agents (Agent tool) for:
- TUI/UI work, research, codebase exploration
- Code review and quality analysis
- Planning and architecture docs
### Do directly:
- Small file edits, quick answers, reading/searching known files
### Shared workspace warning:
When dispatching into a directory where other agents may be working, include:
"Note: You are in a shared workspace. Other agents may be reading or editing files
concurrently. Focus only on your assigned task."
## Session Protocol
### Session Start
1. Read CLAUDE.md, AI_HANDOFF.md, DEPENDENCY_GRAPH.md
2. Check `git status` and recent commits
3. Identify the current task from the task surface
4. Announce what you're working on
### Session Close
1. `git status` — review all changes
2. `git add` specific files (never `git add -A` blindly)
3. `git commit` with conventional commit message
4. Verify: `cargo test --workspace --all-features`
5. Verify: `cargo clippy --workspace --all-targets --all-features`
6. Update AI_HANDOFF.md if task state changed
7. Push only if operator approves
### Commit Convention
Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
## Crate Architecture Reference
```
crates/
cli/ deepseek-cli -> `deepseek` binary
tui/ deepseek-tui -> `deepseek-tui` binary
app-server/ deepseek-app-server HTTP/SSE + JSON-RPC server
core/ deepseek-core Agent loop, session, turns
protocol/ deepseek-protocol Request/response types
config/ deepseek-config Config loading, profiles
state/ deepseek-state SQLite persistence
tools/ deepseek-tools Tool registry + specs
mcp/ deepseek-mcp MCP server integration
hooks/ deepseek-hooks Lifecycle hooks
execpolicy/ deepseek-execpolicy Approval policy engine
agent/ deepseek-agent Model/provider registry
tui-core/ deepseek-tui-core TUI state machine scaffold
```
See DEPENDENCY_GRAPH.md for the full dependency graph.
## Key Architectural Decisions
- Edition 2024, Rust 1.85+
- Workspace version 0.3.30 (all crates share version)
- TUI binary still references monolith source (src/) — migration incremental
- DeepSeek API: Responses API preferred, chat completions fallback
- Sandbox: macOS Seatbelt, Linux Landlock
- Modes: Plan, Agent, YOLO (visible). Hidden `/normal` and legacy `default_mode = "normal"` normalize to Agent.
+4 -15
View File
@@ -14,24 +14,13 @@ This file provides context for AI assistants working on this project.
### Documentation
See README.md for project overview, docs/ARCHITECTURE.md for internals.
## Trimtab Workflow
This repo uses the Trimtab closed-loop protocol for self-verifying agentic development.
- **Protocol:** `.trimtab/init-trimtab-protocol.md` (canonical — read this first)
- **Task graph:** `DEPENDENCY_GRAPH.md` (crate deps + task deps with ready queue)
- **Task queue:** `AI_HANDOFF.md` (6 open items with priorities)
- **Claude entrypoint:** `.claude/commands/init-trimtab.md`
- **Codex skill:** `.codex/skills/init-trimtab/SKILL.md`
**No-self-verdict rule:** The agent that wrote code must not be the one to declare it passes. Always use an independent verifier (fresh context or separate sub-agent).
## DeepSeek-Specific Notes
- **Thinking Tokens**: DeepSeek models output thinking blocks (`ContentBlock::Thinking`) before final answers. The TUI streams and displays these with visual distinction.
- **Reasoning Models**: `deepseek-reasoner` and `deepseek-r1` excel at step-by-step problem solving.
- **Large Context Window**: 128k tokens. Use search tools to navigate efficiently.
- **API**: OpenAI-compatible with Responses API preferred, chat completions as fallback. Base URL configurable for global (`api.deepseek.com`) or China (`api.deepseeki.com`).
- **Reasoning Models**: `deepseek-v4-pro` and `deepseek-v4-flash` are the documented V4 model IDs. Legacy `deepseek-chat` and `deepseek-reasoner` are compatibility aliases for `deepseek-v4-flash`.
- **Large Context Window**: DeepSeek V4 models have 1M-token context windows. Use search tools to navigate efficiently.
- **API**: OpenAI-compatible Chat Completions (`/chat/completions`) is the documented DeepSeek API path. Base URL configurable for global (`api.deepseek.com`) or China (`api.deepseeki.com`); `/v1` is accepted for OpenAI SDK compatibility, and `/beta` is only needed for beta features such as strict tool mode, chat prefix completion, and FIM completion.
- **Thinking + Tool Calls**: In V4 thinking mode, assistant messages that contain tool calls must replay their `reasoning_content` in all subsequent requests or the API returns HTTP 400.
## Important Notes
+105 -2
View File
@@ -7,6 +7,106 @@ 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
- Alternate-screen TUI sessions now capture mouse input by default so wheel scrolling moves the transcript instead of exposing terminal scrollback from before the TUI started. Use `--no-mouse-capture` or `tui.mouse_capture = false` when terminal-native drag selection is preferred.
## [0.4.2] - 2026-04-24
### Fixed
- DeepSeek V4 thinking-mode tool turns now checkpoint the engine's authoritative API transcript, including assistant `reasoning_content` on reasoning-to-tool-call turns with no visible assistant text.
- Chat Completions request building now drops stale V4 tool-call rounds that are missing required `reasoning_content`, preventing old corrupted checkpoints from triggering DeepSeek HTTP 400 replay errors.
- Web search now falls back to Bing HTML results when DuckDuckGo returns a bot challenge or otherwise yields no parseable results.
## [0.4.1] - 2026-04-24
### Fixed
- DeepSeek V4 tool-result context now preserves large file reads and command outputs instead of compacting noisy tools to a 900-character snippet after 2k characters.
- Capacity guardrail refresh no longer performs destructive summary compaction unless the normal model-aware compaction thresholds are actually crossed.
- V4 compaction summaries retain larger tool-result excerpts and summary input when compaction is genuinely needed.
- The transcript now follows the bottom again when sending a new message, shows an in-app scrollbar when internally scrolled, and leaves mouse capture off in `--no-alt-screen` mode so terminal-native scrolling can work.
## [0.4.0] - 2026-04-23
### Added
- **DeepSeek V4 support**: `deepseek-v4-pro` (flagship) and `deepseek-v4-flash` (fast/cheap) are now first-class model IDs with 1M context windows.
- **Reasoning-effort tier**: new `reasoning_effort` config field (`off | low | medium | high | max`) mapped to DeepSeek's `reasoning_effort` + `thinking` request fields. Defaults to `max`.
- **Shift+Tab cycles reasoning-effort** through the three behaviorally distinct tiers (`off → high → max`). The current tier is shown as a ⚡ chip in the header.
- Per-model pricing table: `deepseek-v4-pro` priced at $0.145/$1.74/$3.48 per 1M tokens (cache-hit/miss/output); `deepseek-v4-flash` and legacy aliases at $0.028/$0.14/$0.28.
### Changed
- **Default model flipped to `deepseek-v4-pro`** (from `deepseek-reasoner`).
- `deepseek-chat` / `deepseek-reasoner` remain as silent aliases of `deepseek-v4-flash` for API compatibility; priced identically.
- **Context compaction**: 1M-context V4 models now compact at 800k input tokens or 2,000 messages, so short/tool-heavy sessions do not compact as if they were 128k-context runs.
- Cycling modes is now Tab-only; Shift+Tab is repurposed for reasoning-effort (reverse-mode cycle was low-value with only three modes).
- Updated help/hint strings, validator error messages, and the model picker to reference V4 IDs.
### Fixed
- `requires_reasoning_content` now recognizes `deepseek-v4*` so thinking streams render correctly on V4 models.
- DeepSeek V4 thinking-mode tool calls now preserve prior assistant `reasoning_content` whenever a tool call is replayed, matching DeepSeek's multi-turn contract and avoiding HTTP 400 rejections on later turns.
- Raw Chat Completions requests now send DeepSeek's top-level `thinking` parameter instead of the OpenAI SDK-only `extra_body` wrapper.
- Config, env, and UI model selection now normalize legacy DeepSeek aliases to `deepseek-v4-flash` instead of preserving old model labels.
- npm wrapper first-run downloads now use process-unique temp files so concurrent `deepseek` / `deepseek-tui` invocations do not race on `*.download` files.
## [0.3.33] - 2026-04-11
### Changed
- Footer polish: simplified footer rendering, removed footer clock label, updated status line layout
- Palette cleanup: removed `FOOTER_HINT` color constant
### Removed
- `FOOTER_HINT` color constant from palette (use `TEXT_MUTED` or `TEXT_HINT` instead)
### Fixed
- Test updates to align with simplified footer logic
- Empty state placeholder text removed for cleaner UI
## [0.3.32] - 2026-04-11
### Added
@@ -181,7 +281,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Expanded reasoning-model detection for chat history reconstruction (supports R-series and reasoner-style naming without hardcoding single versions).
- Aligned docs/config examples with actual runtime default model (`deepseek-v3.2`).
- Aligned docs/config examples with the then-current runtime default model.
## [0.3.14] - 2026-02-05
@@ -430,7 +530,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...HEAD
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.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
[0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28
+27 -25
View File
@@ -45,8 +45,11 @@ Thank you for your interest in contributing to DeepSeek TUI! This document provi
### Testing
- Write tests for new functionality
- Ensure all existing tests pass: `cargo test`
- For integration tests, use the `tests/` directory
- Ensure all existing tests pass: `cargo test --workspace --all-features`
- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`
modules), and add integration tests under the owning crate's `tests/`
directory (for example `crates/tui/tests/` or `crates/state/tests/`). The
repository root `tests/` directory is not used
### Commit Messages
@@ -59,35 +62,34 @@ Use clear, descriptive commit messages following conventional commits:
- `test:` Adding or updating tests
- `chore:` Maintenance tasks
Example: `feat: add --doctor command for system diagnostics`
Example: `feat: add doctor subcommand for system diagnostics`
## Project Structure
DeepSeek TUI is a Cargo workspace. The live runtime and the majority of TUI,
engine, and tool code currently live in `crates/tui/src/`. Smaller workspace
crates provide shared abstractions that are being extracted incrementally.
```
src/
├── main.rs # Entry point and CLI definition
├── config.rs # Configuration management
├── client.rs # HTTP client for DeepSeek API
├── llm_client.rs # LLM abstraction layer
├── models.rs # Data structures
├── mcp.rs # Model Context Protocol support
├── hooks.rs # Hook system for extensibility
├── skills.rs # Skills/plugin system
├── core/ # Core engine components
│ ├── engine.rs # Main agent loop
│ ├── session.rs # Session management
│ └── ...
├── tools/ # Built-in tools
│ ├── shell.rs # Shell execution
│ ├── file.rs # File operations
│ └── ...
├── tui/ # Terminal UI
│ ├── app.rs # Application state
│ ├── ui.rs # Rendering logic
│ └── ...
└── sandbox/ # Sandbox execution (macOS)
crates/
├── tui/ deepseek-tui binary (interactive TUI + runtime API)
├── cli/ deepseek binary (dispatcher facade)
├── app-server/ HTTP/SSE + JSON-RPC transport
├── core/ Agent loop / session / turn management
├── protocol/ Request/response framing
├── config/ Config loading, profiles, env precedence
├── state/ SQLite thread/session persistence
├── tools/ Typed tool specs and lifecycle
├── mcp/ MCP client + stdio server
├── hooks/ Lifecycle hooks (stdout/jsonl/webhook)
├── execpolicy/ Approval/sandbox policy engine
├── agent/ Model/provider registry
└── tui-core/ Event-driven TUI state machine scaffold
```
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the live data flow across
these crates and [DEPENDENCY_GRAPH.md](DEPENDENCY_GRAPH.md) for build ordering.
## Submitting Changes
1. Create a feature branch from `main`:
Generated
+13 -13
View File
@@ -806,7 +806,7 @@ dependencies = [
[[package]]
name = "deepseek-agent"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"deepseek-config",
"serde",
@@ -814,7 +814,7 @@ dependencies = [
[[package]]
name = "deepseek-app-server"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"axum",
@@ -837,7 +837,7 @@ dependencies = [
[[package]]
name = "deepseek-config"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"dirs",
@@ -848,7 +848,7 @@ dependencies = [
[[package]]
name = "deepseek-core"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"chrono",
@@ -867,7 +867,7 @@ dependencies = [
[[package]]
name = "deepseek-execpolicy"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"deepseek-protocol",
@@ -876,7 +876,7 @@ dependencies = [
[[package]]
name = "deepseek-hooks"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"async-trait",
@@ -890,7 +890,7 @@ dependencies = [
[[package]]
name = "deepseek-mcp"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"deepseek-protocol",
@@ -900,7 +900,7 @@ dependencies = [
[[package]]
name = "deepseek-protocol"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"serde",
"serde_json",
@@ -908,7 +908,7 @@ dependencies = [
[[package]]
name = "deepseek-state"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"chrono",
@@ -920,7 +920,7 @@ dependencies = [
[[package]]
name = "deepseek-tools"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"async-trait",
@@ -933,7 +933,7 @@ dependencies = [
[[package]]
name = "deepseek-tui"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"arboard",
@@ -987,7 +987,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-cli"
version = "0.3.32"
version = "0.5.2"
dependencies = [
"anyhow",
"chrono",
@@ -1005,7 +1005,7 @@ dependencies = [
[[package]]
name = "deepseek-tui-core"
version = "0.3.32"
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.3.32"
version = "0.5.2"
edition = "2024"
license = "MIT"
repository = "https://github.com/Hmbown/DeepSeek-TUI"
+4 -53
View File
@@ -4,7 +4,7 @@
```
deepseek-tui (binary: `deepseek-tui`)
(no workspace deps — uses monolith src/ directly)
(no workspace deps — monolith source under crates/tui/src/)
deepseek-tui-cli (binary: `deepseek`)
<- deepseek-agent
@@ -48,8 +48,9 @@ deepseek-tui-core (leaf — no internal deps)
```
Note: `deepseek-tui` has zero workspace deps because it still compiles the
monolith source tree (`src/main.rs`). The crate split is structural — actual
source migration into individual crates is incremental.
monolith source tree (`crates/tui/src/main.rs`). The crate split is
structural — source migration into individual workspace crates is
incremental.
## Build Order (bottom-up)
@@ -62,53 +63,3 @@ Layer 4: deepseek-app-server, deepseek-tui
Layer 5: deepseek-tui-cli
```
## Task Dependencies (Linear: shannon-labs/deepseek-tui)
Canonical source: https://linear.app/shannon-labs/project/deepseek-tui-6213bbbeaa26
```
[High] SHA-2794 UI Footer Redesign (Kimi CLI Style) ← DONE (v0.3.31)
-> landed: mode/model/token/cost layout, quadrant separators, context bar
-> remaining polish tracked in AI_HANDOFF.md
[High] SHA-2795 Thinking vs Normal Chat Delineation ← DONE (v0.3.31)
-> landed: labeled delimiters, separate transcript cell, show_thinking
[High] SHA-2798 Finance Tool Replacement ← DONE (v0.3.31)
-> landed: Yahoo Finance v8 + CoinGecko fallback
[Med] SHA-2796 Intelligent Compaction UX ← DONE (v0.3.31)
-> landed: auto-compaction, /compact, status strip, CompactionCompleted stats
[Med] SHA-2797 Escape Key After Plan Mode
-> base fix landed (v0.3.31); remaining: regression test coverage ← READY
-> files: crates/tui/src/tui/ui.rs, app.rs
[Med] SHA-2799 "Alive and Animated" Feel
-> was blocked by SHA-2794, SHA-2795 (now done) ← READY
-> files: crates/tui/src/tui/ (various)
[Med] SHA-2801 Docs and Workflow Update
-> was blocked by SHA-2798 (now done) ← READY
-> files: AGENTS.md, README.md, CHANGELOG.md
[Med] SHA-2802 Release Prep
-> was blocked by SHA-2794, SHA-2795, SHA-2798 (all done) ← READY
-> files: Cargo.toml, CHANGELOG.md, npm/
[Low] SHA-2800 Header Redesign
-> was blocked by SHA-2794 (now done) ← READY
-> files: crates/tui/src/tui/widgets/header.rs
[Low] SHA-2803 Context Window Visualization
-> blocked by SHA-2800
-> files: crates/tui/src/tui/ui.rs
```
## Ready Queue (unblocked, by priority)
1. **SHA-2797** Escape Key regression test (Medium)
2. **SHA-2799** "Alive and Animated" Feel (Medium)
3. **SHA-2801** Docs and Workflow Update (Medium)
4. **SHA-2802** Release Prep (Medium)
5. **SHA-2800** Header Redesign (Low)
+140 -48
View File
@@ -1,101 +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)
<p align="center">
<img src="assets/hero.png" alt="DeepSeek TUI" width="800">
</p>
![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
deepseek
```
Start the TUI:
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
deepseek-tui
# via CLI
deepseek login --api-key "YOUR_DEEPSEEK_API_KEY"
# via env var
export DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY"
deepseek
```
On first launch, it will prompt for your API key if one is not already configured.
You can also set auth ahead of time with either of these:
### Using NVIDIA NIM
```bash
deepseek-tui login
DEEPSEEK_API_KEY="YOUR_DEEPSEEK_API_KEY" deepseek-tui
deepseek auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY"
deepseek --provider nvidia-nim
# 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** / **Shift+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).
---
## Models & Pricing
DeepSeek TUI targets **DeepSeek V4** models with 1M-token context windows by default.
| 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-tui # interactive TUI
deepseek-tui -p "explain this in 2 sentences" # one-shot prompt
deepseek-tui --yolo # YOLO mode
deepseek-tui login # save API key to config
deepseek-tui doctor # check setup
deepseek-tui models # list available models
deepseek-tui serve --http # HTTP/SSE API server
deepseek # interactive TUI
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 latest session
deepseek serve --http # HTTP/SSE API server
```
Controls: `F1` help, `Esc` backs out of the current action, `Ctrl+K` command palette.
### 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_PROFILE`.
Key environment overrides:
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
| 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 |
## Docs
Quick diagnostics:
[docs/](docs/) — configuration, modes, MCP integration, runtime API, and release runbooks.
```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
```
DeepSeek context caching is automatic — when the API returns cache hit/miss token fields, the TUI includes them in usage and cost tracking.
Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
---
## 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

+61 -13
View File
@@ -7,20 +7,42 @@
# 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
# ─────────────────────────────────────────────────────────────────────────────────
# Default Models
# ─────────────────────────────────────────────────────────────────────────────────
default_text_model = "deepseek-reasoner" # common IDs: deepseek-reasoner, deepseek-chat (any deepseek-* ID is valid)
# DeepSeek V4 family:
# deepseek-v4-pro — flagship reasoning model on DeepSeek Platform
# deepseek-v4-flash — fast, cost-efficient (legacy aliases: deepseek-chat, deepseek-reasoner)
# deepseek-ai/deepseek-v4-pro — NVIDIA NIM-hosted Pro model ID
# deepseek-ai/deepseek-v4-flash — NVIDIA NIM-hosted Flash model ID
default_text_model = "deepseek-v4-pro"
# ─────────────────────────────────────────────────────────────────────────────────
# Thinking Mode (DeepSeek V4 reasoning effort)
# ─────────────────────────────────────────────────────────────────────────────────
# "off" — disables chain-of-thought (thinking.type = disabled)
# "low" — compat-maps to "high" server-side
# "medium" — compat-maps to "high" server-side
# "high" — reasoning_effort = high (DeepSeek default)
# "max" — reasoning_effort = max (deepest reasoning)
#
# Shift+Tab in the TUI cycles between off / high / max. The header shows the
# current tier as a ⚡ chip.
reasoning_effort = "max"
# ─────────────────────────────────────────────────────────────────────────────────
# Paths
@@ -46,11 +68,34 @@ 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
# ─────────────────────────────────────────────────────────────────────────────────
[tui]
alternate_screen = "auto" # auto | always | never
mouse_capture = true # true keeps wheel scrolling inside the TUI; false allows terminal-native drag selection/copy
# ─────────────────────────────────────────────────────────────────────────────────
# Feature Flags
@@ -74,14 +119,11 @@ max_delay = 60.0
exponential_base = 2.0
# ─────────────────────────────────────────────────────────────────────────────────
# Context Compaction (config-level tuning not yet wired; use /set auto_compact on|off)
# Context Compaction
# ─────────────────────────────────────────────────────────────────────────────────
# [compaction]
# enabled = false # Enable auto-compaction
# token_threshold = 50000 # Trigger compaction above this token estimate
# message_threshold = 50 # Or above this message count
# model = "deepseek-chat" # Model to use for summarization
# cache_summary = true # Cache the summary block
# Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`).
# There is no config-file `[compaction]` table yet; detailed thresholds are
# chosen by the TUI from the active model/context budget.
# ─────────────────────────────────────────────────────────────────────────────────
# Capacity Controller (runtime pressure guardrails)
@@ -113,6 +155,12 @@ base_url = "https://api.deepseek.com"
api_key = "DEV_DEEPSEEK_API_KEY"
allow_shell = true
[profiles.nvidia-nim]
provider = "nvidia-nim"
api_key = "YOUR_NVIDIA_API_KEY"
base_url = "https://integrate.api.nvidia.com/v1"
default_text_model = "deepseek-ai/deepseek-v4-pro"
# ─────────────────────────────────────────────────────────────────────────────────
# Hooks (optional)
# ─────────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -7,5 +7,5 @@ repository.workspace = true
description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture"
[dependencies]
deepseek-config = { path = "../config", version = "0.3.31" }
deepseek-config = { path = "../config", version = "0.5.0" }
serde.workspace = true
+103 -9
View File
@@ -30,18 +30,48 @@ impl Default for ModelRegistry {
fn default() -> Self {
let models = vec![
ModelInfo {
id: "deepseek-reasoner".to_string(),
id: "deepseek-v4-pro".to_string(),
provider: ProviderKind::Deepseek,
aliases: vec!["deepseek-r1".to_string()],
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-chat".to_string(),
id: "deepseek-v4-flash".to_string(),
provider: ProviderKind::Deepseek,
aliases: vec!["deepseek-v3".to_string(), "deepseek-v3.2".to_string()],
aliases: vec![
"deepseek-chat".to_string(),
"deepseek-reasoner".to_string(),
"deepseek-r1".to_string(),
"deepseek-v3".to_string(),
"deepseek-v3.2".to_string(),
],
supports_tools: true,
supports_reasoning: false,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/deepseek-v4-pro".to_string(),
provider: ProviderKind::NvidiaNim,
aliases: vec![
"deepseek-v4-pro".to_string(),
"nvidia-deepseek-v4-pro".to_string(),
"nim-deepseek-v4-pro".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/deepseek-v4-flash".to_string(),
provider: ProviderKind::NvidiaNim,
aliases: vec![
"deepseek-v4-flash".to_string(),
"deepseek-chat".to_string(),
"deepseek-reasoner".to_string(),
"nvidia-deepseek-v4-flash".to_string(),
"nim-deepseek-v4-flash".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gpt-4.1".to_string(),
@@ -67,9 +97,9 @@ impl ModelRegistry {
pub fn new(models: Vec<ModelInfo>) -> Self {
let mut alias_map = HashMap::new();
for (idx, model) in models.iter().enumerate() {
alias_map.insert(normalize(&model.id), idx);
alias_map.entry(normalize(&model.id)).or_insert(idx);
for alias in &model.aliases {
alias_map.insert(normalize(alias), idx);
alias_map.entry(normalize(alias)).or_insert(idx);
}
}
Self { models, alias_map }
@@ -90,6 +120,20 @@ impl ModelRegistry {
if let Some(name) = requested {
fallback_chain.push(format!("requested:{name}"));
if let Some(provider) = provider_hint
&& let Some(model) = self
.models
.iter()
.find(|m| m.provider == provider && model_matches(m, name))
.cloned()
{
return ModelResolution {
requested: Some(name.to_string()),
resolved: model,
used_fallback: false,
fallback_chain,
};
}
if let Some(idx) = self.alias_map.get(&normalize(name)) {
return ModelResolution {
requested: Some(name.to_string()),
@@ -112,13 +156,13 @@ impl ModelRegistry {
}
let final_fallback = self.models.first().cloned().unwrap_or(ModelInfo {
id: "deepseek-reasoner".to_string(),
id: "deepseek-v4-pro".to_string(),
provider: ProviderKind::Deepseek,
aliases: Vec::new(),
supports_tools: true,
supports_reasoning: true,
});
fallback_chain.push("global_default:deepseek-reasoner".to_string());
fallback_chain.push("global_default:deepseek-v4-pro".to_string());
ModelResolution {
requested: requested.map(ToOwned::to_owned),
resolved: final_fallback,
@@ -131,3 +175,53 @@ impl ModelRegistry {
fn normalize(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn model_matches(model: &ModelInfo, requested: &str) -> bool {
let requested = normalize(requested);
normalize(&model.id) == requested
|| model
.aliases
.iter()
.any(|alias| normalize(alias) == requested)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deepseek_v4_pro_alias_stays_deepseek_by_default() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-pro"), None);
assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
}
#[test]
fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-pro"), Some(ProviderKind::NvidiaNim));
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
}
#[test]
fn nvidia_nim_default_uses_catalog_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::NvidiaNim));
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-pro");
}
#[test]
fn deepseek_v4_flash_alias_resolves_to_nvidia_nim_when_provider_hinted() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::NvidiaNim));
assert_eq!(resolved.resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.resolved.id, "deepseek-ai/deepseek-v4-flash");
}
}
+9 -9
View File
@@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
deepseek-agent = { path = "../agent", version = "0.3.31" }
deepseek-config = { path = "../config", version = "0.3.31" }
deepseek-core = { path = "../core", version = "0.3.31" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" }
deepseek-hooks = { path = "../hooks", version = "0.3.31" }
deepseek-mcp = { path = "../mcp", version = "0.3.31" }
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
deepseek-state = { path = "../state", version = "0.3.31" }
deepseek-tools = { path = "../tools", version = "0.3.31" }
deepseek-agent = { path = "../agent", version = "0.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.3.31" }
deepseek-app-server = { path = "../app-server", version = "0.3.31" }
deepseek-config = { path = "../config", version = "0.3.31" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" }
deepseek-mcp = { path = "../mcp", version = "0.3.31" }
deepseek-state = { path = "../state", version = "0.3.31" }
deepseek-agent = { path = "../agent", version = "0.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
+266 -20
View File
@@ -10,7 +10,7 @@ use deepseek_agent::ModelRegistry;
use deepseek_app_server::{
AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
};
use deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind};
use deepseek_config::{CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions};
use deepseek_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
use deepseek_mcp::{McpServerDefinition, run_stdio_server};
use deepseek_state::{StateStore, ThreadListFilters};
@@ -18,6 +18,7 @@ use deepseek_state::{StateStore, ThreadListFilters};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum ProviderArg {
Deepseek,
NvidiaNim,
Openai,
}
@@ -25,6 +26,7 @@ impl From<ProviderArg> for ProviderKind {
fn from(value: ProviderArg) -> Self {
match value {
ProviderArg::Deepseek => ProviderKind::Deepseek,
ProviderArg::NvidiaNim => ProviderKind::NvidiaNim,
ProviderArg::Openai => ProviderKind::Openai,
}
}
@@ -42,7 +44,11 @@ struct Cli {
config: Option<PathBuf>,
#[arg(long)]
profile: Option<String>,
#[arg(long, value_enum)]
#[arg(
long,
value_enum,
help = "Advanced provider selector for non-TUI registry/config commands"
)]
provider: Option<ProviderArg>,
#[arg(long)]
model: Option<String>,
@@ -60,6 +66,12 @@ struct Cli {
api_key: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long = "no-alt-screen")]
no_alt_screen: bool,
#[arg(long = "mouse-capture", conflicts_with = "no_mouse_capture")]
mouse_capture: bool,
#[arg(long = "no-mouse-capture", conflicts_with = "mouse_capture")]
no_mouse_capture: bool,
#[arg(value_name = "PROMPT")]
prompt: Option<String>,
#[command(subcommand)]
@@ -70,7 +82,37 @@ struct Cli {
enum Commands {
/// Run interactive/non-interactive flows via the TUI binary.
Run(RunArgs),
/// Login using API key, ChatGPT token, or device code style session.
/// Run DeepSeek TUI diagnostics.
Doctor(TuiPassthroughArgs),
/// List live DeepSeek API models via the TUI binary.
Models(TuiPassthroughArgs),
/// List saved TUI sessions.
Sessions(TuiPassthroughArgs),
/// Resume a saved TUI session.
Resume(TuiPassthroughArgs),
/// Fork a saved TUI session.
Fork(TuiPassthroughArgs),
/// Create a default AGENTS.md in the current directory.
Init(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
Setup(TuiPassthroughArgs),
/// Run the DeepSeek TUI non-interactive agent command.
Exec(TuiPassthroughArgs),
/// Run a DeepSeek-powered code review over a git diff.
Review(TuiPassthroughArgs),
/// Apply a patch file or stdin to the working tree.
Apply(TuiPassthroughArgs),
/// Run the offline TUI evaluation harness.
Eval(TuiPassthroughArgs),
/// Manage TUI MCP servers.
Mcp(TuiPassthroughArgs),
/// Inspect TUI feature flags.
Features(TuiPassthroughArgs),
/// Run a local TUI server.
Serve(TuiPassthroughArgs),
/// Generate shell completions for the TUI binary.
Completions(TuiPassthroughArgs),
/// Save a DeepSeek API key to the shared config.
Login(LoginArgs),
/// Remove saved authentication state.
Logout,
@@ -101,17 +143,23 @@ struct RunArgs {
args: Vec<String>,
}
#[derive(Debug, Args, Clone)]
struct TuiPassthroughArgs {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
#[derive(Debug, Args)]
struct LoginArgs {
#[arg(long, value_enum, default_value_t = ProviderArg::Deepseek)]
#[arg(long, value_enum, default_value_t = ProviderArg::Deepseek, hide = true)]
provider: ProviderArg,
#[arg(long)]
api_key: Option<String>,
#[arg(long, default_value_t = false)]
#[arg(long, default_value_t = false, hide = true)]
chatgpt: bool,
#[arg(long, default_value_t = false)]
#[arg(long, default_value_t = false, hide = true)]
device_code: bool,
#[arg(long)]
#[arg(long, hide = true)]
token: Option<String>,
}
@@ -279,12 +327,57 @@ fn run() -> Result<()> {
approval_policy: cli.approval_policy.clone(),
sandbox_mode: cli.sandbox_mode.clone(),
};
let _resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);
let resolved_runtime = store.config.resolve_runtime_options(&runtime_overrides);
let command = cli.command.take();
match command {
Some(Commands::Run(args)) => delegate_to_tui(&cli, args.args),
Some(Commands::Run(args)) => delegate_to_tui(&cli, &resolved_runtime, args.args),
Some(Commands::Doctor(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("doctor", args))
}
Some(Commands::Models(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("models", args))
}
Some(Commands::Sessions(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("sessions", args))
}
Some(Commands::Resume(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("resume", args))
}
Some(Commands::Fork(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
}
Some(Commands::Init(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("init", args))
}
Some(Commands::Setup(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("setup", args))
}
Some(Commands::Exec(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("exec", args))
}
Some(Commands::Review(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("review", args))
}
Some(Commands::Apply(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("apply", args))
}
Some(Commands::Eval(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("eval", args))
}
Some(Commands::Mcp(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("mcp", args))
}
Some(Commands::Features(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("features", args))
}
Some(Commands::Serve(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("serve", args))
}
Some(Commands::Completions(args)) => {
delegate_to_tui(&cli, &resolved_runtime, tui_args("completions", args))
}
Some(Commands::Login(args)) => run_login_command(&mut store, args),
Some(Commands::Logout) => run_logout_command(&mut store),
Some(Commands::Auth(args)) => run_auth_command(&mut store, args.command),
@@ -305,11 +398,18 @@ fn run() -> Result<()> {
forwarded.push("--prompt".to_string());
forwarded.push(prompt);
}
delegate_to_tui(&cli, forwarded)
delegate_to_tui(&cli, &resolved_runtime, forwarded)
}
}
}
fn tui_args(command: &str, args: TuiPassthroughArgs) -> Vec<String> {
let mut forwarded = Vec::with_capacity(args.args.len() + 1);
forwarded.push(command.to_string());
forwarded.extend(args.args);
forwarded
}
fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
let provider: ProviderKind = args.provider.into();
store.config.provider = provider;
@@ -349,13 +449,35 @@ fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> {
};
store.config.auth_mode = Some("api_key".to_string());
store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
if provider == ProviderKind::Deepseek {
store.config.api_key = store.config.providers.deepseek.api_key.clone();
if store.config.default_text_model.is_none() {
store.config.default_text_model = Some(
store
.config
.providers
.deepseek
.model
.clone()
.unwrap_or_else(|| "deepseek-v4-pro".to_string()),
);
}
}
store.save()?;
println!("logged in using API key mode ({})", provider.as_str());
if provider == ProviderKind::Deepseek {
println!(
"logged in using API key mode (deepseek). This also updates the shared deepseek-tui config."
);
} else {
println!("logged in using API key mode ({})", provider.as_str());
}
Ok(())
}
fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
store.config.api_key = None;
store.config.providers.deepseek.api_key = None;
store.config.providers.nvidia_nim.api_key = None;
store.config.providers.openai.api_key = None;
store.config.auth_mode = None;
store.config.chatgpt_access_token = None;
@@ -376,12 +498,18 @@ fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()>
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let nvidia_env = std::env::var("NVIDIA_API_KEY")
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
.ok()
.filter(|v| !v.trim().is_empty())
.is_some();
let deepseek_file = store
.config
.providers
.deepseek
.api_key
.as_ref()
.or(store.config.api_key.as_ref())
.is_some_and(|v| !v.trim().is_empty());
let openai_file = store
.config
@@ -390,12 +518,23 @@ fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()>
.api_key
.as_ref()
.is_some_and(|v| !v.trim().is_empty());
let nvidia_file = store
.config
.providers
.nvidia_nim
.api_key
.as_ref()
.is_some_and(|v| !v.trim().is_empty());
println!("provider: {}", store.config.provider.as_str());
println!(
"deepseek auth: env={}, config={}",
deepseek_env, deepseek_file
);
println!(
"nvidia-nim auth: env={}, config={}",
nvidia_env, nvidia_file
);
println!("openai auth: env={}, config={}", openai_env, openai_file);
Ok(())
}
@@ -407,6 +546,9 @@ fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()>
};
store.config.provider = provider;
store.config.providers.for_provider_mut(provider).api_key = Some(api_key);
if provider == ProviderKind::Deepseek {
store.config.api_key = store.config.providers.deepseek.api_key.clone();
}
store.save()?;
println!("saved API key for {}", provider.as_str());
Ok(())
@@ -414,6 +556,9 @@ fn run_auth_command(store: &mut ConfigStore, command: AuthCommand) -> Result<()>
AuthCommand::Clear { provider } => {
let provider: ProviderKind = provider.into();
store.config.providers.for_provider_mut(provider).api_key = None;
if provider == ProviderKind::Deepseek {
store.config.api_key = None;
}
store.save()?;
println!("cleared API key for {}", provider.as_str());
Ok(())
@@ -623,7 +768,11 @@ fn persist_mcp_server_definitions(
store.save()
}
fn delegate_to_tui(cli: &Cli, passthrough: Vec<String>) -> Result<()> {
fn delegate_to_tui(
cli: &Cli,
resolved_runtime: &ResolvedRuntimeOptions,
passthrough: Vec<String>,
) -> Result<()> {
let current = std::env::current_exe().context("failed to locate current executable path")?;
let tui = current.with_file_name("deepseek-tui");
if !tui.exists() {
@@ -640,11 +789,34 @@ fn delegate_to_tui(cli: &Cli, passthrough: Vec<String>) -> Result<()> {
if let Some(profile) = cli.profile.as_ref() {
cmd.arg("--profile").arg(profile);
}
if cli.no_alt_screen {
cmd.arg("--no-alt-screen");
}
if cli.mouse_capture {
cmd.arg("--mouse-capture");
}
if cli.no_mouse_capture {
cmd.arg("--no-mouse-capture");
}
cmd.args(passthrough);
if let Some(provider) = cli.provider {
cmd.env("DEEPSEEK_PROVIDER", ProviderKind::from(provider).as_str());
if !matches!(
resolved_runtime.provider,
ProviderKind::Deepseek | ProviderKind::NvidiaNim
) {
bail!(
"The interactive TUI supports DeepSeek and NVIDIA NIM providers. Remove --provider {} or use `deepseek model ...` for provider registry inspection.",
resolved_runtime.provider.as_str()
);
}
cmd.env("DEEPSEEK_MODEL", &resolved_runtime.model);
cmd.env("DEEPSEEK_BASE_URL", &resolved_runtime.base_url);
cmd.env("DEEPSEEK_PROVIDER", resolved_runtime.provider.as_str());
if let Some(api_key) = resolved_runtime.api_key.as_ref() {
cmd.env("DEEPSEEK_API_KEY", api_key);
}
if let Some(model) = cli.model.as_ref() {
cmd.env("DEEPSEEK_MODEL", model);
}
@@ -735,12 +907,12 @@ mod tests {
})) if key == "provider"
));
let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-chat"]);
let cli = parse_ok(&["deepseek", "config", "set", "model", "deepseek-v4-flash"]);
assert!(matches!(
cli.command,
Some(Commands::Config(ConfigArgs {
command: ConfigCommand::Set { ref key, ref value }
})) if key == "model" && value == "deepseek-chat"
})) if key == "model" && value == "deepseek-v4-flash"
));
let cli = parse_ok(&["deepseek", "config", "unset", "model"]);
@@ -785,7 +957,7 @@ mod tests {
}))
));
let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-chat"]);
let cli = parse_ok(&["deepseek", "model", "resolve", "deepseek-v4-flash"]);
assert!(matches!(
cli.command,
Some(Commands::Model(ModelArgs {
@@ -793,7 +965,7 @@ mod tests {
model: Some(ref model),
provider: None
}
})) if model == "deepseek-chat"
})) if model == "deepseek-v4-flash"
));
let cli = parse_ok(&[
@@ -802,7 +974,7 @@ mod tests {
"resolve",
"--provider",
"deepseek",
"deepseek-reasoner",
"deepseek-v4-pro",
]);
assert!(matches!(
cli.command,
@@ -811,7 +983,7 @@ mod tests {
model: Some(ref model),
provider: Some(ProviderArg::Deepseek)
}
})) if model == "deepseek-reasoner"
})) if model == "deepseek-v4-pro"
));
}
@@ -931,6 +1103,67 @@ mod tests {
));
}
#[test]
fn parses_direct_tui_command_aliases() {
let cli = parse_ok(&["deepseek", "doctor"]);
assert!(matches!(
cli.command,
Some(Commands::Doctor(TuiPassthroughArgs { ref args })) if args.is_empty()
));
let cli = parse_ok(&["deepseek", "models", "--json"]);
assert!(matches!(
cli.command,
Some(Commands::Models(TuiPassthroughArgs { ref args })) if args == &["--json"]
));
let cli = parse_ok(&["deepseek", "resume", "abc123"]);
assert!(matches!(
cli.command,
Some(Commands::Resume(TuiPassthroughArgs { ref args })) if args == &["abc123"]
));
let cli = parse_ok(&["deepseek", "setup", "--skills", "--local"]);
assert!(matches!(
cli.command,
Some(Commands::Setup(TuiPassthroughArgs { ref args }))
if args == &["--skills", "--local"]
));
}
#[test]
fn deepseek_login_writes_tui_compatible_config() {
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
let path = std::env::temp_dir().join(format!(
"deepseek-cli-login-test-{}-{nanos}.toml",
std::process::id()
));
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
run_login_command(
&mut store,
LoginArgs {
provider: ProviderArg::Deepseek,
api_key: Some("sk-test".to_string()),
chatgpt: false,
device_code: false,
token: None,
},
)
.expect("login should write config");
assert_eq!(store.config.api_key.as_deref(), Some("sk-test"));
assert_eq!(
store.config.default_text_model.as_deref(),
Some("deepseek-v4-pro")
);
let saved = std::fs::read_to_string(&path).expect("config should be written");
assert!(saved.contains("api_key = \"sk-test\""));
assert!(saved.contains("default_text_model = \"deepseek-v4-pro\""));
let _ = std::fs::remove_file(path);
}
#[test]
fn parses_global_override_flags() {
let cli = parse_ok(&[
@@ -957,6 +1190,8 @@ mod tests {
"https://api.openai.com/v1",
"--api-key",
"sk-test",
"--no-alt-screen",
"--no-mouse-capture",
"model",
"resolve",
"gpt-4.1",
@@ -973,6 +1208,9 @@ mod tests {
assert_eq!(cli.sandbox_mode.as_deref(), Some("workspace-write"));
assert_eq!(cli.base_url.as_deref(), Some("https://api.openai.com/v1"));
assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
assert!(cli.no_alt_screen);
assert!(cli.no_mouse_capture);
assert!(!cli.mouse_capture);
}
#[test]
@@ -981,6 +1219,11 @@ mod tests {
for token in [
"run",
"doctor",
"models",
"sessions",
"resume",
"setup",
"login",
"logout",
"auth",
@@ -1002,6 +1245,9 @@ mod tests {
"--api-key",
"--approval-policy",
"--sandbox-mode",
"--no-alt-screen",
"--mouse-capture",
"--no-mouse-capture",
] {
assert!(
rendered.contains(token),
+371 -8
View File
@@ -6,9 +6,12 @@ use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const CONFIG_FILE_NAME: &str = "config.toml";
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-reasoner";
const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
const DEFAULT_OPENAI_MODEL: &str = "gpt-4.1";
const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com";
const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -16,6 +19,7 @@ const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub enum ProviderKind {
#[default]
Deepseek,
NvidiaNim,
Openai,
}
@@ -24,6 +28,7 @@ impl ProviderKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Deepseek => "deepseek",
Self::NvidiaNim => "nvidia-nim",
Self::Openai => "openai",
}
}
@@ -32,6 +37,7 @@ impl ProviderKind {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"deepseek" | "deep-seek" => Some(Self::Deepseek),
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
"openai" | "open-ai" => Some(Self::Openai),
_ => None,
}
@@ -50,6 +56,8 @@ pub struct ProvidersToml {
#[serde(default)]
pub deepseek: ProviderConfigToml,
#[serde(default)]
pub nvidia_nim: ProviderConfigToml,
#[serde(default)]
pub openai: ProviderConfigToml,
}
@@ -58,6 +66,7 @@ impl ProvidersToml {
pub fn for_provider(&self, provider: ProviderKind) -> &ProviderConfigToml {
match provider {
ProviderKind::Deepseek => &self.deepseek,
ProviderKind::NvidiaNim => &self.nvidia_nim,
ProviderKind::Openai => &self.openai,
}
}
@@ -65,6 +74,7 @@ impl ProvidersToml {
pub fn for_provider_mut(&mut self, provider: ProviderKind) -> &mut ProviderConfigToml {
match provider {
ProviderKind::Deepseek => &mut self.deepseek,
ProviderKind::NvidiaNim => &mut self.nvidia_nim,
ProviderKind::Openai => &mut self.openai,
}
}
@@ -72,6 +82,13 @@ impl ProvidersToml {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigToml {
/// TUI-compatible DeepSeek API key. Kept at the root so both `deepseek`
/// and `deepseek-tui` can share a single config file.
pub api_key: Option<String>,
/// TUI-compatible DeepSeek base URL.
pub base_url: Option<String>,
/// TUI-compatible default DeepSeek model.
pub default_text_model: Option<String>,
#[serde(default)]
pub provider: ProviderKind,
pub model: Option<String>,
@@ -94,6 +111,9 @@ impl ConfigToml {
pub fn get_value(&self, key: &str) -> Option<String> {
match key {
"provider" => Some(self.provider.as_str().to_string()),
"api_key" => self.api_key.clone(),
"base_url" => self.base_url.clone(),
"default_text_model" => self.default_text_model.clone(),
"model" => self.model.clone(),
"auth.mode" => self.auth_mode.clone(),
"auth.chatgpt_access_token" => self.chatgpt_access_token.clone(),
@@ -106,6 +126,9 @@ impl ConfigToml {
"providers.deepseek.api_key" => self.providers.deepseek.api_key.clone(),
"providers.deepseek.base_url" => self.providers.deepseek.base_url.clone(),
"providers.deepseek.model" => self.providers.deepseek.model.clone(),
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key.clone(),
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url.clone(),
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model.clone(),
"providers.openai.api_key" => self.providers.openai.api_key.clone(),
"providers.openai.base_url" => self.providers.openai.base_url.clone(),
"providers.openai.model" => self.providers.openai.model.clone(),
@@ -119,6 +142,9 @@ impl ConfigToml {
self.provider = ProviderKind::parse(value)
.with_context(|| format!("unknown provider '{value}'"))?;
}
"api_key" => self.api_key = Some(value.to_string()),
"base_url" => self.base_url = Some(value.to_string()),
"default_text_model" => self.default_text_model = Some(value.to_string()),
"model" => self.model = Some(value.to_string()),
"auth.mode" => self.auth_mode = Some(value.to_string()),
"auth.chatgpt_access_token" => self.chatgpt_access_token = Some(value.to_string()),
@@ -131,15 +157,32 @@ impl ConfigToml {
"approval_policy" => self.approval_policy = Some(value.to_string()),
"sandbox_mode" => self.sandbox_mode = Some(value.to_string()),
"providers.deepseek.api_key" => {
self.providers.deepseek.api_key = Some(value.to_string())
let value = value.to_string();
self.providers.deepseek.api_key = Some(value.clone());
self.api_key = Some(value);
}
"providers.deepseek.base_url" => {
self.providers.deepseek.base_url = Some(value.to_string());
let value = value.to_string();
self.providers.deepseek.base_url = Some(value.clone());
self.base_url = Some(value);
}
"providers.deepseek.model" => {
let value = value.to_string();
self.providers.deepseek.model = Some(value.clone());
self.default_text_model = Some(value);
}
"providers.deepseek.model" => self.providers.deepseek.model = Some(value.to_string()),
"providers.openai.api_key" => self.providers.openai.api_key = Some(value.to_string()),
"providers.openai.base_url" => self.providers.openai.base_url = Some(value.to_string()),
"providers.openai.model" => self.providers.openai.model = Some(value.to_string()),
"providers.nvidia_nim.api_key" => {
self.providers.nvidia_nim.api_key = Some(value.to_string());
}
"providers.nvidia_nim.base_url" => {
self.providers.nvidia_nim.base_url = Some(value.to_string());
}
"providers.nvidia_nim.model" => {
self.providers.nvidia_nim.model = Some(value.to_string());
}
_ => {
self.extras
.insert(key.to_string(), toml::Value::String(value.to_string()));
@@ -151,6 +194,9 @@ impl ConfigToml {
pub fn unset_value(&mut self, key: &str) -> Result<()> {
match key {
"provider" => self.provider = ProviderKind::Deepseek,
"api_key" => self.api_key = None,
"base_url" => self.base_url = None,
"default_text_model" => self.default_text_model = None,
"model" => self.model = None,
"auth.mode" => self.auth_mode = None,
"auth.chatgpt_access_token" => self.chatgpt_access_token = None,
@@ -160,12 +206,24 @@ impl ConfigToml {
"telemetry" => self.telemetry = None,
"approval_policy" => self.approval_policy = None,
"sandbox_mode" => self.sandbox_mode = None,
"providers.deepseek.api_key" => self.providers.deepseek.api_key = None,
"providers.deepseek.base_url" => self.providers.deepseek.base_url = None,
"providers.deepseek.model" => self.providers.deepseek.model = None,
"providers.deepseek.api_key" => {
self.providers.deepseek.api_key = None;
self.api_key = None;
}
"providers.deepseek.base_url" => {
self.providers.deepseek.base_url = None;
self.base_url = None;
}
"providers.deepseek.model" => {
self.providers.deepseek.model = None;
self.default_text_model = None;
}
"providers.openai.api_key" => self.providers.openai.api_key = None,
"providers.openai.base_url" => self.providers.openai.base_url = None,
"providers.openai.model" => self.providers.openai.model = None,
"providers.nvidia_nim.api_key" => self.providers.nvidia_nim.api_key = None,
"providers.nvidia_nim.base_url" => self.providers.nvidia_nim.base_url = None,
"providers.nvidia_nim.model" => self.providers.nvidia_nim.model = None,
_ => {
self.extras.remove(key);
}
@@ -178,6 +236,15 @@ impl ConfigToml {
let mut out = BTreeMap::new();
out.insert("provider".to_string(), self.provider.as_str().to_string());
if let Some(v) = self.api_key.as_ref() {
out.insert("api_key".to_string(), redact_secret(v));
}
if let Some(v) = self.base_url.as_ref() {
out.insert("base_url".to_string(), v.clone());
}
if let Some(v) = self.default_text_model.as_ref() {
out.insert("default_text_model".to_string(), v.clone());
}
if let Some(v) = self.model.as_ref() {
out.insert("model".to_string(), v.clone());
}
@@ -223,6 +290,15 @@ impl ConfigToml {
if let Some(v) = self.providers.openai.model.as_ref() {
out.insert("providers.openai.model".to_string(), v.clone());
}
if let Some(v) = self.providers.nvidia_nim.api_key.as_ref() {
out.insert("providers.nvidia_nim.api_key".to_string(), redact_secret(v));
}
if let Some(v) = self.providers.nvidia_nim.base_url.as_ref() {
out.insert("providers.nvidia_nim.base_url".to_string(), v.clone());
}
if let Some(v) = self.providers.nvidia_nim.model.as_ref() {
out.insert("providers.nvidia_nim.model".to_string(), v.clone());
}
for (k, v) in &self.extras {
out.insert(k.clone(), v.to_string());
@@ -236,19 +312,31 @@ impl ConfigToml {
let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
let provider_cfg = self.providers.for_provider(provider);
let root_deepseek_api_key = (provider == ProviderKind::Deepseek)
.then(|| self.api_key.clone())
.flatten();
let root_deepseek_base_url = (provider == ProviderKind::Deepseek)
.then(|| self.base_url.clone())
.flatten();
let root_deepseek_model = (provider == ProviderKind::Deepseek)
.then(|| self.default_text_model.clone())
.flatten();
let api_key = cli
.api_key
.clone()
.or_else(|| env.api_key_for(provider))
.or_else(|| provider_cfg.api_key.clone());
.or_else(|| provider_cfg.api_key.clone())
.or(root_deepseek_api_key);
let base_url = cli
.base_url
.clone()
.or_else(|| env.base_url_for(provider))
.or_else(|| provider_cfg.base_url.clone())
.or(root_deepseek_base_url)
.unwrap_or_else(|| match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(),
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(),
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(),
});
@@ -257,11 +345,14 @@ impl ConfigToml {
.clone()
.or_else(|| env.model.clone())
.or_else(|| provider_cfg.model.clone())
.or(root_deepseek_model)
.or_else(|| self.model.clone())
.unwrap_or_else(|| match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL.to_string(),
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
ProviderKind::Openai => DEFAULT_OPENAI_MODEL.to_string(),
});
let model = normalize_model_for_provider(provider, &model);
let output_mode = cli
.output_mode
@@ -309,6 +400,21 @@ impl ConfigToml {
}
}
fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String {
let normalized = model.trim().to_ascii_lowercase();
match (provider, normalized.as_str()) {
(ProviderKind::NvidiaNim, "deepseek-v4-pro" | "deepseek-v4pro") => {
DEFAULT_NVIDIA_NIM_MODEL.to_string()
}
(
ProviderKind::NvidiaNim,
"deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner"
| "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2",
) => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
_ => model.to_string(),
}
}
#[derive(Debug, Clone, Default)]
pub struct CliRuntimeOverrides {
pub provider: Option<ProviderKind>,
@@ -427,7 +533,9 @@ struct EnvRuntimeOverrides {
sandbox_mode: Option<String>,
deepseek_api_key: Option<String>,
openai_api_key: Option<String>,
nvidia_api_key: Option<String>,
deepseek_base_url: Option<String>,
nvidia_base_url: Option<String>,
openai_base_url: Option<String>,
}
@@ -452,9 +560,18 @@ impl EnvRuntimeOverrides {
openai_api_key: std::env::var("OPENAI_API_KEY")
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_api_key: std::env::var("NVIDIA_API_KEY")
.or_else(|_| std::env::var("NVIDIA_NIM_API_KEY"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
@@ -464,6 +581,10 @@ impl EnvRuntimeOverrides {
fn api_key_for(&self, provider: ProviderKind) -> Option<String> {
match provider {
ProviderKind::Deepseek => self.deepseek_api_key.clone(),
ProviderKind::NvidiaNim => self
.nvidia_api_key
.clone()
.or_else(|| self.deepseek_api_key.clone()),
ProviderKind::Openai => self.openai_api_key.clone(),
}
}
@@ -471,7 +592,249 @@ impl EnvRuntimeOverrides {
fn base_url_for(&self, provider: ProviderKind) -> Option<String> {
match provider {
ProviderKind::Deepseek => self.deepseek_base_url.clone(),
ProviderKind::NvidiaNim => self.nvidia_base_url.clone(),
ProviderKind::Openai => self.openai_base_url.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
struct EnvGuard {
deepseek_api_key: Option<OsString>,
deepseek_base_url: Option<OsString>,
deepseek_model: Option<OsString>,
deepseek_provider: Option<OsString>,
nvidia_api_key: Option<OsString>,
nvidia_nim_api_key: Option<OsString>,
nim_base_url: Option<OsString>,
nvidia_base_url: Option<OsString>,
nvidia_nim_base_url: Option<OsString>,
}
impl EnvGuard {
fn without_deepseek_runtime_overrides() -> Self {
let guard = Self {
deepseek_api_key: env::var_os("DEEPSEEK_API_KEY"),
deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"),
deepseek_model: env::var_os("DEEPSEEK_MODEL"),
deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"),
nvidia_api_key: env::var_os("NVIDIA_API_KEY"),
nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"),
nim_base_url: env::var_os("NIM_BASE_URL"),
nvidia_base_url: env::var_os("NVIDIA_BASE_URL"),
nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"),
};
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
env::remove_var("DEEPSEEK_API_KEY");
env::remove_var("DEEPSEEK_BASE_URL");
env::remove_var("DEEPSEEK_MODEL");
env::remove_var("DEEPSEEK_PROVIDER");
env::remove_var("NVIDIA_API_KEY");
env::remove_var("NVIDIA_NIM_API_KEY");
env::remove_var("NIM_BASE_URL");
env::remove_var("NVIDIA_BASE_URL");
env::remove_var("NVIDIA_NIM_BASE_URL");
}
guard
}
unsafe fn restore_var(key: &str, value: Option<OsString>) {
if let Some(value) = value {
unsafe { env::set_var(key, value) };
} else {
unsafe { env::remove_var(key) };
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
}
}
}
#[test]
fn root_deepseek_fields_are_runtime_fallbacks() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
api_key: Some("root-key".to_string()),
base_url: Some("https://api.deepseek.com".to_string()),
default_text_model: Some("deepseek-v4-pro".to_string()),
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Deepseek);
assert_eq!(resolved.api_key.as_deref(), Some("root-key"));
assert_eq!(resolved.base_url, "https://api.deepseek.com");
assert_eq!(resolved.model, "deepseek-v4-pro");
}
#[test]
fn provider_specific_deepseek_fields_override_tui_compat_fields() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let mut config = ConfigToml {
api_key: Some("root-key".to_string()),
base_url: Some("https://api.deepseek.com".to_string()),
default_text_model: Some("deepseek-v4-pro".to_string()),
..ConfigToml::default()
};
config.providers.deepseek.api_key = Some("provider-key".to_string());
config.providers.deepseek.base_url = Some("https://api.deepseeki.com".to_string());
config.providers.deepseek.model = Some("deepseek-v4-flash".to_string());
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.api_key.as_deref(), Some("provider-key"));
assert_eq!(resolved.base_url, "https://api.deepseeki.com");
assert_eq!(resolved.model, "deepseek-v4-flash");
}
#[test]
fn nvidia_nim_provider_defaults_to_catalog_endpoint_and_model() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
provider: ProviderKind::NvidiaNim,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.base_url, DEFAULT_NVIDIA_NIM_BASE_URL);
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
}
#[test]
fn nvidia_nim_provider_uses_provider_specific_credentials() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let mut config = ConfigToml {
provider: ProviderKind::NvidiaNim,
..ConfigToml::default()
};
config.providers.nvidia_nim.api_key = Some("nim-key".to_string());
config.providers.nvidia_nim.base_url = Some("https://nim.example/v1".to_string());
config.providers.nvidia_nim.model = Some("deepseek-ai/deepseek-v4-pro".to_string());
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.api_key.as_deref(), Some("nim-key"));
assert_eq!(resolved.base_url, "https://nim.example/v1");
assert_eq!(resolved.model, "deepseek-ai/deepseek-v4-pro");
}
#[test]
fn nvidia_nim_provider_normalizes_flash_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let cli = CliRuntimeOverrides {
provider: Some(ProviderKind::NvidiaNim),
model: Some("deepseek-v4-flash".to_string()),
..CliRuntimeOverrides::default()
};
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_FLASH_MODEL);
}
#[test]
fn nvidia_nim_provider_uses_nvidia_env_credentials() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
env::set_var("NVIDIA_API_KEY", "nim-env-key");
env::set_var("NVIDIA_NIM_BASE_URL", "https://nim-env.example/v1");
}
let config = ConfigToml::default();
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.api_key.as_deref(), Some("nim-env-key"));
assert_eq!(resolved.base_url, "https://nim-env.example/v1");
assert_eq!(resolved.model, DEFAULT_NVIDIA_NIM_MODEL);
}
#[test]
fn nvidia_nim_provider_accepts_short_nim_base_url_alias() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
env::set_var("NVIDIA_API_KEY", "nim-env-key");
env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
}
let config = ConfigToml::default();
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.base_url, "https://short-nim.example/v1");
}
#[test]
fn nvidia_nim_provider_can_fallback_to_deepseek_api_key_env() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
env::set_var("DEEPSEEK_API_KEY", "deepseek-compat-key");
}
let config = ConfigToml::default();
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::NvidiaNim);
assert_eq!(resolved.api_key.as_deref(), Some("deepseek-compat-key"));
}
#[test]
fn list_values_redacts_root_api_key() {
let config = ConfigToml {
api_key: Some("sk-deepseek-secret".to_string()),
..ConfigToml::default()
};
let values = config.list_values();
assert_eq!(
values.get("api_key").map(String::as_str),
Some("sk-d***cret")
);
}
}
+8 -8
View File
@@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
deepseek-agent = { path = "../agent", version = "0.3.31" }
deepseek-config = { path = "../config", version = "0.3.31" }
deepseek-execpolicy = { path = "../execpolicy", version = "0.3.31" }
deepseek-hooks = { path = "../hooks", version = "0.3.31" }
deepseek-mcp = { path = "../mcp", version = "0.3.31" }
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
deepseek-state = { path = "../state", version = "0.3.31" }
deepseek-tools = { path = "../tools", version = "0.3.31" }
deepseek-agent = { path = "../agent", version = "0.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.3.31" }
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.3.31" }
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.3.31" }
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
serde.workspace = true
serde_json.workspace = true
+2 -2
View File
@@ -6,7 +6,7 @@ fn thread_resume_params_round_trip() {
thread_id: "thread-123".to_string(),
history: None,
path: None,
model: Some("deepseek-reasoner".to_string()),
model: Some("deepseek-v4-pro".to_string()),
model_provider: Some("deepseek".to_string()),
cwd: None,
approval_policy: Some("on-request".to_string()),
@@ -23,7 +23,7 @@ fn thread_resume_params_round_trip() {
match decoded {
ThreadRequest::Resume(params) => {
assert_eq!(params.thread_id, "thread-123");
assert_eq!(params.model.as_deref(), Some("deepseek-reasoner"));
assert_eq!(params.model.as_deref(), Some("deepseek-v4-pro"));
assert!(params.persist_extended_history);
}
other => panic!("unexpected request: {other:?}"),
+1 -1
View File
@@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
deepseek-protocol = { path = "../protocol", version = "0.3.31" }
deepseek-protocol = { path = "../protocol", version = "0.5.0" }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
+2 -2
View File
@@ -403,7 +403,7 @@ impl AutomationManager {
}
out.push(record);
}
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
out.sort_by_key(|r| std::cmp::Reverse(r.updated_at));
Ok(out)
}
@@ -522,7 +522,7 @@ impl AutomationManager {
out.push(run);
}
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
out.sort_by_key(|r| std::cmp::Reverse(r.created_at));
if let Some(limit) = limit {
out.truncate(limit);
}
+1127 -97
View File
File diff suppressed because it is too large. Load diff
+124
View File
@@ -0,0 +1,124 @@
//! Local media attachment commands.
use std::path::{Path, PathBuf};
use super::CommandResult;
use crate::tui::app::App;
pub fn attach(app: &mut App, arg: Option<&str>) -> CommandResult {
let Some(raw_path) = arg.map(str::trim).filter(|value| !value.is_empty()) else {
return CommandResult::error("Usage: /attach <image-or-video-path>");
};
let path = resolve_attachment_path(raw_path, &app.workspace);
let Ok(path) = path.canonicalize() else {
return CommandResult::error(format!("Attachment not found: {}", path.display()));
};
if !path.is_file() {
return CommandResult::error(format!("Attachment is not a file: {}", path.display()));
}
let Some(kind) = media_kind(&path) else {
return CommandResult::error(
"Unsupported attachment type. /attach is for image/video paths; use @path for text files or directories.",
);
};
app.insert_media_attachment(kind, &path, None);
CommandResult::message(format!("Attached {kind}: {}", path.display()))
}
fn resolve_attachment_path(raw_path: &str, workspace: &Path) -> PathBuf {
let unquoted = raw_path.trim().trim_matches('"').trim_matches('\'');
let path = expand_home(unquoted);
if path.is_absolute() {
path
} else {
workspace.join(path)
}
}
fn expand_home(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home);
}
} else if let Some(rest) = path.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return PathBuf::from(home).join(rest);
}
PathBuf::from(path)
}
fn media_kind(path: &Path) -> Option<&'static str> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"),
"mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::app::TuiOptions;
use tempfile::TempDir;
fn app_with_workspace(tmpdir: &TempDir) -> App {
App::new(
TuiOptions {
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
notes_path: tmpdir.path().join("notes.txt"),
mcp_config_path: tmpdir.path().join("mcp.json"),
use_memory: false,
start_in_agent_mode: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
},
&Config::default(),
)
}
#[test]
fn attach_inserts_image_reference() {
let tmpdir = TempDir::new().expect("tempdir");
let image_path = tmpdir.path().join("photo.png");
std::fs::write(&image_path, b"not actually decoded").expect("write image fixture");
let mut app = app_with_workspace(&tmpdir);
let result = attach(&mut app, Some("photo.png"));
assert!(result.message.expect("message").contains("Attached image"));
assert!(app.input.contains("[Attached image:"));
let canonical_path = image_path.canonicalize().expect("canonical image path");
assert!(app.input.contains(&canonical_path.display().to_string()));
}
#[test]
fn attach_rejects_unsupported_extension() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(tmpdir.path().join("notes.txt"), b"text").expect("write fixture");
let mut app = app_with_workspace(&tmpdir);
let result = attach(&mut app, Some("notes.txt"));
assert!(
result
.message
.expect("message")
.contains("Unsupported attachment type")
);
assert!(app.input.is_empty());
}
}
+139 -14
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
@@ -332,6 +441,7 @@ mod tests {
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"),
@@ -400,11 +510,11 @@ mod tests {
fn test_set_model_updates_app_state() {
let mut app = create_test_app();
let _old_model = app.model.clone();
let result = set_config(&mut app, Some("model deepseek-reasoner"));
let result = set_config(&mut app, Some("model deepseek-v4-flash"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("model = deepseek-reasoner"));
assert_eq!(app.model, "deepseek-reasoner");
assert!(msg.contains("model = deepseek-v4-flash"));
assert_eq!(app.model, "deepseek-v4-flash");
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
@@ -424,10 +534,10 @@ mod tests {
#[test]
fn test_set_model_with_save_flag() {
let mut app = create_test_app();
let _result = set_config(&mut app, Some("model deepseek-reasoner --save"));
let _result = set_config(&mut app, Some("model deepseek-v4-flash --save"));
// Note: This test may fail in environments where settings can't be saved
// The important thing is that the model is updated
assert_eq!(app.model, "deepseek-reasoner");
assert_eq!(app.model, "deepseek-v4-flash");
}
#[test]
@@ -508,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();
+14 -19
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: {}\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)
}
}
@@ -212,10 +210,11 @@ mod tests {
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-reasoner".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: PathBuf::from("/tmp/test-skills"),
memory_path: PathBuf::from("memory.md"),
@@ -328,16 +327,16 @@ mod tests {
fn test_model_change_updates_state() {
let mut app = create_test_app();
let old_model = app.model.clone();
let result = model(&mut app, Some("deepseek-reasoner"));
let result = model(&mut app, Some("deepseek-v4-flash"));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains(&old_model));
assert!(msg.contains("deepseek-reasoner"));
assert!(msg.contains("deepseek-v4-flash"));
assert!(matches!(
result.action,
Some(AppAction::UpdateCompaction(_))
));
assert_eq!(app.model, "deepseek-reasoner");
assert_eq!(app.model, "deepseek-v4-flash");
assert_eq!(app.last_prompt_tokens, None);
assert_eq!(app.last_completion_tokens, None);
}
@@ -364,21 +363,17 @@ mod tests {
let msg = result.message.unwrap();
assert!(msg.contains("Invalid model"));
assert!(msg.contains("DeepSeek model ID"));
assert!(msg.contains("deepseek-chat"));
assert!(msg.contains("deepseek-reasoner"));
assert!(msg.contains("deepseek-v4-pro"));
assert!(msg.contains("deepseek-v4-flash"));
assert!(result.action.is_none());
}
#[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]
+2 -1
View File
@@ -123,10 +123,11 @@ mod tests {
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("/tmp/test-workspace"),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: PathBuf::from("/tmp/test-skills"),
memory_path: PathBuf::from("memory.md"),
+2 -1
View File
@@ -161,10 +161,11 @@ mod tests {
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
+22 -5
View File
@@ -3,11 +3,13 @@
//! This module provides a modular command system inspired by Codex-rs.
//! Commands are organized by category and dispatched through a central registry.
mod attachment;
mod config;
mod core;
mod debug;
mod init;
mod note;
mod provider;
mod queue;
mod review;
mod session;
@@ -132,6 +134,12 @@ pub const COMMANDS: &[CommandInfo] = &[
description: "List available models from API",
usage: "/models",
},
CommandInfo {
name: "provider",
aliases: &[],
description: "Switch or view the active LLM backend (deepseek | nvidia-nim)",
usage: "/provider [name]",
},
CommandInfo {
name: "queue",
aliases: &["queued"],
@@ -162,6 +170,12 @@ pub const COMMANDS: &[CommandInfo] = &[
description: "Append note to persistent notes file (.deepseek/notes.md)",
usage: "/note <text>",
},
CommandInfo {
name: "attach",
aliases: &["image", "media"],
description: "Attach image/video media; use @path for text files or directories",
usage: "/attach <path>",
},
CommandInfo {
name: "task",
aliases: &["tasks"],
@@ -227,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",
@@ -322,11 +336,13 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"exit" | "quit" | "q" => core::exit(),
"model" => core::model(app, arg),
"models" => core::models(app),
"provider" => provider::provider(app, arg),
"queue" | "queued" => queue::queue(app, arg),
"subagents" | "agents" => core::subagents(app),
"links" | "dashboard" | "api" => core::deepseek_links(),
"home" | "stats" | "overview" => core::home_dashboard(app),
"note" => note::note(app, arg),
"attach" | "image" | "media" => attachment::attach(app, arg),
"task" | "tasks" => task::task(app, arg),
// Session commands
@@ -342,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
@@ -505,10 +521,11 @@ mod tests {
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-reasoner".to_string(),
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"),
@@ -562,7 +579,7 @@ mod tests {
#[test]
fn removed_set_and_deepseek_commands_show_migration_hints() {
let mut app = create_test_app();
let set_result = execute("/set model deepseek-reasoner", &mut app);
let set_result = execute("/set model deepseek-v4-pro", &mut app);
let set_msg = set_result
.message
.expect("legacy command should return an error message");
+2 -1
View File
@@ -58,10 +58,11 @@ mod tests {
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
+191
View File
@@ -0,0 +1,191 @@
//! Provider switching: flip between DeepSeek and NVIDIA NIM at runtime.
use crate::config::{ApiProvider, normalize_model_name};
use crate::tui::app::{App, AppAction};
use super::CommandResult;
/// Switch or view the current LLM backend.
///
/// Accepts `<provider> [model]` so you can flip backend and model in one
/// shot, e.g. `/provider nim flash` lands you on
/// `deepseek-ai/deepseek-v4-flash`. The optional model accepts shorthand
/// (`flash`, `pro`, `v4-flash`, `v4-pro`) or any normal DeepSeek model ID.
pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
let trimmed = args.map(str::trim).filter(|s| !s.is_empty());
let Some(args) = trimmed else {
return CommandResult::message(format!(
"Current provider: {}\n\
Active model: {}\n\
Available: deepseek, nvidia-nim\n\
Usage: /provider <name> [model]\n\
Examples: /provider nim flash → NIM v4-flash (recommended)\n\
/provider nim pro → NIM v4-pro (currently DEGRADED)\n\
/provider deepseek → DeepSeek native, default model\n\
Tip: NIM needs NVIDIA_API_KEY (or [providers.nvidia_nim].api_key in config.toml).",
app.api_provider.as_str(),
app.model
));
};
let mut parts = args.split_whitespace();
let name = parts.next().unwrap_or("");
let model_arg = parts.next();
let Some(target) = ApiProvider::parse(name) else {
return CommandResult::error(format!(
"Unknown provider '{name}'. Expected: deepseek, nvidia-nim."
));
};
let model = match model_arg {
None => None,
Some(raw) => match normalize_model_name(&expand_model_alias(raw)) {
Some(normalized) => Some(normalized),
None => {
return CommandResult::error(format!(
"Invalid model '{raw}'. Try: flash, pro, deepseek-v4-flash, deepseek-v4-pro."
));
}
},
};
if target == app.api_provider && model.is_none() {
return CommandResult::message(format!("Already on provider: {}", target.as_str()));
}
CommandResult::action(AppAction::SwitchProvider {
provider: target,
model,
})
}
fn expand_model_alias(name: &str) -> String {
match name.trim().to_ascii_lowercase().as_str() {
"pro" | "v4-pro" => "deepseek-v4-pro".to_string(),
"flash" | "v4-flash" => "deepseek-v4-flash".to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::tui::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: false,
skip_onboarding: true,
yolo: false,
resume_session_id: None,
};
App::new(options, &Config::default())
}
#[test]
fn no_args_shows_current_provider_and_usage() {
let mut app = create_test_app();
let result = provider(&mut app, None);
let msg = result.message.expect("expected info message");
assert!(msg.contains("Current provider:"));
assert!(msg.contains("deepseek"));
assert!(msg.contains("Available:"));
assert!(msg.contains("nvidia-nim"));
assert!(msg.contains("/provider nim flash"));
assert!(result.action.is_none());
}
#[test]
fn unknown_provider_returns_error() {
let mut app = create_test_app();
let result = provider(&mut app, Some("openai"));
let msg = result.message.expect("expected error message");
assert!(msg.contains("Unknown provider"));
assert!(result.action.is_none());
}
#[test]
fn switching_to_active_provider_without_model_is_a_noop() {
let mut app = create_test_app();
let result = provider(&mut app, Some("deepseek"));
let msg = result.message.expect("expected message");
assert!(msg.contains("Already on provider"));
assert!(result.action.is_none());
}
#[test]
fn switch_to_nim_emits_action_without_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nvidia-nim"));
assert!(result.message.is_none());
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model, None);
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn nim_flash_shorthand_emits_action_with_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn nim_pro_shorthand_emits_action_with_model_override() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim pro"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::NvidiaNim);
assert_eq!(model.as_deref(), Some("deepseek-v4-pro"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn switch_to_active_provider_with_new_model_still_emits_action() {
let mut app = create_test_app();
let result = provider(&mut app, Some("deepseek flash"));
match result.action {
Some(AppAction::SwitchProvider { provider, model }) => {
assert_eq!(provider, ApiProvider::Deepseek);
assert_eq!(model.as_deref(), Some("deepseek-v4-flash"));
}
other => panic!("expected SwitchProvider action, got {other:?}"),
}
}
#[test]
fn invalid_model_returns_error() {
let mut app = create_test_app();
let result = provider(&mut app, Some("nim gpt-4"));
let msg = result.message.expect("expected error message");
assert!(msg.contains("Invalid model"));
assert!(result.action.is_none());
}
}
+2 -1
View File
@@ -137,10 +137,11 @@ mod tests {
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
+2 -1
View File
@@ -71,10 +71,11 @@ mod tests {
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
+2 -1
View File
@@ -195,10 +195,11 @@ mod tests {
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
+2 -1
View File
@@ -117,10 +117,11 @@ mod tests {
fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
let options = TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: tmpdir.path().to_path_buf(),
allow_shell: false,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: tmpdir.path().join("skills"),
memory_path: tmpdir.path().join("memory.md"),
+2 -1
View File
@@ -50,10 +50,11 @@ mod tests {
fn app() -> App {
App::new(
TuiOptions {
model: "deepseek-v3.2".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: PathBuf::from("."),
allow_shell: false,
use_alt_screen: false,
use_mouse_capture: false,
max_subagents: 2,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
+166 -18
View File
@@ -14,6 +14,7 @@ use crate::llm_client::LlmClient;
use crate::logging;
use crate::models::{
CacheControl, ContentBlock, Message, MessageRequest, SystemBlock, SystemPrompt,
context_window_for_model,
};
/// Configuration for conversation compaction behavior.
@@ -47,6 +48,50 @@ const SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 240;
const SUMMARY_INPUT_MAX_CHARS: usize = 24_000;
const SUMMARY_INPUT_HEAD_CHARS: usize = 14_000;
const SUMMARY_INPUT_TAIL_CHARS: usize = 6_000;
const LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS: usize = 2_000;
const LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000;
const LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS: usize = 120_000;
const LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS: usize = 72_000;
const LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS: usize = 36_000;
const LARGE_CONTEXT_SUMMARY_MAX_TOKENS: u32 = 2_048;
const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
#[derive(Debug, Clone, Copy)]
struct SummaryInputLimits {
text_snippet_chars: usize,
tool_result_snippet_chars: usize,
input_max_chars: usize,
input_head_chars: usize,
input_tail_chars: usize,
max_tokens: u32,
word_limit: usize,
}
fn summary_input_limits_for_model(model: &str) -> SummaryInputLimits {
let is_large_context =
context_window_for_model(model).is_some_and(|window| window >= LARGE_CONTEXT_WINDOW_TOKENS);
if is_large_context {
SummaryInputLimits {
text_snippet_chars: LARGE_CONTEXT_SUMMARY_TEXT_SNIPPET_CHARS,
tool_result_snippet_chars: LARGE_CONTEXT_SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
input_max_chars: LARGE_CONTEXT_SUMMARY_INPUT_MAX_CHARS,
input_head_chars: LARGE_CONTEXT_SUMMARY_INPUT_HEAD_CHARS,
input_tail_chars: LARGE_CONTEXT_SUMMARY_INPUT_TAIL_CHARS,
max_tokens: LARGE_CONTEXT_SUMMARY_MAX_TOKENS,
word_limit: 900,
}
} else {
SummaryInputLimits {
text_snippet_chars: SUMMARY_TEXT_SNIPPET_CHARS,
tool_result_snippet_chars: SUMMARY_TOOL_RESULT_SNIPPET_CHARS,
input_max_chars: SUMMARY_INPUT_MAX_CHARS,
input_head_chars: SUMMARY_INPUT_HEAD_CHARS,
input_tail_chars: SUMMARY_INPUT_TAIL_CHARS,
max_tokens: 1_024,
word_limit: 500,
}
}
}
#[derive(Debug, Clone, Default)]
struct CompactionPlan {
@@ -456,13 +501,16 @@ fn enforce_tool_call_pairs(messages: &[Message], pinned_indices: &mut BTreeSet<u
}
}
fn estimate_tokens_for_message(message: &Message) -> usize {
fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize {
message
.content
.iter()
.map(|c| match c {
ContentBlock::Text { text, .. } => text.len() / 4,
ContentBlock::Thinking { thinking } => thinking.len() / 4,
// Historical reasoning blocks are UI/session metadata for DeepSeek.
// Only current-turn tool-call reasoning is sent back to the API.
ContentBlock::Thinking { thinking } if include_thinking => thinking.len() / 4,
ContentBlock::Thinking { .. } => 0,
ContentBlock::ToolUse { input, .. } => serde_json::to_string(input)
.map(|s| s.len() / 4)
.unwrap_or(100),
@@ -475,8 +523,20 @@ fn estimate_tokens_for_message(message: &Message) -> usize {
}
pub fn estimate_tokens(messages: &[Message]) -> usize {
// Rough estimate: ~4 chars per token
messages.iter().map(estimate_tokens_for_message).sum()
// 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()
.map(|message| estimate_tokens_for_message(message, message_has_tool_use(message)))
.sum()
}
fn message_has_tool_use(message: &Message) -> bool {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
}
fn estimate_text_tokens_conservative(text: &str) -> usize {
@@ -529,14 +589,14 @@ pub fn should_compact(
let pinned_tokens: usize = plan
.pinned_indices
.iter()
.map(|&idx| estimate_tokens_for_message(&messages[idx]))
.map(|&idx| estimate_tokens_for_message(&messages[idx], false))
.sum();
let pinned_count = plan.pinned_indices.len();
let token_estimate: usize = plan
.summarize_indices
.iter()
.map(|&idx| estimate_tokens_for_message(&messages[idx]))
.map(|&idx| estimate_tokens_for_message(&messages[idx], false))
.sum();
let message_count = plan.summarize_indices.len();
@@ -755,6 +815,7 @@ async fn create_summary(
messages: &[Message],
model: &str,
) -> Result<String> {
let limits = summary_input_limits_for_model(model);
// Format messages for summarization
let mut conversation_text = String::new();
for msg in messages {
@@ -766,14 +827,14 @@ async fn create_summary(
for block in &msg.content {
match block {
ContentBlock::Text { text, .. } => {
let snippet = truncate_chars(text, SUMMARY_TEXT_SNIPPET_CHARS);
let snippet = truncate_chars(text, limits.text_snippet_chars);
let _ = write!(conversation_text, "{role}: {snippet}\n\n");
}
ContentBlock::ToolUse { name, .. } => {
let _ = write!(conversation_text, "{role}: [Used tool: {name}]\n\n");
}
ContentBlock::ToolResult { content, .. } => {
let snippet = truncate_chars(content, SUMMARY_TOOL_RESULT_SNIPPET_CHARS);
let snippet = truncate_chars(content, limits.tool_result_snippet_chars);
let _ = write!(conversation_text, "Tool result: {}\n\n", snippet);
}
ContentBlock::Thinking { .. } => {
@@ -787,9 +848,9 @@ async fn create_summary(
}
let conversation_chars = conversation_text.chars().count();
if conversation_chars > SUMMARY_INPUT_MAX_CHARS {
let head = truncate_chars(&conversation_text, SUMMARY_INPUT_HEAD_CHARS).to_string();
let tail = tail_chars(&conversation_text, SUMMARY_INPUT_TAIL_CHARS);
if conversation_chars > limits.input_max_chars {
let head = truncate_chars(&conversation_text, limits.input_head_chars).to_string();
let tail = tail_chars(&conversation_text, limits.input_tail_chars);
let omitted = conversation_chars
.saturating_sub(head.chars().count())
.saturating_sub(tail.chars().count());
@@ -804,14 +865,16 @@ async fn create_summary(
content: vec![ContentBlock::Text {
text: format!(
"Summarize the following conversation in a concise but comprehensive way. \
Preserve key information, decisions made, and any important context. \
Tool outputs may be abbreviated. \
Keep it under 500 words.\n\n---\n\n{conversation_text}"
Preserve key information, decisions made, exact file paths, commands, \
errors, and tool-result facts needed to continue the work. \
Tool outputs may be abbreviated only when they are repetitive. \
Keep it under {} words.\n\n---\n\n{conversation_text}",
limits.word_limit
),
cache_control: None,
}],
}],
max_tokens: 1024,
max_tokens: limits.max_tokens,
system: Some(SystemPrompt::Text(
"You are a helpful assistant that creates concise conversation summaries.".to_string(),
)),
@@ -819,6 +882,7 @@ async fn create_summary(
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(false),
temperature: Some(0.3),
top_p: None,
@@ -859,15 +923,14 @@ fn extract_workflow_context(messages: &[Message], workspace: Option<&Path>) -> S
files_touched.push(path);
}
}
ContentBlock::Text { text, .. } => {
ContentBlock::Text { text, .. }
// Look for task/todo mentions
if text.contains("TODO") || text.contains("task") || text.contains("need to") {
if (text.contains("TODO") || text.contains("task") || text.contains("need to")) => {
let task = truncate_chars(text, 200).to_string();
if !tasks_identified.contains(&task) {
tasks_identified.push(task);
}
}
}
_ => {}
}
}
@@ -1033,6 +1096,16 @@ mod tests {
assert!(!is_transient_error(&validation_err));
}
#[test]
fn summary_limits_expand_for_v4_context() {
let legacy = summary_input_limits_for_model("deepseek-v3.2-128k");
let v4 = summary_input_limits_for_model("deepseek-v4-pro");
assert!(v4.input_max_chars > legacy.input_max_chars);
assert!(v4.tool_result_snippet_chars > legacy.tool_result_snippet_chars);
assert!(v4.max_tokens > legacy.max_tokens);
}
#[test]
fn estimate_tokens_empty_messages() {
let messages: Vec<Message> = vec![];
@@ -1052,6 +1125,81 @@ mod tests {
assert!(tokens > 0 && tokens < 10);
}
#[test]
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 {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Use a tool".to_string(),
cache_control: None,
}],
},
Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Thinking {
thinking: thinking.clone(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({"path": "Cargo.toml"}),
caller: None,
},
],
},
Message {
role: "user".to_string(),
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "manifest".to_string(),
is_error: None,
content_blocks: None,
}],
},
];
let historical_messages = {
let mut messages = current_messages.clone();
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done.".to_string(),
cache_control: None,
}],
});
messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "Next question.".to_string(),
cache_control: None,
}],
});
messages
};
let completed_messages = {
let mut messages = current_messages.clone();
messages.push(Message {
role: "assistant".to_string(),
content: vec![ContentBlock::Text {
text: "Done.".to_string(),
cache_control: None,
}],
});
messages
};
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]
fn should_compact_respects_enabled_flag() {
let config = CompactionConfig {
+587 -29
View File
@@ -15,16 +15,55 @@ use crate::hooks::HooksConfig;
pub const DEFAULT_MAX_SUBAGENTS: usize = 5;
pub const MAX_SUBAGENTS: usize = 20;
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-reasoner";
pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro";
pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
pub const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
pub const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
const API_KEYRING_SENTINEL: &str = "__KEYRING__";
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &["deepseek-chat", "deepseek-reasoner"];
pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
"deepseek-ai/deepseek-v4-flash",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiProvider {
Deepseek,
NvidiaNim,
}
impl ApiProvider {
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"deepseek" | "deep-seek" => Some(Self::Deepseek),
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => Some(Self::NvidiaNim),
_ => None,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Deepseek => "deepseek",
Self::NvidiaNim => "nvidia-nim",
}
}
}
/// Canonicalize common model aliases to stable DeepSeek IDs.
///
/// Legacy `deepseek-chat` / `deepseek-reasoner` remain silent aliases for the
/// current fast V4 model.
#[must_use]
pub fn canonical_model_name(model: &str) -> Option<&'static str> {
match model.trim().to_ascii_lowercase().as_str() {
"deepseek-chat" | "deepseek-v3" | "deepseek-v3.2" => Some("deepseek-chat"),
"deepseek-reasoner" | "deepseek-r1" => Some("deepseek-reasoner"),
"deepseek-v4-pro" | "deepseek-v4pro" => Some("deepseek-v4-pro"),
"deepseek-v4-flash" | "deepseek-v4flash" => Some("deepseek-v4-flash"),
"deepseek-chat" | "deepseek-reasoner" | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2" => {
Some("deepseek-v4-flash")
}
_ => None,
}
}
@@ -49,7 +88,7 @@ pub fn normalize_model_name(model: &str) -> Option<String> {
}
if normalized.chars().all(|ch| {
ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_' | '.' | ':')
ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_' | '.' | ':' | '/')
}) {
return Some(normalized);
}
@@ -73,6 +112,7 @@ pub struct RetryConfig {
#[derive(Debug, Clone, Deserialize, Default)]
pub struct TuiConfig {
pub alternate_screen: Option<String>,
pub mouse_capture: Option<bool>,
}
/// Resolved retry policy with defaults applied.
@@ -120,9 +160,13 @@ impl RetryPolicy {
/// Resolved CLI configuration, including defaults and environment overrides.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
pub provider: Option<String>,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub default_text_model: Option<String>,
/// DeepSeek reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// Defaults to `"max"` at runtime if unset.
pub reasoning_effort: Option<String>,
pub tools_file: Option<String>,
pub skills_dir: Option<String>,
pub mcp_config_path: Option<String>,
@@ -144,6 +188,25 @@ pub struct Config {
/// Lifecycle hooks configuration
#[serde(default)]
pub hooks: Option<HooksConfig>,
/// Provider-specific credentials and defaults shared with the `deepseek` facade.
#[serde(default)]
pub providers: Option<ProvidersConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProviderConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProvidersConfig {
#[serde(default)]
pub deepseek: ProviderConfig,
#[serde(default)]
pub nvidia_nim: ProviderConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
@@ -199,6 +262,11 @@ impl Config {
/// Validate that critical config fields are present.
pub fn validate(&self) -> Result<()> {
if let Some(provider) = self.provider.as_deref()
&& ApiProvider::parse(provider).is_none()
{
anyhow::bail!("Invalid provider '{provider}': expected deepseek or nvidia-nim.");
}
if let Some(ref key) = self.api_key
&& key.trim().is_empty()
{
@@ -215,7 +283,7 @@ impl Config {
&& normalize_model_name(model).is_none()
{
anyhow::bail!(
"Invalid default_text_model '{model}': expected a DeepSeek model ID (for example: deepseek-chat, deepseek-reasoner, deepseek-v4)."
"Invalid default_text_model '{model}': expected a DeepSeek model ID (for example: deepseek-v4-pro, deepseek-v4-flash, deepseek-ai/deepseek-v4-pro)."
);
}
if let Some(policy) = self.approval_policy.as_deref() {
@@ -283,26 +351,112 @@ impl Config {
Ok(())
}
/// Return the `DeepSeek` base URL (normalized).
#[must_use]
pub fn api_provider(&self) -> ApiProvider {
self.provider
.as_deref()
.and_then(ApiProvider::parse)
.unwrap_or_else(|| {
self.base_url
.as_deref()
.filter(|base| base.contains("integrate.api.nvidia.com"))
.map(|_| ApiProvider::NvidiaNim)
.unwrap_or(ApiProvider::Deepseek)
})
}
fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
let providers = self.providers.as_ref()?;
Some(match provider {
ApiProvider::Deepseek => &providers.deepseek,
ApiProvider::NvidiaNim => &providers.nvidia_nim,
})
}
fn provider_config(&self) -> Option<&ProviderConfig> {
self.provider_config_for(self.api_provider())
}
#[must_use]
pub fn default_model(&self) -> String {
let provider = self.api_provider();
if let Some(model) = self
.provider_config()
.and_then(|provider| provider.model.as_deref())
&& let Some(normalized) = normalize_model_for_provider(provider, model)
{
return normalized;
}
if let Some(model) = self.default_text_model.as_deref()
&& let Some(normalized) = normalize_model_name(model)
{
return model_for_provider(provider, normalized);
}
match provider {
ApiProvider::Deepseek => DEFAULT_TEXT_MODEL,
ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
}
.to_string()
}
/// Return the configured API base URL (normalized).
#[must_use]
pub fn deepseek_base_url(&self) -> String {
let base = self
.base_url
.clone()
.unwrap_or_else(|| "https://api.deepseek.com".to_string());
let provider = self.api_provider();
let provider_base = self
.provider_config_for(provider)
.and_then(|provider| provider.base_url.clone());
let root_base = match provider {
ApiProvider::Deepseek => self.base_url.clone(),
ApiProvider::NvidiaNim => self
.base_url
.as_ref()
.filter(|base| base.contains("integrate.api.nvidia.com"))
.cloned(),
};
let base = provider_base.or(root_base).unwrap_or_else(|| {
match provider {
ApiProvider::Deepseek => "https://api.deepseek.com",
ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
}
.to_string()
});
normalize_base_url(&base)
}
/// Read the `DeepSeek` API key from config/environment.
/// Read the API key from config/environment.
pub fn deepseek_api_key(&self) -> Result<String> {
// First check environment variable (highest priority)
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
let provider = self.api_provider();
match provider {
ApiProvider::Deepseek => {
if let Ok(key) = std::env::var("DEEPSEEK_API_KEY")
&& !key.trim().is_empty()
{
return Ok(key);
}
}
ApiProvider::NvidiaNim => {
for name in ["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] {
if let Ok(key) = std::env::var(name)
&& !key.trim().is_empty()
{
return Ok(key);
}
}
}
}
// Then check config file
if let Some(configured) = self
.provider_config_for(provider)
.and_then(|provider| provider.api_key.clone())
&& !configured.trim().is_empty()
{
return Ok(configured);
}
if let Some(configured) = self.api_key.clone()
&& !configured.trim().is_empty()
&& configured != API_KEYRING_SENTINEL
@@ -310,13 +464,18 @@ impl Config {
return Ok(configured);
}
// Provide helpful error message with alternatives
anyhow::bail!(
"DeepSeek API key not found. Set it using one of these methods:\n\
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
)
match provider {
ApiProvider::Deepseek => anyhow::bail!(
"DeepSeek API key not found. Set it using one of these methods:\n\
1. Set DEEPSEEK_API_KEY environment variable (recommended)\n\
2. Run 'deepseek login' to save to ~/.deepseek/config.toml\n\
3. Add 'api_key = \"your-key\"' to ~/.deepseek/config.toml"
),
ApiProvider::NvidiaNim => anyhow::bail!(
"NVIDIA NIM API key not found. Set NVIDIA_API_KEY, NVIDIA_NIM_API_KEY, \
or save api_key in ~/.deepseek/config.toml with provider = \"nvidia-nim\"."
),
}
}
/// Resolve the skills directory path.
@@ -373,6 +532,12 @@ impl Config {
.clamp(1, MAX_SUBAGENTS)
}
/// Return the configured DeepSeek reasoning-effort tier, if any.
#[must_use]
pub fn reasoning_effort(&self) -> Option<&str> {
self.reasoning_effort.as_deref()
}
/// Get hooks configuration, returning default if not configured.
pub fn hooks_config(&self) -> HooksConfig {
self.hooks.clone().unwrap_or_default()
@@ -560,11 +725,45 @@ fn default_memory_path() -> Option<PathBuf> {
// === Environment Overrides ===
fn apply_env_overrides(config: &mut Config) {
if let Ok(value) = std::env::var("DEEPSEEK_API_KEY") {
if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") {
config.provider = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_API_KEY")
&& !value.trim().is_empty()
{
config.api_key = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") {
config.base_url = Some(value);
if matches!(config.api_provider(), ApiProvider::NvidiaNim) {
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.nvidia_nim
.base_url = Some(value);
} else {
config.base_url = Some(value);
}
}
if matches!(config.api_provider(), ApiProvider::NvidiaNim)
&& let Ok(value) = std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
{
config
.providers
.get_or_insert_with(ProvidersConfig::default)
.nvidia_nim
.base_url = Some(value);
}
if let Ok(value) =
std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
{
config.default_text_model = Some(value);
}
if matches!(config.api_provider(), ApiProvider::NvidiaNim)
&& let Ok(value) = std::env::var("NVIDIA_NIM_MODEL")
{
config.default_text_model = Some(value);
}
if let Ok(value) = std::env::var("DEEPSEEK_SKILLS_DIR") {
config.skills_dir = Some(value);
@@ -701,10 +900,35 @@ fn apply_env_overrides(config: &mut Config) {
fn normalize_model_config(config: &mut Config) {
if let Some(model) = config.default_text_model.as_deref()
&& let Some(normalized) = normalize_model_name(model)
&& let Some(normalized) = normalize_model_for_provider(config.api_provider(), model)
{
config.default_text_model = Some(normalized);
}
if let Some(providers) = config.providers.as_mut() {
if let Some(model) = providers.deepseek.model.as_deref()
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model)
{
providers.deepseek.model = Some(normalized);
}
if let Some(model) = providers.nvidia_nim.model.as_deref()
&& let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model)
{
providers.nvidia_nim.model = Some(normalized);
}
}
}
fn normalize_model_for_provider(provider: ApiProvider, model: &str) -> Option<String> {
normalize_model_name(model).map(|normalized| model_for_provider(provider, normalized))
}
fn model_for_provider(provider: ApiProvider, normalized: String) -> String {
match (provider, normalized.as_str()) {
(ApiProvider::NvidiaNim, "deepseek-v4-pro") => DEFAULT_NVIDIA_NIM_MODEL.to_string(),
(ApiProvider::NvidiaNim, "deepseek-v4-flash") => DEFAULT_NVIDIA_NIM_FLASH_MODEL.to_string(),
_ => normalized,
}
}
fn normalize_base_url(base: &str) -> String {
@@ -750,9 +974,11 @@ fn apply_profile(config: ConfigFile, profile: Option<&str>) -> Result<Config> {
fn merge_config(base: Config, override_cfg: Config) -> Config {
Config {
provider: override_cfg.provider.or(base.provider),
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
default_text_model: override_cfg.default_text_model.or(base.default_text_model),
reasoning_effort: override_cfg.reasoning_effort.or(base.reasoning_effort),
tools_file: override_cfg.tools_file.or(base.tools_file),
skills_dir: override_cfg.skills_dir.or(base.skills_dir),
mcp_config_path: override_cfg.mcp_config_path.or(base.mcp_config_path),
@@ -770,10 +996,34 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
capacity: override_cfg.capacity.or(base.capacity),
tui: override_cfg.tui.or(base.tui),
hooks: override_cfg.hooks.or(base.hooks),
providers: merge_providers(base.providers, override_cfg.providers),
features: merge_features(base.features, override_cfg.features),
}
}
fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) -> ProviderConfig {
ProviderConfig {
api_key: override_cfg.api_key.or(base.api_key),
base_url: override_cfg.base_url.or(base.base_url),
model: override_cfg.model.or(base.model),
}
}
fn merge_providers(
base: Option<ProvidersConfig>,
override_cfg: Option<ProvidersConfig>,
) -> Option<ProvidersConfig> {
match (base, override_cfg) {
(None, None) => None,
(Some(base), None) => Some(base),
(None, Some(override_cfg)) => Some(override_cfg),
(Some(base), Some(override_cfg)) => Some(ProvidersConfig {
deepseek: merge_provider_config(base.deepseek, override_cfg.deepseek),
nvidia_nim: merge_provider_config(base.nvidia_nim, override_cfg.nvidia_nim),
}),
}
}
fn load_single_config_file(path: &Path) -> Result<Config> {
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
@@ -926,6 +1176,11 @@ api_key = "{key_to_write}"
# Default model
default_text_model = "{default_model}"
# Thinking mode (DeepSeek V4 reasoning effort):
# "off" | "low" | "medium" | "high" | "max"
# Shift+Tab in the TUI cycles between off / high / max.
reasoning_effort = "max"
"#,
default_model = DEFAULT_TEXT_MODEL
)
@@ -1006,7 +1261,17 @@ mod tests {
home: Option<OsString>,
userprofile: Option<OsString>,
deepseek_config_path: Option<OsString>,
deepseek_provider: Option<OsString>,
deepseek_api_key: Option<OsString>,
deepseek_base_url: Option<OsString>,
deepseek_model: Option<OsString>,
deepseek_default_text_model: Option<OsString>,
nvidia_api_key: Option<OsString>,
nvidia_nim_api_key: Option<OsString>,
nim_base_url: Option<OsString>,
nvidia_base_url: Option<OsString>,
nvidia_nim_base_url: Option<OsString>,
nvidia_nim_model: Option<OsString>,
}
impl EnvGuard {
@@ -1017,19 +1282,49 @@ mod tests {
let home_prev = env::var_os("HOME");
let userprofile_prev = env::var_os("USERPROFILE");
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
let deepseek_provider_prev = env::var_os("DEEPSEEK_PROVIDER");
let api_key_prev = env::var_os("DEEPSEEK_API_KEY");
let base_url_prev = env::var_os("DEEPSEEK_BASE_URL");
let model_prev = env::var_os("DEEPSEEK_MODEL");
let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL");
let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY");
let nvidia_nim_api_key_prev = env::var_os("NVIDIA_NIM_API_KEY");
let nim_base_url_prev = env::var_os("NIM_BASE_URL");
let nvidia_base_url_prev = env::var_os("NVIDIA_BASE_URL");
let nvidia_nim_base_url_prev = env::var_os("NVIDIA_NIM_BASE_URL");
let nvidia_nim_model_prev = env::var_os("NVIDIA_NIM_MODEL");
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("HOME", &home_str);
env::set_var("USERPROFILE", &home_str);
env::set_var("DEEPSEEK_CONFIG_PATH", &config_str);
env::remove_var("DEEPSEEK_PROVIDER");
env::remove_var("DEEPSEEK_API_KEY");
env::remove_var("DEEPSEEK_BASE_URL");
env::remove_var("DEEPSEEK_MODEL");
env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
env::remove_var("NVIDIA_API_KEY");
env::remove_var("NVIDIA_NIM_API_KEY");
env::remove_var("NIM_BASE_URL");
env::remove_var("NVIDIA_BASE_URL");
env::remove_var("NVIDIA_NIM_BASE_URL");
env::remove_var("NVIDIA_NIM_MODEL");
}
Self {
home: home_prev,
userprofile: userprofile_prev,
deepseek_config_path: deepseek_config_prev,
deepseek_provider: deepseek_provider_prev,
deepseek_api_key: api_key_prev,
deepseek_base_url: base_url_prev,
deepseek_model: model_prev,
deepseek_default_text_model: default_text_model_prev,
nvidia_api_key: nvidia_api_key_prev,
nvidia_nim_api_key: nvidia_nim_api_key_prev,
nim_base_url: nim_base_url_prev,
nvidia_base_url: nvidia_base_url_prev,
nvidia_nim_base_url: nvidia_nim_base_url_prev,
nvidia_nim_model: nvidia_nim_model_prev,
}
}
}
@@ -1041,7 +1336,20 @@ mod tests {
Self::restore_var("HOME", self.home.take());
Self::restore_var("USERPROFILE", self.userprofile.take());
Self::restore_var("DEEPSEEK_CONFIG_PATH", self.deepseek_config_path.take());
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
Self::restore_var("DEEPSEEK_API_KEY", self.deepseek_api_key.take());
Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take());
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
Self::restore_var(
"DEEPSEEK_DEFAULT_TEXT_MODEL",
self.deepseek_default_text_model.take(),
);
Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
Self::restore_var("NVIDIA_NIM_MODEL", self.nvidia_nim_model.take());
}
}
}
@@ -1248,20 +1556,61 @@ mod tests {
Ok(())
}
#[test]
fn apply_env_overrides_ignores_empty_api_key() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-empty-key-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
// Simulate a fresh user who copied .env.example to .env without
// filling in DEEPSEEK_API_KEY: dotenv loads it as the empty string.
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_API_KEY", "");
}
let mut config = Config {
api_key: Some("from-config-file".to_string()),
..Default::default()
};
apply_env_overrides(&mut config);
assert_eq!(config.api_key.as_deref(), Some("from-config-file"));
config.validate()?;
Ok(())
}
#[test]
fn normalize_model_name_handles_aliases_and_future_ids() {
assert_eq!(
normalize_model_name("deepseek-v3.2").as_deref(),
Some("deepseek-chat")
Some("deepseek-v4-flash")
);
assert_eq!(
normalize_model_name("deepseek-r1").as_deref(),
Some("deepseek-reasoner")
Some("deepseek-v4-flash")
);
assert_eq!(
normalize_model_name("DeepSeek-V4").as_deref(),
Some("deepseek-v4")
);
assert_eq!(
normalize_model_name("deepseek-ai/deepseek-v4-pro").as_deref(),
Some("deepseek-ai/deepseek-v4-pro")
);
assert_eq!(
normalize_model_name("deepseek-ai/deepseek-v4-flash").as_deref(),
Some("deepseek-ai/deepseek-v4-flash")
);
}
#[test]
@@ -1280,4 +1629,213 @@ mod tests {
config.validate()?;
Ok(())
}
#[test]
fn deepseek_model_env_overrides_default_text_model() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-model-env-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_MODEL", "deepseek-chat");
}
let config = Config::load(None, None)?;
assert_eq!(
config.default_text_model.as_deref(),
Some("deepseek-v4-flash")
);
Ok(())
}
#[test]
fn nvidia_nim_provider_uses_nim_defaults() -> Result<()> {
let config = Config {
provider: Some("nvidia-nim".to_string()),
..Default::default()
};
config.validate()?;
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
assert_eq!(config.deepseek_base_url(), DEFAULT_NVIDIA_NIM_BASE_URL);
Ok(())
}
#[test]
fn nvidia_nim_provider_normalizes_deepseek_v4_pro_alias() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-nim-model-alias-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
ensure_parent_dir(&config_path)?;
fs::write(
&config_path,
"provider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-v4-pro\"\napi_key = \"nim-key\"\n",
)?;
let config = Config::load(None, None)?;
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
assert_eq!(
config.default_text_model.as_deref(),
Some(DEFAULT_NVIDIA_NIM_MODEL)
);
Ok(())
}
#[test]
fn nvidia_nim_provider_normalizes_deepseek_v4_flash_alias() -> Result<()> {
let config = Config {
provider: Some("nvidia-nim".to_string()),
default_text_model: Some("deepseek-v4-flash".to_string()),
..Default::default()
};
config.validate()?;
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_FLASH_MODEL);
Ok(())
}
#[test]
fn nvidia_nim_env_overrides_provider_and_credentials() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-nim-env-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
env::set_var("NVIDIA_API_KEY", "nim-env-key");
env::set_var("NVIDIA_NIM_MODEL", "deepseek-ai/deepseek-v4-pro");
}
let config = Config::load(None, None)?;
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
assert_eq!(config.deepseek_api_key()?, "nim-env-key");
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
Ok(())
}
#[test]
fn nvidia_nim_env_accepts_short_nim_base_url_alias() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-nim-base-url-alias-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
env::set_var("NIM_BASE_URL", "https://short-nim.example/v1");
}
let config = Config::load(None, None)?;
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
assert_eq!(config.deepseek_base_url(), "https://short-nim.example/v1");
Ok(())
}
#[test]
fn nvidia_nim_env_accepts_facade_base_url_forwarding() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-nim-forwarded-base-url-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "nvidia-nim");
env::set_var("DEEPSEEK_BASE_URL", "https://forwarded-nim.example/v1");
}
let config = Config::load(None, None)?;
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
assert_eq!(
config.deepseek_base_url(),
"https://forwarded-nim.example/v1"
);
Ok(())
}
#[test]
fn nvidia_nim_reads_facade_provider_table() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"deepseek-tui-nim-provider-table-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
ensure_parent_dir(&config_path)?;
fs::write(
&config_path,
r#"provider = "nvidia-nim"
default_text_model = "deepseek-v4-flash"
[providers.nvidia_nim]
api_key = "nim-table-key"
base_url = "https://nim-table.example/v1"
model = "deepseek-v4-pro"
"#,
)?;
let config = Config::load(None, None)?;
assert_eq!(config.api_provider(), ApiProvider::NvidiaNim);
assert_eq!(config.deepseek_api_key()?, "nim-table-key");
assert_eq!(config.deepseek_base_url(), "https://nim-table.example/v1");
assert_eq!(config.default_model(), DEFAULT_NVIDIA_NIM_MODEL);
Ok(())
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Plain-language session coherence state derived from capacity events.
use serde::{Deserialize, Serialize};
use crate::core::capacity::{GuardrailAction, RiskBand};
/// User-facing coherence ladder for session health.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoherenceState {
#[default]
Healthy,
GettingCrowded,
RefreshingContext,
VerifyingRecentWork,
ResettingPlan,
}
impl CoherenceState {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::GettingCrowded => "getting crowded",
Self::RefreshingContext => "refreshing context",
Self::VerifyingRecentWork => "verifying recent work",
Self::ResettingPlan => "resetting plan",
}
}
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::Healthy => "The session is stable and focused.",
Self::GettingCrowded => "The session is approaching context pressure.",
Self::RefreshingContext => "The engine is refreshing context before continuing.",
Self::VerifyingRecentWork => {
"The engine is checking recent tool results before continuing."
}
Self::ResettingPlan => {
"The engine is rebuilding from canonical context and replanning."
}
}
}
}
/// Synthetic input to the coherence reducer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoherenceSignal {
CapacityDecision {
risk_band: RiskBand,
action: GuardrailAction,
cooldown_blocked: bool,
},
CapacityIntervention {
action: GuardrailAction,
},
CompactionStarted,
CompactionCompleted,
CompactionFailed,
}
/// Pure transition function for the plain-language coherence ladder.
#[must_use]
pub fn next_coherence_state(current: CoherenceState, signal: CoherenceSignal) -> CoherenceState {
match signal {
CoherenceSignal::CompactionStarted => CoherenceState::RefreshingContext,
CoherenceSignal::CompactionCompleted => CoherenceState::Healthy,
CoherenceSignal::CompactionFailed => CoherenceState::GettingCrowded,
CoherenceSignal::CapacityIntervention { action }
| CoherenceSignal::CapacityDecision { action, .. } => match action {
GuardrailAction::NoIntervention => match signal {
CoherenceSignal::CapacityDecision {
risk_band,
cooldown_blocked,
..
} => {
if cooldown_blocked {
return current;
}
match risk_band {
RiskBand::Low => CoherenceState::Healthy,
RiskBand::Medium | RiskBand::High => CoherenceState::GettingCrowded,
}
}
_ => current,
},
GuardrailAction::TargetedContextRefresh => CoherenceState::RefreshingContext,
GuardrailAction::VerifyWithToolReplay => CoherenceState::VerifyingRecentWork,
GuardrailAction::VerifyAndReplan => CoherenceState::ResettingPlan,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthetic_capacity_event_log_drives_plain_language_ladder() {
let log = [
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Low,
action: GuardrailAction::NoIntervention,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Medium,
action: GuardrailAction::NoIntervention,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Medium,
action: GuardrailAction::TargetedContextRefresh,
cooldown_blocked: false,
},
CoherenceSignal::CompactionCompleted,
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::High,
action: GuardrailAction::VerifyWithToolReplay,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::High,
action: GuardrailAction::VerifyAndReplan,
cooldown_blocked: false,
},
];
let mut state = CoherenceState::Healthy;
let mut states = Vec::new();
for signal in log {
state = next_coherence_state(state, signal);
states.push(state);
}
assert_eq!(
states,
vec![
CoherenceState::Healthy,
CoherenceState::GettingCrowded,
CoherenceState::RefreshingContext,
CoherenceState::Healthy,
CoherenceState::VerifyingRecentWork,
CoherenceState::ResettingPlan,
]
);
}
}
File diff suppressed because it is too large. Load diff
+379 -1
View File
@@ -73,6 +73,86 @@ fn parallel_batch_requires_read_only_parallel_tools() {
assert!(!should_parallelize_tool_batch(&plans));
}
#[test]
fn successful_update_plan_ends_plan_mode_turn_immediately() {
assert!(should_stop_after_plan_tool(
AppMode::Plan,
"update_plan",
&Ok(ToolResult::success("planned"))
));
assert!(!should_stop_after_plan_tool(
AppMode::Agent,
"update_plan",
&Ok(ToolResult::success("planned"))
));
assert!(!should_stop_after_plan_tool(
AppMode::Plan,
"request_user_input",
&Ok(ToolResult::success("input"))
));
assert!(!should_stop_after_plan_tool(
AppMode::Plan,
"update_plan",
&Err(ToolError::execution_failed("failed".to_string()))
));
}
#[test]
fn quick_plan_requests_force_update_plan_on_first_step() {
assert!(should_force_update_plan_first(
AppMode::Plan,
"Give me a quick 3-step plan to verify the UI changes."
));
assert!(should_force_update_plan_first(
AppMode::Plan,
"Make a high-level plan for the footer work."
));
assert!(!should_force_update_plan_first(
AppMode::Plan,
"Inspect the repo and then give me a quick plan."
));
assert!(!should_force_update_plan_first(
AppMode::Agent,
"Give me a quick 3-step plan."
));
}
#[test]
fn quick_plan_turn_can_narrow_first_step_tools_to_update_plan() {
let catalog = vec![
Tool {
tool_type: Some("function".to_string()),
name: "read_file".to_string(),
description: "Read a file".to_string(),
input_schema: json!({"type": "object"}),
allowed_callers: Some(vec!["direct".to_string()]),
defer_loading: Some(false),
input_examples: None,
strict: None,
cache_control: None,
},
Tool {
tool_type: Some("function".to_string()),
name: "update_plan".to_string(),
description: "Publish a plan".to_string(),
input_schema: json!({"type": "object"}),
allowed_callers: Some(vec!["direct".to_string()]),
defer_loading: Some(false),
input_examples: None,
strict: None,
cache_control: None,
},
];
let active = initial_active_tools(&catalog);
let forced = active_tools_for_step(&catalog, &active, true);
assert_eq!(forced.len(), 1);
assert_eq!(forced[0].name, "update_plan");
let default = active_tools_for_step(&catalog, &active, false);
assert_eq!(default.len(), 2);
}
#[test]
fn tool_error_messages_include_actionable_hints() {
let path_error = ToolError::path_escape(PathBuf::from("../escape.txt"));
@@ -130,6 +210,37 @@ fn agent_mode_can_build_auto_approved_tool_context() {
assert!(engine.build_tool_context(AppMode::Yolo, false).auto_approve);
}
#[tokio::test]
async fn session_update_preserves_reasoning_tool_only_turn() {
let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default());
let assistant = Message {
role: "assistant".to_string(),
content: vec![
ContentBlock::Thinking {
thinking: "Need a tool before answering.".to_string(),
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "Cargo.toml"}),
caller: None,
},
],
};
engine.add_session_message(assistant.clone()).await;
let event = {
let mut rx = handle.rx_event.write().await;
rx.recv().await.expect("session update event")
};
let Event::SessionUpdated { messages, .. } = event else {
panic!("expected session update event");
};
assert_eq!(messages, vec![assistant]);
}
#[test]
fn detects_context_length_errors_from_provider_payloads() {
let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#;
@@ -141,12 +252,137 @@ fn detects_context_length_errors_from_provider_payloads() {
#[test]
fn context_budget_reserves_output_and_headroom() {
let budget = context_input_budget("deepseek-reasoner", TURN_MAX_OUTPUT_TOKENS)
let budget = context_input_budget("deepseek-v3.2-128k", TURN_MAX_OUTPUT_TOKENS)
.expect("deepseek models should have known context window");
let expected = 128_000usize - 4_096usize - 1_024usize;
assert_eq!(budget, expected);
}
#[test]
fn v4_tool_outputs_keep_large_file_reads_in_context() {
let content = "0123456789abcdef\n".repeat(2_000);
let output = ToolResult::success(content.clone());
let v4_context = compact_tool_result_for_context("deepseek-v4-pro", "exec_shell", &output);
assert_eq!(v4_context, content.trim());
let legacy_context =
compact_tool_result_for_context("deepseek-v3.2-128k", "exec_shell", &output);
assert!(legacy_context.contains("output compacted to protect context"));
assert!(legacy_context.len() < v4_context.len());
}
#[test]
fn refresh_system_prompt_places_working_set_after_stable_prefix() {
let tmp = tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
fs::write(tmp.path().join("src/lib.rs"), "pub fn sample() {}").expect("write");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
..Default::default()
};
let (mut engine, _handle) = Engine::new(config, &Config::default());
engine
.session
.working_set
.observe_user_message("please inspect src/lib.rs", tmp.path());
engine.refresh_system_prompt(AppMode::Agent);
let Some(SystemPrompt::Blocks(blocks)) = &engine.session.system_prompt else {
panic!("expected structured prompt blocks");
};
let last = blocks.last().expect("working-set block");
assert!(last.text.contains(WORKING_SET_SUMMARY_MARKER));
assert!(
blocks[..blocks.len() - 1]
.iter()
.all(|block| !block.text.contains(WORKING_SET_SUMMARY_MARKER))
);
}
#[test]
fn compaction_summary_stays_before_volatile_working_set() {
let tmp = tempdir().expect("tempdir");
fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
..Default::default()
};
let (mut engine, _handle) = Engine::new(config, &Config::default());
engine
.session
.working_set
.observe_user_message("continue in src/main.rs", tmp.path());
engine.refresh_system_prompt(AppMode::Agent);
engine.merge_compaction_summary(Some(SystemPrompt::Blocks(vec![SystemBlock {
block_type: "text".to_string(),
text: format!("{COMPACTION_SUMMARY_MARKER}\nsummary"),
cache_control: None,
}])));
let Some(SystemPrompt::Blocks(blocks)) = &engine.session.system_prompt else {
panic!("expected structured prompt blocks");
};
let summary_index = blocks
.iter()
.position(|block| block.text.contains(COMPACTION_SUMMARY_MARKER))
.expect("summary block");
let working_set_index = blocks
.iter()
.position(|block| block.text.contains(WORKING_SET_SUMMARY_MARKER))
.expect("working-set block");
assert!(summary_index < working_set_index);
assert_eq!(working_set_index, blocks.len() - 1);
}
#[tokio::test]
async fn pre_request_refresh_skips_compaction_below_normal_threshold() {
let capacity = CapacityControllerConfig {
enabled: true,
low_risk_max: 0.0,
medium_risk_max: 1.0,
min_turns_before_guardrail: 0,
..Default::default()
};
let mut engine = build_engine_with_capacity(capacity.clone());
engine.config.capacity = capacity.clone();
engine.capacity_controller = CapacityController::new(capacity);
engine.turn_counter = 5;
engine
.capacity_controller
.mark_turn_start(engine.turn_counter);
engine.session.model = "deepseek-v4-pro".to_string();
engine.config.model = "deepseek-v4-pro".to_string();
for i in 0..20 {
engine.session.messages.push(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: format!("small message {i}"),
cache_control: None,
}],
});
}
let before = engine.estimated_input_tokens();
let before_len = engine.session.messages.len();
let turn = TurnContext::new(10);
let applied = engine
.run_capacity_pre_request_checkpoint(&turn, None, AppMode::Agent)
.await;
let after = engine.estimated_input_tokens();
assert!(!applied);
assert_eq!(after, before);
assert_eq!(engine.session.messages.len(), before_len);
}
#[tokio::test]
async fn pre_request_refresh_invoked_when_medium_risk() {
let capacity = CapacityControllerConfig {
@@ -165,6 +401,11 @@ async fn pre_request_refresh_invoked_when_medium_risk() {
.capacity_controller
.mark_turn_start(engine.turn_counter);
// Pin the model to an explicit 128k-context variant so the pressure ratio stays
// stable regardless of changes to the workspace-wide default model.
engine.session.model = "deepseek-v3.2-128k".to_string();
engine.config.model = "deepseek-v3.2-128k".to_string();
let long = "x".repeat(5_000);
for _ in 0..200 {
engine.session.messages.push(Message {
@@ -505,3 +746,140 @@ fn missing_tool_error_message_includes_discovery_guidance_when_no_match() {
assert!(message.contains("not available in the current tool catalog"));
assert!(message.contains(TOOL_SEARCH_BM25_NAME));
}
#[test]
fn filter_tool_call_delta_strips_bracket_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"intro [TOOL_CALL]\n{\"tool\":\"x\"}\n[/TOOL_CALL] outro",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("[TOOL_CALL]"));
assert!(!visible.contains("[/TOOL_CALL]"));
assert!(!visible.contains("\"tool\":\"x\""));
assert!(visible.contains("intro"));
assert!(visible.contains("outro"));
}
#[test]
fn filter_tool_call_delta_strips_deepseek_xml_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"before <deepseek:tool_call name=\"x\">payload</deepseek:tool_call> after",
&mut in_block,
);
assert!(!in_block);
for marker in TOOL_CALL_START_MARKERS {
assert!(
!visible.contains(marker),
"visible text leaked start marker `{marker}`: {visible:?}"
);
}
assert!(visible.contains("before"));
assert!(visible.contains("after"));
}
#[test]
fn filter_tool_call_delta_strips_generic_tool_call_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"lead <tool_call>\n{\"name\":\"do\"}\n</tool_call> tail",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("<tool_call"));
assert!(!visible.contains("</tool_call>"));
assert!(visible.contains("lead"));
assert!(visible.contains("tail"));
}
#[test]
fn filter_tool_call_delta_strips_invoke_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"alpha <invoke name=\"x\"><parameter name=\"k\">v</parameter></invoke> beta",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("<invoke "));
assert!(!visible.contains("</invoke>"));
assert!(visible.contains("alpha"));
assert!(visible.contains("beta"));
}
#[test]
fn filter_tool_call_delta_strips_function_calls_marker() {
let mut in_block = false;
let visible = filter_tool_call_delta(
"head <function_calls>\n{\"name\":\"x\"}\n</function_calls> tail",
&mut in_block,
);
assert!(!in_block);
assert!(!visible.contains("<function_calls>"));
assert!(!visible.contains("</function_calls>"));
assert!(visible.contains("head"));
assert!(visible.contains("tail"));
}
#[test]
fn filter_tool_call_delta_handles_chunk_split_marker() {
let mut in_block = false;
// First chunk opens the wrapper but does not close it.
let visible_a = filter_tool_call_delta("hello <tool_call>partial", &mut in_block);
assert!(in_block, "filter must remember it is mid-wrapper");
assert_eq!(visible_a, "hello ");
// Second chunk continues inside the wrapper, then closes it and adds tail.
let visible_b = filter_tool_call_delta("payload</tool_call> tail", &mut in_block);
assert!(!in_block);
assert_eq!(visible_b, " tail");
}
#[test]
fn filter_tool_call_delta_unmatched_open_suppresses_remainder() {
let mut in_block = false;
let visible = filter_tool_call_delta("ok [TOOL_CALL]rest of stream", &mut in_block);
assert_eq!(visible, "ok ");
assert!(
in_block,
"unmatched open must leave filter in tool-call mode"
);
}
#[test]
fn filter_tool_call_delta_passes_through_clean_text() {
let mut in_block = false;
let input = "no markers here, just prose with code `<not a tag>`.";
let visible = filter_tool_call_delta(input, &mut in_block);
assert!(!in_block);
assert_eq!(visible, input);
}
#[test]
fn contains_fake_tool_wrapper_detects_each_marker() {
for marker in TOOL_CALL_START_MARKERS {
let needle = format!("noise {marker} more noise");
assert!(
contains_fake_tool_wrapper(&needle),
"marker `{marker}` should be detected"
);
}
}
#[test]
fn contains_fake_tool_wrapper_returns_false_on_clean_text() {
assert!(!contains_fake_tool_wrapper(
"plain assistant text without wrappers"
));
assert!(!contains_fake_tool_wrapper(
"`<tool` lookalike but not a real start marker"
));
}
#[test]
fn fake_wrapper_notice_is_compact_and_actionable() {
// Keep this short so it fits cleanly in a single status line.
assert!(FAKE_WRAPPER_NOTICE.len() < 120);
assert!(FAKE_WRAPPER_NOTICE.contains("API tool channel"));
}
+26 -1
View File
@@ -3,9 +3,12 @@
//! These events flow from the engine to the TUI via a channel,
//! enabling non-blocking, real-time updates.
use std::path::PathBuf;
use serde_json::Value;
use crate::models::Usage;
use crate::core::coherence::CoherenceState;
use crate::models::{Message, SystemPrompt, Usage};
use crate::tools::spec::{ToolError, ToolResult};
use crate::tools::subagent::SubAgentResult;
use crate::tools::user_input::UserInputRequest;
@@ -156,6 +159,14 @@ pub enum Event {
error: String,
},
/// Plain-language session coherence state.
CoherenceState {
state: CoherenceState,
label: String,
description: String,
reason: String,
},
// === Sub-Agent Events ===
/// A sub-agent has been spawned
AgentSpawned { id: String, prompt: String },
@@ -199,6 +210,20 @@ pub enum Event {
request: UserInputRequest,
},
/// Authoritative API conversation state from the engine session.
///
/// The UI receives granular display events, but those are not always a
/// lossless representation of the API transcript. DeepSeek can emit
/// reasoning directly followed by tool calls without a visible assistant
/// text block, and that assistant message still has to be persisted for
/// later `reasoning_content` replay.
SessionUpdated {
messages: Vec<Message>,
system_prompt: Option<SystemPrompt>,
model: String,
workspace: PathBuf,
},
/// Request user decision after sandbox denial
#[allow(dead_code)]
ElevationRequired {
+1
View File
@@ -11,6 +11,7 @@
pub mod capacity;
pub mod capacity_memory;
pub mod coherence;
pub mod engine;
pub mod events;
pub mod ops;
+5
View File
@@ -16,6 +16,9 @@ pub enum Op {
content: String,
mode: AppMode,
model: String,
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
/// `None` lets the provider apply its default.
reasoning_effort: Option<String>,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
@@ -72,6 +75,7 @@ impl Op {
content: impl Into<String>,
mode: AppMode,
model: impl Into<String>,
reasoning_effort: Option<String>,
allow_shell: bool,
trust_mode: bool,
auto_approve: bool,
@@ -80,6 +84,7 @@ impl Op {
content: content.into(),
mode,
model: model.into(),
reasoning_effort,
allow_shell,
trust_mode,
auto_approve,
+12
View File
@@ -13,6 +13,11 @@ pub struct Session {
/// Model being used
pub model: String,
/// Reasoning-effort tier for DeepSeek thinking mode:
/// `"off" | "low" | "medium" | "high" | "max"`. `None` lets the provider
/// apply its own defaults.
pub reasoning_effort: Option<String>,
/// Workspace directory
pub workspace: PathBuf,
@@ -69,6 +74,12 @@ impl SessionUsage {
pub fn add(&mut self, usage: &Usage) {
self.input_tokens += u64::from(usage.input_tokens);
self.output_tokens += u64::from(usage.output_tokens);
if let Some(tokens) = usage.prompt_cache_miss_tokens {
self.cache_creation_input_tokens += u64::from(tokens);
}
if let Some(tokens) = usage.prompt_cache_hit_tokens {
self.cache_read_input_tokens += u64::from(tokens);
}
}
}
@@ -88,6 +99,7 @@ impl Session {
Self {
model,
reasoning_effort: None,
workspace,
system_prompt: None,
compaction_summary_prompt: None,
+20 -1
View File
@@ -57,7 +57,7 @@ impl TurnContext {
usage: Usage {
input_tokens: 0,
output_tokens: 0,
server_tool_use: None,
..Usage::default()
},
}
}
@@ -94,6 +94,25 @@ impl TurnContext {
pub fn add_usage(&mut self, usage: &Usage) {
self.usage.input_tokens += usage.input_tokens;
self.usage.output_tokens += usage.output_tokens;
self.usage.prompt_cache_hit_tokens = add_optional_usage(
self.usage.prompt_cache_hit_tokens,
usage.prompt_cache_hit_tokens,
);
self.usage.prompt_cache_miss_tokens = add_optional_usage(
self.usage.prompt_cache_miss_tokens,
usage.prompt_cache_miss_tokens,
);
self.usage.reasoning_tokens =
add_optional_usage(self.usage.reasoning_tokens, usage.reasoning_tokens);
}
}
fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> {
match (total, delta) {
(Some(total), Some(delta)) => Some(total.saturating_add(delta)),
(None, Some(delta)) => Some(delta),
(Some(total), None) => Some(total),
(None, None) => None,
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Whale/DeepSeek terminal theme tokens.
//!
//! A small, deliberately flat module that names the color, border, and
//! padding choices the TUI is already making. The dark variant matches the
//! values previously hard-coded against [`crate::palette`]; the light variant
//! is reserved for the future skin swap that issue #14 tracks. Visible output
//! is not changed by introducing this module.
//!
//! The only consumers today are the plan and tool cell renderers in
//! [`crate::tui::history`] and the sidebar section chrome in
//! [`crate::tui::ui`]. All other call sites continue to use [`crate::palette`]
//! directly until they are migrated in a later slice.
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{BorderType, Borders, Padding};
use crate::palette;
use crate::tui::history::ToolStatus;
/// Visual variant exposed by the theme.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
Dark,
/// Reserved for the future skin swap (issue #14). Not wired up yet.
#[allow(dead_code)]
Light,
}
/// Centralized visual tokens for sidebar, plan, and tool rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Theme {
pub variant: Variant,
// Sidebar / section chrome
pub section_borders: Borders,
pub section_border_type: BorderType,
pub section_border_color: Color,
pub section_bg: Color,
pub section_title_color: Color,
pub section_padding: Padding,
// Tool cell color tokens
pub tool_title_color: Color,
pub tool_value_color: Color,
pub tool_label_color: Color,
pub tool_running_accent: Color,
pub tool_success_accent: Color,
pub tool_failed_accent: Color,
// Plan cell color tokens
pub plan_progress_color: Color,
pub plan_summary_color: Color,
pub plan_explanation_color: Color,
pub plan_pending_color: Color,
pub plan_in_progress_color: Color,
pub plan_completed_color: Color,
}
impl Theme {
/// The current dark theme. Visible output today uses these values.
#[must_use]
pub const fn dark() -> Self {
Self {
variant: Variant::Dark,
section_borders: Borders::ALL,
section_border_type: BorderType::Plain,
section_border_color: palette::BORDER_COLOR,
section_bg: palette::DEEPSEEK_INK,
section_title_color: palette::DEEPSEEK_BLUE,
section_padding: Padding::uniform(1),
tool_title_color: palette::TEXT_SOFT,
tool_value_color: palette::TEXT_MUTED,
tool_label_color: palette::TEXT_DIM,
tool_running_accent: palette::ACCENT_TOOL_LIVE,
tool_success_accent: palette::TEXT_DIM,
tool_failed_accent: palette::ACCENT_TOOL_ISSUE,
plan_progress_color: palette::STATUS_SUCCESS,
plan_summary_color: palette::TEXT_MUTED,
plan_explanation_color: palette::TEXT_DIM,
plan_pending_color: palette::TEXT_MUTED,
plan_in_progress_color: palette::STATUS_WARNING,
plan_completed_color: palette::STATUS_SUCCESS,
}
}
/// The light variant. Same RGB values as `dark()` today so a future skin
/// swap is a single-source-of-truth change in this file. Out of scope:
/// actually picking a distinct light palette.
#[must_use]
#[allow(dead_code)]
pub const fn light() -> Self {
Self {
variant: Variant::Light,
..Self::dark()
}
}
/// Pick the right tool accent for a given [`ToolStatus`].
#[must_use]
pub const fn tool_status_color(self, status: ToolStatus) -> Color {
match status {
ToolStatus::Running => self.tool_running_accent,
ToolStatus::Success => self.tool_success_accent,
ToolStatus::Failed => self.tool_failed_accent,
}
}
/// Bold tool title style (e.g. "Plan", "Shell").
#[must_use]
pub fn tool_title_style(self) -> Style {
Style::default()
.fg(self.tool_title_color)
.add_modifier(Modifier::BOLD)
}
/// Right-side status text ("running", "done", "issue") style.
#[must_use]
pub fn tool_status_style(self, status: ToolStatus) -> Style {
Style::default().fg(self.tool_status_color(status))
}
/// Detail label style ("command:", "time:", step markers).
#[must_use]
pub fn tool_label_style(self) -> Style {
Style::default().fg(self.tool_label_color)
}
/// Default value style for tool detail rows.
#[must_use]
pub fn tool_value_style(self) -> Style {
Style::default().fg(self.tool_value_color)
}
}
/// Returns the active theme used by the TUI today.
///
/// Today this is always `Theme::dark()`. A future PR can wire this to an
/// `App` field or a config setting in five lines.
#[must_use]
pub const fn active_theme() -> Theme {
Theme::dark()
}
#[cfg(test)]
mod tests {
use super::{Theme, Variant, active_theme};
use crate::palette;
use crate::tui::history::ToolStatus;
#[test]
fn active_theme_returns_dark() {
assert_eq!(active_theme(), Theme::dark());
}
#[test]
fn dark_theme_matches_existing_palette_choices() {
let theme = Theme::dark();
assert_eq!(theme.variant, Variant::Dark);
assert_eq!(theme.section_border_color, palette::BORDER_COLOR);
assert_eq!(theme.section_bg, palette::DEEPSEEK_INK);
assert_eq!(theme.section_title_color, palette::DEEPSEEK_BLUE);
assert_eq!(theme.tool_title_color, palette::TEXT_SOFT);
assert_eq!(theme.tool_value_color, palette::TEXT_MUTED);
assert_eq!(theme.tool_label_color, palette::TEXT_DIM);
assert_eq!(theme.tool_running_accent, palette::ACCENT_TOOL_LIVE);
assert_eq!(theme.tool_success_accent, palette::TEXT_DIM);
assert_eq!(theme.tool_failed_accent, palette::ACCENT_TOOL_ISSUE);
}
#[test]
fn light_theme_keeps_dark_values_until_skin_swap() {
// The light variant exists so the future skin swap is a single-file
// change. It intentionally mirrors `dark()` today so introducing the
// module does not move pixels.
let dark = Theme::dark();
let light = Theme::light();
assert_eq!(light.variant, Variant::Light);
assert_eq!(light.section_border_color, dark.section_border_color);
assert_eq!(light.tool_running_accent, dark.tool_running_accent);
assert_eq!(light.tool_failed_accent, dark.tool_failed_accent);
assert_eq!(light.plan_in_progress_color, dark.plan_in_progress_color);
}
#[test]
fn tool_status_color_maps_each_status() {
let theme = Theme::dark();
assert_eq!(
theme.tool_status_color(ToolStatus::Running),
theme.tool_running_accent
);
assert_eq!(
theme.tool_status_color(ToolStatus::Success),
theme.tool_success_accent
);
assert_eq!(
theme.tool_status_color(ToolStatus::Failed),
theme.tool_failed_accent
);
}
}
+37
View File
@@ -12,6 +12,29 @@ pub fn set_verbose(enabled: bool) {
VERBOSE.store(enabled, Ordering::SeqCst);
}
/// Return true when supported env logging knobs request verbose output.
#[must_use]
pub fn env_requests_verbose_logging() -> bool {
std::env::var("DEEPSEEK_LOG_LEVEL")
.ok()
.is_some_and(|value| log_value_enables_verbose(&value))
|| std::env::var("RUST_LOG")
.ok()
.is_some_and(|value| log_value_enables_verbose(&value))
}
fn log_value_enables_verbose(value: &str) -> bool {
value.split(',').any(|directive| {
let level = directive
.rsplit('=')
.next()
.unwrap_or(directive)
.trim()
.to_ascii_lowercase();
matches!(level.as_str(), "trace" | "debug" | "info")
})
}
/// Check whether verbose logging is enabled.
#[must_use]
pub fn is_verbose() -> bool {
@@ -33,3 +56,17 @@ pub fn warn(message: impl AsRef<str>) {
eprintln!("{} {}", "warn".truecolor(r, g, b).bold(), message.as_ref());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_value_parser_accepts_common_rust_log_directives() {
assert!(log_value_enables_verbose("debug"));
assert!(log_value_enables_verbose("deepseek_cli=debug"));
assert!(log_value_enables_verbose("warn,deepseek_tui::client=trace"));
assert!(!log_value_enables_verbose("warn"));
assert!(!log_value_enables_verbose("deepseek_tui=off"));
}
}
+942 -43
View File
File diff suppressed because it is too large. Load diff
+166 -10
View File
@@ -3,11 +3,12 @@
use serde::{Deserialize, Serialize};
pub const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000;
pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 50_000;
pub const DEFAULT_COMPACTION_MESSAGE_THRESHOLD: usize = 50;
const COMPACTION_THRESHOLD_PERCENT: u32 = 80;
const COMPACTION_MESSAGE_DIVISOR: u32 = 1200;
const MAX_COMPACTION_MESSAGE_THRESHOLD: usize = 150;
const COMPACTION_MESSAGE_DIVISOR: u32 = 500;
const MAX_COMPACTION_MESSAGE_THRESHOLD: usize = 2_000;
// === Core Message Types ===
@@ -27,6 +28,10 @@ pub struct MessageRequest {
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<serde_json::Value>,
/// DeepSeek reasoning-effort tier: "off" | "low" | "medium" | "high" | "max".
/// Translated by the client into DeepSeek's `reasoning_effort` + `thinking` fields.
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -176,11 +181,23 @@ pub struct MessageResponse {
}
/// Token usage metadata for a response.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Usage {
pub input_tokens: u32,
pub output_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_hit_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
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>,
}
@@ -188,11 +205,15 @@ pub struct Usage {
#[must_use]
pub fn context_window_for_model(model: &str) -> Option<u32> {
let lower = model.to_lowercase();
// DeepSeek models default to 128k unless an explicit *k suffix is present.
// Unknown DeepSeek model IDs default to 128k unless an explicit *k suffix is present.
// DeepSeek-V4 family and current legacy aliases ship with a 1M context window.
if lower.contains("deepseek") {
if let Some(explicit_window) = deepseek_context_window_hint(&lower) {
return Some(explicit_window);
}
if lower.contains("v4") || is_current_deepseek_v4_alias(&lower) {
return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS);
}
return Some(DEFAULT_CONTEXT_WINDOW_TOKENS);
}
if lower.contains("claude") {
@@ -201,6 +222,13 @@ pub fn context_window_for_model(model: &str) -> Option<u32> {
None
}
fn is_current_deepseek_v4_alias(model_lower: &str) -> bool {
matches!(
model_lower,
"deepseek-chat" | "deepseek-reasoner" | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2"
)
}
fn deepseek_context_window_hint(model_lower: &str) -> Option<u32> {
let bytes = model_lower.as_bytes();
let mut i = 0usize;
@@ -246,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 {
@@ -342,13 +413,29 @@ mod tests {
use super::*;
#[test]
fn deepseek_models_map_to_128k_context_window() {
fn current_deepseek_aliases_map_to_v4_1m_context_window() {
assert_eq!(
context_window_for_model("deepseek-reasoner"),
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
context_window_for_model("deepseek-chat"),
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
context_window_for_model("deepseek-v3"),
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
context_window_for_model("deepseek-v3.2"),
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
);
}
#[test]
fn unknown_deepseek_models_map_to_128k_context_window() {
assert_eq!(
context_window_for_model("deepseek-coder"),
Some(DEFAULT_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
@@ -357,6 +444,22 @@ mod tests {
);
}
#[test]
fn deepseek_v4_models_map_to_1m_context_window() {
assert_eq!(
context_window_for_model("deepseek-v4-pro"),
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
context_window_for_model("deepseek-v4-flash"),
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
);
assert_eq!(
context_window_for_model("deepseek-ai/deepseek-v4-pro"),
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
);
}
#[test]
fn deepseek_models_with_k_suffix_use_hint() {
assert_eq!(context_window_for_model("deepseek-v3.2-32k"), Some(32_000));
@@ -372,17 +475,70 @@ mod tests {
#[test]
fn compaction_threshold_scales_with_context_window() {
assert_eq!(compaction_threshold_for_model("deepseek-reasoner"), 102_400);
assert_eq!(
compaction_threshold_for_model("deepseek-v3.2-128k"),
102_400
);
assert_eq!(compaction_threshold_for_model("unknown-model"), 50_000);
}
#[test]
fn compaction_message_threshold_scales_with_context_window() {
assert_eq!(
compaction_message_threshold_for_model("deepseek-reasoner"),
106
compaction_message_threshold_for_model("deepseek-v3.2-128k"),
256
);
assert_eq!(compaction_message_threshold_for_model("unknown-model"), 50);
assert_eq!(compaction_message_threshold_for_model("claude-3"), 150);
// 200k / 500 = 400, within the 2k cap.
assert_eq!(compaction_message_threshold_for_model("claude-3"), 400);
}
#[test]
fn compaction_scales_for_deepseek_v4_1m_context() {
assert_eq!(compaction_threshold_for_model("deepseek-v4-pro"), 800_000);
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
);
}
}
+1
View File
@@ -180,6 +180,7 @@ async fn process_deepseek_turn(
tool_choice: options.tool_choice.clone(),
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(options.stream),
temperature: options.temperature,
top_p: options.top_p,
-1
View File
@@ -48,7 +48,6 @@ pub const TEXT_BODY: Color = Color::White;
pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); // #C0C0C0
pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0
pub const TEXT_ACCENT: Color = DEEPSEEK_SKY;
pub const FOOTER_HINT: Color = Color::Rgb(180, 190, 208); // #B4BED0
pub const SELECTION_TEXT: Color = Color::White;
pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235); // #D6DFEB
+124 -26
View File
@@ -2,51 +2,106 @@
//!
//! 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.
struct ModelPricing {
input_per_million: f64,
input_cache_hit_per_million: f64,
input_cache_miss_per_million: f64,
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.contains("deepseek-reasoner") || lower.contains("deepseek-r1") {
// DeepSeek-R1: $0.55/M input, $2.19/M output
if lower.starts_with("deepseek-ai/") {
// NVIDIA NIM-hosted DeepSeek uses NVIDIA's catalog/account terms, not
// DeepSeek Platform pricing. Avoid showing misleading DeepSeek costs.
return None;
}
if !lower.contains("deepseek") {
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_per_million: 0.55,
output_per_million: 2.19,
})
} else if lower.contains("deepseek-v3.2") {
// DeepSeek-V3.2 (with reasoning): same pricing tier as V3
Some(ModelPricing {
input_per_million: 0.27,
output_per_million: 1.10,
})
} else if lower.contains("deepseek-chat") || lower.contains("deepseek-v3") {
// DeepSeek-V3: $0.27/M input, $1.10/M output
Some(ModelPricing {
input_per_million: 0.27,
output_per_million: 1.10,
})
} else if lower.contains("deepseek") {
// Generic DeepSeek fallback (V3 pricing)
Some(ModelPricing {
input_per_million: 0.27,
output_per_million: 1.10,
input_cache_hit_per_million: 0.145,
input_cache_miss_per_million: 1.74,
output_per_million: 3.48,
})
} else {
None
// deepseek-v4-flash and legacy aliases (deepseek-chat, deepseek-reasoner,
// deepseek-v3*) all price as v4-flash.
Some(ModelPricing {
input_cache_hit_per_million: 0.028,
input_cache_miss_per_million: 0.14,
output_per_million: 0.28,
})
}
}
/// Calculate cost for a turn given token usage and model.
#[must_use]
#[allow(dead_code)]
pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) -> Option<f64> {
let pricing = pricing_for_model(model)?;
let input_cost = (input_tokens as f64 / 1_000_000.0) * pricing.input_per_million;
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
.unwrap_or_else(|| usage.input_tokens.saturating_sub(hit_tokens));
let accounted_input = hit_tokens.saturating_add(miss_tokens);
let uncategorized_input = usage.input_tokens.saturating_sub(accounted_input);
let hit_cost = (hit_tokens as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million;
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;
hit_cost + miss_cost + output_cost
}
/// Format a USD cost for compact display.
@@ -63,3 +118,46 @@ pub fn format_cost(cost: f64) -> String {
format!("${:.2}", cost)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
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);
}
}
+95
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(
@@ -109,3 +135,72 @@ pub fn yolo_system_prompt() -> SystemPrompt {
pub fn plan_system_prompt() -> SystemPrompt {
system_prompt_for_mode(AppMode::Plan)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn plan_prompt_prefers_best_effort_plans_over_clarifying_loops() {
let prompt = match system_prompt_for_mode(AppMode::Plan) {
SystemPrompt::Text(text) => text,
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
};
assert!(prompt.contains("Default to publishing a best-effort plan immediately."));
assert!(prompt.contains("your first action should be update_plan."));
assert!(prompt.contains("do not browse the repo first"));
assert!(prompt.contains("Do not ask clarifying questions for straightforward requests"));
assert!(prompt.contains("If the user asks for \"a 3-step plan\""));
}
/// 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));
}
}
+37 -29
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,19 +33,30 @@ 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
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
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.
@@ -78,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.
+2 -2
View File
@@ -12,8 +12,8 @@ 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 multi_tool_use.parallel for multiple read-only tool calls that can run together.
- 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.
+4 -3
View File
@@ -11,10 +11,11 @@ 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
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
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.
@@ -47,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.
+21 -9
View File
@@ -9,10 +9,13 @@ In this mode, focus on:
4. Creating a detailed plan using update_plan before implementation.
Interaction workflow:
1. Before publishing a plan, ask clarifying questions with request_user_input when requirements are ambiguous.
2. Use concise multiple-choice questions with numbered options and clear tradeoffs.
3. Keep it to 1-3 questions total, then synthesize the answers into update_plan output.
4. After emitting update_plan, stop and wait for explicit user approval before implementation.
1. For straightforward planning requests such as "quick plan", "3-step plan", "give me a plan", or review/checklist asks, your first action should be update_plan.
2. For those straightforward planning requests, do not browse the repo first and do not ask request_user_input unless the user explicitly asks for grounded investigation or you are blocked from producing a credible plan.
3. Default to publishing a best-effort plan immediately.
4. Ask clarifying questions with request_user_input only when you are blocked from producing a credible plan without the answer.
5. Do not ask clarifying questions for straightforward requests such as "give me a plan", "3-step plan", "high-level plan", review/checklist requests, or when reasonable assumptions are acceptable. State those assumptions in the plan instead.
6. If you do ask, use concise multiple-choice questions with numbered options and clear tradeoffs. Keep it to 1 question unless the first answer still leaves the task blocked.
7. After emitting update_plan, stop and wait for explicit user approval before implementation.
Available tools:
@@ -24,10 +27,11 @@ 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
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
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.
@@ -40,12 +44,20 @@ EXPLORATION:
- diagnostics: Report workspace, git, sandbox, and toolchain info
Guidelines:
- Prefer tool-centric planning: use grep_files/list_dir/read_file to ground the plan in the actual codebase.
- Use web.run for time-sensitive or uncertain facts, and cite sources as [cite:ref_id].
- 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_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.
- Include git hygiene in the plan: check git status early and before finishing; avoid reverting unrelated changes.
- Identify dependencies, risks, edge cases, and rollback/mitigation ideas.
- Budget steps: if key facts are missing after 2-3 exploration attempts, ask a focused clarifying question.
- Prefer reasonable assumptions over questions when a solid plan is still possible.
- Treat verification-scope ambiguity as non-blocking: include the assumption in the plan instead of stopping to clarify.
- Ask clarifying questions only when missing facts would materially change the plan or make it unsafe.
- Budget steps: if key facts are missing after 2-3 exploration attempts and no reasonable assumption would work, ask a focused clarifying question.
- Provide concise progress notes, then wait for user direction once the plan is ready.
Examples:
- If the user asks for "a 3-step plan" or "a quick plan", call update_plan directly and avoid request_user_input.
- If the user asks to verify UI work, assume code-review-first unless they explicitly ask for runtime/manual testing.
+6 -5
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,10 +42,11 @@ 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
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
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.
+7 -7
View File
@@ -2014,7 +2014,7 @@ mod tests {
usage: Usage {
input_tokens: 10,
output_tokens: 5,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -2027,7 +2027,7 @@ mod tests {
usage: Usage {
input_tokens: 0,
output_tokens: 0,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -2159,7 +2159,7 @@ mod tests {
usage: Usage {
input_tokens: 5,
output_tokens: 3,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -2281,7 +2281,7 @@ mod tests {
usage: Usage {
input_tokens: 2,
output_tokens: 1,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -2500,7 +2500,7 @@ mod tests {
usage: Usage {
input_tokens: 4,
output_tokens: 2,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -2646,7 +2646,7 @@ mod tests {
"updated_at": "2025-01-01T00:10:00Z",
"message_count": 2,
"total_tokens": 100,
"model": "deepseek-chat",
"model": "deepseek-v4-pro",
"workspace": "/tmp/test",
"mode": "agent"
},
@@ -2678,7 +2678,7 @@ mod tests {
.post(format!(
"http://{addr}/v1/sessions/{session_id}/resume-thread"
))
.json(&json!({ "model": "deepseek-chat" }))
.json(&json!({ "model": "deepseek-v4-pro" }))
.send()
.await?;
assert_eq!(resp.status(), StatusCode::CREATED);
+60 -9
View File
@@ -19,6 +19,7 @@ use uuid::Uuid;
use crate::compaction::CompactionConfig;
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
use crate::core::coherence::CoherenceState;
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
use crate::core::ops::Op;
@@ -97,6 +98,8 @@ pub struct ThreadRecord {
pub archived: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default)]
pub coherence_state: CoherenceState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -320,7 +323,7 @@ impl RuntimeThreadStore {
}
out.push(thread);
}
out.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
out.sort_by_key(|t| std::cmp::Reverse(t.updated_at));
Ok(out)
}
@@ -349,7 +352,7 @@ impl RuntimeThreadStore {
out.push(turn);
}
}
out.sort_by(|a, b| a.created_at.cmp(&b.created_at));
out.sort_by_key(|a| a.created_at);
Ok(out)
}
@@ -677,6 +680,7 @@ impl RuntimeThreadManager {
latest_response_bookmark: None,
archived: req.archived,
system_prompt: req.system_prompt,
coherence_state: CoherenceState::default(),
};
self.store.save_thread(&thread)?;
self.emit_event(
@@ -1044,6 +1048,7 @@ impl RuntimeThreadManager {
prompt,
mode,
model.clone(),
None,
allow_shell,
trust_mode,
auto_approve,
@@ -1675,6 +1680,31 @@ impl RuntimeThreadManager {
.await?;
}
}
EngineEvent::CoherenceState {
state,
label,
description,
reason,
} => {
let mut thread = self.store.load_thread(&thread_id)?;
thread.coherence_state = state;
thread.updated_at = Utc::now();
self.store.save_thread(&thread)?;
self.emit_event(
&thread_id,
Some(&turn_id),
None,
"coherence.state",
json!({
"state": state,
"label": label,
"description": description,
"reason": reason,
"thread": thread,
}),
)
.await?;
}
EngineEvent::CapacityDecision {
risk_band,
action,
@@ -2377,6 +2407,7 @@ mod tests {
latest_response_bookmark: None,
archived: false,
system_prompt: None,
coherence_state: CoherenceState::default(),
}
}
@@ -2633,12 +2664,20 @@ mod tests {
let _ = tx_event
.send(EngineEvent::MessageComplete { index: 0 })
.await;
let _ = tx_event
.send(EngineEvent::CoherenceState {
state: CoherenceState::GettingCrowded,
label: "getting crowded".to_string(),
description: "The session is approaching context pressure.".to_string(),
reason: "test capacity signal".to_string(),
})
.await;
let _ = tx_event
.send(EngineEvent::TurnComplete {
usage: Usage {
input_tokens: 10,
output_tokens: 12,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -2669,6 +2708,10 @@ mod tests {
let reopened = test_manager(runtime_dir)?;
let detail = reopened.get_thread_detail(&thread.id).await?;
assert_eq!(detail.thread.id, thread.id);
assert_eq!(
detail.thread.coherence_state,
CoherenceState::GettingCrowded
);
assert_eq!(detail.turns.len(), 1);
assert!(detail.latest_seq >= 1);
assert!(!detail.items.is_empty());
@@ -2677,6 +2720,12 @@ mod tests {
events.iter().any(|ev| ev.event == "turn.completed"),
"expected turn.completed event after restart"
);
assert!(
events.iter().any(|ev| ev.event == "coherence.state"
&& ev.payload.get("state").and_then(serde_json::Value::as_str)
== Some("getting_crowded")),
"expected machine-readable coherence event after restart"
);
Ok(())
}
@@ -2697,6 +2746,7 @@ mod tests {
.await?;
assert!(!thread.auto_approve);
assert_eq!(thread.coherence_state, CoherenceState::Healthy);
Ok(())
}
@@ -2913,7 +2963,7 @@ mod tests {
usage: Usage {
input_tokens: 5,
output_tokens: 5,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -3144,7 +3194,7 @@ mod tests {
usage: Usage {
input_tokens: 0,
output_tokens: 0,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -3226,7 +3276,7 @@ mod tests {
usage: Usage {
input_tokens: 0,
output_tokens: 0,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -3286,7 +3336,7 @@ mod tests {
usage: Usage {
input_tokens: 8,
output_tokens: 9,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -3395,7 +3445,7 @@ mod tests {
usage: Usage {
input_tokens: 3,
output_tokens: 3,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -3425,7 +3475,7 @@ mod tests {
usage: Usage {
input_tokens: 1,
output_tokens: 1,
server_tool_use: None,
..Usage::default()
},
status: TurnOutcomeStatus::Completed,
error: None,
@@ -3556,6 +3606,7 @@ mod tests {
latest_response_bookmark: None,
archived: false,
system_prompt: None,
coherence_state: CoherenceState::default(),
};
manager.store.save_thread(&thread)?;
+1 -1
View File
@@ -306,7 +306,7 @@ impl SessionManager {
}
// Sort by updated_at descending (most recent first)
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
Ok(sessions)
}
+2 -2
View File
@@ -235,7 +235,7 @@ impl Settings {
let Some(model) = normalize_model_name(trimmed) else {
anyhow::bail!(
"Failed to update setting: invalid model '{value}'. Expected: a DeepSeek model ID (for example deepseek-chat, deepseek-reasoner, deepseek-v4), or none/default."
"Failed to update setting: invalid model '{value}'. Expected: a DeepSeek model ID (for example deepseek-v4-pro, deepseek-v4-flash), or none/default."
);
};
self.default_model = Some(model);
@@ -311,7 +311,7 @@ impl Settings {
("max_history", "Max input history entries"),
(
"default_model",
"Default model: any DeepSeek model ID (e.g. deepseek-chat)",
"Default model: any DeepSeek model ID (e.g. deepseek-v4-pro)",
),
]
}
+2 -2
View File
@@ -767,7 +767,7 @@ impl TaskManager {
.values()
.map(TaskSummary::from)
.collect::<Vec<_>>();
items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
items.sort_by_key(|i| std::cmp::Reverse(i.created_at));
if let Some(limit) = limit {
items.truncate(limit);
}
@@ -1483,7 +1483,7 @@ mod tests {
data_dir: root,
worker_count: 1,
default_workspace: PathBuf::from("."),
default_model: "deepseek-v3.2".to_string(),
default_model: "deepseek-v4-flash".to_string(),
default_mode: "agent".to_string(),
allow_shell: false,
trust_mode: false,
+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;
+9
View File
@@ -1,4 +1,12 @@
//! Tool wrapper for executing multiple tool calls in parallel.
//!
//! NOTE: this meta-tool is intentionally no longer registered with the
//! agent (see `ToolRegistryBuilder::with_parallel_tool`). DeepSeek-V4
//! supports native parallel `tool_calls` in a single assistant turn, and
//! advertising the OpenAI-internal name `multi_tool_use.parallel` made
//! the model hallucinate ChatGPT-style XML wrappers. The struct stays
//! around so the engine compatibility dispatcher and historical sessions
//! still resolve it cleanly.
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
@@ -6,6 +14,7 @@ use super::spec::{
use async_trait::async_trait;
use serde_json::{Value, json};
#[allow(dead_code)]
pub struct MultiToolUseParallelTool;
#[async_trait]
+10 -3
View File
@@ -345,19 +345,26 @@ 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))
}
/// Include multi-tool parallel wrapper.
/// Previously registered the OpenAI-style `multi_tool_use.parallel`
/// meta-tool. DeepSeek-V4 has native parallel tool calls (multiple
/// `tool_calls` entries in one assistant turn) and the meta-tool name
/// triggered the model to hallucinate OpenAI-internal XML wrappers
/// (`<multi_tool_use.parallel><tool_name>…</tool_name>…`) instead of
/// emitting native calls. Kept as a no-op so existing callers compile;
/// the engine's compatibility dispatcher still handles legacy emissions.
#[must_use]
pub fn with_parallel_tool(self) -> Self {
use super::parallel::MultiToolUseParallelTool;
self.with_tool(Arc::new(MultiToolUseParallelTool))
self
}
/// Include request_user_input tool.
+1
View File
@@ -227,6 +227,7 @@ impl ToolSpec for ReviewTool {
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(false),
temperature: Some(0.2),
top_p: Some(0.9),
+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});
+3 -6
View File
@@ -143,7 +143,6 @@ impl SubAgentType {
"file_search",
"web.run",
"web_search",
"multi_tool_use.parallel",
"exec_shell",
"exec_shell_wait",
"exec_shell_interact",
@@ -164,7 +163,6 @@ impl SubAgentType {
"file_search",
"web.run",
"web_search",
"multi_tool_use.parallel",
"exec_shell",
"exec_shell_wait",
"exec_shell_interact",
@@ -2301,6 +2299,7 @@ async fn run_subagent(
tool_choice: Some(json!({ "type": "auto" })),
metadata: None,
thinking: None,
reasoning_effort: None,
stream: Some(false),
temperature: None,
top_p: None,
@@ -2311,10 +2310,8 @@ async fn run_subagent(
let mut tool_uses = Vec::new();
for block in &response.content {
match block {
ContentBlock::Text { text, .. } => {
if !text.trim().is_empty() {
final_result = Some(text.clone());
}
ContentBlock::Text { text, .. } if !text.trim().is_empty() => {
final_result = Some(text.clone());
}
ContentBlock::ToolUse {
id, name, input, ..
+183 -11
View File
@@ -8,6 +8,7 @@ use super::spec::{
optional_u64, required_str,
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -473,7 +474,7 @@ impl ToolSpec for WebRunTool {
})
.unwrap_or_default();
let (entries, warning) =
let (entries, source, warning) =
run_search(&query, max_results, timeout_ms, &domains).await?;
let mut warnings = Vec::new();
if recency > 0 {
@@ -493,7 +494,7 @@ impl ToolSpec for WebRunTool {
results.push(SearchResult {
ref_id,
query,
source: "duckduckgo".to_string(),
source,
count: entries.len(),
results: entries,
warning: if warnings.is_empty() {
@@ -707,7 +708,7 @@ async fn run_search(
max_results: usize,
timeout_ms: u64,
domains: &[String],
) -> Result<(Vec<SearchEntry>, Option<String>), ToolError> {
) -> Result<(Vec<SearchEntry>, String, Option<String>), ToolError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
@@ -741,19 +742,85 @@ async fn run_search(
}
let mut results = parse_duckduckgo_results(&body, max_results);
let warning = if !domains.is_empty() {
let mut source = "duckduckgo".to_string();
let mut warnings = Vec::new();
if results.is_empty() {
let duckduckgo_blocked = is_duckduckgo_challenge(&body);
match run_bing_search(&client, query, max_results).await {
Ok(fallback_results) if !fallback_results.is_empty() => {
results = fallback_results;
source = "bing".to_string();
warnings.push(if duckduckgo_blocked {
"DuckDuckGo returned a bot challenge; used Bing fallback".to_string()
} else {
"DuckDuckGo returned no parseable results; used Bing fallback".to_string()
});
}
Ok(_) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(
"DuckDuckGo returned a bot challenge and Bing fallback returned no results",
));
}
Err(err) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(format!(
"DuckDuckGo returned a bot challenge and Bing fallback failed: {err}"
)));
}
Ok(_) | Err(_) => {}
}
}
if !domains.is_empty() {
let before = results.len();
results.retain(|entry| domain_matches(&entry.url, domains));
if before != results.len() {
Some("Filtered search results by domain list".to_string())
} else {
None
warnings.push("Filtered search results by domain list".to_string());
}
} else {
None
};
}
Ok((results, warning))
Ok((
results,
source,
if warnings.is_empty() {
None
} else {
Some(warnings.join("; "))
},
))
}
async fn run_bing_search(
client: &reqwest::Client,
query: &str,
max_results: usize,
) -> Result<Vec<SearchEntry>, ToolError> {
let encoded = url_encode(query);
let url = format!("https://www.bing.com/search?q={encoded}");
let resp = client
.get(&url)
.header(
"Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header("Accept-Language", "en-US,en;q=0.9")
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Bing fallback request failed: {e}")))?;
let status = resp.status();
let body = resp.text().await.map_err(|e| {
ToolError::execution_failed(format!("Failed to read Bing fallback response: {e}"))
})?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Bing fallback failed: HTTP {}",
status.as_u16()
)));
}
Ok(parse_bing_results(&body, max_results))
}
fn domain_matches(url: &str, domains: &[String]) -> bool {
@@ -1189,6 +1256,9 @@ static STYLE_RE: OnceLock<Regex> = OnceLock::new();
static TITLE_RE: OnceLock<Regex> = OnceLock::new();
static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
static SEARCH_TITLE_RE: OnceLock<Regex> = OnceLock::new();
static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
fn get_anchor_re() -> &'static Regex {
ANCHOR_RE.get_or_init(|| {
@@ -1236,6 +1306,27 @@ fn get_search_snippet_re() -> &'static Regex {
})
}
fn get_bing_result_re() -> &'static Regex {
BING_RESULT_RE.get_or_init(|| {
Regex::new(r#"(?is)<li[^>]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)</li>"#)
.expect("bing result regex pattern is valid")
})
}
fn get_bing_title_re() -> &'static Regex {
BING_TITLE_RE.get_or_init(|| {
Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
.expect("bing title regex pattern is valid")
})
}
fn get_bing_snippet_re() -> &'static Regex {
BING_SNIPPET_RE.get_or_init(|| {
Regex::new(r#"(?is)<div[^>]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?<p[^>]*>(.*?)</p>"#)
.expect("bing snippet regex pattern is valid")
})
}
fn parse_html(html: &str, base_url: &str) -> (Vec<String>, Vec<WebLink>, Option<String>) {
let title = extract_title(html);
let without_scripts = get_script_re().replace_all(html, "").to_string();
@@ -1398,6 +1489,44 @@ fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<SearchEntry>
results
}
fn is_duckduckgo_challenge(html: &str) -> bool {
html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too")
}
fn parse_bing_results(html: &str, max_results: usize) -> Vec<SearchEntry> {
let mut results = Vec::new();
for cap in get_bing_result_re().captures_iter(html) {
if results.len() >= max_results {
break;
}
let Some(block) = cap.get(1).map(|m| m.as_str()) else {
continue;
};
let Some(title_cap) = get_bing_title_re().captures(block) else {
continue;
};
let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or("");
let title = normalize_whitespace(&decode_html_entities(&strip_tags(title_raw)));
if title.is_empty() {
continue;
}
let snippet = get_bing_snippet_re()
.captures(block)
.and_then(|snippet_cap| snippet_cap.get(1))
.map(|m| normalize_whitespace(&decode_html_entities(&strip_tags(m.as_str()))))
.filter(|s| !s.is_empty());
results.push(SearchEntry {
title,
url: normalize_bing_url(href),
snippet,
});
}
results
}
fn normalize_search_url(href: &str) -> String {
if let Some(uddg) = extract_query_param(href, "uddg") {
let decoded = percent_decode(&uddg);
@@ -1414,6 +1543,30 @@ fn normalize_search_url(href: &str) -> String {
href.to_string()
}
fn normalize_bing_url(href: &str) -> String {
if let Some(encoded) = extract_query_param(href, "u") {
let decoded = percent_decode(&encoded);
let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
let mut padded = token.replace('-', "+").replace('_', "/");
while !padded.len().is_multiple_of(4) {
padded.push('=');
}
if let Ok(bytes) = general_purpose::STANDARD.decode(padded)
&& let Ok(url) = String::from_utf8(bytes)
&& looks_like_url(&url)
{
return url;
}
}
if href.starts_with("//") {
return format!("https:{href}");
}
if href.starts_with('/') {
return format!("https://www.bing.com{href}");
}
href.to_string()
}
fn extract_query_param(url: &str, key: &str) -> Option<String> {
let query_start = url.find('?')?;
let query = &url[query_start + 1..];
@@ -1510,6 +1663,25 @@ mod tests {
);
}
#[test]
fn parses_bing_results_and_decodes_redirect_url() {
let html = r#"
<ol>
<li class="b_algo">
<h2><a href="https://www.bing.com/ck/a?u=a1aHR0cHM6Ly9leGFtcGxlLmNvbS9wYXRoP3E9MQ">Example &amp; Result</a></h2>
<div class="b_caption"><p>A <strong>useful</strong> snippet.</p></div>
</li>
</ol>
"#;
let results = parse_bing_results(html, 5);
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Example & Result");
assert_eq!(results[0].url, "https://example.com/path?q=1");
assert_eq!(results[0].snippet.as_deref(), Some("A useful snippet."));
}
#[test]
fn scoped_ref_prefix_is_session_specific() {
reset_web_run_state();
+265 -19
View File
@@ -1,13 +1,13 @@
//! 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, required_str,
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use regex::Regex;
use serde::Serialize;
use serde_json::{Value, json};
@@ -18,6 +18,9 @@ use std::time::Duration;
static TITLE_RE: OnceLock<Regex> = OnceLock::new();
static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
static TAG_RE: OnceLock<Regex> = OnceLock::new();
static BING_RESULT_RE: OnceLock<Regex> = OnceLock::new();
static BING_TITLE_RE: OnceLock<Regex> = OnceLock::new();
static BING_SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
fn get_title_re() -> &'static Regex {
TITLE_RE.get_or_init(|| {
@@ -39,6 +42,27 @@ fn get_tag_re() -> &'static Regex {
TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex pattern is valid"))
}
fn get_bing_result_re() -> &'static Regex {
BING_RESULT_RE.get_or_init(|| {
Regex::new(r#"(?is)<li[^>]*class=\"[^\"]*\bb_algo\b[^\"]*\"[^>]*>(.*?)</li>"#)
.expect("bing result regex pattern is valid")
})
}
fn get_bing_title_re() -> &'static Regex {
BING_TITLE_RE.get_or_init(|| {
Regex::new(r#"(?is)<h2[^>]*>.*?<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>"#)
.expect("bing title regex pattern is valid")
})
}
fn get_bing_snippet_re() -> &'static Regex {
BING_SNIPPET_RE.get_or_init(|| {
Regex::new(r#"(?is)<div[^>]*class=\"[^\"]*\bb_caption\b[^\"]*\"[^>]*>.*?<p[^>]*>(.*?)</p>"#)
.expect("bing snippet regex pattern is valid")
})
}
const DEFAULT_MAX_RESULTS: usize = 5;
const MAX_RESULTS: usize = 10;
const DEFAULT_TIMEOUT_MS: u64 = 15_000;
@@ -69,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 {
@@ -78,7 +102,23 @@ impl ToolSpec for WebSearchTool {
"properties": {
"query": {
"type": "string",
"description": "Search query"
"description": "Search query. Compatibility aliases: q, or search_query[0].q."
},
"q": {
"type": "string",
"description": "Search query."
},
"search_query": {
"type": "array",
"description": "Array form for advanced queries: [{\"q\":\"...\", \"max_results\": 5}]",
"items": {
"type": "object",
"properties": {
"q": { "type": "string" },
"query": { "type": "string" },
"max_results": { "type": "integer" }
}
}
},
"max_results": {
"type": "integer",
@@ -88,8 +128,7 @@ impl ToolSpec for WebSearchTool {
"type": "integer",
"description": "Timeout in milliseconds (default: 15000, max: 60000)"
}
},
"required": ["query"]
}
})
}
@@ -102,17 +141,13 @@ impl ToolSpec for WebSearchTool {
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
let query = required_str(&input, "query")?.trim().to_string();
let query = extract_search_query(&input)?;
if query.is_empty() {
return Err(ToolError::invalid_input("Query cannot be empty"));
}
let max_results = usize::try_from(optional_u64(
&input,
"max_results",
DEFAULT_MAX_RESULTS as u64,
))
.unwrap_or(DEFAULT_MAX_RESULTS)
.clamp(1, MAX_RESULTS);
let max_results =
usize::try_from(optional_search_max_results(&input)).unwrap_or(DEFAULT_MAX_RESULTS);
let max_results = max_results.clamp(1, MAX_RESULTS);
let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000);
let client = reqwest::Client::builder()
@@ -149,16 +184,45 @@ impl ToolSpec for WebSearchTool {
)));
}
let results = parse_duckduckgo_results(&body, max_results);
let mut results = parse_duckduckgo_results(&body, max_results);
let mut source = "duckduckgo".to_string();
let mut message_suffix = None;
if results.is_empty() {
let duckduckgo_blocked = is_duckduckgo_challenge(&body);
match run_bing_search(&client, &query, max_results).await {
Ok(fallback_results) if !fallback_results.is_empty() => {
results = fallback_results;
source = "bing".to_string();
message_suffix = Some(if duckduckgo_blocked {
"DuckDuckGo returned a bot challenge; used Bing fallback"
} else {
"DuckDuckGo returned no parseable results; used Bing fallback"
});
}
Ok(_) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(
"DuckDuckGo returned a bot challenge and Bing fallback returned no results",
));
}
Err(err) if duckduckgo_blocked => {
return Err(ToolError::execution_failed(format!(
"DuckDuckGo returned a bot challenge and Bing fallback failed: {err}"
)));
}
Ok(_) | Err(_) => {}
}
}
let message = if results.is_empty() {
"No results found".to_string()
} else if let Some(suffix) = message_suffix {
format!("Found {} result(s). {suffix}", results.len())
} else {
format!("Found {} result(s)", results.len())
};
let response = WebSearchResponse {
query,
source: "duckduckgo".to_string(),
source,
count: results.len(),
message,
results,
@@ -168,6 +232,91 @@ impl ToolSpec for WebSearchTool {
}
}
fn extract_search_query(input: &Value) -> Result<String, ToolError> {
for key in ["query", "q"] {
if let Some(value) = input.get(key) {
let Some(query) = value.as_str() else {
return Err(ToolError::invalid_input(format!(
"Field '{key}' must be a string"
)));
};
let query = query.trim();
if !query.is_empty() {
return Ok(query.to_string());
}
}
}
for item in search_query_items(input) {
for key in ["q", "query"] {
if let Some(value) = item.get(key) {
let Some(query) = value.as_str() else {
return Err(ToolError::invalid_input(format!(
"Field 'search_query[].{key}' must be a string"
)));
};
let query = query.trim();
if !query.is_empty() {
return Ok(query.to_string());
}
}
}
}
Err(ToolError::missing_field("query"))
}
fn optional_search_max_results(input: &Value) -> u64 {
if let Some(value) = input.get("max_results").and_then(Value::as_u64) {
return value;
}
search_query_items(input)
.filter_map(|item| item.get("max_results").and_then(Value::as_u64))
.next()
.unwrap_or(DEFAULT_MAX_RESULTS as u64)
}
fn search_query_items(input: &Value) -> impl Iterator<Item = &Value> {
input
.get("search_query")
.and_then(Value::as_array)
.into_iter()
.flat_map(|items| items.iter())
}
async fn run_bing_search(
client: &reqwest::Client,
query: &str,
max_results: usize,
) -> Result<Vec<WebSearchEntry>, ToolError> {
let encoded = url_encode(query);
let url = format!("https://www.bing.com/search?q={encoded}");
let resp = client
.get(&url)
.header(
"Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header("Accept-Language", "en-US,en;q=0.9")
.send()
.await
.map_err(|e| ToolError::execution_failed(format!("Bing fallback request failed: {e}")))?;
let status = resp.status();
let body = resp.text().await.map_err(|e| {
ToolError::execution_failed(format!("Failed to read Bing fallback response: {e}"))
})?;
if !status.is_success() {
return Err(ToolError::execution_failed(format!(
"Bing fallback failed: HTTP {}",
status.as_u16()
)));
}
Ok(parse_bing_results(&body, max_results))
}
fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
let title_re = get_title_re();
let snippet_re = get_snippet_re();
@@ -204,6 +353,44 @@ fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<WebSearchEntr
results
}
fn is_duckduckgo_challenge(html: &str) -> bool {
html.contains("anomaly-modal") || html.contains("Unfortunately, bots use DuckDuckGo too")
}
fn parse_bing_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
let mut results = Vec::new();
for cap in get_bing_result_re().captures_iter(html) {
if results.len() >= max_results {
break;
}
let Some(block) = cap.get(1).map(|m| m.as_str()) else {
continue;
};
let Some(title_cap) = get_bing_title_re().captures(block) else {
continue;
};
let href = title_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let title_raw = title_cap.get(2).map(|m| m.as_str()).unwrap_or("");
let title = normalize_text(title_raw);
if title.is_empty() {
continue;
}
let snippet = get_bing_snippet_re()
.captures(block)
.and_then(|snippet_cap| snippet_cap.get(1))
.map(|m| normalize_text(m.as_str()))
.filter(|s| !s.is_empty());
results.push(WebSearchEntry {
title,
url: normalize_bing_url(href),
snippet,
});
}
results
}
fn normalize_url(href: &str) -> String {
if let Some(uddg) = extract_query_param(href, "uddg") {
let decoded = percent_decode(&uddg);
@@ -220,6 +407,30 @@ fn normalize_url(href: &str) -> String {
href.to_string()
}
fn normalize_bing_url(href: &str) -> String {
if let Some(encoded) = extract_query_param(href, "u") {
let decoded = percent_decode(&encoded);
let token = decoded.strip_prefix("a1").unwrap_or(&decoded);
let mut padded = token.replace('-', "+").replace('_', "/");
while !padded.len().is_multiple_of(4) {
padded.push('=');
}
if let Ok(bytes) = general_purpose::STANDARD.decode(padded)
&& let Ok(url) = String::from_utf8(bytes)
&& (url.starts_with("http://") || url.starts_with("https://"))
{
return url;
}
}
if href.starts_with("//") {
return format!("https:{href}");
}
if href.starts_with('/') {
return format!("https://www.bing.com{href}");
}
href.to_string()
}
fn normalize_text(text: &str) -> String {
let stripped = strip_html_tags(text);
let decoded = decode_html_entities(&stripped);
@@ -278,3 +489,38 @@ fn extract_query_param(url: &str, key: &str) -> Option<String> {
}
None
}
#[cfg(test)]
mod tests {
use super::{extract_search_query, optional_search_max_results};
use serde_json::json;
#[test]
fn extract_search_query_accepts_legacy_query() {
let query =
extract_search_query(&json!({"query": " deepseek v4 "})).expect("query should parse");
assert_eq!(query, "deepseek v4");
}
#[test]
fn extract_search_query_accepts_q_alias() {
let query =
extract_search_query(&json!({"q": "deepseek v4 pro"})).expect("q alias should parse");
assert_eq!(query, "deepseek v4 pro");
}
#[test]
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("array form should parse");
assert_eq!(query, "deepseek api");
assert_eq!(optional_search_max_results(&input), 3);
}
#[test]
fn extract_search_query_rejects_missing_query() {
let err = extract_search_query(&json!({"max_results": 2}))
.expect_err("missing query should fail");
assert!(format!("{err}").contains("missing required field 'query'"));
}
}
+199 -18
View File
@@ -1,7 +1,7 @@
//! Application state for the `DeepSeek` TUI.
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Instant;
use ratatui::layout::Rect;
@@ -9,10 +9,12 @@ use serde_json::Value;
use thiserror::Error;
use crate::compaction::CompactionConfig;
use crate::config::{Config, has_api_key, save_api_key};
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;
@@ -49,6 +51,81 @@ pub enum AppMode {
Plan,
}
/// DeepSeek reasoning-effort tier, mirrored on ChatGPT/Claude effort pickers.
///
/// The config file accepts all five string values for forward-compat with
/// providers that expose the full spectrum; DeepSeek currently collapses
/// `Low`/`Medium` → `high` and `Max` → `max` at the API boundary. The
/// keyboard cycler (Shift+Tab) walks only the three behaviorally distinct
/// tiers: `Off` → `High` → `Max` → `Off`.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
Off,
Low,
Medium,
High,
#[default]
Max,
}
impl ReasoningEffort {
/// Parse a config-file string into an effort tier. Unknown values fall
/// back to the default (`Max`) rather than erroring out.
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" | "none" | "false" => Self::Off,
"low" | "minimal" => Self::Low,
"medium" | "mid" => Self::Medium,
"high" => Self::High,
"max" | "maximum" | "xhigh" => Self::Max,
_ => Self::default(),
}
}
/// Canonical lowercase label used for config storage and UI hints.
#[must_use]
pub fn as_setting(self) -> &'static str {
match self {
Self::Off => "off",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Max => "max",
}
}
/// Short label for the header chip.
#[must_use]
pub fn short_label(self) -> &'static str {
match self {
Self::Off => "off",
Self::Low => "low",
Self::Medium => "med",
Self::High => "high",
Self::Max => "max",
}
}
/// Value forwarded to the engine/client. `None` means "provider default"
/// (for `Off` we still emit `"off"` so the client can inject
/// `thinking = {"type": "disabled"}`).
#[must_use]
pub fn api_value(self) -> Option<&'static str> {
Some(self.as_setting())
}
/// Cycle through the three behaviorally distinct tiers.
#[must_use]
pub fn cycle_next(self) -> Self {
match self {
Self::Off => Self::High,
Self::Low | Self::Medium | Self::High => Self::Max,
Self::Max => Self::Off,
}
}
}
/// Sidebar content focus mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarFocus {
@@ -241,6 +318,8 @@ pub struct TuiOptions {
pub allow_shell: bool,
/// Use the alternate screen buffer (fullscreen TUI).
pub use_alt_screen: bool,
/// Capture mouse input for internal scrolling/selection.
pub use_mouse_capture: bool,
/// Maximum number of concurrent sub-agents.
pub max_subagents: usize,
#[allow(dead_code)]
@@ -302,9 +381,17 @@ pub struct App {
/// Last status text already promoted from `status_message` into toast state.
pub last_status_message_seen: Option<String>,
pub model: String,
/// Current API provider (mirrors `Config::api_provider`).
/// Updated by `/provider` switches so the UI/commands can read the
/// active backend without re-deriving it from the live config.
pub api_provider: ApiProvider,
/// Current reasoning-effort tier for DeepSeek thinking mode.
/// Cycled via Shift+Tab; initialized from config at startup.
pub reasoning_effort: ReasoningEffort,
pub workspace: PathBuf,
pub skills_dir: PathBuf,
pub use_alt_screen: bool,
pub use_mouse_capture: bool,
#[allow(dead_code)]
pub system_prompt: Option<SystemPrompt>,
pub input_history: Vec<String>,
@@ -415,6 +502,14 @@ pub struct App {
pub last_prompt_tokens: Option<u32>,
/// Last completion token usage
pub last_completion_tokens: Option<u32>,
/// DeepSeek context-cache hit tokens from the last API call.
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.
@@ -427,10 +522,10 @@ pub struct App {
pub thinking_started_at: Option<Instant>,
/// Whether context compaction is currently in progress.
pub is_compacting: bool,
/// Plain-language session coherence state for the footer.
pub coherence_state: CoherenceState,
/// Timestamp of the last user message send (for brief visual feedback).
pub last_send_at: Option<Instant>,
/// Cached footer clock label so idle sessions still repaint when the minute changes.
pub footer_clock_label: String,
}
/// Message queued while the engine is busy.
@@ -466,6 +561,7 @@ impl QueuedMessage {
}
}
#[allow(dead_code)] // Tests and queue helpers use the display-only form; send path resolves @mentions.
pub fn content(&self) -> String {
if let Some(skill_instruction) = self.skill_instruction.as_ref() {
format!(
@@ -501,6 +597,7 @@ impl App {
workspace,
allow_shell,
use_alt_screen,
use_mouse_capture,
max_subagents,
skills_dir: global_skills_dir,
memory_path: _,
@@ -530,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);
@@ -595,9 +693,16 @@ impl App {
sticky_status: None,
last_status_message_seen: None,
model,
api_provider: config.api_provider(),
reasoning_effort: config
.reasoning_effort()
.map_or_else(ReasoningEffort::default, |s| {
ReasoningEffort::from_setting(s)
}),
workspace,
skills_dir,
use_alt_screen,
use_mouse_capture,
system_prompt: None,
input_history: Vec::new(),
history_index: None,
@@ -675,14 +780,17 @@ impl App {
runtime_turn_status: None,
last_prompt_tokens: None,
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(),
needs_redraw: true,
thinking_started_at: None,
is_compacting: false,
coherence_state: CoherenceState::default(),
last_send_at: None,
footer_clock_label: chrono::Local::now().format("%H:%M").to_string(),
}
}
@@ -766,6 +874,7 @@ impl App {
}
/// Cycle through modes in reverse: YOLO -> Agent -> Plan
#[allow(dead_code)]
pub fn cycle_mode_reverse(&mut self) {
let next = match self.mode {
AppMode::Agent => AppMode::Plan,
@@ -775,6 +884,18 @@ impl App {
let _ = self.set_mode(next);
}
/// Cycle reasoning-effort through the three behaviorally distinct tiers:
/// `Off` → `High` → `Max` → `Off`.
pub fn cycle_effort(&mut self) {
self.reasoning_effort = self.reasoning_effort.cycle_next();
self.needs_redraw = true;
self.push_status_toast(
format!("Thinking: {}", self.reasoning_effort.short_label()),
StatusToastLevel::Info,
Some(1_500),
);
}
/// Execute hooks for a specific event with the given context
pub fn execute_hooks(&self, event: HookEvent, context: &HookContext) -> Vec<HookResult> {
self.hooks.execute(event, context)
@@ -959,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;
@@ -1004,6 +1134,31 @@ impl App {
self.paste_burst.clear_after_explicit_paste();
}
pub fn insert_media_attachment(&mut self, kind: &str, path: &Path, description: Option<&str>) {
let reference = media_attachment_reference(kind, path, description);
let cursor = self.cursor_position.min(char_count(&self.input));
let byte_index = byte_index_at_char(&self.input, cursor);
let needs_prefix_newline = self.input[..byte_index]
.chars()
.last()
.is_some_and(|ch| !ch.is_whitespace());
let needs_suffix_newline = self.input[byte_index..]
.chars()
.next()
.is_some_and(|ch| !ch.is_whitespace());
let mut inserted = String::new();
if needs_prefix_newline {
inserted.push('\n');
}
inserted.push_str(&reference);
if needs_suffix_newline || self.input[byte_index..].is_empty() {
inserted.push('\n');
}
self.insert_str(&inserted);
self.paste_burst.clear_after_explicit_paste();
}
pub fn flush_paste_burst_if_due(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(text) => {
@@ -1057,11 +1212,8 @@ impl App {
self.insert_paste_text(&text);
}
ClipboardContent::Image { path, description } => {
// Insert image path reference
let reference = format!("[Image: {} at {}]", description, path.display());
self.insert_str(&reference);
self.paste_burst.clear_after_explicit_paste();
self.status_message = Some(format!("Pasted image: {}", path.display()));
self.insert_media_attachment("image", &path, Some(&description));
self.status_message = Some(format!("Attached image: {}", path.display()));
}
}
}
@@ -1245,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 {
@@ -1259,6 +1414,19 @@ impl App {
}
}
pub fn media_attachment_reference(kind: &str, path: &Path, description: Option<&str>) -> String {
match description {
Some(description) if !description.trim().is_empty() => {
format!(
"[Attached {kind}: {} at {}]",
description.trim(),
path.display()
)
}
_ => format!("[Attached {kind}: {}]", path.display()),
}
}
// === Actions ===
/// Actions emitted by the UI event loop.
@@ -1276,9 +1444,19 @@ pub enum AppAction {
workspace: PathBuf,
},
OpenConfigView,
/// Open the `/model` two-pane picker (Pro/Flash + Off/High/Max).
OpenModelPicker,
SendMessage(String),
ListSubAgents,
FetchModels,
/// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without
/// restarting the process. The runtime rebuilds its API client from
/// the updated config. `model` overrides the post-switch model
/// (already normalized but not yet provider-prefixed).
SwitchProvider {
provider: ApiProvider,
model: Option<String>,
},
UpdateCompaction(CompactionConfig),
CompactContext,
TaskAdd {
@@ -1305,6 +1483,7 @@ mod tests {
workspace: PathBuf::from("."),
allow_shell: yolo,
use_alt_screen: true,
use_mouse_capture: false,
max_subagents: 1,
skills_dir: PathBuf::from("."),
memory_path: PathBuf::from("memory.md"),
@@ -1540,11 +1719,13 @@ mod tests {
#[test]
fn test_update_model_compaction_budget() {
let mut app = App::new(test_options(false), &Config::default());
app.model = "unknown-test-model".to_string();
app.update_model_compaction_budget();
let initial_threshold = app.compact_threshold;
app.model = "deepseek-reasoner".to_string();
app.model = "deepseek-v3.2-128k".to_string();
app.update_model_compaction_budget();
// Threshold may have changed based on model
// deepseek-reasoner has 128k context, so threshold should be higher
// Explicit 128k DeepSeek model IDs have a higher threshold than unknown models.
assert!(app.compact_threshold >= initial_threshold);
}
+1 -1
View File
@@ -91,7 +91,7 @@ fn save_image_to_workspace(workspace: &Path, image: &ImageData) -> Result<PathBu
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
.as_millis();
let path = dir.join(format!("clipboard-{timestamp}.ppm"));
let mut data = Vec::with_capacity((image.width * image.height * 3) + 64);
+15 -1
View File
@@ -207,7 +207,6 @@ fn command_runs_directly(name: &str) -> bool {
"help"
| "clear"
| "exit"
| "model"
| "models"
| "queue"
| "subagents"
@@ -724,6 +723,21 @@ mod tests {
assert!(!command_labels.contains(&"/deepseek"));
}
#[test]
fn command_palette_inserts_model_command_for_argument_entry() {
let entries = build_entries(Path::new("."), Path::new("."));
let model = entries
.iter()
.find(|entry| entry.section == PaletteSection::Command && entry.label == "/model")
.expect("model command entry");
assert_eq!(model.command, "/model ");
assert!(matches!(
&model.action,
CommandPaletteAction::InsertText { text } if text == "/model "
));
}
#[test]
fn command_palette_emits_actions_not_raw_insertions() {
let entries = vec![CommandPaletteEntry {
+141 -24
View File
@@ -8,6 +8,7 @@ use ratatui::text::{Line, Span};
use serde_json::Value;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::deepseek_theme::active_theme;
use crate::models::{ContentBlock, Message};
use crate::palette;
use crate::tools::review::ReviewOutput;
@@ -1187,7 +1188,7 @@ fn render_thinking(
lines.push(Line::from(vec![
Span::styled("▏ ", Style::default().fg(thinking_state_accent(state))),
Span::styled(
"summary only; press v for details",
"thinking collapsed; press v for full text",
Style::default().fg(palette::TEXT_MUTED).italic(),
),
]));
@@ -1432,11 +1433,9 @@ fn status_symbol(started_at: Option<Instant>, status: ToolStatus, low_motion: bo
|t| t.elapsed().as_millis(),
);
let cycle = u128::from(TOOL_STATUS_SYMBOL_MS);
let idx = if cycle == 0 {
0
} else {
(elapsed_ms / cycle) % (TOOL_RUNNING_SYMBOLS.len() as u128)
};
let idx = elapsed_ms
.checked_div(cycle)
.map_or(0, |d| d % (TOOL_RUNNING_SYMBOLS.len() as u128));
TOOL_RUNNING_SYMBOLS[usize::try_from(idx).unwrap_or_default()].to_string()
}
ToolStatus::Success => TOOL_DONE_SYMBOL.to_string(),
@@ -1540,29 +1539,19 @@ fn render_card_detail_line(
}
fn tool_title_style() -> Style {
Style::default()
.fg(palette::TEXT_SOFT)
.add_modifier(Modifier::BOLD)
active_theme().tool_title_style()
}
fn tool_status_style(status: ToolStatus) -> Style {
Style::default().fg(match status {
ToolStatus::Running => palette::ACCENT_TOOL_LIVE,
ToolStatus::Success => palette::TEXT_DIM,
ToolStatus::Failed => palette::ACCENT_TOOL_ISSUE,
})
active_theme().tool_status_style(status)
}
fn tool_detail_label_style() -> Style {
Style::default().fg(palette::TEXT_DIM)
active_theme().tool_label_style()
}
fn tool_state_color(status: ToolStatus) -> Color {
match status {
ToolStatus::Running => palette::ACCENT_TOOL_LIVE,
ToolStatus::Success => palette::TEXT_DIM,
ToolStatus::Failed => palette::ACCENT_TOOL_ISSUE,
}
active_theme().tool_status_color(status)
}
fn tool_status_label(status: ToolStatus) -> &'static str {
@@ -1574,7 +1563,7 @@ fn tool_status_label(status: ToolStatus) -> &'static str {
}
fn tool_value_style() -> Style {
Style::default().fg(palette::TEXT_MUTED)
active_theme().tool_value_style()
}
fn thinking_visual_state(streaming: bool, duration_secs: Option<f32>) -> ThinkingVisualState {
@@ -1632,9 +1621,12 @@ fn thinking_state_accent(state: ThinkingVisualState) -> Color {
#[cfg(test)]
mod tests {
use super::{
ExecCell, ExecSource, HistoryCell, TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS, ToolCell,
ToolStatus, TranscriptRenderOptions, extract_reasoning_summary, render_thinking,
ExecCell, ExecSource, HistoryCell, PlanStep, PlanUpdateCell, TOOL_RUNNING_SYMBOLS,
TOOL_STATUS_SYMBOL_MS, ToolCell, ToolStatus, TranscriptRenderOptions,
extract_reasoning_summary, render_thinking,
};
use crate::deepseek_theme::Theme;
use ratatui::style::Modifier;
use std::time::{Duration, Instant};
#[test]
@@ -1665,7 +1657,7 @@ mod tests {
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect::<String>();
assert!(text.contains("summary only; press v for details"));
assert!(text.contains("thinking collapsed; press v for full text"));
assert!(text.contains("thinking"));
}
@@ -1704,4 +1696,129 @@ mod tests {
// The animated path should be on a different frame (index 2).
assert_ne!(animated_symbol, TOOL_RUNNING_SYMBOLS[0]);
}
// === Theme parity tests ===
//
// These lock the visible color/style choices for one plan cell and one
// tool cell against `deepseek_theme::Theme::dark()`. The render path is
// unchanged in shape; the assertions just guarantee a future skin swap
// (or accidental drift) is caught here instead of at runtime.
#[test]
fn plan_update_cell_renders_with_dark_theme_tokens() {
let theme = Theme::dark();
let cell = PlanUpdateCell {
explanation: None,
steps: vec![
PlanStep {
step: "scan repo".to_string(),
status: "completed".to_string(),
},
PlanStep {
step: "extract theme".to_string(),
status: "in_progress".to_string(),
},
PlanStep {
step: "land tests".to_string(),
status: "pending".to_string(),
},
],
status: ToolStatus::Running,
};
let lines = cell.lines_with_motion(80, true);
// Header: "<symbol> Plan <state>"
let header = &lines[0];
let symbol_span = &header.spans[0];
let title_span = &header.spans[1];
let state_span = &header.spans[3];
assert_eq!(
symbol_span.style.fg,
Some(theme.tool_running_accent),
"running header symbol should use the dark theme running accent"
);
assert_eq!(
title_span.content.as_ref(),
"Plan",
"tool title text is locked"
);
assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
assert!(
title_span.style.add_modifier.contains(Modifier::BOLD),
"tool title should be bold"
);
assert_eq!(
state_span.content.as_ref(),
"running",
"running PlanUpdate should label state as 'running'"
);
assert_eq!(state_span.style.fg, Some(theme.tool_running_accent));
// Each step row: ["▏ ", "<marker>:", " ", "<step>"]
let step_line = &lines[1];
let label_span = &step_line.spans[1];
let value_span = &step_line.spans[3];
assert_eq!(
label_span.style.fg,
Some(theme.tool_label_color),
"step label should use theme.tool_label_color"
);
assert_eq!(
value_span.style.fg,
Some(theme.tool_value_color),
"step value should use theme.tool_value_color"
);
// Plain content stays identical so visible output does not move.
let visible = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>();
assert_eq!(visible[1].trim_end(), "▏ done: scan repo");
assert_eq!(visible[2].trim_end(), "▏ live: extract theme");
assert_eq!(visible[3].trim_end(), "▏ next: land tests");
}
#[test]
fn exec_cell_failed_status_renders_with_dark_theme_tokens() {
let theme = Theme::dark();
let cell = ExecCell {
command: "false".to_string(),
status: ToolStatus::Failed,
output: Some("boom".to_string()),
started_at: None,
duration_ms: Some(42),
source: ExecSource::Assistant,
interaction: None,
};
let lines = cell.lines_with_motion(80, true);
let header = &lines[0];
let symbol_span = &header.spans[0];
let title_span = &header.spans[1];
let state_span = &header.spans[3];
assert_eq!(
symbol_span.style.fg,
Some(theme.tool_failed_accent),
"failed exec header symbol should use the dark theme failed accent"
);
assert_eq!(
title_span.content.as_ref(),
"Shell",
"exec title text is locked"
);
assert_eq!(title_span.style.fg, Some(theme.tool_title_color));
assert!(title_span.style.add_modifier.contains(Modifier::BOLD));
assert_eq!(state_span.content.as_ref(), "issue");
assert_eq!(state_span.style.fg, Some(theme.tool_failed_accent));
}
}
+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"]);
}
}
+134 -37
View File
@@ -3,7 +3,7 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap};
use crate::palette;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
@@ -24,6 +24,77 @@ const PLAN_OPTIONS: [(&str, &str); 4] = [
),
];
fn modal_block() -> Block<'static> {
Block::default()
.title(Line::from(vec![Span::styled(
" Plan Confirmation ",
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::DEEPSEEK_INK))
.padding(Padding::uniform(1))
}
fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) {
let shadow_x = popup_area.x.saturating_add(1);
let shadow_y = popup_area.y.saturating_add(1);
let shadow_right = area.x.saturating_add(area.width);
let shadow_bottom = area.y.saturating_add(area.height);
let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x));
let shadow_height = popup_area
.height
.min(shadow_bottom.saturating_sub(shadow_y));
if shadow_width > 0 && shadow_height > 0 {
Block::default()
.style(Style::default().bg(palette::DEEPSEEK_NAVY))
.render(
Rect {
x: shadow_x,
y: shadow_y,
width: shadow_width,
height: shadow_height,
},
buf,
);
}
Clear.render(popup_area, buf);
}
fn push_option_lines(
lines: &mut Vec<Line<'static>>,
selected: bool,
number: usize,
label: &str,
description: &str,
) {
let row_style = if selected {
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
.bold()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let detail_style = if selected {
row_style
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let prefix = if selected { ">" } else { " " };
lines.push(Line::from(Span::styled(
format!("{prefix} {number}) {label}"),
row_style,
)));
lines.push(Line::from(Span::styled(
format!(" {description}"),
detail_style,
)));
}
#[derive(Debug, Clone, Default)]
pub struct PlanPromptView {
selected: usize,
@@ -115,56 +186,45 @@ impl ModalView for PlanPromptView {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![Span::styled(
"Plan ready. Confirm next step:",
"Action required",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]));
lines.push(Line::from(vec![Span::styled(
"Choose what should happen after this plan.",
Style::default().fg(palette::TEXT_PRIMARY).bold(),
)]));
lines.push(Line::from(""));
for (idx, (label, description)) in PLAN_OPTIONS.iter().enumerate() {
let selected = self.selected == idx;
let prefix = if selected { ">" } else { " " };
let number = idx + 1;
let style = if selected {
Style::default()
.fg(palette::DEEPSEEK_SKY)
.bg(palette::SELECTION_BG)
.bold()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
lines.push(Line::from(vec![
Span::raw(format!("{prefix} {number}) ")),
Span::styled((*label).to_string(), style),
Span::raw(" — "),
Span::styled(
(*description).to_string(),
Style::default().fg(palette::TEXT_MUTED),
),
]));
push_option_lines(&mut lines, self.selected == idx, number, label, description);
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"1-4 / a / y / r / q = quick pick, Up/Down=select, Enter=confirm, Esc=close",
Style::default().fg(palette::TEXT_MUTED),
)));
lines.push(Line::from(vec![
Span::styled(
"1-4 / a / y / r / q",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
),
Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" close", Style::default().fg(palette::TEXT_MUTED)),
]));
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Line::from(vec![Span::styled(
" Plan Confirmation ",
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
)]))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::BORDER_COLOR))
.style(Style::default().bg(palette::DEEPSEEK_INK))
.padding(Padding::uniform(1)),
);
.block(modal_block());
let popup_area = centered_rect(66, 42, area);
let popup_area = centered_rect(72, 52, area);
render_modal_chrome(area, popup_area, buf);
paragraph.render(popup_area, buf);
}
}
@@ -188,3 +248,40 @@ fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
])
.split(popup_layout[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
fn render_view(view: &PlanPromptView, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
(0..height)
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn plan_prompt_calls_out_required_action_and_controls() {
let rendered = render_view(&PlanPromptView::new(), 110, 36);
assert!(rendered.contains("Action required"));
assert!(rendered.contains("Choose what should happen after this plan."));
assert!(rendered.contains("1-4"));
assert!(rendered.contains("Enter"));
}
#[test]
fn plan_prompt_keeps_selected_option_and_description_together() {
let mut view = PlanPromptView::new();
view.selected = 1;
let rendered = render_view(&view, 110, 36);
assert!(rendered.contains("> 2) Accept plan (YOLO)"));
assert!(rendered.contains("Start implementation in YOLO mode (auto-approve)"));
}
}
+3 -3
View File
@@ -83,14 +83,14 @@ impl SessionPickerView {
match self.sort_mode {
SortMode::Recent => {
self.sessions
.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
}
SortMode::Name => {
self.sessions.sort_by(|a, b| a.title.cmp(&b.title));
}
SortMode::Size => {
self.sessions
.sort_by(|a, b| b.message_count.cmp(&a.message_count));
.sort_by_key(|s| std::cmp::Reverse(s.message_count));
}
}
@@ -605,7 +605,7 @@ mod tests {
updated_at: Utc::now(),
message_count: idx + 1,
total_tokens: 100,
model: "deepseek-reasoner".to_string(),
model: "deepseek-v4-pro".to_string(),
workspace: std::path::PathBuf::from("/tmp"),
mode: Some("agent".to_string()),
}
+989 -549
View File
File diff suppressed because it is too large. Load diff
+346 -127
View File
@@ -108,10 +108,11 @@ fn transcript_scroll_percent_is_clamped_and_relative() {
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-reasoner".to_string(),
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"),
@@ -126,6 +127,73 @@ fn create_test_app() -> App {
App::new(options, &Config::default())
}
#[test]
fn file_mentions_add_local_text_context_to_model_payload() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(
tmpdir.path().join("guide.md"),
"# Guide\nUse the fast path.\n",
)
.expect("write file");
let mut app = create_test_app();
app.workspace = tmpdir.path().to_path_buf();
let message = QueuedMessage::new("Summarize @guide.md".to_string(), None);
let content = queued_message_content_for_app(&app, &message);
assert!(content.starts_with("Summarize @guide.md"));
assert!(content.contains("Local context from @mentions:"));
assert!(content.contains("<file mention=\"@guide.md\""));
assert!(content.contains("# Guide\nUse the fast path."));
assert_eq!(message.display, "Summarize @guide.md");
}
#[test]
fn file_mentions_do_not_trigger_inside_email_addresses() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(tmpdir.path().join("example.com"), "not a mention").expect("write file");
let content = user_request_with_file_mentions("email [email protected]", tmpdir.path());
assert_eq!(content, "email [email protected]");
}
#[test]
fn media_file_mentions_point_to_attach_instead_of_inlining_bytes() {
let tmpdir = TempDir::new().expect("tempdir");
std::fs::write(tmpdir.path().join("photo.png"), b"\0png").expect("write image");
let content = user_request_with_file_mentions("inspect @photo.png", tmpdir.path());
assert!(content.contains("<media-file mention=\"@photo.png\""));
assert!(content.contains("Use /attach photo.png"));
assert!(!content.contains("\0png"));
}
#[tokio::test]
async fn model_change_update_syncs_engine_model_before_compaction() {
let mut app = create_test_app();
app.model = "deepseek-v4-flash".to_string();
let compaction = app.compaction_config();
let mut engine = crate::core::engine::mock_engine_handle();
apply_model_and_compaction_update(&engine.handle, compaction).await;
match engine.rx_op.recv().await.expect("set model op") {
crate::core::ops::Op::SetModel { model } => {
assert_eq!(model, "deepseek-v4-flash");
}
other => panic!("expected SetModel, got {other:?}"),
}
match engine.rx_op.recv().await.expect("set compaction op") {
crate::core::ops::Op::SetCompaction { config } => {
assert_eq!(config.model, "deepseek-v4-flash");
}
other => panic!("expected SetCompaction, got {other:?}"),
}
}
fn init_git_repo() -> TempDir {
let dir = tempfile::tempdir().expect("tempdir");
@@ -242,59 +310,6 @@ fn running_agent_count_unions_cache_and_progress() {
assert_eq!(running_agent_count(&app), 2);
}
#[test]
fn compute_status_layout_reserves_extra_rows_for_active_state() {
let app = create_test_app();
let baseline = compute_status_layout(&app, 30, 3);
assert_eq!(baseline.status_height, 1);
let mut with_agents = create_test_app();
with_agents
.agent_progress
.insert("agent_a".to_string(), "running".to_string());
let active = compute_status_layout(&with_agents, 30, 3);
assert!(active.status_height > baseline.status_height);
}
#[test]
fn status_summary_line_mentions_queue_and_approval_mode() {
let mut app = create_test_app();
app.approval_mode = crate::tui::approval::ApprovalMode::Auto;
app.queue_message(crate::tui::app::QueuedMessage::new(
"queued message".to_string(),
None,
));
let summary = status_summary_line(&app, 120);
let summary_text = summary
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(summary_text.contains("queue 1"));
assert!(summary_text.contains("approvals auto"));
}
#[test]
fn active_agent_rows_prefers_cache_order_and_progress_text() {
let mut app = create_test_app();
app.subagent_cache = vec![
make_subagent("agent_a", crate::tools::subagent::SubAgentStatus::Running),
make_subagent("agent_b", crate::tools::subagent::SubAgentStatus::Running),
];
app.agent_progress
.insert("agent_b".to_string(), "step 2".to_string());
app.agent_progress
.insert("agent_c".to_string(), "queued".to_string());
let rows = active_agent_rows(&app, 3);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].0, "agent_a");
assert!(rows[0].1.contains("objective-agent_a"));
assert_eq!(rows[1].0, "agent_b");
assert_eq!(rows[1].1, "step 2");
assert_eq!(rows[2].0, "agent_c");
}
#[test]
fn reconcile_subagent_activity_state_trims_stale_progress_and_sets_anchor() {
let mut app = create_test_app();
@@ -335,72 +350,139 @@ fn footer_state_label_prefers_compacting_then_thinking() {
assert_eq!(footer_state_label(&app).0, "ready");
app.is_loading = true;
assert_eq!(footer_state_label(&app).0, "thinking");
assert!(footer_state_label(&app).0.starts_with("thinking"));
app.is_compacting = true;
assert_eq!(footer_state_label(&app).0, "compacting");
assert!(footer_state_label(&app).0.starts_with("compacting"));
}
#[test]
fn footer_context_spans_uses_decimal_context_label() {
let full = spans_text(&footer_context_spans(12.34, 32));
assert_eq!(full, "context: 12.3%");
let compact = spans_text(&footer_context_spans(12.34, 6));
assert_eq!(compact, "12.3%");
}
#[test]
fn footer_narrow_status_spans_hides_ready_state_but_shows_activity() {
fn footer_status_line_spans_show_mode_model_and_status() {
let mut app = create_test_app();
assert_eq!(spans_text(&footer_narrow_status_spans(&app, 24)), "agent");
app.model = "deepseek-v4-flash".to_string();
let idle = spans_text(&footer_status_line_spans(&app, 60));
assert!(idle.contains("agent"));
assert!(idle.contains("deepseek-v4-flash"));
assert!(idle.contains("\u{00B7}"));
assert!(!idle.contains("ready"));
app.is_loading = true;
assert_eq!(
spans_text(&footer_narrow_status_spans(&app, 24)),
"agent thinking"
);
app.is_loading = false;
app.is_compacting = true;
assert_eq!(
spans_text(&footer_narrow_status_spans(&app, 24)),
"agent compacting"
);
let active = spans_text(&footer_status_line_spans(&app, 60));
assert!(active.contains("agent"));
assert!(active.contains("deepseek-v4-flash"));
assert!(active.contains("thinking"));
}
#[test]
fn footer_status_line_spans_truncate_long_model_names() {
let mut app = create_test_app();
app.model = "deepseek-reasoner-with-an-extremely-long-model-name".to_string();
app.model = "deepseek-v4-pro-with-an-extremely-long-model-name".to_string();
app.is_loading = true;
let line = spans_text(&footer_status_line_spans(&app, 48));
assert!(line.contains("agent ("));
assert!(line.contains(", thinking)"));
let line = spans_text(&footer_status_line_spans(&app, 40));
assert!(line.contains("..."));
assert!(UnicodeWidthStr::width(line.as_str()) <= 48);
assert!(UnicodeWidthStr::width(line.as_str()) <= 40);
}
#[test]
fn sync_footer_clock_to_marks_redraw_only_when_minute_changes() {
fn footer_coherence_chip_hides_healthy_and_uses_clear_labels() {
let mut app = create_test_app();
app.footer_clock_label = "12:00".to_string();
app.needs_redraw = false;
sync_footer_clock_to(&mut app, "12:00".to_string());
assert_eq!(app.footer_clock_label, "12:00");
assert!(!app.needs_redraw);
app.coherence_state = crate::core::coherence::CoherenceState::Healthy;
assert!(
footer_coherence_spans(&app).is_empty(),
"healthy state should produce no footer chip"
);
sync_footer_clock_to(&mut app, "12:01".to_string());
assert_eq!(app.footer_clock_label, "12:01");
assert!(app.needs_redraw);
let cases = [
(
crate::core::coherence::CoherenceState::GettingCrowded,
"high load",
),
(
crate::core::coherence::CoherenceState::RefreshingContext,
"refreshing context",
),
(
crate::core::coherence::CoherenceState::VerifyingRecentWork,
"verifying",
),
(
crate::core::coherence::CoherenceState::ResettingPlan,
"resetting plan",
),
];
for (state, expected) in cases {
app.coherence_state = state;
assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);
}
}
#[test]
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);
app.last_prompt_cache_hit_tokens = Some(36_000);
app.last_prompt_cache_miss_tokens = Some(12_000);
app.session_cost = 12.34;
let compact = spans_text(&footer_auxiliary_spans(&app, 12));
assert!(compact.contains("cache"));
assert!(!compact.contains('$'));
}
#[test]
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);
app.last_prompt_cache_miss_tokens = Some(12_000);
app.session_cost = 12.34;
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
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]
fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {
let mut app = create_test_app();
app.last_prompt_tokens = Some(320_000);
app.last_prompt_tokens = Some(1_200_000);
app.api_messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
@@ -411,16 +493,63 @@ fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {
let (used, max, percent) =
context_usage_snapshot(&app).expect("context usage should be available");
assert_eq!(max, 128_000);
assert_eq!(max, 1_000_000);
assert!(used > 0);
assert!(used <= i64::from(max));
assert!(percent < 100.0);
}
#[test]
fn context_usage_snapshot_prefers_estimate_when_reported_is_inflated_by_old_reasoning() {
let mut app = create_test_app();
app.last_prompt_tokens = Some(980_000);
app.api_messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "small current context".to_string(),
cache_control: None,
}],
}];
let (used, max, percent) =
context_usage_snapshot(&app).expect("context usage should be available");
assert_eq!(max, 1_000_000);
assert!(used < 10_000);
assert!(percent < 2.0);
}
#[test]
fn context_usage_snapshot_prefers_live_estimate_while_loading() {
let mut app = create_test_app();
app.is_loading = true;
app.last_prompt_tokens = Some(128);
app.api_messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "context ".repeat(6_000),
cache_control: None,
}],
}];
let estimated = estimated_context_tokens(&app).expect("estimated context should be available");
let (used, max, percent) =
context_usage_snapshot(&app).expect("context usage should be available");
assert_eq!(used, estimated);
assert_eq!(max, 1_000_000);
assert!(used > i64::from(app.last_prompt_tokens.expect("reported tokens")));
assert!(percent > 0.0);
}
#[test]
fn should_auto_compact_before_send_respects_threshold_and_setting() {
let mut app = create_test_app();
app.last_prompt_tokens = Some(123_000);
app.api_messages = vec![Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: "context ".repeat(400_000),
cache_control: None,
}],
}];
app.auto_compact = true;
assert!(should_auto_compact_before_send(&app));
@@ -611,21 +740,6 @@ fn apply_slash_menu_selection_appends_space_for_arg_commands() {
assert_eq!(app.input, "/model ");
}
#[test]
fn status_layout_budget_preserves_chat_and_composer_on_tiny_heights() {
let mut app = create_test_app();
app.is_loading = true;
for idx in 0..5 {
app.queue_message(crate::tui::app::QueuedMessage::new(
format!("queued message {idx}"),
None,
));
}
let layout = compute_status_layout(&app, 9, 3);
assert_eq!(layout.status_height, 1);
}
#[test]
fn workspace_context_refresh_is_deferred_while_ui_is_busy() {
let repo = init_git_repo();
@@ -761,23 +875,6 @@ fn api_key_validation_warns_without_blocking_unusual_formats() {
));
}
#[test]
fn status_detail_lines_show_queue_draft_when_editing() {
let mut app = create_test_app();
app.queued_draft = Some(crate::tui::app::QueuedMessage::new(
"refine the queued prompt".to_string(),
None,
));
let details = status_detail_lines(&app, 120, 2);
assert!(!details.is_empty());
let text = details[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(text.contains("Editing queued draft"));
}
#[test]
fn jump_to_adjacent_tool_cell_finds_next_and_previous() {
let mut app = create_test_app();
@@ -826,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));
}
+182 -62
View File
@@ -3,7 +3,7 @@
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Rect};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Wrap};
use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap};
use crate::palette;
use crate::tools::user_input::{
@@ -23,6 +23,65 @@ fn modal_block(title: &str) -> Block<'static> {
.padding(Padding::uniform(1))
}
fn render_modal_chrome(area: Rect, popup_area: Rect, buf: &mut Buffer) {
let shadow_x = popup_area.x.saturating_add(1);
let shadow_y = popup_area.y.saturating_add(1);
let shadow_right = area.x.saturating_add(area.width);
let shadow_bottom = area.y.saturating_add(area.height);
let shadow_width = popup_area.width.min(shadow_right.saturating_sub(shadow_x));
let shadow_height = popup_area
.height
.min(shadow_bottom.saturating_sub(shadow_y));
if shadow_width > 0 && shadow_height > 0 {
Block::default()
.style(Style::default().bg(palette::DEEPSEEK_NAVY))
.render(
Rect {
x: shadow_x,
y: shadow_y,
width: shadow_width,
height: shadow_height,
},
buf,
);
}
Clear.render(popup_area, buf);
}
fn push_option_lines(
lines: &mut Vec<Line<'static>>,
selected: bool,
number: usize,
label: String,
description: String,
) {
let row_style = if selected {
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
.bold()
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let detail_style = if selected {
row_style
} else {
Style::default().fg(palette::TEXT_MUTED)
};
let prefix = if selected { ">" } else { " " };
lines.push(Line::from(Span::styled(
format!("{prefix} {number}) {label}"),
row_style,
)));
lines.push(Line::from(Span::styled(
format!(" {description}"),
detail_style,
)));
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputMode {
Selecting,
@@ -197,6 +256,21 @@ impl ModalView for UserInputView {
);
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![Span::styled(
"Action required",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]));
lines.push(Line::from(vec![
Span::styled(
question.header.clone(),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
),
Span::styled(
format!(" Question {} of {}", self.question_index + 1, total),
Style::default().fg(palette::TEXT_MUTED),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
question.question.clone(),
Style::default().fg(palette::TEXT_PRIMARY).bold(),
@@ -204,67 +278,33 @@ impl ModalView for UserInputView {
lines.push(Line::from(""));
for (idx, option) in question.options.iter().enumerate() {
let selected = self.selected == idx;
let prefix = if selected { ">" } else { " " };
let number = idx + 1;
if selected {
// Single span with consistent foreground and background
let content = format!(
"{prefix} {number}) {} - {}",
option.label, option.description
);
lines.push(Line::from(Span::styled(
content,
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
.bold(),
)));
} else {
// Keep original multi‑span formatting
lines.push(Line::from(vec![
Span::raw(format!("{prefix} {number}) ")),
Span::styled(
option.label.clone(),
Style::default().fg(palette::TEXT_PRIMARY),
),
Span::raw(" - "),
Span::styled(
option.description.clone(),
Style::default().fg(palette::TEXT_MUTED),
),
]));
}
push_option_lines(
&mut lines,
self.selected == idx,
number,
option.label.clone(),
option.description.clone(),
);
}
let other_index = question.options.len();
let other_selected = self.selected == other_index;
let other_number = other_index + 1;
if other_selected {
let content = format!("> {other_number}) Other - Provide a custom response");
lines.push(Line::from(Span::styled(
content,
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
.bold(),
)));
} else {
lines.push(Line::from(vec![
Span::raw(format!(" {other_number}) ")),
Span::styled("Other", Style::default().fg(palette::TEXT_PRIMARY)),
Span::raw(" - "),
Span::styled(
"Provide a custom response",
Style::default().fg(palette::TEXT_MUTED),
),
]));
}
push_option_lines(
&mut lines,
self.selected == other_index,
other_number,
"Other".to_string(),
"Type a custom response".to_string(),
);
if self.mode == InputMode::OtherInput {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Other:", Style::default().fg(palette::TEXT_PRIMARY)),
Span::styled(
"> Custom response:",
Style::default().fg(palette::TEXT_PRIMARY).bold(),
),
Span::raw(" "),
Span::styled(
if self.other_input.is_empty() {
@@ -278,22 +318,37 @@ impl ModalView for UserInputView {
}
lines.push(Line::from(""));
let hint = if self.mode == InputMode::OtherInput {
"Enter=submit, Esc=back"
if self.mode == InputMode::OtherInput {
lines.push(Line::from(vec![
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" submit", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" back", Style::default().fg(palette::TEXT_MUTED)),
]));
} else {
"Number keys=quick pick, Up/Down=select, Enter=confirm, Esc=cancel"
};
lines.push(Line::from(Span::styled(
hint,
Style::default().fg(palette::TEXT_MUTED),
)));
lines.push(Line::from(vec![
Span::styled("1-4", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" quick pick", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Up/Down", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" move", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Enter", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" confirm", Style::default().fg(palette::TEXT_MUTED)),
Span::raw(" "),
Span::styled("Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()),
Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)),
]));
}
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.wrap(Wrap { trim: true })
.block(modal_block(&header));
let popup_area = centered_rect(80, 60, area);
let popup_area = centered_rect(82, 68, area);
render_modal_chrome(area, popup_area, buf);
paragraph.render(popup_area, buf);
}
}
@@ -317,3 +372,68 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
.split(popup_layout[1]);
horizontal[1]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::user_input::{UserInputOption, UserInputQuestion, UserInputRequest};
fn render_view(view: &UserInputView, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
(0..height)
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
fn sample_view() -> UserInputView {
UserInputView::new(
"tool-1",
UserInputRequest {
questions: vec![UserInputQuestion {
header: "Confirm".to_string(),
id: "confirm".to_string(),
question: "What should happen next?".to_string(),
options: vec![
UserInputOption {
label: "Ship it".to_string(),
description: "Proceed with the current change set".to_string(),
},
UserInputOption {
label: "Revise it".to_string(),
description: "Return to editing before continuing".to_string(),
},
],
}],
},
)
}
#[test]
fn user_input_modal_calls_out_required_action_and_controls() {
let rendered = render_view(&sample_view(), 110, 36);
assert!(rendered.contains("Action required"));
assert!(rendered.contains("Question 1 of 1"));
assert!(rendered.contains("1-4"));
assert!(rendered.contains("quick pick"));
}
#[test]
fn user_input_modal_renders_custom_response_state() {
let mut view = sample_view();
view.selected = 2;
view.mode = InputMode::OtherInput;
view.other_input = "Need one more pass".to_string();
let rendered = render_view(&view, 110, 36);
assert!(rendered.contains("Custom response"));
assert!(rendered.contains("Need one more pass"));
assert!(rendered.contains("Enter"));
assert!(rendered.contains("submit"));
}
}
+25 -11
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)]
@@ -579,9 +591,7 @@ impl ConfigView {
fn config_hint_for_key(key: &str) -> &'static str {
match key {
"model" => {
"deepseek-chat | deepseek-reasoner | deepseek-* (aliases: deepseek-v3, deepseek-v3.2, deepseek-r1)"
}
"model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-*",
"approval_mode" => "auto | suggest | never",
"auto_compact" | "calm_mode" | "low_motion" | "show_thinking" | "show_tool_details"
| "composer_border" => "on/off, true/false, yes/no, 1/0",
@@ -591,7 +601,7 @@ fn config_hint_for_key(key: &str) -> &'static str {
"sidebar_width" => "10..=50",
"sidebar_focus" => "auto | plan | todos | tasks | agents",
"max_history" => "integer (0 allowed)",
"default_model" => "deepseek-chat | deepseek-reasoner | deepseek-* | none/default",
"default_model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-* | none/default",
_ => "",
}
}
@@ -1044,8 +1054,10 @@ impl ModalView for HelpView {
"=== Clipboard ===",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]),
Line::from(" Ctrl+V - Paste from clipboard (Cmd+V on macOS)"),
Line::from(" Ctrl+V - Paste text or attach clipboard image"),
Line::from(" Ctrl+Shift+C - Copy selection (Cmd+C on macOS)"),
Line::from(" @path - Add local text file or directory context"),
Line::from(" /attach <path> - Attach local image/video media path"),
Line::from(""),
Line::from(vec![Span::styled(
"=== Help ===",
@@ -1057,8 +1069,9 @@ impl ModalView for HelpView {
"=== Mouse ===",
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
)]),
Line::from(" Scroll wheel - Scroll transcript"),
Line::from(" Drag to select - Select text (auto-copies on release)"),
Line::from(" Native drag - Select/copy visible text in your terminal"),
Line::from(" --no-alt-screen - Use terminal scrollback"),
Line::from(" --mouse-capture - Enable wheel + internal selection"),
Line::from(""),
Line::from(vec![Span::styled(
"Mode Cycle:",
@@ -1256,7 +1269,7 @@ impl ModalView for SubAgentsView {
if idx > 0 {
summary.push(Span::raw(" · "));
}
summary.extend(part.into_iter());
summary.extend(part);
}
lines.push(Line::from(summary));
lines.push(Line::from(Span::styled(
@@ -1510,10 +1523,11 @@ mod tests {
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-reasoner".to_string(),
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"),
@@ -1571,7 +1585,7 @@ mod tests {
.expect("editing should remain active after Ctrl+U");
assert!(cleared.buffer.is_empty());
for ch in "deepseek-chat".chars() {
for ch in "deepseek-v4-flash".chars() {
let action = view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
assert!(matches!(action, ViewAction::None));
}
@@ -1584,7 +1598,7 @@ mod tests {
persist,
}) => {
assert_eq!(key, "model");
assert_eq!(value, "deepseek-chat");
assert_eq!(value, "deepseek-v4-flash");
assert!(!persist);
}
other => panic!("expected config update emit, got {other:?}"),
+185 -11
View File
@@ -33,6 +33,14 @@ pub struct HeaderData<'a> {
pub session_cost: f64,
/// Input tokens from the most recent API call (current context utilization).
pub last_prompt_tokens: Option<u32>,
/// 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> {
@@ -55,9 +63,26 @@ impl<'a> HeaderData<'a> {
context_window: None,
session_cost: 0.0,
last_prompt_tokens: None,
reasoning_effort_label: None,
provider_label: None,
}
}
/// Attach a short reasoning-effort label for the header chip.
#[must_use]
pub fn with_reasoning_effort(mut self, label: Option<&'a str>) -> Self {
self.reasoning_effort_label = label;
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(
@@ -140,7 +165,7 @@ impl<'a> HeaderWidget<'a> {
if max <= 0.0 {
return None;
}
Some((used / max * 100.0).clamp(0.0, 999.0))
Some((used / max * 100.0).clamp(0.0, 100.0))
}
fn context_color(percent: f64) -> Color {
@@ -180,10 +205,84 @@ impl<'a> HeaderWidget<'a> {
spans
}
fn status_variant(&self, show_stream_label: bool, show_percent: bool) -> Vec<Span<'static>> {
fn context_percent_spans(&self) -> Vec<Span<'static>> {
let Some(percent) = self.context_percent() else {
return Vec::new();
};
vec![Span::styled(
format!("{percent:.0}%"),
Style::default().fg(Self::context_color(percent)),
)]
}
fn 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();
};
let trimmed = label.trim();
if trimmed.is_empty() {
return Vec::new();
}
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 {
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))]
}
fn status_variant(
&self,
show_stream_label: bool,
show_percent: bool,
show_signal: bool,
) -> 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 || has_provider {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
"●",
Style::default()
@@ -199,7 +298,13 @@ impl<'a> HeaderWidget<'a> {
}
}
let context_spans = self.context_signal_spans(show_percent);
let context_spans = if show_signal {
self.context_signal_spans(show_percent)
} else if show_percent {
self.context_percent_spans()
} else {
Vec::new()
};
if !context_spans.is_empty() {
if !spans.is_empty() {
spans.push(Span::raw(" "));
@@ -212,9 +317,10 @@ impl<'a> HeaderWidget<'a> {
fn right_spans(&self, max_width: usize) -> Vec<Span<'static>> {
let candidates = [
self.status_variant(true, true),
self.status_variant(false, true),
self.status_variant(false, false),
self.status_variant(true, true, true),
self.status_variant(false, true, true),
self.status_variant(false, true, false),
self.status_variant(false, false, true),
];
candidates
@@ -384,7 +490,7 @@ mod tests {
let rendered = render_header(
HeaderData::new(
AppMode::Agent,
"deepseek-v3.2",
"deepseek-v4-pro",
"deepseek-tui",
false,
palette::DEEPSEEK_INK,
@@ -394,7 +500,7 @@ mod tests {
assert!(rendered.contains("Agent"));
assert!(rendered.contains("deepseek-tui"));
assert!(rendered.contains("deepseek-v3.2"));
assert!(rendered.contains("deepseek-v4-pro"));
assert!(!rendered.contains("Plan"));
assert!(!rendered.contains("Yolo"));
}
@@ -404,7 +510,7 @@ mod tests {
let rendered = render_header(
HeaderData::new(
AppMode::Plan,
"deepseek-reasoner",
"deepseek-v4-pro",
"workspace",
true,
palette::DEEPSEEK_INK,
@@ -418,12 +524,27 @@ mod tests {
assert!(rendered.contains("▰"));
}
#[test]
fn narrow_header_keeps_context_percent_visible() {
let rendered = render_header(
HeaderData::new(AppMode::Agent, "", "", true, palette::DEEPSEEK_INK).with_usage(
0,
Some(128_000),
0.0,
Some(48_000),
),
14,
);
assert!(rendered.contains('%'));
}
#[test]
fn narrow_header_falls_back_to_mode_without_rendering_all_modes() {
let rendered = render_header(
HeaderData::new(
AppMode::Yolo,
"deepseek-chat",
"deepseek-v4-flash",
"repo",
true,
palette::DEEPSEEK_INK,
@@ -442,7 +563,7 @@ mod tests {
let rendered = render_header(
HeaderData::new(
AppMode::Agent,
"deepseek-chat",
"deepseek-v4-flash",
"repo",
false,
palette::DEEPSEEK_INK,
@@ -453,4 +574,57 @@ mod tests {
assert!(!rendered.contains('%'));
assert!(!rendered.contains("▰"));
}
#[test]
fn header_caps_context_signal_at_hundred_percent() {
let rendered = render_header(
HeaderData::new(
AppMode::Agent,
"deepseek-v4-flash",
"repo",
false,
palette::DEEPSEEK_INK,
)
.with_usage(1_000, Some(128_000), 0.0, Some(320_000)),
48,
);
assert!(rendered.contains("100%"));
assert!(!rendered.contains("250%"));
}
#[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"));
}
}
+179 -15
View File
@@ -18,7 +18,10 @@ use ratatui::{
prelude::Stylize,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
widgets::{
Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
StatefulWidget, Widget, Wrap,
},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
@@ -29,6 +32,14 @@ const COMPOSER_PANEL_HEIGHT: u16 = 2;
pub struct ChatWidget {
content_area: Rect,
lines: Vec<Line<'static>>,
scrollbar: Option<TranscriptScrollbar>,
}
#[derive(Debug, Clone, Copy)]
struct TranscriptScrollbar {
top: usize,
visible: usize,
total: usize,
}
impl ChatWidget {
@@ -47,6 +58,7 @@ impl ChatWidget {
return Self {
content_area,
lines,
scrollbar: None,
};
}
@@ -105,9 +117,18 @@ impl ChatWidget {
pad_lines_to_bottom(&mut lines, visible_lines);
}
let scrollbar = (total_lines > visible_lines && content_area.width > 1).then_some(
TranscriptScrollbar {
top,
visible: visible_lines,
total: total_lines,
},
);
Self {
content_area,
lines,
scrollbar,
}
}
}
@@ -116,6 +137,21 @@ impl Renderable for ChatWidget {
fn render(&self, _area: Rect, buf: &mut Buffer) {
let paragraph = Paragraph::new(self.lines.clone());
paragraph.render(self.content_area, buf);
if let Some(scrollbar) = self.scrollbar {
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)
.end_symbol(None)
.track_symbol(Some("│"))
.track_style(Style::default().fg(palette::BORDER_COLOR))
.thumb_symbol("┃")
.thumb_style(Style::default().fg(palette::DEEPSEEK_SKY))
.render(self.content_area, buf, &mut state);
}
}
fn desired_height(&self, _width: u16) -> u16 {
@@ -773,7 +809,7 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
let left_padding = usize::from(area.width.saturating_sub(body_width as u16) / 2);
let inset = " ".repeat(left_padding);
let mut body = vec![
let body = vec![
Line::from(Span::styled(
format!("{inset}DeepSeek TUI"),
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
@@ -782,19 +818,8 @@ fn build_empty_state_lines(app: &App, area: Rect) -> Vec<Line<'static>> {
format!("{inset}{workspace_name} · {}", app.model),
Style::default().fg(palette::TEXT_MUTED),
)),
Line::from(""),
];
for line in wrap_text(
"Start in plain language. The transcript stays clear until the first real turn.",
body_width,
) {
body.push(Line::from(Span::styled(
format!("{inset}{line}"),
Style::default().fg(palette::TEXT_PRIMARY),
)));
}
let top_padding = usize::from(area.height.saturating_sub(body.len() as u16) / 3);
let mut lines = Vec::new();
for _ in 0..top_padding {
@@ -1033,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,
@@ -1041,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},
@@ -1051,10 +1078,11 @@ mod tests {
fn create_test_app() -> App {
let options = TuiOptions {
model: "deepseek-chat".to_string(),
model: "deepseek-v4-flash".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"),
@@ -1409,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"
);
}
}
+1 -1
View File
@@ -274,7 +274,7 @@ impl WorkingSet {
.values()
.map(|entry| (entry.path.clone(), score_entry(entry, self.turn)))
.collect();
ranked.sort_by(|a, b| a.1.cmp(&b.1));
ranked.sort_by_key(|a| a.1);
let to_remove = self.entries.len().saturating_sub(max_entries);
for (path, _) in ranked.into_iter().take(to_remove) {
+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());
}
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more