From 19f8d83d3bb21d29c23f9bc80925ca9f2650700a Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 25 Apr 2026 12:21:01 -0500 Subject: [PATCH] =?UTF-8?q?release:=20v0.5.0=20=E2=80=94=20fix=20multi-tur?= =?UTF-8?q?n=20tool=20call=20400=20error=20(missing=20reasoning=5Fcontent?= =?UTF-8?q?=20on=20assistant=20messages=20with=20tool=5Fcalls)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 26 ++-- Cargo.toml | 2 +- crates/agent/Cargo.toml | 2 +- crates/app-server/Cargo.toml | 18 +-- crates/cli/Cargo.toml | 12 +- crates/core/Cargo.toml | 16 +-- crates/execpolicy/Cargo.toml | 2 +- crates/hooks/Cargo.toml | 2 +- crates/mcp/Cargo.toml | 2 +- crates/tools/Cargo.toml | 2 +- crates/tui/src/client.rs | 170 ++++++++++++++++++++++----- crates/tui/src/tui/app.rs | 15 ++- crates/tui/src/tui/ui.rs | 136 +++++---------------- crates/tui/src/tui/ui/tests.rs | 37 +++--- crates/tui/src/tui/widgets/header.rs | 11 +- crates/tui/src/tui/widgets/mod.rs | 5 +- npm/deepseek-tui/package.json | 4 +- 17 files changed, 257 insertions(+), 205 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80bcc66d..965d6bf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,7 +806,7 @@ dependencies = [ [[package]] name = "deepseek-agent" -version = "0.4.9" +version = "0.5.0" dependencies = [ "deepseek-config", "serde", @@ -814,7 +814,7 @@ dependencies = [ [[package]] name = "deepseek-app-server" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "axum", @@ -837,7 +837,7 @@ dependencies = [ [[package]] name = "deepseek-config" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "dirs", @@ -848,7 +848,7 @@ dependencies = [ [[package]] name = "deepseek-core" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "chrono", @@ -867,7 +867,7 @@ dependencies = [ [[package]] name = "deepseek-execpolicy" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "deepseek-protocol", @@ -876,7 +876,7 @@ dependencies = [ [[package]] name = "deepseek-hooks" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "async-trait", @@ -890,7 +890,7 @@ dependencies = [ [[package]] name = "deepseek-mcp" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "deepseek-protocol", @@ -900,7 +900,7 @@ dependencies = [ [[package]] name = "deepseek-protocol" -version = "0.4.9" +version = "0.5.0" dependencies = [ "serde", "serde_json", @@ -908,7 +908,7 @@ dependencies = [ [[package]] name = "deepseek-state" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "chrono", @@ -920,7 +920,7 @@ dependencies = [ [[package]] name = "deepseek-tools" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "async-trait", @@ -933,7 +933,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "arboard", @@ -987,7 +987,7 @@ dependencies = [ [[package]] name = "deepseek-tui-cli" -version = "0.4.9" +version = "0.5.0" dependencies = [ "anyhow", "chrono", @@ -1005,7 +1005,7 @@ dependencies = [ [[package]] name = "deepseek-tui-core" -version = "0.4.9" +version = "0.5.0" [[package]] name = "deranged" diff --git a/Cargo.toml b/Cargo.toml index eb90c8ab..79103afe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.4.9" +version = "0.5.0" edition = "2024" license = "MIT" repository = "https://github.com/Hmbown/DeepSeek-TUI" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index edb17e56..32ce966b 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -deepseek-config = { path = "../config", version = "0.4.9" } +deepseek-config = { path = "../config", version = "0.5.0" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 136de0bd..aeb2f403 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -deepseek-agent = { path = "../agent", version = "0.4.9" } -deepseek-config = { path = "../config", version = "0.4.9" } -deepseek-core = { path = "../core", version = "0.4.9" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.4.9" } -deepseek-hooks = { path = "../hooks", version = "0.4.9" } -deepseek-mcp = { path = "../mcp", version = "0.4.9" } -deepseek-protocol = { path = "../protocol", version = "0.4.9" } -deepseek-state = { path = "../state", version = "0.4.9" } -deepseek-tools = { path = "../tools", version = "0.4.9" } +deepseek-agent = { path = "../agent", version = "0.5.0" } +deepseek-config = { path = "../config", version = "0.5.0" } +deepseek-core = { path = "../core", version = "0.5.0" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" } +deepseek-hooks = { path = "../hooks", version = "0.5.0" } +deepseek-mcp = { path = "../mcp", version = "0.5.0" } +deepseek-protocol = { path = "../protocol", version = "0.5.0" } +deepseek-state = { path = "../state", version = "0.5.0" } +deepseek-tools = { path = "../tools", version = "0.5.0" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 71c766a5..d785df5f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,12 +14,12 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -deepseek-agent = { path = "../agent", version = "0.4.9" } -deepseek-app-server = { path = "../app-server", version = "0.4.9" } -deepseek-config = { path = "../config", version = "0.4.9" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.4.9" } -deepseek-mcp = { path = "../mcp", version = "0.4.9" } -deepseek-state = { path = "../state", version = "0.4.9" } +deepseek-agent = { path = "../agent", version = "0.5.0" } +deepseek-app-server = { path = "../app-server", version = "0.5.0" } +deepseek-config = { path = "../config", version = "0.5.0" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" } +deepseek-mcp = { path = "../mcp", version = "0.5.0" } +deepseek-state = { path = "../state", version = "0.5.0" } chrono.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9fce4bd9..98a6b8ab 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,14 +9,14 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -deepseek-agent = { path = "../agent", version = "0.4.9" } -deepseek-config = { path = "../config", version = "0.4.9" } -deepseek-execpolicy = { path = "../execpolicy", version = "0.4.9" } -deepseek-hooks = { path = "../hooks", version = "0.4.9" } -deepseek-mcp = { path = "../mcp", version = "0.4.9" } -deepseek-protocol = { path = "../protocol", version = "0.4.9" } -deepseek-state = { path = "../state", version = "0.4.9" } -deepseek-tools = { path = "../tools", version = "0.4.9" } +deepseek-agent = { path = "../agent", version = "0.5.0" } +deepseek-config = { path = "../config", version = "0.5.0" } +deepseek-execpolicy = { path = "../execpolicy", version = "0.5.0" } +deepseek-hooks = { path = "../hooks", version = "0.5.0" } +deepseek-mcp = { path = "../mcp", version = "0.5.0" } +deepseek-protocol = { path = "../protocol", version = "0.5.0" } +deepseek-state = { path = "../state", version = "0.5.0" } +deepseek-tools = { path = "../tools", version = "0.5.0" } serde_json.workspace = true tokio.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 99732f7e..203589cd 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.9" } +deepseek-protocol = { path = "../protocol", version = "0.5.0" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 8b5b2469..2cfade24 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.9" } +deepseek-protocol = { path = "../protocol", version = "0.5.0" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 9271cac9..ac4ee06c 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -8,6 +8,6 @@ description = "MCP server lifecycle and tool proxy compatibility for DeepSeek wo [dependencies] anyhow.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.9" } +deepseek-protocol = { path = "../protocol", version = "0.5.0" } serde.workspace = true serde_json.workspace = true diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 73e75328..3eb99734 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -deepseek-protocol = { path = "../protocol", version = "0.4.9" } +deepseek-protocol = { path = "../protocol", version = "0.5.0" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 97389ea6..4815921a 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -801,6 +801,20 @@ impl LlmClient for DeepSeekClient { self.api_provider, ); + // Bulletproof final sanitizer: walk the wire payload and force + // `reasoning_content` onto any assistant message that has tool_calls + // but no reasoning_content. DeepSeek's thinking-mode API rejects + // such messages with a 400. This is the last line of defense after + // engine-side and build-side substitution; if either upstream path + // misses a case (e.g. a session restored from disk, a sub-agent + // adding messages directly, or a cached prefix mismatch), this pass + // still produces a valid request. + sanitize_thinking_mode_messages( + &mut body, + &request.model, + request.reasoning_effort.as_deref(), + ); + let url = api_url(&self.base_url, "chat/completions"); let response = self .send_with_retry(|| self.http_client.post(&url).json(&body)) @@ -809,6 +823,12 @@ impl LlmClient for DeepSeekClient { let status = response.status(); if !status.is_success() { let error_text = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await; + // If DeepSeek rejected for missing reasoning_content despite the + // sanitizer, dump the offending indices so we can diagnose where + // they came from on the next failure. + if error_text.contains("reasoning_content") { + log_thinking_mode_violations(&body); + } anyhow::bail!("SSE stream request failed: HTTP {status}: {error_text}"); } @@ -1423,17 +1443,16 @@ fn build_chat_messages_with_reasoning( let mut reasoning_content = thinking_parts.join("\n"); let has_text = !content.trim().is_empty(); let has_tool_calls = !tool_calls.is_empty(); - // DeepSeek thinking-mode rule: any assistant message that performed - // a tool call must keep its `reasoning_content` and replay it in - // ALL subsequent requests, including across new user turns. Final - // text-only answers may drop reasoning_content (the API ignores - // it). If a tool-call round somehow lost its reasoning_content - // (e.g. a session checkpoint from before this rule was enforced, - // or a sub-turn where the model emitted no reasoning text), + // DeepSeek thinking-mode rule: every assistant message in the + // conversation must carry its `reasoning_content` when thinking + // is enabled. The docs say non-tool-call messages' reasoning is + // "ignored", but the API still validates presence and rejects + // with a 400 if any assistant message is missing it. If reasoning + // was lost (e.g. a session checkpoint from before this rule was + // enforced, or a sub-turn with no streamed reasoning text), // substitute a non-empty placeholder so the API accepts the - // request. Dropping tool_calls instead would orphan matching - // tool_results and fragment the conversation chain. - let include_reasoning_for_turn = include_reasoning && has_tool_calls; + // request. + let include_reasoning_for_turn = include_reasoning; let mut has_reasoning = include_reasoning_for_turn && !reasoning_content.trim().is_empty(); if include_reasoning_for_turn && !has_reasoning { @@ -1660,6 +1679,81 @@ fn map_tool_choice_for_chat(choice: &Value) -> Option { } } +/// Final-pass sanitizer over the outgoing chat-completions JSON payload. +/// Forces a non-empty `reasoning_content` onto every `assistant` message that +/// carries `tool_calls`, when the model + effort combination requires it. +/// DeepSeek's thinking-mode API rejects such messages with a 400 error; +/// substituting a placeholder keeps the conversation chain intact. +fn sanitize_thinking_mode_messages(body: &mut Value, model: &str, effort: Option<&str>) { + if !should_replay_reasoning_content(model, effort) { + return; + } + let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else { + return; + }; + let mut substitutions: u32 = 0; + for (idx, msg) in messages.iter_mut().enumerate() { + if msg.get("role").and_then(Value::as_str) != Some("assistant") { + continue; + } + let needs_placeholder = msg + .get("reasoning_content") + .and_then(Value::as_str) + .is_none_or(|s| s.trim().is_empty()); + if needs_placeholder { + msg["reasoning_content"] = json!("(reasoning omitted)"); + substitutions = substitutions.saturating_add(1); + logging::warn(format!( + "Final sanitizer: forced reasoning_content placeholder on assistant[{idx}]", + )); + } + } + if substitutions > 0 { + logging::warn(format!( + "Final sanitizer: {substitutions} assistant message(s) needed reasoning_content placeholder", + )); + } +} + +/// Diagnostic logger fired when DeepSeek rejects the request despite the +/// sanitizer. Walks the body and logs which assistant messages have tool_calls +/// but no `reasoning_content` — useful to track down a code path that bypasses +/// the sanitizer entirely. +fn log_thinking_mode_violations(body: &Value) { + let Some(messages) = body.get("messages").and_then(Value::as_array) else { + logging::warn("400-after-sanitizer: body has no `messages` array"); + return; + }; + let mut violations: Vec = Vec::new(); + for (idx, msg) in messages.iter().enumerate() { + if msg.get("role").and_then(Value::as_str) != Some("assistant") { + continue; + } + let reasoning = msg + .get("reasoning_content") + .and_then(Value::as_str) + .unwrap_or(""); + let has_tc = msg.get("tool_calls").is_some(); + if reasoning.trim().is_empty() { + violations.push(format!( + "assistant[{idx}] (reasoning_content missing, tool_calls={})", + has_tc + )); + } + } + if violations.is_empty() { + logging::warn( + "400-after-sanitizer: all assistant messages have reasoning_content — DeepSeek rejected for a different reason", + ); + } else { + logging::warn(format!( + "400-after-sanitizer: {} assistant message(s) lack reasoning_content despite sanitizer: {}", + violations.len(), + violations.join(", ") + )); + } +} + fn requires_reasoning_content(model: &str) -> bool { let lower = model.to_lowercase(); lower.contains("deepseek-v3.2") @@ -2288,7 +2382,7 @@ mod tests { } #[test] - fn chat_messages_strip_reasoning_content_from_final_answer() { + fn chat_messages_keep_reasoning_content_on_all_assistant_messages() { let message = Message { role: "assistant".to_string(), content: vec![ @@ -2310,11 +2404,15 @@ mod tests { assistant.get("content").and_then(Value::as_str), Some("done") ); - assert!(assistant.get("reasoning_content").is_none()); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan"), + "thinking-mode models must keep reasoning_content on ALL assistant messages" + ); } #[test] - fn chat_messages_drop_thinking_only_assistant_for_chat_model() { + fn chat_messages_keep_thinking_only_assistant_for_v4_flash() { let message = Message { role: "assistant".to_string(), content: vec![ContentBlock::Thinking { @@ -2322,14 +2420,18 @@ mod tests { }], }; let out = build_chat_messages(None, &[message], "deepseek-v4-flash"); - assert!( - !out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("thinking-only assistant kept for V4 model"); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan") ); } #[test] - fn chat_messages_drop_thinking_only_assistant_for_reasoner_model() { + fn chat_messages_keep_thinking_only_assistant_for_v4_pro() { let message = Message { role: "assistant".to_string(), content: vec![ContentBlock::Thinking { @@ -2337,14 +2439,18 @@ mod tests { }], }; let out = build_chat_messages(None, &[message], "deepseek-v4-pro"); - assert!( - !out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("thinking-only assistant kept for V4 model"); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan") ); } #[test] - fn chat_messages_drop_thinking_only_assistant_for_r_series_model() { + fn chat_messages_keep_thinking_only_assistant_for_r_series_model() { let message = Message { role: "assistant".to_string(), content: vec![ContentBlock::Thinking { @@ -2352,9 +2458,13 @@ mod tests { }], }; let out = build_chat_messages(None, &[message], "deepseek-r2-lite-preview"); - assert!( - !out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + let assistant = out + .iter() + .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("thinking-only assistant kept for R-series model"); + assert_eq!( + assistant.get("reasoning_content").and_then(Value::as_str), + Some("plan") ); } @@ -2529,8 +2639,11 @@ mod tests { .rfind(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) .expect("final assistant message"); assert!( - final_assistant.get("reasoning_content").is_none(), - "final text answer can drop reasoning_content (API ignores it)" + final_assistant + .get("reasoning_content") + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()), + "all assistant messages must carry reasoning_content in thinking mode" ); } @@ -2859,10 +2972,11 @@ mod tests { thinking: "plan".to_string(), }], }; - let out = build_chat_messages(None, &[message], "deepseek-v4-mini"); + let out = build_chat_messages(None, &[message], "some-non-deepseek-model"); assert!( !out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) + .any(|value| value.get("role").and_then(Value::as_str) == Some("assistant")), + "non-reasoning model should drop thinking-only assistant" ); } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 55a9d710..3488a3de 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1073,9 +1073,18 @@ impl App { // Invalidate transcript cache (will be rebuilt on next render) self.transcript_cache = TranscriptViewCache::new(); - // Reset scroll to bottom to avoid invalid anchors - // (anchored cell indices may be invalid at new width) - self.transcript_scroll = TranscriptScroll::ToBottom; + // Preserve cell-level anchor through resize: line_in_cell depends + // on the old width so reset it to 0 (start of the same cell). + // ToBottom stays ToBottom; SpacerBeforeCell keeps its cell_index. + match self.transcript_scroll { + TranscriptScroll::Scrolled { cell_index, .. } => { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell: 0, + }; + } + TranscriptScroll::ToBottom | TranscriptScroll::ScrolledSpacerBeforeCell { .. } => {} + } // Clear pending scroll delta self.pending_scroll_delta = 0; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a0f343e6..40f7ad1b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3585,8 +3585,10 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { } fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { + // Context % is already shown in the header signal bar — don't + // duplicate it in the footer. The footer carries unique info only: + // coherence state, cache hit rate, and session cost. 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 { vec![Span::styled( @@ -3597,94 +3599,40 @@ fn footer_auxiliary_spans(app: &App, max_width: usize) -> Vec> { Vec::new() }; - 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(" ")); - combined.extend(cache_spans.clone()); - combined.push(Span::raw(" ")); - 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); - } - if !cache_spans.is_empty() { - candidates.push(cache_spans); - } - if !cost_spans.is_empty() { - candidates.push(cost_spans); - } - candidates.push(Vec::new()); + let parts: Vec<&Vec>> = [&coherence_spans, &cache_spans, &cost_spans] + .iter() + .filter(|spans| !spans.is_empty()) + .copied() + .collect(); - candidates - .into_iter() - .find(|spans| spans_width(spans) <= max_width) - .unwrap_or_default() + // Try to fit as many parts as possible, dropping from the end. + for end in (0..=parts.len()).rev() { + let mut combined = Vec::new(); + for (i, part) in parts[..end].iter().enumerate() { + if i > 0 { + combined.push(Span::raw(" ")); + } + combined.extend(part.iter().cloned()); + } + if spans_width(&combined) <= max_width { + return combined; + } + } + Vec::new() } fn footer_coherence_spans(app: &App) -> Vec> { + // Only show coherence when it's NOT healthy — normal operation + // needs no label; anomalies stand out. Renamed "crowded" to + // "high load" because the capacity model measures tool/action + // complexity, not context-window fullness, and "crowded" is + // confusing when the header shows 6% context. 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), + CoherenceState::Healthy => return Vec::new(), + CoherenceState::GettingCrowded => ("high load", palette::STATUS_WARNING), + CoherenceState::RefreshingContext => ("refreshing context", palette::STATUS_WARNING), + CoherenceState::VerifyingRecentWork => ("verifying", palette::DEEPSEEK_SKY), + CoherenceState::ResettingPlan => ("resetting plan", palette::STATUS_ERROR), }; vec![Span::styled(label.to_string(), Style::default().fg(color))] @@ -3707,26 +3655,6 @@ fn footer_cache_spans(app: &App) -> Vec> { )] } -fn footer_context_spans(app: &App) -> Vec> { - let (_, _, percent) = match context_usage_snapshot(app) { - Some(snapshot) => snapshot, - None => return Vec::new(), - }; - - let color = if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT { - palette::STATUS_ERROR - } else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT { - palette::STATUS_WARNING - } else { - palette::DEEPSEEK_SKY - }; - - vec![Span::styled( - format!("ctx {:.0}%", percent), - Style::default().fg(color), - )] -} - fn footer_toast_spans( toast: &crate::tui::app::StatusToast, max_width: usize, diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 605b6319..fa294e7e 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -386,28 +386,31 @@ fn footer_status_line_spans_truncate_long_model_names() { } #[test] -fn footer_coherence_chip_snapshots_plain_language_ladder() { +fn footer_coherence_chip_hides_healthy_and_uses_clear_labels() { let mut app = create_test_app(); + + app.coherence_state = crate::core::coherence::CoherenceState::Healthy; + assert!( + footer_coherence_spans(&app).is_empty(), + "healthy state should produce no footer chip" + ); + let cases = [ - ( - crate::core::coherence::CoherenceState::Healthy, - "coherence healthy", - ), ( crate::core::coherence::CoherenceState::GettingCrowded, - "coherence crowded", + "high load", ), ( crate::core::coherence::CoherenceState::RefreshingContext, - "coherence refreshing", + "refreshing context", ), ( crate::core::coherence::CoherenceState::VerifyingRecentWork, - "coherence verifying", + "verifying", ), ( crate::core::coherence::CoherenceState::ResettingPlan, - "coherence resetting", + "resetting plan", ), ]; @@ -418,7 +421,7 @@ fn footer_coherence_chip_snapshots_plain_language_ladder() { } #[test] -fn footer_auxiliary_spans_prioritize_context_when_busy() { +fn footer_auxiliary_spans_show_cache_when_compact() { let mut app = create_test_app(); app.is_loading = true; app.last_prompt_tokens = Some(48_000); @@ -426,19 +429,13 @@ fn footer_auxiliary_spans_prioritize_context_when_busy() { app.last_prompt_cache_miss_tokens = Some(12_000); app.session_cost = 12.34; - let compact = spans_text(&footer_auxiliary_spans(&app, 8)); - assert!(compact.contains("ctx")); - assert!(compact.contains('%')); + let compact = spans_text(&footer_auxiliary_spans(&app, 12)); + assert!(compact.contains("cache")); assert!(!compact.contains('$')); - - let roomy = spans_text(&footer_auxiliary_spans(&app, 20)); - assert!(roomy.contains("ctx")); - assert!(roomy.contains('%')); - assert!(roomy.contains("cache")); } #[test] -fn footer_auxiliary_spans_can_display_cache_and_cost_when_roomy() { +fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() { let mut app = create_test_app(); app.last_prompt_tokens = Some(48_000); app.last_prompt_cache_hit_tokens = Some(36_000); @@ -446,9 +443,9 @@ fn footer_auxiliary_spans_can_display_cache_and_cost_when_roomy() { app.session_cost = 12.34; let roomy = spans_text(&footer_auxiliary_spans(&app, 32)); - assert!(roomy.contains("ctx")); assert!(roomy.contains("cache 75%")); assert!(roomy.contains("$12.34")); + assert!(!roomy.contains("ctx"), "context % removed from footer — shown in header only"); } #[test] diff --git a/crates/tui/src/tui/widgets/header.rs b/crates/tui/src/tui/widgets/header.rs index 70f6e691..036b366d 100644 --- a/crates/tui/src/tui/widgets/header.rs +++ b/crates/tui/src/tui/widgets/header.rs @@ -210,15 +210,18 @@ impl<'a> HeaderWidget<'a> { if trimmed.is_empty() { return Vec::new(); } - let color = if trimmed.eq_ignore_ascii_case("off") { + let is_off = trimmed.eq_ignore_ascii_case("off"); + let color = if is_off { palette::TEXT_HINT } else { palette::DEEPSEEK_SKY }; - let body = if include_prefix { - format!("⚡{trimmed}") - } else { + let body = if !include_prefix { trimmed.to_string() + } else if trimmed.eq_ignore_ascii_case("max") || trimmed.eq_ignore_ascii_case("maximum") { + format!("\u{1F433} {trimmed}") + } else { + format!("\u{00B7} {trimmed}") }; vec![Span::styled(body, Style::default().fg(color))] } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a5854a52..56a52968 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -139,8 +139,9 @@ impl Renderable for ChatWidget { paragraph.render(self.content_area, buf); if let Some(scrollbar) = self.scrollbar { - let mut state = ScrollbarState::new(scrollbar.total) - .position(scrollbar.top) + let scrollable_range = scrollbar.total.saturating_sub(scrollbar.visible); + let mut state = ScrollbarState::new(scrollable_range) + .position(scrollbar.top.min(scrollable_range)) .viewport_content_length(scrollbar.visible); Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) diff --git a/npm/deepseek-tui/package.json b/npm/deepseek-tui/package.json index e29dabe7..f2a9b5a1 100644 --- a/npm/deepseek-tui/package.json +++ b/npm/deepseek-tui/package.json @@ -1,7 +1,7 @@ { "name": "deepseek-tui", - "version": "0.4.9", - "deepseekBinaryVersion": "0.4.9", + "version": "0.5.0", + "deepseekBinaryVersion": "0.5.0", "description": "Install and run deepseek and deepseek-tui binaries from GitHub release artifacts.", "author": "Hmbown", "license": "MIT",