mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6bc351da | ||
|
|
ebdda09c29 | ||
|
|
f42f94207c | ||
|
|
d79178a926 | ||
|
|
bcf6ba9a8e | ||
|
|
7a85f182e2 | ||
|
|
f7fe5e09a5 | ||
|
|
24b8945010 | ||
|
|
82e4a564aa | ||
|
|
07ae792068 | ||
|
|
ba40ae4aac | ||
|
|
7f2c382343 | ||
|
|
017ac97d0d | ||
|
|
0a394e1587 | ||
|
|
fafb76063d | ||
|
|
9e7cfc951a | ||
|
|
1be18e691b | ||
|
|
ccc9554ef4 | ||
|
|
97f245d5ac | ||
|
|
19f8d83d3b | ||
|
|
67b232b063 | ||
|
|
41c54f08aa | ||
|
|
56ea63e2c6 | ||
|
|
94834b2eb4 | ||
|
|
dc8f8496d6 | ||
|
|
5c1086fe9e | ||
|
|
dff592bc6d | ||
|
|
29141bc89b | ||
|
|
fe4f261a5d | ||
|
|
7f0444d26b | ||
|
|
298f5c6c51 | ||
|
|
853a39138c | ||
|
|
6ad3727fa0 | ||
|
|
16f62f7abf | ||
|
|
d0dc26ce25 | ||
|
|
7d8c40893f | ||
|
|
411153c549 | ||
|
|
e06071c177 | ||
|
|
f377b0f9d2 | ||
|
|
f3df8f5f26 | ||
|
|
8323bedfb7 | ||
|
|
6c14e2e1f4 | ||
|
|
c4f9078712 | ||
|
|
d713032b9f | ||
|
|
d89b33330f | ||
|
|
ffa75f07e5 | ||
|
|
35595f8edc | ||
|
|
b7bd02d814 | ||
|
|
dc8e94d705 | ||
|
|
f4dbf828c9 |
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
[](https://github.com/Hmbown/DeepSeek-TUI/actions/workflows/ci.yml)
|
||||
[](https://crates.io/crates/deepseek-tui)
|
||||
[](https://www.npmjs.com/package/deepseek-tui)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/hero.png" alt="DeepSeek TUI" width="800">
|
||||
</p>
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
@@ -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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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(¤t_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
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+491
-148
File diff suppressed because it is too large.
Load diff
@@ -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"));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
pub mod coherence;
|
||||
pub mod engine;
|
||||
pub mod events;
|
||||
pub mod ops;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+166
-10
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,11 @@ struct DiagnosticsOutput {
|
||||
sandbox_type: Option<String>,
|
||||
rustc_version: Option<String>,
|
||||
cargo_version: Option<String>,
|
||||
/// User-trusted external paths the agent may access from this workspace
|
||||
/// (`/trust add <path>` from the slash command, persisted in
|
||||
/// `~/.deepseek/workspace-trust.json`). See issue #29.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
trusted_external_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -81,6 +86,11 @@ impl ToolSpec for DiagnosticsTool {
|
||||
let sandbox_type = crate::sandbox::get_platform_sandbox().map(|s| s.to_string());
|
||||
let sandbox_available = sandbox_type.is_some();
|
||||
|
||||
let trusted_external_paths = context
|
||||
.trusted_external_paths
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect();
|
||||
let diagnostics = DiagnosticsOutput {
|
||||
workspace_root,
|
||||
current_dir,
|
||||
@@ -92,6 +102,7 @@ impl ToolSpec for DiagnosticsTool {
|
||||
sandbox_type,
|
||||
rustc_version: probe_version("rustc", &["--version"], &context.workspace),
|
||||
cargo_version: probe_version("cargo", &["--version"], &context.workspace),
|
||||
trusted_external_paths,
|
||||
};
|
||||
|
||||
ToolResult::json(&diagnostics).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Direct-fetch HTTP tool. Complements `web_search` for cases where the user
|
||||
//! already knows the URL — a known repo, a blog post, a spec page — and
|
||||
//! search is overkill or actively unhelpful.
|
||||
//!
|
||||
//! Returns a structured `{url, status, content_type, content, truncated}`
|
||||
//! payload. HTML responses are stripped to readable text by default
|
||||
//! (`format = "markdown"`); pass `format = "raw"` to keep the bytes intact
|
||||
//! when the model wants to do its own parsing.
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_MAX_BYTES: u64 = 1_000_000;
|
||||
const HARD_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 15_000;
|
||||
const HARD_MAX_TIMEOUT_MS: u64 = 60_000;
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
const USER_AGENT: &str =
|
||||
"Mozilla/5.0 (compatible; deepseek-tui/0.5; +https://github.com/Hmbown/DeepSeek-TUI)";
|
||||
|
||||
static SCRIPT_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static STYLE_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static TAG_RE: OnceLock<Regex> = OnceLock::new();
|
||||
static WHITESPACE_RE: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
fn script_re() -> &'static Regex {
|
||||
SCRIPT_RE.get_or_init(|| Regex::new(r"(?is)<script[^>]*>.*?</script>").expect("script re"))
|
||||
}
|
||||
fn style_re() -> &'static Regex {
|
||||
STYLE_RE.get_or_init(|| Regex::new(r"(?is)<style[^>]*>.*?</style>").expect("style re"))
|
||||
}
|
||||
fn tag_re() -> &'static Regex {
|
||||
TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag re"))
|
||||
}
|
||||
fn whitespace_re() -> &'static Regex {
|
||||
WHITESPACE_RE.get_or_init(|| Regex::new(r"\s+").expect("ws re"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Format {
|
||||
Text,
|
||||
Markdown,
|
||||
Raw,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
fn parse(value: Option<&str>) -> Result<Self, ToolError> {
|
||||
match value
|
||||
.unwrap_or("markdown")
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"text" | "txt" | "plain" => Ok(Self::Text),
|
||||
"markdown" | "md" => Ok(Self::Markdown),
|
||||
"raw" | "html" | "bytes" => Ok(Self::Raw),
|
||||
other => Err(ToolError::invalid_input(format!(
|
||||
"unknown format `{other}` (allowed: text, markdown, raw)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FetchResponse {
|
||||
url: String,
|
||||
status: u16,
|
||||
content_type: String,
|
||||
content: String,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
pub struct FetchUrlTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for FetchUrlTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"fetch_url"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Fetch a known URL directly (HTTP GET) and return its content. Use this when the user gives a URL or you already know the canonical link — it's faster and more reliable than web_search for known pages."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Absolute HTTP/HTTPS URL to fetch."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["text", "markdown", "raw"],
|
||||
"description": "Post-processing for the response body. `markdown` (default) and `text` strip HTML tags to readable text; `raw` returns the body bytes as-is."
|
||||
},
|
||||
"max_bytes": {
|
||||
"type": "integer",
|
||||
"description": "Truncate response body after this many bytes (default 1,000,000; hard max 10,485,760)."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Request timeout in milliseconds (default 15,000; max 60,000)."
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Network]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let url = input
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| ToolError::invalid_input("`url` is required"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if url.is_empty() {
|
||||
return Err(ToolError::invalid_input("`url` cannot be empty"));
|
||||
}
|
||||
let scheme_ok = url.starts_with("http://") || url.starts_with("https://");
|
||||
if !scheme_ok {
|
||||
return Err(ToolError::invalid_input(
|
||||
"only http:// and https:// URLs are supported",
|
||||
));
|
||||
}
|
||||
|
||||
let format = Format::parse(input.get("format").and_then(Value::as_str))?;
|
||||
let max_bytes = optional_u64(&input, "max_bytes", DEFAULT_MAX_BYTES).min(HARD_MAX_BYTES);
|
||||
let timeout_ms =
|
||||
optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).min(HARD_MAX_TIMEOUT_MS);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.user_agent(USER_AGENT)
|
||||
.redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("failed to build HTTP client: {e}"))
|
||||
})?;
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header("Accept", "text/html,text/plain,application/json,*/*;q=0.5")
|
||||
.header("Accept-Language", "en-US,en;q=0.5")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("request failed: {e}")))?;
|
||||
|
||||
let final_url = resp.url().to_string();
|
||||
let status = resp.status();
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("failed to read body: {e}")))?;
|
||||
let total_bytes = bytes.len() as u64;
|
||||
let truncated = total_bytes > max_bytes;
|
||||
let usable = if truncated {
|
||||
&bytes[..max_bytes as usize]
|
||||
} else {
|
||||
&bytes[..]
|
||||
};
|
||||
|
||||
let body_text = String::from_utf8_lossy(usable).to_string();
|
||||
let processed = match format {
|
||||
Format::Raw => body_text,
|
||||
Format::Text | Format::Markdown => {
|
||||
if content_type.contains("text/html") || body_text.contains("<html") {
|
||||
html_to_text(&body_text)
|
||||
} else {
|
||||
body_text
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let response = FetchResponse {
|
||||
url: final_url,
|
||||
status: status.as_u16(),
|
||||
content_type,
|
||||
content: processed,
|
||||
truncated,
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
// Don't `Err` on 4xx/5xx — the caller often wants to see the body
|
||||
// (e.g. a JSON error envelope). Mark the result as a failure so the
|
||||
// engine renders it as such.
|
||||
return Ok(ToolResult {
|
||||
content: serde_json::to_string_pretty(&response).map_err(|e| {
|
||||
ToolError::execution_failed(format!("failed to serialize response: {e}"))
|
||||
})?,
|
||||
success: false,
|
||||
metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
ToolResult::json(&response)
|
||||
.map_err(|e| ToolError::execution_failed(format!("failed to serialize response: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip `<script>` / `<style>` blocks, drop remaining tags, and collapse
|
||||
/// whitespace. Good enough for "let the model read this page" — not a full
|
||||
/// HTML-to-Markdown converter.
|
||||
fn html_to_text(html: &str) -> String {
|
||||
let no_script = script_re().replace_all(html, "");
|
||||
let no_style = style_re().replace_all(&no_script, "");
|
||||
let no_tags = tag_re().replace_all(&no_style, " ");
|
||||
let decoded = decode_entities(&no_tags);
|
||||
whitespace_re()
|
||||
.replace_all(&decoded, " ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Decode the handful of HTML entities we expect to hit in stripped text.
|
||||
/// Pulling in `html-escape` for the long tail isn't worth the dep weight.
|
||||
fn decode_entities(s: &str) -> String {
|
||||
s.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::spec::ToolContext;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn ctx() -> ToolContext {
|
||||
ToolContext::new(PathBuf::from("."))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_to_text_strips_scripts_styles_and_tags() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<style>body { color: red; }</style>
|
||||
<script>alert("nope");</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello & welcome</h1>
|
||||
<p>This is <b>important</b>.</p>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
let text = html_to_text(html);
|
||||
assert!(text.contains("Hello & welcome"));
|
||||
assert!(text.contains("This is important"));
|
||||
assert!(!text.contains("alert"));
|
||||
assert!(!text.contains("color: red"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_parse_accepts_aliases_and_rejects_unknown() {
|
||||
assert_eq!(Format::parse(Some("markdown")).unwrap(), Format::Markdown);
|
||||
assert_eq!(Format::parse(Some("MD")).unwrap(), Format::Markdown);
|
||||
assert_eq!(Format::parse(Some("text")).unwrap(), Format::Text);
|
||||
assert_eq!(Format::parse(Some("raw")).unwrap(), Format::Raw);
|
||||
assert_eq!(Format::parse(None).unwrap(), Format::Markdown);
|
||||
assert!(Format::parse(Some("yaml")).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_non_http_schemes() {
|
||||
let tool = FetchUrlTool;
|
||||
let res = tool
|
||||
.execute(json!({"url": "file:///etc/passwd"}), &ctx())
|
||||
.await;
|
||||
let err = res.unwrap_err();
|
||||
assert!(format!("{err:?}").contains("http"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_empty_url() {
|
||||
let tool = FetchUrlTool;
|
||||
let res = tool.execute(json!({"url": " "}), &ctx()).await;
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_url() {
|
||||
let tool = FetchUrlTool;
|
||||
let res = tool.execute(json!({}), &ctx()).await;
|
||||
assert!(res.is_err());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use super::spec::{
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
// === ReadFileTool ===
|
||||
|
||||
@@ -23,7 +25,7 @@ impl ToolSpec for ReadFileTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Read a UTF-8 file from the workspace."
|
||||
"Read a file from the workspace. Plain text is returned as-is; PDFs are auto-extracted via `pdftotext` (poppler) when available."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -33,6 +35,10 @@ impl ToolSpec for ReadFileTool {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file (relative to workspace or absolute)"
|
||||
},
|
||||
"pages": {
|
||||
"type": "string",
|
||||
"description": "PDF only: page range to extract, e.g. \"1-5\" or \"10\". Ignored for non-PDF files."
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
@@ -50,6 +56,11 @@ impl ToolSpec for ReadFileTool {
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let path_str = required_str(&input, "path")?;
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let pages = optional_str(&input, "pages");
|
||||
|
||||
if is_pdf(&file_path)? {
|
||||
return read_pdf(&file_path, pages);
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&file_path).map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e))
|
||||
@@ -59,6 +70,114 @@ impl ToolSpec for ReadFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect a PDF by extension OR by sniffing the `%PDF-` magic bytes.
|
||||
/// Files without an extension are still recognized as PDFs when the header
|
||||
/// matches.
|
||||
fn is_pdf(path: &Path) -> Result<bool, ToolError> {
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
// Sniff first 4 bytes. Don't error if the file doesn't exist — let the
|
||||
// caller's `read_to_string` produce the canonical not-found error.
|
||||
let mut buf = [0u8; 4];
|
||||
let result = match fs::File::open(path) {
|
||||
Ok(mut f) => {
|
||||
use std::io::Read;
|
||||
f.read_exact(&mut buf).map(|_| buf)
|
||||
}
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
Ok(matches!(result, Ok(b) if &b == b"%PDF"))
|
||||
}
|
||||
|
||||
fn parse_pages_arg(spec: &str) -> Option<(u32, u32)> {
|
||||
let trimmed = spec.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some((a, b)) = trimmed.split_once('-') {
|
||||
let start: u32 = a.trim().parse().ok()?;
|
||||
let end: u32 = b.trim().parse().ok()?;
|
||||
if start == 0 || end < start {
|
||||
return None;
|
||||
}
|
||||
Some((start, end))
|
||||
} else {
|
||||
let n: u32 = trimmed.parse().ok()?;
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((n, n))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_pdf(path: &Path, pages: Option<&str>) -> Result<ToolResult, ToolError> {
|
||||
// Try pdftotext (from the poppler suite). Other extractors (mutool,
|
||||
// pdfminer) could be added later behind the same dispatch.
|
||||
let mut cmd = Command::new("pdftotext");
|
||||
cmd.arg("-layout");
|
||||
|
||||
if let Some(spec) = pages {
|
||||
match parse_pages_arg(spec) {
|
||||
Some((start, end)) => {
|
||||
cmd.arg("-f").arg(start.to_string());
|
||||
cmd.arg("-l").arg(end.to_string());
|
||||
}
|
||||
None => {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"invalid `pages` value `{spec}` (expected `N` or `N-M`, e.g. `1-5`)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd.arg(path).arg("-"); // output to stdout
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Structured "binary unavailable" — caller knows what to suggest.
|
||||
return ToolResult::json(&json!({
|
||||
"type": "binary_unavailable",
|
||||
"path": path.display().to_string(),
|
||||
"kind": "pdf",
|
||||
"reason": "pdftotext not installed",
|
||||
"hint": "install poppler (macOS: `brew install poppler`; Debian/Ubuntu: `apt install poppler-utils`)"
|
||||
}))
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("failed to serialize response: {e}"))
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"failed to launch pdftotext: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| ToolError::execution_failed(format!("pdftotext failed to complete: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"pdftotext failed (exit {:?}): {stderr}",
|
||||
output.status.code()
|
||||
)));
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
Ok(ToolResult::success(text))
|
||||
}
|
||||
|
||||
// === WriteFileTool ===
|
||||
|
||||
/// Tool for writing UTF-8 files to the workspace.
|
||||
@@ -330,6 +449,69 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_detected_by_extension() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("paper.PDF");
|
||||
fs::write(&path, b"not really a pdf, but extension says yes").unwrap();
|
||||
assert!(is_pdf(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_detected_by_magic_bytes_without_extension() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("blob");
|
||||
fs::write(&path, b"%PDF-1.7\nrest of bytes").unwrap();
|
||||
assert!(is_pdf(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_pdf_not_detected() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("notes.txt");
|
||||
fs::write(&path, "hello").unwrap();
|
||||
assert!(!is_pdf(&path).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_arg_parses_single_and_range() {
|
||||
assert_eq!(parse_pages_arg("5"), Some((5, 5)));
|
||||
assert_eq!(parse_pages_arg("1-10"), Some((1, 10)));
|
||||
assert_eq!(parse_pages_arg(" 3 - 7 "), Some((3, 7)));
|
||||
assert_eq!(parse_pages_arg("0"), None);
|
||||
assert_eq!(parse_pages_arg("10-3"), None);
|
||||
assert_eq!(parse_pages_arg(""), None);
|
||||
assert_eq!(parse_pages_arg("abc"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_returns_binary_unavailable_when_pdftotext_missing() {
|
||||
// We can't reliably remove pdftotext from $PATH in a test, but if
|
||||
// it's missing on the runner this test exercises that branch. If
|
||||
// it's installed, the test exits early — covered by the parse_pages
|
||||
// and is_pdf tests above.
|
||||
if Command::new("pdftotext")
|
||||
.arg("-v")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().join("doc.pdf");
|
||||
fs::write(&path, b"%PDF-1.7\n%%EOF").unwrap();
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
let result = ReadFileTool
|
||||
.execute(json!({"path": "doc.pdf"}), &ctx)
|
||||
.await
|
||||
.expect("structured response, not error");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("binary_unavailable"));
|
||||
assert!(result.content.contains("pdftotext"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_write_file_tool() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod file;
|
||||
pub mod file_search;
|
||||
pub mod finance;
|
||||
|
||||
pub mod fetch_url;
|
||||
pub mod git;
|
||||
pub mod git_history;
|
||||
pub mod parallel;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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
@@ -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 & 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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod diff_render;
|
||||
pub mod event_broker;
|
||||
pub mod history;
|
||||
pub mod markdown_render;
|
||||
pub mod model_picker;
|
||||
pub mod onboarding;
|
||||
pub mod pager;
|
||||
pub mod paste_burst;
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
//! `/model` picker modal: pick a DeepSeek model and a thinking-effort tier
|
||||
//! and apply both at once (#39).
|
||||
//!
|
||||
//! Two side-by-side panes — Models on the left, Thinking effort on the
|
||||
//! right. Tab swaps focus, ↑/↓ moves within the focused pane, Enter applies
|
||||
//! both and closes the modal, Esc cancels.
|
||||
//!
|
||||
//! The effort pane intentionally only exposes `Off / High / Max`. Per
|
||||
//! DeepSeek's [Thinking Mode docs](https://api-docs.deepseek.com/guides/reasoning_model),
|
||||
//! `low`/`medium` are silently mapped to `high` server-side and `xhigh` is
|
||||
//! mapped to `max`, so surfacing them as separate choices would be misleading.
|
||||
//! The legacy variants remain valid in `~/.deepseek/settings.toml` for
|
||||
//! back-compat — the picker just doesn't offer them.
|
||||
//!
|
||||
//! On apply we emit a [`ViewEvent::ModelPickerApplied`] with the resolved
|
||||
//! model id and effort tier; the UI handler updates `App` state, persists
|
||||
//! the choice via [`Settings`], and forwards `Op::SetModel` so the running
|
||||
//! engine picks up the change without a restart.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
prelude::Stylize,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::app::{App, ReasoningEffort};
|
||||
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||
|
||||
/// Models the picker exposes by default. Kept short on purpose — power
|
||||
/// users can still type `/model <id>` for anything else.
|
||||
const PICKER_MODELS: &[(&str, &str)] = &[
|
||||
("deepseek-v4-pro", "flagship"),
|
||||
("deepseek-v4-flash", "fast / cheap"),
|
||||
];
|
||||
|
||||
/// Thinking-effort rows shown in the picker, in the order DeepSeek
|
||||
/// behaviorally distinguishes them.
|
||||
const PICKER_EFFORTS: &[ReasoningEffort] = &[
|
||||
ReasoningEffort::Off,
|
||||
ReasoningEffort::High,
|
||||
ReasoningEffort::Max,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Pane {
|
||||
Model,
|
||||
Effort,
|
||||
}
|
||||
|
||||
pub struct ModelPickerView {
|
||||
initial_model: String,
|
||||
initial_effort: ReasoningEffort,
|
||||
/// Working selection (separate from the initial values so we can offer a
|
||||
/// clean Esc-to-cancel without mutating App state).
|
||||
selected_model_idx: usize,
|
||||
selected_effort_idx: usize,
|
||||
focus: Pane,
|
||||
/// True when the active model is one we don't list — we still show it
|
||||
/// so the picker doesn't quietly forget the user's chosen IDs.
|
||||
show_custom_model_row: bool,
|
||||
}
|
||||
|
||||
impl ModelPickerView {
|
||||
#[must_use]
|
||||
pub fn new(app: &App) -> Self {
|
||||
let initial_model = app.model.clone();
|
||||
let mut selected_model_idx = PICKER_MODELS
|
||||
.iter()
|
||||
.position(|(id, _)| *id == initial_model);
|
||||
let show_custom_model_row = selected_model_idx.is_none();
|
||||
if show_custom_model_row {
|
||||
// Custom row sits at the end; precompute its index.
|
||||
selected_model_idx = Some(PICKER_MODELS.len());
|
||||
}
|
||||
let selected_model_idx = selected_model_idx.unwrap_or(0);
|
||||
|
||||
let initial_effort = app.reasoning_effort;
|
||||
// Map low/medium → high, xhigh → max for picker purposes.
|
||||
let normalized = match initial_effort {
|
||||
ReasoningEffort::Low | ReasoningEffort::Medium => ReasoningEffort::High,
|
||||
other => other,
|
||||
};
|
||||
let selected_effort_idx = PICKER_EFFORTS
|
||||
.iter()
|
||||
.position(|e| *e == normalized)
|
||||
.unwrap_or(1); // default to High if somehow unknown
|
||||
|
||||
Self {
|
||||
initial_model,
|
||||
initial_effort,
|
||||
selected_model_idx,
|
||||
selected_effort_idx,
|
||||
focus: Pane::Model,
|
||||
show_custom_model_row,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_row_count(&self) -> usize {
|
||||
PICKER_MODELS.len() + if self.show_custom_model_row { 1 } else { 0 }
|
||||
}
|
||||
|
||||
/// Resolve the currently highlighted model row to a model id. If the
|
||||
/// custom row is selected we return the original model from the App so
|
||||
/// "Apply" doesn't blow away an unrecognised id.
|
||||
fn resolved_model(&self) -> String {
|
||||
if self.show_custom_model_row && self.selected_model_idx == PICKER_MODELS.len() {
|
||||
self.initial_model.clone()
|
||||
} else {
|
||||
PICKER_MODELS[self.selected_model_idx].0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved_effort(&self) -> ReasoningEffort {
|
||||
PICKER_EFFORTS[self.selected_effort_idx]
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
match self.focus {
|
||||
Pane::Model => {
|
||||
if self.selected_model_idx > 0 {
|
||||
self.selected_model_idx -= 1;
|
||||
}
|
||||
}
|
||||
Pane::Effort => {
|
||||
if self.selected_effort_idx > 0 {
|
||||
self.selected_effort_idx -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
match self.focus {
|
||||
Pane::Model => {
|
||||
let max = self.model_row_count().saturating_sub(1);
|
||||
if self.selected_model_idx < max {
|
||||
self.selected_model_idx += 1;
|
||||
}
|
||||
}
|
||||
Pane::Effort => {
|
||||
let max = PICKER_EFFORTS.len().saturating_sub(1);
|
||||
if self.selected_effort_idx < max {
|
||||
self.selected_effort_idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Pane::Model => Pane::Effort,
|
||||
Pane::Effort => Pane::Model,
|
||||
};
|
||||
}
|
||||
|
||||
fn build_event(&self) -> ViewEvent {
|
||||
ViewEvent::ModelPickerApplied {
|
||||
model: self.resolved_model(),
|
||||
effort: self.resolved_effort(),
|
||||
previous_model: self.initial_model.clone(),
|
||||
previous_effort: self.initial_effort,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pane(
|
||||
&self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
title: &str,
|
||||
rows: Vec<(String, String)>,
|
||||
selected: usize,
|
||||
focused: bool,
|
||||
) {
|
||||
let border_style = if focused {
|
||||
Style::default().fg(palette::DEEPSEEK_SKY)
|
||||
} else {
|
||||
Style::default().fg(palette::BORDER_COLOR)
|
||||
};
|
||||
let block = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default().fg(palette::TEXT_PRIMARY).bold(),
|
||||
)))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style)
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut lines = Vec::with_capacity(rows.len());
|
||||
for (idx, (label, hint)) in rows.iter().enumerate() {
|
||||
let is_selected = idx == selected;
|
||||
let marker = if is_selected { "▸" } else { " " };
|
||||
let label_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(palette::SELECTION_TEXT)
|
||||
.bg(palette::SELECTION_BG)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_PRIMARY)
|
||||
};
|
||||
let hint_style = if is_selected {
|
||||
Style::default()
|
||||
.fg(palette::SELECTION_TEXT)
|
||||
.bg(palette::SELECTION_BG)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_MUTED)
|
||||
};
|
||||
let mut spans = vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(marker, label_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(label.clone(), label_style),
|
||||
];
|
||||
if !hint.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!("({hint})"), hint_style));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl ModalView for ModelPickerView {
|
||||
fn kind(&self) -> ModalKind {
|
||||
ModalKind::ModelPicker
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
|
||||
match key.code {
|
||||
KeyCode::Esc => ViewAction::Close,
|
||||
KeyCode::Enter => ViewAction::EmitAndClose(self.build_event()),
|
||||
KeyCode::Up => {
|
||||
self.move_up();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.move_down();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.toggle_focus();
|
||||
ViewAction::None
|
||||
}
|
||||
_ => ViewAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
|
||||
let popup_height = 14.min(area.height.saturating_sub(4)).max(10);
|
||||
let popup_area = Rect {
|
||||
x: area.x + (area.width.saturating_sub(popup_width)) / 2,
|
||||
y: area.y + (area.height.saturating_sub(popup_height)) / 2,
|
||||
width: popup_width,
|
||||
height: popup_height,
|
||||
};
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
// Outer chrome with title + footer hint.
|
||||
let outer = Block::default()
|
||||
.title(Line::from(Span::styled(
|
||||
" Model & thinking ",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.title_bottom(Line::from(vec![
|
||||
Span::styled(" ↑↓ ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("move "),
|
||||
Span::styled(" Tab ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("switch "),
|
||||
Span::styled(" Enter ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("apply "),
|
||||
Span::styled(" Esc ", Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw("cancel "),
|
||||
]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK));
|
||||
let inner = outer.inner(popup_area);
|
||||
outer.render(popup_area, buf);
|
||||
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
|
||||
.split(inner);
|
||||
|
||||
let mut model_rows: Vec<(String, String)> = PICKER_MODELS
|
||||
.iter()
|
||||
.map(|(id, hint)| ((*id).to_string(), (*hint).to_string()))
|
||||
.collect();
|
||||
if self.show_custom_model_row {
|
||||
model_rows.push((self.initial_model.clone(), "current (custom)".to_string()));
|
||||
}
|
||||
self.render_pane(
|
||||
columns[0],
|
||||
buf,
|
||||
"Model",
|
||||
model_rows,
|
||||
self.selected_model_idx,
|
||||
self.focus == Pane::Model,
|
||||
);
|
||||
|
||||
let effort_rows: Vec<(String, String)> = PICKER_EFFORTS
|
||||
.iter()
|
||||
.map(|effort| {
|
||||
let label = effort.short_label().to_string();
|
||||
let hint = match effort {
|
||||
ReasoningEffort::Off => "thinking disabled".to_string(),
|
||||
ReasoningEffort::High => "thinking enabled (default)".to_string(),
|
||||
ReasoningEffort::Max => "thinking enabled, max effort".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(label, hint)
|
||||
})
|
||||
.collect();
|
||||
self.render_pane(
|
||||
columns[1],
|
||||
buf,
|
||||
"Thinking",
|
||||
effort_rows,
|
||||
self.selected_effort_idx,
|
||||
self.focus == Pane::Effort,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::app::{App, TuiOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn create_test_app() -> App {
|
||||
let options = TuiOptions {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
workspace: PathBuf::from("."),
|
||||
allow_shell: false,
|
||||
use_alt_screen: true,
|
||||
use_mouse_capture: false,
|
||||
max_subagents: 1,
|
||||
skills_dir: PathBuf::from("."),
|
||||
memory_path: PathBuf::from("memory.md"),
|
||||
notes_path: PathBuf::from("notes.txt"),
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
use_memory: false,
|
||||
start_in_agent_mode: true,
|
||||
skip_onboarding: true,
|
||||
yolo: false,
|
||||
resume_session_id: None,
|
||||
};
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_initial_selection_matches_app_state() {
|
||||
let mut app = create_test_app();
|
||||
app.model = "deepseek-v4-flash".to_string();
|
||||
app.reasoning_effort = ReasoningEffort::Max;
|
||||
let view = ModelPickerView::new(&app);
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-flash");
|
||||
assert_eq!(view.resolved_effort(), ReasoningEffort::Max);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_normalizes_low_medium_to_high() {
|
||||
let mut app = create_test_app();
|
||||
app.reasoning_effort = ReasoningEffort::Medium;
|
||||
let view = ModelPickerView::new(&app);
|
||||
assert_eq!(
|
||||
view.resolved_effort(),
|
||||
ReasoningEffort::High,
|
||||
"medium should map to high in the picker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_preserves_unknown_model_via_custom_row() {
|
||||
let mut app = create_test_app();
|
||||
app.model = "deepseek-v4-pro-2026-04-XX".to_string();
|
||||
let view = ModelPickerView::new(&app);
|
||||
assert!(view.show_custom_model_row);
|
||||
assert_eq!(view.resolved_model(), "deepseek-v4-pro-2026-04-XX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_within_focused_pane() {
|
||||
let app = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
// Default focus is Model; move down then up.
|
||||
let initial = view.selected_model_idx;
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial + 1);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Up,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.selected_model_idx, initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_switches_focus_and_arrow_now_moves_effort() {
|
||||
let mut app = create_test_app();
|
||||
// Default is Max (index 2 = last); pin to Off so the Down arrow has
|
||||
// somewhere to go.
|
||||
app.reasoning_effort = ReasoningEffort::Off;
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
let initial_effort_idx = view.selected_effort_idx;
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert_eq!(view.focus, Pane::Effort);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert!(view.selected_effort_idx > initial_effort_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_emits_apply_event_with_selection() {
|
||||
let mut app = create_test_app();
|
||||
app.reasoning_effort = ReasoningEffort::High;
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Tab,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
view.handle_key(KeyEvent::new(
|
||||
KeyCode::Down,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
let action = view.handle_key(KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
match action {
|
||||
ViewAction::EmitAndClose(ViewEvent::ModelPickerApplied {
|
||||
model,
|
||||
effort,
|
||||
previous_effort,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(model, "deepseek-v4-pro");
|
||||
assert_eq!(effort, ReasoningEffort::Max);
|
||||
assert_eq!(previous_effort, ReasoningEffort::High);
|
||||
}
|
||||
other => panic!("expected ModelPickerApplied EmitAndClose, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_closes_without_emitting() {
|
||||
let app = create_test_app();
|
||||
let mut view = ModelPickerView::new(&app);
|
||||
let action = view.handle_key(KeyEvent::new(
|
||||
KeyCode::Esc,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
));
|
||||
assert!(matches!(action, ViewAction::Close));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_only_exposes_off_high_max() {
|
||||
let labels: Vec<&str> = PICKER_EFFORTS
|
||||
.iter()
|
||||
.map(|effort| effort.short_label())
|
||||
.collect();
|
||||
assert_eq!(labels, vec!["off", "high", "max"]);
|
||||
}
|
||||
}
|
||||
@@ -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)"));
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+346
-127
@@ -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));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user