diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 86efe0e..c4f5a96 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -70,6 +70,7 @@ const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450); const RESIZE_STEP: f32 = 0.05; const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); +pub(crate) const MIN_MESSAGE_CARD_WIDTH: usize = 14; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SlashOutcome { @@ -2266,6 +2267,10 @@ impl ChatApp { card_width: usize, theme: &Theme, ) -> Vec> { + if card_width < MIN_MESSAGE_CARD_WIDTH { + return Self::wrap_message_compact(lines, role, timestamp, markers, theme); + } + let inner_width = card_width.saturating_sub(4).max(1); let mut card_lines = Vec::with_capacity(lines.len() + 2); @@ -2285,6 +2290,46 @@ impl ChatApp { card_lines } + fn wrap_message_compact( + lines: Vec>, + role: &Role, + timestamp: Option<&str>, + markers: &[String], + theme: &Theme, + ) -> Vec> { + 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 (emoji, title) = role_label_parts(role); + let mut header_spans: Vec> = + vec![Span::styled(format!("{emoji} {title}"), role_style)]; + + if let Some(ts) = timestamp { + header_spans.push(Span::styled(" · ".to_string(), meta_style)); + header_spans.push(Span::styled(ts.to_string(), meta_style)); + } + + for marker in markers { + header_spans.push(Span::styled(" ".to_string(), meta_style)); + header_spans.push(Span::styled(marker.clone(), tool_style)); + } + + let mut compact_lines = Vec::with_capacity(lines.len() + 2); + compact_lines.push(Line::from(header_spans)); + + if lines.is_empty() { + compact_lines.push(Line::from(vec![Span::raw("")])); + } else { + compact_lines.extend(lines); + } + + compact_lines.push(Line::from(vec![Span::raw("")])); + compact_lines + } + fn build_card_header( role: &Role, timestamp: Option<&str>, @@ -4178,7 +4223,7 @@ impl ChatApp { fn handle_resize(&mut self, width: u16, _height: u16) { let approx_content_width = usize::from(width.saturating_sub(6)); - self.content_width = approx_content_width.max(20); + self.content_width = approx_content_width.max(1); self.auto_scroll.stick_to_bottom = true; self.thinking_scroll.stick_to_bottom = true; if let Some(scroll) = self.code_view_scroll_mut() { @@ -8295,9 +8340,18 @@ impl ChatApp { FocusedPanel::Chat => { let conversation = self.conversation(); let mut formatter = self.formatter().clone(); - 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); + let body_width = self.content_width.max(1); + let mut card_width = body_width.saturating_add(4); + let mut compact_cards = false; + if card_width < MIN_MESSAGE_CARD_WIDTH { + card_width = body_width.saturating_add(2).max(1); + compact_cards = true; + } + let inner_width = if compact_cards { + card_width.saturating_sub(2).max(1) + } else { + card_width.saturating_sub(4).max(1) + }; formatter.set_wrap_width(body_width); let role_label_mode = formatter.role_label_mode(); @@ -8442,22 +8496,45 @@ impl ChatApp { .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)); + if compact_cards { + let (emoji, title) = role_label_parts(role); + let mut header = format!("{emoji} {title}"); + if let Some(ts) = formatted_timestamp.as_deref() { + header.push_str(" · "); + header.push_str(ts); } - } + for marker in &markers { + header.push(' '); + header.push_str(marker); + } + lines.push(header); - lines.push(Self::build_card_footer_plain(card_width)); + if body_lines.is_empty() { + lines.push(String::new()); + } else { + lines.extend(body_lines); + } + + lines.push(String::new()); + } else { + 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 diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 7190ae0..81fcb4b 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -11,7 +11,9 @@ use tui_textarea::TextArea; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use crate::chat_app::{ChatApp, HELP_TAB_COUNT, MessageRenderContext, ModelSelectorItemKind}; +use crate::chat_app::{ + ChatApp, HELP_TAB_COUNT, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, ModelSelectorItemKind, +}; use crate::highlight; use crate::state::{ CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, @@ -1320,8 +1322,21 @@ 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 card_width = usize::from(area.width.saturating_sub(4).max(20)); - let body_width = card_width.saturating_sub(4).max(12); + let inner_width = usize::from(area.width.saturating_sub(2)).max(1); + let mut card_width = inner_width.saturating_sub(2); + if card_width > inner_width { + card_width = inner_width; + } + if card_width < MIN_MESSAGE_CARD_WIDTH { + card_width = inner_width.max(1); + } + card_width = card_width.clamp(1, inner_width); + let compact_cards = card_width < MIN_MESSAGE_CARD_WIDTH; + let body_width = if compact_cards { + card_width.saturating_sub(2).max(1) + } else { + card_width.saturating_sub(4).max(1) + }; app.set_viewport_dimensions(viewport_height, body_width); let total_messages = app.message_count(); @@ -2734,6 +2749,9 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { if inner.width == 0 || inner.height == 0 { return; } + let highlight_symbol = " "; + let highlight_width = UnicodeWidthStr::width(highlight_symbol); + let max_line_width = inner.width.saturating_sub(highlight_width as u16).max(1) as usize; let layout = Layout::default() .direction(Direction::Vertical) @@ -2747,25 +2765,28 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { match item.kind() { ModelSelectorItemKind::Header { provider, expanded } => { let marker = if *expanded { "▼" } else { "▶" }; - let lines = vec![Line::from(vec![ - Span::styled( - marker, - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - provider.clone(), - Style::default() - .fg(theme.mode_command) - .add_modifier(Modifier::BOLD), - ), - ])]; - items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); + let line = clip_line_to_width( + Line::from(vec![ + Span::styled( + marker, + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + provider.clone(), + Style::default() + .fg(theme.mode_command) + .add_modifier(Modifier::BOLD), + ), + ]), + max_line_width, + ); + items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); } ModelSelectorItemKind::Model { model_index, .. } => { - let mut lines = Vec::new(); + let mut lines: Vec> = Vec::new(); if let Some(model) = app.model_info_by_index(*model_index) { let badges = model_badge_icons(model); let detail = app.cached_model_detail(&model.id); @@ -2775,34 +2796,43 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { &badges, model.id == active_model_id, ); - lines.push(Line::from(Span::styled( - title, - Style::default().fg(theme.text), - ))); + lines.push(clip_line_to_width( + Line::from(Span::styled(title, Style::default().fg(theme.text))), + max_line_width, + )); if let Some(meta) = metadata { - lines.push(Line::from(Span::styled( - meta, - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM), - ))); + lines.push(clip_line_to_width( + Line::from(Span::styled( + meta, + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM), + )), + max_line_width, + )); } } else { - lines.push(Line::from(Span::styled( - " ", - Style::default().fg(theme.error), - ))); + lines.push(clip_line_to_width( + Line::from(Span::styled( + " ", + Style::default().fg(theme.error), + )), + max_line_width, + )); } items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); } ModelSelectorItemKind::Empty { provider } => { - let lines = vec![Line::from(Span::styled( - format!(" (no models configured for {provider})"), - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::DIM | Modifier::ITALIC), - ))]; - items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); + let line = clip_line_to_width( + Line::from(Span::styled( + format!(" (no models configured for {provider})"), + Style::default() + .fg(theme.placeholder) + .add_modifier(Modifier::DIM | Modifier::ITALIC), + )), + max_line_width, + ); + items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background))); } } } @@ -2831,6 +2861,49 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) { frame.render_widget(footer, layout[1]); } +fn clip_line_to_width(line: Line<'_>, max_width: usize) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + let mut used = 0usize; + let mut clipped: Vec> = Vec::new(); + + for span in line.spans { + if used >= max_width { + break; + } + let text = span.content.to_string(); + let span_width = UnicodeWidthStr::width(text.as_str()); + if used + span_width <= max_width { + if !text.is_empty() { + clipped.push(Span::styled(text, span.style)); + } + used += span_width; + } else { + let mut buf = String::new(); + for grapheme in span.content.as_ref().graphemes(true) { + let g_width = UnicodeWidthStr::width(grapheme); + if g_width == 0 { + buf.push_str(grapheme); + continue; + } + if used + g_width > max_width { + break; + } + buf.push_str(grapheme); + used += g_width; + } + if !buf.is_empty() { + clipped.push(Span::styled(buf, span.style)); + } + break; + } + } + + Line::from(clipped) +} + fn build_model_selector_label( model: &ModelInfo, detail: Option<&DetailedModelInfo>,