Skip to content
Merged
89 changes: 77 additions & 12 deletions crates/oxide-code/src/tui/components/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,8 @@ pub(crate) struct ChatView {
// Transient state (cleared per turn)
/// In-flight assistant tokens with a rendered-prefix cache.
streaming: Option<StreamingAssistant>,
/// Live thinking tokens — transient; cleared when a stream token or
/// turn completion arrives. Resumed thinking comes through
/// [`blocks`] as an [`AssistantThinking`] block instead.
/// Live thinking tokens for the current block; flushed into a
/// committed [`AssistantThinking`] block on stream start or turn end.
thinking_buffer: String,

// View state
Expand Down Expand Up @@ -166,10 +165,9 @@ impl ChatView {
}

/// Appends a streamed token to the current assistant response.
/// Any pending thinking is committed first.
pub(crate) fn append_stream_token(&mut self, token: &str) {
if !self.thinking_buffer.is_empty() {
self.thinking_buffer.clear();
}
self.commit_thinking_buffer();
self.streaming
.get_or_insert_with(StreamingAssistant::new)
.append(token);
Expand All @@ -187,10 +185,10 @@ impl ChatView {
}
}

/// Finalize the current streaming buffer into a committed assistant
/// block.
/// Finalize the streaming buffer into a committed block. Flushes
/// pending thinking first so thinking-only turns still leave a block.
pub(crate) fn commit_streaming(&mut self) {
self.thinking_buffer.clear();
self.commit_thinking_buffer();
if let Some(mut s) = self.streaming.take() {
let text = s.take_buffer();
if !text.is_empty() {
Expand All @@ -199,6 +197,14 @@ impl ChatView {
}
}

fn commit_thinking_buffer(&mut self) {
if self.thinking_buffer.is_empty() {
return;
}
let text = std::mem::take(&mut self.thinking_buffer);
self.blocks.push(Box::new(AssistantThinking::new(text)));
}

/// Appends a tool call entry with its icon and label.
///
/// Finalizes any in-flight streaming buffer first — a tool call
Expand Down Expand Up @@ -1026,13 +1032,19 @@ mod tests {
// ── append_stream_token ──

#[test]
fn append_stream_token_clears_thinking() {
fn append_stream_token_persists_thinking_into_blocks() {
let mut chat = test_chat();
chat.append_thinking_token("thinking...");
chat.append_thinking_token("reasoning here");
assert!(!chat.thinking_buffer.is_empty());
assert_eq!(chat.blocks.len(), 0);

chat.append_stream_token("text");
chat.append_stream_token("reply text");
assert!(chat.thinking_buffer.is_empty());
assert_eq!(chat.blocks.len(), 1);

let text = all_text(&chat);
assert!(text.contains("reasoning here"));
assert!(text.contains("reply text"));
}

#[test]
Expand Down Expand Up @@ -1354,6 +1366,20 @@ mod tests {
assert!(chat.thinking_buffer.is_empty());
}

#[test]
fn commit_streaming_persists_thinking_only_turn() {
// Thinking → tool-call turn (no text) still persists the reasoning.
let mut chat = test_chat();
chat.append_thinking_token("plan before tool");
assert!(chat.blocks.is_empty());

chat.commit_streaming();
assert_eq!(chat.blocks.len(), 1);
assert!(chat.thinking_buffer.is_empty());
let text = all_text(&chat);
assert!(text.contains("plan before tool"));
}

// ── push_tool_call ──

#[test]
Expand Down Expand Up @@ -1554,6 +1580,45 @@ mod tests {
);
}

#[test]
fn push_tool_result_preserves_leading_whitespace_on_first_body_line() {
// `git diff --stat` and similar tools indent every body line with
// a meaningful leading space. Trimming whole-content whitespace
// used to strip it off the first line only, misaligning the
// first row against the rest.
let mut chat = test_chat();
chat.push_tool_result("out", " a.rs | 1 +\n b.rs | 2 +", false);
let text = all_text(&chat);
assert!(
text.contains(" a.rs | 1 +"),
"first body line must keep its leading space: {text}",
);
assert!(text.contains(" b.rs | 2 +"));
}

#[test]
fn push_tool_result_drops_surrounding_blank_lines() {
// Some tools pad output with leading / trailing blank lines;
// those must not produce empty body rows, but per-line indent
// on real data lines in between must survive.
let mut chat = test_chat();
chat.push_tool_result("out", "\n\n real line\n\n\n", false);
let text = all_text(&chat);

// Exactly one body row with the `▎ ` prefix (4-col status-line
// continuation). A regression that kept surrounding blanks would
// render 2+ body rows.
let body_row_count = text.lines().filter(|l| l.starts_with("▎ ")).count();
assert_eq!(
body_row_count, 1,
"expected one body row after blank-line stripping: {text}",
);
assert!(
text.contains("▎ real line"),
"data-line indent must survive: {text}",
);
}

#[test]
fn push_tool_result_dedup_collapses_body_when_only_line_matches_label() {
// When content is just the duplicated label (no trailing body
Expand Down
17 changes: 12 additions & 5 deletions crates/oxide-code/src/tui/components/chat/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ use unicode_width::UnicodeWidthStr;
use crate::tui::theme::Theme;
use crate::tui::wrap::wrap_line;

// ── Shared Glyphs ──

/// Shared left-bar glyph for tool and thinking blocks.
pub(super) const BAR: &str = "▎";

// ── Trait ──

/// Immutable context passed to [`ChatBlock::render`].
Expand Down Expand Up @@ -112,18 +117,20 @@ pub(super) fn push_icon_wrapped(
out.extend(wrap_line(line, width, indent, Some(&cont_prefix)));
}

/// Prepends a styled prefix span to a markdown-rendered line. Used by
/// the bar-less markdown blocks (assistant text, streaming, thinking
/// body) for per-line first-column decoration (icon on line 1, plain
/// indent on continuations).
/// Prepends a styled prefix span to a markdown-rendered line,
/// preserving the input's whole-line style so fenced code blocks
/// (which carry their fg on `Line.style`, not the spans) keep their
/// highlight when wrapped in per-block decoration.
pub(super) fn prepend_markdown_prefix(
line: Line<'static>,
prefix: &str,
prefix_style: Style,
) -> Line<'static> {
let mut spans = vec![Span::styled(prefix.to_owned(), prefix_style)];
spans.extend(line.spans);
Line::from(spans)
let mut out = Line::from(spans);
out.style = line.style;
out
}

/// Whether the last rendered line is non-empty. Used to decide where
Expand Down
165 changes: 148 additions & 17 deletions crates/oxide-code/src/tui/components/chat/blocks/assistant.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
//! Assistant text and thinking blocks.

use ratatui::text::Line;
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;

use super::{ChatBlock, RenderCtx, prepend_markdown_prefix, push_icon_wrapped};
use super::{ChatBlock, RenderCtx, prepend_markdown_prefix};
use crate::tui::markdown::render_markdown;
use crate::tui::theme::Theme;
use crate::tui::wrap::wrap_line;

/// First-line prefix for assistant text — diamond + space. Continuation
/// (and all lines when the streaming block is continuing a turn) uses a
Expand All @@ -15,8 +17,11 @@ pub(super) const ASSISTANT_PREFIX: &str = "◉ ";
/// visual width of [`ASSISTANT_PREFIX`].
pub(super) const ASSISTANT_CONT: &str = " ";

/// First-line prefix for the thinking header — diamond + space.
const THINKING_PREFIX: &str = "◇ ";
/// Per-line prefix for thinking blocks — shares [`BAR`] so bars align.
const THINKING_PREFIX: &str = "▎ ";

/// Header label on the first line of a thinking block.
const THINKING_LABEL: &str = "Thinking...";

// ── AssistantText ──

Expand Down Expand Up @@ -77,8 +82,8 @@ pub(super) fn render_assistant_markdown(

// ── AssistantThinking ──

/// Extended-thinking block, shown dimmed-italic under a `Thinking`
/// header. Collapses to zero lines when `show_thinking` is off.
/// Extended-thinking block — bar-prefixed quote with a `Thinking...`
/// header and markdown-rendered body. Hidden when `show_thinking` is off.
pub(crate) struct AssistantThinking {
text: String,
}
Expand All @@ -91,25 +96,151 @@ impl AssistantThinking {

impl ChatBlock for AssistantThinking {
fn render(&self, ctx: &RenderCtx<'_>) -> Vec<Line<'static>> {
let style = ctx.theme.thinking();
let theme = ctx.theme;
let width = usize::from(ctx.width);
let style = theme.thinking();

let bar_width = THINKING_PREFIX.width();
let md_width = width.saturating_sub(bar_width);
let bar_spans = vec![Span::styled(THINKING_PREFIX, style)];

let mut out = Vec::new();
push_icon_wrapped(
&mut out,
THINKING_PREFIX,
style,
"Thinking...",
style,
width,
);
for text_line in self.text.lines() {
push_icon_wrapped(&mut out, " ", style, text_line, style, width);

let header = Line::from(vec![
Span::styled(THINKING_PREFIX, style),
Span::styled(THINKING_LABEL, style),
]);
out.extend(wrap_line(header, width, bar_width, Some(&bar_spans)));

if !self.text.trim().is_empty() {
let rendered = render_markdown(&self.text, theme, md_width);
for line in rendered.lines {
let dimmed = apply_thinking_style(line, theme);
out.push(prepend_markdown_prefix(dimmed, THINKING_PREFIX, style));
}
}

out
}

fn visible(&self, ctx: &RenderCtx<'_>) -> bool {
ctx.show_thinking
}
}

/// Dims plain spans; leaves explicitly-colored spans (inline code,
/// links, highlighted fences) at full color.
fn apply_thinking_style(mut line: Line<'static>, theme: &Theme) -> Line<'static> {
if line.style.fg.is_some() {
return line;
}
let base = theme.thinking();
for span in &mut line.spans {
if span.style.fg.is_none() {
span.style = span.style.patch(base);
}
}
line
}

#[cfg(test)]
mod tests {
use indoc::indoc;
use ratatui::style::Style;

use super::super::BAR;
use super::*;
use crate::tui::theme::Theme;

fn ctx_at(width: u16, theme: &Theme) -> RenderCtx<'_> {
RenderCtx {
width,
theme,
show_thinking: true,
}
}

// ── THINKING_PREFIX ──

#[test]
fn thinking_prefix_shares_bar_glyph_with_tool_blocks() {
assert!(
THINKING_PREFIX.starts_with(BAR),
"THINKING_PREFIX ({THINKING_PREFIX:?}) must start with BAR ({BAR:?})",
);
}

// ── AssistantText::render ──

#[test]
fn assistant_text_fenced_code_preserves_highlight_style() {
// Regression: `prepend_markdown_prefix` used to drop `line.style`
// on its output, so an unknown-lang fenced block inside an
// assistant reply silently lost its whole-line fg.
let theme = Theme::default();
let block = AssistantText::new(indoc! {"
```
let x = 1;
```
"});
let ctx = ctx_at(60, &theme);
let lines = block.render(&ctx);
let fence_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("let x = 1;")))
.expect("fence body line missing from render");
assert_eq!(fence_line.style.fg, Some(theme.code));
}

// ── AssistantThinking::render ──

#[test]
fn render_empty_body_emits_header_only() {
// Exercised by the zero-delta frame before the first thinking chunk.
let theme = Theme::default();
let block = AssistantThinking::new(" \n ");
let lines = block.render(&ctx_at(60, &theme));
assert_eq!(lines.len(), 1, "only the header should render: {lines:?}");
let header: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(header.starts_with(THINKING_PREFIX));
assert!(header.contains("Thinking..."));
}

#[test]
fn render_fenced_code_block_preserves_highlight_style() {
// Whole-line fg on fence output must survive the bar prefix.
let theme = Theme::default();
let block = AssistantThinking::new(indoc! {"
Consider:

```
let x = 1;
```
"});
let lines = block.render(&ctx_at(60, &theme));

let fence_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("let x = 1;")))
.expect("fence body line missing from render");
assert_eq!(fence_line.style.fg, Some(theme.code));

let first_span = fence_line.spans.first().expect("empty fence line");
assert_eq!(first_span.content, THINKING_PREFIX);
assert_eq!(first_span.style, theme.thinking());
}

// ── apply_thinking_style ──

#[test]
fn apply_thinking_style_dims_plain_spans_only() {
let theme = Theme::default();
let line = Line::from(vec![
Span::raw("plain "),
Span::styled("code", Style::default().fg(theme.code)),
]);
let out = apply_thinking_style(line, &theme);
assert_eq!(out.spans[0].style.fg, theme.thinking().fg);
assert_eq!(out.spans[1].style.fg, Some(theme.code));
}
}
Loading