diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index b1584371..cf1c7609 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -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::(); + 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::>() + .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::(); + 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,