release: v0.5.0 — fix multi-turn tool call 400 error (missing reasoning_content on assistant messages with tool_calls)

This commit is contained in:
Hunter Bown
2026-04-25 12:21:01 -05:00
parent 67b232b063
commit 19f8d83d3b
17 changed files with 257 additions and 205 deletions
Generated
+13 -13
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
+6 -6
View File
@@ -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
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+142 -28
View File
@@ -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<Value> {
}
}
/// 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<String> = 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"
);
}
+12 -3
View File
@@ -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;
+32 -104
View File
@@ -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<Span<'static>> {
// 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<Span<'static>> {
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<Span<'static>>> = [&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<Span<'static>> {
// 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<Span<'static>> {
)]
}
fn footer_context_spans(app: &App) -> Vec<Span<'static>> {
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,
+17 -20
View File
@@ -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]
+7 -4
View File
@@ -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))]
}
+3 -2
View File
@@ -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)
+2 -2
View File
@@ -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",