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:
co-authored by
Claude Sonnet 5
parent
6b58977875
commit
e34708a319
+129
-105
@@ -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<Span<'_>>) -> Vec<Line<'_>> {
|
||||
@@ -45,30 +54,24 @@ fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
|
||||
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<Line> = Vec::new();
|
||||
let mut prev_role: Option<Role> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user