From b8d1866b7d3fe8e2ad8ffeebfc379cc65c3e1406 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 29 Sep 2025 22:48:51 +0200 Subject: [PATCH] Refactor TUI message rendering: improve role label handling, add emoji labels, enhance line wrapping, and optimize loading indicator logic. --- crates/owlen-tui/src/ui.rs | 122 ++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 42 deletions(-) diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 9c60252..93888df 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -361,10 +361,10 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let mut lines: Vec = Vec::new(); for (message_index, message) in conversation.messages.iter().enumerate() { let role = &message.role; - let prefix = match role { - Role::User => "👤 You:", - Role::Assistant => "🤖 Assistant:", - Role::System => "⚙️ System:", + let (emoji, name) = match role { + Role::User => ("👤 ", "You: "), + Role::Assistant => ("🤖 ", "Assistant: "), + Role::System => ("⚙️ ", "System: "), }; let formatted = formatter.format_message(message); @@ -375,36 +375,87 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { .unwrap_or(false); let show_role_labels = formatter.show_role_labels(); - let indent = if show_role_labels { " " } else { "" }; if show_role_labels { - let mut role_spans = vec![Span::styled( - prefix, - role_color(role).add_modifier(Modifier::BOLD), - )]; + // Calculate the prefix width for proper wrapping + let prefix = format!("{emoji}{name}"); + let prefix_width = UnicodeWidthStr::width(prefix.as_str()); - // Add loading animation for Assistant if currently loading and this is the last message - if matches!(role, Role::Assistant) && + // Join all formatted lines into single content string + let content = formatted.join("\n"); + + // Add loading indicator if applicable + let loading_indicator = if matches!(role, Role::Assistant) && app.get_loading_indicator() != "" && message_index == conversation.messages.len() - 1 && is_streaming { - role_spans.push(Span::styled( - format!(" {}", app.get_loading_indicator()), - Style::default().fg(Color::Yellow), - )); - } + format!("{} ", app.get_loading_indicator()) + } else { + String::new() + }; - lines.push(Line::from(role_spans)); + // Wrap content considering available width minus prefix + let available_width = (content_width as usize).saturating_sub(prefix_width); + let chunks = if available_width > 0 { + wrap(&content, available_width) + } else { + vec![] + }; + + if chunks.is_empty() { + let mut first_line_spans = vec![ + Span::raw(emoji), + Span::styled(name, role_color(role).add_modifier(Modifier::BOLD)), + ]; + if !loading_indicator.is_empty() { + first_line_spans.push(Span::styled( + loading_indicator, + Style::default().fg(Color::Yellow), + )); + } + lines.push(Line::from(first_line_spans)); + } else { + let chunks_len = chunks.len(); + for (i, seg) in chunks.into_iter().enumerate() { + if i == 0 { + let mut first_line_spans = vec![ + Span::raw(emoji), + Span::styled(name, role_color(role).add_modifier(Modifier::BOLD)), + ]; + if !loading_indicator.is_empty() { + first_line_spans.push(Span::styled( + loading_indicator.clone(), + Style::default().fg(Color::Yellow), + )); + } + first_line_spans.push(Span::raw(seg.into_owned())); + if chunks_len == 1 && is_streaming { + first_line_spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta))); + } + lines.push(Line::from(first_line_spans)); + } else { + let mut spans = vec![Span::raw(seg.into_owned())]; + if i == chunks_len - 1 && is_streaming { + spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta))); + } + lines.push(Line::from(spans)); + } + } + } + } else { + // No role labels - just show content + let content = formatted.join("\n"); + let chunks = wrap(&content, content_width as usize); + let chunks_len = chunks.len(); + for (i, seg) in chunks.into_iter().enumerate() { + let mut spans = vec![Span::raw(seg.into_owned())]; + if i == chunks_len - 1 && is_streaming { + spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta))); + } + lines.push(Line::from(spans)); + } } - for (i, line) in formatted.iter().enumerate() { - let mut spans = Vec::new(); - spans.push(Span::raw(format!("{indent}{line}"))); - if i == formatted.len() - 1 && is_streaming { - spans.push(Span::styled(" ▌", Style::default().fg(Color::Magenta))); - } - lines.push(Line::from(spans)); - } // Add an empty line after each message, except the last one if message_index < conversation.messages.len() - 1 { lines.push(Line::from("")); @@ -419,8 +470,9 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if app.get_loading_indicator() != "" && last_message_is_user { let loading_spans = vec![ + Span::raw("🤖 "), Span::styled( - "🤖 Assistant:", + "Assistant:", Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD), ), Span::styled( @@ -435,33 +487,19 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { lines.push(Line::from("No messages yet. Press 'i' to start typing.")); } - // Wrap lines to get accurate content height - let wrapped: Vec = { - use textwrap::wrap; - let mut out = Vec::new(); - for l in &lines { - let s = l.to_string(); - for w in wrap(&s, content_width as usize) { - out.push(Line::from(w.into_owned())); - } - } - out - }; - // Update AutoScroll state with accurate content length let auto_scroll = app.auto_scroll_mut(); - auto_scroll.content_len = wrapped.len(); + auto_scroll.content_len = lines.len(); auto_scroll.on_viewport(viewport_height); let scroll_position = app.scroll().min(u16::MAX as usize) as u16; - let paragraph = Paragraph::new(wrapped) + let paragraph = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), ) - .wrap(Wrap { trim: false }) .scroll((scroll_position, 0)); frame.render_widget(paragraph, area);