Merge pull request #18 from Hmbown/codex/deepseek-tui-app-working

Verify app smoke and add NIM env support
This commit is contained in:
Hunter Bown
2026-04-25 07:46:41 -05:00
20 changed files with 1202 additions and 148 deletions
+111
View File
@@ -0,0 +1,111 @@
You are working in /Volumes/VIXinSSD/deepseek-tui.
Goal:
Continue improving deepseek-tui as a first-class DeepSeek V4 coding harness, building directly on the previous overnight slice (commit f190ab74 + merge aa356e8b + import fix f9ede57c, plus release commit d7944421 / tag v0.4.6). This time, ACTUALLY read /Volumes/VIXinSSD/mathcode — the previous run claimed it wasn't mounted and adapted patterns from the work-order description instead. mathcode IS mounted; the directory exists and is readable.
What already shipped (do not redo):
- Slice A: setup --status, setup --clean (with --force gate), setup --tools, setup --plugins, doctor --json, init_tools_dir / init_plugins_dir scaffolding with frontmatter examples (# name: / # description: / # usage: in tools/example.sh; ----delimited YAML in plugins/example/PLUGIN.md). Code in crates/tui/src/main.rs.
- Slice B: fake-wrapper hostility — TOOL_CALL_START_MARKERS/END_MARKERS + filter_tool_call_delta promoted to pub(crate); FAKE_WRAPPER_NOTICE + contains_fake_tool_wrapper(); per-turn fake_wrapper_notice_emitted flag in handle_deepseek_turn. Code in crates/tui/src/core/engine.rs. Locked by crates/tui/tests/protocol_recovery.rs.
- Slice C: docs in docs/CONFIGURATION.md (Setup status, clean, and extension dirs section + Why the engine strips XML/[TOOL_CALL] text section) and README.md quick-checks block.
- 37 new tests landed (23 unit + 14 integration). All green.
Read first (in this order):
1. AGENTS.md
2. git status --short --branch (expect clean tree on main, 0 ahead of origin/main; a stash from a prior session may exist — leave it alone)
3. README.md
4. docs/ARCHITECTURE.md
5. docs/CONFIGURATION.md (note the new Setup status / extension dirs / Why the engine strips ... sections)
6. docs/RUNTIME_API.md
7. crates/tui/src/main.rs (large file; pay attention to the new run_setup_status, run_setup_clean, init_tools_dir, init_plugins_dir, run_doctor, run_doctor_json helpers — do not duplicate them)
8. crates/tui/src/core/engine.rs (new: TOOL_CALL_START_MARKERS, TOOL_CALL_END_MARKERS, filter_tool_call_delta, FAKE_WRAPPER_NOTICE, contains_fake_tool_wrapper)
9. crates/tui/src/core/engine/tests.rs
10. crates/tui/tests/protocol_recovery.rs
11. crates/tui/src/client.rs
12. crates/tui/src/tui/widgets/mod.rs
13. crates/tui/src/core/capacity.rs (existing compaction decisions — relevant for issue #6)
Then ACTUALLY read mathcode (it IS mounted):
14. /Volumes/VIXinSSD/mathcode/README.md
15. /Volumes/VIXinSSD/mathcode/setup.sh (~10KB; full status/clean/help flag matrix and error messages worth borrowing)
16. /Volumes/VIXinSSD/mathcode/run (763B; thin wrapper that loads .env and dispatches)
17. /Volumes/VIXinSSD/mathcode/.env.example (~6KB; clear provider/backend comments — note our codebase only loads .env via dotenvy::dotenv() in crates/tui/src/main.rs:475 and has no .env.example)
18. /Volumes/VIXinSSD/mathcode/skills/README.md
19. /Volumes/VIXinSSD/mathcode/plugins/README.md
20. /Volumes/VIXinSSD/mathcode/tools/ (list contents; look for self-describing tool scripts)
21. /Volumes/VIXinSSD/mathcode/bin/ (list contents)
Open GitHub issue spine (use as roadmap):
- #6 Coherence as plain-language session health ← previous slice's recommended next step
- #7 Long-session evals for coherence/context handling
- #8 Workspace extraction/runtime seams
- #9 Thread/turn/item protocol stability
- #10 Sandboxing, approvals, server safety
- #11 Skills/plugins/MCP installability and management (partly done; doctor + status now report tools/plugins; install/manage UX still missing)
- #12 DeepSeek API key setup/provider drift (partly done; setup --status surfaces source — but no .env.example, no provider-aware setup wizard)
- #13 Observability for agent quality/coherence events (partly done; fake-wrapper strip emits a status event — coherence ladder still missing)
- #14 Whale/DeepSeek TUI design system
- #15 Thin IDE companions over runtime API
- #16 Public roadmap/docs rewrite
Pick 1-3 high-leverage slices that can be finished and tested tonight. Strongly preferred (in priority order):
Slice 1 (recommended — issue #6 + #13): Plain-language coherence ladder.
- The runtime already emits compaction events (CompactionStarted/Completed/Failed) and capacity decisions live in crates/tui/src/core/capacity.rs.
- Add an Event::CoherenceState variant with a small fixed ladder: Healthy / GettingCrowded / RefreshingContext / VerifyingRecentWork / ResettingPlan.
- Emit it from one place (engine), driven by existing capacity decisions and compaction events. Do NOT add a new background task.
- Surface it in the TUI footer as a single chip (right-aligned, terminal-native, no emoji) and on the runtime API thread shape.
- Snapshot-test the footer chip for each state and unit-test the state transitions from a synthetic capacity event log.
- Land docs in docs/RUNTIME_API.md (CoherenceState shape) and docs/CONFIGURATION.md (footer chip).
Slice 2 (issue #12 + mathcode reference): .env.example + setup wizard messages.
- Adapt /Volumes/VIXinSSD/mathcode/.env.example structure (provider/backend grouping, comment style) to a deepseek-tui .env.example at repo root.
- Cover: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL (global vs china), DEEPSEEK_MODEL, NVIDIA_API_KEY (NIM), NIM_BASE_URL, RUST_LOG, sandbox toggles.
- Update setup --status to point at .env.example when no .env is found ("Run `cp .env.example .env` and edit").
- Do NOT add a `run` wrapper — dotenvy handles .env loading inside the binary, the wrapper is redundant and introduces drift.
- Lock the .env.example shape with a test that asserts every documented variable is referenced from at least one source file.
Slice 3 (issue #14 / TUI render polish): Whale/DeepSeek design tokens.
- Extract the current TUI color/border/padding choices into a single deepseek_theme module (light + dark variants), without changing any visible output yet.
- Add a snapshot test or two that lock the existing rendering of one plan cell and one tool cell.
- Out of scope for tonight: actually changing colors. Goal is to make a future skin-swap a 5-line change.
DO NOT pick:
- Anything that re-touches NIM provider, V4 reasoning context accounting, or tool-call routing — that work is finished and protected by tests.
- Anything that re-introduces multi_tool_use.parallel.
- Anything that introduces an alternate fake-wrapper code path (XML, [TOOL_CALL], <function_calls>, ```tool_code, ```python, etc.). The engine's contract is: API tool channel only, all five wrapper shapes get stripped + emit a notice.
- Broad rewrites of main.rs, the engine, or the TUI app loop.
- A `run` shell wrapper. (.env loading is already in-binary via dotenvy.)
Hard rules / hostility budget:
- Prompts must never instruct or imply that the assistant should emit XML/markdown/Replit-style fake tool calls. If you touch crates/tui/src/prompts/*.txt, the rule is: API tool channel only, never wrapped tool calls in assistant text.
- Protocol recovery must remain visible. If you change how filter_tool_call_delta or FAKE_WRAPPER_NOTICE works, update crates/tui/tests/protocol_recovery.rs in the same commit and keep at least one assertion that all 5 marker pairs are present.
- UI must stay dense and terminal-native. No emoji, no decorative box drawing for data, no gratuitous color. DeepSeek-specific copy (Whale, V4) is welcome.
- Show, don't hide, runtime problems. New events should be compact (single-line where possible) and machine-readable as structured fields.
Stop rules:
- Do not revert any user/unrelated work; the previous run already merged and pushed (current main HEAD: d7944421, tag v0.4.6).
- Do not push tags or trigger releases. Local commits + push to a feature branch is fine; the maintainer handles release decisions.
- Do not delete or modify the existing stash (if any).
- If a live provider is down/unpaid, use fixtures/mocks and say so explicitly in the report.
- If a gate fails from pre-existing unrelated work, isolate it with evidence — don't paper over.
- Pre-existing test cluster (8 tests in tools::git*, tools::diagnostics, tui::ui::tests::workspace_context_refresh*) fails because of a sandbox commit-signing issue with `git commit ... -S`. Document but do not "fix" by globally setting commit.gpgsign=false — fix narrowly inside init_git_repo at crates/tui/src/tui/ui/tests.rs:158 if you touch it.
Required verification before final report:
- cargo fmt --all -- --check
- cargo check --workspace --all-targets --locked
- cargo test --workspace --all-features --locked
- cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- cargo build --release --locked -p deepseek-tui-cli -p deepseek-tui
Live smoke (optional, time-boxed at 60s):
- If DEEPSEEK_API_KEY or NVIDIA_API_KEY is set in the env, run a single bounded turn against the relevant provider with a hard token cap and report the first 30 lines of output. Otherwise skip and say so.
Final report headings (use exactly these):
1. Summary
2. Implemented Changes
3. GitHub Issues Advanced
4. MathCode Reference Points Used (be honest: list which mathcode files you actually read and which patterns you adapted vs deliberately skipped, with one-sentence rationale per skip)
5. Tests/Gates
6. Live Smoke Result
7. Remaining Risks
8. Next Best Slice
+44
View File
@@ -0,0 +1,44 @@
# DeepSeek TUI environment
# Shell-exported variables override values in this file.
# Copy this file to `.env`, then uncomment only the values you want to use.
# DeepSeek API (default provider)
# Get an API key from DeepSeek, then keep it local in `.env`.
DEEPSEEK_API_KEY=
# Global endpoint:
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# China endpoint:
# DEEPSEEK_BASE_URL=https://api.deepseeki.com
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
# to the current V4 flash model in the TUI.
# DEEPSEEK_MODEL=deepseek-v4-pro
# DEEPSEEK_MODEL=deepseek-v4-flash
# NVIDIA NIM-hosted DeepSeek V4
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
# DEEPSEEK_PROVIDER=nvidia-nim
# NVIDIA_API_KEY=
# NVIDIA_NIM_API_KEY=
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
# Logging
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
# TUI's lightweight verbose logs for info/debug/trace directives.
# DEEPSEEK_LOG_LEVEL=debug
# RUST_LOG=deepseek_tui=debug
# Agent safety defaults
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
# inside the workspace unless a sandbox elevation path is explicitly used.
# DEEPSEEK_APPROVAL_POLICY=on-request
# DEEPSEEK_SANDBOX_MODE=workspace-write
# DEEPSEEK_ALLOW_SHELL=true
# Optional extension paths
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
+1
View File
@@ -11,6 +11,7 @@
# Development
.env
.env.*
!.env.example
node_modules/
.vscode/
.idea/
+23
View File
@@ -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();
+1 -4
View File
@@ -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 {
+79 -4
View File
@@ -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();
+149
View File
@@ -0,0 +1,149 @@
//! Plain-language session coherence state derived from capacity events.
use serde::{Deserialize, Serialize};
use crate::core::capacity::{GuardrailAction, RiskBand};
/// User-facing coherence ladder for session health.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoherenceState {
#[default]
Healthy,
GettingCrowded,
RefreshingContext,
VerifyingRecentWork,
ResettingPlan,
}
impl CoherenceState {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::GettingCrowded => "getting crowded",
Self::RefreshingContext => "refreshing context",
Self::VerifyingRecentWork => "verifying recent work",
Self::ResettingPlan => "resetting plan",
}
}
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::Healthy => "The session is stable and focused.",
Self::GettingCrowded => "The session is approaching context pressure.",
Self::RefreshingContext => "The engine is refreshing context before continuing.",
Self::VerifyingRecentWork => {
"The engine is checking recent tool results before continuing."
}
Self::ResettingPlan => {
"The engine is rebuilding from canonical context and replanning."
}
}
}
}
/// Synthetic input to the coherence reducer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoherenceSignal {
CapacityDecision {
risk_band: RiskBand,
action: GuardrailAction,
cooldown_blocked: bool,
},
CapacityIntervention {
action: GuardrailAction,
},
CompactionStarted,
CompactionCompleted,
CompactionFailed,
}
/// Pure transition function for the plain-language coherence ladder.
#[must_use]
pub fn next_coherence_state(current: CoherenceState, signal: CoherenceSignal) -> CoherenceState {
match signal {
CoherenceSignal::CompactionStarted => CoherenceState::RefreshingContext,
CoherenceSignal::CompactionCompleted => CoherenceState::Healthy,
CoherenceSignal::CompactionFailed => CoherenceState::GettingCrowded,
CoherenceSignal::CapacityIntervention { action }
| CoherenceSignal::CapacityDecision { action, .. } => match action {
GuardrailAction::NoIntervention => match signal {
CoherenceSignal::CapacityDecision {
risk_band,
cooldown_blocked,
..
} => {
if cooldown_blocked {
return current;
}
match risk_band {
RiskBand::Low => CoherenceState::Healthy,
RiskBand::Medium | RiskBand::High => CoherenceState::GettingCrowded,
}
}
_ => current,
},
GuardrailAction::TargetedContextRefresh => CoherenceState::RefreshingContext,
GuardrailAction::VerifyWithToolReplay => CoherenceState::VerifyingRecentWork,
GuardrailAction::VerifyAndReplan => CoherenceState::ResettingPlan,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthetic_capacity_event_log_drives_plain_language_ladder() {
let log = [
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Low,
action: GuardrailAction::NoIntervention,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Medium,
action: GuardrailAction::NoIntervention,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::Medium,
action: GuardrailAction::TargetedContextRefresh,
cooldown_blocked: false,
},
CoherenceSignal::CompactionCompleted,
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::High,
action: GuardrailAction::VerifyWithToolReplay,
cooldown_blocked: false,
},
CoherenceSignal::CapacityDecision {
risk_band: RiskBand::High,
action: GuardrailAction::VerifyAndReplan,
cooldown_blocked: false,
},
];
let mut state = CoherenceState::Healthy;
let mut states = Vec::new();
for signal in log {
state = next_coherence_state(state, signal);
states.push(state);
}
assert_eq!(
states,
vec![
CoherenceState::Healthy,
CoherenceState::GettingCrowded,
CoherenceState::RefreshingContext,
CoherenceState::Healthy,
CoherenceState::VerifyingRecentWork,
CoherenceState::ResettingPlan,
]
);
}
}
+129 -98
View File
@@ -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(
+9
View File
@@ -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 },
+1
View File
@@ -11,6 +11,7 @@
pub mod capacity;
pub mod capacity_memory;
pub mod coherence;
pub mod engine;
pub mod events;
pub mod ops;
+200
View File
@@ -0,0 +1,200 @@
//! Whale/DeepSeek terminal theme tokens.
//!
//! A small, deliberately flat module that names the color, border, and
//! padding choices the TUI is already making. The dark variant matches the
//! values previously hard-coded against [`crate::palette`]; the light variant
//! is reserved for the future skin swap that issue #14 tracks. Visible output
//! is not changed by introducing this module.
//!
//! The only consumers today are the plan and tool cell renderers in
//! [`crate::tui::history`] and the sidebar section chrome in
//! [`crate::tui::ui`]. All other call sites continue to use [`crate::palette`]
//! directly until they are migrated in a later slice.
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{BorderType, Borders, Padding};
use crate::palette;
use crate::tui::history::ToolStatus;
/// Visual variant exposed by the theme.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Variant {
Dark,
/// Reserved for the future skin swap (issue #14). Not wired up yet.
#[allow(dead_code)]
Light,
}
/// Centralized visual tokens for sidebar, plan, and tool rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Theme {
pub variant: Variant,
// Sidebar / section chrome
pub section_borders: Borders,
pub section_border_type: BorderType,
pub section_border_color: Color,
pub section_bg: Color,
pub section_title_color: Color,
pub section_padding: Padding,
// Tool cell color tokens
pub tool_title_color: Color,
pub tool_value_color: Color,
pub tool_label_color: Color,
pub tool_running_accent: Color,
pub tool_success_accent: Color,
pub tool_failed_accent: Color,
// Plan cell color tokens
pub plan_progress_color: Color,
pub plan_summary_color: Color,
pub plan_explanation_color: Color,
pub plan_pending_color: Color,
pub plan_in_progress_color: Color,
pub plan_completed_color: Color,
}
impl Theme {
/// The current dark theme. Visible output today uses these values.
#[must_use]
pub const fn dark() -> Self {
Self {
variant: Variant::Dark,
section_borders: Borders::ALL,
section_border_type: BorderType::Plain,
section_border_color: palette::BORDER_COLOR,
section_bg: palette::DEEPSEEK_INK,
section_title_color: palette::DEEPSEEK_BLUE,
section_padding: Padding::uniform(1),
tool_title_color: palette::TEXT_SOFT,
tool_value_color: palette::TEXT_MUTED,
tool_label_color: palette::TEXT_DIM,
tool_running_accent: palette::ACCENT_TOOL_LIVE,
tool_success_accent: palette::TEXT_DIM,
tool_failed_accent: palette::ACCENT_TOOL_ISSUE,
plan_progress_color: palette::STATUS_SUCCESS,
plan_summary_color: palette::TEXT_MUTED,
plan_explanation_color: palette::TEXT_DIM,
plan_pending_color: palette::TEXT_MUTED,
plan_in_progress_color: palette::STATUS_WARNING,
plan_completed_color: palette::STATUS_SUCCESS,
}
}
/// The light variant. Same RGB values as `dark()` today so a future skin
/// swap is a single-source-of-truth change in this file. Out of scope:
/// actually picking a distinct light palette.
#[must_use]
#[allow(dead_code)]
pub const fn light() -> Self {
Self {
variant: Variant::Light,
..Self::dark()
}
}
/// Pick the right tool accent for a given [`ToolStatus`].
#[must_use]
pub const fn tool_status_color(self, status: ToolStatus) -> Color {
match status {
ToolStatus::Running => self.tool_running_accent,
ToolStatus::Success => self.tool_success_accent,
ToolStatus::Failed => self.tool_failed_accent,
}
}
/// Bold tool title style (e.g. "Plan", "Shell").
#[must_use]
pub fn tool_title_style(self) -> Style {
Style::default()
.fg(self.tool_title_color)
.add_modifier(Modifier::BOLD)
}
/// Right-side status text ("running", "done", "issue") style.
#[must_use]
pub fn tool_status_style(self, status: ToolStatus) -> Style {
Style::default().fg(self.tool_status_color(status))
}
/// Detail label style ("command:", "time:", step markers).
#[must_use]
pub fn tool_label_style(self) -> Style {
Style::default().fg(self.tool_label_color)
}
/// Default value style for tool detail rows.
#[must_use]
pub fn tool_value_style(self) -> Style {
Style::default().fg(self.tool_value_color)
}
}
/// Returns the active theme used by the TUI today.
///
/// Today this is always `Theme::dark()`. A future PR can wire this to an
/// `App` field or a config setting in five lines.
#[must_use]
pub const fn active_theme() -> Theme {
Theme::dark()
}
#[cfg(test)]
mod tests {
use super::{Theme, Variant, active_theme};
use crate::palette;
use crate::tui::history::ToolStatus;
#[test]
fn active_theme_returns_dark() {
assert_eq!(active_theme(), Theme::dark());
}
#[test]
fn dark_theme_matches_existing_palette_choices() {
let theme = Theme::dark();
assert_eq!(theme.variant, Variant::Dark);
assert_eq!(theme.section_border_color, palette::BORDER_COLOR);
assert_eq!(theme.section_bg, palette::DEEPSEEK_INK);
assert_eq!(theme.section_title_color, palette::DEEPSEEK_BLUE);
assert_eq!(theme.tool_title_color, palette::TEXT_SOFT);
assert_eq!(theme.tool_value_color, palette::TEXT_MUTED);
assert_eq!(theme.tool_label_color, palette::TEXT_DIM);
assert_eq!(theme.tool_running_accent, palette::ACCENT_TOOL_LIVE);
assert_eq!(theme.tool_success_accent, palette::TEXT_DIM);
assert_eq!(theme.tool_failed_accent, palette::ACCENT_TOOL_ISSUE);
}
#[test]
fn light_theme_keeps_dark_values_until_skin_swap() {
// The light variant exists so the future skin swap is a single-file
// change. It intentionally mirrors `dark()` today so introducing the
// module does not move pixels.
let dark = Theme::dark();
let light = Theme::light();
assert_eq!(light.variant, Variant::Light);
assert_eq!(light.section_border_color, dark.section_border_color);
assert_eq!(light.tool_running_accent, dark.tool_running_accent);
assert_eq!(light.tool_failed_accent, dark.tool_failed_accent);
assert_eq!(light.plan_in_progress_color, dark.plan_in_progress_color);
}
#[test]
fn tool_status_color_maps_each_status() {
let theme = Theme::dark();
assert_eq!(
theme.tool_status_color(ToolStatus::Running),
theme.tool_running_accent
);
assert_eq!(
theme.tool_status_color(ToolStatus::Success),
theme.tool_success_accent
);
assert_eq!(
theme.tool_status_color(ToolStatus::Failed),
theme.tool_failed_accent
);
}
}
+37
View File
@@ -12,6 +12,29 @@ pub fn set_verbose(enabled: bool) {
VERBOSE.store(enabled, Ordering::SeqCst);
}
/// Return true when supported env logging knobs request verbose output.
#[must_use]
pub fn env_requests_verbose_logging() -> bool {
std::env::var("DEEPSEEK_LOG_LEVEL")
.ok()
.is_some_and(|value| log_value_enables_verbose(&value))
|| std::env::var("RUST_LOG")
.ok()
.is_some_and(|value| log_value_enables_verbose(&value))
}
fn log_value_enables_verbose(value: &str) -> bool {
value.split(',').any(|directive| {
let level = directive
.rsplit('=')
.next()
.unwrap_or(directive)
.trim()
.to_ascii_lowercase();
matches!(level.as_str(), "trace" | "debug" | "info")
})
}
/// Check whether verbose logging is enabled.
#[must_use]
pub fn is_verbose() -> bool {
@@ -33,3 +56,17 @@ pub fn warn(message: impl AsRef<str>) {
eprintln!("{} {}", "warn".truecolor(r, g, b).bold(), message.as_ref());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_value_parser_accepts_common_rust_log_directives() {
assert!(log_value_enables_verbose("debug"));
assert!(log_value_enables_verbose("deepseek_cli=debug"));
assert!(log_value_enables_verbose("warn,deepseek_tui::client=trace"));
assert!(!log_value_enables_verbose("warn"));
assert!(!log_value_enables_verbose("deepseek_tui=off"));
}
}
+92 -7
View File
@@ -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.
+50
View File
@@ -19,6 +19,7 @@ use uuid::Uuid;
use crate::compaction::CompactionConfig;
use crate::config::{Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS};
use crate::core::coherence::CoherenceState;
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
use crate::core::ops::Op;
@@ -97,6 +98,8 @@ pub struct ThreadRecord {
pub archived: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default)]
pub coherence_state: CoherenceState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -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)?;
+6
View File
@@ -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
View File
@@ -8,6 +8,7 @@ use ratatui::text::{Line, Span};
use serde_json::Value;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::deepseek_theme::active_theme;
use crate::models::{ContentBlock, Message};
use crate::palette;
use crate::tools::review::ReviewOutput;
@@ -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
View File
@@ -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();
+32
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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`