use super::app::{AppState, NotificationKind};
use oxi_tui::theme::Theme;
use oxi_tui::truncate_to_width;
use oxi_tui::widgets::{chat::ChatView, footer::Footer, input::Input};
use ratatui::{
layout::{Constraint, Direction, Layout, Margin, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn draw(f: &mut Frame, state: &mut AppState, theme: &Theme) {
let size = f.area();
if state.overlay.is_some() || state.overlay_state.is_some() {
render_overlay(f, size, state, theme);
return;
}
let inner = size.inner(Margin::new(1, 0));
let queue_count = state.steering_messages_snapshot.len();
let is_active = state.queue_panel_visible && queue_count > 0;
let queue_lines = if queue_count > 0 {
if is_active {
(queue_count.min(5) as u16 + 1).min(6) } else {
queue_count.min(3) as u16
}
} else {
0
};
let content_width = inner.width.saturating_sub(2); let input_height = state.input.required_height(content_width, INPUT_MAX_HEIGHT);
let input_area_height = 1 + queue_lines + input_height;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), Constraint::Length(input_area_height), Constraint::Length(3), ])
.split(inner);
f.render_stateful_widget(ChatView::new(theme), chunks[0], &mut state.chat);
render_input_area(f, chunks[1], state, theme);
if state.slash_completion_active {
render_slash_popup_overlay(f, chunks[1], state, theme);
}
if state.file_completion_active {
render_file_completion_popup(f, chunks[1], state, theme);
}
state.footer_state.data.is_busy = state.is_agent_busy;
f.render_stateful_widget(Footer::new(theme), chunks[2], &mut state.footer_state);
render_notifications(f, size, state, theme);
}
const INPUT_MAX_HEIGHT: u16 = 8;
fn render_input_area(f: &mut Frame, area: Rect, state: &mut AppState, theme: &Theme) {
let queue_count = state.steering_messages_snapshot.len();
let is_active = state.queue_panel_visible && queue_count > 0;
let queue_lines = if queue_count > 0 {
if is_active {
(queue_count.min(5) as u16 + 1).min(6)
} else {
queue_count.min(3) as u16
}
} else {
0
};
let status_row = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
render_status_line(f, status_row, state, theme);
if is_active {
render_queue_active(f, area, state, theme);
} else {
render_queue_compact(f, area, state, theme);
}
let input_y = area.y + 1 + queue_lines;
let input_height = area.height.saturating_sub(1 + queue_lines);
let input_row = Rect {
x: area.x,
y: input_y,
width: area.width,
height: input_height,
};
state.input.set_placeholder(None);
f.render_stateful_widget(Input::new(theme), input_row, &mut state.input);
}
fn render_queue_compact(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) {
let queue_count = state.steering_messages_snapshot.len().min(3);
let hint_text = " Ctrl+Q ";
let hint_width = hint_text.len() as u16;
for (i, msg) in state
.steering_messages_snapshot
.iter()
.take(queue_count)
.enumerate()
{
let row = Rect {
x: area.x,
y: area.y + 1 + i as u16,
width: area.width,
height: 1,
};
let badge = if i == 0 { "next" } else { "" };
let num = format!("{}. ", i + 1);
let prefix_w: u16 = (num.len() + badge.len()) as u16;
let max_chars = area.width.saturating_sub(prefix_w + 3) as usize;
let truncated: String = msg.chars().take(max_chars).collect();
let ellipsis = if msg.chars().count() > max_chars {
"…"
} else {
""
};
let mut spans: Vec<Span<'_>> = Vec::new();
spans.push(Span::styled(num, Style::default().fg(theme.colors.muted)));
if i == 0 {
spans.push(Span::styled(
"next ",
Style::default()
.fg(theme.colors.accent)
.add_modifier(Modifier::ITALIC),
));
}
let msg_style = if i == 0 {
Style::default().fg(theme.colors.foreground)
} else {
Style::default().fg(theme.colors.muted)
};
spans.push(Span::styled(
format!("{}{}", truncated, ellipsis),
msg_style,
));
f.render_widget(Paragraph::new(Line::from(spans)), row);
if i == 0 {
f.render_widget(
Paragraph::new(Span::styled(
hint_text,
Style::default()
.fg(theme.colors.muted)
.add_modifier(Modifier::ITALIC),
)),
Rect {
x: area.x + area.width.saturating_sub(hint_width),
y: row.y,
width: hint_width,
height: 1,
},
);
}
}
}
fn render_queue_active(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) {
let queue_count = state.steering_messages_snapshot.len();
let max_items = queue_count.min(5);
let selected = state.queue_panel_selected;
for (i, msg) in state
.steering_messages_snapshot
.iter()
.take(max_items)
.enumerate()
{
let row = Rect {
x: area.x,
y: area.y + 1 + i as u16,
width: area.width,
height: 1,
};
let is_sel = i == selected;
let pointer = if is_sel { "› " } else { " " };
let badge = if i == 0 { "next " } else { "" };
let num = format!("{}. ", i + 1);
let prefix_w: u16 = (pointer.len() + num.len() + badge.len()) as u16;
let max_chars = area.width.saturating_sub(prefix_w + 2) as usize;
let truncated: String = msg.chars().take(max_chars).collect();
let ellipsis = if msg.chars().count() > max_chars {
"…"
} else {
""
};
let mut spans: Vec<Span<'_>> = Vec::new();
spans.push(Span::styled(
pointer,
if is_sel {
Style::default()
.fg(theme.colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
},
));
spans.push(Span::styled(
num,
if is_sel {
Style::default()
.fg(theme.colors.background)
.bg(theme.colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.colors.muted)
},
));
if i == 0 {
spans.push(Span::styled(
badge,
if is_sel {
Style::default()
.fg(theme.colors.background)
.bg(theme.colors.accent)
.add_modifier(Modifier::ITALIC)
} else {
Style::default()
.fg(theme.colors.accent)
.add_modifier(Modifier::ITALIC)
},
));
}
let msg_style = if is_sel {
Style::default()
.fg(theme.colors.background)
.bg(theme.colors.accent)
.add_modifier(Modifier::BOLD)
} else if i == 0 {
Style::default().fg(theme.colors.foreground)
} else {
Style::default().fg(theme.colors.muted)
};
spans.push(Span::styled(
format!("{}{}", truncated, ellipsis),
msg_style,
));
f.render_widget(Paragraph::new(Line::from(spans)), row);
}
let hint_y = area.y + 1 + max_items as u16;
if hint_y < area.y + area.height {
let row = Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
};
let hint = " ↑↓ nav · d del · e edit · Esc close";
f.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(theme.colors.muted))),
row,
);
}
}
fn render_status_line(f: &mut Frame, row: Rect, state: &AppState, theme: &Theme) {
let queue_count = state.steering_messages_snapshot.len();
if state.is_agent_busy {
let sp = ["\u{25D0}", "\u{25D3}", "\u{25D1}", "\u{25D2}"];
let ch = sp[state.spinner_frame % sp.len()];
let label = if queue_count > 0 {
format!(" {} Working ⏳{} queued", ch, queue_count)
} else {
format!(" {} Working", ch)
};
let label_len: u16 = label
.chars()
.map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(1) as u16)
.sum();
let dash_count = row.width.saturating_sub(label_len + 2) as usize;
let line = format!("── {} {}", label, "─".repeat(dash_count));
f.render_widget(
Paragraph::new(Span::styled(line, Style::default().fg(theme.colors.accent))),
row,
);
} else {
let label = if queue_count > 0 {
format!(" ○ Idle ⏳{} queued", queue_count)
} else {
" ○ Idle".to_string()
};
let label_len: u16 = label
.chars()
.map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(1) as u16)
.sum();
let dash_count = row.width.saturating_sub(label_len + 2) as usize;
let line = format!("── {} {}", label, "─".repeat(dash_count));
f.render_widget(
Paragraph::new(Span::styled(line, Style::default().fg(theme.colors.muted))),
row,
);
}
}
fn render_slash_popup_overlay(f: &mut Frame, input_area: Rect, state: &AppState, theme: &Theme) {
if state.slash_completions.is_empty() {
return;
}
let selected = state.slash_completion_index;
let total = state.slash_completions.len();
let max_show = 8usize.min(total);
let window_start = if selected >= max_show {
selected - max_show + 1
} else {
0
};
let popup_width = input_area.width;
let popup_height = max_show as u16 + 2;
let popup_x = input_area.x;
let popup_y = input_area.y.saturating_sub(popup_height);
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
f.render_widget(Clear, popup_area);
let visible: Vec<_> = state
.slash_completions
.iter()
.enumerate()
.skip(window_start)
.take(max_show)
.collect();
let name_width = state
.slash_completions
.iter()
.map(|c| c.name.chars().count())
.max()
.unwrap_or(10)
.max(10);
let list_items: Vec<ListItem> = visible
.iter()
.map(|(i, comp)| {
let is_selected = *i == selected;
let pointer = if is_selected { "->" } else { " " };
let name_padded = format!("{:<width$}", comp.name, width = name_width);
let desc_space = (popup_width as usize).saturating_sub(name_width + 8);
let desc: String = comp.description.chars().take(desc_space).collect();
if is_selected {
ListItem::new(Line::from(vec![
Span::styled(
format!(" {} ", pointer),
Style::default().fg(theme.colors.accent),
),
Span::styled(
format!(" {} ", name_padded),
Style::default()
.fg(theme.colors.background)
.bg(theme.colors.primary)
.bold(),
),
Span::styled(desc, Style::default().fg(theme.colors.muted)),
]))
} else {
ListItem::new(Line::from(vec![
Span::styled(format!(" {} ", pointer), Style::default()),
Span::styled(
format!(" {} ", name_padded),
Style::default().fg(theme.colors.foreground),
),
Span::styled(desc, Style::default().fg(theme.colors.muted)),
]))
}
})
.collect();
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(theme.colors.border));
let popup_inner = block.inner(popup_area);
f.render_widget(block, popup_area);
f.render_widget(List::new(list_items), popup_inner);
let page = window_start / max_show + 1;
let total_pages = total.div_ceil(max_show);
if total_pages > 1 {
let indicator = format!("({}/{})", page, total_pages);
let indicator_area = Rect {
x: popup_area.x
+ popup_area
.width
.saturating_sub(indicator.chars().count() as u16 + 2),
y: popup_area.y + popup_area.height.saturating_sub(1),
width: indicator.chars().count() as u16 + 2,
height: 1,
};
f.render_widget(
Paragraph::new(Span::styled(
indicator,
Style::default().fg(theme.colors.muted),
)),
indicator_area,
);
}
}
fn render_file_completion_popup(f: &mut Frame, input_area: Rect, state: &AppState, theme: &Theme) {
if state.file_completions.is_empty() {
return;
}
let selected = state.file_completion_index;
let total = state.file_completions.len();
let max_show = 8usize.min(total);
let window_start = if selected >= max_show {
selected - max_show + 1
} else {
0
};
let popup_width = input_area.width;
let popup_height = max_show as u16 + 2; let popup_x = input_area.x;
let popup_y = input_area.y.saturating_sub(popup_height);
let popup_area = Rect {
x: popup_x,
y: popup_y,
width: popup_width,
height: popup_height,
};
f.render_widget(Clear, popup_area);
let visible: Vec<_> = state
.file_completions
.iter()
.enumerate()
.skip(window_start)
.take(max_show)
.collect();
let styles = theme.to_styles();
let bg = theme.colors.background;
let items: Vec<Line> = visible
.iter()
.map(|(idx, item)| {
let is_selected = *idx == selected;
let style = if is_selected {
styles.primary
} else {
styles.muted
};
let label = if let Some(desc) = &item.description {
format!(" {} {}", item.label, desc)
} else {
format!(" {}", item.label)
};
Line::from(Span::styled(label, style))
})
.collect();
let block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(styles.muted)
.style(ratatui::style::Style::default().bg(bg));
let list = ratatui::widgets::List::new(items).block(block);
f.render_widget(list, popup_area);
}
fn render_overlay(f: &mut Frame, area: Rect, state: &mut AppState, theme: &Theme) {
if let Some(ref mut overlay) = state.overlay_state {
overlay.render(f, area, theme);
}
}
const NOTIF_MIN_WIDTH: u16 = 36;
const NOTIF_MAX_WIDTH: u16 = 60;
const NOTIF_PADDING: u16 = 2;
const NOTIF_GAP: u16 = 1;
const NOTIF_RIGHT_MARGIN: u16 = 2;
const NOTIF_BOTTOM_OFFSET: u16 = 5;
fn split_line_by_width(text: &str, max_width: usize) -> (String, String) {
if max_width == 0 {
return (String::new(), text.to_string());
}
let mut width = 0usize;
let mut end = 0usize;
for (idx, ch) in text.char_indices() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw > max_width {
break;
}
width += cw;
end = idx + ch.len_utf8();
}
let line = text[..end].to_string();
let rest = text[end..].trim_start().to_string();
(line, rest)
}
fn render_notifications(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) {
if state.notifications.is_empty() {
return;
}
let available_width = area.width.saturating_sub(NOTIF_RIGHT_MARGIN);
if area.height < 4 || available_width < 8 {
return;
}
let visible: Vec<_> = state.notifications.iter().rev().take(3).collect();
let max_inner_width = NOTIF_MAX_WIDTH.min(available_width.saturating_sub(1));
let min_inner_width = NOTIF_MIN_WIDTH.min(max_inner_width);
if max_inner_width == 0 {
return;
}
let mut y_offset = 0u16;
let base_y = area.y + area.height.saturating_sub(NOTIF_BOTTOM_OFFSET);
let text_style = Style::default().fg(theme.colors.foreground);
for notif in &visible {
let (icon, fg_color, bg_color, border_color) = match notif.kind {
NotificationKind::Success => (
"\u{2713}", theme.colors.success,
theme.colors.tool_success_bg,
theme.colors.success,
),
NotificationKind::Warning => (
"\u{26A0}", theme.colors.warning,
theme.colors.tool_executing_bg,
theme.colors.warning,
),
NotificationKind::Error => (
"\u{2717}", theme.colors.error,
theme.colors.tool_error_bg,
theme.colors.error,
),
NotificationKind::Info => (
"\u{2139}", theme.colors.primary,
theme.colors.tool_pending_bg,
theme.colors.primary,
),
};
let icon_label = icon.to_string();
let icon_width = UnicodeWidthStr::width(icon_label.as_str()) as u16;
let icon_gap_width = icon_width.saturating_add(1);
let message_width = UnicodeWidthStr::width(notif.message.as_str()) as u16;
let desired_inner_width = message_width
.saturating_add(icon_gap_width)
.saturating_add(NOTIF_PADDING * 2)
.max(min_inner_width)
.min(max_inner_width);
let notif_width = desired_inner_width.saturating_add(1).min(available_width);
let inner_width = notif_width.saturating_sub(1);
let text_area_width = inner_width.saturating_sub(NOTIF_PADDING * 2);
if text_area_width == 0 {
continue;
}
let line1_width = text_area_width.saturating_sub(icon_gap_width);
let line2_width = text_area_width;
let padding = " ".repeat(NOTIF_PADDING as usize);
let icon_pad = " ".repeat(icon_gap_width as usize);
let (line1_text, remainder) = split_line_by_width(¬if.message, line1_width as usize);
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::raw(padding.clone()),
Span::styled(
icon_label.clone(),
Style::default().fg(fg_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(line1_text, text_style),
]));
if !remainder.is_empty() && line2_width > 0 {
let line2_text = truncate_to_width(&remainder, line2_width as usize);
lines.push(Line::from(vec![
Span::raw(padding.clone()),
Span::raw(icon_pad.clone()),
Span::styled(line2_text, text_style),
]));
}
let card_height = 2 + lines.len() as u16;
let notif_y = base_y.saturating_sub(y_offset + card_height);
if notif_y < area.y {
break;
}
let notif_x = area.x
+ area
.width
.saturating_sub(notif_width)
.saturating_sub(NOTIF_RIGHT_MARGIN);
let notif_area = Rect {
x: notif_x,
y: notif_y,
width: notif_width,
height: card_height,
};
f.render_widget(ratatui::widgets::Clear, notif_area);
let card_block = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(bg_color));
let inner = card_block.inner(notif_area);
f.render_widget(
ratatui::widgets::Paragraph::new("")
.style(Style::default().bg(bg_color))
.block(card_block),
notif_area,
);
f.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
y_offset += card_height + NOTIF_GAP;
}
}