fix(ui): stream thinking content during collapsed view (#861 RC4, #1324)

Closes the visibility gap reported in #1324 ("Thinking 思考内容不能流式
输出,只能等到完全输出后通过 ctrl+O 查看完整思考内容") and root cause 4
of #861.

Today `render_thinking` blanks the body whenever `collapsed && streaming`:

```rust
let body_text = if collapsed && streaming {
    String::new()
} else if collapsed { … } else { … };
```

That left the user staring at a "thinking..." placeholder for the
entire reasoning phase — V4-Pro thinking can run for tens of seconds,
so the live transcript looked frozen even though tokens were flowing.

Fix:

1. During `collapsed && streaming` we now render the raw content
   instead of blanking. `extract_reasoning_summary` is meaningless
   while the block is mid-flight (no completed reasoning to summarise),
   so the streaming branch returns the body verbatim.

2. The `> THINKING_SUMMARY_LINE_LIMIT` truncation now drops *head*
   lines while streaming, keeping the visible window tracking the live
   cursor at the bottom — which is what users expect when watching a
   model think.

3. The existing "thinking collapsed; press Ctrl+O for full text"
   affordance was gated on `!streaming`; it now renders during
   streaming as well, with a slightly different label ("thinking
   continues; …") so the user knows there's more content above and
   how to reach it.

Three new tests cover the new contract: streaming-collapsed shows
live content, the head is dropped not the tail, and the live
affordance fires when truncated.

Refs #861 (RC4), closes #1324
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinQ
2026-05-10 19:19:00 +01:00
committed by Hunter Bown
parent 741f36d2be
commit ca5204e311
+94 -10
View File
@@ -2092,10 +2092,18 @@ fn render_thinking(
lines.push(Line::from(header_spans));
let content_width = width.saturating_sub(3).max(1);
let body_text = if collapsed && streaming {
String::new()
} else if collapsed {
extract_reasoning_summary(content).unwrap_or_else(|| content.trim().to_string())
let body_text = if collapsed {
if streaming {
// #861 RC4 / #1324: during streaming we don't yet have a
// completed reasoning block, so `extract_reasoning_summary`
// is meaningless. Show the raw content and let the
// truncation logic below keep the *last* `LIMIT` lines so
// the user sees the model's most recent thinking instead of
// staring at an empty placeholder.
content.to_string()
} else {
extract_reasoning_summary(content).unwrap_or_else(|| content.trim().to_string())
}
} else {
content.to_string()
};
@@ -2106,7 +2114,14 @@ fn render_thinking(
};
let mut truncated = false;
if collapsed && rendered.len() > THINKING_SUMMARY_LINE_LIMIT {
rendered.truncate(THINKING_SUMMARY_LINE_LIMIT);
if streaming {
// Drop the *head* during streaming so the visible window
// tracks the live cursor at the bottom.
let drop = rendered.len() - THINKING_SUMMARY_LINE_LIMIT;
rendered.drain(0..drop);
} else {
rendered.truncate(THINKING_SUMMARY_LINE_LIMIT);
}
truncated = true;
}
@@ -2134,13 +2149,24 @@ fn render_thinking(
lines.push(Line::from(spans));
}
if collapsed && (!streaming && (truncated || body_text.trim() != content.trim())) {
let needs_affordance = collapsed
&& if streaming {
// #861 RC4 / #1324: during streaming, surface the affordance
// whenever any head lines have been clipped so the user
// knows there's more above and how to reach it.
truncated
} else {
truncated || body_text.trim() != content.trim()
};
if needs_affordance {
let label = if streaming {
"thinking continues; press Ctrl+O for full text"
} else {
"thinking collapsed; press Ctrl+O for full text"
};
lines.push(Line::from(vec![
Span::styled(REASONING_RAIL.to_string(), rail_style),
Span::styled(
"thinking collapsed; press Ctrl+O for full text",
Style::default().fg(palette::TEXT_MUTED).italic(),
),
Span::styled(label, Style::default().fg(palette::TEXT_MUTED).italic()),
]));
}
@@ -3624,6 +3650,64 @@ mod tests {
assert!(text.contains("thinking"));
}
#[test]
fn render_thinking_streaming_collapsed_shows_live_content() {
// #861 RC4 / #1324: during a live thinking block in collapsed view,
// the body must NOT be blanked out. Users want to watch the model
// think; the previous behaviour stalled on a "thinking..." spinner
// until ThinkingComplete fired.
let lines = render_thinking(
"Step 1: read the code\nStep 2: trace the call\nStep 3: form a hypothesis",
80,
true, // streaming
None, // no duration yet
true, // collapsed
true, // low_motion (no cursor noise to grep)
);
let text = lines
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect::<String>();
assert!(
text.contains("Step 3: form a hypothesis"),
"the most recent thinking line must be visible during streaming, got: {text}"
);
// "thinking..." placeholder must not be the only thing rendered.
assert!(
!text.contains("thinking..."),
"raw content present means the placeholder line should not be drawn, got: {text}"
);
}
#[test]
fn render_thinking_streaming_truncated_shows_continues_affordance() {
// #861 RC4: when a streaming thinking block exceeds the line cap,
// surface a live affordance pointing at Ctrl+O. The earlier code
// suppressed the affordance unless `!streaming`.
let long = (1..=10)
.map(|i| format!("Reasoning line {i}"))
.collect::<Vec<_>>()
.join("\n");
let lines = render_thinking(&long, 80, true, None, true, true);
let text = lines
.iter()
.flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
.collect::<String>();
assert!(
text.contains("thinking continues; press Ctrl+O for full text"),
"streaming-truncation affordance missing, got: {text}"
);
// The most recent line must be the visible tail (head dropped).
assert!(
text.contains("Reasoning line 10"),
"tail line missing, got: {text}"
);
assert!(
!text.contains("Reasoning line 1\n"),
"head should be clipped, got: {text}"
);
}
#[test]
fn tool_lines_with_options_respects_low_motion_in_default_path() {
// Use a 2× cycle offset so the animated frame lands on index 2,