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_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
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
Reference in New Issue
Block a user