feat: Enhance token usage tracking and improve chat UI with emojis

This commit is contained in:
asepharyana
2026-07-15 03:39:15 +07:00
parent 1a6a0c628e
commit 188e7cc9a9
4 changed files with 144 additions and 98 deletions
+24 -25
View File
@@ -18,7 +18,7 @@
use ratatui::layout::Rect;
use ratatui::style::{Color, Style, Modifier};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
use ratatui::Frame;
use super::theme::Theme;
use crate::dto::chat::message::Role;
@@ -26,7 +26,7 @@ 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;
const PREFIX_WIDTH: usize = 15;
/// Break a flat run of styled spans into `Line`s at embedded `\n` boundaries.
fn split_spans_into_lines(spans: Vec<Span<'_>>) -> Vec<Line<'_>> {
@@ -68,10 +68,10 @@ fn role_accent_color(role: &Role) -> Color {
/// raw label).
fn format_role_label(role: &Role) -> &'static str {
match role {
Role::User => "you",
Role::Assistant => "ai",
Role::System => "sys",
Role::Tool => "tool",
Role::User => "👤 you ",
Role::Assistant => "🤖 ai ",
Role::System => "💻 sys ",
Role::Tool => "🔧 tool",
}
}
@@ -90,8 +90,8 @@ fn format_timestamp(ts: i64) -> String {
/// `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)
fn needs_speaker_separator(_prev_role: Option<&Role>, _role: &Role) -> bool {
false // User requested zsh-style compactness (no empty lines between speakers)
}
/// Render the scrollable chat transcript panel in tight inline-log style.
@@ -108,9 +108,9 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
let mut prev_role: Option<Role> = None;
let title = if messages.is_empty() {
String::from(" Chat ")
String::from(" 💬 Chat ")
} else {
format!(" Chat [{} msgs]", messages.len())
format!(" 💬 Chat [{} msgs] ", messages.len())
};
for msg in messages {
@@ -163,7 +163,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
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!("{label} "), Style::default().fg(accent).add_modifier(Modifier::BOLD)),
Span::styled(format!("{ts_str:<5} "), Style::default().fg(Theme::TEXT_DIM)),
];
@@ -207,7 +207,7 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
}
display_lines.push(Line::from(vec![
Span::styled(
format!("{:<4} ", format_role_label(&Role::Assistant)),
format!("{} ", format_role_label(&Role::Assistant)),
Style::default().fg(Theme::ROLE_ASSISTANT).add_modifier(Modifier::BOLD),
),
Span::styled(format!("{spinner} "), Style::default().fg(Theme::TEXT_DIM)),
@@ -218,8 +218,9 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
// ── Scrolling ────────────────────────────────────────────────────────
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.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).add_modifier(Modifier::BOLD)));
let total = display_lines.len();
let max_offset = total.saturating_sub(max_visible);
@@ -240,11 +241,12 @@ pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest:
};
let block = if scroll_pct > 0 {
let scroll_title = format!(" Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct);
let scroll_title = format!(" 💬 Chat [{} msgs] ── {}% ↑ ", messages.len(), scroll_pct);
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.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).add_modifier(Modifier::BOLD)))
} else {
block
};
@@ -272,18 +274,15 @@ mod tests {
}
#[test]
fn separator_when_speaker_changes() {
assert!(needs_speaker_separator(Some(&Role::User), &Role::Assistant));
fn no_separator_when_speaker_changes_because_zsh_style() {
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);
}
fn role_labels_include_emojis_and_padding() {
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");
}
}