use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, Wrap},
Frame,
};
use crate::canonical::{EventBlock, EventRole, SessionEvent};
use crate::core::{SessionGroup, SessionItem};
use crate::tui::app::{
provider_label, provider_tabs, ActionDialog, ActionField, ActionResult, App, AppResult,
MainFocus, SessionAction, ACTION_OPTIONS, SEARCH_SCOPE_OPTIONS,
};
use crate::tui::theme::{self, Theme};
pub fn draw(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
draw_provider_tabs(frame, app, chunks[0], theme);
draw_session_table(frame, app, chunks[1], theme);
if app.detail_modal_open {
draw_detail_modal(frame, app, area, theme);
} else if app.action_modal_open {
draw_action_modal(frame, app, area, theme);
} else if app.search_modal_open {
draw_search_modal(frame, app, area, theme);
}
}
fn draw_provider_tabs(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let tabs = provider_tabs(app.language());
draw_chip_row(
frame,
"Providers",
&tabs,
app.selected_provider_tab,
false,
area,
theme,
);
}
fn draw_session_table(frame: &mut Frame, app: &mut App, area: Rect, theme: &Theme) {
let total_items = app.session_count();
if total_items == 0 {
app.table_state.select(None);
let empty = Paragraph::new("No sessions found in this workspace.")
.style(Style::default().fg(theme.text_dim).bg(theme.background))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
frame.render_widget(empty, area);
return;
}
if app.table_state.selected().is_none() {
app.table_state.select(Some(0));
}
let rows = build_table_rows(
&app.session_groups,
app.table_state.selected(),
app.language(),
theme,
);
let widths = [
Constraint::Length(11),
Constraint::Percentage(28),
Constraint::Length(14),
Constraint::Percentage(36),
Constraint::Length(12),
];
let header = Row::new(vec![
Cell::from("AI"),
Cell::from("Title"),
Cell::from("Session"),
Cell::from("Workspace"),
Cell::from("Active"),
])
.style(
Style::default()
.fg(theme.secondary)
.add_modifier(Modifier::BOLD),
)
.bottom_margin(1);
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::NONE)
.style(Style::default().bg(theme.background)),
)
.style(Style::default().fg(theme.text).bg(theme.background))
.highlight_spacing(HighlightSpacing::Never);
frame.render_stateful_widget(table, area, &mut app.table_state);
}
fn build_table_rows(
groups: &[SessionGroup],
selected_row: Option<usize>,
language: crate::config::UiLanguage,
theme: &Theme,
) -> Vec<Row<'static>> {
let mut rows = Vec::new();
let mut row_index = 0;
for group in groups {
for session in &group.sessions {
let title = session.title.as_deref().unwrap_or("(untitled)");
let dir = session.project_dir.as_deref().unwrap_or("(no dir)");
let time_str = theme::format_relative_time(session.last_active_at, language);
let provider = provider_label(&session.provider_id);
let selected = selected_row == Some(row_index);
let value_style = if selected {
selected_row_style(theme)
} else {
Style::default().fg(theme.text).bg(theme.background)
};
let muted_style = if selected {
selected_row_style(theme)
} else {
Style::default().fg(theme.text_dim).bg(theme.background)
};
let provider_style = if selected {
selected_row_style(theme)
} else {
Style::default()
.fg(theme.provider_color(&group.provider_id))
.bg(theme.background)
};
rows.push(Row::new(vec![
table_cell(provider.to_string(), provider_style, 10, theme),
table_cell(theme::truncate(title, 42), value_style, 24, theme),
table_cell(
theme::truncate(&session.session_id, 12),
muted_style,
12,
theme,
),
table_cell(theme::truncate(dir, 56), muted_style, 32, theme),
table_cell(time_str, muted_style, 10, theme),
]));
row_index += 1;
}
}
rows
}
fn table_cell(
value: String,
value_style: Style,
rule_width: usize,
theme: &Theme,
) -> Cell<'static> {
Cell::from(Text::from(vec![
Line::from(Span::styled(value, value_style)),
Line::from(Span::styled(
"─".repeat(rule_width),
Style::default().fg(theme.border).bg(theme.background),
)),
]))
}
fn draw_chip_row<T: AsRef<str>>(
frame: &mut Frame,
title: &str,
options: &[T],
selected: usize,
focused: bool,
area: Rect,
theme: &Theme,
) {
let mut spans = Vec::new();
for (index, option) in options.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(" "));
}
let style = if index == selected {
highlighted_value_style(theme)
} else {
Style::default().fg(theme.text).bg(theme.surface)
};
spans.push(Span::styled(format!(" {} ", option.as_ref()), style));
}
let row = Paragraph::new(Line::from(spans))
.block(section_block(title, focused, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(row, area);
}
fn draw_action_modal(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let popup_area = centered_rect(84, 82, area);
frame.render_widget(Clear, popup_area);
frame.render_widget(modal_block("Session Actions", theme), popup_area);
let inner = popup_area.inner(Margin {
horizontal: 2,
vertical: 1,
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(2),
])
.split(inner);
let selected = app
.selected_session
.as_ref()
.or_else(|| app.get_selected_session());
draw_session_summary(frame, selected, chunks[0], theme);
draw_action_tabs(frame, app, chunks[1], theme);
if let Some(result) = &app.action_result {
draw_action_result(frame, result, chunks[2], theme);
} else {
draw_action_body(frame, app, chunks[2], theme);
}
let footer = Paragraph::new(action_footer_text(app))
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(footer, chunks[3]);
if let Some(dialog) = app.action_dialog {
draw_action_dialog(frame, app, dialog, area, theme);
}
}
fn draw_action_tabs(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let labels: Vec<&str> = ACTION_OPTIONS
.iter()
.map(|action| action.label(app.language()))
.collect();
draw_chip_row(
frame,
"Actions",
&labels,
app.action_selection,
app.action_field == ActionField::Action,
area,
theme,
);
}
fn draw_action_body(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
match app.current_action() {
SessionAction::Switch => draw_switch_panel(frame, app, area, theme),
SessionAction::Rename => draw_rename_panel(frame, app, area, theme),
SessionAction::Delete => draw_delete_panel(frame, app, area, theme),
SessionAction::Export => draw_export_panel(frame, app, area, theme),
SessionAction::Details => draw_details_panel(frame, app, area, theme),
}
}
fn draw_switch_panel(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(5),
Constraint::Length(4),
Constraint::Min(0),
])
.split(area);
draw_picker_block(
frame,
"Target Agent",
app.selected_target_provider()
.map(provider_label)
.unwrap_or("-"),
"Press Enter to choose the agent to switch into.",
app.action_field == ActionField::TargetAgent,
chunks[0],
theme,
);
draw_picker_block(
frame,
"Workspace",
app.selected_target_workspace()
.as_deref()
.unwrap_or("(no workspace)"),
"Press Enter to choose or edit the target workspace.",
app.action_field == ActionField::TargetWorkspace,
chunks[1],
theme,
);
draw_execute_block(
frame,
"Run Switch",
app.action_field == ActionField::Execute,
chunks[2],
theme,
);
let note = Paragraph::new(
"Memorph writes a new session for the selected agent in the chosen workspace.",
)
.block(section_block("What Happens", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(note, chunks[3]);
}
fn draw_rename_panel(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Length(4),
Constraint::Min(0),
])
.split(area);
draw_picker_block(
frame,
"New Title",
if app.rename_input.is_empty() {
"(empty)"
} else {
&app.rename_input
},
"Type directly in this field.",
app.action_field == ActionField::RenameTitle,
chunks[0],
theme,
);
draw_execute_block(
frame,
"Run Rename",
app.action_field == ActionField::Execute,
chunks[1],
theme,
);
let note = Paragraph::new("Type the new title, then move to Execute and press Enter.")
.block(section_block("How To", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(note, chunks[2]);
}
fn draw_delete_panel(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(4), Constraint::Length(4)])
.split(area);
let warning = Paragraph::new(
"Delete removes the provider session selected in the table. This action cannot be undone.",
)
.block(section_block("Warning", false, theme))
.style(Style::default().fg(theme.warning).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(warning, chunks[0]);
draw_execute_block(
frame,
"Run Delete",
app.action_field == ActionField::Execute,
chunks[1],
theme,
);
}
fn draw_export_panel(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(4),
Constraint::Min(0),
])
.split(area);
draw_picker_block(
frame,
"Output Prefix",
if app.export_output_prefix.is_empty() {
"(empty)"
} else {
&app.export_output_prefix
},
"Type the full output path without the .json suffix.",
app.action_field == ActionField::ExportPath,
chunks[0],
theme,
);
draw_execute_block(
frame,
"Run Export",
app.action_field == ActionField::Execute,
chunks[1],
theme,
);
let info = Paragraph::new(
"Export writes a JSON file to the selected local path and appends the .json suffix automatically.",
)
.block(section_block("Export", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(info, chunks[2]);
}
fn draw_details_panel(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(4), Constraint::Length(4)])
.split(area);
let info = Paragraph::new(
"Open a dedicated session detail popup with metadata and a scrollable message preview.",
)
.block(section_block("Details", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(info, chunks[0]);
draw_execute_block(
frame,
"Open Details",
app.action_field == ActionField::Execute,
chunks[1],
theme,
);
}
fn draw_search_modal(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let popup_area = centered_rect(78, 62, area);
frame.render_widget(Clear, popup_area);
frame.render_widget(modal_block("Search Sessions", theme), popup_area);
let inner = popup_area.inner(Margin {
horizontal: 2,
vertical: 1,
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Length(8),
Constraint::Min(0),
Constraint::Length(2),
])
.split(inner);
draw_search_query(frame, app, chunks[0], theme);
draw_search_scope_tabs(frame, app, chunks[1], theme);
let matches = app.search_matches();
draw_search_results(frame, app, &matches, chunks[2], theme);
draw_search_preview(frame, app, &matches, chunks[3], theme);
let footer = Paragraph::new("Type query ←→ Scope ↑↓ Results Enter Jump Esc Close")
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(footer, chunks[4]);
}
fn draw_detail_modal(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let popup_area = centered_rect(86, 84, area);
frame.render_widget(Clear, popup_area);
frame.render_widget(modal_block("Session Details", theme), popup_area);
let inner = popup_area.inner(Margin {
horizontal: 2,
vertical: 1,
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(4),
Constraint::Min(0),
Constraint::Length(2),
])
.split(inner);
let selected = app.selected_session.as_ref();
draw_session_summary(frame, selected, chunks[0], theme);
draw_detail_metadata(frame, app, chunks[1], theme);
draw_detail_messages(frame, app, chunks[2], theme);
let footer = Paragraph::new("↑↓ Scroll Esc Close")
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(footer, chunks[3]);
}
fn draw_action_dialog(
frame: &mut Frame,
app: &App,
dialog: ActionDialog,
area: Rect,
theme: &Theme,
) {
match dialog {
ActionDialog::TargetAgent => draw_target_agent_dialog(frame, app, area, theme),
ActionDialog::TargetWorkspace => draw_workspace_dialog(frame, app, area, theme),
}
}
fn draw_target_agent_dialog(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let popup_area = centered_rect(42, 58, area);
frame.render_widget(Clear, popup_area);
frame.render_widget(modal_block("Target Agent", theme), popup_area);
let inner = popup_area.inner(Margin {
horizontal: 2,
vertical: 1,
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(5), Constraint::Length(2)])
.split(inner);
let providers = app.target_provider_options();
let selected = app
.switch_target_index
.min(providers.len().saturating_sub(1));
let mut lines = Vec::new();
if providers.is_empty() {
lines.push(Line::from(Span::styled(
"No agent is available for switching.",
Style::default().fg(theme.warning),
)));
} else {
for (index, provider) in providers.iter().enumerate() {
let style = if index == selected {
highlighted_value_style(theme)
} else {
Style::default().fg(theme.text).bg(theme.surface)
};
lines.push(Line::from(Span::styled(
format!(" {} ", provider_label(provider)),
style,
)));
}
}
let body = Paragraph::new(Text::from(lines))
.block(section_block("Agents", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(body, chunks[0]);
let footer = Paragraph::new("↑↓ Select target Enter Save Esc Cancel")
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(footer, chunks[1]);
}
fn draw_workspace_dialog(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let popup_area = centered_rect(80, 68, area);
frame.render_widget(Clear, popup_area);
frame.render_widget(modal_block("Workspace", theme), popup_area);
let inner = popup_area.inner(Margin {
horizontal: 2,
vertical: 1,
});
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Min(8),
Constraint::Length(2),
])
.split(inner);
let input = Paragraph::new(Text::from(vec![
Line::from(Span::styled(
format!(" {} ", app.target_workspace),
highlighted_value_style(theme),
)),
Line::from(Span::styled(
"Type to edit the path directly.",
Style::default().fg(theme.text),
)),
]))
.block(section_block("Workspace Path", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(input, chunks[0]);
let matches = app.filtered_workspace_options();
let selected = app
.workspace_picker_index
.min(matches.len().saturating_sub(1));
let mut lines = Vec::new();
if matches.is_empty() {
lines.push(Line::from(Span::styled(
"No matching saved workspace. Enter saves the typed path.",
Style::default().fg(theme.warning),
)));
} else {
for (index, workspace) in matches.iter().take(6).enumerate() {
if index > 0 {
lines.push(Line::from(""));
}
let style = if index == selected {
highlighted_value_style(theme)
} else {
Style::default().fg(theme.text).bg(theme.surface)
};
lines.push(Line::from(Span::styled(format!(" {} ", workspace), style)));
}
}
let suggestions = Paragraph::new(Text::from(lines))
.block(section_block("Suggestions", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(suggestions, chunks[1]);
let footer = Paragraph::new("Type path ↑↓ Suggestions Enter Save Esc Cancel")
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(footer, chunks[2]);
}
fn draw_session_summary(
frame: &mut Frame,
selected: Option<&SessionItem>,
area: Rect,
theme: &Theme,
) {
let text = if let Some(session) = selected {
Text::from(vec![
Line::from(vec![
Span::styled(
provider_label(&session.provider_id),
Style::default()
.fg(theme.provider_color(&session.provider_id))
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
session.title.as_deref().unwrap_or("(untitled)"),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled("Session", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}", session.session_id)),
]),
Line::from(vec![
Span::styled("Workspace", Style::default().fg(theme.text_dim)),
Span::raw(format!(
" {}",
session.project_dir.as_deref().unwrap_or("(no dir)")
)),
]),
])
} else {
Text::from(Line::from("No session selected."))
};
let summary = Paragraph::new(text)
.block(section_block("Session", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(summary, area);
}
fn draw_detail_metadata(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let Some(session) = &app.loaded_session else {
let placeholder = Paragraph::new("Session metadata is unavailable.")
.block(section_block("Metadata", false, theme))
.style(Style::default().fg(theme.text_dim).bg(theme.surface));
frame.render_widget(placeholder, area);
return;
};
let created = session
.created_at
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "-".to_string());
let active = session
.last_active_at
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "-".to_string());
let text = Text::from(vec![
Line::from(vec![
Span::styled("Created", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}", created)),
Span::raw(" "),
Span::styled("Last Active", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}", active)),
]),
Line::from(vec![
Span::styled("Messages", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}", session.message_count)),
Span::raw(" "),
Span::styled("Source", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}", session.provider_name)),
]),
]);
let block = Paragraph::new(text)
.block(section_block("Metadata", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(block, area);
}
fn draw_detail_messages(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let Some(session) = &app.loaded_session else {
let placeholder = Paragraph::new("Session messages are unavailable.")
.block(section_block("Message Preview", false, theme))
.style(Style::default().fg(theme.text_dim).bg(theme.surface));
frame.render_widget(placeholder, area);
return;
};
let total = session.events.len();
if total == 0 {
let empty = Paragraph::new("This session has no messages.")
.block(section_block("Message Preview", false, theme))
.style(Style::default().fg(theme.text_dim).bg(theme.surface));
frame.render_widget(empty, area);
return;
}
let start = app.detail_scroll.min(total.saturating_sub(1));
let end = (start + 5).min(total);
let mut lines = vec![Line::from(vec![
Span::styled("Showing", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}-{} of {}", start + 1, end, total)),
])];
for event in session.events.iter().skip(start).take(5) {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", role_name(event.role).to_uppercase()),
role_style(event, theme),
),
Span::styled(
format!(" {}", event.timestamp.format("%H:%M:%S")),
Style::default().fg(theme.text_dim),
),
]));
lines.push(Line::from(Span::raw(content_preview(event))));
}
let block = Paragraph::new(Text::from(lines))
.block(section_block("Message Preview", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(block, area);
}
fn draw_picker_block(
frame: &mut Frame,
title: &str,
value: &str,
hint: &str,
focused: bool,
area: Rect,
theme: &Theme,
) {
let text = Text::from(vec![
Line::from(Span::styled(
format!(" {} ", value),
if focused {
highlighted_value_style(theme)
} else {
Style::default().fg(theme.text).bg(theme.surface)
},
)),
Line::from(Span::styled(hint, Style::default().fg(theme.text))),
]);
let block = Paragraph::new(text)
.block(section_block(title, focused, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(block, area);
}
fn draw_execute_block(frame: &mut Frame, label: &str, focused: bool, area: Rect, theme: &Theme) {
let style = if focused {
highlighted_value_style(theme)
} else {
Style::default().fg(theme.text).bg(theme.surface)
};
if area.height >= 3 && area.width >= 8 {
let label_width = label.chars().count() as u16;
let button_width = (label_width + 6).min(area.width);
let button_area = Rect {
x: area.x + area.width.saturating_sub(button_width) / 2,
y: area.y + area.height.saturating_sub(3) / 2,
width: button_width,
height: 3,
};
let border = Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(theme.text).bg(theme.surface))
.border_style(if focused {
theme.border_focused
} else {
theme.border
});
frame.render_widget(border, button_area);
let inner = button_area.inner(Margin {
horizontal: 1,
vertical: 1,
});
let button = Paragraph::new(Line::from(Span::styled(format!(" {} ", label), style)))
.alignment(Alignment::Center)
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(button, inner);
return;
}
let button = Paragraph::new(Line::from(Span::styled(format!(" {} ", label), style)))
.alignment(Alignment::Center)
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(button, area);
}
fn draw_action_result(frame: &mut Frame, result: &ActionResult, area: Rect, theme: &Theme) {
let color = if result.is_error {
theme.error
} else {
theme.success
};
let mut lines = vec![
Line::from(Span::styled(
&result.title,
Style::default().fg(color).add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
for line in &result.lines {
lines.push(Line::from(Span::raw(line.clone())));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Enter or Esc closes this result.",
Style::default().fg(theme.text),
)));
let result = Paragraph::new(Text::from(lines))
.block(section_block("Result", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(result, area);
}
fn draw_search_query(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let query = if app.search_query.is_empty() {
" ".to_string()
} else {
app.search_query.clone()
};
let query_line = Paragraph::new(Text::from(vec![Line::from(vec![
Span::styled("Type", Style::default().fg(theme.text_dim)),
Span::raw(" "),
Span::styled(
format!(" {} ", query),
Style::default()
.fg(theme.text)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
),
])]))
.block(section_block("Query", true, theme))
.style(Style::default().fg(theme.text).bg(theme.surface));
frame.render_widget(query_line, area);
}
fn draw_search_scope_tabs(frame: &mut Frame, app: &App, area: Rect, theme: &Theme) {
let labels: Vec<&str> = SEARCH_SCOPE_OPTIONS
.iter()
.map(|scope| scope.label(app.language()))
.collect();
draw_chip_row(
frame,
"Scope",
&labels,
app.search_scope_index,
false,
area,
theme,
);
}
fn draw_search_results(frame: &mut Frame, app: &App, matches: &[usize], area: Rect, theme: &Theme) {
let lines = search_match_lines(app, matches, theme);
let results = Paragraph::new(Text::from(lines))
.block(section_block("Matches", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(results, area);
}
fn draw_search_preview(frame: &mut Frame, app: &App, matches: &[usize], area: Rect, theme: &Theme) {
let lines = search_preview_lines(app, matches, theme);
let preview = Paragraph::new(Text::from(lines))
.block(section_block("Preview", false, theme))
.style(Style::default().fg(theme.text).bg(theme.surface))
.wrap(Wrap { trim: true });
frame.render_widget(preview, area);
}
fn search_match_lines(app: &App, matches: &[usize], theme: &Theme) -> Vec<Line<'static>> {
if matches.is_empty() {
return vec![Line::from(Span::styled(
"No matching sessions.",
Style::default().fg(theme.warning),
))];
}
let active = app.search_match_index.min(matches.len() - 1);
let sessions = app.flattened_sessions();
let start = active.saturating_sub(2);
let end = (start + 5).min(matches.len());
let mut lines = vec![Line::from(vec![
Span::styled("Matches", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {} total", matches.len())),
])];
for (offset, match_pos) in (start..end).enumerate() {
let session = sessions[matches[match_pos]];
let style = if match_pos == active {
Style::default()
.fg(theme.primary)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(theme.text).bg(theme.surface)
};
let title = session.title.as_deref().unwrap_or("(untitled)");
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}. {}", start + offset + 1, theme::truncate(title, 44)),
style,
)));
}
lines
}
fn search_preview_lines(app: &App, matches: &[usize], theme: &Theme) -> Vec<Line<'static>> {
if matches.is_empty() {
return vec![Line::from(
"Search works inside the sessions currently shown in the table.",
)];
}
let active = app.search_match_index.min(matches.len() - 1);
let sessions = app.flattened_sessions();
let session = sessions[matches[active]];
vec![
Line::from(vec![
Span::styled("Title", Style::default().fg(theme.text_dim)),
Span::raw(format!(
" {}",
session.title.as_deref().unwrap_or("(untitled)")
)),
]),
Line::from(vec![
Span::styled("AI", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}", provider_label(&session.provider_id))),
Span::raw(" "),
Span::styled("Active", Style::default().fg(theme.text_dim)),
Span::raw(format!(
" {}",
theme::format_relative_time(session.last_active_at, app.language())
)),
]),
Line::from(vec![
Span::styled("Session", Style::default().fg(theme.text_dim)),
Span::raw(format!(" {}", theme::truncate(&session.session_id, 34))),
]),
Line::from(vec![
Span::styled("Workspace", Style::default().fg(theme.text_dim)),
Span::raw(format!(
" {}",
session.project_dir.as_deref().unwrap_or("(no dir)")
)),
]),
]
}
pub fn handle_key(app: &mut App, key: KeyEvent) -> AppResult {
if app.workspace_modal_open {
return handle_workspace_modal_key(app, key);
}
if app.settings_modal_open {
return handle_settings_modal_key(app, key);
}
if app.detail_modal_open {
return handle_detail_key(app, key);
}
if app.action_modal_open {
return handle_modal_key(app, key);
}
if app.search_modal_open {
return handle_search_key(app, key);
}
match key.code {
KeyCode::Up => {
app.select_previous();
AppResult::Continue
}
KeyCode::Down => {
app.select_next();
AppResult::Continue
}
KeyCode::Left => {
if app.main_focus == MainFocus::Sessions {
app.previous_provider_tab();
} else {
app.focus_previous_top_control();
}
AppResult::Continue
}
KeyCode::Right => {
if app.main_focus == MainFocus::Sessions {
app.next_provider_tab();
} else {
app.focus_next_top_control();
}
AppResult::Continue
}
KeyCode::Enter => {
app.open_action_modal();
AppResult::Continue
}
KeyCode::Esc if app.main_focus != MainFocus::Sessions => {
app.main_focus = MainFocus::Sessions;
AppResult::Continue
}
KeyCode::Char('f')
if key.modifiers.is_empty() || key.modifiers.contains(KeyModifiers::CONTROL) =>
{
app.open_search_modal();
AppResult::Continue
}
KeyCode::Char('q') if key.modifiers.is_empty() => AppResult::Quit,
_ => AppResult::Continue,
}
}
fn handle_workspace_modal_key(app: &mut App, key: KeyEvent) -> AppResult {
match key.code {
KeyCode::Up => {
app.step_main_workspace_picker(false);
AppResult::Continue
}
KeyCode::Down => {
app.step_main_workspace_picker(true);
AppResult::Continue
}
KeyCode::Enter => {
app.confirm_workspace_modal();
AppResult::Continue
}
KeyCode::Esc => {
app.close_workspace_modal();
AppResult::Continue
}
KeyCode::Backspace | KeyCode::Char(_) => {
app.edit_main_workspace_input(key.code);
AppResult::Continue
}
_ => AppResult::Continue,
}
}
fn handle_settings_modal_key(app: &mut App, key: KeyEvent) -> AppResult {
match key.code {
KeyCode::Up => {
app.move_settings_previous();
AppResult::Continue
}
KeyCode::Down => {
app.move_settings_next();
AppResult::Continue
}
KeyCode::Left => {
app.cycle_settings_value(false);
AppResult::Continue
}
KeyCode::Right => {
app.cycle_settings_value(true);
AppResult::Continue
}
KeyCode::Enter => {
app.activate_settings_field();
AppResult::Continue
}
KeyCode::Esc => {
app.close_settings_modal();
AppResult::Continue
}
KeyCode::Backspace | KeyCode::Char(_) => {
app.edit_settings_number(key.code);
AppResult::Continue
}
_ => AppResult::Continue,
}
}
fn handle_modal_key(app: &mut App, key: KeyEvent) -> AppResult {
if app.action_result.is_some() {
match key.code {
KeyCode::Enter | KeyCode::Esc => app.close_action_modal(),
_ => {}
}
return AppResult::Continue;
}
if app.action_dialog.is_some() {
return handle_action_dialog_key(app, key);
}
if app.current_action() == SessionAction::Rename && app.action_field == ActionField::RenameTitle
{
match key.code {
KeyCode::Char(_) | KeyCode::Backspace => {
app.edit_rename_input(key.code);
return AppResult::Continue;
}
_ => {}
}
}
if app.current_action() == SessionAction::Export && app.action_field == ActionField::ExportPath
{
match key.code {
KeyCode::Char(_) | KeyCode::Backspace => {
app.edit_export_output_prefix(key.code);
return AppResult::Continue;
}
_ => {}
}
}
match key.code {
KeyCode::Up => {
app.move_modal_field_previous();
AppResult::Continue
}
KeyCode::Down => {
app.move_modal_field_next();
AppResult::Continue
}
KeyCode::Left => {
app.cycle_modal_value(false);
AppResult::Continue
}
KeyCode::Right => {
app.cycle_modal_value(true);
AppResult::Continue
}
KeyCode::Enter => {
app.activate_modal_field();
AppResult::Continue
}
KeyCode::Esc => {
app.close_action_modal();
AppResult::Continue
}
_ => AppResult::Continue,
}
}
fn handle_action_dialog_key(app: &mut App, key: KeyEvent) -> AppResult {
match key.code {
KeyCode::Up => {
app.cycle_action_dialog_selection(false);
AppResult::Continue
}
KeyCode::Down => {
app.cycle_action_dialog_selection(true);
AppResult::Continue
}
KeyCode::Enter => {
app.confirm_action_dialog();
AppResult::Continue
}
KeyCode::Esc => {
app.close_action_dialog();
AppResult::Continue
}
KeyCode::Backspace | KeyCode::Char(_)
if matches!(app.action_dialog, Some(ActionDialog::TargetWorkspace)) =>
{
app.edit_workspace_input(key.code);
AppResult::Continue
}
_ => AppResult::Continue,
}
}
fn handle_search_key(app: &mut App, key: KeyEvent) -> AppResult {
match key.code {
KeyCode::Up => {
app.previous_search_match();
AppResult::Continue
}
KeyCode::Down => {
app.next_search_match();
AppResult::Continue
}
KeyCode::Left => {
app.cycle_search_scope(false);
AppResult::Continue
}
KeyCode::Right => {
app.cycle_search_scope(true);
AppResult::Continue
}
KeyCode::Enter => {
app.accept_search_selection();
AppResult::Continue
}
KeyCode::Esc => {
app.close_search_modal();
AppResult::Continue
}
KeyCode::Backspace | KeyCode::Char(_) => {
app.edit_search_query(key.code);
AppResult::Continue
}
_ => AppResult::Continue,
}
}
fn handle_detail_key(app: &mut App, key: KeyEvent) -> AppResult {
match key.code {
KeyCode::Up => {
app.detail_scroll_up();
AppResult::Continue
}
KeyCode::Down => {
app.detail_scroll_down();
AppResult::Continue
}
KeyCode::Esc => {
app.close_detail_modal();
AppResult::Continue
}
_ => AppResult::Continue,
}
}
fn highlighted_value_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.primary)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
fn selected_row_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.primary)
.bg(theme.background)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
}
fn action_footer_text(app: &App) -> &'static str {
match app.action_field {
ActionField::Action => "↑↓ Focus ←→ Action Enter Next Esc Close",
ActionField::TargetAgent => "↑↓ Focus Enter Choose Target Esc Close",
ActionField::TargetWorkspace => "↑↓ Focus Enter Choose Workspace Esc Close",
ActionField::ExportPath => "Type path ↑↓ Focus Enter Run Esc Close",
ActionField::RenameTitle => "Type title ↑↓ Focus Enter Run Esc Close",
ActionField::Execute => "↑↓ Focus Enter Run Esc Close",
}
}
fn modal_block(title: &str, theme: &Theme) -> Block<'static> {
Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.style(Style::default().fg(theme.text).bg(theme.surface))
.border_style(theme.border_focused)
}
fn section_block(title: &str, focused: bool, theme: &Theme) -> Block<'static> {
Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.style(Style::default().fg(theme.text).bg(theme.surface))
.border_style(if focused {
theme.border_focused
} else {
theme.border
})
}
fn role_style(event: &SessionEvent, theme: &Theme) -> Style {
let color = match event.role {
EventRole::User => theme.accent,
EventRole::Assistant => theme.primary,
EventRole::Tool => theme.secondary,
EventRole::Unknown => theme.warning,
_ => theme.text_dim,
};
Style::default()
.fg(color)
.bg(theme.highlight)
.add_modifier(Modifier::BOLD)
}
fn role_name(role: EventRole) -> &'static str {
match role {
EventRole::User => "user",
EventRole::Assistant => "assistant",
EventRole::Tool => "tool",
EventRole::System => "system",
EventRole::Developer => "developer",
EventRole::Unknown => "unknown",
}
}
fn content_preview(event: &SessionEvent) -> String {
for block in &event.blocks {
match block {
EventBlock::Text { text } => return theme::truncate(text, 96),
EventBlock::Thinking { text, .. } => {
return format!("Thinking: {}", theme::truncate(text, 84));
}
EventBlock::ToolCall { name, .. } => return format!("Tool call: {}", name),
EventBlock::ToolResult { content, .. } => {
return format!("Tool result: {}", theme::truncate(content, 80));
}
EventBlock::Patch { files, .. } => {
return if files.is_empty() {
"Patch".to_string()
} else {
format!("Patch: {}", theme::truncate(&files.join(", "), 80))
};
}
EventBlock::Command { command, .. } => return format!("Command: {}", command),
EventBlock::CommandResult { stdout, .. } => {
return format!(
"Command result: {}",
theme::truncate(stdout.as_deref().unwrap_or("(no output)"), 76)
);
}
EventBlock::File { path, .. } => return format!("File: {}", path),
EventBlock::Image { .. } => return "Image attachment".to_string(),
EventBlock::ProviderPayload { kind, .. } => return format!("Payload: {}", kind),
EventBlock::Unknown { .. } => return "Unknown payload".to_string(),
}
}
"(empty event)".to_string()
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}