diff --git a/crates/oxide-code/src/tui/components/chat.rs b/crates/oxide-code/src/tui/components/chat.rs index 4e4612c..6145c2f 100644 --- a/crates/oxide-code/src/tui/components/chat.rs +++ b/crates/oxide-code/src/tui/components/chat.rs @@ -51,9 +51,8 @@ pub(crate) struct ChatView { // Transient state (cleared per turn) /// In-flight assistant tokens with a rendered-prefix cache. streaming: Option, - /// 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 @@ -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); @@ -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() { @@ -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 @@ -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] @@ -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] @@ -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 diff --git a/crates/oxide-code/src/tui/components/chat/blocks.rs b/crates/oxide-code/src/tui/components/chat/blocks.rs index 5231138..31dfbf5 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks.rs @@ -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`]. @@ -112,10 +117,10 @@ 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, @@ -123,7 +128,9 @@ pub(super) fn prepend_markdown_prefix( ) -> 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 diff --git a/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs b/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs index 63ff111..7373d2b 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/assistant.rs @@ -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 @@ -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 ── @@ -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, } @@ -91,21 +96,30 @@ impl AssistantThinking { impl ChatBlock for AssistantThinking { fn render(&self, ctx: &RenderCtx<'_>) -> Vec> { - 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 } @@ -113,3 +127,120 @@ impl ChatBlock for AssistantThinking { 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)); + } +} diff --git a/crates/oxide-code/src/tui/components/chat/blocks/tool.rs b/crates/oxide-code/src/tui/components/chat/blocks/tool.rs index c6d50cb..6654d1e 100644 --- a/crates/oxide-code/src/tui/components/chat/blocks/tool.rs +++ b/crates/oxide-code/src/tui/components/chat/blocks/tool.rs @@ -18,7 +18,7 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; -use super::{ChatBlock, RenderCtx}; +use super::{BAR, ChatBlock, RenderCtx}; use crate::tool::{ReadExcerptLine, ToolResultView}; use crate::tui::theme::Theme; use crate::tui::wrap::{expand_tabs, wrap_line}; @@ -42,9 +42,6 @@ const MAX_DIFF_BODY_LINES: usize = 20; /// pasted into tool output. const MAX_TOOL_OUTPUT_LINE_BYTES: usize = 512; -/// Left bar character for tool blocks. -const BAR: &str = "▎"; - /// First-line prefix for tool-call and tool-result status lines — bar + /// space. Content sits at col 2. const BORDER_PREFIX: &str = "▎ "; @@ -171,8 +168,7 @@ fn render_text_body( label: &str, is_error: bool, ) { - let trimmed = content.trim(); - if trimmed.is_empty() { + if content.trim().is_empty() { return; } @@ -181,13 +177,25 @@ fn render_text_body( let cont_prefix = border_continuation_prefix(STATUS_LINE_CONT, border_style); let width = usize::from(ctx.width); + // Preserve per-line leading whitespace — some tools (e.g., `git + // diff --stat`) indent every line with a meaningful space that + // `content.trim()` would strip off the first line only, producing + // a misaligned first row. Drop whole blank surrounding lines + // instead, one unit of work at a time. + let mut output_lines: Vec<&str> = content.lines().collect(); + while output_lines.first().is_some_and(|l| l.trim().is_empty()) { + output_lines.remove(0); + } + while output_lines.last().is_some_and(|l| l.trim().is_empty()) { + output_lines.pop(); + } + // Tools (grep, glob) commonly use their own summary line as both // the `title` metadata (shown in the status line) and the first // line of `content` (shown in the body) — the model needs the // summary to parse counts, but rendering both duplicates it on // screen. Skip the first body line when it matches the label // verbatim. - let mut output_lines: Vec<&str> = trimmed.lines().collect(); if output_lines .first() .is_some_and(|l| l.trim() == label.trim()) diff --git a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__chat__tests__render_history_with_resumed_thinking_block.snap b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__chat__tests__render_history_with_resumed_thinking_block.snap index cf92df5..13540f0 100644 --- a/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__chat__tests__render_history_with_resumed_thinking_block.snap +++ b/crates/oxide-code/src/tui/components/snapshots/ox__tui__components__chat__tests__render_history_with_resumed_thinking_block.snap @@ -1,12 +1,11 @@ --- source: crates/oxide-code/src/tui/components/chat.rs -assertion_line: 1555 expression: "render_chat(&mut chat, 60, 10)" --- "❯ hello " " " -"◇ Thinking... " -" pondering... " +"▎ Thinking... " +"▎ pondering... " " " "◉ Hi! " " "