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:
Generated
+13
-13
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user