use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
};
use crate::app::App;
use crate::app::chat::{TodoItem, TodoPriority, TodoStatus};
use crate::tui::render::chat::message_list::centered_rect;
use crate::tui::syntax::wrap_text_to_lines;
pub(crate) fn render_confirm_modal(frame: &mut Frame, app: &App, area: Rect) {
let Some(crate::app::PendingTask::ConfirmFunction { name, args }) = &app.proc.pending else {
return;
};
let active_theme = crate::tui::render::get_active_theme(app);
let modal_bg = active_theme.background_panel.unwrap_or({
if active_theme.is_light {
Color::Rgb(240, 240, 240)
} else {
Color::Rgb(24, 24, 24)
}
});
let modal_fg = active_theme.text;
let dim_text = active_theme.text_muted;
let popup_area = centered_rect(55, 45, area);
frame.render_widget(ratatui::widgets::Clear, popup_area);
frame.render_widget(
Block::default().style(Style::default().bg(modal_bg)),
popup_area,
);
let margin = 1u16;
let content = Rect {
x: popup_area.x + margin,
y: popup_area.y + margin,
width: popup_area.width.saturating_sub(margin * 2),
height: popup_area.height.saturating_sub(margin * 2),
};
if content.height == 0 || content.width == 0 {
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ])
.split(content);
let title_area = Rect {
x: chunks[0].x + 1,
y: chunks[0].y,
width: chunks[0].width.saturating_sub(2),
height: 1,
};
let path_str = if name == "edit" || name == "write" || name == "patch" {
args.get("path")
.and_then(|v| v.as_str())
.or_else(|| {
args.get("edits")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|e| e.get("path"))
.and_then(|v| v.as_str())
})
.unwrap_or("")
} else {
""
};
let title_line = if path_str.is_empty() {
Line::from(vec![
Span::styled(
"Confirm Tool Execution",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled(" • ", Style::default().fg(dim_text)),
Span::styled(
name.as_str(),
Style::default()
.fg(Color::Rgb(245, 158, 11))
.add_modifier(Modifier::BOLD),
),
])
} else {
Line::from(vec![
Span::styled(
"Confirm Tool Execution",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled(" • ", Style::default().fg(dim_text)),
Span::styled(
name.as_str(),
Style::default()
.fg(Color::Rgb(245, 158, 11))
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
Span::styled(path_str, Style::default().fg(dim_text)),
])
};
frame.render_widget(Paragraph::new(title_line), title_area);
frame.render_widget(
Paragraph::new("─".repeat(content.width as usize)).style(Style::default().fg(dim_text)),
chunks[1],
);
let body_area = Rect {
x: chunks[2].x + 1,
y: chunks[2].y,
width: chunks[2].width.saturating_sub(2),
height: chunks[2].height,
};
if body_area.height > 0 {
let mut body_lines: Vec<Line> = Vec::new();
if name == "edit" {
if let Some(edits) = args.get("edits").and_then(|v| v.as_array()) {
for edit_val in edits.iter() {
let old_str = edit_val
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new_str = edit_val
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
for line in old_str.lines() {
body_lines.push(Line::from(Span::styled(
format!("- {line}"),
Style::default()
.fg(Color::Rgb(255, 120, 120))
.bg(Color::Rgb(50, 15, 15)),
)));
}
for line in new_str.lines() {
body_lines.push(Line::from(Span::styled(
format!("+ {line}"),
Style::default()
.fg(Color::Rgb(120, 220, 120))
.bg(Color::Rgb(15, 45, 15)),
)));
}
}
} else {
let old_str = args
.get("old_string")
.and_then(|v| v.as_str())
.unwrap_or("");
let new_str = args
.get("new_string")
.and_then(|v| v.as_str())
.unwrap_or("");
for line in old_str.lines() {
body_lines.push(Line::from(Span::styled(
format!("- {line}"),
Style::default()
.fg(Color::Rgb(255, 120, 120))
.bg(Color::Rgb(50, 15, 15)),
)));
}
for line in new_str.lines() {
body_lines.push(Line::from(Span::styled(
format!("+ {line}"),
Style::default()
.fg(Color::Rgb(120, 220, 120))
.bg(Color::Rgb(15, 45, 15)),
)));
}
}
} else if name == "write" {
let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
for line in content.lines() {
body_lines.push(Line::from(Span::styled(
format!("+ {line}"),
Style::default()
.fg(Color::Rgb(120, 220, 120))
.bg(Color::Rgb(15, 45, 15)),
)));
}
} else if name == "patch" {
let patch = args.get("patch").and_then(|v| v.as_str()).unwrap_or("");
for line in patch.lines() {
let style = if line.starts_with('+') && !line.starts_with("+++") {
Style::default()
.fg(Color::Rgb(120, 220, 120))
.bg(Color::Rgb(15, 45, 15))
} else if line.starts_with('-') && !line.starts_with("---") {
Style::default()
.fg(Color::Rgb(255, 120, 120))
.bg(Color::Rgb(50, 15, 15))
} else if line.starts_with("@@") {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(dim_text)
};
body_lines.push(Line::from(Span::styled(line.to_string(), style)));
}
} else {
let args_str = serde_json::to_string_pretty(args).unwrap_or_default();
for line in args_str.lines() {
body_lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(modal_fg),
)));
}
}
let max_scroll = body_lines.len().saturating_sub(body_area.height as usize);
let current_scroll = app
.ui
.confirm_scroll
.get()
.min(u16::try_from(max_scroll.min(usize::from(u16::MAX))).unwrap_or(u16::MAX));
app.ui.confirm_scroll.set(current_scroll);
frame.render_widget(
Paragraph::new(body_lines.clone())
.style(Style::default().fg(modal_fg))
.wrap(Wrap { trim: true })
.scroll((current_scroll, 0)),
body_area,
);
if max_scroll > 0 {
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("^"))
.end_symbol(Some("v"))
.track_symbol(Some("|"))
.thumb_symbol("#");
let mut scrollbar_state =
ScrollbarState::new(body_lines.len()).position(current_scroll as usize);
frame.render_stateful_widget(scrollbar, body_area, &mut scrollbar_state);
}
}
frame.render_widget(
Paragraph::new("─".repeat(content.width as usize)).style(Style::default().fg(dim_text)),
chunks[3],
);
let footer_area = Rect {
x: chunks[4].x + 1,
y: chunks[4].y,
width: chunks[4].width.saturating_sub(2),
height: 1,
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"Y ",
Style::default()
.fg(Color::Rgb(120, 220, 120))
.add_modifier(Modifier::BOLD),
),
Span::styled("allow ", Style::default().fg(dim_text)),
Span::styled(
"N ",
Style::default()
.fg(Color::Rgb(255, 120, 120))
.add_modifier(Modifier::BOLD),
),
Span::styled("deny", Style::default().fg(dim_text)),
])),
footer_area,
);
}
pub(crate) fn render_ask_user_in_input_box(frame: &mut Frame, app: &App, area: Rect) {
let active_theme = crate::tui::render::get_active_theme(app);
let modal_fg = active_theme.text;
let dim_text = active_theme.text_muted;
let selected_color = active_theme.accent;
let inner = Rect {
x: area.x + 3,
y: area.y + 1,
width: area.width.saturating_sub(5),
height: area.height.saturating_sub(1),
};
if inner.height == 0 || inner.width == 0 {
return;
}
let q_wrapped = wrap_text_to_lines(app.ui.ask_user.question.as_str(), inner.width as usize);
let q_height = q_wrapped.len() as u16;
for (i, line) in q_wrapped.iter().enumerate() {
let row_y = inner.y + i as u16;
if row_y >= inner.bottom() {
break;
}
frame.render_widget(
Paragraph::new(Span::styled(line.as_str(), Style::default().fg(modal_fg))),
Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
},
);
}
let list_start_y = inner.y + q_height + 1;
let footer_y = inner.bottom().saturating_sub(2);
let total_options = app.ui.ask_user.options.len() + 1;
let selected = app.ui.ask_user.selected_idx;
let mut row_y = list_start_y;
for idx in 0..total_options {
if row_y >= footer_y.saturating_sub(1) {
break;
}
let is_selected = idx == selected;
let is_custom = idx == app.ui.ask_user.options.len();
let label: &str = if is_custom {
"Type your own answer"
} else {
&app.ui.ask_user.options[idx]
};
let num_style = if is_selected {
Style::default()
.fg(selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(dim_text)
};
let label_style = if is_selected {
Style::default()
.fg(selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD)
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{}.", idx + 1), num_style),
Span::styled(" ", Style::default()),
Span::styled(label, label_style),
])),
Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
},
);
row_y += 1;
if is_custom && app.ui.ask_user.is_custom && row_y < footer_y.saturating_sub(1) {
let prefix_len = format!("{}. ", idx + 1).chars().count();
let indent = " ".repeat(prefix_len);
let display = if app.ui.ask_user.custom_input.is_empty() {
format!("{}Type your answer...", indent)
} else {
format!("{}{}", indent, app.ui.ask_user.custom_input)
};
let input_style = if app.ui.ask_user.custom_input.is_empty() {
Style::default().fg(dim_text)
} else {
Style::default()
.fg(selected_color)
.add_modifier(Modifier::BOLD)
};
frame.render_widget(
Paragraph::new(Span::styled(display, input_style)),
Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
},
);
let cx = inner.x
+ prefix_len as u16
+ if app.ui.ask_user.custom_input.is_empty() {
0
} else {
app.ui.ask_user.custom_input.chars().count() as u16
};
if cx < inner.right() {
frame.set_cursor_position((cx, row_y));
}
row_y += 1;
}
}
if footer_y < inner.bottom() {
let footer = if app.ui.ask_user.is_custom {
Line::from(vec![
Span::styled(
"Enter ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("submit ", Style::default().fg(dim_text)),
Span::styled(
"Esc ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("dismiss", Style::default().fg(dim_text)),
])
} else {
Line::from(vec![
Span::styled(
"Up/Down ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("select ", Style::default().fg(dim_text)),
Span::styled(
"Enter ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("submit ", Style::default().fg(dim_text)),
Span::styled(
"Esc ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("dismiss", Style::default().fg(dim_text)),
])
};
frame.render_widget(
Paragraph::new(footer),
Rect {
x: inner.x,
y: footer_y,
width: inner.width,
height: 1,
},
);
}
}
pub(crate) fn render_permissions_in_input_box(frame: &mut Frame, app: &App, area: Rect) {
let active_theme = crate::tui::render::get_active_theme(app);
let modal_fg = active_theme.text;
let dim_text = active_theme.text_muted;
let selected_color = active_theme.accent;
let inner = Rect {
x: area.x + 3,
y: area.y + 1,
width: area.width.saturating_sub(5),
height: area.height.saturating_sub(1),
};
if inner.height == 0 || inner.width == 0 {
return;
}
frame.render_widget(
Paragraph::new(Span::styled(
"Select permission level",
Style::default().fg(modal_fg),
)),
Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
},
);
let options = crate::app::PermissionPickerState::options();
let total = options.len();
let selected = app.ui.permissions.selected.min(total.saturating_sub(1));
let footer_y = inner.bottom().saturating_sub(2);
let mut row_y = inner.y + 2;
for (idx, &(label, desc, _)) in options.iter().enumerate().take(total) {
let is_selected = idx == selected;
let num_style = if is_selected {
Style::default()
.fg(selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(dim_text)
};
let label_style = if is_selected {
Style::default()
.fg(selected_color)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD)
};
if row_y < footer_y.saturating_sub(1) {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{}.", idx + 1), num_style),
Span::styled(" ", Style::default()),
Span::styled(label, label_style),
])),
Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
},
);
row_y += 1;
}
if row_y < footer_y.saturating_sub(1) {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(desc, Style::default().fg(dim_text)),
])),
Rect {
x: inner.x,
y: row_y,
width: inner.width,
height: 1,
},
);
row_y += 1;
}
}
if footer_y < inner.bottom() {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"Up/Down ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("select ", Style::default().fg(dim_text)),
Span::styled(
"Enter ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("apply ", Style::default().fg(dim_text)),
Span::styled(
"Esc ",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
),
Span::styled("cancel", Style::default().fg(dim_text)),
])),
Rect {
x: inner.x,
y: footer_y,
width: inner.width,
height: 1,
},
);
}
}
pub(crate) fn render_model_picker_modal(frame: &mut Frame, app: &App, area: Rect) {
let active_theme = crate::tui::render::get_active_theme(app);
let modal_bg = active_theme.background_panel.unwrap_or({
if active_theme.is_light {
Color::Rgb(240, 240, 240)
} else {
Color::Rgb(24, 24, 24)
}
});
let modal_fg = active_theme.text;
let placeholder_fg = active_theme.text_muted;
let list_fg = active_theme.text;
let hint_fg = active_theme.text_muted;
let active_bullet_color = active_theme.success;
let active_bullet_color_on_select = active_theme.secondary;
let select_bg = active_theme.accent;
let popup_area = centered_rect(36, 48, area);
frame.render_widget(ratatui::widgets::Clear, popup_area);
frame.render_widget(
Block::default().style(Style::default().bg(modal_bg)),
popup_area,
);
let margin = 1u16;
let content_area = Rect {
x: popup_area.x + margin,
y: popup_area.y + margin,
width: popup_area.width.saturating_sub(margin * 2),
height: popup_area.height.saturating_sub(margin * 2),
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(content_area);
let title_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(chunks[0]);
let title_content = title_row[1];
frame.render_widget(
Paragraph::new(Span::styled(
"Select model",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
)),
title_content,
);
frame.render_widget(
Paragraph::new(Span::styled("esc", Style::default().fg(hint_fg)))
.alignment(Alignment::Right),
title_content,
);
let search_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(chunks[2]);
let search_content = search_row[1];
let search_line = if app.ui.models.query.is_empty() {
Line::from(Span::styled("Search", Style::default().fg(placeholder_fg)))
} else {
Line::from(Span::styled(
app.ui.models.query.clone(),
Style::default().fg(modal_fg),
))
};
frame.render_widget(Paragraph::new(search_line), search_content);
let cursor_x = search_content.x + app.ui.models.query.chars().count() as u16;
if cursor_x < search_content.right() {
frame.set_cursor_position((cursor_x, search_content.y));
}
let list_area = chunks[4];
let filtered = app.ui.models.filtered_indices();
let active_model = app.chat.config.model.as_str();
if filtered.is_empty() {
let msg = if app.ui.models.query.is_empty() {
"No models available"
} else {
"No matches"
};
let empty_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(list_area);
frame.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(placeholder_fg))),
empty_cols[1],
);
return;
}
let viewport = list_area.height as usize;
let total = filtered.len();
let selected = app.ui.models.selected.min(total.saturating_sub(1));
let start = if total <= viewport || selected < viewport / 2 {
0
} else if selected >= total - viewport / 2 {
total - viewport
} else {
selected - viewport / 2
};
let visible_count = viewport.min(total.saturating_sub(start));
for offset in 0..visible_count {
let idx = start + offset;
let model = &app.ui.models.models[filtered[idx]];
let display = model.trim_start_matches("models/");
let is_selected = idx == selected;
let is_active = display == active_model || model == active_model;
let row_y = list_area.y + offset as u16;
if row_y >= list_area.bottom() {
break;
}
let row_area = Rect {
x: list_area.x,
y: row_y,
width: list_area.width,
height: 1,
};
if is_selected {
frame.render_widget(
Block::default().style(Style::default().bg(select_bg)),
row_area,
);
}
let row_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(row_area);
let bullet_style = if is_active {
if is_selected {
Style::default()
.bg(select_bg)
.fg(active_bullet_color_on_select)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(active_bullet_color)
.add_modifier(Modifier::BOLD)
}
} else if is_selected {
Style::default().bg(select_bg)
} else {
Style::default()
};
let bullet_char = if is_active { "*" } else { " " };
frame.render_widget(
Paragraph::new(Span::styled(bullet_char, bullet_style)),
row_cols[1],
);
let text_style = if is_selected {
Style::default()
.bg(select_bg)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(list_fg)
};
frame.render_widget(
Paragraph::new(Span::styled(display, text_style)),
row_cols[3],
);
}
}
pub(crate) fn render_theme_picker_modal(frame: &mut Frame, app: &App, area: Rect) {
let active_theme = crate::tui::render::get_active_theme(app);
let modal_bg = active_theme.background_panel.unwrap_or({
if active_theme.is_light {
Color::Rgb(240, 240, 240)
} else {
Color::Rgb(24, 24, 24)
}
});
let modal_fg = active_theme.text;
let placeholder_fg = active_theme.text_muted;
let list_fg = active_theme.text;
let hint_fg = active_theme.text_muted;
let active_bullet_color = active_theme.success;
let active_bullet_color_on_select = active_theme.secondary;
let select_bg = active_theme.accent;
let popup_area = centered_rect(36, 48, area);
frame.render_widget(ratatui::widgets::Clear, popup_area);
frame.render_widget(
Block::default().style(Style::default().bg(modal_bg)),
popup_area,
);
let margin = 1u16;
let content_area = Rect {
x: popup_area.x + margin,
y: popup_area.y + margin,
width: popup_area.width.saturating_sub(margin * 2),
height: popup_area.height.saturating_sub(margin * 2),
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(content_area);
let title_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(chunks[0]);
let title_content = title_row[1];
frame.render_widget(
Paragraph::new(Span::styled(
"Select theme",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
)),
title_content,
);
frame.render_widget(
Paragraph::new(Span::styled("esc", Style::default().fg(hint_fg)))
.alignment(Alignment::Right),
title_content,
);
let search_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(chunks[2]);
let search_content = search_row[1];
let search_line = if app.ui.theme_picker.query.is_empty() {
Line::from(Span::styled("Search", Style::default().fg(placeholder_fg)))
} else {
Line::from(Span::styled(
app.ui.theme_picker.query.clone(),
Style::default().fg(modal_fg),
))
};
frame.render_widget(Paragraph::new(search_line), search_content);
let cursor_x = search_content.x + app.ui.theme_picker.query.chars().count() as u16;
if cursor_x < search_content.right() {
frame.set_cursor_position((cursor_x, search_content.y));
}
let list_area = chunks[4];
let filtered = app.ui.theme_picker.filtered_indices();
if filtered.is_empty() {
let msg = if app.ui.theme_picker.query.is_empty() {
"No themes available"
} else {
"No matches"
};
let empty_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(list_area);
frame.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(placeholder_fg))),
empty_cols[1],
);
return;
}
let viewport = list_area.height as usize;
let total = filtered.len();
let selected = app.ui.theme_picker.selected.min(total.saturating_sub(1));
let start = if total <= viewport || selected < viewport / 2 {
0
} else if selected >= total - viewport / 2 {
total - viewport
} else {
selected - viewport / 2
};
let visible_count = viewport.min(total.saturating_sub(start));
for offset in 0..visible_count {
let idx = start + offset;
let theme = &app.ui.theme_picker.themes[filtered[idx]];
let display = theme.label();
let is_selected = idx == selected;
let is_active = theme == &app.chat.config.theme;
let row_y = list_area.y + offset as u16;
if row_y >= list_area.bottom() {
break;
}
let row_area = Rect {
x: list_area.x,
y: row_y,
width: list_area.width,
height: 1,
};
if is_selected {
frame.render_widget(
Block::default().style(Style::default().bg(select_bg)),
row_area,
);
}
let row_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(row_area);
let bullet_style = if is_active {
if is_selected {
Style::default()
.bg(select_bg)
.fg(active_bullet_color_on_select)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(active_bullet_color)
.add_modifier(Modifier::BOLD)
}
} else if is_selected {
Style::default().bg(select_bg)
} else {
Style::default()
};
let bullet_char = if is_active { "*" } else { " " };
frame.render_widget(
Paragraph::new(Span::styled(bullet_char, bullet_style)),
row_cols[1],
);
let text_style = if is_selected {
Style::default()
.bg(select_bg)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(list_fg)
};
frame.render_widget(
Paragraph::new(Span::styled(display, text_style)),
row_cols[3],
);
}
}
pub(crate) fn render_agent_picker_modal(frame: &mut Frame, app: &App, area: Rect) {
let active_theme = crate::tui::render::get_active_theme(app);
let modal_bg = active_theme.background_panel.unwrap_or({
if active_theme.is_light {
Color::Rgb(240, 240, 240)
} else {
Color::Rgb(24, 24, 24)
}
});
let modal_fg = active_theme.text;
let placeholder_fg = active_theme.text_muted;
let list_fg = active_theme.text;
let hint_fg = active_theme.text_muted;
let active_bullet_color = active_theme.success;
let active_bullet_color_on_select = active_theme.secondary;
let select_bg = active_theme.accent;
let popup_area = centered_rect(42, 48, area);
frame.render_widget(ratatui::widgets::Clear, popup_area);
frame.render_widget(
Block::default().style(Style::default().bg(modal_bg)),
popup_area,
);
let margin = 1u16;
let content_area = Rect {
x: popup_area.x + margin,
y: popup_area.y + margin,
width: popup_area.width.saturating_sub(margin * 2),
height: popup_area.height.saturating_sub(margin * 2),
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(content_area);
let title_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(chunks[0]);
let title_content = title_row[1];
frame.render_widget(
Paragraph::new(Span::styled(
"Select agent",
Style::default().fg(modal_fg).add_modifier(Modifier::BOLD),
)),
title_content,
);
frame.render_widget(
Paragraph::new(Span::styled("esc", Style::default().fg(hint_fg)))
.alignment(Alignment::Right),
title_content,
);
let search_row = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(chunks[2]);
let search_content = search_row[1];
let search_line = if app.ui.agent_picker.query.is_empty() {
Line::from(Span::styled("Search", Style::default().fg(placeholder_fg)))
} else {
Line::from(Span::styled(
app.ui.agent_picker.query.clone(),
Style::default().fg(modal_fg),
))
};
frame.render_widget(Paragraph::new(search_line), search_content);
let cursor_x = search_content.x + app.ui.agent_picker.query.chars().count() as u16;
if cursor_x < search_content.right() {
frame.set_cursor_position((cursor_x, search_content.y));
}
let list_area = chunks[4];
let filtered = app.ui.agent_picker.filtered_indices();
if filtered.is_empty() {
let msg = if app.ui.agent_picker.query.is_empty() {
"No agents available"
} else {
"No matches"
};
let empty_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(list_area);
frame.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(placeholder_fg))),
empty_cols[1],
);
return;
}
let viewport = list_area.height as usize;
let total = filtered.len();
let selected = app.ui.agent_picker.selected.min(total.saturating_sub(1));
let start = if total <= viewport || selected < viewport / 2 {
0
} else if selected >= total - viewport / 2 {
total - viewport
} else {
selected - viewport / 2
};
let visible_count = viewport.min(total.saturating_sub(start));
for offset in 0..visible_count {
let idx = start + offset;
let (agent_id, display_name) = &app.ui.agent_picker.agents[filtered[idx]];
let is_selected = idx == selected;
let is_active = agent_id == &app.core.active_agent;
let row_y = list_area.y + offset as u16;
if row_y >= list_area.bottom() {
break;
}
let row_area = Rect {
x: list_area.x,
y: row_y,
width: list_area.width,
height: 1,
};
if is_selected {
frame.render_widget(
Block::default().style(Style::default().bg(select_bg)),
row_area,
);
}
let row_cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(row_area);
let bullet_style = if is_active {
if is_selected {
Style::default()
.bg(select_bg)
.fg(active_bullet_color_on_select)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(active_bullet_color)
.add_modifier(Modifier::BOLD)
}
} else if is_selected {
Style::default().bg(select_bg)
} else {
Style::default()
};
let bullet_char = if is_active { "*" } else { " " };
frame.render_widget(
Paragraph::new(Span::styled(bullet_char, bullet_style)),
row_cols[1],
);
let text_style = if is_selected {
Style::default()
.bg(select_bg)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(list_fg)
};
frame.render_widget(
Paragraph::new(Span::styled(display_name, text_style)),
row_cols[3],
);
}
}
pub(crate) fn render_todos(frame: &mut Frame, app: &App, area: Rect) {
app.chat.todo_area.set(Some(area));
let active_theme = crate::tui::render::get_active_theme(app);
let sidebar_bg = active_theme
.background_panel
.unwrap_or(Color::Rgb(24, 24, 24));
let sidebar_fg = active_theme.text;
let block = Block::default()
.style(Style::default().bg(sidebar_bg).fg(sidebar_fg))
.padding(Padding::new(1, 1, 1, 1));
let inner_area = block.inner(area);
frame.render_widget(block, area);
let todo_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.split(inner_area);
frame.render_widget(
Paragraph::new(Span::styled(
"TODO",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
todo_chunks[0],
);
let render_priority = |p: &TodoPriority| -> Span<'static> {
match p {
TodoPriority::High => {
Span::styled("[H] ", Style::default().fg(Color::Rgb(239, 68, 68)))
}
TodoPriority::Medium => {
Span::styled("[M] ", Style::default().fg(Color::Rgb(245, 158, 11)))
}
TodoPriority::Low => Span::styled("[L] ", Style::default().fg(active_theme.text_muted)),
}
};
let in_progress: Vec<&TodoItem> = app
.chat
.todos
.iter()
.filter(|t| t.status == TodoStatus::InProgress)
.collect();
let pending: Vec<&TodoItem> = app
.chat
.todos
.iter()
.filter(|t| t.status == TodoStatus::Pending)
.collect();
let completed: Vec<&TodoItem> = app
.chat
.todos
.iter()
.filter(|t| t.status == TodoStatus::Completed)
.collect();
let cancelled: Vec<&TodoItem> = app
.chat
.todos
.iter()
.filter(|t| t.status == TodoStatus::Cancelled)
.collect();
let mut all_sorted_todos = Vec::new();
all_sorted_todos.extend(in_progress);
all_sorted_todos.extend(pending);
all_sorted_todos.extend(completed);
all_sorted_todos.extend(cancelled);
let tasks_area = todo_chunks[2];
let first_prefix: usize = 8;
let cont_prefix: usize = 6;
let content_width = (tasks_area.width as usize)
.saturating_sub(first_prefix)
.max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
for item in &all_sorted_todos {
let (status_bullet, item_style) = match item.status {
TodoStatus::InProgress => (
Span::styled("● ", Style::default().fg(Color::Rgb(234, 179, 8))),
Style::default().fg(Color::Rgb(220, 220, 220)),
),
TodoStatus::Completed => (
Span::styled("● ", Style::default().fg(Color::Rgb(34, 197, 94))),
Style::default().fg(Color::DarkGray),
),
TodoStatus::Cancelled => (
Span::styled("◌ ", Style::default().fg(Color::DarkGray)),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
TodoStatus::Pending => (
Span::styled("○ ", Style::default().fg(Color::DarkGray)),
Style::default().fg(Color::DarkGray),
),
};
let priority_marker = render_priority(&item.priority);
let wrapped = wrap_text_to_lines(&item.content, content_width);
for (idx, line_text) in wrapped.iter().enumerate() {
if idx == 0 {
lines.push(Line::from(vec![
Span::raw(" "),
status_bullet.clone(),
priority_marker.clone(),
Span::styled(line_text.clone(), item_style),
]));
} else {
let indent = " ".repeat(cont_prefix);
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled(line_text.clone(), item_style),
]));
}
}
}
let visible_height = tasks_area.height as usize;
let total_lines = lines.len();
let mut scroll = app.chat.todo_scroll as usize;
if scroll >= total_lines {
scroll = total_lines.saturating_sub(1);
}
let max_scroll = total_lines.saturating_sub(visible_height);
if scroll > max_scroll {
scroll = max_scroll;
}
if total_lines > visible_height {
let scroll_pct = if total_lines == 0 {
100
} else {
let shown_end = (scroll + visible_height).min(total_lines);
((shown_end * 100) / total_lines).min(100)
};
let hint = format!(" {}% ", scroll_pct);
let hint_area = Rect {
x: todo_chunks[1].x,
y: todo_chunks[1].y,
width: todo_chunks[1].width,
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(
hint,
Style::default().fg(active_theme.text_muted),
))
.alignment(Alignment::Center),
hint_area,
);
}
frame.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), tasks_area);
}