feat(tui): add line-clipping helper and compact message card rendering for narrow widths

- Introduce `MIN_MESSAGE_CARD_WIDTH` and use it to switch to compact card layout when terminal width is limited.
- Implement `clip_line_to_width` to truncate UI lines based on available width, preventing overflow in model selector and headers.
- Adjust viewport and card width calculations to respect inner area constraints and handle compact cards.
- Update resize handling and rendering logic to use the new width calculations and clipping functionality.
This commit is contained in:
2025-10-15 06:51:18 +02:00
parent 30c375b6c5
commit 5210e196f2
2 changed files with 209 additions and 59 deletions

View File

@@ -70,6 +70,7 @@ const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450);
const RESIZE_STEP: f32 = 0.05; const RESIZE_STEP: f32 = 0.05;
const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25]; const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25];
const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500); 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SlashOutcome { enum SlashOutcome {
@@ -2266,6 +2267,10 @@ impl ChatApp {
card_width: usize, card_width: usize,
theme: &Theme, theme: &Theme,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
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 inner_width = card_width.saturating_sub(4).max(1);
let mut card_lines = Vec::with_capacity(lines.len() + 2); let mut card_lines = Vec::with_capacity(lines.len() + 2);
@@ -2285,6 +2290,46 @@ impl ChatApp {
card_lines card_lines
} }
fn wrap_message_compact(
lines: Vec<Line<'static>>,
role: &Role,
timestamp: Option<&str>,
markers: &[String],
theme: &Theme,
) -> Vec<Line<'static>> {
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<Span<'static>> =
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( fn build_card_header(
role: &Role, role: &Role,
timestamp: Option<&str>, timestamp: Option<&str>,
@@ -4178,7 +4223,7 @@ impl ChatApp {
fn handle_resize(&mut self, width: u16, _height: u16) { fn handle_resize(&mut self, width: u16, _height: u16) {
let approx_content_width = usize::from(width.saturating_sub(6)); 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.auto_scroll.stick_to_bottom = true;
self.thinking_scroll.stick_to_bottom = true; self.thinking_scroll.stick_to_bottom = true;
if let Some(scroll) = self.code_view_scroll_mut() { if let Some(scroll) = self.code_view_scroll_mut() {
@@ -8295,9 +8340,18 @@ impl ChatApp {
FocusedPanel::Chat => { FocusedPanel::Chat => {
let conversation = self.conversation(); let conversation = self.conversation();
let mut formatter = self.formatter().clone(); let mut formatter = self.formatter().clone();
let body_width = self.content_width.max(20); let body_width = self.content_width.max(1);
let card_width = body_width.saturating_add(4); let mut card_width = body_width.saturating_add(4);
let inner_width = card_width.saturating_sub(4).max(1); 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); formatter.set_wrap_width(body_width);
let role_label_mode = formatter.role_label_mode(); let role_label_mode = formatter.role_label_mode();
@@ -8442,22 +8496,45 @@ impl ChatApp {
.and_then(|value| value.as_str()), .and_then(|value| value.as_str()),
); );
lines.push(Self::build_card_header_plain( if compact_cards {
role, let (emoji, title) = role_label_parts(role);
formatted_timestamp.as_deref(), let mut header = format!("{emoji} {title}");
&markers, if let Some(ts) = formatted_timestamp.as_deref() {
card_width, header.push_str(" · ");
)); header.push_str(ts);
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));
} }
} 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 let last_message_is_user = conversation
.messages .messages

View File

@@ -11,7 +11,9 @@ use tui_textarea::TextArea;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; 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::highlight;
use crate::state::{ use crate::state::{
CodePane, EditorTab, FileFilterMode, FileNode, LayoutNode, PaletteGroup, PaneId, 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 // Calculate viewport dimensions for autoscroll calculations
let viewport_height = area.height.saturating_sub(2) as usize; // subtract borders 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 inner_width = usize::from(area.width.saturating_sub(2)).max(1);
let body_width = card_width.saturating_sub(4).max(12); 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); app.set_viewport_dimensions(viewport_height, body_width);
let total_messages = app.message_count(); 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 { if inner.width == 0 || inner.height == 0 {
return; 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() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@@ -2747,25 +2765,28 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
match item.kind() { match item.kind() {
ModelSelectorItemKind::Header { provider, expanded } => { ModelSelectorItemKind::Header { provider, expanded } => {
let marker = if *expanded { "" } else { "" }; let marker = if *expanded { "" } else { "" };
let lines = vec![Line::from(vec![ let line = clip_line_to_width(
Span::styled( Line::from(vec![
marker, Span::styled(
Style::default() marker,
.fg(theme.placeholder) Style::default()
.add_modifier(Modifier::BOLD), .fg(theme.placeholder)
), .add_modifier(Modifier::BOLD),
Span::raw(" "), ),
Span::styled( Span::raw(" "),
provider.clone(), Span::styled(
Style::default() provider.clone(),
.fg(theme.mode_command) Style::default()
.add_modifier(Modifier::BOLD), .fg(theme.mode_command)
), .add_modifier(Modifier::BOLD),
])]; ),
items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); ]),
max_line_width,
);
items.push(ListItem::new(vec![line]).style(Style::default().bg(theme.background)));
} }
ModelSelectorItemKind::Model { model_index, .. } => { ModelSelectorItemKind::Model { model_index, .. } => {
let mut lines = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(model) = app.model_info_by_index(*model_index) { if let Some(model) = app.model_info_by_index(*model_index) {
let badges = model_badge_icons(model); let badges = model_badge_icons(model);
let detail = app.cached_model_detail(&model.id); let detail = app.cached_model_detail(&model.id);
@@ -2775,34 +2796,43 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
&badges, &badges,
model.id == active_model_id, model.id == active_model_id,
); );
lines.push(Line::from(Span::styled( lines.push(clip_line_to_width(
title, Line::from(Span::styled(title, Style::default().fg(theme.text))),
Style::default().fg(theme.text), max_line_width,
))); ));
if let Some(meta) = metadata { if let Some(meta) = metadata {
lines.push(Line::from(Span::styled( lines.push(clip_line_to_width(
meta, Line::from(Span::styled(
Style::default() meta,
.fg(theme.placeholder) Style::default()
.add_modifier(Modifier::DIM), .fg(theme.placeholder)
))); .add_modifier(Modifier::DIM),
)),
max_line_width,
));
} }
} else { } else {
lines.push(Line::from(Span::styled( lines.push(clip_line_to_width(
" <model unavailable>", Line::from(Span::styled(
Style::default().fg(theme.error), " <model unavailable>",
))); Style::default().fg(theme.error),
)),
max_line_width,
));
} }
items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); items.push(ListItem::new(lines).style(Style::default().bg(theme.background)));
} }
ModelSelectorItemKind::Empty { provider } => { ModelSelectorItemKind::Empty { provider } => {
let lines = vec![Line::from(Span::styled( let line = clip_line_to_width(
format!(" (no models configured for {provider})"), Line::from(Span::styled(
Style::default() format!(" (no models configured for {provider})"),
.fg(theme.placeholder) Style::default()
.add_modifier(Modifier::DIM | Modifier::ITALIC), .fg(theme.placeholder)
))]; .add_modifier(Modifier::DIM | Modifier::ITALIC),
items.push(ListItem::new(lines).style(Style::default().bg(theme.background))); )),
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]); 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::<Span<'static>>::new());
}
let mut used = 0usize;
let mut clipped: Vec<Span<'static>> = 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( fn build_model_selector_label(
model: &ModelInfo, model: &ModelInfo,
detail: Option<&DetailedModelInfo>, detail: Option<&DetailedModelInfo>,