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, Clear, Padding};
use crate::domain::agent::ReasoningLevel;
use crate::domain::session::{Session, SessionId};
use crate::infra::review_comment_cache::ReviewCommentCache;
use crate::ui::router::{ListBackgroundRenderContext, render_list_background};
use crate::ui::state::app_mode::{AppMode, ConfirmationViewMode, DiffRightPanel, HelpContext};
use crate::ui::style::palette;
use crate::ui::{Component, Page, SessionReviewSnapshot, component, markdown, page};
const OVERLAY_HORIZONTAL_PADDING: u16 = 2;
const OVERLAY_VERTICAL_PADDING: u16 = 1;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OverlayDimensions {
height_percent: u16,
min_height: u16,
min_width: u16,
width_percent: u16,
}
impl OverlayDimensions {
pub const fn new(
width_percent: u16,
height_percent: u16,
min_width: u16,
min_height: u16,
) -> Self {
Self {
height_percent,
min_height,
min_width,
width_percent,
}
}
pub fn centered_popup_area(self, area: Rect) -> Rect {
centered_popup_area(
area,
self.width_percent,
self.height_percent,
self.min_width,
self.min_height,
)
}
}
#[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) can_open_worktree: bool,
pub(crate) default_reasoning_level: ReasoningLevel,
pub(crate) markdown_render_cache: &'a markdown::MarkdownRenderCache,
pub(crate) output_layout_cache: &'a component::session_output::SessionOutputLayoutCache,
pub(crate) is_loading: bool,
pub(crate) loading_label: &'a str,
pub(crate) message: &'a str,
pub(crate) restore_view: &'a ConfirmationViewMode,
pub(crate) review_snapshot: Option<&'a SessionReviewSnapshot<'a>>,
pub(crate) session_progress_messages: &'a HashMap<SessionId, String>,
pub(crate) session_update_versions: &'a HashMap<SessionId, u64>,
pub(crate) sessions: &'a [Session],
pub(crate) title: &'a str,
pub(crate) wall_clock_unix_seconds: i64,
}
pub(crate) struct HelpOverlayRenderContext<'a, 'state> {
pub(crate) help_context: &'a HelpContext,
pub(crate) list_background: ListBackgroundRenderContext<'a, 'state>,
pub(crate) diff_layout_cache: &'a page::diff::DiffLayoutCache,
pub(crate) markdown_render_cache: &'a markdown::MarkdownRenderCache,
pub(crate) output_layout_cache: &'a component::session_output::SessionOutputLayoutCache,
pub(crate) review_comment_cache: &'a ReviewCommentCache,
pub(crate) review_snapshot: Option<&'a SessionReviewSnapshot<'a>>,
pub(crate) scroll_offset: u16,
pub(crate) session_progress_messages: &'a HashMap<SessionId, String>,
pub(crate) session_update_versions: &'a HashMap<SessionId, u64>,
pub(crate) wall_clock_unix_seconds: i64,
}
struct HelpBackgroundRenderContext<'a, 'state> {
help_context: &'a HelpContext,
list_background: ListBackgroundRenderContext<'a, 'state>,
diff_layout_cache: &'a page::diff::DiffLayoutCache,
markdown_render_cache: &'a markdown::MarkdownRenderCache,
output_layout_cache: &'a component::session_output::SessionOutputLayoutCache,
review_comment_cache: &'a ReviewCommentCache,
review_snapshot: Option<&'a SessionReviewSnapshot<'a>>,
session_progress_messages: &'a HashMap<SessionId, String>,
session_update_versions: &'a HashMap<SessionId, u64>,
wall_clock_unix_seconds: i64,
}
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);
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_session_creation_overlay(
f: &mut Frame,
area: Rect,
list_background: ListBackgroundRenderContext<'_, '_>,
selected_option_index: usize,
wall_clock_unix_seconds: i64,
) {
let can_create_stacked_session = list_background.can_create_stacked_session();
render_list_background(f, area, list_background, wall_clock_unix_seconds);
component::session_creation_overlay::SessionCreationOverlay::new(
selected_option_index,
can_create_stacked_session,
)
.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);
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,
context: ViewInfoPopupRenderContext<'_>,
) {
let ViewInfoPopupRenderContext {
can_open_worktree,
default_reasoning_level,
markdown_render_cache,
output_layout_cache,
is_loading,
loading_label,
message,
restore_view,
review_snapshot,
session_progress_messages,
session_update_versions,
sessions,
title,
wall_clock_unix_seconds,
} = 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);
let session_update_version = session_update_versions
.get(&restore_view.session_id)
.copied()
.unwrap_or_default();
page::session_chat::SessionChatPage::new(page::session_chat::SessionChatPageInput {
active_prompt_output: None,
active_progress,
default_reasoning_level,
markdown_render_cache,
mode: &background_mode,
output_layout_cache,
review_status_message: review_snapshot
.filter(|snapshot| snapshot.session_id == restore_view.session_id.as_str())
.and_then(|snapshot| snapshot.status_message.as_deref()),
review_text: review_snapshot
.filter(|snapshot| snapshot.session_id == restore_view.session_id.as_str())
.and_then(|snapshot| snapshot.text),
scroll_offset: restore_view.scroll_offset,
session_index,
session_update_version,
sessions,
wall_clock_unix_seconds,
})
.can_open_worktree(can_open_worktree)
.render(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, context: HelpOverlayRenderContext<'_, '_>) {
let HelpOverlayRenderContext {
diff_layout_cache,
help_context,
list_background,
markdown_render_cache,
output_layout_cache,
review_comment_cache,
review_snapshot,
scroll_offset,
session_progress_messages,
session_update_versions,
wall_clock_unix_seconds,
} = context;
render_help_background(
f,
area,
HelpBackgroundRenderContext {
help_context,
list_background,
diff_layout_cache,
markdown_render_cache,
output_layout_cache,
review_comment_cache,
review_snapshot,
session_progress_messages,
session_update_versions,
wall_clock_unix_seconds,
},
);
component::help_overlay::HelpOverlay::new(help_context)
.scroll_offset(scroll_offset)
.render(f, area);
}
pub(crate) fn clear_popup_area(f: &mut Frame, area: Rect) {
let popup_style = Style::default()
.fg(palette::text())
.bg(palette::surface_overlay());
f.render_widget(Clear, area);
f.render_widget(Block::default().style(popup_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_title_style(border_color: Color) -> Style {
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD)
}
enum ResolvedHelpBackground<'a> {
List,
View {
scroll_offset: Option<u16>,
session_id: &'a str,
session_index: usize,
},
Diff {
diff: &'a str,
file_explorer_selected_index: usize,
right_panel: DiffRightPanel,
scroll_offset: u16,
session: &'a Session,
snapshot: Option<crate::infra::review_comment_cache::CachedReviewCommentSnapshot>,
},
}
fn resolve_help_background<'a>(
help_context: &'a HelpContext,
sessions: &'a [Session],
review_comment_cache: &ReviewCommentCache,
) -> Option<ResolvedHelpBackground<'a>> {
match help_context {
HelpContext::List { .. } => Some(ResolvedHelpBackground::List),
HelpContext::View {
session_id,
scroll_offset,
..
} => sessions
.iter()
.position(|session| session.id == *session_id)
.map(|session_index| ResolvedHelpBackground::View {
scroll_offset: *scroll_offset,
session_id,
session_index,
}),
HelpContext::Diff {
diff,
file_explorer_selected_index,
right_panel,
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,
right_panel: *right_panel,
scroll_offset: *scroll_offset,
session,
snapshot: review_comment_cache.snapshot(session_id),
}),
}
}
fn render_help_background(f: &mut Frame, area: Rect, context: HelpBackgroundRenderContext<'_, '_>) {
let HelpBackgroundRenderContext {
diff_layout_cache,
help_context,
list_background,
markdown_render_cache,
output_layout_cache,
review_comment_cache,
review_snapshot,
session_progress_messages,
session_update_versions,
wall_clock_unix_seconds,
} = context;
let sessions = list_background.sessions();
match resolve_help_background(help_context, sessions, review_comment_cache) {
Some(ResolvedHelpBackground::List) => {
render_list_background(f, area, list_background, wall_clock_unix_seconds);
}
Some(ResolvedHelpBackground::View {
session_id,
session_index,
scroll_offset,
}) => {
let bg_mode = AppMode::View {
session_id: session_id.into(),
scroll_offset,
};
let active_progress = session_progress_messages
.get(session_id)
.map(std::string::String::as_str);
let session_update_version = session_update_versions
.get(session_id)
.copied()
.unwrap_or_default();
page::session_chat::SessionChatPage::new(page::session_chat::SessionChatPageInput {
active_prompt_output: None,
active_progress,
default_reasoning_level: list_background.default_reasoning_level(),
markdown_render_cache,
mode: &bg_mode,
output_layout_cache,
review_status_message: review_snapshot
.filter(|snapshot| snapshot.session_id == session_id)
.and_then(|snapshot| snapshot.status_message.as_deref()),
review_text: review_snapshot
.filter(|snapshot| snapshot.session_id == session_id)
.and_then(|snapshot| snapshot.text),
scroll_offset,
session_index,
session_update_version,
sessions,
wall_clock_unix_seconds,
})
.render(f, area);
}
Some(ResolvedHelpBackground::Diff {
diff,
file_explorer_selected_index,
right_panel,
session,
snapshot,
scroll_offset,
}) => page::diff::DiffPage::new(page::diff::DiffPageInput {
diff,
diff_layout_cache,
file_explorer_selected_index,
markdown_render_cache,
review_comment_snapshot: snapshot.as_ref(),
right_panel,
scroll_offset,
session,
})
.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, &[], &ReviewCommentCache::default());
assert!(matches!(resolved, Some(ResolvedHelpBackground::List)));
}
#[test]
fn test_resolve_help_background_returns_none_for_missing_view_session() {
let help_context = HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_rebase_session_branch: true,
can_reply_to_session: true,
can_start_staged_session: false,
publish_pull_request_action: None,
session_id: "missing-session".into(),
session_state: crate::ui::state::help_action::ViewSessionState::Done,
scroll_offset: Some(0),
};
let resolved = resolve_help_background(&help_context, &[], &ReviewCommentCache::default());
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".into(),
diff: "diff --git a/file b/file".to_string(),
restore_question: None,
right_panel: DiffRightPanel::Diff,
scroll_offset: 0,
file_explorer_selected_index: 0,
};
let resolved = resolve_help_background(&help_context, &[], &ReviewCommentCache::default());
assert!(resolved.is_none());
}
#[test]
fn test_clear_popup_area_uses_overlay_surface_style() {
let backend = ratatui::backend::TestBackend::new(8, 4);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let initial_style = Style::default()
.fg(palette::warning())
.bg(palette::surface());
terminal
.draw(|frame| {
let area = Rect::new(2, 1, 3, 2);
frame.render_widget(Block::default().style(initial_style), frame.area());
clear_popup_area(frame, area);
})
.expect("failed to draw");
let buffer = terminal.backend().buffer();
for y in 1..3 {
for x in 2..5 {
let cell = &buffer[(x, y)];
assert_eq!(cell.symbol(), " ");
assert_eq!(cell.fg, palette::text());
assert_eq!(cell.bg, palette::surface_overlay());
}
}
}
}