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 <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-14 23:41:53 +07:00
co-authored by Claude Sonnet 5
parent 6b58977875
commit e34708a319
2 changed files with 138 additions and 118 deletions
+129 -105
View File
@@ -1,23 +1,32 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] #![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 //! Flow: `draw_chat` turns `state.transcript_cache.messages` into a dense,
//! visually rich transcript where each message is rendered as a "card" //! log-like transcript: each non-tool message gets a one-line
//! with a role-colored left accent bar, a role badge pill, timestamp, //! `{role} {time} {content}` header with wrapped continuation lines
//! and markdown body. A streaming spinner line is appended when a turn //! 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 //! is in flight. The combined line list is sliced to the visible scroll
//! window before rendering. //! window before rendering.
//! //!
//! Design: messages are visually separated with vertical spacing, role //! Design: no per-message card/border/badge — role identity comes from a
//! badges are colored pills on the left, and each message has a thin //! short colored label, and vertical space is reserved for a blank line
//! role-colored border on its left side for quick visual scanning. //! 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::layout::Rect;
use ratatui::style::{Style, Modifier}; use ratatui::style::{Color, Style, Modifier};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
use super::theme::Theme; 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. /// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> { fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
@@ -45,30 +54,24 @@ fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
lines lines
} }
fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { fn role_accent_color(role: &Role) -> Color {
match role { match role {
crate::dto::chat::message::Role::User => " YOU ", Role::User => Theme::ROLE_USER,
crate::dto::chat::message::Role::Assistant => " AI ", Role::Assistant => Theme::ROLE_ASSISTANT,
crate::dto::chat::message::Role::System => " SYS ", Role::System => Theme::ROLE_SYSTEM,
crate::dto::chat::message::Role::Tool => " TOOL ", 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 { match role {
crate::dto::chat::message::Role::User => Theme::ROLE_USER, Role::User => "you",
crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT, Role::Assistant => "ai",
crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM, Role::System => "sys",
crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL, Role::Tool => "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",
} }
} }
@@ -80,60 +83,68 @@ fn format_timestamp(ts: i64) -> String {
format!("{hrs:02}:{mins:02}") 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)] #[allow(clippy::too_many_lines)]
pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
let messages = &state.transcript_cache.messages; let messages = &state.transcript_cache.messages;
let scroll_offset = state.scroll.offset; let scroll_offset = state.scroll.offset;
let max_visible = (area.height as usize).saturating_sub(3); 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<Line> = Vec::new(); let mut display_lines: Vec<Line> = Vec::new();
let mut prev_role: Option<Role> = None;
// ── Header ───────────────────────────────────────────────────────────
let title = if messages.is_empty() { let title = if messages.is_empty() {
String::from(" Chat ") String::from(" Chat ")
} else { } else {
format!(" Chat [{} msgs]", messages.len()) format!(" Chat [{} msgs]", messages.len())
}; };
// ── Render messages as cards ─────────────────────────────────────────
for msg in messages { 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()); 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() { let content_str = if msg.content.trim().is_empty() {
if is_last && state.turn_in_flight() { if is_last && state.turn_in_flight() {
"(streaming...)".to_string() "(streaming...)".to_string()
@@ -144,19 +155,23 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
msg.content.clone() msg.content.clone()
}; };
// Render markdown content with accent-colored prefix let content_spans = super::markdown::render_markdown(&content_str, content_width);
let mut content_spans = vec![ let content_lines = split_spans_into_lines(content_spans);
Span::styled(" ", Style::default().fg(accent)), let mut lines_iter = content_lines.into_iter();
];
content_spans.extend(super::markdown::render_markdown(&content_str, area.width));
let message_lines = split_spans_into_lines(content_spans);
for line in message_lines { if let Some(first) = lines_iter.next() {
display_lines.push(line); 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 ──────────────────────────────────────────── for line in lines_iter {
display_lines.push(Line::from(Span::raw(""))); let mut spans = vec![Span::raw(" ".repeat(PREFIX_WIDTH))];
spans.extend(line.spans);
display_lines.push(Line::from(spans));
}
} }
// ── Streaming indicator ────────────────────────────────────────────── // ── 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 frame_idx = (state.misc.tick_count as usize / 2) % spinner_frames.len();
let spinner = spinner_frames[frame_idx]; 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![ display_lines.push(Line::from(vec![
Span::styled( Span::styled(
"", format!("{:<4} ", format_role_label(&Role::Assistant)),
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} "),
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD), Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
), ),
Span::styled( Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
"Generating...", Span::styled("generating...", Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC)),
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
),
])); ]));
display_lines.push(Line::from(Span::raw("")));
} }
// ── Scrolling ──────────────────────────────────────────────────────── // ── Scrolling ────────────────────────────────────────────────────────
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER)) .border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled( .title(Span::styled(title, Style::default().fg(Theme::TEXT_MUTED)));
title,
Style::default().fg(Theme::TEXT_MUTED),
));
let total = display_lines.len(); let total = display_lines.len();
let max_offset = total.saturating_sub(max_visible); 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() 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 { let scroll_pct = if total > max_visible {
((offset as f64 / max_offset as f64) * 100.0) as u8 ((offset as f64 / max_offset as f64) * 100.0) as u8
} else { } else {
@@ -223,10 +222,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
Block::default() Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER)) .border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled( .title(Span::styled(scroll_title, Style::default().fg(Theme::TEXT_MUTED)))
scroll_title,
Style::default().fg(Theme::TEXT_MUTED),
))
} else { } else {
block block
}; };
@@ -239,5 +235,33 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
} }
// Need to import Color for role_accent_color #[cfg(test)]
use ratatui::style::Color; 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);
}
}
}
+9 -13
View File
@@ -7,6 +7,13 @@
//! Design: code blocks get a dark background with a labeled top bar, //! Design: code blocks get a dark background with a labeled top bar,
//! headings are bold with distinct colors, blockquotes get a vertical //! headings are bold with distinct colors, blockquotes get a vertical
//! accent bar prefix, and inline code is highlighted with a background. //! 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::style::{Modifier, Style};
use ratatui::text::Span; use ratatui::text::Span;
@@ -27,7 +34,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let mut in_code_block = false; let mut in_code_block = false;
let mut in_heading = false; let mut in_heading = false;
let mut heading_level = 0; let mut heading_level = 0;
let mut first_in_paragraph = true;
for event in parser { for event in parser {
match event { match event {
@@ -59,13 +65,10 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
}; };
// No prefix, we'll handle in the text events // No prefix, we'll handle in the text events
} }
pulldown_cmark::Tag::Paragraph => {
first_in_paragraph = true;
}
pulldown_cmark::Tag::Item => { pulldown_cmark::Tag::Item => {
// List item bullet // List item bullet
spans.push(Span::styled( spans.push(Span::styled(
" ", "",
Style::default().fg(Theme::PRIMARY), Style::default().fg(Theme::PRIMARY),
)); ));
} }
@@ -106,7 +109,6 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
pulldown_cmark::TagEnd::Paragraph => { pulldown_cmark::TagEnd::Paragraph => {
first_in_paragraph = true;
spans.push(Span::raw("\n\n")); spans.push(Span::raw("\n\n"));
} }
pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => { pulldown_cmark::TagEnd::Item | pulldown_cmark::TagEnd::BlockQuote(_) => {
@@ -129,17 +131,11 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
3 => Theme::ACCENT_PURPLE, 3 => Theme::ACCENT_PURPLE,
_ => Theme::TEXT, _ => Theme::TEXT,
}; };
let prefix = " ";
spans.push(Span::styled( spans.push(Span::styled(
format!("{prefix}{s}"), s,
Style::default().fg(color).add_modifier(Modifier::BOLD), Style::default().fg(color).add_modifier(Modifier::BOLD),
)); ));
} else { } 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)); spans.push(Span::raw(s));
} }
} }