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_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<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 mut card_lines = Vec::with_capacity(lines.len() + 2);
@@ -2285,6 +2290,46 @@ impl ChatApp {
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(
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,6 +8496,27 @@ impl ChatApp {
.and_then(|value| value.as_str()),
);
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);
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(),
@@ -8453,12 +8528,14 @@ impl ChatApp {
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::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()

View File

@@ -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,7 +2765,8 @@ 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![
let line = clip_line_to_width(
Line::from(vec![
Span::styled(
marker,
Style::default()
@@ -2761,11 +2780,13 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
.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, .. } => {
let mut lines = Vec::new();
let mut lines: Vec<Line<'static>> = 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(
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(
lines.push(clip_line_to_width(
Line::from(Span::styled(
" <model unavailable>",
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(
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),
))];
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]);
}
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(
model: &ModelInfo,
detail: Option<&DetailedModelInfo>,