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:
@@ -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,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
|
||||
|
||||
@@ -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<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(
|
||||
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(
|
||||
" <model unavailable>",
|
||||
Style::default().fg(theme.error),
|
||||
)));
|
||||
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(
|
||||
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::<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>,
|
||||
|
||||
Reference in New Issue
Block a user