From 29141bc89ba00a56cf63ca00710ac1e29a289763 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 25 Apr 2026 07:21:43 -0500 Subject: [PATCH] Add NIM env support and .env.example template --- .claude/next-agent-prompt.md | 111 ++++++++++++++ .env.example | 44 ++++++ .gitignore | 1 + crates/config/src/lib.rs | 23 +++ crates/tui/src/commands/provider.rs | 5 +- crates/tui/src/config.rs | 83 +++++++++- crates/tui/src/core/coherence.rs | 149 ++++++++++++++++++ crates/tui/src/core/engine.rs | 227 ++++++++++++++++------------ crates/tui/src/core/events.rs | 9 ++ crates/tui/src/core/mod.rs | 1 + crates/tui/src/deepseek_theme.rs | 200 ++++++++++++++++++++++++ crates/tui/src/logging.rs | 37 +++++ crates/tui/src/main.rs | 99 +++++++++++- crates/tui/src/runtime_threads.rs | 50 ++++++ crates/tui/src/tui/app.rs | 6 + crates/tui/src/tui/history.rs | 153 ++++++++++++++++--- crates/tui/src/tui/ui.rs | 98 ++++++++++-- crates/tui/src/tui/ui/tests.rs | 32 ++++ docs/CONFIGURATION.md | 13 +- docs/RUNTIME_API.md | 9 ++ 20 files changed, 1202 insertions(+), 148 deletions(-) create mode 100644 .claude/next-agent-prompt.md create mode 100644 .env.example create mode 100644 crates/tui/src/core/coherence.rs create mode 100644 crates/tui/src/deepseek_theme.rs diff --git a/.claude/next-agent-prompt.md b/.claude/next-agent-prompt.md new file mode 100644 index 00000000..484e6892 --- /dev/null +++ b/.claude/next-agent-prompt.md @@ -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], , ```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 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2bf024a3 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 6a1b8dd8..a5aa4a35 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ # Development .env .env.* +!.env.example node_modules/ .vscode/ .idea/ diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 1e47c770..8d04f9c2 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -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, nvidia_api_key: Option, nvidia_nim_api_key: Option, + nim_base_url: Option, nvidia_base_url: Option, nvidia_nim_base_url: Option, } @@ -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(); diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 2fa19617..7846d70e 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -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 { diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 5fd76d6b..a4428279 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -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, nvidia_api_key: Option, nvidia_nim_api_key: Option, + nim_base_url: Option, nvidia_base_url: Option, nvidia_nim_base_url: Option, nvidia_nim_model: Option, @@ -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(); diff --git a/crates/tui/src/core/coherence.rs b/crates/tui/src/core/coherence.rs new file mode 100644 index 00000000..41ca9f0e --- /dev/null +++ b/crates/tui/src/core/coherence.rs @@ -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, + ] + ); + } +} diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index de2fd19f..27d0aee0 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -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>, tool_exec_lock: Arc>, 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) { + 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, + messages_after: Option, + ) { + 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( diff --git a/crates/tui/src/core/events.rs b/crates/tui/src/core/events.rs index 290ab97b..29e4698e 100644 --- a/crates/tui/src/core/events.rs +++ b/crates/tui/src/core/events.rs @@ -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 }, diff --git a/crates/tui/src/core/mod.rs b/crates/tui/src/core/mod.rs index 4f01ccfb..7e995bad 100644 --- a/crates/tui/src/core/mod.rs +++ b/crates/tui/src/core/mod.rs @@ -11,6 +11,7 @@ pub mod capacity; pub mod capacity_memory; +pub mod coherence; pub mod engine; pub mod events; pub mod ops; diff --git a/crates/tui/src/deepseek_theme.rs b/crates/tui/src/deepseek_theme.rs new file mode 100644 index 00000000..4564983e --- /dev/null +++ b/crates/tui/src/deepseek_theme.rs @@ -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 + ); + } +} diff --git a/crates/tui/src/logging.rs b/crates/tui/src/logging.rs index c6a806c9..bdb10813 100644 --- a/crates/tui/src/logging.rs +++ b/crates/tui/src/logging.rs @@ -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) { 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")); + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 8badfbc5..1c57dcfe 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -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 { + 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. diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 1b856626..4307950a 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -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, + #[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)?; diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 565b248c..55a9d710 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -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, /// 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, } @@ -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(); diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 54a5d717..4de6f4f5 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -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) -> 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: " Plan " + 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: ["▏ ", ":", " ", ""] + 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::() + }) + .collect::>(); + 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)); + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a33d6f9d..a0f343e6 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -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> = 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 Vec> { + 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> { }; 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> { 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> { .unwrap_or_default() } +fn footer_coherence_spans(app: &App) -> Vec> { + 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> { let Some(hit_tokens) = app.last_prompt_cache_hit_tokens else { return Vec::new(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 186a23f8..605b6319 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e76818ef..cefa4cbf 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index 053b996b..dea28805 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -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`