use std::collections::HashMap;
use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders, Padding};
use crate::domain::agent::ReasoningLevel;
use crate::domain::session::Session;
use crate::ui::router::{ListBackgroundRenderContext, render_list_background};
use crate::ui::state::app_mode::{
AppMode, ConfirmationViewMode, DoneSessionOutputMode, HelpContext,
};
use crate::ui::style::palette;
use crate::ui::{Component, Page, component, page};
const OVERLAY_HORIZONTAL_PADDING: u16 = 2;
const OVERLAY_VERTICAL_PADDING: u16 = 1;
#[derive(Clone, Copy)]
pub(crate) struct SyncBlockedPopupRenderContext<'a> {
pub(crate) default_branch: Option<&'a str>,
pub(crate) is_loading: bool,
pub(crate) message: &'a str,
pub(crate) project_name: Option<&'a str>,
pub(crate) title: &'a str,
}
#[derive(Clone, Copy)]
pub(crate) struct ViewInfoPopupRenderContext<'a> {
pub(crate) is_loading: bool,
pub(crate) loading_label: &'a str,
pub(crate) message: &'a str,
pub(crate) title: &'a str,
}
pub(crate) fn render_confirmation_overlay(
f: &mut Frame,
area: Rect,
mode: &AppMode,
list_background: ListBackgroundRenderContext<'_>,
wall_clock_unix_seconds: i64,
) {
render_list_background(f, area, list_background, wall_clock_unix_seconds);
render_overlay_backdrop(f, area);
let AppMode::Confirmation {
confirmation_message,
confirmation_title,
selected_confirmation_index,
..
} = mode
else {
unreachable!("matched confirmation mode above");
};
component::confirmation_overlay::ConfirmationOverlay::new(
confirmation_title,
confirmation_message,
)
.selected_yes(*selected_confirmation_index == 0)
.render(f, area);
}
pub(crate) fn render_sync_blocked_popup(
f: &mut Frame,
area: Rect,
list_background: ListBackgroundRenderContext<'_>,
wall_clock_unix_seconds: i64,
context: SyncBlockedPopupRenderContext<'_>,
) {
let SyncBlockedPopupRenderContext {
default_branch,
is_loading,
message,
project_name,
title,
} = context;
render_list_background(f, area, list_background, wall_clock_unix_seconds);
render_overlay_backdrop(f, area);
let popup_message = sync_popup_message(default_branch, message, project_name);
component::info_overlay::InfoOverlay::new(title, &popup_message)
.is_loading(is_loading)
.loading_label("Sync in progress...")
.render(f, area);
}
pub(crate) fn render_view_info_popup(
f: &mut Frame,
area: Rect,
restore_view: &ConfirmationViewMode,
sessions: &[Session],
session_progress_messages: &HashMap<String, String>,
default_reasoning_level: ReasoningLevel,
wall_clock_unix_seconds: i64,
context: ViewInfoPopupRenderContext<'_>,
) {
let ViewInfoPopupRenderContext {
is_loading,
loading_label,
message,
title,
} = context;
let background_mode = restore_view.clone().into_view_mode();
if let Some(session_index) = sessions
.iter()
.position(|session| session.id == restore_view.session_id)
{
let active_progress = session_progress_messages
.get(&restore_view.session_id)
.map(std::string::String::as_str);
page::session_chat::SessionChatPage::new(
sessions,
session_index,
restore_view.scroll_offset,
&background_mode,
default_reasoning_level,
None,
active_progress,
wall_clock_unix_seconds,
)
.render(f, area);
}
render_overlay_backdrop(f, area);
component::info_overlay::InfoOverlay::new(title, message)
.is_loading(is_loading)
.loading_label(loading_label)
.render(f, area);
}
pub(crate) fn sync_popup_message(
default_branch: Option<&str>,
detail_message: &str,
project_name: Option<&str>,
) -> String {
match (project_name, default_branch) {
(Some(project_name), Some(default_branch)) => format!(
"Project `{project_name}` on main branch `{default_branch}`.\n\n{detail_message}"
),
(Some(project_name), None) => format!("Project `{project_name}`.\n\n{detail_message}"),
(None, Some(default_branch)) => {
format!("Main branch `{default_branch}`.\n\n{detail_message}")
}
(None, None) => detail_message.to_string(),
}
}
pub(crate) fn render_help(
f: &mut Frame,
area: Rect,
help_context: &HelpContext,
scroll_offset: u16,
list_background: ListBackgroundRenderContext<'_>,
session_progress_messages: &HashMap<String, String>,
wall_clock_unix_seconds: i64,
) {
render_help_background(
f,
area,
help_context,
list_background,
session_progress_messages,
wall_clock_unix_seconds,
);
render_overlay_backdrop(f, area);
component::help_overlay::HelpOverlay::new(help_context)
.scroll_offset(scroll_offset)
.render(f, area);
}
pub(crate) fn render_overlay_backdrop(f: &mut Frame, area: Rect) {
f.render_widget(Block::default().style(overlay_backdrop_style()), area);
}
pub(crate) fn centered_popup_area(
area: Rect,
width_percent: u16,
height_percent: u16,
min_width: u16,
min_height: u16,
) -> Rect {
let popup_width = (area.width * width_percent / 100)
.max(min_width)
.min(area.width);
let popup_height = (area.height * height_percent / 100)
.max(min_height)
.min(area.height);
Rect::new(
area.x + (area.width.saturating_sub(popup_width)) / 2,
area.y + (area.height.saturating_sub(popup_height)) / 2,
popup_width,
popup_height,
)
}
pub(crate) fn overlay_content_width(popup_width: u16) -> usize {
let horizontal_chrome = 2 + (OVERLAY_HORIZONTAL_PADDING * 2);
usize::from(popup_width.saturating_sub(horizontal_chrome).max(1))
}
pub(crate) fn overlay_required_height(inner_line_count: usize) -> u16 {
let vertical_chrome = 2 + (OVERLAY_VERTICAL_PADDING * 2);
u16::try_from(inner_line_count.saturating_add(usize::from(vertical_chrome))).unwrap_or(u16::MAX)
}
pub(crate) fn overlay_block(title: &str, border_color: Color) -> Block<'static> {
let title_text = format!(" {title} ");
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.padding(Padding::new(
OVERLAY_HORIZONTAL_PADDING,
OVERLAY_HORIZONTAL_PADDING,
OVERLAY_VERTICAL_PADDING,
OVERLAY_VERTICAL_PADDING,
))
.title(Span::styled(title_text, overlay_title_style(border_color)))
.title_alignment(Alignment::Center)
}
fn overlay_backdrop_style() -> Style {
Style::default()
.bg(palette::SURFACE_OVERLAY)
.fg(palette::TEXT_MUTED)
}
fn overlay_title_style(border_color: Color) -> Style {
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD)
}
enum ResolvedHelpBackground<'a> {
List,
View {
done_session_output_mode: DoneSessionOutputMode,
review_status_message: &'a Option<String>,
review_text: &'a Option<String>,
scroll_offset: Option<u16>,
session_id: &'a str,
session_index: usize,
},
Diff {
diff: &'a str,
file_explorer_selected_index: usize,
scroll_offset: u16,
session: &'a Session,
},
}
fn resolve_help_background<'a>(
help_context: &'a HelpContext,
sessions: &'a [Session],
) -> Option<ResolvedHelpBackground<'a>> {
match help_context {
HelpContext::List { .. } => Some(ResolvedHelpBackground::List),
HelpContext::View {
done_session_output_mode,
review_status_message,
review_text,
session_id,
scroll_offset,
..
} => sessions
.iter()
.position(|session| session.id == *session_id)
.map(|session_index| ResolvedHelpBackground::View {
done_session_output_mode: *done_session_output_mode,
review_status_message,
review_text,
scroll_offset: *scroll_offset,
session_id,
session_index,
}),
HelpContext::Diff {
diff,
file_explorer_selected_index,
scroll_offset,
session_id,
..
} => sessions
.iter()
.find(|session| session.id == *session_id)
.map(|session| ResolvedHelpBackground::Diff {
diff,
file_explorer_selected_index: *file_explorer_selected_index,
scroll_offset: *scroll_offset,
session,
}),
}
}
fn render_help_background(
f: &mut Frame,
area: Rect,
help_context: &HelpContext,
list_background: ListBackgroundRenderContext<'_>,
session_progress_messages: &HashMap<String, String>,
wall_clock_unix_seconds: i64,
) {
let sessions = list_background.sessions;
match resolve_help_background(help_context, sessions) {
Some(ResolvedHelpBackground::List) => {
render_list_background(f, area, list_background, wall_clock_unix_seconds);
}
Some(ResolvedHelpBackground::View {
done_session_output_mode,
review_status_message,
review_text,
session_id,
session_index,
scroll_offset,
}) => {
let bg_mode = AppMode::View {
done_session_output_mode,
review_status_message: review_status_message.clone(),
review_text: review_text.clone(),
session_id: session_id.to_string(),
scroll_offset,
};
let active_progress = session_progress_messages
.get(session_id)
.map(std::string::String::as_str);
page::session_chat::SessionChatPage::new(
sessions,
session_index,
scroll_offset,
&bg_mode,
list_background.settings.reasoning_level,
None,
active_progress,
wall_clock_unix_seconds,
)
.render(f, area);
}
Some(ResolvedHelpBackground::Diff {
diff,
file_explorer_selected_index,
session,
scroll_offset,
}) => page::diff::DiffPage::new(
session,
diff.to_string(),
scroll_offset,
file_explorer_selected_index,
)
.render(f, area),
None => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::style::palette;
#[test]
fn test_sync_popup_message_with_project_and_branch() {
let default_branch = Some("develop");
let detail_message = "Synchronizing with its upstream.";
let project_name = Some("agentty");
let message = sync_popup_message(default_branch, detail_message, project_name);
assert_eq!(
message,
"Project `agentty` on main branch `develop`.\n\nSynchronizing with its upstream."
);
}
#[test]
fn test_sync_popup_message_with_project_only() {
let default_branch = None;
let detail_message = "Synchronization is blocked.";
let project_name = Some("agentty");
let message = sync_popup_message(default_branch, detail_message, project_name);
assert_eq!(message, "Project `agentty`.\n\nSynchronization is blocked.");
}
#[test]
fn test_sync_popup_message_with_branch_only() {
let default_branch = Some("main");
let detail_message = "Synchronization is blocked.";
let project_name = None;
let message = sync_popup_message(default_branch, detail_message, project_name);
assert_eq!(
message,
"Main branch `main`.\n\nSynchronization is blocked."
);
}
#[test]
fn test_sync_popup_message_without_project_or_branch() {
let default_branch = None;
let detail_message = "Synchronization is blocked.";
let project_name = None;
let message = sync_popup_message(default_branch, detail_message, project_name);
assert_eq!(message, "Synchronization is blocked.");
}
#[test]
fn test_centered_popup_area_centers_within_bounds() {
let area = Rect::new(0, 0, 100, 50);
let popup_area = centered_popup_area(area, 40, 20, 30, 7);
assert_eq!(popup_area.width, 40);
assert_eq!(popup_area.height, 10);
assert_eq!(popup_area.x, 30);
assert_eq!(popup_area.y, 20);
}
#[test]
fn test_centered_popup_area_clamps_to_small_terminal() {
let area = Rect::new(0, 0, 20, 6);
let popup_area = centered_popup_area(area, 50, 50, 30, 10);
assert_eq!(popup_area.width, 20);
assert_eq!(popup_area.height, 6);
assert_eq!(popup_area.x, 0);
assert_eq!(popup_area.y, 0);
}
#[test]
fn test_centered_popup_area_respects_minimum_size_before_centering() {
let area = Rect::new(10, 5, 80, 40);
let popup_area = centered_popup_area(area, 10, 10, 30, 12);
assert_eq!(popup_area.width, 30);
assert_eq!(popup_area.height, 12);
assert_eq!(popup_area.x, 35);
assert_eq!(popup_area.y, 19);
}
#[test]
fn test_overlay_content_width_subtracts_shared_frame_chrome() {
let popup_width = 40;
let content_width = overlay_content_width(popup_width);
assert_eq!(content_width, 34);
}
#[test]
fn test_overlay_content_width_keeps_minimum_width_for_tiny_popup() {
let popup_width = 1;
let content_width = overlay_content_width(popup_width);
assert_eq!(content_width, 1);
}
#[test]
fn test_overlay_required_height_adds_shared_frame_chrome() {
let inner_line_count = 8;
let total_height = overlay_required_height(inner_line_count);
assert_eq!(total_height, 12);
}
#[test]
fn test_overlay_required_height_saturates_at_u16_max() {
let inner_line_count = usize::MAX;
let total_height = overlay_required_height(inner_line_count);
assert_eq!(total_height, u16::MAX);
}
#[test]
fn test_resolve_help_background_returns_list_variant_for_list_context() {
let help_context = HelpContext::List {
keybindings: vec![],
};
let resolved = resolve_help_background(&help_context, &[]);
assert!(matches!(resolved, Some(ResolvedHelpBackground::List)));
}
#[test]
fn test_resolve_help_background_returns_none_for_missing_view_session() {
let help_context = HelpContext::View {
can_sync_review_request: false,
done_session_output_mode: DoneSessionOutputMode::Summary,
follow_up_task_action: None,
has_multiple_follow_up_tasks: false,
review_status_message: None,
review_text: None,
publish_pull_request_action: None,
session_id: "missing-session".to_string(),
session_state: crate::ui::state::help_action::ViewSessionState::Done,
scroll_offset: Some(0),
};
let resolved = resolve_help_background(&help_context, &[]);
assert!(resolved.is_none());
}
#[test]
fn test_resolve_help_background_returns_none_for_missing_diff_session() {
let help_context = HelpContext::Diff {
session_id: "missing-session".to_string(),
diff: "diff --git a/file b/file".to_string(),
restore_question: None,
scroll_offset: 0,
file_explorer_selected_index: 0,
};
let resolved = resolve_help_background(&help_context, &[]);
assert!(resolved.is_none());
}
#[test]
fn test_render_overlay_backdrop_applies_dimmed_style() {
let backend = ratatui::backend::TestBackend::new(8, 4);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
terminal
.draw(|frame| {
let area = frame.area();
render_overlay_backdrop(frame, area);
})
.expect("failed to draw");
let first_cell = terminal
.backend()
.buffer()
.content()
.first()
.expect("buffer should contain at least one cell");
assert_eq!(first_cell.bg, palette::SURFACE_OVERLAY);
assert_eq!(first_cell.fg, palette::TEXT_MUTED);
}
}