use crate::core::app::ui_state::{EditSelectTarget, ToolPrompt};
use crate::core::app::App;
use crate::core::app::InspectMode;
use crate::core::message::{AppMessageKind, ROLE_ASSISTANT, ROLE_TOOL_CALL};
use crate::core::text_wrapping::{TextWrapper, WrapConfig};
use crate::ui::layout::{LayoutConfig, LayoutEngine, TableOverflowPolicy};
use crate::ui::osc_state::{compute_render_state, set_render_state, OscRenderState};
use crate::ui::span::SpanKind;
use crate::ui::title::build_main_title;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
use std::borrow::Cow;
use std::collections::VecDeque;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
pub fn ui(f: &mut Frame, app: &mut App) {
let bg_block = Block::default().style(Style::default().bg(app.ui.theme.background_color));
f.render_widget(bg_block, f.area());
let input_area_height = app.ui.calculate_input_area_height(f.area().width);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(input_area_height + 2), ])
.split(f.area());
let tool_prompt = app.ui.tool_prompt().cloned();
let use_prompt_layout =
tool_prompt.is_some() && !app.ui.in_edit_select_mode() && !app.ui.in_block_select_mode();
let (mut lines, mut span_metadata, message_spans) = if use_prompt_layout {
let layout_cfg = LayoutConfig {
width: Some(chunks[0].width as usize),
markdown_enabled: app.ui.markdown_enabled,
syntax_enabled: app.ui.syntax_enabled,
table_overflow_policy: TableOverflowPolicy::WrapCells,
user_display_name: Some(app.ui.user_display_name.clone()),
};
let layout = LayoutEngine::layout_messages(&app.ui.messages, &app.ui.theme, &layout_cfg);
(
layout.lines,
layout.span_metadata,
Some(layout.message_spans),
)
} else if !app.ui.in_edit_select_mode() && !app.ui.in_block_select_mode() {
let lines = app.get_prewrapped_lines_cached(chunks[0].width).clone();
let metadata = app
.get_prewrapped_span_metadata_cached(chunks[0].width)
.clone();
(lines, metadata, None)
} else if app.ui.in_edit_select_mode() {
let highlight = Style::default();
let layout = crate::utils::scroll::ScrollCalculator::build_layout_with_theme_and_selection_and_flags_and_width(
&app.ui.messages,
&app.ui.theme,
app.ui.selected_edit_message_index(),
highlight,
app.ui.markdown_enabled,
app.ui.syntax_enabled,
Some(chunks[0].width as usize),
Some(app.ui.user_display_name.clone()),
);
(layout.lines, layout.span_metadata, None)
} else if app.ui.in_block_select_mode() {
let mut lines = app.get_prewrapped_lines_cached(chunks[0].width).clone();
let metadata = app
.get_prewrapped_span_metadata_cached(chunks[0].width)
.clone();
if let Some(selected_idx) = app.ui.selected_block_index() {
apply_code_block_highlight(
&mut lines,
&metadata,
selected_idx,
&app.ui.theme,
chunks[0].width as usize,
);
}
(lines, metadata, None)
} else {
unreachable!()
};
if let (Some(prompt), Some(spans)) = (tool_prompt.as_ref(), message_spans.as_ref()) {
if let Some(insert_at) =
tool_prompt_insert_index(&app.ui.messages, prompt, spans, lines.len())
{
let info_style = app.ui.theme.app_message_style(AppMessageKind::Info);
let text_style = info_style
.text_style
.add_modifier(Modifier::DIM | Modifier::ITALIC);
let line = Line::from(Span::styled(
"(waiting for permission)".to_string(),
text_style,
));
lines.insert(insert_at, line);
span_metadata.insert(insert_at, vec![SpanKind::Text]);
}
}
let available_height = {
let conversation = app.conversation();
conversation.calculate_available_height(f.area().height, input_area_height)
};
let max_offset = app
.ui
.calculate_max_scroll_offset(available_height, chunks[0].width);
let scroll_offset = app.ui.scroll_offset.min(max_offset);
let title_text = build_main_title(app, chunks[0].width);
let block = Block::default().title(Span::styled(title_text, app.ui.theme.title_style));
let inner_area = block.inner(chunks[0]);
let suppress_links = suppress_link_rendering(app);
let mut messages_lines = lines.clone();
if suppress_links {
for (line, kinds) in messages_lines.iter_mut().zip(span_metadata.iter()) {
for (span, kind) in line.spans.iter_mut().zip(kinds.iter()) {
if matches!(kind, SpanKind::Link(_)) {
span.style = span.style.remove_modifier(
Modifier::UNDERLINED | Modifier::SLOW_BLINK | Modifier::RAPID_BLINK,
);
}
}
}
}
let messages_paragraph = Paragraph::new(messages_lines)
.style(Style::default().bg(app.ui.theme.background_color))
.block(block)
.scroll((scroll_offset, app.ui.horizontal_scroll_offset));
f.render_widget(messages_paragraph, chunks[0]);
if suppress_links {
set_render_state(OscRenderState::default());
} else {
let state = compute_render_state(
inner_area,
&lines,
&span_metadata,
scroll_offset as usize,
app.ui.horizontal_scroll_offset,
);
set_render_state(state);
}
const STREAMING_FRAMES: [&str; 8] = ["○", "◔", "◑", "◕", "●", "◕", "◑", "◔"];
const ROTATIONS_PER_SECOND: f32 = 0.5;
let indicator = if app.ui.is_activity_indicator_visible() {
let elapsed = app.ui.pulse_start.elapsed().as_secs_f32();
let total_frames =
(elapsed * ROTATIONS_PER_SECOND * STREAMING_FRAMES.len() as f32).floor() as usize;
STREAMING_FRAMES[total_frames % STREAMING_FRAMES.len()]
} else {
""
};
let indicator_label = activity_indicator_label(app);
let base_title: Cow<'_, str> = if app.ui.in_edit_select_mode() {
match app.ui.edit_select_target() {
Some(EditSelectTarget::Assistant) => {
Cow::Borrowed(
"Select assistant message (↑/↓ • Enter=Edit→Truncate • e=Edit in place • Del=Truncate • Esc=Cancel)",
)
}
_ => {
Cow::Borrowed(
"Select user message (↑/↓ • Enter=Edit→Truncate • e=Edit in place • Del=Truncate • c=Copy • Esc=Cancel)",
)
}
}
} else if app.ui.in_block_select_mode() {
Cow::Borrowed("Select code block (↑/↓ • c=Copy • s=Save • Esc=Cancel)")
} else if app.picker_session().is_some() {
match app.current_picker_mode() {
Some(crate::core::app::PickerMode::Model) => {
Cow::Borrowed("Select a model (Esc=cancel • Ctrl+C=quit)")
}
Some(crate::core::app::PickerMode::Provider) => {
Cow::Borrowed("Select a provider (Esc=cancel • Ctrl+C=quit)")
}
Some(crate::core::app::PickerMode::Theme) => {
Cow::Borrowed("Select a theme (Esc=cancel • Ctrl+C=quit)")
}
Some(crate::core::app::PickerMode::Character) => {
Cow::Borrowed("Select a character (Esc=cancel • Ctrl+C=quit)")
}
Some(crate::core::app::PickerMode::Persona) => {
Cow::Borrowed("Select a persona (Esc=cancel • Ctrl+C=quit)")
}
Some(crate::core::app::PickerMode::Preset) => {
Cow::Borrowed("Select a preset (Esc=cancel • Ctrl+C=quit)")
}
_ => Cow::Borrowed("Make a selection (Esc=cancel • Ctrl+C=quit)"),
}
} else if let Some(prompt) = app.ui.tool_prompt() {
let tool_prompt_frame = {
const TOOL_PROMPT_FRAMES: [&str; 4] = ["-", "\\", "|", "/"];
const TOOL_PROMPT_ROTATIONS_PER_SECOND: f32 = 1.5;
let elapsed = app.ui.pulse_start.elapsed().as_secs_f32();
let total_frames =
(elapsed * TOOL_PROMPT_ROTATIONS_PER_SECOND * TOOL_PROMPT_FRAMES.len() as f32)
.floor() as usize;
TOOL_PROMPT_FRAMES[total_frames % TOOL_PROMPT_FRAMES.len()]
};
let title = tool_prompt_title(prompt, chunks[1].width, tool_prompt_frame);
Cow::Owned(title)
} else if let Some(prompt) = app.ui.mcp_prompt_input() {
let label = prompt
.pending_args
.get(prompt.next_index)
.and_then(|arg| arg.title.as_deref())
.unwrap_or_else(|| {
prompt
.pending_args
.get(prompt.next_index)
.map(|arg| arg.name.as_str())
.unwrap_or("value")
});
Cow::Owned(format!(
"Prompt {} on {}: {} (Enter=Next • Esc=Cancel)",
prompt.prompt_name, prompt.server_name, label
))
} else if app.ui.file_prompt().is_some() {
Cow::Borrowed("Specify new filename (Esc=Cancel • Alt+Enter=Overwrite)")
} else if let Some(index) = app.ui.in_place_edit_index() {
let mut title = String::from("Edit in place: Enter=Apply • Esc=Cancel (no send)");
if app.ui.compose_mode {
if let Some(message) = app.ui.messages.get(index) {
if message.role == ROLE_ASSISTANT {
title.push_str(" • F4: toggle compose mode");
}
}
}
Cow::Owned(title)
} else if app.ui.is_editing_assistant_message() {
if app.ui.compose_mode {
Cow::Owned(String::from("Edit message (F4: toggle compose mode)"))
} else {
Cow::Borrowed("Edit message")
}
} else if app.ui.compose_mode {
Cow::Borrowed("Compose a message (F4=toggle compose mode, Enter=new line, Alt+Enter=send)")
} else if app.has_interruptible_activity() {
Cow::Borrowed("Type a new message (Esc=interrupt • Ctrl+R=retry)")
} else {
Cow::Borrowed("Type a new message (Alt+Enter=new line • Ctrl+C=quit • More: Type /help)")
};
let input_title: Line = if indicator.is_empty() {
Line::from(Span::styled(
base_title.to_string(),
app.ui.theme.input_title_style,
))
} else {
Line::from(vec![
Span::styled(base_title.to_string(), app.ui.theme.input_title_style),
Span::raw(" "), Span::styled(
indicator.to_string(),
app.ui.theme.streaming_indicator_style,
),
Span::styled(
indicator_label.to_string(),
app.ui.theme.streaming_indicator_style,
),
Span::raw(" "), ])
};
let status_bottom: Option<Line> = if let Some(status) = &app.ui.status {
let input_area_width = chunks[1].width;
let inner_width = input_area_width.saturating_sub(2) as usize; if inner_width < 8 {
None
} else {
let max_chars = inner_width.saturating_sub(2);
let text_raw = if status.chars().count() > max_chars {
let mut s = String::new();
for (i, ch) in status.chars().enumerate() {
if i + 1 >= max_chars {
break;
}
s.push(ch);
}
s.push('…');
s
} else {
status.clone()
};
let is_error = {
let s = text_raw.to_ascii_lowercase();
s.contains("error")
|| s.contains("exists")
|| s.contains("failed")
|| s.contains("denied")
|| s.contains("cancelled")
};
let base_style = if is_error {
app.ui.theme.error_text_style
} else {
app.ui.theme.system_text_style
};
let style = if let Some(set_at) = app.ui.status_set_at {
let ms = set_at.elapsed().as_millis() as u64;
if ms < 300 {
base_style.add_modifier(Modifier::BOLD)
} else if ms < 900 {
base_style.add_modifier(Modifier::DIM)
} else {
base_style
}
} else {
base_style
};
let status_len = text_raw.chars().count() + 2;
let dash_count = inner_width.saturating_sub(status_len);
let right_border = if dash_count > 0 {
"─".repeat(dash_count)
} else {
String::new()
};
let line = Line::from(vec![
Span::styled(" ", app.ui.theme.input_border_style),
Span::styled(text_raw.clone(), style),
Span::styled(" ", app.ui.theme.input_border_style),
Span::styled(right_border, app.ui.theme.input_border_style),
]);
Some(line)
}
} else {
None
};
let mut input_block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(app.ui.theme.background_color))
.border_style(app.ui.theme.input_border_style)
.title(input_title);
if let Some(bottom) = status_bottom {
input_block = input_block.title_bottom(bottom);
}
let area = chunks[1];
let inner = input_block.inner(area);
f.render_widget(input_block, area);
let mut text_area = inner;
let mut consumed_columns = 0;
if inner.width > 0 {
let indicator = if app.ui.is_input_focused() {
"›"
} else {
"·"
};
let indicator_style = app
.ui
.theme
.system_text_style
.patch(Style::default().bg(app.ui.theme.background_color));
let indicator_line = Line::from(Span::styled(indicator.to_string(), indicator_style));
let indicator_paragraph = Paragraph::new(indicator_line)
.style(Style::default().bg(app.ui.theme.background_color));
let indicator_area = Rect {
x: inner.x,
y: inner.y,
width: 1,
height: inner.height,
};
f.render_widget(indicator_paragraph, indicator_area);
consumed_columns += 1;
if inner.width > 1 {
let spacer_line = Line::from(Span::styled(" ".to_string(), indicator_style));
let spacer_paragraph = Paragraph::new(spacer_line)
.style(Style::default().bg(app.ui.theme.background_color));
let spacer_area = Rect {
x: inner.x.saturating_add(1),
y: inner.y,
width: 1,
height: inner.height,
};
f.render_widget(spacer_paragraph, spacer_area);
consumed_columns += 1;
}
}
if consumed_columns > 0 {
text_area.x = text_area.x.saturating_add(consumed_columns);
text_area.width = text_area.width.saturating_sub(consumed_columns);
}
let available_width = text_area.width.saturating_sub(1);
if available_width > 0 && text_area.height > 0 {
let config = WrapConfig::new(available_width as usize);
let wrapped_text = TextWrapper::wrap_text(app.ui.get_input_text(), &config);
let paragraph = Paragraph::new(wrapped_text)
.style(
app.ui
.theme
.input_text_style
.patch(Style::default().bg(app.ui.theme.background_color)),
)
.wrap(Wrap { trim: false })
.scroll((app.ui.input_scroll_offset, 0));
f.render_widget(paragraph, text_area);
if app.ui.is_input_active() && app.ui.is_input_focused() && app.picker_session().is_none() {
let (line, col) = TextWrapper::calculate_cursor_position_in_wrapped_text(
app.ui.get_input_text(),
app.ui.get_input_cursor_position(),
&config,
);
let visible_line = (line as u16).saturating_sub(app.ui.input_scroll_offset);
if visible_line < text_area.height {
let cursor_x = text_area.x.saturating_add(col as u16);
let cursor_y = text_area.y.saturating_add(visible_line);
f.set_cursor_position((cursor_x, cursor_y));
}
}
} else if text_area.width > 0 && text_area.height > 0 {
let blank = Paragraph::new("")
.style(Style::default().bg(app.ui.theme.background_color))
.wrap(Wrap { trim: false });
f.render_widget(blank, text_area);
}
if app.inspect_state().is_some() {
let theme = app.ui.theme.clone();
let inspect_title = app
.inspect_state()
.map(|state| state.title.clone())
.unwrap_or_else(|| "Inspect".to_string());
let area = centered_rect(80, 80, f.area());
f.render_widget(Clear, area);
let modal_bg = Block::default().style(Style::default().bg(theme.background_color));
f.render_widget(modal_bg, area);
let modal_block = Block::default()
.borders(Borders::ALL)
.border_style(theme.input_border_style)
.title(Span::styled(inspect_title, theme.title_style));
let content_area = modal_block.inner(area);
f.render_widget(modal_block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(2), Constraint::Length(2)])
.split(content_area);
let body_area = inset_rect(chunks[0], 1, 1);
let help_area = chunks[1];
if let Some(inspect_state) = app.inspect_state_mut() {
let wrap_width = body_area.width as usize;
let wrap_config = WrapConfig::new(wrap_width);
let wrapped_text = TextWrapper::wrap_text(&inspect_state.content, &wrap_config);
let total_lines =
TextWrapper::count_wrapped_lines(&inspect_state.content, &wrap_config);
let visible_height = body_area.height.max(1) as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
let max_scroll_u16 = max_scroll.min(u16::MAX as usize) as u16;
if inspect_state.scroll_offset > max_scroll_u16 {
inspect_state.scroll_offset = max_scroll_u16;
}
let paragraph = Paragraph::new(wrapped_text)
.style(
theme
.assistant_text_style
.patch(Style::default().bg(theme.background_color)),
)
.wrap(Wrap { trim: false })
.scroll((inspect_state.scroll_offset, 0));
f.render_widget(paragraph, body_area);
}
let inspect_state = app.inspect_state();
let inspect_mode = inspect_state.map(|state| state.mode);
let decoded = inspect_state.map(|state| state.decoded).unwrap_or(false);
let in_picker = app.picker_session().is_some();
let (line1, line2): (String, String) = match inspect_mode {
Some(InspectMode::ToolCalls { view, kind, .. }) => {
let toggle_label = match (kind, view) {
(
crate::core::app::ToolInspectKind::Result,
crate::core::app::ToolInspectView::Result,
) => Some("Tab=Show request"),
(
crate::core::app::ToolInspectKind::Result,
crate::core::app::ToolInspectView::Request,
) => Some("Tab=Show result"),
_ => None,
};
let toggle = toggle_label
.map(|label| format!(" • {}", label))
.unwrap_or_default();
(
format!(
"Esc=Close • D={} • C=Copy{} • ←/→=Prev/Next",
if decoded { "Raw" } else { "Decode" },
toggle
),
"↑/↓=Scroll • PgUp/PgDn=Faster • Home/End=Jump".to_string(),
)
}
_ if in_picker => (
"Esc=Back to picker • ↑/↓=Scroll • PgUp/PgDn=Faster".to_string(),
"Home/End=Jump".to_string(),
),
_ => (
"Esc=Close • ↑/↓=Scroll • PgUp/PgDn=Faster".to_string(),
"Home/End=Jump".to_string(),
),
};
let help_lines = vec![
Line::from(Span::styled(line1, theme.system_text_style)),
Line::from(Span::styled(line2, theme.system_text_style)),
];
let help = Paragraph::new(help_lines).style(theme.system_text_style);
f.render_widget(help, help_area);
} else if let Some(picker) = app.picker_state() {
let area = centered_rect(60, 60, f.area());
f.render_widget(Clear, area);
let modal_bg = Block::default().style(Style::default().bg(app.ui.theme.background_color));
f.render_widget(modal_bg, area);
let modal_block = Block::default()
.borders(Borders::ALL)
.border_style(app.ui.theme.input_border_style)
.title(Span::styled(&picker.title, app.ui.theme.title_style));
let content_area = modal_block.inner(area); f.render_widget(modal_block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), Constraint::Length(2), ])
.split(content_area);
let list_area = inset_rect(chunks[0], 1, 1);
let metadata_area = chunks[1];
let help_area = chunks[2];
let items: Vec<ListItem> = picker
.items
.iter()
.map(|it| {
ListItem::new(Line::from(Span::styled(
it.label.clone(),
app.ui.theme.assistant_text_style,
)))
})
.collect();
let list = List::new(items)
.style(Style::default().bg(app.ui.theme.background_color))
.highlight_style(
app.ui
.theme
.streaming_indicator_style
.add_modifier(Modifier::BOLD | Modifier::REVERSED),
)
.highlight_symbol("▶ ");
f.render_stateful_widget(list, list_area, &mut make_list_state(picker.selected));
let metadata_text = picker
.get_selected_metadata()
.unwrap_or("No metadata available");
let metadata = Paragraph::new(Span::styled(
metadata_text,
app.ui
.theme
.system_text_style
.add_modifier(ratatui::style::Modifier::DIM),
));
f.render_widget(metadata, metadata_area);
let help_text = generate_picker_help_text(app);
let help = Paragraph::new(help_text.as_str()).style(app.ui.theme.system_text_style);
f.render_widget(help, help_area);
}
}
fn suppress_link_rendering(app: &App) -> bool {
app.picker_state().is_some() || app.inspect_state().is_some()
}
fn activity_indicator_label(app: &App) -> &'static str {
if matches!(
app.ui.activity_kind(),
Some(crate::core::app::ActivityKind::McpOperation)
) {
" [MCP]"
} else {
""
}
}
fn tool_prompt_insert_index(
messages: &VecDeque<crate::core::message::Message>,
prompt: &ToolPrompt,
spans: &[crate::ui::layout::MessageLineSpan],
line_count: usize,
) -> Option<usize> {
if messages.len() != spans.len() {
return None;
}
let mut last_tool_call = None;
for (idx, message) in messages.iter().enumerate().rev() {
if message.role == ROLE_TOOL_CALL {
last_tool_call = Some(idx);
break;
}
}
let last_tool_call = last_tool_call?;
let mut first_in_batch = last_tool_call;
for idx in (0..=last_tool_call).rev() {
let message = &messages[idx];
if message.role == ROLE_TOOL_CALL {
first_in_batch = idx;
} else {
break;
}
}
let batch_offset = prompt.batch_index;
let target_idx = first_in_batch
.saturating_add(batch_offset)
.min(last_tool_call);
let span = spans.get(target_idx)?;
if span.len == 0 {
return Some(span.start.min(line_count));
}
let insert_at = span.start.saturating_add(span.len.saturating_sub(1));
Some(insert_at.min(line_count))
}
fn tool_prompt_title(prompt: &ToolPrompt, width: u16, frame: &str) -> String {
let available = width.saturating_sub(2) as usize;
let frame_suffix = if frame.is_empty() {
String::new()
} else {
format!(" {}", frame)
};
if let Some(display_name) = prompt.display_name.as_deref() {
let mut candidates = Vec::new();
candidates.push(format!(
"❓ {} (A=once • S=session • D/Esc=deny • B=block • Ctrl+O=inspect){}",
display_name, frame_suffix
));
candidates.push(format!(
"❓ {} (A=once • S=session • D=deny • B=block • Ctrl+O=inspect){}",
display_name, frame_suffix
));
candidates.push(format!(
"❓ {} (A/S/D/B • Ctrl+O){}",
display_name, frame_suffix
));
candidates.push(format!("❓ {} (A/S/D/B){}", display_name, frame_suffix));
candidates.push(format!("❓ {}{}", display_name, frame_suffix));
for candidate in candidates {
if UnicodeWidthStr::width(candidate.as_str()) <= available {
return candidate;
}
}
}
let tool = prompt.tool_name.as_str();
let server = if prompt.server_name.trim().is_empty() {
prompt.server_id.as_str()
} else {
prompt.server_name.as_str()
};
let mut candidates = Vec::new();
candidates.push(format!(
"❓ Allow {} on {}? (A=once • S=session • D/Esc=deny • B=block • Ctrl+O=inspect){}",
tool, server, frame_suffix
));
candidates.push(format!(
"❓ Allow {} on {}? (A=once • S=session • D=deny • B=block • Ctrl+O=inspect){}",
tool, server, frame_suffix
));
candidates.push(format!(
"❓ Allow {} on {}? (A/S/D/B • Ctrl+O){}",
tool, server, frame_suffix
));
candidates.push(format!(
"❓ {} on {}? (A/S/D/B • Ctrl+O){}",
tool, server, frame_suffix
));
candidates.push(format!("❓ {}? (A/S/D/B • Ctrl+O){}", tool, frame_suffix));
candidates.push(format!(
"❓ Tool permission (A/S/D/B • Ctrl+O){}",
frame_suffix
));
candidates.push("❓ Tool permission (A/S/D/B • Ctrl+O)".to_string());
candidates.push("❓ Tool permission".to_string());
for candidate in candidates {
if UnicodeWidthStr::width(candidate.as_str()) <= available {
return candidate;
}
}
truncate_to_width("❓ Tool permission", available)
}
fn truncate_to_width(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(text) <= max_width {
return text.to_string();
}
let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
let mut truncated = String::new();
for grapheme in graphemes {
let next = format!("{}{}", truncated, grapheme);
if UnicodeWidthStr::width(next.as_str()) > max_width.saturating_sub(1) {
truncated.push('…');
return truncated;
}
truncated.push_str(grapheme);
}
truncated
}
fn generate_picker_help_text(app: &App) -> String {
let selected_is_default = app
.picker_state()
.and_then(|picker| picker.get_selected_item())
.map(|item| item.label.ends_with('*'))
.unwrap_or(false);
let del_help = if selected_is_default {
" • Del=Remove default"
} else {
""
};
let search_filter = match app.current_picker_mode() {
Some(crate::core::app::PickerMode::Model) => app
.model_picker_state()
.map(|state| state.search_filter.as_str())
.unwrap_or(""),
Some(crate::core::app::PickerMode::Theme) => app
.theme_picker_state()
.map(|state| state.search_filter.as_str())
.unwrap_or(""),
Some(crate::core::app::PickerMode::Provider) => app
.provider_picker_state()
.map(|state| state.search_filter.as_str())
.unwrap_or(""),
Some(crate::core::app::PickerMode::Character) => app
.character_picker_state()
.map(|state| state.search_filter.as_str())
.unwrap_or(""),
Some(crate::core::app::PickerMode::Persona) => app
.persona_picker_state()
.map(|state| state.search_filter.as_str())
.unwrap_or(""),
Some(crate::core::app::PickerMode::Preset) => app
.preset_picker_state()
.map(|state| state.search_filter.as_str())
.unwrap_or(""),
_ => "",
};
let inspect_help = " • Ctrl+O=Inspect";
let first_line = if search_filter.is_empty() {
format!(
"↑/↓=Navigate • F6=Sort • Type=Filter{}{}",
inspect_help, del_help
)
} else {
format!(
"↑/↓=Navigate • Backspace=Clear • F6=Sort{}{}",
inspect_help, del_help
)
};
let show_persist = !(app.session.startup_env_only
&& app.current_picker_mode() == Some(crate::core::app::PickerMode::Model));
if show_persist {
format!("{}\nEnter=This session • Alt+Enter=As default", first_line)
} else {
format!("{}\nEnter=This session", first_line)
}
}
fn make_list_state(selected: usize) -> ratatui::widgets::ListState {
let mut state = ratatui::widgets::ListState::default();
state.select(Some(selected));
state
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
]
.as_ref(),
)
.split(r);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
]
.as_ref(),
)
.split(popup_layout[1]);
horizontal[1]
}
fn inset_rect(r: Rect, dx: u16, dy: u16) -> Rect {
let nx = r.x.saturating_add(dx);
let ny = r.y.saturating_add(dy);
let nw = r.width.saturating_sub(dx.saturating_mul(2));
let nh = r.height.saturating_sub(dy.saturating_mul(2));
Rect {
x: nx,
y: ny,
width: nw,
height: nh,
}
}
fn apply_code_block_highlight(
lines: &mut [ratatui::text::Line],
metadata: &[Vec<crate::ui::span::SpanKind>],
block_index: usize,
theme: &crate::ui::theme::Theme,
terminal_width: usize,
) {
use crate::utils::color::ColorDepth;
use ratatui::style::{Color, Modifier};
let depth = crate::utils::color::detect_color_depth();
let using_16_color = depth == ColorDepth::X16;
let mut highlight_style = theme.selection_highlight_style;
if using_16_color {
highlight_style = Style::default().add_modifier(Modifier::REVERSED);
}
for (line, line_meta) in lines.iter_mut().zip(metadata.iter()) {
let mut fallback_fg = theme
.assistant_text_style
.fg
.or(theme.user_text_style.fg)
.unwrap_or(Color::White);
let mut line_belongs_to_block = false;
for (span, kind) in line.spans.iter_mut().zip(line_meta.iter()) {
if let Some(meta) = kind.code_block_meta() {
if meta.block_index() == block_index {
line_belongs_to_block = true;
if using_16_color {
let base_fg = span.style.fg.unwrap_or(fallback_fg);
fallback_fg = base_fg;
span.style = Style::default().fg(base_fg);
}
span.style = span.style.patch(highlight_style);
}
}
}
if line_belongs_to_block && terminal_width > 0 {
let current_width = line.width();
if current_width < terminal_width {
let padding = " ".repeat(terminal_width - current_width);
line.spans.push(Span::styled(padding, highlight_style));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::app::{
App, CharacterPickerState, ModelPickerState, PickerData, PickerSession,
ProviderPickerState, ThemePickerState,
};
use crate::ui::picker::PickerState;
use crate::ui::theme::Theme;
fn create_test_app() -> App {
App::new_test_app(Theme::dark_default(), true, false)
}
fn set_model_picker(
app: &mut App,
search_filter: &str,
items: Vec<crate::ui::picker::PickerItem>,
selected: usize,
has_dates: bool,
) {
let picker_state = PickerState::new("Test".to_string(), items.clone(), selected);
app.picker.picker_session = Some(PickerSession {
state: picker_state,
data: PickerData::Model(ModelPickerState {
search_filter: search_filter.to_string(),
all_items: items,
before_model: None,
has_dates,
}),
});
}
fn set_theme_picker(
app: &mut App,
search_filter: &str,
items: Vec<crate::ui::picker::PickerItem>,
selected: usize,
) {
let picker_state = PickerState::new("Test".to_string(), items.clone(), selected);
app.picker.picker_session = Some(PickerSession {
state: picker_state,
data: PickerData::Theme(ThemePickerState {
search_filter: search_filter.to_string(),
all_items: items,
before_theme: None,
before_theme_id: None,
}),
});
}
fn set_provider_picker(
app: &mut App,
search_filter: &str,
items: Vec<crate::ui::picker::PickerItem>,
selected: usize,
) {
let picker_state = PickerState::new("Test".to_string(), items.clone(), selected);
app.picker.picker_session = Some(PickerSession {
state: picker_state,
data: PickerData::Provider(ProviderPickerState {
search_filter: search_filter.to_string(),
all_items: items,
before_provider: None,
}),
});
}
fn set_character_picker(
app: &mut App,
search_filter: &str,
items: Vec<crate::ui::picker::PickerItem>,
selected: usize,
) {
let picker_state = PickerState::new("Test".to_string(), items.clone(), selected);
app.picker.picker_session = Some(PickerSession {
state: picker_state,
data: PickerData::Character(CharacterPickerState {
search_filter: search_filter.to_string(),
all_items: items,
}),
});
}
fn find_segment<'a>(title: &'a str, prefix: &str) -> Option<&'a str> {
title
.split(" • ")
.find(|segment| segment.starts_with(prefix))
}
fn find_title_with<F>(app: &App, mut width: u16, predicate: F) -> Option<(u16, String)>
where
F: Fn(&str) -> bool,
{
while width > 0 {
width -= 1;
let title = build_main_title(app, width);
if predicate(&title) {
return Some((width, title));
}
}
None
}
#[test]
fn title_shows_no_model_selected_during_transition() {
let mut app = create_test_app();
app.session.provider_display_name = "Cerebras".to_string();
app.session.model = "foo-model".to_string();
app.picker.in_provider_model_transition = true;
let title = build_main_title(&app, 1000);
assert!(title.contains("(no model selected)"));
assert!(!title.contains("foo-model"));
assert!(!title.contains("Preset:"));
}
#[test]
fn title_shows_model_when_not_in_transition() {
let mut app = create_test_app();
app.session.provider_display_name = "Cerebras".to_string();
app.session.model = "foo-model".to_string();
app.picker.in_provider_model_transition = false;
let title = build_main_title(&app, 1000);
assert!(title.contains("foo-model"));
assert!(!title.contains("(no model selected)"));
assert!(!title.contains("Preset:"));
}
#[test]
fn activity_indicator_label_marks_mcp_operations() {
let mut app = create_test_app();
assert_eq!(activity_indicator_label(&app), "");
app.begin_mcp_operation();
assert_eq!(activity_indicator_label(&app), " [MCP]");
}
#[test]
fn test_generate_picker_help_text_model_no_filter_no_default() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "test-model".to_string(),
label: "Test Model".to_string(),
metadata: None,
inspect_metadata: None,
sort_key: None,
}];
set_model_picker(&mut app, "", items, 0, false);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("↑/↓=Navigate • F6=Sort • Type=Filter • Ctrl+O=Inspect"));
assert!(help_text.contains("Enter=This session • Alt+Enter=As default"));
assert!(!help_text.contains("Del=Remove default"));
}
#[test]
fn test_generate_picker_help_text_model_with_filter() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "test-model".to_string(),
label: "Test Model".to_string(),
metadata: None,
inspect_metadata: None,
sort_key: None,
}];
set_model_picker(&mut app, "gpt", items, 0, false);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("↑/↓=Navigate • Backspace=Clear • F6=Sort • Ctrl+O=Inspect"));
assert!(help_text.contains("Enter=This session • Alt+Enter=As default"));
assert!(!help_text.contains("Type=Filter"));
}
#[test]
fn test_generate_picker_help_text_with_default_selected() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "default-provider".to_string(),
label: "Default Provider*".to_string(), metadata: None,
inspect_metadata: None,
sort_key: None,
}];
set_provider_picker(&mut app, "", items, 0);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("Del=Remove default"));
assert!(help_text.contains(
"↑/↓=Navigate • F6=Sort • Type=Filter • Ctrl+O=Inspect • Del=Remove default"
));
assert!(help_text.contains("Enter=This session • Alt+Enter=As default"));
}
#[test]
fn test_generate_picker_help_text_model_with_default_selected() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "default-model".to_string(),
label: "Default Model*".to_string(),
metadata: None,
inspect_metadata: None,
sort_key: None,
}];
set_model_picker(&mut app, "", items, 0, false);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("Del=Remove default"));
assert!(help_text.contains(
"↑/↓=Navigate • F6=Sort • Type=Filter • Ctrl+O=Inspect • Del=Remove default"
));
assert!(help_text.contains("Enter=This session • Alt+Enter=As default"));
}
#[test]
fn test_generate_picker_help_text_theme_picker() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "dark".to_string(),
label: "Dark Theme".to_string(),
metadata: None,
inspect_metadata: None,
sort_key: None,
}];
set_theme_picker(&mut app, "", items, 0);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("↑/↓=Navigate • F6=Sort • Type=Filter • Ctrl+O=Inspect"));
assert!(help_text.contains("Enter=This session • Alt+Enter=As default"));
assert!(!help_text.contains("Del=Remove default"));
}
#[test]
fn test_generate_picker_help_text_theme_with_default_selected() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "dark".to_string(),
label: "Dark Theme*".to_string(),
metadata: None,
inspect_metadata: None,
sort_key: None,
}];
set_theme_picker(&mut app, "", items, 0);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("Del=Remove default"));
}
#[test]
fn test_generate_picker_help_text_no_picker() {
let app = create_test_app();
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("Enter=This session • Alt+Enter=As default"));
assert!(!help_text.contains("Del=Remove default"));
}
#[test]
fn test_generate_picker_help_text_character_picker() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "alice".to_string(),
label: "Alice".to_string(),
metadata: Some("A helpful assistant".to_string()),
inspect_metadata: Some("A helpful assistant".to_string()),
sort_key: Some("Alice".to_string()),
}];
set_character_picker(&mut app, "", items, 0);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("↑/↓=Navigate • F6=Sort • Type=Filter • Ctrl+O=Inspect"));
assert!(help_text.contains("Enter=This session • Alt+Enter=As default"));
assert!(!help_text.contains("Del=Remove default"));
}
#[test]
fn test_generate_picker_help_text_character_with_filter() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "alice".to_string(),
label: "Alice".to_string(),
metadata: Some("A helpful assistant".to_string()),
inspect_metadata: Some("A helpful assistant".to_string()),
sort_key: Some("Alice".to_string()),
}];
set_character_picker(&mut app, "ali", items, 0);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("↑/↓=Navigate • Backspace=Clear • F6=Sort • Ctrl+O=Inspect"));
assert!(!help_text.contains("Type=Filter"));
}
#[test]
fn test_generate_picker_help_text_character_with_default_selected() {
let mut app = create_test_app();
let items = vec![crate::ui::picker::PickerItem {
id: "alice".to_string(),
label: "Alice*".to_string(),
metadata: Some("A helpful assistant".to_string()),
inspect_metadata: Some("A helpful assistant".to_string()),
sort_key: Some("Alice".to_string()),
}];
set_character_picker(&mut app, "", items, 0);
let help_text = generate_picker_help_text(&app);
assert!(help_text.contains("Del=Remove default"));
}
#[test]
fn title_shows_character_name_when_active() {
use crate::character::card::{CharacterCard, CharacterData};
let mut app = create_test_app();
app.session.provider_display_name = "OpenAI".to_string();
app.session.model = "gpt-4".to_string();
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Alice".to_string(),
description: "A helpful assistant".to_string(),
personality: "Friendly".to_string(),
scenario: "Helping users".to_string(),
first_mes: "Hello!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
app.session.active_character = Some(card);
let title = build_main_title(&app, 1000);
assert!(title.contains("Character: Alice"));
assert!(title.contains("OpenAI"));
assert!(title.contains("gpt-4"));
assert!(!title.contains("Preset:"));
}
#[test]
fn title_does_not_show_character_when_none() {
let mut app = create_test_app();
app.session.provider_display_name = "OpenAI".to_string();
app.session.model = "gpt-4".to_string();
app.session.active_character = None;
let title = build_main_title(&app, 1000);
assert!(!title.contains("Character:"));
assert!(title.contains("OpenAI"));
assert!(title.contains("gpt-4"));
assert!(!title.contains("Preset:"));
}
#[test]
fn title_shows_active_preset_when_set() {
let mut app = create_test_app();
app.session.provider_display_name = "OpenAI".to_string();
app.session.model = "gpt-4".to_string();
app.preset_manager
.set_active_preset("short")
.expect("preset to activate");
let title = build_main_title(&app, 1000);
assert!(title.contains("Preset: short"));
assert!(title.contains("(gpt-4) • Preset: short"));
}
#[test]
fn title_places_preset_after_character_when_active() {
use crate::character::card::{CharacterCard, CharacterData};
let mut app = create_test_app();
app.session.provider_display_name = "OpenAI".to_string();
app.session.model = "gpt-4".to_string();
app.preset_manager
.set_active_preset("short")
.expect("preset to activate");
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Alice".to_string(),
description: "A helpful assistant".to_string(),
personality: "Friendly".to_string(),
scenario: "Helping users".to_string(),
first_mes: "Hello!".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
app.session.active_character = Some(card);
let title = build_main_title(&app, 1000);
assert!(title.contains("Character: Alice • Preset: short"));
}
#[test]
fn title_abbreviates_and_hides_fields_based_on_width() {
use crate::character::card::{CharacterCard, CharacterData};
let mut app = create_test_app();
app.session.provider_display_name = "OpenAI".to_string();
app.session.model = "gpt-4".to_string();
app.preset_manager
.set_active_preset("roleplay")
.expect("preset to activate");
let card = CharacterCard {
spec: "chara_card_v2".to_string(),
spec_version: "2.0".to_string(),
data: CharacterData {
name: "Jean-Luc Picard".to_string(),
description: "A starship captain".to_string(),
personality: "Decisive".to_string(),
scenario: "Commanding the Enterprise".to_string(),
first_mes: "Make it so.".to_string(),
mes_example: String::new(),
creator_notes: None,
system_prompt: None,
post_history_instructions: None,
alternate_greetings: None,
tags: None,
creator: None,
character_version: None,
},
};
app.session.active_character = Some(card);
let wide_title = build_main_title(&app, 1000);
assert!(matches!(
find_segment(&wide_title, "Character: "),
Some(segment) if segment.ends_with("Jean-Luc Picard")
));
assert!(matches!(
find_segment(&wide_title, "Preset: "),
Some(segment) if segment.ends_with("roleplay")
));
let (char_width, char_abbrev_title) = find_title_with(&app, 1000, |title| {
matches!(
find_segment(title, "Character: "),
Some(segment) if segment.contains('…') && !segment.ends_with("Picard")
)
})
.expect("character should abbreviate before hiding");
assert_eq!(
find_segment(&char_abbrev_title, "Preset: "),
Some("Preset: roleplay")
);
let (preset_width, preset_abbrev_title) = find_title_with(&app, char_width, |title| {
matches!(
find_segment(title, "Preset: "),
Some(segment) if segment.contains('…') && !segment.ends_with("roleplay")
)
})
.expect("preset should abbreviate after character");
assert!(find_segment(&preset_abbrev_title, "Character: ").is_some());
let (char_hidden_width, char_hidden_title) = find_title_with(&app, preset_width, |title| {
find_segment(title, "Character: ").is_none()
})
.expect("character should hide before preset");
assert!(find_segment(&char_hidden_title, "Preset: ").is_some());
let (_, preset_hidden_title) = find_title_with(&app, char_hidden_width, |title| {
find_segment(title, "Preset: ").is_none()
})
.expect("preset should hide after character");
assert!(find_segment(&preset_hidden_title, "Character: ").is_none());
assert!(find_segment(&preset_hidden_title, "Preset: ").is_none());
assert!(find_segment(&preset_hidden_title, "Logging: ").is_some());
}
}