use crate::{
app::mcp_panel::McpPanelState,
app::mcp_panel::McpServerEditorState,
app::memory_panel::{MemoryPanelMode, MemoryPanelState},
app::message_panel::MessagePanelState,
app::model_panel::{ModelPanelItem, ModelPanelState},
app::permission::PermissionDialogState,
app::question::QuestionDialogState,
app::ui::workspace_boundary::WorkspaceBoundaryDialogState,
app::session_panel::{SessionPanelDialog, SessionPanelState, SessionViewMode},
app::settings_panel::SettingsPanelState,
app::theme_panel::ThemePanelState,
app::ui::agents_panel::AgentsPanelState,
app::ui::rename::RenameSessionDialogState,
app::ui::skills_panel::SkillsPanelState,
config::ProviderSource,
provider_setup::{ConnectDialog, EditProviderStep, NewProviderStep},
};
use chrono;
use ratatui::{
layout::{Alignment, Constraint, Layout, Margin, Rect},
prelude::{Frame, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use super::{App, connect::ProviderPickerItem, render::*};
impl App {
pub(super) fn render_command_palette(&self, frame: &mut Frame<'_>, area: Rect) {
if !self.command_palette.visible || self.command_palette.suggestions.is_empty() {
return;
}
let palette = self.palette();
let width = area.width.min(72);
let height = (self.command_palette.suggestions.len() as u16)
.min(6)
.saturating_add(2);
let rect = Rect::new(area.x, area.y.saturating_sub(height), width, height);
let inner = rect.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let items = self
.command_palette
.suggestions
.iter()
.map(|suggestion| {
ListItem::new(Line::from(vec![
Span::styled(
suggestion.spec.label(),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
suggestion.spec.description,
Style::default().fg(palette.muted),
),
]))
})
.collect::<Vec<_>>();
let mut state = ListState::default();
state.select(Some(self.command_palette.selected_index));
let panel = Block::default()
.style(Style::default().bg(palette.panel_alt))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()))
.title(format!("Commands · /{}", self.command_palette.query));
let list = List::new(items)
.style(Style::default().bg(palette.panel_alt).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(Clear, rect);
frame.render_widget(panel, rect);
frame.render_stateful_widget(list, inner, &mut state);
}
pub(super) fn render_at_mention_palette(&self, frame: &mut Frame<'_>, area: Rect) {
if !self.at_mention.visible || self.at_mention.suggestions.is_empty() {
return;
}
let palette = self.palette();
let width = area.width.min(72);
let height = (self.at_mention.suggestions.len() as u16)
.min(6)
.saturating_add(2);
let rect = Rect::new(area.x, area.y.saturating_sub(height), width, height);
let inner = rect.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let items = self
.at_mention
.suggestions
.iter()
.map(|suggestion| {
let path_style = Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD);
let highlight_style = Style::default()
.fg(palette.accent_soft)
.add_modifier(Modifier::BOLD);
let mut path_spans = vec![Span::styled("@", path_style)];
path_spans.extend(spans_with_highlights(
&suggestion.path,
&suggestion.matched_indices,
path_style,
highlight_style,
));
let mut spans = path_spans;
spans.push(Span::raw(" "));
spans.push(Span::styled(
suggestion.display.clone(),
Style::default().fg(palette.muted),
));
ListItem::new(Line::from(spans))
})
.collect::<Vec<_>>();
let mut state = ListState::default();
state.select(Some(self.at_mention.selected_index));
let panel = Block::default()
.style(Style::default().bg(palette.panel_alt))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()))
.title(format!("Files · @{}", self.at_mention.query));
let list = List::new(items)
.style(Style::default().bg(palette.panel_alt).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(Clear, rect);
frame.render_widget(panel, rect);
frame.render_stateful_widget(list, inner, &mut state);
}
pub(super) fn render_snippet_palette(&self, frame: &mut Frame<'_>, area: Rect) {
if !self.snippet_state.visible || self.snippet_state.snippets.is_empty() {
return;
}
let palette = self.palette();
let width = area.width.min(72);
let height = (self.snippet_state.snippets.len() as u16)
.min(6)
.saturating_add(2);
let rect = Rect::new(area.x, area.y.saturating_sub(height), width, height);
let inner = rect.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let items = self
.snippet_state
.snippets
.iter()
.map(|snippet| {
let text_style = Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD);
let highlight_style = Style::default()
.fg(palette.accent_soft)
.add_modifier(Modifier::BOLD);
let spans = spans_with_highlights(
&snippet.text,
&snippet.matched_indices,
text_style,
highlight_style,
);
ListItem::new(Line::from(spans))
})
.collect::<Vec<_>>();
let mut state = ListState::default();
state.select(Some(self.snippet_state.selected_index));
let panel = Block::default()
.style(Style::default().bg(palette.panel_alt))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()))
.title(format!("Snippets · {}", self.snippet_state.query));
let list = List::new(items)
.style(Style::default().bg(palette.panel_alt).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(Clear, rect);
frame.render_widget(panel, rect);
frame.render_stateful_widget(list, inner, &mut state);
}
pub(super) fn render_connect_dialog(&self, frame: &mut Frame<'_>, area: Rect) {
let Some(dialog) = &self.connect_dialog else {
return;
};
let palette = self.palette();
let (overlay_width, overlay_height) = match dialog {
ConnectDialog::ProviderPicker { .. } => (area.width.min(92), area.height.min(28)),
ConnectDialog::EditProvider {
step, model_step, ..
} => {
if model_step.is_some() {
(area.width.min(90), area.height.min(26))
} else {
match step {
EditProviderStep::ModelList | EditProviderStep::ConfirmDeleteModel => {
(area.width.min(96), area.height.min(34))
}
_ => (area.width.min(84), area.height.min(24)),
}
}
}
_ => (area.width.min(80), area.height.min(24)),
};
let overlay = centered_rect(overlay_width, overlay_height, area);
frame.render_widget(Clear, overlay);
let dialog_title = match dialog {
ConnectDialog::ProviderPicker { .. } => "Connect provider".to_string(),
ConnectDialog::ApiKey { provider_id } => {
let label = self
.config
.provider_display_name(provider_id)
.unwrap_or(provider_id)
.to_string();
format!("API key · {label}")
}
ConnectDialog::NewProvider { step, .. } => {
format!("Create provider · {}", step.title())
}
ConnectDialog::EditProvider {
provider_id,
step,
model_step,
..
} => {
if let Some(model_step) = model_step {
format!("Edit model · {provider_id} · {}", model_step.title())
} else {
format!("Edit provider · {provider_id} · {}", step.title())
}
}
};
let panel = Block::default()
.style(Style::default().bg(palette.panel))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()))
.title(dialog_title);
frame.render_widget(panel, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
match dialog {
ConnectDialog::ProviderPicker { selected } => {
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(6),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new("Type to filter by provider id or display name. Press Ctrl+E to edit custom providers.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[0],
);
self.render_input_block(
frame,
sections[1],
"Search",
self.composer.placeholder(),
false,
);
let items = self.provider_picker_items();
let list_items = items
.iter()
.map(|item| match item {
ProviderPickerItem::Provider {
provider_id,
display_name,
source,
connected,
} => {
let status_style = if *connected {
Style::default().fg(palette.success)
} else {
Style::default().fg(palette.muted)
};
let source_label = match source {
ProviderSource::Bundled => "preset",
ProviderSource::User => "custom",
};
ListItem::new(Line::from(vec![
Span::styled(
display_name.to_string(),
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({provider_id})"),
Style::default().fg(palette.muted),
),
Span::raw(" "),
Span::styled(
format!("[{source_label}]"),
Style::default().fg(palette.accent_soft),
),
Span::raw(" "),
Span::styled(
if *connected {
"connected"
} else {
"not connected"
},
status_style,
),
]))
}
ProviderPickerItem::AddNew { query } => {
let label = if query.is_empty() {
"Add new provider".to_string()
} else {
format!("Add new provider: {query}")
};
ListItem::new(Line::from(vec![
Span::styled(
label,
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
"Create a new OpenAI-compatible provider",
Style::default().fg(palette.warning),
),
]))
}
})
.collect::<Vec<_>>();
let mut state = ListState::default();
state.select(Some((*selected).min(items.len().saturating_sub(1))));
let list = List::new(list_items)
.style(Style::default().bg(palette.panel).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, sections[2], &mut state);
frame.render_widget(
Paragraph::new(
"Enter to connect · Ctrl+E to edit custom providers · Esc to cancel",
)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[3],
);
}
ConnectDialog::ApiKey { provider_id } => {
let label = self
.config
.provider_display_name(provider_id)
.unwrap_or(provider_id)
.to_string();
let lines = Layout::vertical([
Constraint::Length(2),
Constraint::Length(1),
Constraint::Length(4),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!("Enter API key for {label}"))
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel)
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
lines[0],
);
frame.render_widget(
Paragraph::new(
"The key will be stored in auth.json and used for future requests.",
)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[1],
);
self.render_input_block(
frame,
lines[2],
"API Key",
self.composer.placeholder(),
true,
);
frame.render_widget(
Paragraph::new("Enter to save · Esc to cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[3],
);
}
ConnectDialog::NewProvider { step, draft: _ } => {
let lines = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(4),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!("Create provider · {}", step.title()))
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel)
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
lines[0],
);
frame.render_widget(
Paragraph::new(step.help())
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[1],
);
self.render_input_block(
frame,
lines[2],
step.label(),
self.composer.placeholder(),
step.is_secret(),
);
let prompt_line = if matches!(step, NewProviderStep::AddAnotherModel) {
"y to add another model · Enter to save provider".to_string()
} else {
format!(
"Next: {}",
step.next()
.map(|next| next.label())
.unwrap_or("Save provider")
)
};
frame.render_widget(
Paragraph::new(prompt_line)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
lines[3],
);
let footer = if matches!(step, NewProviderStep::AddAnotherModel) {
"Enter to save provider · y to add another model · Esc to cancel"
} else {
"Enter to continue · Esc to cancel"
};
frame.render_widget(
Paragraph::new(footer)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[4],
);
}
ConnectDialog::EditProvider {
provider_id,
step,
model_step,
draft,
} => {
if let Some(model_step) = model_step {
let lines = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(4),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let provider_label = self
.config
.provider_display_name(provider_id)
.unwrap_or(provider_id)
.to_string();
frame.render_widget(
Paragraph::new(format!(
"Edit model for {provider_label} · {}",
model_step.title()
))
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel)
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
lines[0],
);
frame.render_widget(
Paragraph::new(model_step.help())
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[1],
);
self.render_input_block(
frame,
lines[2],
model_step.label(),
model_step.placeholder(),
model_step.is_secret(),
);
frame.render_widget(
Paragraph::new("Enter to continue · Esc to cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
lines[3],
);
frame.render_widget(
Paragraph::new("Model ids stay fixed while editing existing models")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[4],
);
} else if *step == EditProviderStep::ModelList {
let lines = Layout::vertical([
Constraint::Length(2),
Constraint::Min(8),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!("Manage models for {}", provider_id))
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel)
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
lines[0],
);
let items = draft
.models
.iter()
.enumerate()
.map(|(index, (model_id, model))| {
let is_selected = index == draft.selected_model_index;
let status_style = if is_selected {
Style::default().fg(palette.selection_fg)
} else {
Style::default().fg(palette.muted)
};
ListItem::new(Line::from(vec![
Span::styled(
model.display_name.to_string(),
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({model_id})"),
Style::default().fg(palette.muted),
),
Span::raw(" "),
Span::styled(format!("ctx {}", model.context_window), status_style),
Span::raw(" "),
Span::styled(
format!("max {}", model.max_output_tokens),
status_style,
),
]))
})
.collect::<Vec<_>>();
let mut state = ListState::default();
state.select(Some(
draft
.selected_model_index
.min(items.len().saturating_sub(1)),
));
let list = List::new(items)
.style(Style::default().bg(palette.panel).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, lines[1], &mut state);
frame.render_widget(
Paragraph::new("Enter edit · N new · D delete · S save · Esc cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
lines[2],
);
} else if *step == EditProviderStep::ConfirmDeleteModel {
let lines = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(4),
Constraint::Length(1),
])
.split(inner);
let pending = draft
.pending_delete_model_id
.as_deref()
.unwrap_or("unknown model");
frame.render_widget(
Paragraph::new(format!("Delete model {pending}?"))
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel)
.fg(palette.error)
.add_modifier(Modifier::BOLD),
),
lines[0],
);
frame.render_widget(
Paragraph::new("This only removes the model from config.toml. Historical sessions keep their stored snapshot.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[1],
);
self.render_input_block(frame, lines[2], "Confirm", "y or n", false);
frame.render_widget(
Paragraph::new("Y to delete · N / Esc to keep")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
lines[3],
);
} else {
let lines = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(4),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!("Edit provider · {}", provider_id))
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel)
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
lines[0],
);
frame.render_widget(
Paragraph::new(step.help())
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[1],
);
self.render_input_block(
frame,
lines[2],
step.label(),
step.placeholder(),
step.is_secret(),
);
frame.render_widget(
Paragraph::new("Enter to continue · Esc to cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
lines[3],
);
frame.render_widget(
Paragraph::new("After the fields, manage models from the list")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
lines[4],
);
}
}
}
}
pub(super) fn render_theme_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &ThemePanelState,
) {
let current_palette = self.palette();
let overlay = centered_rect(40, 18, area);
let themes = ThemePanelState::themes();
let items: Vec<ListItem> = themes
.iter()
.map(|theme| {
ListItem::new(Line::from(vec![Span::styled(
format!(" {} ", theme.as_str()),
Style::default()
.fg(current_palette.text)
.add_modifier(Modifier::BOLD),
)]))
})
.collect();
let mut state = ListState::default();
state.select(Some(panel.selected_index));
let panel_block = Block::default()
.style(Style::default().bg(current_palette.panel_alt))
.title(" Theme ")
.borders(Borders::ALL)
.border_style(Style::default().fg(current_palette.border_active()));
let list = List::new(items)
.style(
Style::default()
.bg(current_palette.panel_alt)
.fg(current_palette.text),
)
.highlight_style(
Style::default()
.bg(current_palette.selection_bg)
.fg(current_palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(Clear, overlay);
frame.render_widget(panel_block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
frame.render_stateful_widget(list, inner, &mut state);
}
pub(super) fn render_agents_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &AgentsPanelState,
) {
let palette = self.palette();
let overlay = centered_rect(70, 24, area);
frame.render_widget(Clear, overlay);
let panel_block = Block::default()
.style(Style::default().bg(palette.panel))
.title(" Agents ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(panel_block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let header = Line::from(vec![
Span::styled(
" Agent",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
"Description",
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(Paragraph::new(header).style(Style::default().bg(palette.panel)), inner);
let divider = Line::from(Span::styled(
"─".repeat(inner.width as usize),
Style::default().fg(palette.muted),
));
let sections = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
frame.render_widget(
Paragraph::new(divider).style(Style::default().bg(palette.panel)),
sections[1],
);
let mut lines: Vec<Line<'_>> = Vec::new();
for agent in &panel.agents {
let tag = if agent.read_only { " [read-only]" } else { "" };
lines.push(Line::from(vec![
Span::styled(
format!(" @{}", agent.display_name),
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}{}", agent.description, tag),
Style::default().fg(palette.muted),
),
]));
}
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
" Press Esc or q to close",
Style::default().fg(palette.muted),
)));
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(palette.panel)),
sections[2],
);
}
pub(super) fn render_settings_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &SettingsPanelState,
) {
use crate::app::ui::settings_panel::SettingType;
let current_palette = self.palette();
let overlay = centered_rect(60, 12, area);
let items: Vec<ListItem> = panel
.items
.iter()
.map(|item| {
let status = match item.setting_type {
SettingType::Toggle(true) => "[x]",
SettingType::Toggle(false) => "[ ]",
SettingType::Number { .. } => "[~]",
};
ListItem::new(vec![
Line::from(vec![
Span::styled(
format!(" {} ", status),
Style::default()
.fg(match item.setting_type {
SettingType::Toggle(true) => current_palette.accent,
_ => current_palette.muted,
})
.add_modifier(Modifier::BOLD),
),
Span::styled(
&item.name,
Style::default()
.fg(current_palette.text)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
&item.description,
Style::default().fg(current_palette.muted),
),
]),
])
})
.collect();
let mut state = ListState::default();
state.select(Some(panel.selected_index));
let panel_block = Block::default()
.style(Style::default().bg(current_palette.panel_alt))
.title(" Settings ")
.borders(Borders::ALL)
.border_style(Style::default().fg(current_palette.border_active()));
let list = List::new(items)
.style(
Style::default()
.bg(current_palette.panel_alt)
.fg(current_palette.text),
)
.highlight_style(
Style::default()
.bg(current_palette.selection_bg)
.fg(current_palette.selection_fg),
);
frame.render_widget(Clear, overlay);
frame.render_widget(panel_block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
frame.render_stateful_widget(list, inner, &mut state);
}
pub(super) fn render_session_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &SessionPanelState,
) {
let palette = self.palette();
let overlay = centered_rect(area.width.min(112), area.height.min(36), area);
frame.render_widget(Clear, overlay);
let view_mode_text = match panel.view_mode {
SessionViewMode::CurrentWorkspace => "Current Workspace",
SessionViewMode::AllSessions => "All Sessions",
};
let title_text =
if panel.operation_mode == crate::app::session_panel::OperationMode::MultiSelect {
format!(
" Sessions: {} ({} selected) ",
view_mode_text,
panel.selected_count()
)
} else {
format!(" Sessions: {} ", view_mode_text)
};
let title = Block::default()
.style(Style::default().bg(palette.panel))
.title(title_text)
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(title, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new("Type to filter by title, model, provider, or session id.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[0],
);
self.render_input_block(
frame,
sections[1],
"Search sessions",
self.composer.placeholder(),
false,
);
let query = self.composer.text().to_string();
let matches = panel.matching_indices(&query);
let is_multi_select =
panel.operation_mode == crate::app::session_panel::OperationMode::MultiSelect;
if matches.is_empty() {
frame.render_widget(
Paragraph::new("No sessions match this search.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[2],
);
} else {
let mut items: Vec<ListItem> = Vec::new();
let mut current_workspace = String::new();
for index in matches.iter() {
let session = &panel.sessions[*index];
if panel.view_mode == SessionViewMode::AllSessions
&& session.workspace_root != current_workspace
{
if !current_workspace.is_empty() {
items.push(ListItem::new(Line::from("")));
}
current_workspace = session.workspace_root.clone();
items.push(ListItem::new(Line::from(vec![Span::styled(
format!("[ {} ]", session.workspace_root),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
)])));
}
let is_current = session.session_id == self.conversation.session_id;
let updated_at = session.updated_at.format("%Y-%m-%d %H:%M").to_string();
let is_selected = panel.is_selected(*index);
let checkbox = if is_multi_select {
if is_selected { "[✓] " } else { "[ ] " }
} else {
""
};
let mut spans = vec![
Span::raw(checkbox),
Span::styled(
shorten(&session.title, 24),
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({})", session.session_id.simple()),
Style::default().fg(palette.muted),
),
Span::raw(" "),
Span::styled(
format!(
"{} / {}",
shorten(&session.provider_display_name, 12),
shorten(&session.model_display_name, 14)
),
Style::default().fg(palette.accent_soft),
),
Span::raw(" "),
Span::styled(updated_at, Style::default().fg(palette.muted)),
];
if is_current {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"current",
Style::default().fg(palette.success),
));
}
if session.parent_session_id.is_some() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"child",
Style::default().fg(palette.accent_soft),
));
}
items.push(ListItem::new(Line::from(spans)));
}
let mut state = ListState::default();
state.select(Some(
panel.selected_index.min(matches.len().saturating_sub(1)),
));
let list = List::new(items)
.style(Style::default().bg(palette.panel).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, sections[2], &mut state);
}
let help_text = if panel.operation_mode
== crate::app::session_panel::OperationMode::MultiSelect
{
"Enter/D: switch/delete · Space: select · Ctrl+A: exit multi-select · Tab: switch view · C: cleanup · E: export"
} else {
"Enter: switch · D: delete · C: cleanup · Ctrl+A: multi-select · Tab: switch view · W: all sessions · E: export"
};
frame.render_widget(
Paragraph::new(help_text)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[3],
);
}
pub(super) fn render_message_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &MessagePanelState,
) {
let palette = self.palette();
let overlay = centered_rect(area.width.min(112), area.height.min(36), area);
frame.render_widget(Clear, overlay);
let title = Block::default()
.style(Style::default().bg(palette.panel))
.title(" User messages ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(title, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(
"Type to filter current session user messages. Enter jumps to the selected message.",
)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[0],
);
self.render_input_block(
frame,
sections[1],
"Search user messages",
self.composer.placeholder(),
false,
);
let query = self.composer.text().to_string();
let matches = panel.matching_indices(&query);
if matches.is_empty() {
frame.render_widget(
Paragraph::new("No user messages match this search.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[2],
);
} else {
let mut items: Vec<ListItem> = Vec::new();
for index in matches.iter() {
let message = &panel.messages[*index];
let timestamp = message.created_at.format("%Y-%m-%d %H:%M").to_string();
let spans = vec![
Span::styled(
timestamp.to_string(),
Style::default().fg(palette.accent_soft),
),
Span::raw(" "),
Span::styled(
shorten(&message.content, 64),
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({})", message.message_id.as_simple()),
Style::default().fg(palette.muted),
),
];
items.push(ListItem::new(Line::from(spans)));
}
let mut state = ListState::default();
state.select(Some(
panel.selected_index.min(matches.len().saturating_sub(1)),
));
let list = List::new(items)
.style(Style::default().bg(palette.panel).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, sections[2], &mut state);
}
frame.render_widget(
Paragraph::new("Enter: jump · Esc: close · Ctrl+P/N: nav")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[3],
);
}
pub(super) fn render_model_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &ModelPanelState,
) {
let palette = self.palette();
let overlay = centered_rect(area.width.min(104), area.height.min(34), area);
frame.render_widget(Clear, overlay);
let title = Block::default()
.style(Style::default().bg(palette.panel))
.title(" Select model ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(title, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(1), Constraint::Length(2), Constraint::Length(3), Constraint::Min(8), Constraint::Length(1), ])
.split(inner);
let tab_spans: Vec<Span> = panel
.tabs
.iter()
.enumerate()
.flat_map(|(idx, tab)| {
let is_active = idx == panel.selected_tab_index;
let tab_style = if is_active {
Style::default()
.fg(palette.selection_fg)
.bg(palette.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.muted)
};
let label = format!(" {} ", tab.display_name);
let mut spans = vec![Span::styled(label, tab_style)];
if idx + 1 < panel.tabs.len() {
spans.push(Span::styled(" │ ", Style::default().fg(palette.border)));
}
spans
})
.collect();
frame.render_widget(
Paragraph::new(Line::from(tab_spans))
.style(Style::default().bg(palette.panel))
.alignment(Alignment::Left),
sections[0],
);
let instruction = if panel.is_general_tab() {
"Select a model for the main session. Enter to switch, Esc to close."
} else {
"Select a model for this agent. Enter to save, Esc to close."
};
frame.render_widget(
Paragraph::new(instruction)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[1],
);
self.render_input_block_with_composer(
frame,
sections[2],
"Search models",
&panel.query,
panel.query.placeholder(),
false,
false,
);
let items = self.model_panel_items(panel);
let active_index = panel.current_tab().and_then(|tab| {
let label = &tab.current_label;
if label == "<inherit>" || label.is_empty() {
items.iter().position(|item| {
matches!(item, ModelPanelItem::Model { summary }
if summary.provider_id == self.active_model.provider_id
&& summary.model_id == self.active_model.model_id)
})
} else if let Some(slash_pos) = label.find('/') {
let p = &label[..slash_pos];
let m = &label[slash_pos + 1..];
items.iter().position(|item| {
matches!(item, ModelPanelItem::Model { summary }
if summary.provider_id == p && summary.model_id == m)
})
} else {
None
}
});
let mut rows = Vec::new();
for (index, item) in items.iter().enumerate() {
match item {
ModelPanelItem::ProviderHeader {
provider_id,
display_name,
} => {
rows.push(ListItem::new(Line::from(vec![
Span::styled(
display_name.to_string(),
Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({provider_id})"),
Style::default().fg(palette.muted),
),
])));
}
ModelPanelItem::Model { summary } => {
let active_marker = if active_index == Some(index) {
Span::styled("✓ ", Style::default().fg(palette.accent))
} else {
Span::raw(" ")
};
rows.push(ListItem::new(Line::from(vec![
active_marker,
Span::styled(
summary.model_display_name.clone(),
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({})", summary.model_id),
Style::default().fg(palette.muted),
),
Span::raw(" "),
Span::styled(
format!(
"{} · max {}",
summary.provider_display_name, summary.max_output_tokens
),
Style::default().fg(palette.accent_soft),
),
])));
}
}
}
if rows.is_empty() {
frame.render_widget(
Paragraph::new("No connected models match this search.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[3],
);
} else {
let sel = panel
.current_tab()
.map(|t| t.selected_index)
.unwrap_or(0)
.min(items.len().saturating_sub(1));
let mut state = ListState::default();
state.select(Some(sel));
let list = List::new(rows)
.style(Style::default().bg(palette.panel).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, sections[3], &mut state);
}
let footer = "Enter apply · Ctrl+E edit provider · Tab switch tab · Esc close";
frame.render_widget(
Paragraph::new(footer)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[4],
);
}
pub(super) fn render_mcp_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &McpPanelState,
) {
let palette = self.palette();
let has_editor = panel.editor.is_some();
let overlay = centered_rect(
area.width.min(112),
if has_editor {
area.height.min(42)
} else {
area.height.min(34)
},
area,
);
frame.render_widget(Clear, overlay);
let title_text = panel
.editor
.as_ref()
.map(McpServerEditorState::title)
.unwrap_or_else(|| " MCP servers ".to_string());
let title = Block::default()
.style(Style::default().bg(palette.panel))
.title(title_text)
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(title, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
if let Some(editor) = &panel.editor {
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(editor.help())
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[0],
);
self.render_input_block(
frame,
sections[1],
editor.step_label(),
self.composer.placeholder(),
false,
);
frame.render_widget(
Paragraph::new(editor.draft.summary_text())
.style(Style::default().bg(palette.panel).fg(palette.text))
.wrap(Wrap { trim: false }),
sections[2],
);
frame.render_widget(
Paragraph::new("Enter advance/save · Tab advance/save · Esc cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
sections[3],
);
} else {
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new("Type to filter by server name, transport, or status. Enter toggles connect/disconnect.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[0],
);
self.render_input_block(
frame,
sections[1],
"Search MCP servers",
self.composer.placeholder(),
false,
);
let items = self.mcp_panel_items();
let mut rows = Vec::new();
for item in &items {
let summary = &item.summary;
rows.push(ListItem::new(Line::from(vec![
Span::styled(
summary.name.clone(),
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("({})", summary.kind),
Style::default().fg(palette.muted),
),
Span::raw(" "),
Span::styled(
summary.status_text(),
Style::default().fg(match summary.status.label() {
"connected" => palette.success,
"connecting" => palette.warning,
"failed" => palette.error,
_ => palette.muted,
}),
),
Span::raw(" "),
Span::styled(
format!("{} tools", summary.tool_count),
Style::default().fg(palette.accent_soft),
),
])));
}
if rows.is_empty() {
frame.render_widget(
Paragraph::new("No MCP servers match this search.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[2],
);
} else {
let mut state = ListState::default();
state.select(Some(
panel.selected_index.min(items.len().saturating_sub(1)),
));
let list = List::new(rows)
.style(Style::default().bg(palette.panel).fg(palette.text))
.highlight_style(
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, sections[2], &mut state);
}
frame.render_widget(
Paragraph::new(
"Enter connect/disconnect · a add · e edit · d remove · R refresh · Esc close",
)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
sections[3],
);
}
}
pub(super) fn render_permission_dialog(
&self,
frame: &mut Frame<'_>,
area: Rect,
dialog: &PermissionDialogState,
) {
let palette = self.palette();
let preview = pretty_tool_arguments(&dialog.tool_call.arguments);
let preview_height = preview.lines().count().min(8) as u16;
let overlay = centered_rect(area.width.min(96), preview_height.saturating_add(10), area);
frame.render_widget(Clear, overlay);
let block = Block::default()
.style(Style::default().bg(palette.panel_alt))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()))
.title(" Tool approval ");
frame.render_widget(block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
frame.render_widget(
Paragraph::new(dialog.title())
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel_alt)
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
sections[0],
);
frame.render_widget(
Paragraph::new(
"This tool can change state. Review the arguments and choose whether to allow it.",
)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel_alt).fg(palette.muted)),
sections[1],
);
frame.render_widget(
Paragraph::new(preview)
.style(Style::default().bg(palette.panel_alt).fg(palette.text))
.wrap(Wrap { trim: false }),
sections[2],
);
frame.render_widget(
Paragraph::new(
"Y allow · N deny · R allow and remember · X deny and remember · Esc deny",
)
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel_alt)
.fg(palette.accent_soft),
),
sections[3],
);
}
pub(super) fn render_workspace_boundary_dialog(
&self,
frame: &mut Frame<'_>,
area: Rect,
dialog: &WorkspaceBoundaryDialogState,
) {
let palette = self.palette();
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
frame.render_widget(Clear, area);
let block = Block::default()
.style(Style::default().bg(palette.panel_alt))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.error))
.title(format!(" {} ", dialog.title()));
frame.render_widget(block, area);
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new("A tool is trying to access a path outside the workspace:")
.style(Style::default().bg(palette.panel_alt).fg(palette.text)),
sections[0],
);
let path_text = format!(
"Requested: {}\nWorkspace: {}",
dialog.path_display(),
dialog.workspace_display()
);
frame.render_widget(
Paragraph::new(path_text)
.style(Style::default().bg(palette.panel_alt).fg(palette.accent_soft)),
sections[1],
);
frame.render_widget(
Paragraph::new("Y allow once · A allow until exit · N deny once · D deny until exit · Esc deny once")
.style(
Style::default()
.bg(palette.panel_alt)
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
),
sections[2],
);
}
pub(super) fn render_question_dialog(
&self,
frame: &mut Frame<'_>,
area: Rect,
dialog: &QuestionDialogState,
) {
let palette = self.palette();
let inner = area.inner(Margin {
horizontal: 1,
vertical: 1,
});
let options_lines = dialog.options_lines(inner.width);
let options_text = options_lines.join("\n");
frame.render_widget(Clear, area);
let block = Block::default()
.style(Style::default().bg(palette.panel_alt))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()))
.title(" Question prompt ");
frame.render_widget(block, area);
let options_height = options_lines.len().max(2) as u16;
let sections = if dialog.editing_custom {
let available_input_height = inner
.height
.saturating_sub(options_height.saturating_add(6));
let input_height = self
.composer
.preferred_height(
inner.width.saturating_sub(4),
self.config.ui.max_input_lines,
)
.min(available_input_height.max(3));
Layout::vertical([
Constraint::Length(1),
Constraint::Min(2),
Constraint::Min(options_height),
Constraint::Min(input_height),
Constraint::Length(1),
])
.split(inner)
} else {
Layout::vertical([
Constraint::Length(1),
Constraint::Min(2),
Constraint::Min(options_height),
Constraint::Length(1),
])
.split(inner)
};
let footer_text = if dialog.editing_custom {
"Enter save custom answer · Esc cancel · Ctrl+P/Ctrl+N/←/→ previous/next"
} else {
"Enter select · Space toggle · Ctrl+P/Ctrl+N/←/→ previous/next · Esc dismiss"
};
frame.render_widget(
Paragraph::new(dialog.title())
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel_alt)
.fg(palette.text)
.add_modifier(Modifier::BOLD),
),
sections[0],
);
frame.render_widget(
Paragraph::new(dialog.body_title())
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel_alt).fg(palette.muted)),
sections[1],
);
frame.render_widget(
Paragraph::new(options_text)
.style(Style::default().bg(palette.panel_alt).fg(palette.text)),
sections[2],
);
if dialog.editing_custom {
self.render_input_block(
frame,
sections[3],
"Answer",
&dialog.answer_placeholder(),
false,
);
}
frame.render_widget(
Paragraph::new(footer_text)
.alignment(Alignment::Center)
.style(
Style::default()
.bg(palette.panel_alt)
.fg(palette.accent_soft),
),
if dialog.editing_custom {
sections[4]
} else {
sections[3]
},
);
}
pub(super) fn render_session_panel_dialog(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &SessionPanelState,
) {
let palette = self.palette();
match &panel.dialog {
SessionPanelDialog::None => {}
SessionPanelDialog::DeleteConfirm {
session_ids,
session_titles,
} => {
let overlay = centered_rect(60, 20, area);
frame.render_widget(Clear, overlay);
let block = Block::default()
.style(Style::default().bg(palette.panel))
.title(" Confirm Delete ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!("Delete {} session(s)?", session_ids.len()))
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.text)),
sections[0],
);
let mut content = String::new();
for title in session_titles.iter().take(5) {
content.push_str(&format!(" • {}\n", title));
}
if session_titles.len() > 5 {
content.push_str(&format!(" ... and {} more\n", session_titles.len() - 5));
}
frame.render_widget(
Paragraph::new(content)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[1],
);
frame.render_widget(
Paragraph::new("Enter: confirm · Esc: cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
sections[2],
);
}
SessionPanelDialog::ExportConfirm {
session_ids,
session_titles,
} => {
let overlay = centered_rect(60, 20, area);
frame.render_widget(Clear, overlay);
let block = Block::default()
.style(Style::default().bg(palette.panel))
.title(" Confirm Export ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!("Export {} session(s) to JSONL?", session_ids.len()))
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.text)),
sections[0],
);
let mut content = String::new();
for title in session_titles.iter().take(5) {
content.push_str(&format!(" • {}\n", title));
}
if session_titles.len() > 5 {
content.push_str(&format!(" ... and {} more\n", session_titles.len() - 5));
}
frame.render_widget(
Paragraph::new(content)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[1],
);
frame.render_widget(
Paragraph::new("Enter: export · Esc: cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
sections[2],
);
}
SessionPanelDialog::Cleanup {
preview,
selected_duration,
cleanup_workspace,
} => {
let overlay = centered_rect(70, 25, area);
frame.render_widget(Clear, overlay);
let block = Block::default()
.style(Style::default().bg(palette.panel))
.title(" Cleanup Old Sessions ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
Constraint::Min(6),
Constraint::Length(3),
])
.split(inner);
let (title_text, hint_text) = if *cleanup_workspace {
(
"Delete all sessions in current workspace".to_string(),
"5: current workspace (selected)".to_string(),
)
} else {
let duration_text = match selected_duration {
Some(d) if *d <= chrono::Duration::weeks(1) => "1 week",
Some(d) if *d <= chrono::Duration::days(30) => "1 month",
Some(d) if *d <= chrono::Duration::days(90) => "3 months",
Some(d) if *d <= chrono::Duration::days(365) => "1 year",
None => "Select duration",
_ => "Custom",
};
(
format!("Delete sessions older than: {}", duration_text),
"1: 1 week · 2: 1 month · 3: 3 months · 4: 1 year · 5: current workspace"
.to_string(),
)
};
frame.render_widget(
Paragraph::new(title_text)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.text)),
sections[0],
);
frame.render_widget(
Paragraph::new(hint_text)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[1],
);
frame.render_widget(
Paragraph::new(format!(
"Preview: {} session(s) will be deleted",
preview.total_count
))
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
sections[2],
);
let mut content = String::new();
for (workspace, count) in preview.workspace_counts.iter().take(5) {
content.push_str(&format!(" {} ({} sessions)\n", workspace, count));
}
frame.render_widget(
Paragraph::new(content)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[3],
);
frame.render_widget(
Paragraph::new("Enter: confirm · Esc: cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
sections[4],
);
}
}
}
pub(super) fn render_rename_session_dialog(
&self,
frame: &mut Frame<'_>,
area: Rect,
dialog: &RenameSessionDialogState,
) {
let palette = self.palette();
let overlay = centered_rect(60, 12, area);
frame.render_widget(Clear, overlay);
let block = Block::default()
.style(Style::default().bg(palette.panel))
.title(dialog.title())
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
let sections = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(dialog.description())
.style(Style::default().bg(palette.panel).fg(palette.text)),
sections[0],
);
frame.render_widget(
Paragraph::new("Press Enter to save, Esc to cancel")
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[1],
);
frame.render_widget(
Paragraph::new(self.composer.text())
.style(Style::default().bg(palette.panel).fg(palette.text))
.wrap(Wrap { trim: false }),
sections[3],
);
}
pub(super) fn render_memory_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &MemoryPanelState,
) {
let palette = self.palette();
let filtered = panel.filtered_indices();
let overlay = centered_rect(
area.width.min(96),
area.height.min(36),
area,
);
frame.render_widget(Clear, overlay);
let title_block = Block::default()
.style(Style::default().bg(palette.panel))
.title(" Memories ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(title_block, overlay);
let inner = overlay.inner(Margin { horizontal: 1, vertical: 1 });
match panel.mode {
MemoryPanelMode::Browse => {
let sections = Layout::vertical([
Constraint::Length(1), Constraint::Min(6), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
let filter_text = match panel.filter_type {
None => "All types".to_string(),
Some(t) => format!("Type: {}", t.as_str()),
};
frame.render_widget(
Paragraph::new(filter_text)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[0],
);
if filtered.is_empty() {
frame.render_widget(
Paragraph::new("No memories yet. Press 'a' to add one.")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[1],
);
} else {
let items: Vec<ListItem> = filtered
.iter()
.enumerate()
.map(|(list_idx, &mem_idx)| {
let entry = &panel.memories[mem_idx];
let is_selected = list_idx == panel.selected_index;
let prefix = if is_selected { "▸ " } else { " " };
let type_label = entry.memory_type.short_label();
let preview: String = entry.content.chars().take(80).collect();
let suffix = if entry.content.len() > 80 { "…" } else { "" };
let text = format!("{}[{}] {} – {}{}", prefix, type_label, entry.title, preview, suffix);
let style = if is_selected {
Style::default()
.fg(palette.accent)
.bg(palette.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().bg(palette.panel).fg(palette.text)
};
ListItem::new(text).style(style)
})
.collect();
let list = List::new(items);
frame.render_widget(list, sections[1]);
}
frame.render_widget(
Paragraph::new(format!("{} / {} memories", filtered.len(), panel.memories.len()))
.alignment(Alignment::Right)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[2],
);
frame.render_widget(
Paragraph::new("↑↓ navigate · a add · e edit · d delete · r filter type · Esc close")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent_soft)),
sections[3],
);
}
MemoryPanelMode::Add | MemoryPanelMode::Edit => {
let label = match panel.mode {
MemoryPanelMode::Add => "Add Memory",
MemoryPanelMode::Edit => "Edit Memory",
_ => unreachable!(),
};
let sections = Layout::vertical([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Min(8), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
frame.render_widget(
Paragraph::new(label)
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.accent)),
sections[0],
);
frame.render_widget(
Paragraph::new(format!("Type: {}", panel.edit_type.as_str()))
.style(Style::default().bg(palette.panel).fg(palette.text)),
sections[1],
);
frame.render_widget(
Paragraph::new(format!("Title: {}", panel.edit_title))
.style(Style::default().bg(palette.panel).fg(palette.text))
.wrap(Wrap { trim: false }),
sections[2],
);
frame.render_widget(
Paragraph::new(if panel.edit_content.is_empty() {
"Content: (type in input box below)"
} else {
&panel.edit_content
})
.style(Style::default().bg(palette.panel).fg(palette.text))
.wrap(Wrap { trim: false }),
sections[3],
);
frame.render_widget(
Paragraph::new(format!("Tags: {}", panel.edit_tags))
.style(Style::default().bg(palette.panel).fg(palette.text)),
sections[4],
);
frame.render_widget(
Paragraph::new("Tab: cycle type · Enter: save · Esc: cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[5],
);
}
MemoryPanelMode::DeleteConfirm => {
let sections = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
if let Some(entry) = panel.selected_entry() {
frame.render_widget(
Paragraph::new(format!("Delete memory: {}?", entry.title))
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.warning)),
sections[0],
);
}
frame.render_widget(
Paragraph::new("Press Y to confirm, N or Esc to cancel")
.alignment(Alignment::Center)
.style(Style::default().bg(palette.panel).fg(palette.muted)),
sections[1],
);
}
}
}
}
fn pretty_tool_arguments(arguments: &str) -> String {
match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| arguments.to_string()),
Err(_) => arguments.to_string(),
}
}
impl App {
pub(super) fn render_skills_panel(
&self,
frame: &mut Frame<'_>,
area: Rect,
panel: &SkillsPanelState,
) {
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
let palette = self.palette();
let overlay = centered_rect(85, 80, area);
frame.render_widget(Clear, overlay);
let title = if panel.is_empty() {
" Skills ".to_string()
} else {
format!(" Skills · {}/{} ", panel.selected_index + 1, panel.filtered_count())
};
let panel_block = Block::default()
.style(Style::default().bg(palette.panel))
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border_active()));
frame.render_widget(panel_block, overlay);
let inner = overlay.inner(Margin {
horizontal: 1,
vertical: 1,
});
self.register_selection_region(inner);
if panel.is_empty() {
let empty_text = vec![
Line::from(""),
Line::from(Span::styled(
" No skills discovered",
Style::default().fg(palette.muted),
)),
Line::from(""),
Line::from(Span::styled(
" Create .opencode/skills/SKILL.md to add skills",
Style::default().fg(palette.muted),
)),
Line::from(""),
Line::from(Span::styled(
" Press Esc or q to close",
Style::default().fg(palette.muted),
)),
];
frame.render_widget(
Paragraph::new(empty_text).style(Style::default().bg(palette.panel)),
inner,
);
return;
}
let panes = Layout::horizontal([
Constraint::Percentage(35),
Constraint::Percentage(65),
])
.split(inner);
let list_area = panes[0];
let preview_area = panes[1];
let search_status = if panel.query_active {
format!("Search: {}_", panel.query)
} else if !panel.query.is_empty() {
format!("Filter: {} (press / to edit)", panel.query)
} else {
"Press / to search".to_string()
};
let header_lines = vec![
Line::from(vec![
Span::styled(" Name", Style::default().fg(palette.accent).add_modifier(Modifier::BOLD)),
]),
Line::from(Span::styled(
format!(" {}", search_status),
Style::default().fg(palette.muted),
)),
];
let header_height = header_lines.len() as u16;
frame.render_widget(
Paragraph::new(header_lines).style(Style::default().bg(palette.panel)),
Rect::new(list_area.x, list_area.y, list_area.width, header_height),
);
let divider_y = list_area.y + header_height;
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"─".repeat(list_area.width as usize),
Style::default().fg(palette.muted),
)))
.style(Style::default().bg(palette.panel)),
Rect::new(list_area.x, divider_y, list_area.width, 1),
);
let list_start_y = divider_y + 1;
let list_content_height = list_area.height.saturating_sub(header_height + 1);
let list_content_area = Rect::new(list_area.x, list_start_y, list_area.width, list_content_height);
let mut list_lines: Vec<Line<'_>> = Vec::new();
let visible_start = panel.list_scroll;
let visible_end = (panel.list_scroll + list_content_height as usize)
.min(panel.filtered_indices.len());
for (i, skill_idx) in panel.filtered_indices.iter().enumerate().skip(visible_start).take(visible_end - visible_start) {
let skill = &panel.all_skills[*skill_idx];
let is_selected = i == panel.selected_index;
let icon = "📁 ";
let name_style = if is_selected {
Style::default()
.bg(palette.selection_bg)
.fg(palette.selection_fg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(palette.text)
};
let line = Line::from(vec![
Span::styled(icon, name_style),
Span::styled(&skill.name, name_style),
]);
list_lines.push(line);
}
while list_lines.len() < list_content_height as usize {
list_lines.push(Line::from(""));
}
frame.render_widget(
Paragraph::new(list_lines).style(Style::default().bg(palette.panel)),
list_content_area,
);
let preview_header = vec![
Line::from(vec![
Span::styled(" Preview", Style::default().fg(palette.accent).add_modifier(Modifier::BOLD)),
]),
];
frame.render_widget(
Paragraph::new(preview_header).style(Style::default().bg(palette.panel)),
Rect::new(preview_area.x, preview_area.y, preview_area.width, 1),
);
let preview_divider_y = preview_area.y + 1;
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"─".repeat(preview_area.width as usize),
Style::default().fg(palette.muted),
)))
.style(Style::default().bg(palette.panel)),
Rect::new(preview_area.x, preview_divider_y, preview_area.width, 1),
);
let preview_content_y = preview_divider_y + 1;
let preview_content_height = preview_area.height.saturating_sub(2);
let preview_content_area = Rect::new(preview_area.x, preview_content_y, preview_area.width, preview_content_height);
if let Some(skill) = panel.selected_skill() {
let content = self.tools.skills().render_skill(&skill.name).unwrap_or_default();
let content_width = preview_content_area.width.saturating_sub(2) as usize;
let rendered = render_markdown_text_with_width_and_cwd(&content, Some(content_width), None);
let scroll = panel.preview_scroll;
let visible_lines: Vec<Line<'_>> = rendered
.into_iter()
.skip(scroll)
.take(preview_content_height as usize)
.collect();
frame.render_widget(
Paragraph::new(visible_lines).style(Style::default().bg(palette.panel)),
preview_content_area,
);
}
let footer_y = inner.y + inner.height - 1;
let hints = if panel.query_active {
"Enter: confirm search • Esc: cancel"
} else {
"↑/↓: navigate • ←/→: scroll preview • /: search • c: copy • Esc: close"
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {}", hints),
Style::default().fg(palette.muted),
)))
.style(Style::default().bg(palette.panel)),
Rect::new(inner.x, footer_y, inner.width, 1),
);
}
}