Add NIM env support and .env.example template
This commit is contained in:
@@ -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,7 @@
|
||||
# Development
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -568,6 +568,7 @@ impl EnvRuntimeOverrides {
|
||||
.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()),
|
||||
@@ -616,6 +617,7 @@ mod tests {
|
||||
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>,
|
||||
}
|
||||
@@ -629,6 +631,7 @@ mod tests {
|
||||
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"),
|
||||
};
|
||||
@@ -640,6 +643,7 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
@@ -665,6 +669,7 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
@@ -783,6 +788,24 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -51,10 +51,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult {
|
||||
};
|
||||
|
||||
if target == app.api_provider && model.is_none() {
|
||||
return CommandResult::message(format!(
|
||||
"Already on provider: {}",
|
||||
target.as_str()
|
||||
));
|
||||
return CommandResult::message(format!("Already on provider: {}", target.as_str()));
|
||||
}
|
||||
|
||||
CommandResult::action(AppAction::SwitchProvider {
|
||||
|
||||
@@ -732,13 +732,26 @@ fn apply_env_overrides(config: &mut Config) {
|
||||
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("NVIDIA_BASE_URL"))
|
||||
&& 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.base_url = Some(value);
|
||||
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"))
|
||||
@@ -1253,6 +1266,7 @@ mod tests {
|
||||
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>,
|
||||
@@ -1273,6 +1287,7 @@ mod tests {
|
||||
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");
|
||||
@@ -1288,6 +1303,7 @@ mod tests {
|
||||
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");
|
||||
@@ -1303,6 +1319,7 @@ mod tests {
|
||||
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,
|
||||
@@ -1327,6 +1344,7 @@ mod tests {
|
||||
);
|
||||
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());
|
||||
@@ -1692,6 +1710,63 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+129
-98
@@ -52,6 +52,7 @@ use super::capacity_memory::{
|
||||
CanonicalState, CapacityMemoryRecord, ReplayInfo, append_capacity_record,
|
||||
load_last_k_capacity_records, new_record_id, now_rfc3339,
|
||||
};
|
||||
use super::coherence::{CoherenceSignal, CoherenceState, next_coherence_state};
|
||||
use super::events::{Event, TurnOutcomeStatus};
|
||||
use super::ops::Op;
|
||||
use super::session::Session;
|
||||
@@ -234,6 +235,7 @@ pub struct Engine {
|
||||
shared_cancel_token: Arc<StdMutex<CancellationToken>>,
|
||||
tool_exec_lock: Arc<RwLock<()>>,
|
||||
capacity_controller: CapacityController,
|
||||
coherence_state: CoherenceState,
|
||||
turn_counter: u64,
|
||||
}
|
||||
|
||||
@@ -1385,6 +1387,7 @@ impl Engine {
|
||||
shared_cancel_token: shared_cancel_token.clone(),
|
||||
tool_exec_lock,
|
||||
capacity_controller,
|
||||
coherence_state: CoherenceState::default(),
|
||||
turn_counter: 0,
|
||||
};
|
||||
engine.rehydrate_latest_canonical_state();
|
||||
@@ -1803,13 +1806,7 @@ impl Engine {
|
||||
};
|
||||
let Some(client) = self.deepseek_client.clone() else {
|
||||
let message = "Manual compaction unavailable: API client not configured".to_string();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id,
|
||||
auto: false,
|
||||
message: message.clone(),
|
||||
})
|
||||
self.emit_compaction_failed(id, false, message.clone())
|
||||
.await;
|
||||
let _ = self
|
||||
.tx_event
|
||||
@@ -1827,13 +1824,7 @@ impl Engine {
|
||||
};
|
||||
|
||||
let start_message = "Manual context compaction started".to_string();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionStarted {
|
||||
id: id.clone(),
|
||||
auto: false,
|
||||
message: start_message,
|
||||
})
|
||||
self.emit_compaction_started(id.clone(), false, start_message)
|
||||
.await;
|
||||
|
||||
let compaction_pins = self
|
||||
@@ -1872,25 +1863,17 @@ impl Engine {
|
||||
"Compaction complete: {messages_before} → {messages_after} messages ({removed} removed)"
|
||||
)
|
||||
};
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionCompleted {
|
||||
id,
|
||||
auto: false,
|
||||
message,
|
||||
messages_before: Some(messages_before),
|
||||
messages_after: Some(messages_after),
|
||||
})
|
||||
.await;
|
||||
self.emit_compaction_completed(
|
||||
id,
|
||||
false,
|
||||
message,
|
||||
Some(messages_before),
|
||||
Some(messages_after),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let message = "Compaction skipped: produced empty result".to_string();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id,
|
||||
auto: false,
|
||||
message: message.clone(),
|
||||
})
|
||||
self.emit_compaction_failed(id, false, message.clone())
|
||||
.await;
|
||||
turn_status = TurnOutcomeStatus::Failed;
|
||||
turn_error = Some(message);
|
||||
@@ -1898,13 +1881,7 @@ impl Engine {
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("Manual context compaction failed: {err}");
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id,
|
||||
auto: false,
|
||||
message: message.clone(),
|
||||
})
|
||||
self.emit_compaction_failed(id, false, message.clone())
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(message.clone())).await;
|
||||
turn_status = TurnOutcomeStatus::Failed;
|
||||
@@ -1954,13 +1931,7 @@ impl Engine {
|
||||
|
||||
let id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
let start_message = format!("Emergency context compaction started ({reason})");
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionStarted {
|
||||
id: id.clone(),
|
||||
auto: true,
|
||||
message: start_message,
|
||||
})
|
||||
self.emit_compaction_started(id.clone(), true, start_message)
|
||||
.await;
|
||||
|
||||
let before_tokens = self.estimated_input_tokens();
|
||||
@@ -2026,16 +1997,14 @@ impl Engine {
|
||||
if trimmed > 0 {
|
||||
details.push_str(&format!(", trimmed {trimmed} oldest"));
|
||||
}
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionCompleted {
|
||||
id,
|
||||
auto: true,
|
||||
message: details.clone(),
|
||||
messages_before: Some(before_count),
|
||||
messages_after: Some(after_count),
|
||||
})
|
||||
.await;
|
||||
self.emit_compaction_completed(
|
||||
id,
|
||||
true,
|
||||
details.clone(),
|
||||
Some(before_count),
|
||||
Some(after_count),
|
||||
)
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(details)).await;
|
||||
return true;
|
||||
}
|
||||
@@ -2045,14 +2014,7 @@ impl Engine {
|
||||
(estimate ~{} tokens, budget ~{}).",
|
||||
after_tokens, target_budget
|
||||
);
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id,
|
||||
auto: true,
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_compaction_failed(id, true, message.clone()).await;
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
false
|
||||
}
|
||||
@@ -2439,14 +2401,12 @@ impl Engine {
|
||||
)
|
||||
{
|
||||
let compaction_id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionStarted {
|
||||
id: compaction_id.clone(),
|
||||
auto: true,
|
||||
message: "Auto context compaction started".to_string(),
|
||||
})
|
||||
.await;
|
||||
self.emit_compaction_started(
|
||||
compaction_id.clone(),
|
||||
true,
|
||||
"Auto context compaction started".to_string(),
|
||||
)
|
||||
.await;
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::status("Auto-compacting context...".to_string()))
|
||||
@@ -2480,40 +2440,30 @@ impl Engine {
|
||||
"Auto-compaction complete: {auto_messages_before} → {auto_messages_after} messages ({removed} removed)"
|
||||
)
|
||||
};
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionCompleted {
|
||||
id: compaction_id.clone(),
|
||||
auto: true,
|
||||
message: status.clone(),
|
||||
messages_before: Some(auto_messages_before),
|
||||
messages_after: Some(auto_messages_after),
|
||||
})
|
||||
.await;
|
||||
self.emit_compaction_completed(
|
||||
compaction_id.clone(),
|
||||
true,
|
||||
status.clone(),
|
||||
Some(auto_messages_before),
|
||||
Some(auto_messages_after),
|
||||
)
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(status)).await;
|
||||
} else {
|
||||
let message = "Auto-compaction skipped: empty result".to_string();
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id: compaction_id.clone(),
|
||||
auto: true,
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_compaction_failed(
|
||||
compaction_id.clone(),
|
||||
true,
|
||||
message.clone(),
|
||||
)
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Log error but continue with original messages (never corrupt)
|
||||
let message = format!("Auto-compaction failed: {err}");
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id: compaction_id,
|
||||
auto: true,
|
||||
message: message.clone(),
|
||||
})
|
||||
self.emit_compaction_failed(compaction_id, true, message.clone())
|
||||
.await;
|
||||
let _ = self.tx_event.send(Event::status(message)).await;
|
||||
}
|
||||
@@ -3824,8 +3774,70 @@ impl Engine {
|
||||
refs.len()
|
||||
}
|
||||
|
||||
async fn emit_coherence_signal(&mut self, signal: CoherenceSignal, reason: impl Into<String>) {
|
||||
let next = next_coherence_state(self.coherence_state, signal);
|
||||
self.coherence_state = next;
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CoherenceState {
|
||||
state: next,
|
||||
label: next.label().to_string(),
|
||||
description: next.description().to_string(),
|
||||
reason: reason.into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn emit_compaction_started(&mut self, id: String, auto: bool, message: String) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionStarted {
|
||||
id,
|
||||
auto,
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(CoherenceSignal::CompactionStarted, message)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn emit_compaction_completed(
|
||||
&mut self,
|
||||
id: String,
|
||||
auto: bool,
|
||||
message: String,
|
||||
messages_before: Option<usize>,
|
||||
messages_after: Option<usize>,
|
||||
) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionCompleted {
|
||||
id,
|
||||
auto,
|
||||
message: message.clone(),
|
||||
messages_before,
|
||||
messages_after,
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(CoherenceSignal::CompactionCompleted, message)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn emit_compaction_failed(&mut self, id: String, auto: bool, message: String) {
|
||||
let _ = self
|
||||
.tx_event
|
||||
.send(Event::CompactionFailed {
|
||||
id,
|
||||
auto,
|
||||
message: message.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(CoherenceSignal::CompactionFailed, message)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn emit_capacity_decision(
|
||||
&self,
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
snapshot: Option<&CapacitySnapshot>,
|
||||
decision: &CapacityDecision,
|
||||
@@ -3850,10 +3862,24 @@ impl Engine {
|
||||
reason: decision.reason.clone(),
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(
|
||||
CoherenceSignal::CapacityDecision {
|
||||
risk_band: snapshot.risk_band,
|
||||
action: decision.action,
|
||||
cooldown_blocked: decision.cooldown_blocked,
|
||||
},
|
||||
format!(
|
||||
"capacity_decision: risk={} action={} reason={}",
|
||||
snapshot.risk_band.as_str(),
|
||||
decision.action.as_str(),
|
||||
decision.reason
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn emit_capacity_intervention(
|
||||
&self,
|
||||
&mut self,
|
||||
turn: &TurnContext,
|
||||
action: GuardrailAction,
|
||||
before_prompt_tokens: usize,
|
||||
@@ -3874,6 +3900,11 @@ impl Engine {
|
||||
replan_performed,
|
||||
})
|
||||
.await;
|
||||
self.emit_coherence_signal(
|
||||
CoherenceSignal::CapacityIntervention { action },
|
||||
format!("capacity_intervention: action={}", action.as_str()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn apply_targeted_context_refresh(
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::core::coherence::CoherenceState;
|
||||
use crate::models::{Message, SystemPrompt, Usage};
|
||||
use crate::tools::spec::{ToolError, ToolResult};
|
||||
use crate::tools::subagent::SubAgentResult;
|
||||
@@ -158,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 },
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
pub mod capacity;
|
||||
pub mod capacity_memory;
|
||||
pub mod coherence;
|
||||
pub mod engine;
|
||||
pub mod events;
|
||||
pub mod ops;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+92
-7
@@ -20,6 +20,7 @@ mod commands;
|
||||
mod compaction;
|
||||
mod config;
|
||||
mod core;
|
||||
mod deepseek_theme;
|
||||
mod error_taxonomy;
|
||||
mod eval;
|
||||
mod execpolicy;
|
||||
@@ -493,7 +494,7 @@ enum SandboxCommand {
|
||||
async fn main() -> Result<()> {
|
||||
dotenv().ok();
|
||||
let cli = Cli::parse();
|
||||
logging::set_verbose(cli.verbose);
|
||||
logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging());
|
||||
|
||||
// Handle subcommands first
|
||||
if let Some(command) = cli.command.clone() {
|
||||
@@ -1197,18 +1198,26 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
),
|
||||
}
|
||||
|
||||
let dotenv = workspace.join(".env");
|
||||
if dotenv.exists() {
|
||||
println!(" {} .env present at {}", "·".dimmed(), dotenv.display());
|
||||
} else {
|
||||
println!(" {} .env not present in workspace", "·".dimmed());
|
||||
}
|
||||
println!(" {} {}", "·".dimmed(), dotenv_status_line(workspace));
|
||||
|
||||
println!();
|
||||
println!("Run `deepseek-tui doctor --json` for a machine-readable check.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dotenv_status_line(workspace: &Path) -> String {
|
||||
let dotenv = workspace.join(".env");
|
||||
if dotenv.exists() {
|
||||
return format!(".env present at {}", dotenv.display());
|
||||
}
|
||||
|
||||
if workspace.join(".env.example").exists() {
|
||||
return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
|
||||
}
|
||||
|
||||
".env not present in workspace".to_string()
|
||||
}
|
||||
|
||||
fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
|
||||
use colored::Colorize;
|
||||
|
||||
@@ -3167,6 +3176,7 @@ mod doctor_mcp_tests {
|
||||
#[cfg(test)]
|
||||
mod setup_helper_tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -3300,6 +3310,81 @@ mod setup_helper_tests {
|
||||
assert!(!dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotenv_status_points_to_example_when_present() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join(".env.example"), "DEEPSEEK_API_KEY=\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
dotenv_status_line(tmp.path()),
|
||||
".env not present in workspace (run `cp .env.example .env` and edit)"
|
||||
);
|
||||
|
||||
std::fs::write(tmp.path().join(".env"), "DEEPSEEK_API_KEY=test\n").unwrap();
|
||||
assert!(dotenv_status_line(tmp.path()).contains(".env present at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_example_is_trackable_and_every_key_is_wired() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let env_example = std::fs::read_to_string(root.join(".env.example")).unwrap();
|
||||
let gitignore = std::fs::read_to_string(root.join(".gitignore")).unwrap();
|
||||
|
||||
assert!(gitignore.contains("!.env.example"));
|
||||
|
||||
let keys = documented_env_keys(&env_example);
|
||||
for required in [
|
||||
"DEEPSEEK_API_KEY",
|
||||
"DEEPSEEK_BASE_URL",
|
||||
"DEEPSEEK_MODEL",
|
||||
"NVIDIA_API_KEY",
|
||||
"NIM_BASE_URL",
|
||||
"RUST_LOG",
|
||||
"DEEPSEEK_APPROVAL_POLICY",
|
||||
"DEEPSEEK_SANDBOX_MODE",
|
||||
] {
|
||||
assert!(
|
||||
keys.contains(required),
|
||||
".env.example is missing {required}"
|
||||
);
|
||||
}
|
||||
|
||||
let sources = [
|
||||
include_str!("config.rs"),
|
||||
include_str!("logging.rs"),
|
||||
include_str!("../../config/src/lib.rs"),
|
||||
include_str!("../../cli/src/main.rs"),
|
||||
]
|
||||
.join("\n");
|
||||
|
||||
for key in keys {
|
||||
assert!(
|
||||
sources.contains(&key),
|
||||
".env.example documents {key}, but no source file references it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn documented_env_keys(content: &str) -> BTreeSet<String> {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
let uncommented = trimmed
|
||||
.strip_prefix('#')
|
||||
.map(str::trim_start)
|
||||
.unwrap_or(trimmed);
|
||||
let (key, _) = uncommented.split_once('=')?;
|
||||
let key = key.trim();
|
||||
let is_env_key = key
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
|
||||
&& key.chars().any(|ch| ch == '_');
|
||||
is_env_key.then(|| key.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_source_reports_env_when_set() {
|
||||
// Snapshot env so we can restore it.
|
||||
|
||||
@@ -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)]
|
||||
@@ -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(
|
||||
@@ -1676,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,
|
||||
@@ -2378,6 +2407,7 @@ mod tests {
|
||||
latest_response_bookmark: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
coherence_state: CoherenceState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2634,6 +2664,14 @@ 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 {
|
||||
@@ -2670,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());
|
||||
@@ -2678,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(())
|
||||
}
|
||||
|
||||
@@ -2698,6 +2746,7 @@ mod tests {
|
||||
.await?;
|
||||
|
||||
assert!(!thread.auto_approve);
|
||||
assert_eq!(thread.coherence_state, CoherenceState::Healthy);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3557,6 +3606,7 @@ mod tests {
|
||||
latest_response_bookmark: None,
|
||||
archived: false,
|
||||
system_prompt: None,
|
||||
coherence_state: CoherenceState::default(),
|
||||
};
|
||||
manager.store.save_thread(&thread)?;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use thiserror::Error;
|
||||
|
||||
use crate::compaction::CompactionConfig;
|
||||
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,
|
||||
@@ -516,6 +517,8 @@ 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>,
|
||||
}
|
||||
@@ -779,6 +782,7 @@ impl App {
|
||||
needs_redraw: true,
|
||||
thinking_started_at: None,
|
||||
is_compacting: false,
|
||||
coherence_state: CoherenceState::default(),
|
||||
last_send_at: None,
|
||||
}
|
||||
}
|
||||
@@ -1694,6 +1698,8 @@ 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-v3.2-128k".to_string();
|
||||
app.update_model_compaction_budget();
|
||||
|
||||
+136
-17
@@ -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;
|
||||
@@ -1538,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 {
|
||||
@@ -1572,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 {
|
||||
@@ -1630,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]
|
||||
@@ -1702,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));
|
||||
}
|
||||
}
|
||||
|
||||
+82
-16
@@ -22,7 +22,7 @@ use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Padding, Paragraph, Wrap},
|
||||
widgets::{Block, Paragraph, Wrap},
|
||||
};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
@@ -31,9 +31,11 @@ use crate::client::DeepSeekClient;
|
||||
use crate::commands;
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
|
||||
use crate::core::coherence::CoherenceState;
|
||||
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
|
||||
use crate::core::events::Event as EngineEvent;
|
||||
use crate::core::ops::Op;
|
||||
use crate::deepseek_theme::active_theme;
|
||||
use crate::hooks::HookEvent;
|
||||
use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model};
|
||||
use crate::palette;
|
||||
@@ -615,6 +617,9 @@ async fn run_event_loop(
|
||||
app.is_compacting = false;
|
||||
app.status_message = Some(message);
|
||||
}
|
||||
EngineEvent::CoherenceState { state, .. } => {
|
||||
app.coherence_state = state;
|
||||
}
|
||||
EngineEvent::CapacityDecision { .. } => {
|
||||
// Telemetry-only event. Surface actual interventions and failures
|
||||
// instead of replacing the footer with no-op guardrail chatter.
|
||||
@@ -1064,7 +1069,9 @@ async fn run_event_loop(
|
||||
|
||||
if !app.view_stack.is_empty() {
|
||||
let events = app.view_stack.handle_key(key);
|
||||
if handle_view_events(app, config, &task_manager, &mut engine_handle, events).await? {
|
||||
if handle_view_events(app, config, &task_manager, &mut engine_handle, events)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
@@ -2823,6 +2830,7 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
|
||||
return;
|
||||
}
|
||||
|
||||
let theme = active_theme();
|
||||
let content_width = area.width.saturating_sub(4) as usize;
|
||||
let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));
|
||||
|
||||
@@ -2831,7 +2839,7 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
|
||||
if plan.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"No active plan",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
Style::default().fg(theme.plan_summary_color),
|
||||
)));
|
||||
} else {
|
||||
let (pending, in_progress, completed) = plan.counts();
|
||||
@@ -2839,18 +2847,18 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{}%", plan.progress_percent()),
|
||||
Style::default().fg(palette::STATUS_SUCCESS).bold(),
|
||||
Style::default().fg(theme.plan_progress_color).bold(),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" complete ({completed}/{total})"),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
Style::default().fg(theme.plan_summary_color),
|
||||
),
|
||||
]));
|
||||
|
||||
if let Some(explanation) = plan.explanation() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
truncate_line_to_width(explanation, content_width.max(1)),
|
||||
Style::default().fg(palette::TEXT_DIM),
|
||||
Style::default().fg(theme.plan_explanation_color),
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -2858,9 +2866,9 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
|
||||
let max_steps = usable_rows.saturating_sub(lines.len());
|
||||
for step in plan.steps().iter().take(max_steps) {
|
||||
let (prefix, color) = match &step.status {
|
||||
StepStatus::Pending => ("[ ]", palette::TEXT_MUTED),
|
||||
StepStatus::InProgress => ("[~]", palette::STATUS_WARNING),
|
||||
StepStatus::Completed => ("[x]", palette::STATUS_SUCCESS),
|
||||
StepStatus::Pending => ("[ ]", theme.plan_pending_color),
|
||||
StepStatus::InProgress => ("[~]", theme.plan_in_progress_color),
|
||||
StepStatus::Completed => ("[x]", theme.plan_completed_color),
|
||||
};
|
||||
let mut text = format!("{prefix} {}", step.text);
|
||||
let elapsed = step.elapsed_str();
|
||||
@@ -2877,7 +2885,7 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
|
||||
if remaining > 0 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("+{remaining} more steps"),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
Style::default().fg(theme.plan_summary_color),
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -2885,7 +2893,7 @@ fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) {
|
||||
Err(_) => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"Plan state updating...",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
Style::default().fg(theme.plan_summary_color),
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -3128,16 +3136,18 @@ fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec<Lin
|
||||
return;
|
||||
}
|
||||
|
||||
let theme = active_theme();
|
||||
let section = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
|
||||
Block::default()
|
||||
.title(Line::from(vec![Span::styled(
|
||||
format!(" {title} "),
|
||||
Style::default().fg(palette::DEEPSEEK_BLUE).bold(),
|
||||
Style::default().fg(theme.section_title_color).bold(),
|
||||
)]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(palette::BORDER_COLOR))
|
||||
.style(Style::default().bg(palette::DEEPSEEK_INK))
|
||||
.padding(Padding::uniform(1)),
|
||||
.borders(theme.section_borders)
|
||||
.border_type(theme.section_border_type)
|
||||
.border_style(Style::default().fg(theme.section_border_color))
|
||||
.style(Style::default().bg(theme.section_bg))
|
||||
.padding(theme.section_padding),
|
||||
);
|
||||
|
||||
f.render_widget(section, area);
|
||||
@@ -3575,6 +3585,7 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
}
|
||||
|
||||
fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
let coherence_spans = footer_coherence_spans(app);
|
||||
let context_spans = footer_context_spans(app);
|
||||
let cache_spans = footer_cache_spans(app);
|
||||
let cost_spans = if app.session_cost > 0.001 {
|
||||
@@ -3587,6 +3598,20 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
};
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
if !coherence_spans.is_empty()
|
||||
&& !context_spans.is_empty()
|
||||
&& !cache_spans.is_empty()
|
||||
&& !cost_spans.is_empty()
|
||||
{
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(context_spans.clone());
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !context_spans.is_empty() && !cache_spans.is_empty() && !cost_spans.is_empty() {
|
||||
let mut combined = context_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
@@ -3595,18 +3620,47 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !context_spans.is_empty() && !cache_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(context_spans.clone());
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !context_spans.is_empty() && !cache_spans.is_empty() {
|
||||
let mut combined = context_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !context_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(context_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !context_spans.is_empty() && !cost_spans.is_empty() {
|
||||
let mut combined = context_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !cache_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cache_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() && !cost_spans.is_empty() {
|
||||
let mut combined = coherence_spans.clone();
|
||||
combined.push(Span::raw(" "));
|
||||
combined.extend(cost_spans.clone());
|
||||
candidates.push(combined);
|
||||
}
|
||||
if !coherence_spans.is_empty() {
|
||||
candidates.push(coherence_spans);
|
||||
}
|
||||
if !context_spans.is_empty() {
|
||||
candidates.push(context_spans);
|
||||
}
|
||||
@@ -3624,6 +3678,18 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn footer_coherence_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let (label, color) = match app.coherence_state {
|
||||
CoherenceState::Healthy => ("coherence healthy", palette::DEEPSEEK_SKY),
|
||||
CoherenceState::GettingCrowded => ("coherence crowded", palette::STATUS_WARNING),
|
||||
CoherenceState::RefreshingContext => ("coherence refreshing", palette::STATUS_WARNING),
|
||||
CoherenceState::VerifyingRecentWork => ("coherence verifying", palette::DEEPSEEK_SKY),
|
||||
CoherenceState::ResettingPlan => ("coherence resetting", palette::STATUS_ERROR),
|
||||
};
|
||||
|
||||
vec![Span::styled(label.to_string(), Style::default().fg(color))]
|
||||
}
|
||||
|
||||
fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let Some(hit_tokens) = app.last_prompt_cache_hit_tokens else {
|
||||
return Vec::new();
|
||||
|
||||
@@ -385,6 +385,38 @@ fn footer_status_line_spans_truncate_long_model_names() {
|
||||
assert!(UnicodeWidthStr::width(line.as_str()) <= 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_coherence_chip_snapshots_plain_language_ladder() {
|
||||
let mut app = create_test_app();
|
||||
let cases = [
|
||||
(
|
||||
crate::core::coherence::CoherenceState::Healthy,
|
||||
"coherence healthy",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::GettingCrowded,
|
||||
"coherence crowded",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::RefreshingContext,
|
||||
"coherence refreshing",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::VerifyingRecentWork,
|
||||
"coherence verifying",
|
||||
),
|
||||
(
|
||||
crate::core::coherence::CoherenceState::ResettingPlan,
|
||||
"coherence resetting",
|
||||
),
|
||||
];
|
||||
|
||||
for (state, expected) in cases {
|
||||
app.coherence_state = state;
|
||||
assert_eq!(spans_text(&footer_coherence_spans(&app)), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_prioritize_context_when_busy() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
+11
-2
@@ -1,6 +1,9 @@
|
||||
# Configuration
|
||||
|
||||
DeepSeek TUI reads configuration from a TOML file plus environment variables.
|
||||
At process startup it also loads a workspace-local `.env` file when present.
|
||||
Use the tracked `.env.example` as the template; copy it to `.env`, then edit
|
||||
only the provider and safety knobs you need.
|
||||
|
||||
## Where It Looks
|
||||
|
||||
@@ -73,8 +76,9 @@ These override config values:
|
||||
- `DEEPSEEK_PROVIDER` (`deepseek|nvidia-nim`)
|
||||
- `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL`
|
||||
- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`)
|
||||
- `NVIDIA_BASE_URL` or `NVIDIA_NIM_BASE_URL`
|
||||
- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`
|
||||
- `NVIDIA_NIM_MODEL`
|
||||
- `DEEPSEEK_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)
|
||||
- `DEEPSEEK_SKILLS_DIR`
|
||||
- `DEEPSEEK_MCP_CONFIG`
|
||||
- `DEEPSEEK_NOTES_PATH`
|
||||
@@ -129,6 +133,10 @@ Readability semantics:
|
||||
|
||||
- Selection uses a unified style across transcript, composer menus, and modals.
|
||||
- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.
|
||||
- The footer includes a compact `coherence` chip that describes how stable and
|
||||
focused the current session is right now. Possible states are `healthy`,
|
||||
`crowded`, `refreshing`, `verifying`, and `resetting`; these are derived from
|
||||
capacity and compaction events without exposing internal formulas in normal UI.
|
||||
|
||||
### Command Migration Notes
|
||||
|
||||
@@ -272,7 +280,8 @@ text.
|
||||
|
||||
- `--status` — print a compact one-screen status (api key, base URL, model,
|
||||
MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and
|
||||
network-free; safe to run in CI.
|
||||
network-free; safe to run in CI. If `.env` is missing and `.env.example` is
|
||||
present in the workspace, the status output points at `cp .env.example .env`.
|
||||
- `--tools` — scaffold `~/.deepseek/tools/` with a `README.md` describing the
|
||||
self-describing frontmatter convention (`# name:` / `# description:` /
|
||||
`# usage:`) and an `example.sh` that follows it. The directory is
|
||||
|
||||
@@ -28,6 +28,7 @@ The runtime uses a durable Thread/Turn/Item lifecycle.
|
||||
- `ThreadRecord`
|
||||
- `id`, `created_at`, `updated_at`
|
||||
- `model`, `workspace`, `mode`
|
||||
- `coherence_state`: `healthy|getting_crowded|refreshing_context|verifying_recent_work|resetting_plan`
|
||||
- `system_prompt` (optional text)
|
||||
- `latest_turn_id`, `latest_response_bookmark`, `archived`
|
||||
- `TurnRecord`
|
||||
@@ -194,11 +195,19 @@ Common event names:
|
||||
- `item.interrupted`
|
||||
- `approval.required`
|
||||
- `sandbox.denied`
|
||||
- `coherence.state`
|
||||
|
||||
Compaction visibility:
|
||||
- auto compaction emits `item.started`/`item.completed` with item kind `context_compaction` and `auto=true`
|
||||
- manual compaction emits the same with `auto=false`
|
||||
|
||||
Coherence visibility:
|
||||
- `coherence.state` is a machine-readable session-health signal derived from
|
||||
existing capacity and compaction events. The payload includes `state`,
|
||||
`label`, `description`, `reason`, and the updated `thread`.
|
||||
- Normal clients should show the `label` or `description`, not internal
|
||||
capacity scores or formulas.
|
||||
|
||||
### Background Tasks
|
||||
|
||||
- `GET /v1/tasks`
|
||||
|
||||
Reference in New Issue
Block a user