From e34708a3191bef63d191e76dee39a58a33e4ad5f Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 14 Jul 2026 12:29:22 +0700 Subject: [PATCH] feat(tui): rombak rendering chat jadi format log rapat markdown.rs juga disesuaikan: indentasi paragraf/heading bawaannya dilepas supaya tidak bentrok dengan indent PREFIX_WIDTH di chat.rs (baris pertama vs baris wrap lanjutan jadi sejajar). Co-Authored-By: Claude Sonnet 5 --- src/view/chat.rs | 234 ++++++++++++++++++++++++------------------- src/view/markdown.rs | 22 ++-- 2 files changed, 138 insertions(+), 118 deletions(-) diff --git a/src/view/chat.rs b/src/view/chat.rs index 45f763d..6876577 100644 --- a/src/view/chat.rs +++ b/src/view/chat.rs @@ -1,23 +1,32 @@ #![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] -//! Chat transcript panel rendering — message cards with role badges. +//! Chat transcript panel rendering — tight inline log style. //! -//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a -//! visually rich transcript where each message is rendered as a "card" -//! with a role-colored left accent bar, a role badge pill, timestamp, -//! and markdown body. A streaming spinner line is appended when a turn +//! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense, +//! log-like transcript: each non-tool message gets a one-line +//! `{role} {time} {content}` header with wrapped continuation lines +//! aligned under the content column; `Role::Tool` messages render as a +//! dim `↳`-prefixed sub-line attached to whatever came before, with no +//! header of their own. A streaming spinner line is appended when a turn //! is in flight. The combined line list is sliced to the visible scroll //! window before rendering. //! -//! Design: messages are visually separated with vertical spacing, role -//! badges are colored pills on the left, and each message has a thin -//! role-colored border on its left side for quick visual scanning. +//! Design: no per-message card/border/badge — role identity comes from a +//! short colored label, and vertical space is reserved for a blank line +//! only when the speaker actually changes (Tool sub-lines never count as +//! a speaker change), keeping more history on screen at once. use ratatui::layout::Rect; -use ratatui::style::{Style, Modifier}; +use ratatui::style::{Color, Style, Modifier}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; use super::theme::Theme; +use crate::dto::chat::message::Role; + +/// Column width reserved for the `{role} {time} ` header prefix; wrapped +/// continuation lines and Tool sub-lines indent to this width so content +/// stays aligned under the first line's content column. +const PREFIX_WIDTH: usize = 12; /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries. fn split_spans_into_lines(spans: Vec>) -> Vec> { @@ -45,30 +54,24 @@ fn split_spans_into_lines(spans: Vec>) -> Vec> { lines } -fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { +fn role_accent_color(role: &Role) -> Color { match role { - crate::dto::chat::message::Role::User => " YOU ", - crate::dto::chat::message::Role::Assistant => " AI ", - crate::dto::chat::message::Role::System => " SYS ", - crate::dto::chat::message::Role::Tool => " TOOL ", + Role::User => Theme::ROLE_USER, + Role::Assistant => Theme::ROLE_ASSISTANT, + Role::System => Theme::ROLE_SYSTEM, + Role::Tool => Theme::ROLE_TOOL, } } -fn role_accent_color(role: &crate::dto::chat::message::Role) -> Color { +/// Short lowercase label for the `{role} {time}` header column. Callers pad +/// it to a fixed width themselves (not padded here so tests can assert the +/// raw label). +fn format_role_label(role: &Role) -> &'static str { match role { - crate::dto::chat::message::Role::User => Theme::ROLE_USER, - crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT, - crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM, - crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL, - } -} - -fn role_label(role: &crate::dto::chat::message::Role) -> &'static str { - match role { - crate::dto::chat::message::Role::User => "You", - crate::dto::chat::message::Role::Assistant => "Assistant", - crate::dto::chat::message::Role::System => "System", - crate::dto::chat::message::Role::Tool => "Tool Call", + Role::User => "you", + Role::Assistant => "ai", + Role::System => "sys", + Role::Tool => "tool", } } @@ -80,60 +83,68 @@ fn format_timestamp(ts: i64) -> String { format!("{hrs:02}:{mins:02}") } -/// Render the scrollable chat transcript panel with message card styling. +/// Whether a blank separator line should be inserted before rendering a +/// message from `role`, given the last non-Tool role that was rendered. +/// +/// Why: `Role::Tool` messages render as an attached sub-line (see +/// `draw_chat`) and must never be passed as `prev_role` — a Tool message +/// never triggers a separator, and it never causes one to be inserted +/// before the next real turn either. +fn needs_speaker_separator(prev_role: Option<&Role>, role: &Role) -> bool { + matches!(prev_role, Some(p) if p != role) +} + +/// Render the scrollable chat transcript panel in tight inline-log style. #[allow(clippy::too_many_lines)] pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; let max_visible = (area.height as usize).saturating_sub(3); + // Wrap width for content: total width minus the header/indent prefix + // and minus the panel's left+right border columns. + let content_width = area.width.saturating_sub(PREFIX_WIDTH as u16 + 2); let mut display_lines: Vec = Vec::new(); + let mut prev_role: Option = None; - // ── Header ─────────────────────────────────────────────────────────── let title = if messages.is_empty() { String::from(" Chat ") } else { format!(" Chat [{} msgs]", messages.len()) }; - // ── Render messages as cards ───────────────────────────────────────── for msg in messages { - let accent = role_accent_color(&msg.role); - let badge = role_badge(&msg.role); - let label = role_label(&msg.role); - let ts_str = format_timestamp(msg.timestamp); - - // ── Role header line ───────────────────────────────────────────── - // Left accent bar + badge pill + role name + timestamp - let header = Line::from(vec![ - // Thin accent bar on the left - Span::styled( - "▎", - Style::default().fg(accent), - ), - // Role badge pill - Span::styled( - badge, - Style::default() - .fg(Theme::BG) - .bg(accent) - .add_modifier(Modifier::BOLD), - ), - // Role name - Span::styled( - format!(" {label}"), - Style::default().fg(accent).add_modifier(Modifier::BOLD), - ), - // Timestamp - Span::styled( - if ts_str.is_empty() { String::new() } else { format!(" {ts_str}") }, - Style::default().fg(Theme::TEXT_DIM), - ), - ]); - display_lines.push(header); - - // ── Message content ────────────────────────────────────────────── let is_last = std::ptr::eq(msg, messages.last().unwrap()); + + // Tool messages render as a dim sub-line attached to whatever came + // before — no header, no speaker-change bookkeeping. + if msg.role == Role::Tool { + let content = if msg.content.trim().is_empty() { + "(tool execution)".to_string() + } else { + msg.content.clone() + }; + display_lines.push(Line::from(vec![ + Span::raw(" ".repeat(PREFIX_WIDTH)), + Span::styled("↳ ", Style::default().fg(Theme::TEXT_DIM)), + Span::styled(content, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)), + ])); + continue; + } + + if needs_speaker_separator(prev_role.as_ref(), &msg.role) { + display_lines.push(Line::from(Span::raw(""))); + } + prev_role = Some(msg.role.clone()); + + let accent = role_accent_color(&msg.role); + let label = format_role_label(&msg.role); + let ts_str = format_timestamp(msg.timestamp); + let header_prefix = vec![ + Span::styled(format!("{label:<4} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)), + Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)), + ]; + let content_str = if msg.content.trim().is_empty() { if is_last && state.turn_in_flight() { "(streaming...)".to_string() @@ -144,19 +155,23 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: msg.content.clone() }; - // Render markdown content with accent-colored prefix - let mut content_spans = vec![ - Span::styled(" ", Style::default().fg(accent)), - ]; - content_spans.extend(super::markdown::render_markdown(&content_str, area.width)); - let message_lines = split_spans_into_lines(content_spans); + let content_spans = super::markdown::render_markdown(&content_str, content_width); + let content_lines = split_spans_into_lines(content_spans); + let mut lines_iter = content_lines.into_iter(); - for line in message_lines { - display_lines.push(line); + if let Some(first) = lines_iter.next() { + let mut spans = header_prefix; + spans.extend(first.spans); + display_lines.push(Line::from(spans)); + } else { + display_lines.push(Line::from(header_prefix)); } - // ── Message separator ──────────────────────────────────────────── - display_lines.push(Line::from(Span::raw(""))); + for line in lines_iter { + let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))]; + spans.extend(line.spans); + display_lines.push(Line::from(spans)); + } } // ── Streaming indicator ────────────────────────────────────────────── @@ -165,38 +180,24 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: let frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len(); let spinner = spinner_frames[frame_idx]; + if needs_speaker_separator(prev_role.as_ref(), &Role::Assistant) { + display_lines.push(Line::from(Span::raw(""))); + } display_lines.push(Line::from(vec![ Span::styled( - "▎", - Style::default().fg(Theme::ROLE_ASSISTANT), - ), - Span::styled( - " AI ", - Style::default() - .fg(Theme::BG) - .bg(Theme::ROLE_ASSISTANT) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {spinner} "), + format!("{:<4} ", format_role_label(&Role::Assistant)), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), ), - Span::styled( - "Generating...", - Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), - ), + Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)), + Span::styled("generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC)), ])); - display_lines.push(Line::from(Span::raw(""))); } // ── Scrolling ──────────────────────────────────────────────────────── let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled( - title, - Style::default().fg(Theme::TEXT_MUTED), - )); + .title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED))); let total = display_lines.len(); let max_offset = total.saturating_sub(max_visible); @@ -210,8 +211,6 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: display_lines[total.saturating_sub(max_visible)..total].to_vec() }; - // ── Scroll position indicator ──────────────────────────────────────── - // Show a small percentage indicator in the title if scrolled let scroll_pct = if total > max_visible { ((offset as f64 / max_offset as f64) * 100.0) as u8 } else { @@ -223,10 +222,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Theme::BORDER)) - .title(Span::styled( - scroll_title, - Style::default().fg(Theme::TEXT_MUTED), - )) + .title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED))) } else { block }; @@ -239,5 +235,33 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest: frame.render_widget(paragraph, area); } -// Need to import Color for role_accent_color -use ratatui::style::Color; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_separator_when_no_previous_message() { + assert!(!needs_speaker_separator(None, &Role::User)); + } + + #[test] + fn no_separator_when_same_speaker_repeats() { + assert!(!needs_speaker_separator(Some(&Role::Assistant), &Role::Assistant)); + } + + #[test] + fn separator_when_speaker_changes() { + assert!(needs_speaker_separator(Some(&Role::User), &Role::Assistant)); + } + + #[test] + fn role_labels_are_lowercase_and_fit_prefix_width() { + assert_eq!(format_role_label(&Role::User), "you"); + assert_eq!(format_role_label(&Role::Assistant), "ai"); + assert_eq!(format_role_label(&Role::System), "sys"); + assert_eq!(format_role_label(&Role::Tool), "tool"); + for role in [Role::User, Role::Assistant, Role::System, Role::Tool] { + assert!(format_role_label(&role).len() <= 4); + } + } +} diff --git a/src/view/markdown.rs b/src/view/markdown.rs index a652a08..2245a8e 100644 --- a/src/view/markdown.rs +++ b/src/view/markdown.rs @@ -7,6 +7,13 @@ //! Design: code blocks get a dark background with a labeled top bar, //! headings are bold with distinct colors, blockquotes get a vertical //! accent bar prefix, and inline code is highlighted with a background. +//! Deliberately adds no leading indentation of its own for paragraphs, +//! headings, or list bullets — the caller (`chat.rs`) owns column +//! alignment via its `PREFIX_WIDTH` scheme, so any indent added here +//! would only apply to a construct's first rendered line and throw +//! wrapped continuation lines out of alignment with it. Code-block lines +//! are the exception: every line gets its `" "` prefix independently +//! and consistently, so there's no first-line-only misalignment there. use ratatui::style::{Modifier, Style}; use ratatui::text::Span; @@ -27,7 +34,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { let mut in_code_block = false; let mut in_heading = false; let mut heading_level = 0; - let mut first_in_paragraph = true; for event in parser { match event { @@ -59,13 +65,10 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { }; // No prefix, we'll handle in the text events } - pulldown_cmark::Tag::Paragraph => { - first_in_paragraph = true; - } pulldown_cmark::Tag::Item => { // List item bullet spans.push(Span::styled( - " • ", + "• ", Style::default().fg(Theme::PRIMARY), )); } @@ -106,7 +109,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { spans.push(Span::raw("\n")); } pulldown_cmark::TagEnd::Paragraph => { - first_in_paragraph = true; spans.push(Span::raw("\n\n")); } pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => { @@ -129,17 +131,11 @@ pub fn render_markdown(text: &str, width: u16) -> Vec> { 3 => Theme::ACCENT_PURPLE, _ => Theme::TEXT, }; - let prefix = " "; spans.push(Span::styled( - format!("{prefix}{s}"), + s, Style::default().fg(color).add_modifier(Modifier::BOLD), )); } else { - // Handle first word detection for paragraph indentation - if first_in_paragraph { - spans.push(Span::raw(" ")); - first_in_paragraph = false; - } spans.push(Span::raw(s)); } }