use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
use std::path::PathBuf;
use super::claude_code::floor_char_boundary;
use super::messages::{format_file_size, read_directory_entries, FileEntry};
use super::theme::Theme;
#[derive(Debug, Clone)]
pub enum Dialog {
Help,
Quit,
ModelPicker {
models: Vec<String>,
selected: usize,
},
_PermissionRequest {
_request_id: String,
_tool_name: String,
_input_display: String,
},
MessageActions {
selected: usize,
},
LinkPreview {
text: String,
url: String,
selected: usize,
},
FilePicker {
current_dir: PathBuf,
entries: Vec<FileEntry>,
selected: usize,
scroll_offset: usize,
filter: String,
},
}
impl Dialog {
pub fn model_picker() -> Self {
Dialog::ModelPicker {
models: vec![
"claude-sonnet-4-20250514".to_string(),
"claude-opus-4-20250514".to_string(),
"claude-haiku-4-5-20251001".to_string(),
"gpt-4o".to_string(),
"gpt-4o-mini".to_string(),
"grok-3".to_string(),
"grok-3-mini".to_string(),
],
selected: 0,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> (bool, Option<String>) {
match self {
Dialog::Help => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => (true, None),
_ => (false, None),
},
Dialog::Quit => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
(true, Some("quit".to_string()))
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => (true, None),
_ => (false, None),
},
Dialog::ModelPicker { models, selected } => match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if *selected > 0 {
*selected -= 1;
}
(false, None)
}
KeyCode::Down | KeyCode::Char('j') => {
if *selected < models.len() - 1 {
*selected += 1;
}
(false, None)
}
KeyCode::Enter => {
let model = models[*selected].clone();
(true, Some(model))
}
KeyCode::Esc => (true, None),
_ => (false, None),
},
Dialog::_PermissionRequest { _request_id, .. } => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
(true, Some(format!("allow:{}", _request_id)))
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
(true, Some(format!("deny:{}", _request_id)))
}
_ => (false, None),
},
Dialog::MessageActions { selected } => {
const ACTION_COUNT: usize = 2;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if *selected > 0 {
*selected -= 1;
}
(false, None)
}
KeyCode::Down | KeyCode::Char('j') => {
if *selected < ACTION_COUNT - 1 {
*selected += 1;
}
(false, None)
}
KeyCode::Enter => {
let action = match *selected {
0 => "copy_last",
1 => "copy_all",
_ => "copy_last",
};
(true, Some(action.to_string()))
}
KeyCode::Esc => (true, None),
_ => (false, None),
}
}
Dialog::LinkPreview { url, selected, .. } => {
const ACTION_COUNT: usize = 2;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
if *selected > 0 {
*selected -= 1;
}
(false, None)
}
KeyCode::Down | KeyCode::Char('j') => {
if *selected < ACTION_COUNT - 1 {
*selected += 1;
}
(false, None)
}
KeyCode::Enter => {
let result = match *selected {
0 => format!("open_url:{}", url),
1 => format!("copy_url:{}", url),
_ => format!("open_url:{}", url),
};
(true, Some(result))
}
KeyCode::Esc => (true, None),
_ => (false, None),
}
}
Dialog::FilePicker {
current_dir,
entries,
selected,
scroll_offset,
filter,
} => {
let filtered = filtered_entries(entries, filter);
match key.code {
KeyCode::Up => {
if *selected > 0 {
*selected -= 1;
}
if *selected < *scroll_offset {
*scroll_offset = *selected;
}
(false, None)
}
KeyCode::Down => {
if *selected < filtered.len().saturating_sub(1) {
*selected += 1;
}
if *selected >= *scroll_offset + 14 {
*scroll_offset = selected.saturating_sub(13);
}
(false, None)
}
KeyCode::Enter => {
if let Some(entry) = filtered.get(*selected) {
if entry.is_dir {
let new_dir = if entry.name == ".." {
current_dir.parent().unwrap_or(current_dir).to_path_buf()
} else {
current_dir.join(&entry.name)
};
*entries = read_directory_entries(&new_dir);
*current_dir = new_dir;
*selected = 0;
*scroll_offset = 0;
*filter = String::new();
(false, None)
} else {
let full_path = current_dir.join(&entry.name);
(true, Some(full_path.to_string_lossy().to_string()))
}
} else {
(false, None)
}
}
KeyCode::Backspace => {
if filter.is_empty() {
if let Some(parent) = current_dir.parent() {
let parent = parent.to_path_buf();
*entries = read_directory_entries(&parent);
*current_dir = parent;
*selected = 0;
*scroll_offset = 0;
}
} else {
filter.pop();
*selected = 0;
*scroll_offset = 0;
}
(false, None)
}
KeyCode::Char(c) => {
filter.push(c);
*selected = 0;
*scroll_offset = 0;
(false, None)
}
KeyCode::Esc => (true, None),
_ => (false, None),
}
}
}
}
}
pub struct DialogWidget<'a> {
pub dialog: &'a Dialog,
pub theme: &'a Theme,
}
impl<'a> Widget for DialogWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
let (width, height) = match self.dialog {
Dialog::Help => (60, 20),
Dialog::Quit => (40, 5),
Dialog::ModelPicker { models, .. } => (50, models.len() as u16 + 4),
Dialog::_PermissionRequest {
ref _input_display, ..
} => {
let input_lines = _input_display.lines().count().min(10) as u16;
(64, 6 + input_lines)
}
Dialog::MessageActions { .. } => (44, 7),
Dialog::LinkPreview { ref url, .. } => {
let url_w = url.len().min(70) + 4;
(url_w.max(44) as u16, 9)
}
Dialog::FilePicker {
ref entries,
ref filter,
..
} => {
let count = filtered_entries(entries, filter).len();
let h = (count as u16 + 6).clamp(10, 22);
(62, h)
}
};
let dialog_area = centered_rect(width, height, area);
Clear.render(dialog_area, buf);
match self.dialog {
Dialog::Help => self.render_help(dialog_area, buf),
Dialog::Quit => self.render_quit(dialog_area, buf),
Dialog::ModelPicker { models, selected } => {
self.render_model_picker(dialog_area, buf, models, *selected)
}
Dialog::_PermissionRequest {
ref _tool_name,
ref _input_display,
..
} => self.render_permission_request(dialog_area, buf, _tool_name, _input_display),
Dialog::MessageActions { selected } => {
self.render_message_actions(dialog_area, buf, *selected)
}
Dialog::LinkPreview {
ref text,
ref url,
selected,
} => self.render_link_preview(dialog_area, buf, text, url, *selected),
Dialog::FilePicker {
ref current_dir,
ref entries,
selected,
scroll_offset,
ref filter,
} => self.render_file_picker(
dialog_area,
buf,
current_dir,
entries,
*selected,
*scroll_offset,
filter,
),
}
}
}
impl<'a> DialogWidget<'a> {
fn render_help(&self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.accent))
.title(Span::styled(
" Keyboard Shortcuts ",
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
));
let shortcuts = vec![
("Enter", "Send message"),
("Shift+Enter", "New line"),
("Ctrl+A", "Attach file"),
("Ctrl+X", "Remove last attachment"),
("Ctrl+C", "Cancel / Quit"),
("Ctrl+H", "Toggle help"),
("Ctrl+O", "Select model"),
("Ctrl+L", "Clear messages"),
("Page Up/Down", "Scroll messages"),
("Ctrl+U/D", "Half-page scroll"),
("Ctrl+Y", "Copy last response"),
("Esc", "Close dialog"),
("", ""),
("Press Esc to close", ""),
];
let lines: Vec<Line> = shortcuts
.iter()
.map(|(key, desc)| {
if key.is_empty() {
Line::raw("")
} else if desc.is_empty() {
Line::from(Span::styled(
key.to_string(),
Style::default().fg(self.theme.muted),
))
} else {
Line::from(vec![
Span::styled(
format!(" {:16}", key),
Style::default().fg(self.theme.accent),
),
Span::raw(desc.to_string()),
])
}
})
.collect();
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
fn render_quit(&self, area: Rect, buf: &mut Buffer) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.tool_err))
.title(Span::styled(
" Quit ",
Style::default()
.fg(self.theme.tool_err)
.add_modifier(Modifier::BOLD),
));
let lines = vec![
Line::raw(""),
Line::from(vec![
Span::raw(" Are you sure? "),
Span::styled("[Y]", Style::default().fg(self.theme.accent)),
Span::raw("es / "),
Span::styled("[N]", Style::default().fg(self.theme.accent)),
Span::raw("o"),
]),
];
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
fn render_model_picker(
&self,
area: Rect,
buf: &mut Buffer,
models: &[String],
selected: usize,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.accent))
.title(Span::styled(
" Select Model ",
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
));
let mut lines = vec![Line::raw("")];
for (i, model) in models.iter().enumerate() {
let prefix = if i == selected { " ▸ " } else { " " };
let style = if i == selected {
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.fg)
};
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, model),
style,
)));
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" ↑↓ navigate │ Enter select │ Esc cancel",
Style::default().fg(self.theme.muted),
)));
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
fn render_permission_request(
&self,
area: Rect,
buf: &mut Buffer,
tool_name: &str,
input_display: &str,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.tool_pending))
.title(Span::styled(
" Permission Request ",
Style::default()
.fg(self.theme.tool_pending)
.add_modifier(Modifier::BOLD),
));
let mut lines = vec![
Line::raw(""),
Line::from(vec![
Span::styled(" Tool: ", Style::default().fg(self.theme.muted)),
Span::styled(
tool_name.to_string(),
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
),
]),
Line::raw(""),
];
for (i, line) in input_display.lines().enumerate() {
if i >= 10 {
lines.push(Line::from(Span::styled(
" ...",
Style::default().fg(self.theme.muted),
)));
break;
}
let display = if line.len() > 56 {
let end = floor_char_boundary(line, 56);
format!(" {}...", &line[..end])
} else {
format!(" {}", line)
};
lines.push(Line::from(Span::styled(
display,
Style::default().fg(self.theme.fg),
)));
}
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("[Y]", Style::default().fg(self.theme.accent)),
Span::raw("es allow / "),
Span::styled("[N]", Style::default().fg(self.theme.tool_err)),
Span::raw("o deny"),
]));
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
fn render_message_actions(&self, area: Rect, buf: &mut Buffer, selected: usize) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.accent))
.title(Span::styled(
" Message Actions ",
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
));
let actions = ["Copy last response", "Copy all messages"];
let mut lines = vec![Line::raw("")];
for (i, action) in actions.iter().enumerate() {
let prefix = if i == selected { " ▸ " } else { " " };
let style = if i == selected {
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.fg)
};
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, action),
style,
)));
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" ↑↓ navigate │ Enter select │ Esc cancel",
Style::default().fg(self.theme.muted),
)));
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
fn render_link_preview(
&self,
area: Rect,
buf: &mut Buffer,
text: &str,
url: &str,
selected: usize,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.accent))
.title(Span::styled(
" Link ",
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
));
let inner_w = area.width.saturating_sub(2) as usize;
let display_text = if text.len() > inner_w - 2 {
let end = floor_char_boundary(text, inner_w - 5);
format!(" {}...", &text[..end])
} else {
format!(" {}", text)
};
let display_url = if url.len() > inner_w - 2 {
let end = floor_char_boundary(url, inner_w - 5);
format!(" {}...", &url[..end])
} else {
format!(" {}", url)
};
let actions = ["Open in browser", "Copy URL"];
let mut lines = vec![
Line::from(Span::styled(
display_text,
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
display_url,
Style::default().fg(self.theme.muted),
)),
Line::raw(""),
];
for (i, action) in actions.iter().enumerate() {
let prefix = if i == selected { " ▸ " } else { " " };
let style = if i == selected {
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.fg)
};
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, action),
style,
)));
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" ↑↓ navigate │ Enter select │ Esc cancel",
Style::default().fg(self.theme.muted),
)));
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
#[allow(clippy::too_many_arguments)]
fn render_file_picker(
&self,
area: Rect,
buf: &mut Buffer,
current_dir: &std::path::Path,
entries: &[FileEntry],
selected: usize,
scroll_offset: usize,
filter: &str,
) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.accent))
.title(Span::styled(
" Attach File ",
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 4 || inner.width < 10 {
return;
}
let inner_w = inner.width as usize;
let dir_str = current_dir.display().to_string();
let dir_display = if dir_str.len() > inner_w - 2 {
let end = floor_char_boundary(&dir_str, inner_w - 5);
format!(" ...{}", &dir_str[dir_str.len() - end..])
} else {
format!(" {}", dir_str)
};
let dir_line = Line::from(Span::styled(
dir_display,
Style::default().fg(self.theme.muted),
));
Paragraph::new(dir_line).render(
Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: 1,
},
buf,
);
let filter_display = if filter.is_empty() {
" Search: ".to_string()
} else {
format!(" Search: {}", filter)
};
let filter_line = Line::from(vec![
Span::styled(filter_display, Style::default().fg(self.theme.fg)),
Span::styled("█", Style::default().fg(self.theme.accent)),
]);
Paragraph::new(filter_line).render(
Rect {
x: inner.x,
y: inner.y + 1,
width: inner.width,
height: 1,
},
buf,
);
let list_height = inner.height.saturating_sub(4) as usize;
let filtered = filtered_entries(entries, filter);
for (i, entry) in filtered
.iter()
.skip(scroll_offset)
.take(list_height)
.enumerate()
{
let idx = scroll_offset + i;
let is_sel = idx == selected;
let prefix = if is_sel { " > " } else { " " };
let icon = if entry.is_dir { "/" } else { " " };
let size_str = if entry.is_dir {
String::new()
} else {
format_file_size(entry.size)
};
let name_w = inner_w.saturating_sub(prefix.len() + 1 + size_str.len() + 1);
let name = if entry.name.len() > name_w {
let end = floor_char_boundary(&entry.name, name_w.saturating_sub(3));
format!("{}...", &entry.name[..end])
} else {
entry.name.clone()
};
let pad = name_w.saturating_sub(name.len());
let style = if is_sel {
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.fg)
};
let line = Line::from(vec![
Span::styled(prefix.to_string(), style),
Span::styled(format!("{}{}", name, icon), style),
Span::styled(" ".repeat(pad), Style::default()),
Span::styled(size_str, Style::default().fg(self.theme.muted)),
]);
Paragraph::new(line).render(
Rect {
x: inner.x,
y: inner.y + 2 + i as u16,
width: inner.width,
height: 1,
},
buf,
);
}
if filtered.is_empty() {
let empty_line = Line::from(Span::styled(
" No files found",
Style::default().fg(self.theme.muted),
));
Paragraph::new(empty_line).render(
Rect {
x: inner.x,
y: inner.y + 2,
width: inner.width,
height: 1,
},
buf,
);
}
let hints_y = inner.y + inner.height.saturating_sub(1);
let hints_line = Line::from(Span::styled(
" ↑↓ navigate │ Enter select │ Bksp parent │ Esc cancel",
Style::default().fg(self.theme.muted),
));
Paragraph::new(hints_line).render(
Rect {
x: inner.x,
y: hints_y,
width: inner.width,
height: 1,
},
buf,
);
}
}
fn filtered_entries<'a>(entries: &'a [FileEntry], filter: &str) -> Vec<&'a FileEntry> {
if filter.is_empty() {
entries.iter().collect()
} else {
let lower = filter.to_lowercase();
entries
.iter()
.filter(|e| e.name.to_lowercase().contains(&lower) || e.name == "..")
.collect()
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}