use super::super::app::App;
use super::utils::format_token_count_raw;
use ratatui::{
Frame,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
struct SelConfig<'a> {
sel_from: usize,
sel_to: usize,
selection_style: &'a Style,
cursor_style: &'a Style,
cursor_local_byte: Option<usize>,
}
#[allow(clippy::too_many_arguments)]
fn spans_with_selection(
text: &str,
row_byte_start: usize,
row_byte_end: usize,
sel: &SelConfig<'_>,
prefix: Span<'static>,
) -> Line<'static> {
let has_sel = row_byte_end > sel.sel_from && row_byte_start < sel.sel_to;
let has_cursor = sel.cursor_local_byte.is_some();
if !has_sel && !has_cursor {
return Line::from(vec![prefix, Span::raw(text.to_string())]);
}
let mut spans: Vec<Span<'static>> = vec![prefix];
for (idx, ch) in text.char_indices() {
let global_start = row_byte_start + idx;
let ch_len = ch.len_utf8();
let global_end = global_start + ch_len;
let in_sel = global_start >= sel.sel_from && global_end <= sel.sel_to;
let is_cursor = sel.cursor_local_byte == Some(idx);
let span_text = if is_cursor && !in_sel {
Span::styled(ch.to_string(), *sel.cursor_style)
} else if in_sel {
Span::styled(ch.to_string(), *sel.selection_style)
} else if is_cursor && in_sel {
Span::styled(
ch.to_string(),
Style::default()
.fg(Color::White)
.bg(Color::Rgb(50, 80, 160)),
)
} else {
Span::raw(ch.to_string())
};
spans.push(span_text);
}
if sel.cursor_local_byte == Some(text.len()) {
let at_end_sel =
row_byte_start + text.len() >= sel.sel_from && row_byte_start + text.len() < sel.sel_to;
if at_end_sel {
spans.push(Span::styled(" ", *sel.selection_style));
} else {
spans.push(Span::styled(" ", *sel.cursor_style));
}
}
Line::from(spans)
}
pub(super) fn render_queue(f: &mut Frame, app: &App, area: Rect) -> u16 {
let Some(session) = &app.current_session else {
return 0;
};
let (msgs_count, last_msg) = {
let Ok(map) = app.queued_messages.lock() else {
return 0;
};
let Some(msgs) = map.get(&session.id) else {
return 0;
};
if msgs.is_empty() {
return 0;
}
(msgs.len(), msgs.last().cloned().unwrap_or_default())
};
let dim_style = Style::default().fg(Color::Rgb(100, 100, 100));
let flat = last_msg.replace('\n', " ");
let content_width = area.width.saturating_sub(2) as usize; let max_preview = content_width.saturating_sub(30);
let preview: String = if flat.chars().count() > max_preview {
let truncated: String = flat.chars().take(max_preview).collect();
format!("{}...", truncated)
} else {
flat
};
let count_label = if msgs_count > 1 {
format!("⏳ queued ({}): ", msgs_count)
} else {
"⏳ queued: ".to_string()
};
let line = Line::from(vec![
Span::styled(count_label, dim_style),
Span::styled(preview, dim_style.add_modifier(Modifier::ITALIC)),
Span::styled(
" (Up to edit)",
Style::default().fg(Color::Rgb(70, 70, 70)),
),
]);
let queue_height = 2u16;
let queue_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: queue_height,
};
let para = Paragraph::new(vec![line]).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::Rgb(80, 80, 80))),
);
f.render_widget(para, queue_area);
queue_height
}
pub(super) fn render_input(f: &mut Frame, app: &App, area: Rect) {
let input_content_width = area.width.saturating_sub(2) as usize; let mut input_lines: Vec<Line> = Vec::new();
let mut cursor_row: usize = 0;
let has_queue = app
.current_session
.as_ref()
.and_then(|s| {
app.queued_messages
.lock()
.ok()
.map(|q| q.contains_key(&s.id))
})
.unwrap_or(false);
let cursor_style = Style::default()
.fg(Color::Black)
.bg(Color::Rgb(120, 120, 120));
let selection_style = Style::default()
.bg(Color::Rgb(50, 80, 160))
.fg(Color::White);
let sel_from_to: Option<(usize, usize)> = if app.input_drag_selecting {
let a = app.input_drag_anchor.unwrap_or((0, 0));
let b = app.input_drag_current.unwrap_or(a);
let (start, end) = if (a.1, a.0) <= (b.1, b.0) {
(a, b)
} else {
(b, a)
};
let input_top = app.input_area_y;
let input_content_width = area.width.saturating_sub(2) as usize;
let content_left = app.input_area_x + 2;
if input_content_width == 0 || app.input_buffer.is_empty() {
None
} else {
let start_visual = start.1.saturating_sub(input_top + 1) as usize;
let end_visual = end.1.saturating_sub(input_top + 1) as usize;
let mut visual_rows: Vec<(usize, usize)> = Vec::new();
let buf = &app.input_buffer;
let blen = buf.len();
let mut ls = 0usize;
while ls <= blen {
let le = buf[ls..].find('\n').map(|i| ls + i).unwrap_or(blen);
let line = &buf[ls..le];
let lw = unicode_width::UnicodeWidthStr::width(line);
let rows = if lw == 0 {
1
} else {
lw.div_ceil(input_content_width)
};
for r in 0..rows {
let chunk_start = r * input_content_width;
let mut display_w = 0;
let mut char_start = 0;
let mut char_end = line.len();
let mut found_start = false;
for (idx, ch) in line.char_indices() {
let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if r > 0 && !found_start && display_w >= chunk_start {
char_start = idx;
found_start = true;
}
if display_w + ch_w > chunk_start + input_content_width {
char_end = idx;
break;
}
display_w += ch_w;
}
visual_rows.push((ls + char_start, ls + char_end));
}
if le == blen {
break;
}
ls = le + 1;
}
let map_vr_to_byte = |vr: usize, col: u16| -> usize {
if vr >= visual_rows.len() {
return blen;
}
let (rs, re) = visual_rows[vr];
let line_text = &buf[rs..re];
let target_disp = col.saturating_sub(content_left) as usize;
let mut w = 0;
for (idx, ch) in line_text.char_indices() {
if w >= target_disp {
return rs + idx;
}
w += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
}
re
};
let from = map_vr_to_byte(start_visual, start.0);
let to = map_vr_to_byte(end_visual, end.0);
Some((from.min(to), from.max(to)))
}
} else {
None
};
if app.input_buffer.is_empty() {
input_lines.push(Line::from(vec![
Span::styled("\u{276F} ", Style::default().fg(Color::Rgb(100, 100, 100))),
Span::styled(" ", cursor_style),
]));
} else {
let buf = &app.input_buffer;
let cursor_pos = app.cursor_position;
let is_queued = has_queue;
let mut line_start = 0usize;
let mut line_idx = 0usize;
let buf_len = buf.len();
while line_start <= buf_len {
let line_end = buf[line_start..]
.find('\n')
.map(|i| line_start + i)
.unwrap_or(buf_len);
let line = &buf[line_start..line_end];
let next_start = line_end + 1;
let is_last_line = line_end == buf_len;
let cursor_in_line = cursor_pos >= line_start && cursor_pos < line_end;
let cursor_at_end_of_last_line = cursor_pos >= buf_len && is_last_line;
let prefix = if line_idx == 0 {
if is_queued {
Span::styled("⏳", Style::default().fg(Color::Rgb(215, 100, 20)))
} else if buf.starts_with('!') {
Span::styled(
"$ ",
Style::default()
.fg(Color::Rgb(215, 100, 20))
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled("\u{276F} ", Style::default().fg(Color::Rgb(100, 100, 100)))
}
} else {
Span::raw(" ")
};
let lw = unicode_width::UnicodeWidthStr::width(line);
let num_visual_rows = if lw == 0 {
1
} else {
lw.div_ceil(input_content_width)
};
let (sel_from, sel_to) = sel_from_to.unwrap_or((usize::MAX, usize::MAX));
for vr in 0..num_visual_rows {
let chunk_start_display = vr * input_content_width;
let chunk_end_display = chunk_start_display + input_content_width;
let mut disp_w = 0;
let mut char_start = 0;
let mut char_end = line.len();
for (idx, ch) in line.char_indices() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if vr == 0 && disp_w + cw > chunk_end_display {
char_end = idx;
break;
}
if vr > 0 {
if disp_w < chunk_start_display {
char_start = idx;
}
if disp_w + cw > chunk_end_display {
char_end = idx;
break;
}
}
disp_w += cw;
}
let chunk_text = &line[char_start..char_end];
let global_chunk_start = line_start + char_start;
let global_chunk_end = line_start + char_end;
let local_cursor = if cursor_in_line {
let c = cursor_pos.saturating_sub(line_start);
if c >= char_start && c <= char_end {
Some(c - char_start)
} else if c == line.len() && vr == num_visual_rows - 1 {
Some(chunk_text.len())
} else {
None
}
} else if cursor_at_end_of_last_line && is_last_line && vr == num_visual_rows - 1 {
Some(chunk_text.len())
} else {
None
};
let line_sel = SelConfig {
sel_from,
sel_to,
selection_style: &selection_style,
cursor_style: &cursor_style,
cursor_local_byte: local_cursor,
};
let padded_line = spans_with_selection(
chunk_text,
global_chunk_start,
global_chunk_end,
&line_sel,
if vr == 0 {
prefix.clone()
} else {
Span::raw(" ")
},
);
input_lines.push(padded_line);
if local_cursor.is_some() {
cursor_row = input_lines.len() - 1;
}
}
if is_last_line {
break;
}
line_start = next_start;
line_idx += 1;
}
if cursor_pos >= buf_len && buf.ends_with('\n') {
input_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(" ", cursor_style),
]));
cursor_row = input_lines.len() - 1;
}
}
let border_style = if app.input_buffer.starts_with('!') {
Style::default().fg(Color::Rgb(215, 100, 20)) } else {
Style::default().fg(Color::Rgb(120, 120, 120))
};
let context_title = if let Some(input_tok) = app.last_input_tokens {
let pct = app.context_usage_percent();
let context_color = if pct > 80.0 {
Color::Red
} else if pct > 60.0 {
Color::Rgb(215, 100, 20)
} else {
Color::Cyan
};
let ctx_label = format_token_count_raw(input_tok as i32);
let max_label = format_token_count_raw(app.context_max_tokens as i32);
let context_label = format!(" ctx: {}/{} ({:.0}%) ", ctx_label, max_label, pct);
Line::from(Span::styled(
context_label,
Style::default()
.fg(context_color)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Right)
} else {
Line::from(Span::styled(
" Context: – ",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Right)
};
let attach_title = if !app.attachments.is_empty() {
let spans: Vec<Span> = app
.attachments
.iter()
.enumerate()
.flat_map(|(i, att)| {
let focused = app.focused_attachment == Some(i);
let kind = if att.is_video { "Video" } else { "Image" };
let label = format!("{} #{}", kind, i + 1);
let style = if focused {
Style::default()
.fg(Color::Black)
.bg(Color::Rgb(60, 185, 185))
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Rgb(60, 185, 185))
.add_modifier(Modifier::BOLD)
};
let mut result = vec![Span::styled(label, style)];
if i + 1 < app.attachments.len() {
result.push(Span::styled(
" | ",
Style::default().fg(Color::Rgb(60, 185, 185)),
));
}
result
})
.collect();
let mut all_spans = vec![Span::styled(
" [",
Style::default()
.fg(Color::Rgb(60, 185, 185))
.add_modifier(Modifier::BOLD),
)];
all_spans.extend(spans);
all_spans.push(Span::styled(
"] ",
Style::default()
.fg(Color::Rgb(60, 185, 185))
.add_modifier(Modifier::BOLD),
));
Line::from(all_spans).alignment(Alignment::Right)
} else {
Line::from("")
};
let mut block = Block::default()
.borders(Borders::TOP | Borders::BOTTOM)
.title_bottom(context_title)
.border_style(border_style);
if !app.attachments.is_empty() {
block = block.title(attach_title);
}
let inner_rows = area.height.saturating_sub(2) as usize;
let total_rows = input_lines.len();
let scroll_y: u16 = if inner_rows == 0 || total_rows <= inner_rows {
0
} else if cursor_row >= inner_rows {
(cursor_row + 1 - inner_rows) as u16
} else {
0
};
let input = Paragraph::new(input_lines)
.style(Style::default().fg(Color::Reset))
.scroll((scroll_y, 0))
.block(block);
f.render_widget(input, area);
}
pub(crate) fn dropdown_dimensions(
name_col_chars: u16,
description_char_counts: &[usize],
input_area_width: u16,
pad_x: u16,
) -> (u16, u16, usize) {
let max_dropdown_width = input_area_width.saturating_sub(1).max(1);
let chrome = 2 + 2 * pad_x; let row_overhead: u16 = 2 + name_col_chars + 1 + 1;
let max_content_width = description_char_counts
.iter()
.map(|&desc_len| row_overhead.saturating_add(desc_len as u16))
.max()
.unwrap_or(40);
let width = (max_content_width.saturating_add(chrome))
.max(40)
.min(max_dropdown_width);
let inner_width = width.saturating_sub(chrome);
let desc_budget = inner_width.saturating_sub(row_overhead) as usize;
(width, inner_width, desc_budget)
}
pub(crate) fn truncate_to_chars(s: &str, max_chars: usize) -> std::borrow::Cow<'_, str> {
if max_chars == 0 {
return std::borrow::Cow::Borrowed("");
}
if s.chars().count() <= max_chars {
return std::borrow::Cow::Borrowed(s);
}
let keep = max_chars.saturating_sub(1);
let mut out: String = s.chars().take(keep).collect();
out.push('…');
std::borrow::Cow::Owned(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DropdownFit {
pub height: u16,
pub visible_items: u16,
pub scroll_offset: u16,
}
pub(crate) fn fit_dropdown(
total_items: u16,
selected: u16,
rows_above_input: u16,
chrome: u16,
) -> Option<DropdownFit> {
if total_items == 0 || rows_above_input < chrome + 1 {
return None;
}
let desired = total_items.saturating_add(chrome);
let height = desired.min(rows_above_input);
let visible_items = height.saturating_sub(chrome);
let scroll_offset = if selected < visible_items {
0
} else {
selected.saturating_sub(visible_items - 1)
};
let scroll_offset = scroll_offset.min(total_items.saturating_sub(visible_items));
Some(DropdownFit {
height,
visible_items,
scroll_offset,
})
}
pub(super) fn render_slash_autocomplete(f: &mut Frame, app: &App, input_area: Rect) {
let count = app.slash_filtered.len() as u16;
if count == 0 {
return;
}
let pad_x: u16 = 1;
let pad_y: u16 = 1;
let chrome = 2 + pad_y * 2;
let selected = app.slash_selected_index as u16;
let Some(fit) = fit_dropdown(count, selected, input_area.y, chrome) else {
return;
};
let visible_items_count = fit.visible_items as usize;
let scroll_offset = fit.scroll_offset as usize;
let visible_end = (scroll_offset + visible_items_count).min(app.slash_filtered.len());
let visible_slice = &app.slash_filtered[scroll_offset..visible_end];
let mut name_lengths: Vec<usize> = Vec::with_capacity(visible_slice.len());
let mut desc_lengths: Vec<usize> = Vec::with_capacity(visible_slice.len());
for &idx in visible_slice {
name_lengths.push(app.slash_command_name(idx).unwrap_or("").chars().count());
desc_lengths.push(
app.slash_command_description(idx)
.unwrap_or("")
.chars()
.count(),
);
}
let name_col_chars = name_lengths.iter().copied().max().unwrap_or(10).max(10) as u16;
let (width, _inner_width, desc_budget) =
dropdown_dimensions(name_col_chars, &desc_lengths, input_area.width, pad_x);
let dropdown_area = Rect {
x: input_area.x + 1,
y: input_area.y.saturating_sub(fit.height),
width,
height: fit.height,
};
let lines: Vec<Line> = visible_slice
.iter()
.enumerate()
.map(|(i, &cmd_idx)| {
let name = app.slash_command_name(cmd_idx).unwrap_or("???");
let raw_desc = app.slash_command_description(cmd_idx).unwrap_or("");
let desc = truncate_to_chars(raw_desc, desc_budget);
let is_selected = (scroll_offset + i) == app.slash_selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Gray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Reset)
};
let desc_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Gray)
} else {
Style::default().fg(Color::DarkGray)
};
let name_col = name_col_chars as usize;
Line::from(vec![
Span::styled(format!(" {:<width$}", name, width = name_col), style),
Span::styled(format!(" {} ", desc.as_ref()), desc_style),
])
})
.collect();
let mut padded_lines = Vec::with_capacity(lines.len() + 2);
padded_lines.push(Line::from(""));
padded_lines.extend(lines);
padded_lines.push(Line::from(""));
f.render_widget(Clear, dropdown_area);
let dropdown = Paragraph::new(padded_lines).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(120, 120, 120))),
);
f.render_widget(dropdown, dropdown_area);
}
pub(super) fn render_emoji_picker(f: &mut Frame, app: &App, input_area: Rect) {
let count = app.emoji_filtered.len() as u16;
if count == 0 {
return;
}
let chrome: u16 = 4; let Some(fit) = fit_dropdown(count, app.emoji_selected_index as u16, input_area.y, chrome)
else {
return;
};
let visible_items_count = fit.visible_items as usize;
let scroll_offset = fit.scroll_offset as usize;
let visible_end = (scroll_offset + visible_items_count).min(app.emoji_filtered.len());
let visible_slice = &app.emoji_filtered[scroll_offset..visible_end];
let width = 36u16.min(input_area.width);
let dropdown_area = Rect {
x: input_area.x + 1,
y: input_area.y.saturating_sub(fit.height),
width,
height: fit.height,
};
let lines: Vec<Line> = visible_slice
.iter()
.enumerate()
.map(|(i, &(emoji, shortcode))| {
let is_selected = (scroll_offset + i) == app.emoji_selected_index;
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Gray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Reset)
};
let sc_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Gray)
} else {
Style::default().fg(Color::DarkGray)
};
Line::from(vec![
Span::styled(format!(" {} ", emoji), style),
Span::styled(format!(":{}: ", shortcode), sc_style),
])
})
.collect();
let mut padded = Vec::with_capacity(lines.len() + 2);
padded.push(Line::from(""));
padded.extend(lines);
padded.push(Line::from(""));
f.render_widget(Clear, dropdown_area);
let dropdown = Paragraph::new(padded).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(120, 120, 120))),
);
f.render_widget(dropdown, dropdown_area);
}
pub(super) fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
if area.height == 0 || area.width == 0 {
return;
}
let orange = Color::Rgb(215, 100, 20);
let session_name = app
.current_session
.as_ref()
.and_then(|s| s.title.as_deref())
.unwrap_or("Chat")
.to_string();
let (provider_str, model_str) = if let Some(ref session) = app.current_session {
let prov = app.agent_service.provider_name_for_session(session.id);
let model = app.agent_service.provider_model_for_session(session.id);
let prefix = format!("{}/", prov);
let stripped = model.strip_prefix(&prefix).unwrap_or(&model);
(
prov,
crate::tui::provider_selector::model_display_label(stripped).to_string(),
)
} else {
(
app.agent_service.provider_name(),
crate::tui::provider_selector::model_display_label(
app.agent_service.provider_model().as_str(),
)
.to_string(),
)
};
let raw_dir = app.working_directory.to_string_lossy();
let home_dir = dirs::home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_default();
let short_dir = if !home_dir.is_empty() && raw_dir.starts_with(&home_dir) {
format!("~{}", &raw_dir[home_dir.len()..])
} else {
raw_dir.to_string()
};
let display_dir = if short_dir.len() > 40 {
let mut start = short_dir.len().saturating_sub(37);
while start > 0 && !short_dir.is_char_boundary(start) {
start -= 1;
}
format!("...{}", &short_dir[start..])
} else {
short_dir
};
let session_text = format!(" {}", session_name);
let provider_model_dir_text =
format!(" · {} / {} · {}", provider_str, model_str, display_dir);
let sep_text = " · ";
let (policy_text, policy_color) = if app.approval_auto_always {
("⚡ yolo", Color::Red)
} else if app.approval_auto_session {
("⚡ auto (session)", orange)
} else {
("🔒 approve", Color::DarkGray)
};
let mut spans = vec![
Span::styled(
session_text,
Style::default().fg(orange).add_modifier(Modifier::BOLD),
),
Span::styled(
provider_model_dir_text,
Style::default().fg(Color::Rgb(90, 110, 150)),
),
];
let live_tps = if app.is_processing && app.streaming_output_tokens > 0 {
let active = app.current_streaming_active_secs();
if active > 0.0 {
Some(app.streaming_output_tokens as f64 / active)
} else {
None
}
} else {
None
};
let display_tps = live_tps.or(app.last_tps());
if let Some(tps) = display_tps {
spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("{:.0} tok/s", tps),
Style::default().fg(Color::Rgb(80, 200, 120)),
));
}
spans.push(Span::styled(sep_text, Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(policy_text, Style::default().fg(policy_color)));
if app.pane_manager.is_split() {
let pane_count = app.pane_manager.pane_count();
let focused_idx = app
.pane_manager
.pane_ids_in_order()
.iter()
.position(|id| *id == app.pane_manager.focused)
.map(|i| i + 1)
.unwrap_or(1);
spans.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
spans.push(Span::styled(
format!("[{}/{}]", focused_idx, pane_count),
Style::default().fg(Color::Rgb(80, 200, 120)),
));
}
let line = Line::from(spans);
let para = Paragraph::new(line).alignment(Alignment::Left);
f.render_widget(para, area);
}