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)]
|
#![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
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user