diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index 316536d..8581c2d 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -722,6 +722,8 @@ pub struct UiSettings { pub show_cursor_outside_insert: bool, #[serde(default = "UiSettings::default_syntax_highlighting")] pub syntax_highlighting: bool, + #[serde(default = "UiSettings::default_show_timestamps")] + pub show_timestamps: bool, } impl UiSettings { @@ -765,6 +767,10 @@ impl UiSettings { false } + const fn default_show_timestamps() -> bool { + true + } + fn deserialize_role_label_mode<'de, D>( deserializer: D, ) -> std::result::Result @@ -831,6 +837,7 @@ impl Default for UiSettings { scrollback_lines: Self::default_scrollback_lines(), show_cursor_outside_insert: Self::default_show_cursor_outside_insert(), syntax_highlighting: Self::default_syntax_highlighting(), + show_timestamps: Self::default_show_timestamps(), } } } diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index d193667..da29156 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -38,6 +38,7 @@ anyhow = { workspace = true } uuid = { workspace = true } serde_json.workspace = true serde.workspace = true +chrono = { workspace = true } [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 2c25ae2..331ee8d 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result, anyhow}; +use chrono::{DateTime, Local}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use owlen_core::mcp::remote_client::RemoteMcpClient; use owlen_core::{ @@ -44,7 +45,7 @@ use std::hash::{Hash, Hasher}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; use dirs::{config_dir, data_local_dir}; @@ -182,6 +183,7 @@ pub struct ChatApp { message_line_cache: HashMap, // Cached rendered lines per message show_cursor_outside_insert: bool, // Configurable cursor visibility flag syntax_highlighting: bool, // Whether syntax highlighting is enabled + show_message_timestamps: bool, // Whether to render timestamps in chat headers supports_extended_colors: bool, // Terminal supports 256-color output auto_scroll: AutoScroll, // Auto-scroll state for message rendering thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel @@ -258,15 +260,24 @@ struct MessageCacheEntry { wrap_width: usize, role_label_mode: RoleLabelDisplay, syntax_highlighting: bool, + show_timestamps: bool, content_hash: u64, lines: Vec>, + metrics: MessageLayoutMetrics, +} + +#[derive(Clone, Debug, Default)] +struct MessageLayoutMetrics { + line_count: usize, + body_width: usize, + card_width: usize, } pub(crate) struct MessageRenderContext<'a> { formatter: &'a mut owlen_core::formatting::MessageFormatter, role_label_mode: RoleLabelDisplay, - content_width: usize, - is_last_message: bool, + body_width: usize, + card_width: usize, is_streaming: bool, loading_indicator: &'a str, theme: &'a Theme, @@ -278,8 +289,8 @@ impl<'a> MessageRenderContext<'a> { pub(crate) fn new( formatter: &'a mut owlen_core::formatting::MessageFormatter, role_label_mode: RoleLabelDisplay, - content_width: usize, - is_last_message: bool, + body_width: usize, + card_width: usize, is_streaming: bool, loading_indicator: &'a str, theme: &'a Theme, @@ -288,8 +299,8 @@ impl<'a> MessageRenderContext<'a> { Self { formatter, role_label_mode, - content_width, - is_last_message, + body_width, + card_width, is_streaming, loading_indicator, theme, @@ -372,6 +383,7 @@ impl ChatApp { let show_onboarding = config_guard.ui.show_onboarding; let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert; let syntax_highlighting = config_guard.ui.syntax_highlighting; + let show_timestamps = config_guard.ui.show_timestamps; drop(config_guard); let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| { eprintln!("Warning: Theme '{}' not found, using default", theme_name); @@ -464,6 +476,7 @@ impl ChatApp { show_cursor_outside_insert, syntax_highlighting, supports_extended_colors: detect_extended_color_support(), + show_message_timestamps: show_timestamps, }; app.update_command_palette_catalog(); @@ -1441,10 +1454,11 @@ impl ChatApp { } } - fn message_content_hash(role: &Role, content: &str) -> u64 { + fn message_content_hash(role: &Role, content: &str, tool_signature: &str) -> u64 { let mut hasher = DefaultHasher::new(); role.to_string().hash(&mut hasher); content.hash(&mut hasher); + tool_signature.hash(&mut hasher); hasher.finish() } @@ -1453,16 +1467,18 @@ impl ChatApp { } fn sync_ui_preferences_from_config(&mut self) { - let (show_cursor, role_label_mode, syntax_highlighting) = { + let (show_cursor, role_label_mode, syntax_highlighting, show_timestamps) = { let guard = self.controller.config(); ( guard.ui.show_cursor_outside_insert, guard.ui.role_label_mode, guard.ui.syntax_highlighting, + guard.ui.show_timestamps, ) }; self.show_cursor_outside_insert = show_cursor; self.syntax_highlighting = syntax_highlighting; + self.show_message_timestamps = show_timestamps; self.controller.set_role_label_mode(role_label_mode); self.message_line_cache.clear(); } @@ -1487,17 +1503,28 @@ impl ChatApp { let MessageRenderContext { formatter, role_label_mode, - content_width, - is_last_message, + body_width, + card_width, is_streaming, loading_indicator, theme, syntax_highlighting, } = ctx; - let (message_id, role, raw_content) = { + let (message_id, role, raw_content, timestamp, tool_calls, tool_result_id) = { let conversation = self.conversation(); let message = &conversation.messages[message_index]; - (message.id, message.role.clone(), message.content.clone()) + ( + message.id, + message.role.clone(), + message.content.clone(), + message.timestamp, + message.tool_calls.clone(), + message + .metadata + .get("tool_call_id") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()), + ) }; let display_content = if matches!(role, Role::Assistant) { @@ -1512,14 +1539,25 @@ impl ChatApp { let trimmed = normalized_content.trim(); let content = trimmed.to_string(); let segments = parse_message_segments(trimmed); - let content_hash = Self::message_content_hash(&role, &content); + let tool_signature = tool_calls + .as_ref() + .map(|calls| { + let mut names: Vec<&str> = calls.iter().map(|call| call.name.as_str()).collect(); + names.sort_unstable(); + names.join("|") + }) + .unwrap_or_default(); + let content_hash = Self::message_content_hash(&role, &content, &tool_signature); if !is_streaming && let Some(entry) = self.message_line_cache.get(&message_id) - && entry.wrap_width == content_width + && entry.wrap_width == card_width && entry.role_label_mode == role_label_mode && entry.syntax_highlighting == syntax_highlighting && entry.theme_name == theme.name + && entry.show_timestamps == self.show_message_timestamps + && entry.metrics.body_width == body_width + && entry.metrics.card_width == card_width && entry.content_hash == content_hash { return entry.lines.clone(); @@ -1538,194 +1576,82 @@ impl ChatApp { None }; + let mut append_segments = |segments: &[MessageSegment], + indent: &str, + available_width: usize, + indicator_target: &mut Option, + code_width: usize| { + if segments.is_empty() { + let line_text = if indent.is_empty() { + String::new() + } else { + indent.to_string() + }; + rendered.push(Line::from(vec![Span::styled(line_text, content_style)])); + *indicator_target = Some(rendered.len() - 1); + return; + } + + for segment in segments { + match segment { + MessageSegment::Text { lines } => { + for line_text in lines { + let mut chunks = wrap_unicode(line_text.as_str(), available_width); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + let text = if indent.is_empty() { + chunk.clone() + } else { + format!("{indent}{chunk}") + }; + rendered.push(Line::from(vec![Span::styled(text, content_style)])); + *indicator_target = Some(rendered.len() - 1); + } + } + } + MessageSegment::CodeBlock { language, lines } => { + append_code_block_lines( + &mut rendered, + indent, + code_width, + language.as_deref(), + lines, + theme, + syntax_highlighting, + indicator_target, + ); + } + } + } + }; + match role_label_mode { RoleLabelDisplay::Above => { - let (emoji, title) = role_label_parts(&role); - let mut role_line_spans = vec![ - Span::raw(format!("{emoji} ")), - Span::styled( - format!("{title}:"), - Self::role_style(theme, &role).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - ]; - - if matches!(role, Role::Assistant) - && is_streaming - && is_last_message - && !loading_indicator.is_empty() - { - role_line_spans.push(Span::styled( - format!(" {}", loading_indicator), - Style::default().fg(theme.info), - )); - } - - rendered.push(Line::from(role_line_spans)); - let indent = " "; let indent_width = UnicodeWidthStr::width(indent); - let available_width = content_width.saturating_sub(indent_width).max(1); - - if segments.is_empty() { - rendered.push(Line::from(vec![Span::styled( - indent.to_string(), - content_style, - )])); - indicator_target = Some(rendered.len() - 1); - } else { - for segment in &segments { - match segment { - MessageSegment::Text { lines } => { - for line in lines { - let mut chunks = wrap_unicode(line.as_str(), available_width); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - let text = format!("{indent}{chunk}"); - rendered.push(Line::from(vec![Span::styled( - text, - content_style, - )])); - indicator_target = Some(rendered.len() - 1); - } - } - } - MessageSegment::CodeBlock { language, lines } => { - append_code_block_lines( - &mut rendered, - indent, - content_width.saturating_sub(indent_width), - language.as_deref(), - lines, - theme, - syntax_highlighting, - &mut indicator_target, - ); - } - } - } - } + let available_width = body_width.saturating_sub(indent_width).max(1); + append_segments( + &segments, + indent, + available_width, + &mut indicator_target, + body_width.saturating_sub(indent_width), + ); } - RoleLabelDisplay::Inline => { - let (emoji, title) = role_label_parts(&role); - let label_style = Self::role_style(theme, &role).add_modifier(Modifier::BOLD); - let inline_label = format!("{emoji} {title}:"); - let label_width = UnicodeWidthStr::width(inline_label.as_str()); - let max_label_width = max_inline_label_width(); - let padding = max_label_width.saturating_sub(label_width); - let indent_columns = max_label_width + 1; - let inline_indent = " ".repeat(indent_columns); - let available_width = content_width.saturating_sub(indent_columns).max(1); - - let mut label_spans = vec![ - Span::raw(format!("{emoji} ")), - Span::styled(format!("{title}:"), label_style), - ]; - if padding > 0 { - label_spans.push(Span::raw(" ".repeat(padding))); - } - label_spans.push(Span::raw(" ")); - - let mut pending_label = Some(label_spans); - - if segments.is_empty() { - if let Some(spans) = pending_label.take() { - rendered.push(Line::from(spans)); - indicator_target = Some(rendered.len() - 1); - } - } else { - for segment in &segments { - match segment { - MessageSegment::Text { lines } => { - for line in lines { - let mut chunks = wrap_unicode(line.as_str(), available_width); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - if let Some(mut spans) = pending_label.take() { - spans.push(Span::styled(chunk.clone(), content_style)); - rendered.push(Line::from(spans)); - } else { - let text = format!("{inline_indent}{chunk}"); - rendered.push(Line::from(vec![Span::styled( - text, - content_style, - )])); - } - indicator_target = Some(rendered.len() - 1); - } - } - } - MessageSegment::CodeBlock { language, lines } => { - if let Some(spans) = pending_label.take() { - rendered.push(Line::from(spans)); - indicator_target = Some(rendered.len() - 1); - } - append_code_block_lines( - &mut rendered, - inline_indent.as_str(), - content_width.saturating_sub(indent_columns), - language.as_deref(), - lines, - theme, - syntax_highlighting, - &mut indicator_target, - ); - } - } - } - } - - if let Some(spans) = pending_label.take() { - rendered.push(Line::from(spans)); - indicator_target = Some(rendered.len() - 1); - } - } - RoleLabelDisplay::None => { - let available_width = content_width.max(1); - - if segments.is_empty() { - rendered.push(Line::from(vec![Span::styled(String::new(), content_style)])); - indicator_target = Some(rendered.len() - 1); - } else { - for segment in &segments { - match segment { - MessageSegment::Text { lines } => { - for line in lines { - let mut chunks = wrap_unicode(line.as_str(), available_width); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - rendered.push(Line::from(vec![Span::styled( - chunk.clone(), - content_style, - )])); - indicator_target = Some(rendered.len() - 1); - } - } - } - MessageSegment::CodeBlock { language, lines } => { - append_code_block_lines( - &mut rendered, - "", - content_width, - language.as_deref(), - lines, - theme, - syntax_highlighting, - &mut indicator_target, - ); - } - } - } - } + RoleLabelDisplay::Inline | RoleLabelDisplay::None => { + let indent = ""; + let available_width = body_width.max(1); + append_segments( + &segments, + indent, + available_width, + &mut indicator_target, + body_width, + ); } } - if let Some(indicator) = indicator_span { if let Some(idx) = indicator_target { if let Some(line) = rendered.get_mut(idx) { @@ -1738,21 +1664,290 @@ impl ChatApp { } } + let markers = + Self::message_tool_markers(&role, tool_calls.as_ref(), tool_result_id.as_deref()); + let formatted_timestamp = if self.show_message_timestamps { + Some(Self::format_message_timestamp(timestamp)) + } else { + None + }; + + let card_lines = Self::wrap_message_in_card( + rendered, + &role, + formatted_timestamp.as_deref(), + &markers, + card_width, + theme, + ); + let metrics = MessageLayoutMetrics { + line_count: card_lines.len(), + body_width, + card_width, + }; + debug_assert_eq!(metrics.line_count, card_lines.len()); + if !is_streaming { self.message_line_cache.insert( message_id, MessageCacheEntry { theme_name: theme.name.clone(), - wrap_width: content_width, + wrap_width: card_width, role_label_mode, syntax_highlighting, + show_timestamps: self.show_message_timestamps, content_hash, - lines: rendered.clone(), + lines: card_lines.clone(), + metrics: metrics.clone(), }, ); } - rendered + card_lines + } + + fn message_tool_markers( + role: &Role, + tool_calls: Option<&Vec>, + tool_result_id: Option<&str>, + ) -> Vec { + let mut markers = Vec::new(); + + match role { + Role::Assistant => { + if let Some(calls) = tool_calls { + const MAX_VISIBLE: usize = 3; + for call in calls.iter().take(MAX_VISIBLE) { + markers.push(format!("[Tool: {}]", call.name)); + } + if calls.len() > MAX_VISIBLE { + markers.push(format!("[+{}]", calls.len() - MAX_VISIBLE)); + } + } + } + Role::Tool => { + if let Some(id) = tool_result_id { + markers.push(format!("[Result: {id}]")); + } else { + markers.push("[Result]".to_string()); + } + } + _ => {} + } + + markers + } + + fn format_message_timestamp(timestamp: SystemTime) -> String { + let datetime: DateTime = timestamp.into(); + datetime.format("%H:%M").to_string() + } + + fn wrap_message_in_card( + mut lines: Vec>, + role: &Role, + timestamp: Option<&str>, + markers: &[String], + card_width: usize, + theme: &Theme, + ) -> Vec> { + let inner_width = card_width.saturating_sub(4).max(1); + let mut card_lines = Vec::with_capacity(lines.len() + 2); + + card_lines.push(Self::build_card_header( + role, timestamp, markers, card_width, theme, + )); + + if lines.is_empty() { + lines.push(Line::from(String::new())); + } + + for line in lines { + card_lines.push(Self::wrap_card_body_line(line, inner_width, theme)); + } + + card_lines.push(Self::build_card_footer(card_width, theme)); + card_lines + } + + fn build_card_header( + role: &Role, + timestamp: Option<&str>, + markers: &[String], + card_width: usize, + theme: &Theme, + ) -> Line<'static> { + let border_style = Style::default().fg(theme.unfocused_panel_border); + let role_style = Self::role_style(theme, role).add_modifier(Modifier::BOLD); + let meta_style = Style::default().fg(theme.placeholder); + let tool_style = Style::default() + .fg(theme.tool_output) + .add_modifier(Modifier::BOLD); + + let mut spans: Vec> = Vec::new(); + spans.push(Span::styled("┌", border_style)); + let mut consumed = 1usize; + + spans.push(Span::styled(" ", border_style)); + consumed += 1; + + let (emoji, title) = role_label_parts(role); + let label_text = format!("{emoji} {title}"); + let label_width = UnicodeWidthStr::width(label_text.as_str()); + spans.push(Span::styled(label_text, role_style)); + consumed += label_width; + + if let Some(ts) = timestamp { + let separator = " — "; + let separator_width = UnicodeWidthStr::width(separator); + let ts_width = UnicodeWidthStr::width(ts); + if consumed + separator_width + ts_width + 1 < card_width { + spans.push(Span::styled(separator.to_string(), border_style)); + consumed += separator_width; + spans.push(Span::styled(ts.to_string(), meta_style)); + consumed += ts_width; + } + } + + for marker in markers { + let spacer_width = 2usize; + let marker_width = UnicodeWidthStr::width(marker.as_str()); + if consumed + spacer_width + marker_width + 1 > card_width { + break; + } + spans.push(Span::styled(" ".to_string(), border_style)); + spans.push(Span::styled(marker.clone(), tool_style)); + consumed += spacer_width + marker_width; + } + + if consumed + 1 < card_width { + spans.push(Span::styled(" ", border_style)); + consumed += 1; + } + + let remaining = card_width.saturating_sub(consumed + 1); + if remaining > 0 { + spans.push(Span::styled("─".repeat(remaining), border_style)); + } + + spans.push(Span::styled("┐", border_style)); + Line::from(spans) + } + + fn build_card_footer(card_width: usize, theme: &Theme) -> Line<'static> { + let border_style = Style::default().fg(theme.unfocused_panel_border); + let mut spans = Vec::new(); + spans.push(Span::styled("└", border_style)); + let horizontal = card_width.saturating_sub(2); + if horizontal > 0 { + spans.push(Span::styled("─".repeat(horizontal), border_style)); + } + spans.push(Span::styled("┘", border_style)); + Line::from(spans) + } + + fn wrap_card_body_line( + line: Line<'static>, + inner_width: usize, + theme: &Theme, + ) -> Line<'static> { + let border_style = Style::default().fg(theme.unfocused_panel_border); + let mut spans = Vec::new(); + spans.push(Span::styled("│ ", border_style)); + + let content_width = Self::line_display_width(&line).min(inner_width); + let mut body_spans = line.spans; + spans.append(&mut body_spans); + + if content_width < inner_width { + spans.push(Span::styled( + " ".repeat(inner_width - content_width), + Style::default(), + )); + } + + spans.push(Span::styled(" │", border_style)); + Line::from(spans) + } + + fn build_card_header_plain( + role: &Role, + timestamp: Option<&str>, + markers: &[String], + card_width: usize, + ) -> String { + let mut result = String::new(); + let mut consumed = 0usize; + + result.push('┌'); + consumed += 1; + + result.push(' '); + consumed += 1; + + let (emoji, title) = role_label_parts(role); + let label_text = format!("{emoji} {title}"); + result.push_str(&label_text); + consumed += UnicodeWidthStr::width(label_text.as_str()); + + if let Some(ts) = timestamp { + let separator = " — "; + let separator_width = UnicodeWidthStr::width(separator); + let ts_width = UnicodeWidthStr::width(ts); + if consumed + separator_width + ts_width + 1 < card_width { + result.push_str(separator); + result.push_str(ts); + consumed += separator_width + ts_width; + } + } + + for marker in markers { + let spacer_width = 2usize; + let marker_width = UnicodeWidthStr::width(marker.as_str()); + if consumed + spacer_width + marker_width + 1 >= card_width { + break; + } + result.push_str(" "); + result.push_str(marker); + consumed += spacer_width + marker_width; + } + + let remaining = card_width.saturating_sub(consumed + 1); + if remaining > 0 { + result.push_str(&"─".repeat(remaining)); + } + + result.push('┐'); + result + } + + fn wrap_card_body_line_plain(line: &str, inner_width: usize) -> String { + let mut result = String::from("│ "); + result.push_str(line); + let content_width = UnicodeWidthStr::width(line); + if content_width < inner_width { + result.push_str(&" ".repeat(inner_width - content_width)); + } + result.push_str(" │"); + result + } + + fn build_card_footer_plain(card_width: usize) -> String { + let mut result = String::new(); + result.push('└'); + let horizontal = card_width.saturating_sub(2); + if horizontal > 0 { + result.push_str(&"─".repeat(horizontal)); + } + result.push('┘'); + result + } + + fn line_display_width(line: &Line<'_>) -> usize { + line.spans + .iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() } pub fn apply_chat_scrollback_trim(&mut self, removed: usize, remaining: usize) { @@ -6675,15 +6870,16 @@ impl ChatApp { FocusedPanel::Chat => { let conversation = self.conversation(); let mut formatter = self.formatter().clone(); - let wrap_width = self.content_width.max(20); - formatter.set_wrap_width(wrap_width); + let body_width = self.content_width.max(20); + let card_width = body_width.saturating_add(4); + let inner_width = card_width.saturating_sub(4).max(1); + formatter.set_wrap_width(body_width); let role_label_mode = formatter.role_label_mode(); let mut lines = Vec::new(); - for (message_index, message) in conversation.messages.iter().enumerate() { + for message in conversation.messages.iter() { let role = &message.role; - let content_to_display = if matches!(role, Role::Assistant) { let (content_without_think, _) = formatter.extract_thinking(&message.content); @@ -6697,230 +6893,155 @@ impl ChatApp { let is_streaming = message .metadata .get("streaming") - .and_then(|v| v.as_bool()) + .and_then(|value| value.as_bool()) .unwrap_or(false); let normalized_content = content_to_display.replace("\r\n", "\n"); let trimmed = normalized_content.trim(); let segments = parse_message_segments(trimmed); + + let mut body_lines: Vec = Vec::new(); let mut indicator_target: Option = None; - let spinner_symbol = streaming_indicator_symbol(self.get_loading_indicator()); - let is_last_message = message_index + 1 == conversation.messages.len(); + + let mut append_segments_plain = + |segments: &[MessageSegment], + indent: &str, + available_width: usize, + indicator_target: &mut Option, + code_width: usize| { + if segments.is_empty() { + let line_text = if indent.is_empty() { + String::new() + } else { + indent.to_string() + }; + body_lines.push(line_text); + *indicator_target = Some(body_lines.len() - 1); + return; + } + + for segment in segments { + match segment { + MessageSegment::Text { lines: seg_lines } => { + for line_text in seg_lines { + let mut chunks = + wrap_unicode(line_text.as_str(), available_width); + if chunks.is_empty() { + chunks.push(String::new()); + } + for chunk in chunks { + let text = if indent.is_empty() { + chunk.clone() + } else { + format!("{indent}{chunk}") + }; + body_lines.push(text); + *indicator_target = Some(body_lines.len() - 1); + } + } + } + MessageSegment::CodeBlock { + language, + lines: code_lines, + } => { + append_code_block_lines_plain( + &mut body_lines, + indent, + code_width, + language.as_deref(), + code_lines, + indicator_target, + ); + } + } + } + }; match role_label_mode { RoleLabelDisplay::Above => { - let (emoji, title) = role_label_parts(role); - let mut label_line = format!("{emoji} {title}: "); - if matches!(role, Role::Assistant) - && is_streaming - && is_last_message - && !self.get_loading_indicator().is_empty() - { - label_line.push(' '); - label_line.push_str(self.get_loading_indicator()); - } - lines.push(label_line); - let indent = " "; let indent_width = UnicodeWidthStr::width(indent); - let available_width = wrap_width.saturating_sub(indent_width).max(1); - - if segments.is_empty() { - lines.push(indent.to_string()); - indicator_target = Some(lines.len() - 1); - } else { - for segment in &segments { - match segment { - MessageSegment::Text { lines: seg_lines } => { - for line_text in seg_lines { - let mut chunks = wrap_unicode( - line_text.as_str(), - available_width, - ); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - lines.push(format!("{indent}{chunk}")); - indicator_target = Some(lines.len() - 1); - } - } - } - MessageSegment::CodeBlock { - language, - lines: code_lines, - } => { - append_code_block_lines_plain( - &mut lines, - indent, - wrap_width.saturating_sub(indent_width), - language.as_deref(), - code_lines, - &mut indicator_target, - ); - } - } - } - } + let available_width = body_width.saturating_sub(indent_width).max(1); + append_segments_plain( + &segments, + indent, + available_width, + &mut indicator_target, + body_width.saturating_sub(indent_width), + ); } - RoleLabelDisplay::Inline => { - let (emoji, title) = role_label_parts(role); - let inline_label = format!("{emoji} {title}:"); - let label_width = UnicodeWidthStr::width(inline_label.as_str()); - let max_label_width = max_inline_label_width(); - let padding = max_label_width.saturating_sub(label_width); - let indent_columns = max_label_width + 1; - let inline_indent = " ".repeat(indent_columns); - let available_width = wrap_width.saturating_sub(indent_columns).max(1); - - let mut label_line = inline_label.clone(); - if padding > 0 { - label_line.push_str(&" ".repeat(padding)); - } - label_line.push(' '); - - let mut pending_label = Some(label_line); - - if segments.is_empty() { - if let Some(line) = pending_label.take() { - lines.push(line); - indicator_target = Some(lines.len() - 1); - } - } else { - for segment in &segments { - match segment { - MessageSegment::Text { lines: seg_lines } => { - for line_text in seg_lines { - let mut chunks = wrap_unicode( - line_text.as_str(), - available_width, - ); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - if let Some(mut line) = pending_label.take() { - line.push_str(&chunk); - lines.push(line); - } else { - lines.push(format!( - "{inline_indent}{chunk}" - )); - } - indicator_target = Some(lines.len() - 1); - } - } - } - MessageSegment::CodeBlock { - language, - lines: code_lines, - } => { - if let Some(line) = pending_label.take() { - lines.push(line); - indicator_target = Some(lines.len() - 1); - } - append_code_block_lines_plain( - &mut lines, - inline_indent.as_str(), - wrap_width.saturating_sub(indent_columns), - language.as_deref(), - code_lines, - &mut indicator_target, - ); - } - } - } - if let Some(line) = pending_label.take() { - lines.push(line); - indicator_target = Some(lines.len() - 1); - } - } - } - RoleLabelDisplay::None => { - let available_width = wrap_width.max(1); - - if segments.is_empty() { - lines.push(String::new()); - indicator_target = Some(lines.len() - 1); - } else { - for segment in &segments { - match segment { - MessageSegment::Text { lines: seg_lines } => { - for line_text in seg_lines { - let mut chunks = wrap_unicode( - line_text.as_str(), - available_width, - ); - if chunks.is_empty() { - chunks.push(String::new()); - } - for chunk in chunks { - lines.push(chunk.clone()); - indicator_target = Some(lines.len() - 1); - } - } - } - MessageSegment::CodeBlock { - language, - lines: code_lines, - } => { - append_code_block_lines_plain( - &mut lines, - "", - wrap_width, - language.as_deref(), - code_lines, - &mut indicator_target, - ); - } - } - } - } + RoleLabelDisplay::Inline | RoleLabelDisplay::None => { + let indent = ""; + let available_width = body_width.max(1); + append_segments_plain( + &segments, + indent, + available_width, + &mut indicator_target, + body_width, + ); } } - if is_streaming && !spinner_symbol.is_empty() { + let loading_indicator = self.get_loading_indicator(); + if is_streaming && !loading_indicator.is_empty() { + let spinner_symbol = streaming_indicator_symbol(loading_indicator); if let Some(idx) = indicator_target { - if let Some(line) = lines.get_mut(idx) { - line.push(' '); + if let Some(line) = body_lines.get_mut(idx) { + if !line.is_empty() { + line.push(' '); + } line.push_str(spinner_symbol); } - } else if let Some(line) = lines.last_mut() { - line.push(' '); + } else if let Some(line) = body_lines.last_mut() { + if !line.is_empty() { + line.push(' '); + } line.push_str(spinner_symbol); + } else { + body_lines.push(spinner_symbol.to_string()); } } - if message_index < conversation.messages.len() - 1 { - lines.push(String::new()); - } - } + let formatted_timestamp = if self.show_message_timestamps { + Some(Self::format_message_timestamp(message.timestamp)) + } else { + None + }; + let markers = Self::message_tool_markers( + role, + message.tool_calls.as_ref(), + message + .metadata + .get("tool_call_id") + .and_then(|value| value.as_str()), + ); + lines.push(Self::build_card_header_plain( + role, + formatted_timestamp.as_deref(), + &markers, + card_width, + )); + + if body_lines.is_empty() { + lines.push(Self::wrap_card_body_line_plain("", inner_width)); + } else { + for body_line in body_lines { + lines.push(Self::wrap_card_body_line_plain(&body_line, inner_width)); + } + } + + lines.push(Self::build_card_footer_plain(card_width)); + } let last_message_is_user = conversation .messages .last() .map(|msg| matches!(msg.role, Role::User)) .unwrap_or(true); - if self.get_loading_indicator() != "" && last_message_is_user { - match role_label_mode { - RoleLabelDisplay::Inline => { - let (emoji, title) = role_label_parts(&Role::Assistant); - let inline_label = format!("{emoji} {title}:"); - let label_width = UnicodeWidthStr::width(inline_label.as_str()); - let padding = max_inline_label_width().saturating_sub(label_width); - let mut line = inline_label; - if padding > 0 { - line.push_str(&" ".repeat(padding)); - } - line.push(' '); - line.push_str(self.get_loading_indicator()); - lines.push(line); - } - _ => { - lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator())); - } - } + if !self.get_loading_indicator().is_empty() && last_message_is_user { + lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator())); } if self.chat_line_offset > 0 { diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 3e0dc18..9b753d2 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -1029,14 +1029,15 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { // Calculate viewport dimensions for autoscroll calculations let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders - let content_width = area.width.saturating_sub(4).max(20); - app.set_viewport_dimensions(viewport_height, usize::from(content_width)); + let card_width = usize::from(area.width.saturating_sub(4).max(20)); + let body_width = card_width.saturating_sub(4).max(12); + app.set_viewport_dimensions(viewport_height, body_width); let total_messages = app.message_count(); let mut formatter = app.formatter().clone(); // Reserve space for borders and the message indent so text fits within the block - formatter.set_wrap_width(usize::from(content_width)); + formatter.set_wrap_width(body_width); // Build the lines for messages using cached rendering let mut lines: Vec> = Vec::new(); @@ -1055,8 +1056,8 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { MessageRenderContext::new( &mut formatter, role_label_mode, - content_width as usize, - message_index + 1 == total_messages, + body_width, + card_width, is_streaming, app.get_loading_indicator(), &theme, @@ -1064,9 +1065,6 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { ), ); lines.extend(message_lines); - if message_index + 1 < total_messages { - lines.push(Line::from(String::new())); - } } // Add loading indicator ONLY if we're loading and there are no messages at all,