use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
use std::time::Duration;
use crate::orchestration::operator_command::{
is_active_status, is_completed_status, is_markable_status,
};
use super::state::{
guards, log_logic, AppState, ChangeState, CopyFeedback, ErrorDetailsPopup,
ERROR_DETAILS_UNAVAILABLE,
};
use super::types::{AppExecutionMode, ModalState};
use super::utils::{get_version_string, truncate_to_display_width_with_suffix};
#[derive(Debug)]
struct RemoteChangeId<'a> {
project: Option<&'a str>,
change: &'a str,
}
fn split_remote_change_id(id: &str) -> RemoteChangeId<'_> {
if let Some((_, after_colon)) = id.split_once("::") {
if let Some((project, change)) = after_colon.rsplit_once('/') {
return RemoteChangeId {
project: Some(project),
change,
};
}
RemoteChangeId {
project: None,
change: after_colon,
}
} else {
RemoteChangeId {
project: None,
change: id,
}
}
}
#[derive(Debug)]
enum ChangeRow {
Header(String),
Item { change_index: usize },
}
fn build_change_rows(changes: &[ChangeState]) -> (Vec<ChangeRow>, Vec<usize>) {
let mut seen_projects: Vec<Option<String>> = Vec::new();
for change in changes {
let parsed = split_remote_change_id(&change.id);
let key = parsed.project.map(|p| p.to_string());
if !seen_projects.contains(&key) {
seen_projects.push(key);
}
}
let all_local = seen_projects.len() == 1 && seen_projects[0].is_none();
let mut rows: Vec<ChangeRow> = Vec::new();
let mut change_to_visual: Vec<usize> = vec![0; changes.len()];
if all_local {
for (ci, _) in changes.iter().enumerate() {
change_to_visual[ci] = rows.len();
rows.push(ChangeRow::Item { change_index: ci });
}
} else {
for project_key in &seen_projects {
let header_label = match project_key {
Some(p) => p.clone(),
None => "(local)".to_string(),
};
rows.push(ChangeRow::Header(header_label));
for (ci, change) in changes.iter().enumerate() {
let parsed = split_remote_change_id(&change.id);
let key = parsed.project.map(|p| p.to_string());
if key == *project_key {
change_to_visual[ci] = rows.len();
rows.push(ChangeRow::Item { change_index: ci });
}
}
}
}
(rows, change_to_visual)
}
const CHECKBOX_PLACEHOLDER: &str = " ";
const CHECKBOX_WIDTH: usize = CHECKBOX_PLACEHOLDER.len();
const CHECKBOX_TO_ID_SEPARATOR_WIDTH: usize = 1;
const CHANGE_ID_CONTENT_WIDTH: usize = 35;
#[cfg_attr(not(test), allow(dead_code))]
const CHANGE_ID_FIELD_WIDTH: usize = CHANGE_ID_CONTENT_WIDTH + CHECKBOX_TO_ID_SEPARATOR_WIDTH;
const CHANGE_ROW_PREFIX_WIDTH: usize =
CHECKBOX_WIDTH + CHECKBOX_TO_ID_SEPARATOR_WIDTH + CHANGE_ID_CONTENT_WIDTH;
fn display_width(text: &str) -> usize {
unicode_width::UnicodeWidthStr::width(text)
}
const MIDDLE_ELLIPSIS: char = '…';
fn middle_elide_to_display_width(text: &str, max_width: usize) -> String {
if display_width(text) <= max_width {
return text.to_string();
}
if max_width == 0 {
return String::new();
}
let retained = max_width - 1;
let prefix_budget = retained / 2;
let suffix_budget = retained - prefix_budget;
let mut prefix = String::new();
let mut prefix_used = 0;
for ch in text.chars() {
let ch_width = char_display_width(ch);
if prefix_used + ch_width > prefix_budget {
break;
}
prefix.push(ch);
prefix_used += ch_width;
}
let mut suffix_rev = String::new();
let mut suffix_used = 0;
for ch in text.chars().rev() {
let ch_width = char_display_width(ch);
if suffix_used + ch_width > suffix_budget {
break;
}
suffix_rev.push(ch);
suffix_used += ch_width;
}
let mut suffix: String = suffix_rev.chars().rev().collect();
if prefix_used == 0 {
prefix.clear();
}
if suffix_used == 0 {
suffix.clear();
}
let mut out = prefix;
out.push(MIDDLE_ELLIPSIS);
out.push_str(&suffix);
out
}
fn char_display_width(ch: char) -> usize {
unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0)
}
fn is_post_archive_status(display_status: &str) -> bool {
is_completed_status(display_status)
}
fn hides_checkbox(change: &ChangeState) -> bool {
change.archive_complete_cache || is_post_archive_status(&change.display_status_cache)
}
fn get_checkbox_display(
display_status: &str,
is_selected: bool,
archive_complete: bool,
) -> (&'static str, Color) {
if archive_complete || is_post_archive_status(display_status) {
(CHECKBOX_PLACEHOLDER, Color::DarkGray) } else if is_selected {
("[x]", Color::Green) } else {
("[ ]", Color::Gray) }
}
fn render_change_id_field(display_id: &str) -> String {
use unicode_width::UnicodeWidthChar;
let mut field = String::new();
let mut width = 0;
for ch in display_id.chars() {
let char_width = ch.width().unwrap_or(0);
if width + char_width > CHANGE_ID_CONTENT_WIDTH {
break;
}
field.push(ch);
width += char_width;
}
field.push_str(&" ".repeat(CHANGE_ID_CONTENT_WIDTH - width));
field
}
fn push_change_row_hints(keys: &mut Vec<String>, app: &AppState, item: &ChangeState) {
let status = item.display_status_cache.as_str();
if is_active_status(status) {
if let Some(ModalState::ConfirmForceKill { .. }) = app.modal {
keys.push("Y: confirm kill".to_string());
keys.push("N: cancel".to_string());
} else {
keys.push("K: kill".to_string());
}
}
if is_markable_status(status, item.archive_complete_cache) {
keys.push(if item.selected {
"Space: unmark".to_string()
} else {
"Space: mark".to_string()
});
}
if app.focused_row_is_dismissable() {
keys.push("d: dismiss".to_string());
}
}
fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs();
if secs >= 3600 {
let hours = secs / 3600;
let mins = (secs % 3600) / 60;
format!("{}h {:02}m", hours, mins)
} else if secs >= 60 {
let mins = secs / 60;
let remaining_secs = secs % 60;
format!("{}m {:02}s", mins, remaining_secs)
} else {
format!("{}s", secs)
}
}
fn format_relative_time(created_at: &chrono::DateTime<chrono::Utc>) -> String {
use chrono::Utc;
let now = Utc::now();
let duration = now.signed_duration_since(*created_at);
let total_seconds = duration.num_seconds();
if total_seconds < 60 {
return "just now".to_string();
}
let total_minutes = total_seconds / 60;
let total_hours = total_minutes / 60;
let total_days = total_hours / 24;
if total_days > 0 {
let remaining_hours = total_hours % 24;
if remaining_hours > 0 {
format!("{}d {}h ago", total_days, remaining_hours)
} else {
format!("{}d ago", total_days)
}
} else if total_hours > 0 {
let remaining_minutes = total_minutes % 60;
if remaining_minutes > 0 {
format!("{}h {}m ago", total_hours, remaining_minutes)
} else {
format!("{}h ago", total_hours)
}
} else {
format!("{}m ago", total_minutes)
}
}
pub const SPINNER_CHARS: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
pub fn render(frame: &mut Frame, app: &mut AppState) {
use crate::tui::types::ViewMode;
let area = frame.area();
if area.width < 60 || area.height < 15 {
let warning = Paragraph::new("Terminal too small. Minimum: 60x15")
.style(Style::default().fg(Color::Red));
frame.render_widget(warning, area);
return;
}
match app.view_mode {
ViewMode::Changes => {
if app.logs.is_empty() {
render_select_mode(frame, app, area);
} else {
render_running_mode(frame, app, area);
}
}
ViewMode::Worktrees => {
worktree_view::render(frame, app, area);
}
}
match &app.modal {
Some(ModalState::QrPopup) => popups::render_qr(frame, app, area),
Some(ModalState::ConfirmWorktreeDelete { .. }) => {
worktree_view::render_delete_confirm(frame, app, area)
}
Some(ModalState::ConfirmDirtyDiscard { .. }) => {
worktree_view::render_dirty_discard_confirm(frame, app, area)
}
Some(ModalState::ConfirmAheadDiscard { .. }) => {
worktree_view::render_ahead_discard_confirm(frame, app, area)
}
Some(ModalState::ConfirmForceKill { .. }) | None => {}
}
if app.error_details_popup.is_some() {
popups::render_error_details(frame, app, area);
}
if app.warning_popup.is_some() {
popups::render_warning(frame, app, area);
}
}
mod screens {
use super::*;
pub(super) fn render_select_mode(frame: &mut Frame, app: &mut AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ])
.split(area);
super::render_header(frame, app, chunks[0]);
super::changes_list::render_select(frame, app, chunks[1]);
super::render_footer_select(frame, app, chunks[2]);
}
pub(super) fn render_running_mode(frame: &mut Frame, app: &mut AppState, area: Rect) {
let chunks = if app.logs_panel_enabled {
let (changes_height, logs_height) = super::running_logs_enabled_layout_heights(
area.height,
super::running_changes_visual_row_count(app),
);
Layout::vertical([
Constraint::Length(super::TUI_HEADER_HEIGHT), Constraint::Length(changes_height), Constraint::Length(super::TUI_STATUS_HEIGHT), Constraint::Length(logs_height), ])
.split(area)
} else {
Layout::vertical([
Constraint::Length(super::TUI_HEADER_HEIGHT),
Constraint::Min(super::RUNNING_CHANGES_MIN_HEIGHT),
Constraint::Length(super::TUI_STATUS_HEIGHT),
])
.split(area)
};
super::render_header(frame, app, chunks[0]);
super::changes_list::render_running(frame, app, chunks[1]);
super::status_logs::render_status(frame, app, chunks[2]);
if app.logs_panel_enabled && chunks.len() > 3 {
super::status_logs::render_logs(frame, app, chunks[3]);
}
}
pub(super) fn render_worktree_view(frame: &mut Frame, app: &mut AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ])
.split(area);
super::render_header(frame, app, chunks[0]);
super::worktree_view::render_list(frame, app, chunks[1]);
super::worktree_view::render_footer(frame, app, chunks[2]);
}
}
fn render_select_mode(frame: &mut Frame, app: &mut AppState, area: Rect) {
screens::render_select_mode(frame, app, area);
}
const TUI_HEADER_HEIGHT: u16 = 3;
const TUI_STATUS_HEIGHT: u16 = 3;
const RUNNING_CHANGES_MIN_HEIGHT: u16 = 5;
const RUNNING_LOGS_TARGET_HEIGHT: u16 = 20;
const PANEL_BORDER_HEIGHT: u16 = 2;
fn running_changes_visual_row_count(app: &AppState) -> u16 {
let (rows, _) = build_change_rows(&app.changes);
rows.len().min(u16::MAX as usize) as u16
}
fn running_logs_enabled_layout_heights(area_height: u16, changes_visual_rows: u16) -> (u16, u16) {
let available = area_height.saturating_sub(TUI_HEADER_HEIGHT + TUI_STATUS_HEIGHT);
let desired_changes_height = changes_visual_rows
.saturating_add(PANEL_BORDER_HEIGHT)
.max(RUNNING_CHANGES_MIN_HEIGHT);
if available <= RUNNING_CHANGES_MIN_HEIGHT {
return (available, 0);
}
if available <= RUNNING_CHANGES_MIN_HEIGHT + RUNNING_LOGS_TARGET_HEIGHT {
let changes_height = RUNNING_CHANGES_MIN_HEIGHT.min(available);
return (changes_height, available.saturating_sub(changes_height));
}
let max_changes_with_target_logs = available.saturating_sub(RUNNING_LOGS_TARGET_HEIGHT);
let changes_height = desired_changes_height.min(max_changes_with_target_logs);
let logs_height = available.saturating_sub(changes_height);
(changes_height, logs_height)
}
fn render_running_mode(frame: &mut Frame, app: &mut AppState, area: Rect) {
screens::render_running_mode(frame, app, area);
}
mod changes_list {
use super::*;
pub(super) fn render_select(frame: &mut Frame, app: &mut AppState, area: Rect) {
super::render_changes_list_select(frame, app, area);
}
pub(super) fn render_running(frame: &mut Frame, app: &mut AppState, area: Rect) {
super::render_changes_list_running(frame, app, area);
}
}
mod status_logs {
use super::*;
pub(super) fn render_status(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_status(frame, app, area);
}
pub(super) fn render_logs(frame: &mut Frame, app: &mut AppState, area: Rect) {
super::render_logs(frame, app, area);
}
}
mod worktree_view {
use super::*;
pub(super) fn render(frame: &mut Frame, app: &mut AppState, area: Rect) {
super::screens::render_worktree_view(frame, app, area);
}
pub(super) fn render_list(frame: &mut Frame, app: &mut AppState, area: Rect) {
super::render_worktree_list(frame, app, area);
}
pub(super) fn render_footer(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_footer_worktree(frame, app, area);
}
pub(super) fn render_delete_confirm(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_worktree_delete_confirm(frame, app, area);
}
pub(super) fn render_dirty_discard_confirm(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_worktree_dirty_discard_confirm(frame, app, area);
}
pub(super) fn render_ahead_discard_confirm(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_worktree_ahead_discard_confirm(frame, app, area);
}
}
mod popups {
use super::*;
pub(super) fn render_warning(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_warning_popup(frame, app, area);
}
pub(super) fn render_qr(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_qr_popup(frame, app, area);
}
pub(super) fn render_error_details(frame: &mut Frame, app: &AppState, area: Rect) {
super::render_error_details_popup(frame, app, area);
}
}
fn render_header(frame: &mut Frame, app: &AppState, area: Rect) {
let active_count = app
.changes
.iter()
.filter(|c| is_active_status(&c.display_status_cache))
.count();
let (mode_text, mode_color, show_status) = match &app.modal {
Some(modal @ ModalState::QrPopup) => (modal.title_label().to_string(), Color::Green, true),
Some(modal @ ModalState::ConfirmWorktreeDelete { .. }) => {
(modal.title_label().to_string(), Color::Yellow, true)
}
Some(
modal @ (ModalState::ConfirmDirtyDiscard { .. }
| ModalState::ConfirmAheadDiscard { .. }),
) => (modal.title_label().to_string(), Color::Red, true),
Some(modal @ ModalState::ConfirmForceKill { .. }) => {
(modal.title_label().to_string(), Color::Red, true)
}
None => match app.execution_mode {
AppExecutionMode::Select | AppExecutionMode::Stopped => {
("Ready".to_string(), Color::Cyan, true)
}
AppExecutionMode::Running => {
if active_count > 0 {
(format!("Running {}", active_count), Color::Yellow, true)
} else {
("Running".to_string(), Color::Yellow, true)
}
}
AppExecutionMode::Stopping => ("Stopping".to_string(), Color::Yellow, true),
AppExecutionMode::Error => {
(String::new(), Color::White, false)
}
},
};
let version = get_version_string();
let version_width = version.len() as u16 + 2;
let chunks =
Layout::horizontal([Constraint::Min(1), Constraint::Length(version_width)]).split(area);
let status_segment = if show_status && !mode_text.is_empty() {
format!(" [{}]", mode_text)
} else {
String::new()
};
let dirty_segment = if app.workspace_dirty().shows_dirty_badge() {
" [dirty]"
} else {
""
};
let mut header_spans = vec![Span::styled("Conflux", Style::default().fg(Color::White))];
if !status_segment.is_empty() {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
format!("[{}]", mode_text),
Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
));
}
let project_path = app.project_path().display().to_string();
if !project_path.is_empty() {
let left_interior = chunks[0].width.saturating_sub(1) as usize;
let reserved = display_width("Conflux")
+ display_width(&status_segment)
+ display_width(" ")
+ display_width(dirty_segment);
let path_budget = left_interior.saturating_sub(reserved);
if path_budget > 0 {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
middle_elide_to_display_width(&project_path, path_budget),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
}
}
if !dirty_segment.is_empty() {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
"[dirty]",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
let header_text = Line::from(header_spans);
let left_header = Paragraph::new(header_text).block(
Block::default()
.borders(Borders::LEFT | Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(Color::Blue)),
);
frame.render_widget(left_header, chunks[0]);
let right_header = Paragraph::new(Line::from(vec![Span::styled(
version,
Style::default().fg(Color::DarkGray),
)]))
.block(
Block::default()
.borders(Borders::RIGHT | Borders::TOP | Borders::BOTTOM)
.border_style(Style::default().fg(Color::Blue)),
);
frame.render_widget(right_header, chunks[1]);
}
const MIN_PREVIEW_WIDTH: usize = 10;
const UNCOMMITTED_BADGE: &str = " UNCOMMITTED";
fn change_row_preview_text(app: &AppState, change: &ChangeState) -> Option<String> {
if change.display_status_cache == "error" {
return Some(match change.error_message_cache.as_deref() {
Some(detail) => format!(" Error: {}", detail),
None => format!(" {}", ERROR_DETAILS_UNAVAILABLE),
});
}
let log = app.get_latest_log_for_change(&change.id)?;
let relative_time = format!("({})", format_relative_time(&log.created_at));
let header = match (&log.operation, log.iteration) {
(Some(op), Some(iter)) => format!(" [{}:{}]", op, iter),
(Some(op), None) => format!(" [{}]", op),
(None, _) => String::new(),
};
Some(if !header.is_empty() {
format!(" {}{} {}", relative_time, header, log.message)
} else {
format!(" {} {}", relative_time, log.message)
})
}
fn change_row_preview_color(change: &ChangeState, is_focused_row: bool) -> Color {
if change.display_status_cache == "error" {
return if is_focused_row {
Color::LightRed
} else {
Color::Red
};
}
if is_focused_row {
Color::Gray
} else {
Color::DarkGray
}
}
fn render_changes_list_select(frame: &mut Frame, app: &mut AppState, area: Rect) {
let (rows, change_to_visual) = build_change_rows(&app.changes);
let items: Vec<ListItem> = rows
.iter()
.map(|row| match row {
ChangeRow::Header(label) => {
let line = Line::from(vec![
Span::styled(
format!(" {} ", label),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"─".repeat(area.width.saturating_sub(label.len() as u16 + 5) as usize),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(line)
}
ChangeRow::Item { change_index: i } => {
let i = *i;
let change = &app.changes[i];
let is_post_archive = hides_checkbox(change);
let is_parallel_blocked = !change.is_parallel_eligible()
&& !is_post_archive
&& matches!(
change.display_status_cache.as_str(),
"not queued" | "queued"
);
let show_uncommitted_badge =
is_parallel_blocked && change.has_uncommitted_proposal_files();
let is_selected_row = i == app.cursor_index;
let blocked_fg = if is_selected_row {
Color::Gray
} else {
Color::DarkGray
};
let (checkbox, checkbox_color) = {
let (text, color) = get_checkbox_display(
&change.display_status_cache,
change.selected,
change.archive_complete_cache,
);
(
text,
if is_parallel_blocked {
blocked_fg
} else {
color
},
)
};
let worktree_badge = if change.has_worktree { " WT" } else { "" };
let worktree_color = if is_parallel_blocked {
blocked_fg
} else {
Color::Green
};
let new_badge = if change.is_new && change.display_status_cache != "rejected" {
" NEW"
} else {
""
};
let uncommitted_badge = if show_uncommitted_badge {
UNCOMMITTED_BADGE
} else {
""
};
let dim_color = if is_parallel_blocked {
blocked_fg
} else if is_selected_row {
Color::Gray } else {
Color::DarkGray
};
let name_color = if is_parallel_blocked {
blocked_fg
} else {
Color::White
};
let parsed = split_remote_change_id(&change.id);
let display_id = parsed.change;
let status_text = format!("[{}]", change.status_badge());
let mut spans = vec![
Span::styled(format!("{checkbox} "), Style::default().fg(checkbox_color)),
Span::styled(
render_change_id_field(display_id),
Style::default().fg(name_color),
),
Span::styled(
worktree_badge,
Style::default()
.fg(worktree_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
new_badge,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
uncommitted_badge,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {:>18}", status_text),
Style::default().fg(change.display_color_cache),
),
Span::styled(
format!(" {}/{} tasks", change.completed_tasks, change.total_tasks),
Style::default().fg(dim_color),
),
Span::styled(
format!(" {:>5.1}%", change.progress_percent()),
Style::default().fg(Color::Cyan),
),
];
if let Some(preview_text) = change_row_preview_text(app, change) {
let worktree_badge_width = if change.has_worktree { 3 } else { 0 }; let new_badge_width = if change.is_new { 4 } else { 0 }; let uncommitted_badge_width = if show_uncommitted_badge {
display_width(UNCOMMITTED_BADGE)
} else {
0
};
let status_text = format!("[{}]", change.display_status_cache.as_str());
let status_width = display_width(&format!(" {:>18}", status_text));
let tasks_text =
format!(" {}/{} tasks", change.completed_tasks, change.total_tasks);
let tasks_width = display_width(&tasks_text);
let percent_text = format!(" {:>5.1}%", change.progress_percent());
let percent_width = display_width(&percent_text);
let list_border_width = 2;
let base_width = CHANGE_ROW_PREFIX_WIDTH
+ worktree_badge_width
+ new_badge_width
+ uncommitted_badge_width
+ status_width
+ tasks_width
+ percent_width
+ list_border_width;
let available = (area.width as usize).saturating_sub(base_width);
if available >= MIN_PREVIEW_WIDTH {
let truncated =
truncate_to_display_width_with_suffix(&preview_text, available, "…");
spans.push(Span::styled(
truncated,
Style::default().fg(change_row_preview_color(change, is_selected_row)),
));
}
}
ListItem::new(Line::from(spans))
}
})
.collect();
if !app.changes.is_empty() && app.cursor_index < change_to_visual.len() {
app.list_state
.select(Some(change_to_visual[app.cursor_index]));
}
let has_selection = !app.changes.is_empty();
let has_queue = app.changes.iter().any(|c| c.selected);
let current_item = if has_selection && app.cursor_index < app.changes.len() {
Some(&app.changes[app.cursor_index])
} else {
None
};
let start_key_label = app.start_key_label();
let mut keys = vec!["↑↓/jk: move".to_string()];
if let Some(item) = current_item {
push_change_row_hints(&mut keys, app, item);
keys.push("e: edit".to_string());
if item.display_status_cache == "error" {
keys.push("Enter: details".to_string());
}
if item.display_status_cache == "merge wait"
&& matches!(
app.execution_mode,
AppExecutionMode::Select | AppExecutionMode::Running | AppExecutionMode::Stopped
)
{
if app.is_resolving() {
keys.push("M: queue resolve".to_string());
} else {
keys.push("M: resolve".to_string());
}
}
}
if has_queue {
keys.push(format!("{start_key_label}: run"));
}
if app.has_bulk_toggle_targets() {
keys.push("x: toggle all".to_string());
}
if app.has_dismissable_merged_rows() {
keys.push("D: dismiss all merged".to_string());
}
keys.push("Tab: worktrees".to_string());
if app.web_url.is_some() {
keys.push("w: QR".to_string());
}
keys.push("l: logs".to_string());
let title = format!(" Changes ({}) ", keys.join(", "));
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut app.list_state);
}
fn render_changes_list_running(frame: &mut Frame, app: &mut AppState, area: Rect) {
let spinner_char = SPINNER_CHARS[app.spinner_frame];
let (rows, change_to_visual) = build_change_rows(&app.changes);
let items: Vec<ListItem> = rows
.iter()
.map(|row| match row {
ChangeRow::Header(label) => {
let line = Line::from(vec![
Span::styled(
format!(" {} ", label),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"─".repeat(area.width.saturating_sub(label.len() as u16 + 5) as usize),
Style::default().fg(Color::DarkGray),
),
]);
ListItem::new(line)
}
ChangeRow::Item { change_index: i } => {
let i = *i;
let change = &app.changes[i];
let is_post_archive = hides_checkbox(change);
let is_parallel_blocked = !change.is_parallel_eligible()
&& !is_post_archive
&& matches!(
change.display_status_cache.as_str(),
"not queued" | "queued"
);
let show_uncommitted_badge =
is_parallel_blocked && change.has_uncommitted_proposal_files();
let is_selected_row = i == app.cursor_index;
let blocked_fg = if is_selected_row {
Color::Gray
} else {
Color::DarkGray
};
let (checkbox, checkbox_color) = {
let (text, color) = get_checkbox_display(
&change.display_status_cache,
change.selected,
change.archive_complete_cache,
);
(
text,
if is_parallel_blocked {
blocked_fg
} else {
color
},
)
};
let worktree_badge = if change.has_worktree { " WT" } else { "" };
let worktree_color = if is_parallel_blocked {
blocked_fg
} else {
Color::Green
};
let new_badge = if change.is_new && change.display_status_cache != "rejected" {
" NEW"
} else {
""
};
let uncommitted_badge = if show_uncommitted_badge {
UNCOMMITTED_BADGE
} else {
""
};
let dim_color = if is_parallel_blocked {
blocked_fg
} else if is_selected_row {
Color::Gray } else {
Color::DarkGray
};
let name_color = if is_parallel_blocked {
blocked_fg
} else {
Color::White
};
let net_elapsed = change.current_total_elapsed();
let elapsed_text = match net_elapsed {
Some(elapsed) => format_duration(elapsed),
None => "--".to_string(),
};
let (spinner_prefix, status_text) = match change.display_status_cache.as_str() {
"preparing" => (format!("{} ", spinner_char), "[preparing]".to_string()),
"applying" | "archiving" | "resolving" | "accepting" => {
let status = if let Some(iter) = change.iteration_number {
format!("[{}:{}]", change.display_status_cache.as_str(), iter)
} else {
format!("[{}]", change.display_status_cache.as_str())
};
(format!("{} ", spinner_char), status)
}
_ => (String::new(), format!("[{}]", change.status_badge())),
};
let timing_field = if !spinner_prefix.is_empty() {
Some(format!(" {}{:>7} ", spinner_prefix.trim(), elapsed_text))
} else {
net_elapsed.map(|_| format!(" {:>7} ", elapsed_text))
};
let (spinner_elapsed_width, status_only_width) = match timing_field.as_ref() {
Some(field) => (display_width(field), display_width(&status_text)),
None => {
let status_formatted = format!(" {:>18}", status_text);
(0, display_width(&status_formatted))
}
};
let parsed = split_remote_change_id(&change.id);
let display_id = parsed.change;
let mut spans = vec![
Span::styled(format!("{checkbox} "), Style::default().fg(checkbox_color)),
Span::styled(
render_change_id_field(display_id),
Style::default().fg(name_color),
),
Span::styled(
worktree_badge,
Style::default()
.fg(worktree_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
new_badge,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
uncommitted_badge,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
];
match timing_field {
Some(field) => {
spans.push(Span::styled(field, Style::default().fg(dim_color)));
spans.push(Span::styled(
status_text,
Style::default().fg(change.display_color_cache),
));
}
None => {
spans.push(Span::styled(
format!(" {:>18}", status_text),
Style::default().fg(change.display_color_cache),
));
}
}
let tasks_text = if change.display_status_cache == "applying" {
format!(
" {}/{}({:.0}%)",
change.completed_tasks,
change.total_tasks,
change.progress_percent()
)
} else {
format!(" {}/{}", change.completed_tasks, change.total_tasks)
};
spans.push(Span::styled(
tasks_text.clone(),
Style::default().fg(dim_color),
));
if let Some(preview_text) = change_row_preview_text(app, change) {
let worktree_badge_width = if change.has_worktree { 3 } else { 0 }; let new_badge_width = if change.is_new { 4 } else { 0 }; let uncommitted_badge_width = if show_uncommitted_badge {
display_width(UNCOMMITTED_BADGE)
} else {
0
};
let tasks_width = display_width(&tasks_text);
let list_border_width = 2;
let base_width = CHANGE_ROW_PREFIX_WIDTH
+ worktree_badge_width
+ new_badge_width
+ uncommitted_badge_width
+ spinner_elapsed_width
+ status_only_width
+ tasks_width
+ list_border_width;
let available = (area.width as usize).saturating_sub(base_width);
if available >= MIN_PREVIEW_WIDTH {
let truncated =
truncate_to_display_width_with_suffix(&preview_text, available, "…");
spans.push(Span::styled(
truncated,
Style::default().fg(change_row_preview_color(change, is_selected_row)),
));
}
}
ListItem::new(Line::from(spans))
}
})
.collect();
if !app.changes.is_empty() && app.cursor_index < change_to_visual.len() {
app.list_state
.select(Some(change_to_visual[app.cursor_index]));
}
let has_selection = !app.changes.is_empty();
let current_item = if has_selection && app.cursor_index < app.changes.len() {
Some(&app.changes[app.cursor_index])
} else {
None
};
let mut keys = vec!["↑↓/jk: move".to_string()];
if let Some(item) = current_item {
push_change_row_hints(&mut keys, app, item);
keys.push("e: edit".to_string());
if item.display_status_cache == "error" {
keys.push("Enter: details".to_string());
}
if item.display_status_cache == "merge wait"
&& matches!(
app.execution_mode,
AppExecutionMode::Select | AppExecutionMode::Running | AppExecutionMode::Stopped
)
{
if app.is_resolving() {
keys.push("M: queue resolve".to_string());
} else {
keys.push("M: resolve".to_string());
}
}
}
if app.has_bulk_toggle_targets() {
keys.push("x: toggle all".to_string());
}
if app.has_dismissable_merged_rows() {
keys.push("D: dismiss all merged".to_string());
}
keys.push("Tab: worktrees".to_string());
if app.web_url.is_some() {
keys.push("w: QR".to_string());
}
keys.push("l: logs".to_string());
let new_indicator = if app.new_change_count > 0 {
format!(" New: {} |", app.new_change_count)
} else {
String::new()
};
let title = format!(" Changes ({} {}) ", new_indicator, keys.join(", "));
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(list, area, &mut app.list_state);
}
fn contributes_to_overall_progress(change: &ChangeState) -> bool {
if change.display_status_cache == "rejected" {
return false;
}
change.archive_complete_cache
|| is_completed_status(&change.display_status_cache)
|| is_active_status(&change.display_status_cache)
|| change.selected
}
fn render_status(frame: &mut Frame, app: &AppState, area: Rect) {
let (total_tasks, completed_tasks) = app
.changes
.iter()
.filter(|c| contributes_to_overall_progress(c))
.fold((0u32, 0u32), |(total, completed), c| {
(total + c.total_tasks, completed + c.completed_tasks)
});
let mut spans = vec![];
if total_tasks > 0 {
let percent = (completed_tasks as f32 / total_tasks as f32) * 100.0;
let bar_width = 20;
let filled = ((percent / 100.0) * bar_width as f32) as usize;
let empty = bar_width - filled;
let progress_text = format!(
"[{}{}] {:>5.1}% ({}/{})",
"█".repeat(filled),
"░".repeat(empty),
percent,
completed_tasks,
total_tasks
);
spans.push(Span::styled(
progress_text,
Style::default().fg(Color::Cyan),
));
}
if let Some(started) = app.orchestration_started_at {
let elapsed = if matches!(
app.execution_mode,
AppExecutionMode::Running | AppExecutionMode::Stopping
) {
started.elapsed()
} else {
app.orchestration_elapsed
.unwrap_or_else(|| started.elapsed())
};
if !spans.is_empty() {
spans.push(Span::raw(" | "));
}
spans.push(Span::styled(
format!("Elapsed {}", format_duration(elapsed)),
Style::default().fg(Color::DarkGray),
));
}
let content = Line::from(spans);
let start_key_label = app.start_key_label();
let title = match app.execution_mode {
AppExecutionMode::Select if app.persistent_scheduler_idle => {
format!(" Status ({start_key_label}: start, Esc: stop, Ctrl+C: quit) ")
}
AppExecutionMode::Select => " Status (Ctrl+C: quit) ".to_string(),
AppExecutionMode::Running => " Status (Esc: stop, Ctrl+C: quit) ".to_string(),
AppExecutionMode::Stopping => {
format!(" Status ({start_key_label}: continue, Esc: force stop, Ctrl+C: quit) ")
}
AppExecutionMode::Stopped => format!(" Status ({start_key_label}: resume, Ctrl+C: quit) "),
AppExecutionMode::Error => format!(" Status ({start_key_label}: retry, Ctrl+C: quit) "),
};
let status = Paragraph::new(content).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
);
frame.render_widget(status, area);
}
fn log_navigation_hint() -> &'static str {
"PgUp/PgDn: older/newer Home/End: oldest/newest l: hide"
}
fn selected_proposal_filter_hint(filter_enabled: bool, filter_target: Option<&str>) -> String {
match (filter_enabled, filter_target) {
(false, _) => compact_selected_proposal_filter_hint(false).to_string(),
(true, Some(target)) => format!("f: filter={}", target),
(true, None) => compact_selected_proposal_filter_hint(true).to_string(),
}
}
fn compact_selected_proposal_filter_hint(filter_enabled: bool) -> &'static str {
if filter_enabled {
"f: filter on"
} else {
"f: filter off"
}
}
struct LogsPanelTitle<'a> {
lines_below: usize,
log_auto_scroll: bool,
total_display_lines: usize,
visible_height: usize,
start_line: usize,
end_line: usize,
filter_enabled: bool,
filter_target: Option<&'a str>,
panel_width: usize,
}
fn logs_panel_title(params: LogsPanelTitle<'_>) -> String {
let auto_scroll_indicator = if params.log_auto_scroll { "▼" } else { "⏸" };
let help = log_navigation_hint();
let build = |filter_hint: &str| {
if params.total_display_lines > params.visible_height {
let visible_start = params.start_line + 1;
let visible_end = params.end_line;
format!(
" Logs [{}-{}/{} lines] lines_below={} {} ({} | {}) ",
visible_start,
visible_end,
params.total_display_lines,
params.lines_below,
auto_scroll_indicator,
filter_hint,
help
)
} else {
format!(
" Logs lines_below={} {} ({} | {}) ",
params.lines_below, auto_scroll_indicator, filter_hint, help
)
}
};
let title = build(&selected_proposal_filter_hint(
params.filter_enabled,
params.filter_target,
));
if params.panel_width > 0 && title.chars().count() > params.panel_width {
return build(compact_selected_proposal_filter_hint(params.filter_enabled));
}
title
}
fn render_logs(frame: &mut Frame, app: &mut AppState, area: Rect) {
app.set_log_viewport(area.width as usize, area.height as usize);
let visible_height = app.log_viewport.visible_height();
let change_colors = [
Color::Cyan,
Color::Magenta,
Color::LightBlue,
Color::LightGreen,
Color::LightYellow,
Color::LightMagenta,
Color::LightCyan,
];
struct RenderedLogChrome {
timestamp: String,
timestamp_style: Style,
header: String,
header_style: Style,
message_style: Style,
}
let entries = app.visible_log_entries();
let chrome: Vec<RenderedLogChrome> = entries
.iter()
.map(|(_, entry)| {
let timestamp = format!("{} ", entry.timestamp);
let header = log_logic::logs_panel_header(entry);
let header_style = if header.is_empty() {
Style::default()
} else {
let color_index = if let Some(ref change_id) = entry.change_id {
change_id
.bytes()
.fold(0usize, |acc, b| acc.wrapping_add(b as usize))
% change_colors.len()
} else {
0
};
Style::default()
.fg(change_colors[color_index])
.add_modifier(Modifier::BOLD)
};
RenderedLogChrome {
timestamp,
timestamp_style: Style::default().fg(Color::DarkGray),
header,
header_style,
message_style: Style::default().fg(entry.color),
}
})
.collect();
let lines = log_logic::build_log_display_lines(&entries, area.width as usize);
let total_display_lines = lines.len();
let start_line = app.log_start_line(&lines);
let end_line = total_display_lines.min(start_line + visible_height);
let log_items: Vec<Line> = lines[start_line..end_line]
.iter()
.map(|line| {
let chrome = &chrome[line.entry_index];
let mut spans = Vec::new();
if line.is_first {
spans.push(Span::styled(
chrome.timestamp.clone(),
chrome.timestamp_style,
));
if !chrome.header.is_empty() {
spans.push(Span::styled(chrome.header.clone(), chrome.header_style));
}
}
spans.push(Span::styled(line.text.clone(), chrome.message_style));
Line::from(spans)
})
.collect();
let title = logs_panel_title(LogsPanelTitle {
lines_below: total_display_lines.saturating_sub(end_line),
log_auto_scroll: app.log_auto_scroll,
total_display_lines,
visible_height,
start_line,
end_line,
filter_enabled: app.selected_proposal_log_filter,
filter_target: app.selected_proposal_log_filter_target(),
panel_width: area.width as usize,
});
let logs = Paragraph::new(log_items).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
);
frame.render_widget(logs, area);
}
fn render_footer_select(frame: &mut Frame, app: &AppState, area: Rect) {
let selected = app.selected_count();
let new_count = app.new_change_count;
let start_key_label = app.start_key_label();
let mut spans = vec![
Span::styled(
format!("Selected: {} changes", selected),
Style::default().fg(Color::Green),
),
Span::raw(" | "),
];
if new_count > 0 {
spans.push(Span::styled(
format!("New: {}", new_count),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" | "));
}
if let Some(warning) = &app.warning_message {
spans.push(Span::styled(
warning.clone(),
Style::default().fg(Color::Red),
));
} else if app.changes.is_empty() {
spans.push(Span::styled(
"Add new changes to get started",
Style::default().fg(Color::DarkGray),
));
} else if selected == 0 {
let retryable_error_rows = app
.changes
.iter()
.any(|change| change.display_status_cache == "error");
let message = if retryable_error_rows {
"Select changes with Space to process (error rows need retry mark)"
} else {
"Select changes with Space to process"
};
spans.push(Span::styled(message, Style::default().fg(Color::Yellow)));
} else {
spans.push(Span::styled(
format!("Press {start_key_label} to start processing"),
Style::default().fg(Color::Cyan),
));
}
let footer = Paragraph::new(Line::from(spans)).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
);
frame.render_widget(footer, area);
}
fn render_worktree_list(frame: &mut Frame, app: &mut AppState, area: Rect) {
use crate::tui::types::ViewMode;
if app.view_mode != ViewMode::Worktrees {
return;
}
let block = Block::default()
.borders(Borders::ALL)
.title(" Worktrees ")
.border_style(Style::default().fg(Color::Cyan));
let inner_area = block.inner(area);
frame.render_widget(block, area);
if app.worktrees.is_empty() {
let empty_msg = Paragraph::new("No worktrees found")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(empty_msg, inner_area);
return;
}
let items: Vec<ListItem> = app
.worktrees
.iter()
.enumerate()
.map(|(idx, wt)| {
let is_selected = idx == app.worktree_cursor_index;
let label = wt.display_label();
let branch = wt.display_branch();
let conflict_badge = if wt.has_merge_conflict() {
format!(" ⚠{}", wt.conflict_file_count())
} else {
String::new()
};
let indicator = if wt.is_main {
" [MAIN]"
} else if wt.is_detached {
" [DETACHED]"
} else {
""
};
let merge_status = wt.merge_status_label();
let merge_indicator = if !merge_status.is_empty() {
format!(" [{}]", merge_status)
} else {
String::new()
};
let inspection_status = wt.inspection_label();
let inspection_indicator = if inspection_status.is_empty() {
String::new()
} else {
format!(" [{}]", inspection_status)
};
let deleting = app.is_worktree_deleting(&wt.path);
let delete_indicator = if deleting { " [Deleting...]" } else { "" };
let line = format!(
"{} → {}{}{}{}{}{}",
label,
branch,
indicator,
merge_indicator,
inspection_indicator,
conflict_badge,
delete_indicator
);
let mut style = Style::default();
if deleting {
style = style.fg(Color::Yellow);
} else if wt.has_merge_conflict() {
style = style.fg(Color::Red);
} else if wt.is_main {
style = style.fg(Color::Green);
} else {
style = style.fg(Color::White);
}
if is_selected {
style = style.add_modifier(Modifier::BOLD).bg(Color::DarkGray);
}
ListItem::new(line).style(style)
})
.collect();
let list = List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
app.worktree_list_state
.select(Some(app.worktree_cursor_index));
frame.render_stateful_widget(list, inner_area, &mut app.worktree_list_state);
}
fn render_footer_worktree(frame: &mut Frame, app: &AppState, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner_area = block.inner(area);
frame.render_widget(block, area);
let mut key_hints = vec![("Tab", "changes"), ("↑↓/jk", "navigate"), ("+", "create")];
if let Some(wt) = app.get_selected_worktree() {
if !wt.is_main && !wt.is_detached {
key_hints.push(("D", "delete"));
}
if !app.is_resolving()
&& matches!(
guards::validate_worktree_mergeable(wt),
guards::MergeGuardResult::Allowed
)
{
key_hints.push(("M", "merge"));
}
}
key_hints.push(("e", "editor"));
key_hints.push(("Enter", "shell"));
key_hints.push(("Ctrl+C", "quit"));
let hints_text = key_hints
.iter()
.map(|(k, v)| format!("{}: {}", k, v))
.collect::<Vec<_>>()
.join(" ");
let status = if let Some(ref msg) = app.warning_message {
Span::styled(msg, Style::default().fg(Color::Yellow))
} else if let Some(label) = app.deleting_worktree_status_label() {
Span::styled(
format!("Deleting worktree: {}", label),
Style::default().fg(Color::Yellow),
)
} else {
let count = app.worktrees.len();
Span::styled(
format!("{} worktree{}", count, if count == 1 { "" } else { "s" }),
Style::default().fg(Color::DarkGray),
)
};
let footer_line = Line::from(vec![
status,
Span::raw(" | "),
Span::styled(hints_text, Style::default().fg(Color::Cyan)),
]);
let footer = Paragraph::new(footer_line).alignment(Alignment::Left);
frame.render_widget(footer, inner_area);
}
fn render_worktree_delete_confirm(frame: &mut Frame, app: &AppState, area: Rect) {
let Some(ModalState::ConfirmWorktreeDelete { path, .. }) = &app.modal else {
return;
};
let path = path.display();
let modal_width = (area.width * 60 / 100).clamp(40, 90);
let modal_height = (area.height * 30 / 100).clamp(9, 12).min(area.height);
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(" Delete Worktree ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner_area = block.inner(modal_area);
frame.render_widget(block, modal_area);
let lines = vec![
Line::from(Span::styled(
format!("Delete worktree at '{}'?", path),
Style::default().fg(Color::Yellow),
)),
Line::from(""),
Line::from(Span::styled(
"This will remove the worktree directory permanently, including",
Style::default().fg(Color::DarkGray),
)),
Line::from(Span::styled(
"generated and ignored contents inside it.",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
"Y: run teardown and delete S: skip teardown and delete",
Style::default().fg(Color::White),
)),
Line::from(Span::styled(
"N or Esc: cancel",
Style::default().fg(Color::White),
)),
];
let body = Paragraph::new(lines);
frame.render_widget(body, inner_area);
}
fn render_worktree_dirty_discard_confirm(frame: &mut Frame, app: &AppState, area: Rect) {
let Some(ModalState::ConfirmDirtyDiscard {
path,
branch,
skip_teardown,
..
}) = &app.modal
else {
return;
};
let modal_width = (area.width * 70 / 100).clamp(46, 100);
let modal_height = (area.height * 40 / 100).clamp(12, 16).min(area.height);
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(" Discard Uncommitted Changes ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner_area = block.inner(modal_area);
frame.render_widget(block, modal_area);
let teardown_line = if *skip_teardown {
"Teardown will be skipped (S was pressed)."
} else {
"Teardown will run before removal (Y was pressed)."
};
let lines = vec![
Line::from(Span::styled(
format!("'{}' has uncommitted changes.", branch),
Style::default().fg(Color::Red),
)),
Line::from(Span::styled(
format!("{}", path.display()),
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
"Tracked and staged edits and reported untracked files will be",
Style::default().fg(Color::Yellow),
)),
Line::from(Span::styled(
"permanently lost. Generated and ignored contents of the",
Style::default().fg(Color::Yellow),
)),
Line::from(Span::styled(
"directory go with it. Nothing is stashed, committed, or backed up.",
Style::default().fg(Color::Yellow),
)),
Line::from(""),
Line::from(Span::styled(
teardown_line,
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
"Press uppercase X to discard and delete, N or Esc to keep it.",
Style::default().fg(Color::White),
)),
];
let body = Paragraph::new(lines);
frame.render_widget(body, inner_area);
}
fn render_worktree_ahead_discard_confirm(frame: &mut Frame, app: &AppState, area: Rect) {
let Some(ModalState::ConfirmAheadDiscard {
path,
branch,
head,
dirty,
skip_teardown,
..
}) = &app.modal
else {
return;
};
let modal_width = (area.width * 70 / 100).clamp(46, 100);
let modal_height = (area.height * 45 / 100).clamp(15, 18).min(area.height);
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(" Discard Unmerged Commits ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner_area = block.inner(modal_area);
frame.render_widget(block, modal_area);
let teardown_line = if *skip_teardown {
"Teardown will be skipped (S was pressed)."
} else {
"Teardown will run before removal (Y was pressed)."
};
let mut lines = vec![
Line::from(Span::styled(
format!("'{}' has commits that base does not have.", branch),
Style::default().fg(Color::Red),
)),
Line::from(Span::styled(
format!("{} at {}", path.display(), head),
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
"The worktree, the local branch, and every unmerged commit on it",
Style::default().fg(Color::Yellow),
)),
Line::from(Span::styled(
"will be permanently deleted. Nothing is merged, pushed, tagged,",
Style::default().fg(Color::Yellow),
)),
Line::from(Span::styled(
"stashed, or backed up first, and the commits are not recoverable.",
Style::default().fg(Color::Yellow),
)),
];
if *dirty {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"This worktree also has uncommitted changes. They are discarded",
Style::default().fg(Color::Yellow),
)));
lines.push(Line::from(Span::styled(
"by the same keypress.",
Style::default().fg(Color::Yellow),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
teardown_line,
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press uppercase X to discard and delete, N or Esc to keep it.",
Style::default().fg(Color::White),
)));
let body = Paragraph::new(lines);
frame.render_widget(body, inner_area);
}
fn warning_popup_modal_area(area: Rect) -> Rect {
let modal_width = (area.width.saturating_mul(85) / 100)
.max(40)
.min(area.width.saturating_sub(2).max(1));
let modal_height = (area.height.saturating_mul(70) / 100)
.max(10)
.min(area.height.saturating_sub(2).max(1));
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
Rect::new(modal_x, modal_y, modal_width, modal_height)
}
fn warning_popup_message_lines(message: &str) -> Vec<Line<'_>> {
message.split('\n').map(Line::from).collect()
}
fn render_warning_popup(frame: &mut Frame, app: &AppState, area: Rect) {
let Some(popup) = &app.warning_popup else {
return;
};
let modal_area = warning_popup_modal_area(area);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(format!(" {} ", popup.title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let inner_area = block.inner(modal_area);
frame.render_widget(block, modal_area);
let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner_area);
let body = Paragraph::new(warning_popup_message_lines(&popup.message))
.style(Style::default().fg(Color::Yellow))
.scroll((app.warning_popup_scroll, 0))
.wrap(Wrap { trim: false });
frame.render_widget(body, chunks[0]);
let footer = Paragraph::new(Line::from(vec![
Span::styled("↑↓/jk PgUp/PgDn", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(" close"),
]))
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(footer, chunks[1]);
}
fn error_details_popup_lines(popup: &ErrorDetailsPopup) -> Vec<Line<'_>> {
let mut lines = vec![
Line::from(vec![
Span::styled("Change: ", Style::default().fg(Color::Cyan)),
Span::styled(popup.change_id.as_str(), Style::default().fg(Color::White)),
]),
Line::from(""),
];
lines.extend(
popup
.error
.split('\n')
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::LightRed)))),
);
lines
}
fn render_error_details_popup(frame: &mut Frame, app: &AppState, area: Rect) {
let Some(popup) = &app.error_details_popup else {
return;
};
let modal_area = warning_popup_modal_area(area);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(" Error Details ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner_area = block.inner(modal_area);
frame.render_widget(block, modal_area);
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner_area);
let body = Paragraph::new(error_details_popup_lines(popup))
.scroll((popup.scroll, 0))
.wrap(Wrap { trim: false });
frame.render_widget(body, chunks[0]);
let feedback = match &popup.copy_feedback {
Some(feedback) => {
let color = match feedback {
CopyFeedback::Copied => Color::Green,
CopyFeedback::Failed(_) => Color::Yellow,
};
Paragraph::new(feedback.message()).style(Style::default().fg(color))
}
None => Paragraph::new(""),
};
frame.render_widget(feedback, chunks[1]);
let footer = Paragraph::new(Line::from(vec![
Span::styled("↑↓/jk PgUp/PgDn", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("c", Style::default().fg(Color::Cyan)),
Span::raw(": copy "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(": close"),
]))
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
frame.render_widget(footer, chunks[2]);
}
fn render_qr_popup(frame: &mut Frame, app: &AppState, area: Rect) {
let url = match &app.web_url {
Some(url) => url.as_str(),
None => return,
};
let qr_content = match super::qr::generate_qr_string(url) {
Ok(qr) => qr,
Err(e) => format!("Failed to generate QR code: {}", e),
};
let qr_lines: Vec<&str> = qr_content.lines().collect();
let qr_height = qr_lines.len() as u16;
let qr_width = qr_lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0) as u16;
let modal_width = (qr_width + 4).max(40).min(area.width - 4);
let modal_height = (qr_height + 6).max(10).min(area.height - 4);
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let block = Block::default()
.title(" Web UI QR Code (press any key to close) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let inner_area = block.inner(modal_area);
frame.render_widget(block, modal_area);
let content_chunks = Layout::vertical([
Constraint::Min(1), Constraint::Length(2), ])
.split(inner_area);
let qr_lines: Vec<Line> = qr_content
.lines()
.map(|line| Line::from(Span::raw(line)))
.collect();
let qr_paragraph = Paragraph::new(qr_lines)
.alignment(ratatui::layout::Alignment::Center)
.style(Style::default().fg(Color::White));
frame.render_widget(qr_paragraph, content_chunks[0]);
let url_text = Line::from(vec![
Span::styled("URL: ", Style::default().fg(Color::DarkGray)),
Span::styled(url, Style::default().fg(Color::Cyan)),
]);
let url_paragraph = Paragraph::new(url_text).alignment(ratatui::layout::Alignment::Center);
frame.render_widget(url_paragraph, content_chunks[1]);
}
#[cfg(test)]
mod tests {
use super::log_logic::wrap_log_message;
use super::*;
use crate::openspec::Change;
use crate::openspec::ProposalMetadata;
use crate::orchestration::operator_command::ParallelEligibility;
use crate::tui::config::TuiConfig;
use crate::tui::events::LogEntry;
use crate::tui::types::{ViewMode, WorktreeInfo};
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::Terminal;
use std::collections::HashSet;
fn create_test_change(id: &str) -> Change {
Change {
id: id.to_string(),
completed_tasks: 0,
total_tasks: 3,
last_modified: "now".to_string(),
dependencies: Vec::new(),
metadata: ProposalMetadata::default(),
}
}
const TEST_PROJECT_PATH: &str = "/projects/conflux";
fn create_test_app(changes: Vec<Change>) -> AppState {
let mut app = AppState::new(changes);
app.logs.clear();
app.web_url = None;
app.set_project_path(TEST_PROJECT_PATH);
app
}
fn create_test_worktree(path: &str, branch: &str) -> WorktreeInfo {
WorktreeInfo {
path: path.into(),
head: "abc123".to_string(),
branch: branch.to_string(),
is_detached: false,
is_main: false,
merge_conflict: None,
has_commits_ahead: true,
is_merging: false,
inspection: crate::worktree_ops::InspectionState::Checked,
}
}
fn render_buffer(app: &mut AppState, width: u16, height: u16) -> Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal init");
terminal.draw(|frame| render(frame, app)).expect("draw");
terminal.backend().buffer().clone()
}
fn buffer_to_string(buffer: &Buffer) -> String {
let mut lines = Vec::new();
for y in 0..buffer.area.height {
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, y)].symbol());
}
lines.push(line);
}
lines.join("\n")
}
#[test]
fn no_execution_mode_toggle_or_badge_is_ever_rendered() {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = mode;
let buffer = render_buffer(&mut app, 120, 30);
let content = buffer_to_string(&buffer);
for forbidden in [
"=: parallel",
"=: sequential",
"[parallel:",
"[workspaces:",
] {
assert!(
!content.contains(forbidden),
"{mode:?} must not render '{forbidden}':\n{content}"
);
}
assert!(
content.contains(TEST_PROJECT_PATH),
"{mode:?} must identify the owned project:\n{content}"
);
}
}
fn header_line(buffer: &Buffer) -> String {
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, 1)].symbol());
}
line
}
fn observe_workspace_dirty(app: &mut AppState, dirty: bool) {
app.adopt_workspace_dirty_observation(
crate::tui::events::TuiRefreshObservation::WorkspaceDirty { dirty },
);
}
#[test]
fn workspace_dirty_header_shows_the_badge_only_for_an_observed_dirty_workspace() {
let cases: [(&str, Option<bool>, bool); 3] = [
("unknown", None, false),
("clean", Some(false), false),
("dirty", Some(true), true),
];
for (label, observation, expects_badge) in cases {
let mut app = create_test_app(vec![create_test_change("change-a")]);
if let Some(dirty) = observation {
observe_workspace_dirty(&mut app, dirty);
}
let buffer = render_buffer(&mut app, 120, 30);
let header = header_line(&buffer);
assert_eq!(
header.contains("[dirty]"),
expects_badge,
"{label} observation rendered the wrong badge state:\n{header}"
);
assert!(
header.contains("[Ready]"),
"{label}: the status label must survive:\n{header}"
);
assert!(
header.contains(TEST_PROJECT_PATH),
"{label}: the project path must survive:\n{header}"
);
assert!(
!header.contains("[workspaces:"),
"{label}: the retired concurrency/backend badge must not return:\n{header}"
);
assert!(
header.contains(&crate::tui::utils::get_version_string()),
"{label}: the right-aligned version area must still render:\n{header}"
);
}
let mut app = create_test_app(vec![create_test_change("change-a")]);
observe_workspace_dirty(&mut app, true);
let buffer = render_buffer(&mut app, 120, 30);
let header = header_line(&buffer);
let project_at = header.find(TEST_PROJECT_PATH).expect("project path");
let dirty_at = header.find("[dirty]").expect("dirty badge");
assert!(
project_at < dirty_at,
"the dirty badge must follow the project path:\n{header}"
);
let cell = buffer
.cell((dirty_at as u16, 1))
.expect("dirty badge cell exists");
assert_eq!(cell.style().fg, Some(Color::Red));
assert!(
cell.style().add_modifier.contains(Modifier::BOLD),
"the dirty badge must render bold"
);
}
#[test]
fn workspace_dirty_header_coexists_with_status_modal_and_version_content() {
for width in [80, 120, 200] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].set_display_status_cache("applying");
observe_workspace_dirty(&mut app, true);
let buffer = render_buffer(&mut app, width, 30);
let header = header_line(&buffer);
assert!(
header.contains("[Running 1]"),
"width {width}: the running status must survive:\n{header}"
);
assert!(
header.contains(TEST_PROJECT_PATH) && header.contains("[dirty]"),
"width {width}: the project path and the dirty badge must render:\n{header}"
);
assert!(
header.contains(&crate::tui::utils::get_version_string()),
"width {width}: the version area must still render:\n{header}"
);
app.modal = Some(ModalState::QrPopup);
let buffer = render_buffer(&mut app, width, 30);
let header = header_line(&buffer);
assert!(
header.contains("[dirty]"),
"width {width}: a modal must not suppress the badge:\n{header}"
);
assert!(
!header.contains("[Running 1]"),
"width {width}: the modal still owns the status label:\n{header}"
);
}
}
fn assert_elision_retains_source_sides(source: &str, elided: &str) {
let (prefix, suffix) = elided
.split_once(MIDDLE_ELLIPSIS)
.unwrap_or_else(|| panic!("{elided:?} carries no ellipsis"));
assert!(
source.starts_with(prefix),
"{elided:?} kept a prefix {prefix:?} that {source:?} does not start with"
);
assert!(
source.ends_with(suffix),
"{elided:?} kept a suffix {suffix:?} that {source:?} does not end with"
);
}
#[test]
fn middle_elision_splits_an_ascii_path_around_one_ellipsis() {
let path = "/projects/conflux";
assert_eq!(display_width(path), 17);
assert_eq!(middle_elide_to_display_width(path, 17), path);
assert_eq!(middle_elide_to_display_width(path, 40), path);
assert_eq!(middle_elide_to_display_width(path, 9), "/pro…flux");
assert_eq!(middle_elide_to_display_width(path, 10), "/pro…nflux");
assert_eq!(middle_elide_to_display_width(path, 16), "/projec…/conflux");
for budget in [9, 10, 16] {
let elided = middle_elide_to_display_width(path, budget);
assert_eq!(display_width(&elided), budget);
assert_elision_retains_source_sides(path, &elided);
}
}
#[test]
fn middle_elision_measures_wide_unicode_in_display_columns() {
let path = "/日本語/conflux";
assert_eq!(display_width(path), 15);
assert_eq!(path.chars().count(), 12);
assert_eq!(middle_elide_to_display_width(path, 8), "/日…flux");
assert_eq!(middle_elide_to_display_width(path, 7), "/日…lux");
for budget in 1..=display_width(path) {
let elided = middle_elide_to_display_width(path, budget);
assert!(
display_width(&elided) <= budget,
"budget {budget} produced {elided:?}, which is wider than its budget"
);
}
}
#[test]
fn middle_elision_keeps_combining_marks_with_their_base_characters() {
let path = "/a\u{301}bcdefg/hij\u{301}";
assert_eq!(display_width(path), 12);
let elided = middle_elide_to_display_width(path, 7);
assert_eq!(elided, "/a\u{301}b…hij\u{301}");
assert_eq!(display_width(&elided), 7);
assert_elision_retains_source_sides(path, &elided);
let (prefix, suffix) = elided.split_once(MIDDLE_ELLIPSIS).expect("ellipsis");
assert!(!prefix.ends_with('\u{301}') || prefix.chars().count() > 1);
assert!(
!suffix.starts_with('\u{301}'),
"{suffix:?} orphans a combining mark onto the ellipsis"
);
}
#[test]
fn middle_elision_stays_bounded_for_budgets_no_wider_than_the_ellipsis() {
let samples = [
"/projects/conflux",
"/日本語/conflux",
"/a\u{301}bcdefg/hij\u{301}",
"/",
];
for source in samples {
assert_eq!(middle_elide_to_display_width(source, 0), "");
for budget in 0..=display_width(source) + 2 {
let elided = middle_elide_to_display_width(source, budget);
assert!(
display_width(&elided) <= budget,
"{source:?} at budget {budget} produced {elided:?}"
);
}
}
assert_eq!(middle_elide_to_display_width("/projects/conflux", 1), "…");
assert_eq!(middle_elide_to_display_width("/日本語/conflux", 1), "…");
}
#[test]
fn header_shows_the_captured_project_path_instead_of_the_workspaces_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let buffer = render_buffer(&mut app, 120, 30);
let header = header_line(&buffer);
assert!(
header.contains(TEST_PROJECT_PATH),
"the captured project path must be visible in full:\n{header}"
);
assert!(
!header.contains("[workspaces:"),
"the concurrency/backend badge must be gone:\n{header}"
);
assert!(
!header.contains(MIDDLE_ELLIPSIS),
"a path that fits must not be elided:\n{header}"
);
}
#[test]
fn header_project_identity_comes_only_from_the_captured_startup_path() {
let mut owned = create_test_app(vec![create_test_change("change-a")]);
owned.set_project_path("/projects/conflux");
let mut elsewhere = create_test_app(vec![create_test_change("change-a")]);
elsewhere.set_project_path("/tmp");
let owned_header = header_line(&render_buffer(&mut owned, 120, 30));
let elsewhere_header = header_line(&render_buffer(&mut elsewhere, 120, 30));
assert!(
owned_header.contains("/projects/conflux"),
"the captured project must be the one shown:\n{owned_header}"
);
assert!(
!owned_header.contains("/tmp"),
"an unrelated directory must not appear:\n{owned_header}"
);
assert!(
elsewhere_header.contains("/tmp"),
"the other app shows its own captured path:\n{elsewhere_header}"
);
let _ = render_buffer(&mut owned, 120, 30);
assert_eq!(
owned.project_path(),
std::path::Path::new("/projects/conflux")
);
}
#[test]
fn header_middle_elides_a_project_path_wider_than_its_budget() {
let long_path =
"/Users/operator/very/deeply/nested/checkouts/organization/conflux-worktrees/show-project-path-in-tui-header";
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.set_project_path(long_path);
observe_workspace_dirty(&mut app, true);
let buffer = render_buffer(&mut app, 80, 30);
let header = header_line(&buffer);
assert!(
!header.contains(long_path),
"the full path cannot fit at width 80:\n{header}"
);
assert!(
header.contains(MIDDLE_ELLIPSIS),
"an over-wide path must be elided:\n{header}"
);
assert!(
header.contains("/Users/") && header.contains("-tui-header"),
"both the path prefix and its distinguishing tail must survive:\n{header}"
);
assert!(
header.contains("[Ready]") && header.contains("[dirty]"),
"elision must not displace the other header segments:\n{header}"
);
assert!(
header.contains(&crate::tui::utils::get_version_string()),
"the right-aligned version must stay intact:\n{header}"
);
let second_row = {
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, 2)].symbol());
}
line
};
assert!(
!second_row.contains("-tui-header"),
"the header must not wrap onto the next row:\n{second_row}"
);
}
#[test]
fn header_project_path_rendering_stays_bounded_at_narrow_widths() {
for width in [1u16, 2, 4, 8, 12, 20, 24, 30, 40, 60] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.set_project_path("/Users/operator/checkouts/conflux");
observe_workspace_dirty(&mut app, true);
let buffer = render_buffer(&mut app, width, 20);
let header = header_line(&buffer);
assert_eq!(
header.chars().count(),
width as usize,
"width {width}: the header row must fill exactly its terminal width"
);
assert!(
header.matches(MIDDLE_ELLIPSIS).count() <= 1,
"width {width}: at most one elision gap belongs in the header:\n{header}"
);
}
}
fn find_row_containing(buffer: &Buffer, needle: &str) -> Option<u16> {
for y in 0..buffer.area.height {
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, y)].symbol());
}
if line.contains(needle) {
return Some(y);
}
}
None
}
#[test]
fn running_logs_enabled_layout_expands_logs_for_few_changes() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("expanded log area"));
let buffer = render_buffer(&mut app, 100, 50);
assert_eq!(find_row_containing(&buffer, " Logs"), Some(11));
assert_eq!(find_row_containing(&buffer, " Status"), Some(8));
}
#[test]
fn running_logs_enabled_layout_keeps_target_logs_height_for_many_changes() {
let changes = (0..30)
.map(|index| create_test_change(&format!("change-{index:02}")))
.collect();
let mut app = create_test_app(changes);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("target log area"));
let buffer = render_buffer(&mut app, 100, 50);
assert_eq!(find_row_containing(&buffer, " Logs"), Some(30));
assert_eq!(find_row_containing(&buffer, " Status"), Some(27));
}
#[test]
fn running_mode_shows_new_change_indicator_with_logs_and_many_changes() {
let changes = (0..30)
.map(|index| create_test_change(&format!("change-{index:02}")))
.collect();
let mut app = create_test_app(changes);
app.execution_mode = AppExecutionMode::Running;
app.logs_panel_enabled = true;
app.new_change_count = 1;
app.changes[29].is_new = true;
app.add_log(LogEntry::info("log area"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("New: 1"));
assert!(content.contains(" Logs"));
}
#[test]
fn running_logs_disabled_layout_does_not_render_logs_panel() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.logs_panel_enabled = false;
app.add_log(LogEntry::info("hidden log area"));
let buffer = render_buffer(&mut app, 100, 50);
let content = buffer_to_string(&buffer);
assert!(content.contains(" Status"));
assert!(!content.contains(" Logs"));
assert!(!content.contains("hidden log area"));
}
#[test]
fn worktree_view_renders_deleting_badge_and_footer_status() {
let mut app = create_test_app(vec![]);
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![create_test_worktree("/tmp/worktree-a", "feature-a")];
app.mark_worktree_deleting("/tmp/worktree-a");
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("[Deleting...]"));
assert!(content.contains("Deleting worktree: worktree-a"));
}
#[test]
fn warning_popup_message_lines_preserve_explicit_newlines() {
let lines = warning_popup_message_lines("first\nsecond\nthird");
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].spans[0].content, "first");
assert_eq!(lines[1].spans[0].content, "second");
assert_eq!(lines[2].spans[0].content, "third");
}
#[test]
fn warning_popup_render_shows_footer_hint_and_multiline_content() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.show_warning_popup(
"Hook failed",
"first diagnostic line\nsecond diagnostic line",
);
let buffer = render_buffer(&mut app, 100, 30);
let content = buffer_to_string(&buffer);
assert!(content.contains("Hook failed"));
assert!(content.contains("first diagnostic line"));
assert!(content.contains("second diagnostic line"));
assert!(content.contains("PgUp/PgDn"));
assert!(content.contains("Esc"));
}
#[test]
fn warning_popup_uses_diagnostics_sized_modal_area() {
let area = Rect::new(0, 0, 100, 30);
let modal = warning_popup_modal_area(area);
assert_eq!(modal.width, 85);
assert_eq!(modal.height, 21);
}
#[test]
fn qr_popup_render_shows_url_and_close_hint() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.web_url = Some("http://127.0.0.1:8080".to_string());
app.show_qr_popup();
let buffer = render_buffer(&mut app, 100, 40);
let content = buffer_to_string(&buffer);
assert!(content.contains("Web UI QR Code"));
assert!(content.contains("http://127.0.0.1:8080"));
}
#[test]
fn remote_grouping_characterization_shows_project_headers_and_bare_change_ids() {
let mut app = create_test_app(vec![
create_test_change("p1::alpha/change-one"),
create_test_change("p2::beta/change-two"),
]);
app.cursor_index = 1;
let buffer = render_buffer(&mut app, 120, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("alpha"));
assert!(content.contains("beta"));
assert!(content.contains("change-one"));
assert!(content.contains("change-two"));
assert!(!content.contains("p1::alpha/change-one"));
}
#[test]
fn status_logs_characterization_shows_progress_elapsed_and_log_header() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].selected = true;
app.changes[0].completed_tasks = 1;
app.changes[0].total_tasks = 4;
app.orchestration_started_at = Some(std::time::Instant::now());
app.add_log(
LogEntry::info("Applying patch")
.with_change_id("change-a")
.with_operation("apply")
.with_iteration(2),
);
let buffer = render_buffer(&mut app, 120, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("25.0%"));
assert!(content.contains("Elapsed"));
assert!(content.contains("[change-a:apply:2]"));
assert!(content.contains("Applying patch"));
}
#[test]
fn worktree_view_characterization_shows_key_hints_and_delete_confirmation() {
let mut app = create_test_app(vec![]);
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![create_test_worktree("/tmp/worktree-a", "feature-a")];
app.modal = Some(ModalState::ConfirmWorktreeDelete {
path: std::path::PathBuf::from("/tmp/worktree-a"),
branch: "feature-a".to_string(),
});
let buffer = render_buffer(&mut app, 120, 30);
let content = buffer_to_string(&buffer);
assert!(content.contains("Worktrees"));
assert!(content.contains("feature-a"));
assert!(content.contains("Tab: changes"));
assert!(content.contains("D: delete"));
assert!(content.contains("Delete Worktree"));
assert!(content.contains("Y: run teardown and delete"));
assert!(content.contains("S: skip teardown and delete"));
assert!(content.contains("N or Esc: cancel"));
assert!(
content.contains("generated and ignored contents"),
"the ordinary warning must still say the directory goes with it: {content}"
);
}
#[test]
fn tui_dirty_worktree_delete_destructive_modal_states_the_loss_and_the_key() {
let mut app = create_test_app(vec![]);
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![create_test_worktree("/tmp/worktree-a", "feature-a")];
app.modal = Some(ModalState::ConfirmDirtyDiscard {
path: std::path::PathBuf::from("/tmp/worktree-a"),
identity: "gitdir: /tmp/worktree-a/.git".to_string(),
branch: "feature-a".to_string(),
head: "abc1234".to_string(),
skip_teardown: false,
});
let buffer = render_buffer(&mut app, 120, 30);
let content = buffer_to_string(&buffer);
assert!(content.contains("Discard Uncommitted Changes"));
assert!(content.contains("has uncommitted changes"));
assert!(content.contains("permanently lost"));
assert!(
content.contains("Nothing is stashed, committed, or backed up"),
"the modal must not imply the work is recoverable: {content}"
);
assert!(content.contains("Press uppercase X to discard and delete"));
assert!(content.contains("Teardown will run before removal"));
}
#[test]
fn tui_dirty_worktree_delete_destructive_modal_reports_the_captured_teardown_choice() {
let mut app = create_test_app(vec![]);
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![create_test_worktree("/tmp/worktree-a", "feature-a")];
app.modal = Some(ModalState::ConfirmDirtyDiscard {
path: std::path::PathBuf::from("/tmp/worktree-a"),
identity: "gitdir: /tmp/worktree-a/.git".to_string(),
branch: "feature-a".to_string(),
head: "abc1234".to_string(),
skip_teardown: true,
});
let buffer = render_buffer(&mut app, 120, 30);
let content = buffer_to_string(&buffer);
assert!(content.contains("Teardown will be skipped"));
}
fn ahead_discard_modal(dirty: bool, skip_teardown: bool) -> ModalState {
ModalState::ConfirmAheadDiscard {
path: std::path::PathBuf::from("/tmp/worktree-a"),
identity: "gitdir: /tmp/worktree-a/.git".to_string(),
branch: "feature-a".to_string(),
head: "abc1234".to_string(),
dirty,
skip_teardown,
}
}
#[test]
fn tui_ahead_worktree_delete_destructive_modal_states_every_resource_it_deletes() {
let mut app = create_test_app(vec![]);
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![create_test_worktree("/tmp/worktree-a", "feature-a")];
app.modal = Some(ahead_discard_modal(false, false));
let buffer = render_buffer(&mut app, 120, 30);
let content = buffer_to_string(&buffer);
assert!(content.contains("Discard Unmerged Commits"));
assert!(content.contains("feature-a"));
assert!(content.contains("/tmp/worktree-a"));
assert!(
content.contains("abc1234"),
"the confirmed commit is what the branch is deleted at: {content}"
);
assert!(
content.contains("the local branch, and every unmerged commit"),
"the modal must name the branch and the commits, not only the worktree: {content}"
);
assert!(
content.contains("Nothing is merged, pushed, tagged"),
"the modal must not imply the commits are preserved somewhere: {content}"
);
assert!(content.contains("not recoverable"));
assert!(content.contains("Press uppercase X to discard and delete"));
assert!(content.contains("Teardown will run before removal"));
assert!(
!content.contains("uncommitted changes"),
"a clean worktree must not be told it is losing uncommitted work: {content}"
);
}
#[test]
fn tui_ahead_worktree_delete_destructive_modal_discloses_both_losses_when_dirty() {
let mut app = create_test_app(vec![]);
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![create_test_worktree("/tmp/worktree-a", "feature-a")];
app.modal = Some(ahead_discard_modal(true, true));
let buffer = render_buffer(&mut app, 120, 30);
let content = buffer_to_string(&buffer);
assert!(content.contains("every unmerged commit"));
assert!(content.contains("also has uncommitted changes"));
assert!(
content.contains("by the same keypress"),
"the operator must be told X covers both losses: {content}"
);
assert!(content.contains("Teardown will be skipped"));
}
#[test]
fn ahead_discard_overlay_renders_above_every_execution_mode() {
for mode in ALL_EXECUTION_MODES {
let mut app = overlay_app();
app.execution_mode = mode;
app.modal = Some(ahead_discard_modal(true, false));
let content = buffer_to_string(&render_buffer(&mut app, 100, 40));
assert!(
content.contains("Discard Unmerged Commits"),
"the ahead confirmation must render above {mode:?}"
);
assert!(content.contains("[Discard Commits]"));
}
}
const CHECKBOX_TO_ID_COLUMNS: usize = CHECKBOX_WIDTH + CHECKBOX_TO_ID_SEPARATOR_WIDTH;
fn change_row_cells(buffer: &Buffer, change_id: &str) -> Vec<char> {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.flat_map(|x| buffer[(x, y)].symbol().chars())
.collect::<Vec<char>>()
})
.find(|cells| find_columns(cells, change_id).is_some())
.unwrap_or_else(|| panic!("no rendered row contains `{change_id}`"))
}
fn find_columns(cells: &[char], needle: &str) -> Option<usize> {
let needle: Vec<char> = needle.chars().collect();
cells.windows(needle.len()).position(|w| w == needle)
}
fn checkbox_and_id_column(cells: &[char], change_id: &str) -> (String, usize) {
let id_column = find_columns(cells, change_id)
.unwrap_or_else(|| panic!("row does not contain `{change_id}`"));
let checkbox_start = id_column
.checked_sub(CHECKBOX_TO_ID_COLUMNS)
.expect("the checkbox column precedes the ID column");
(
cells[checkbox_start..checkbox_start + CHECKBOX_WIDTH]
.iter()
.collect(),
id_column,
)
}
fn checkbox_placeholder_app(
mode: AppExecutionMode,
with_logs: bool,
cursor_index: usize,
status: &str,
marked: bool,
) -> AppState {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.execution_mode = mode;
app.cursor_index = cursor_index;
app.changes[0].display_status_cache = status.to_string();
app.changes[0].selected = marked;
if with_logs {
app.add_log(LogEntry::info("log"));
}
app
}
#[test]
fn archived_checkbox_placeholder_is_exactly_checkbox_wide() {
assert_eq!(CHECKBOX_PLACEHOLDER.len(), CHECKBOX_WIDTH);
assert_eq!(CHECKBOX_WIDTH, "[x]".len());
assert_eq!(CHECKBOX_WIDTH, "[ ]".len());
assert!(
CHECKBOX_PLACEHOLDER.chars().all(|c| c == ' '),
"the placeholder must be blank, not a third checkbox state"
);
}
#[test]
fn archived_checkbox_placeholder_replaces_both_checkbox_states() {
for status in ["archived", "merged", "pushed"] {
for marked in [true, false] {
let (text, color) = get_checkbox_display(status, marked, false);
assert_eq!(
text, CHECKBOX_PLACEHOLDER,
"`{status}` (marked={marked}) must render no checkbox"
);
assert_eq!(color, Color::DarkGray);
}
}
assert_eq!(get_checkbox_display("not queued", true, false).0, "[x]");
assert_eq!(get_checkbox_display("not queued", false, false).0, "[ ]");
}
#[test]
fn archived_checkbox_placeholder_holds_the_column_in_both_layouts() {
for (layout, with_logs) in [("select", false), ("running", true)] {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopped,
] {
for cursor_index in [0, 1] {
let mut baseline = checkbox_placeholder_app(
mode,
with_logs,
cursor_index,
"not queued",
false,
);
let baseline_buffer = render_buffer(&mut baseline, 120, 30);
let (baseline_checkbox, baseline_id_column) = checkbox_and_id_column(
&change_row_cells(&baseline_buffer, "change-a"),
"change-a",
);
assert_eq!(
baseline_checkbox, "[ ]",
"{layout}/{mode:?}/cursor={cursor_index}: the baseline row must own a checkbox"
);
for status in ["archived", "merged", "pushed"] {
for marked in [true, false] {
let mut app = checkbox_placeholder_app(
mode,
with_logs,
cursor_index,
status,
marked,
);
let buffer = render_buffer(&mut app, 120, 30);
let cells = change_row_cells(&buffer, "change-a");
let (checkbox, id_column) = checkbox_and_id_column(&cells, "change-a");
assert_eq!(
checkbox, CHECKBOX_PLACEHOLDER,
"{layout}/{mode:?}/cursor={cursor_index}: `{status}` \
(marked={marked}) must render blank checkbox cells"
);
assert_eq!(
id_column, baseline_id_column,
"{layout}/{mode:?}/cursor={cursor_index}: `{status}` \
(marked={marked}) must not shift the columns after the checkbox"
);
}
}
}
}
}
}
const ARCHIVE_COMPLETE_ROW_STATUSES: [&str; 5] = [
"resolving",
"resolve pending",
"merge wait",
"merged",
"pushed",
];
fn archive_complete_app(
mode: AppExecutionMode,
with_logs: bool,
status: &str,
marked: bool,
archive_complete: bool,
) -> AppState {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.execution_mode = mode;
app.cursor_index = 0;
app.changes[0].set_display_status_cache(status);
app.changes[0].selected = marked;
app.changes[0].archive_complete_cache = archive_complete;
if with_logs {
app.add_log(LogEntry::info("log"));
}
app
}
#[test]
fn tui_change_row_layout_mark_contract_archive_complete_row_hides_the_checkbox() {
for (layout, with_logs) in [("select", false), ("running", true)] {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopped,
] {
let mut baseline =
archive_complete_app(mode, with_logs, "not queued", false, false);
let baseline_buffer = render_buffer(&mut baseline, 120, 30);
let (baseline_checkbox, baseline_id_column) = checkbox_and_id_column(
&change_row_cells(&baseline_buffer, "change-a"),
"change-a",
);
assert_eq!(baseline_checkbox, "[ ]");
for status in ARCHIVE_COMPLETE_ROW_STATUSES {
for marked in [true, false] {
let mut app = archive_complete_app(mode, with_logs, status, marked, true);
let buffer = render_buffer(&mut app, 120, 30);
let (checkbox, id_column) = checkbox_and_id_column(
&change_row_cells(&buffer, "change-a"),
"change-a",
);
assert_eq!(
checkbox, CHECKBOX_PLACEHOLDER,
"{layout}/{mode:?}: archive-complete `{status}` \
(marked={marked}) must render neither [x] nor [ ]"
);
assert_eq!(
checkbox.chars().count(),
CHECKBOX_WIDTH,
"{layout}/{mode:?}: the placeholder stays three columns"
);
assert_eq!(
id_column, baseline_id_column,
"{layout}/{mode:?}: archive-complete `{status}` must not \
shift the ID or the fields after it"
);
}
}
}
}
}
#[test]
fn tui_change_row_layout_mark_contract_row_without_an_archive_record_keeps_its_checkbox() {
for (layout, with_logs) in [("select", false), ("running", true)] {
for status in ["resolving", "resolve pending", "merge wait"] {
for (marked, expected) in [(true, "[x]"), (false, "[ ]")] {
let mut app = archive_complete_app(
AppExecutionMode::Running,
with_logs,
status,
marked,
false,
);
let buffer = render_buffer(&mut app, 120, 30);
let (checkbox, _) =
checkbox_and_id_column(&change_row_cells(&buffer, "change-a"), "change-a");
assert_eq!(
checkbox, expected,
"{layout}/{status} (marked={marked}) has no archive record and \
must still report its own mark"
);
}
}
}
}
#[test]
fn tui_change_row_layout_mark_contract_terminal_status_alone_hides_the_checkbox() {
for status in ["archived", "merged", "pushed"] {
for marked in [true, false] {
let (text, color) = get_checkbox_display(status, marked, false);
assert_eq!(
text, CHECKBOX_PLACEHOLDER,
"`{status}` must suppress the checkbox without an archive snapshot"
);
assert_eq!(color, Color::DarkGray);
}
}
}
#[test]
fn tui_change_row_layout_mark_contract_archive_complete_row_omits_the_mark_hint() {
for (layout, with_logs) in [("select", false), ("running", true)] {
for status in ARCHIVE_COMPLETE_ROW_STATUSES {
for marked in [true, false] {
let mut app = archive_complete_app(
AppExecutionMode::Running,
with_logs,
status,
marked,
true,
);
let content = buffer_to_string(&render_buffer(&mut app, 200, 24));
assert!(
!content.contains("Space: mark") && !content.contains("Space: unmark"),
"{layout}/{status} (marked={marked}): an archive-complete row must \
advertise no mark control:\n{content}"
);
if status == "resolving" {
assert!(
content.contains("K: kill"),
"{layout}/{status}: per-change termination is independent of \
markability:\n{content}"
);
}
}
}
}
}
#[test]
fn tui_change_row_layout_mark_contract_row_without_an_archive_record_keeps_the_mark_hint() {
for with_logs in [false, true] {
for status in ["resolving", "resolve pending", "merge wait"] {
for (marked, expected) in [(false, "Space: mark"), (true, "Space: unmark")] {
let mut app = archive_complete_app(
AppExecutionMode::Running,
with_logs,
status,
marked,
false,
);
let content = buffer_to_string(&render_buffer(&mut app, 200, 24));
assert!(
content.contains(expected),
"{status} (marked={marked}) must still advertise `{expected}`:\n{content}"
);
assert!(
content.contains("x: toggle all"),
"{status}: a markable row keeps the bulk hint:\n{content}"
);
}
}
}
}
const SHORT_REPRESENTATIVE_ID: &str = "fix-stale-resolve-terminal-status";
const LONG_REPRESENTATIVE_ID: &str = "preserve-archiving-during-tui-refresh";
const LONG_REPRESENTATIVE_ID_TRUNCATED: &str = "preserve-archiving-during-tui-refre";
#[test]
fn tui_change_row_layout_render_field_constants_are_coherent() {
assert_eq!(CHANGE_ID_CONTENT_WIDTH, 35);
assert_eq!(CHECKBOX_TO_ID_SEPARATOR_WIDTH, 1);
assert_eq!(CHANGE_ID_FIELD_WIDTH, 36);
assert_eq!(
CHANGE_ROW_PREFIX_WIDTH,
CHECKBOX_WIDTH + CHECKBOX_TO_ID_SEPARATOR_WIDTH + CHANGE_ID_CONTENT_WIDTH,
"the preview base width must be built from the same constants the row is"
);
}
#[test]
fn tui_change_row_layout_render_id_field_pads_and_truncates_ascii() {
let short = render_change_id_field(SHORT_REPRESENTATIVE_ID);
assert_eq!(display_width(SHORT_REPRESENTATIVE_ID), 33);
assert_eq!(short, format!("{SHORT_REPRESENTATIVE_ID} "));
assert_eq!(display_width(&short), CHANGE_ID_CONTENT_WIDTH);
let long = render_change_id_field(LONG_REPRESENTATIVE_ID);
assert_eq!(long, LONG_REPRESENTATIVE_ID_TRUNCATED);
assert_eq!(display_width(&long), CHANGE_ID_CONTENT_WIDTH);
assert!(
!long.contains('…') && !long.contains("..."),
"hard truncation adds no suffix: {long}"
);
let exact = "a".repeat(CHANGE_ID_CONTENT_WIDTH);
assert_eq!(render_change_id_field(&exact), exact);
assert_eq!(
display_width(&render_change_id_field("")),
CHANGE_ID_CONTENT_WIDTH,
"even an empty ID owns the whole field"
);
}
#[test]
fn tui_change_row_layout_render_id_field_uses_unicode_display_width() {
let wide = "変".repeat(18);
let field = render_change_id_field(&wide);
assert_eq!(field.chars().filter(|c| *c == '変').count(), 17);
assert_eq!(
display_width(&field),
CHANGE_ID_CONTENT_WIDTH,
"the straddling character is dropped whole and its column padded"
);
assert!(field.ends_with(' '));
let mixed = format!("{}変変変変変", "ascii-".repeat(4)); let mixed_field = render_change_id_field(&mixed);
assert_eq!(display_width(&mixed_field), CHANGE_ID_CONTENT_WIDTH);
assert!(mixed_field.starts_with("ascii-ascii-ascii-ascii-"));
}
fn representative_row_app(with_logs: bool, cursor_index: usize) -> AppState {
let mut app = create_test_app(vec![
create_test_change(SHORT_REPRESENTATIVE_ID),
create_test_change(LONG_REPRESENTATIVE_ID),
]);
app.execution_mode = if with_logs {
AppExecutionMode::Running
} else {
AppExecutionMode::Select
};
app.cursor_index = cursor_index;
if with_logs {
app.add_log(LogEntry::info("log"));
}
app
}
fn rendered_row(buffer: &Buffer, needle: &str) -> String {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no rendered row contains `{needle}`"))
}
#[test]
fn tui_change_row_layout_render_drops_the_cursor_glyph_and_keeps_the_highlight() {
for with_logs in [false, true] {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopping,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
for cursor_index in [0, 1] {
let mut app = representative_row_app(with_logs, cursor_index);
app.execution_mode = mode;
let buffer = render_buffer(&mut app, 160, 30);
let content = buffer_to_string(&buffer);
assert!(
!content.contains('►'),
"{mode:?}/logs={with_logs}/cursor={cursor_index}: \
no row may render a cursor glyph:\n{content}"
);
let focused_id = if cursor_index == 0 {
SHORT_REPRESENTATIVE_ID
} else {
LONG_REPRESENTATIVE_ID_TRUNCATED
};
let other_id = if cursor_index == 0 {
LONG_REPRESENTATIVE_ID_TRUNCATED
} else {
SHORT_REPRESENTATIVE_ID
};
assert_eq!(
bg_at(&buffer, focused_id),
Color::DarkGray,
"{mode:?}/logs={with_logs}/cursor={cursor_index}: \
the focused row keeps its highlight"
);
assert_ne!(
bg_at(&buffer, other_id),
Color::DarkGray,
"{mode:?}/logs={with_logs}/cursor={cursor_index}: \
only the focused row is highlighted"
);
}
}
}
}
fn bg_at(buffer: &Buffer, needle: &str) -> Color {
for y in 0..buffer.area.height {
let line: String = (0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect();
if let Some(byte_index) = line.find(needle) {
let column = line[..byte_index].chars().count() as u16;
return buffer[(column, y)].bg;
}
}
panic!("{needle:?} not found in rendered buffer");
}
#[test]
fn tui_change_row_layout_render_representative_rows_align_every_field() {
let mut app = representative_row_app(true, 0);
for change in &mut app.changes {
change.set_display_status_cache("applying");
change.has_worktree = true;
change.iteration_number = Some(2);
change.elapsed_time = Some(Duration::from_secs(75));
change.completed_tasks = 1;
change.total_tasks = 4;
}
app.spinner_frame = 0;
let spinner = SPINNER_CHARS[0];
let buffer = render_buffer(&mut app, 120, 30);
let short_row = rendered_row(&buffer, SHORT_REPRESENTATIVE_ID);
let long_row = rendered_row(&buffer, LONG_REPRESENTATIVE_ID_TRUNCATED);
let expected_tail = format!(" WT {spinner} 1m 15s [applying:2] 1/4(25%)");
assert!(
short_row.contains(&format!("[ ] {SHORT_REPRESENTATIVE_ID} {expected_tail}")),
"short row must pad the ID to the field width before `WT`:\n\
{short_row}\nexpected tail: {expected_tail}"
);
assert!(
long_row.contains(&format!(
"[ ] {LONG_REPRESENTATIVE_ID_TRUNCATED}{expected_tail}"
)),
"long row must hard-truncate at the field width:\n\
{long_row}\nexpected tail: {expected_tail}"
);
assert!(
!long_row.contains('…'),
"no truncation suffix is rendered:\n{long_row}"
);
for field in [" WT", "[applying:2]", "1/4(25%)"] {
assert_eq!(
short_row
.find(field)
.map(|i| short_row[..i].chars().count()),
long_row.find(field).map(|i| long_row[..i].chars().count()),
"`{field}` must start in the same column on both rows:\n\
{short_row}\n{long_row}"
);
}
}
#[test]
fn net_execution_time_render_retains_the_elapsed_field_on_inactive_rows() {
for status in ["merged", "error", "stalled"] {
let mut app = create_test_app(vec![
create_test_change("active-row"),
create_test_change("retained-row"),
]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("log"));
app.spinner_frame = 0;
app.changes[0].set_display_status_cache("applying");
app.changes[0].started_at = None;
app.changes[0].elapsed_time = Some(Duration::from_secs(75));
app.changes[1].set_display_status_cache(status);
app.changes[1].elapsed_time = Some(Duration::from_secs(75));
let buffer = render_buffer(&mut app, 120, 30);
let active_row = rendered_row(&buffer, "active-row");
let retained_row = rendered_row(&buffer, "retained-row");
assert!(
retained_row.contains("1m 15s"),
"a `{status}` row must keep showing its net execution time:\n{retained_row}"
);
assert!(
!retained_row.contains("--"),
"a `{status}` row with execution history must not render the placeholder:\n\
{retained_row}"
);
let active_badge_column = active_row
.find("[applying]")
.map(|i| active_row[..i].chars().count());
let retained_badge_column = retained_row
.find(&format!("[{status}]"))
.map(|i| retained_row[..i].chars().count());
assert_eq!(
active_badge_column, retained_badge_column,
"`[{status}]` must start in the same column as an active badge:\n\
{active_row}\n{retained_row}"
);
let elapsed_column = |row: &str| row.find("1m 15s").map(|i| row[..i].chars().count());
assert_eq!(
elapsed_column(&active_row),
elapsed_column(&retained_row),
"the elapsed field must occupy the same columns on both rows:\n\
{active_row}\n{retained_row}"
);
}
}
#[test]
fn net_execution_time_render_leaves_a_row_without_history_unchanged() {
let mut app = create_test_app(vec![create_test_change("idle-row")]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("log"));
app.changes[0].set_display_status_cache("not queued");
assert!(!app.changes[0].has_execution_history());
let buffer = render_buffer(&mut app, 120, 30);
let row = rendered_row(&buffer, "idle-row");
assert!(
row.contains(" [not queued]"),
"a never-executed row keeps the right-aligned status field:\n{row}"
);
}
#[test]
fn tui_change_row_layout_render_id_starts_one_column_after_the_checkbox() {
for with_logs in [false, true] {
for cursor_index in [0, 1] {
let mut app = representative_row_app(with_logs, cursor_index);
let buffer = render_buffer(&mut app, 160, 30);
let (_, short_column) = checkbox_and_id_column(
&change_row_cells(&buffer, SHORT_REPRESENTATIVE_ID),
SHORT_REPRESENTATIVE_ID,
);
let (_, long_column) = checkbox_and_id_column(
&change_row_cells(&buffer, LONG_REPRESENTATIVE_ID_TRUNCATED),
LONG_REPRESENTATIVE_ID_TRUNCATED,
);
assert_eq!(short_column, CHANGE_ID_X as usize);
assert_eq!(long_column, CHANGE_ID_X as usize);
}
}
}
#[test]
fn tui_change_row_layout_render_wide_character_id_preserves_following_columns() {
for with_logs in [false, true] {
let mut app = create_test_app(vec![
create_test_change(SHORT_REPRESENTATIVE_ID),
create_test_change(&"変".repeat(18)),
]);
app.execution_mode = if with_logs {
AppExecutionMode::Running
} else {
AppExecutionMode::Select
};
for change in &mut app.changes {
change.has_worktree = true;
}
if with_logs {
app.add_log(LogEntry::info("log"));
}
let buffer = render_buffer(&mut app, 160, 30);
let ascii_row = rendered_row(&buffer, SHORT_REPRESENTATIVE_ID);
let wide_row = rendered_row(&buffer, "変");
let wide_cells: Vec<char> = wide_row.chars().collect();
assert_eq!(
wide_cells.iter().filter(|c| **c == '変').count(),
17,
"logs={with_logs}: the straddling wide character is dropped whole:\n{wide_row}"
);
let column_of = |row: &str, needle: &str| {
row.find(needle)
.map(|byte_index| row[..byte_index].chars().count())
.unwrap_or_else(|| panic!("`{needle}` missing from `{row}`"))
};
assert_eq!(
column_of(&wide_row, " WT"),
column_of(&ascii_row, " WT"),
"logs={with_logs}: a wide-character ID must not shift the next field:\n\
{ascii_row}\n{wide_row}"
);
}
}
#[test]
fn tui_change_row_layout_render_narrow_terminal_omits_preview_safely() {
for with_logs in [false, true] {
let mut app = create_test_app(vec![create_test_change(LONG_REPRESENTATIVE_ID)]);
app.execution_mode = if with_logs {
AppExecutionMode::Running
} else {
AppExecutionMode::Select
};
app.changes[0].set_error_message_cache("a diagnostic nobody has room for".to_string());
if with_logs {
app.add_log(LogEntry::info("log"));
}
let buffer = render_buffer(&mut app, 76, 20);
let content = buffer_to_string(&buffer);
assert!(
content.contains(LONG_REPRESENTATIVE_ID_TRUNCATED),
"logs={with_logs}: the fixed ID field is still rendered in full:\n{content}"
);
assert!(
!content.contains("Error: a diagnostic"),
"logs={with_logs}: the preview is omitted rather than overlapped:\n{content}"
);
}
}
#[test]
fn run_mark_intent_hint_follows_mark_state_in_every_mode() {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopping,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
for status in [
"not queued",
"queued",
"applying",
"error",
"merge wait",
"stalled",
] {
for marked in [false, true] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = mode;
app.changes[0].display_status_cache = status.to_string();
app.changes[0].selected = marked;
app.cursor_index = 0;
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
let expected = if marked {
"Space: unmark"
} else {
"Space: mark"
};
assert!(
content.contains(expected),
"{mode:?}/{status}/marked={marked}: expected `{expected}`:\n{content}"
);
}
}
}
}
#[test]
fn run_mark_intent_active_row_keeps_kill_alongside_the_mark_hint() {
for status in crate::orchestration::operator_command::ACTIVE_STATUSES {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = status.to_string();
app.cursor_index = 0;
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
assert!(
content.contains("K: kill"),
"{status}: per-change termination stays its own control:\n{content}"
);
assert!(
content.contains("Space: mark"),
"{status}: and mark intent stays available beside it:\n{content}"
);
assert!(
!content.contains("Space: stop"),
"{status}: Space must never be described as a stop:\n{content}"
);
}
}
#[test]
fn run_mark_intent_terminal_row_omits_the_mark_hint() {
for status in ["archived", "merged", "pushed", "rejected"] {
for marked in [false, true] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = status.to_string();
app.changes[0].selected = marked;
app.cursor_index = 0;
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
assert!(
!content.contains("Space: mark") && !content.contains("Space: unmark"),
"{status}/marked={marked}: a terminal row has no mark to advertise:\n{content}"
);
}
}
}
#[test]
fn run_mark_intent_bulk_hint_depends_on_targets_not_mode() {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopping,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
let mut app = create_test_app(vec![
create_test_change("terminal"),
create_test_change("live"),
]);
app.execution_mode = mode;
app.changes[0].display_status_cache = "archived".to_string();
app.changes[1].display_status_cache = "applying".to_string();
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
assert!(
content.contains("x: toggle all"),
"{mode:?}: one non-terminal row is enough to offer the bulk toggle:\n{content}"
);
app.changes[1].display_status_cache = "merged".to_string();
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
assert!(
!content.contains("x: toggle all"),
"{mode:?}: a terminal-only list offers no bulk toggle:\n{content}"
);
}
}
#[test]
fn test_get_checkbox_display_not_selected() {
let (text, color) = get_checkbox_display("not queued", false, false);
assert_eq!(text, "[ ]");
assert_eq!(color, Color::Gray);
}
#[test]
fn test_get_checkbox_display_selected() {
let (text, color) = get_checkbox_display("not queued", true, false);
assert_eq!(text, "[x]");
assert_eq!(color, Color::Green);
let (text, color) = get_checkbox_display("queued", true, false);
assert_eq!(text, "[x]");
assert_eq!(color, Color::Green);
}
#[test]
fn test_get_checkbox_display_marked_not_queued() {
let (text, color) = get_checkbox_display("not queued", true, false);
assert_eq!(text, "[x]");
assert_eq!(color, Color::Green);
}
#[test]
fn test_get_checkbox_display_processing_states() {
let (text, color) = get_checkbox_display("applying", true, false);
assert_eq!(text, "[x]");
assert_eq!(color, Color::Green);
let (text, color) = get_checkbox_display("archiving", true, false);
assert_eq!(text, "[x]");
assert_eq!(color, Color::Green);
}
#[test]
fn test_render_shows_small_terminal_warning() {
let mut app = create_test_app(Vec::new());
let buffer = render_buffer(&mut app, 50, 10);
let content = buffer_to_string(&buffer);
assert!(content.contains("Terminal too small. Minimum: 60x15"));
}
#[test]
fn test_render_shows_worktree_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].has_worktree = true;
let buffer = render_buffer(&mut app, 80, 20);
let content = buffer_to_string(&buffer);
assert!(content.contains("WT"));
}
#[test]
fn test_render_hides_new_badge_for_rejected_row_in_select_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Select;
app.changes[0].display_status_cache = "rejected".to_string();
app.changes[0].is_new = true;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("[rejected]"));
assert!(
!content.contains(" NEW"),
"rejected row must never render NEW badge in Select mode"
);
}
#[test]
fn test_render_hides_new_badge_for_rejected_row_in_running_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "rejected".to_string();
app.changes[0].is_new = true;
app.add_log(LogEntry::info("log"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("rejected"));
assert!(
!content.contains(" NEW"),
"rejected row must never render NEW badge in Running mode"
);
}
#[test]
fn test_render_resolving_status_shows_label() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = "resolving".to_string();
app.add_log(LogEntry::info("log"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("resolving"));
}
#[test]
fn test_render_merge_wait_status_shows_label() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = "merge wait".to_string();
app.add_log(LogEntry::info("log"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("merge wait"));
}
#[test]
fn test_render_merge_wait_shows_resolve_key_hint() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = "merge wait".to_string();
app.clear_resolving();
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("M: resolve"),
"Should show M key hint for MergeWait status"
);
}
#[test]
fn test_render_merge_wait_shows_queue_resolve_key_hint_when_resolving() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = "merge wait".to_string();
app.set_resolving("__active__");
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("M: queue resolve"),
"Should show M queue-resolve intent hint while resolve is in progress"
);
}
#[test]
fn test_render_keeps_start_run_hint_while_resolving() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Select;
app.set_resolving("__active__");
app.cursor_index = 0;
app.changes[0].selected = true;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("F5/!: run"));
let mut configured_app = create_test_app(vec![create_test_change("change-a")]);
configured_app.set_tui_config(
TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap(),
);
configured_app.execution_mode = AppExecutionMode::Select;
configured_app.set_resolving("__active__");
configured_app.cursor_index = 0;
configured_app.changes[0].selected = true;
let buffer = render_buffer(&mut configured_app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("F5/!: run"));
}
#[test]
fn test_render_uses_centralized_resolve_check_in_select_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Select;
app.changes[0].display_status_cache = "merge wait".to_string();
app.clear_resolving();
app.cursor_index = 0;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("M: resolve"),
"Should show M: resolve in Select mode with MergeWait"
);
}
#[test]
fn test_render_hides_resolve_in_error_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Error; app.changes[0].display_status_cache = "merge wait".to_string();
app.clear_resolving();
app.cursor_index = 0;
app.add_log(LogEntry::info("log"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
!content.contains("M: resolve"),
"Should NOT show M: resolve in Error mode"
);
}
#[test]
fn test_render_shows_resolve_in_running_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "merge wait".to_string();
app.clear_resolving();
app.cursor_index = 0;
app.add_log(LogEntry::info("log"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("M: resolve"),
"Should show M: resolve in Running mode when available"
);
}
#[test]
fn test_render_consistency_with_resolve_availability() {
let test_cases = vec![
(
AppExecutionMode::Select,
"merge wait".to_string(),
false,
true,
false,
),
(
AppExecutionMode::Select,
"merge wait".to_string(),
true,
false,
true,
),
(
AppExecutionMode::Running,
"merge wait".to_string(),
false,
true,
false,
),
(
AppExecutionMode::Running,
"merge wait".to_string(),
true,
false,
true,
),
(
AppExecutionMode::Error,
"merge wait".to_string(),
false,
false,
false,
),
(
AppExecutionMode::Select,
"queued".to_string(),
false,
false,
false,
),
];
for (
mode,
display_status_cache,
is_resolving,
should_show_resolve,
should_show_queue_resolve,
) in test_cases
{
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = mode;
app.changes[0].display_status_cache = display_status_cache.clone();
if is_resolving {
app.set_resolving("__active__");
}
app.cursor_index = 0;
if mode != AppExecutionMode::Select {
app.add_log(LogEntry::info("log")); }
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
let shows_resolve = content.contains("M: resolve");
let shows_queue_resolve = content.contains("M: queue resolve");
assert_eq!(
shows_resolve, should_show_resolve,
"Render 'M: resolve' hint mismatch for mode={:?}, display_status_cache={:?}, is_resolving={}",
mode, display_status_cache, is_resolving
);
assert_eq!(
shows_queue_resolve, should_show_queue_resolve,
"Render 'M: queue resolve' hint mismatch for mode={:?}, display_status_cache={:?}, is_resolving={}",
mode, display_status_cache, is_resolving
);
}
}
#[test]
fn test_render_shows_worktree_delete_confirm_modal() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.modal = Some(ModalState::ConfirmWorktreeDelete {
path: std::path::PathBuf::from("/path/to/worktree"),
branch: "feature-a".to_string(),
});
let buffer = render_buffer(&mut app, 80, 20);
let content = buffer_to_string(&buffer);
assert!(content.contains("Delete Worktree"));
assert!(content.contains("/path/to/worktree"));
}
#[test]
fn test_render_parallel_archived_row_does_not_show_uncommitted_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = "archived".to_string();
app.changes[0].parallel_eligibility = ParallelEligibility::UncommittedProposalFiles;
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(!content.contains("UNCOMMITTED"));
let (checkbox, _) =
checkbox_and_id_column(&change_row_cells(&buffer, "change-a"), "change-a");
assert_eq!(checkbox, CHECKBOX_PLACEHOLDER);
}
#[test]
fn test_render_parallel_uncommitted_queueable_row_shows_uncommitted_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].parallel_eligibility = ParallelEligibility::UncommittedProposalFiles;
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("UNCOMMITTED"));
}
#[test]
fn the_badge_uses_the_correct_spelling_and_reserves_its_own_width() {
assert_eq!(UNCOMMITTED_BADGE, " UNCOMMITTED");
assert_eq!(
UNCOMMITTED_BADGE.len(),
12,
"the reserved width is taken from the badge itself, so the two cannot drift"
);
}
fn parallel_ineligible_app(
mode: AppExecutionMode,
display_status: &str,
eligibility: ParallelEligibility,
has_worktree: bool,
) -> AppState {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = mode;
app.changes[0].display_status_cache = display_status.to_string();
app.changes[0].parallel_eligibility = eligibility;
app.changes[0].has_worktree = has_worktree;
app.cursor_index = 0;
app
}
#[test]
fn a_clean_proposal_absent_from_head_is_blocked_without_claiming_dirty_state() {
for mode in [AppExecutionMode::Select, AppExecutionMode::Running] {
for status in ["not queued", "queued"] {
let mut app = parallel_ineligible_app(
mode,
status,
ParallelEligibility::ProposalAbsentFromHead,
false,
);
let buffer = render_buffer(&mut app, 120, 24);
let content = buffer_to_string(&buffer);
assert!(
!content.contains("UNCOMMITTED"),
"{mode:?}/{status}: an absent proposal has no uncommitted files to report"
);
assert!(
!content.contains("Space: queue") && !content.contains("Space: unqueue"),
"{mode:?}/{status}: Space is never described as a queue control"
);
assert!(
content.contains("[ ]"),
"{mode:?}/{status}: an unmarked non-terminal row still renders its checkbox"
);
assert_eq!(
buffer
.cell((CHANGE_ID_X, SELECT_FIRST_ROW_Y))
.unwrap()
.style()
.fg,
Some(Color::Gray),
"{mode:?}/{status}: the focused blocked row stays grayed out"
);
assert!(!app.changes[0].is_parallel_eligible());
app.toggle_selection();
assert!(
app.changes[0].selected,
"{mode:?}/{status}: a worktree-ineligible row still accepts mark intent"
);
assert!(
app.warning_message.is_none(),
"{mode:?}/{status}: marking refuses nothing: {:?}",
app.warning_message
);
}
}
}
#[test]
fn a_retained_clean_worktree_keeps_wt_without_the_uncommitted_badge() {
for mode in [AppExecutionMode::Select, AppExecutionMode::Running] {
for status in ["not queued", "queued"] {
let mut app = parallel_ineligible_app(
mode,
status,
ParallelEligibility::ProposalAbsentFromHead,
true,
);
let buffer = render_buffer(&mut app, 120, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("WT"),
"{mode:?}/{status}: a retained worktree is still reported"
);
assert!(
!content.contains("UNCOMMITTED"),
"{mode:?}/{status}: a retained clean worktree is not dirty proposal content"
);
assert!(
content.contains("[ ]"),
"{mode:?}/{status}: the row stays non-actionable"
);
assert!(!app.changes[0].is_parallel_eligible());
}
}
}
#[test]
fn a_dirty_proposal_is_the_only_row_that_claims_uncommitted_state() {
for mode in [AppExecutionMode::Select, AppExecutionMode::Running] {
for status in ["not queued", "queued"] {
let mut app = parallel_ineligible_app(
mode,
status,
ParallelEligibility::UncommittedProposalFiles,
false,
);
let buffer = render_buffer(&mut app, 120, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("UNCOMMITTED"),
"{mode:?}/{status}: observed dirty proposal files must be reported"
);
assert!(
content.contains("[ ]") && !content.contains("Space: queue"),
"{mode:?}/{status}: the dirty row renders a checkbox, not a queue affordance"
);
app.toggle_selection();
assert!(
app.changes[0].selected,
"{mode:?}/{status}: a dirty row still accepts mark intent"
);
assert!(
app.warning_message.is_none(),
"{mode:?}/{status}: marking refuses nothing: {:?}",
app.warning_message
);
}
}
}
const SELECT_FIRST_ROW_Y: u16 = 4;
const CHANGE_ID_X: u16 = 1 + CHECKBOX_WIDTH as u16 + CHECKBOX_TO_ID_SEPARATOR_WIDTH as u16;
#[test]
fn test_focused_blocked_row_has_readable_fg_select_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].parallel_eligibility = ParallelEligibility::UncommittedProposalFiles;
app.cursor_index = 0;
let buffer = render_buffer(&mut app, 80, 24);
let cell = buffer.cell((CHANGE_ID_X, SELECT_FIRST_ROW_Y)).unwrap();
assert_eq!(
cell.style().fg,
Some(Color::Gray),
"Focused blocked row name should use Gray fg for readability on DarkGray highlight"
);
}
#[test]
fn test_unfocused_blocked_row_remains_dimmed_select_mode() {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].parallel_eligibility = ParallelEligibility::UncommittedProposalFiles;
app.cursor_index = 1;
let buffer = render_buffer(&mut app, 80, 24);
let cell = buffer.cell((CHANGE_ID_X, SELECT_FIRST_ROW_Y)).unwrap();
assert_eq!(
cell.style().fg,
Some(Color::DarkGray),
"Unfocused blocked row name should stay DarkGray to remain de-emphasized"
);
}
#[test]
fn test_focused_blocked_row_has_readable_fg_running_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].parallel_eligibility = ParallelEligibility::UncommittedProposalFiles;
app.cursor_index = 0;
let buffer = render_buffer(&mut app, 80, 24);
let cell = buffer.cell((CHANGE_ID_X, SELECT_FIRST_ROW_Y)).unwrap();
assert_eq!(
cell.style().fg,
Some(Color::Gray),
"Focused blocked row name should use Gray fg in Running view too"
);
}
#[test]
fn test_unfocused_blocked_row_remains_dimmed_running_mode() {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].parallel_eligibility = ParallelEligibility::UncommittedProposalFiles;
app.cursor_index = 1;
let buffer = render_buffer(&mut app, 80, 24);
let cell = buffer.cell((CHANGE_ID_X, SELECT_FIRST_ROW_Y)).unwrap();
assert_eq!(
cell.style().fg,
Some(Color::DarkGray),
"Unfocused blocked row name should stay DarkGray in Running view"
);
}
#[test]
fn test_render_select_mode_footer_message() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].selected = true;
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("Conflux"));
assert!(content.contains("Press F5/! to start processing"));
app.set_tui_config(
TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap(),
);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("Press F5/! to start processing"));
}
fn app_with_limited_error_row() -> AppState {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].set_error_message_cache(
"reached maximum iterations (13/13) without completion".to_string(),
);
app
}
#[test]
fn settled_iteration_limit_tui_row_hint_offers_the_ordinary_mark() {
let mut app = app_with_limited_error_row();
app.cursor_index = 0;
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
assert!(
content.contains("Space: mark"),
"a settled limited row is markable like any other error row:\n{content}"
);
assert!(
!content.contains("Apply limit reached"),
"and no retired active-limit condition replaces that guidance:\n{content}"
);
assert!(
content.contains("Enter: details"),
"the retained diagnostic stays inspectable:\n{content}"
);
}
#[test]
fn settled_iteration_limit_tui_select_footer_gives_ordinary_retry_guidance() {
let mut app = app_with_limited_error_row();
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
assert!(
content.contains("Select changes with Space to process"),
"the footer offers the same Space guidance as any error row:\n{content}"
);
assert!(
content.contains("error rows need retry mark"),
"including the ordinary error-row retry hint:\n{content}"
);
assert!(
!content.contains("Apply iteration limit reached"),
"and no stable active-limit condition replaces it:\n{content}"
);
}
#[test]
fn settled_iteration_limit_tui_select_footer_keeps_guidance_for_eligible_rows() {
let mut app = create_test_app(vec![
create_test_change("limited"),
create_test_change("eligible"),
]);
app.changes[0].set_error_message_cache(
"reached maximum iterations (13/13) without completion".to_string(),
);
let content = buffer_to_string(&render_buffer(&mut app, 120, 24));
assert!(
content.contains("Select changes with Space to process"),
"an eligible row keeps its ordinary guidance:\n{content}"
);
}
#[test]
fn settled_iteration_limit_tui_error_header_offers_the_retry_key() {
let mut app = app_with_limited_error_row();
app.execution_mode = AppExecutionMode::Error;
app.error_change_id = Some("change-a".to_string());
app.add_log(LogEntry::error("apply failed"));
let content = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(
content.contains(&format!("{}: retry", app.start_key_label())),
"the header offers the retry the shared service now admits:\n{content}"
);
assert!(
!content.contains("Apply iteration limit reached"),
"and states no retired active-limit condition:\n{content}"
);
}
#[test]
fn settled_iteration_limit_tui_keeps_the_attempts_and_ceiling_visible() {
let mut app = app_with_limited_error_row();
app.cursor_index = 0;
app.execution_mode = AppExecutionMode::Error;
app.error_change_id = Some("change-a".to_string());
app.add_log(LogEntry::error("apply failed"));
let content = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(
content.contains("13/13"),
"the exact attempts/max evidence stays observable:\n{content}"
);
}
#[test]
fn test_render_status_uses_configured_start_label() {
let mut stopped_app = create_test_app(vec![create_test_change("change-a")]);
stopped_app.set_tui_config(
TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap(),
);
stopped_app.execution_mode = AppExecutionMode::Stopped;
stopped_app.logs.push(LogEntry::info("show status"));
let buffer = render_buffer(&mut stopped_app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("F5/!: resume"));
let mut stopping_app = create_test_app(vec![create_test_change("change-a")]);
stopping_app.set_tui_config(
TuiConfig::parse_jsonc(
r#"{"keybindings":{"start":["F5","!"]}}"#,
std::path::Path::new("/tmp/tui.jsonc"),
)
.unwrap(),
);
stopping_app.execution_mode = AppExecutionMode::Stopping;
stopping_app.logs.push(LogEntry::info("show status"));
let buffer = render_buffer(&mut stopping_app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("F5/!: continue"));
}
type ProgressRow = (&'static str, bool, u32, u32);
fn render_status_panel(app: &AppState) -> String {
let backend = TestBackend::new(100, 3);
let mut terminal = Terminal::new(backend).expect("terminal init");
terminal
.draw(|frame| {
let area = frame.area();
render_status(frame, app, area);
})
.expect("draw");
buffer_to_string(terminal.backend().buffer())
}
fn overall_progress_app(rows: &[ProgressRow]) -> AppState {
let changes = (0..rows.len())
.map(|i| create_test_change(&format!("change-{i}")))
.collect();
let mut app = create_test_app(changes);
for (state, (status, marked, completed, total)) in app.changes.iter_mut().zip(rows) {
state.display_status_cache = (*status).to_string();
state.selected = *marked;
state.completed_tasks = *completed;
state.total_tasks = *total;
}
app
}
fn assert_status_aggregate(app: &AppState, expected: &str, context: &str) {
let content = render_status_panel(app);
assert!(
content.contains(expected),
"{context}: expected Status aggregate {expected} in:\n{content}"
);
}
#[test]
fn tui_status_overall_progress_mixed_lifecycle_and_mark_states() {
let app = overall_progress_app(&[
("merged", false, 3, 3),
("applying", false, 1, 4),
("not queued", true, 0, 2),
("not queued", false, 0, 5),
]);
let content = render_status_panel(&app);
assert!(
content.contains("(4/9)"),
"completed, active, and marked rows form the aggregate:\n{content}"
);
assert!(
content.contains("44.4%"),
"the percentage follows the same aggregate:\n{content}"
);
}
#[test]
fn tui_status_overall_progress_retains_completed_work_after_mark_revocation() {
for final_success in ["archived", "merged", "pushed"] {
let mut app =
overall_progress_app(&[(final_success, true, 3, 3), ("applying", true, 1, 4)]);
assert_status_aggregate(&app, "(4/7)", final_success);
app.changes[0].selected = false;
assert_status_aggregate(
&app,
"(4/7)",
&format!("{final_success} after mark revocation"),
);
}
}
#[test]
fn tui_status_overall_progress_includes_archive_complete_post_archive_rows() {
for post_archive in ["resolving", "resolve pending", "merge wait"] {
let mut app = overall_progress_app(&[(post_archive, false, 3, 3)]);
app.changes[0].archive_complete_cache = true;
assert_status_aggregate(&app, "(3/3)", post_archive);
}
}
#[test]
fn tui_status_overall_progress_includes_every_active_status_without_a_mark() {
for status in crate::orchestration::operator_command::ACTIVE_STATUSES {
let app = overall_progress_app(&[(status, false, 1, 4)]);
assert_status_aggregate(&app, "(1/4)", status);
}
}
#[test]
fn tui_status_overall_progress_includes_marked_unfinished_rows() {
for status in [
"not queued",
"queued",
"merge wait",
"resolve pending",
"error",
] {
let app = overall_progress_app(&[(status, true, 1, 2)]);
assert_status_aggregate(&app, "(1/2)", status);
}
}
#[test]
fn tui_status_overall_progress_excludes_unmarked_idle_and_rejected_rows() {
let app = overall_progress_app(&[
("applying", false, 1, 4),
("error", false, 2, 6),
("not queued", false, 0, 5),
("rejected", true, 3, 3),
]);
assert_status_aggregate(&app, "(1/4)", "only the active row contributes");
}
#[test]
fn tui_status_overall_progress_counts_overlapping_rows_once() {
let mut app = overall_progress_app(&[
("applying", true, 1, 4),
("merged", true, 3, 3),
("resolving", true, 2, 2),
]);
app.changes[2].archive_complete_cache = true;
assert_status_aggregate(&app, "(6/9)", "each overlapping row counts once");
}
#[test]
fn tui_status_overall_progress_zero_total_rows_render_no_bar() {
let app = overall_progress_app(&[
("merged", false, 0, 0),
("applying", false, 0, 0),
("not queued", true, 0, 0),
]);
let content = render_status_panel(&app);
assert!(
!content.contains('%') && !content.contains('█') && !content.contains('░'),
"included rows with no tasks keep the existing no-task rendering:\n{content}"
);
assert!(
content.contains("Status"),
"the Status panel still renders:\n{content}"
);
}
#[test]
fn test_render_shows_uncommitted_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.apply_parallel_eligibility(
&HashSet::from(["change-a".to_string()]),
&HashSet::from(["change-a".to_string()]),
);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("UNCOMMITTED"));
}
#[test]
fn test_log_header_analysis_with_iteration() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Analyzing dependencies")
.with_operation("analysis")
.with_iteration(2);
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[analysis:2]"),
"Buffer should contain '[analysis:2]' header, but got:\n{}",
content
);
}
#[test]
fn test_log_header_analysis_without_iteration() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Starting analysis").with_operation("analysis");
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[analysis:1]"),
"Buffer should contain '[analysis:1]' header (analysis logs must always show iteration), but got:\n{}",
content
);
}
#[test]
fn test_log_header_resolve_with_change_id_and_iteration() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Resolving conflicts")
.with_change_id("my-change")
.with_operation("resolve")
.with_iteration(1);
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[my-change:resolve:1]"),
"Buffer should contain '[my-change:resolve:1]' header, but got:\n{}",
content
);
}
#[test]
fn test_log_header_with_change_id_only() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Processing change").with_change_id("test-change");
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("Processing change"),
"Buffer should contain log message"
);
assert!(
!content.contains("[test-change]"),
"Buffer should not contain header when only change_id is present"
);
}
#[test]
fn test_log_no_header_when_no_change_id_or_operation() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Regular log message");
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("Regular log message"),
"Buffer should contain log message"
);
let has_headers = content.contains("[analysis]")
|| content.contains("[resolve]")
|| content.contains("[test-change]");
assert!(
!has_headers,
"Buffer should not contain headers for plain log messages"
);
}
#[test]
fn test_log_header_acceptance_with_iteration() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Running acceptance test")
.with_change_id("my-change")
.with_operation("acceptance")
.with_iteration(3);
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[my-change:acceptance:3]"),
"Buffer should contain '[my-change:acceptance:3]' header, but got:\n{}",
content
);
}
#[test]
fn test_log_header_acceptance_without_iteration() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Acceptance test starting")
.with_change_id("my-change")
.with_operation("acceptance");
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[my-change:acceptance]"),
"Buffer should contain '[my-change:acceptance]' header, but got:\n{}",
content
);
}
#[test]
fn test_log_header_archive_with_change_id_and_iteration() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let entry = LogEntry::info("Archiving change")
.with_change_id("test-change")
.with_operation("archive")
.with_iteration(2);
app.add_log(entry);
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[test-change:archive:2]"),
"Buffer should contain '[test-change:archive:2]' header for retry identification, but got:\n{}",
content
);
}
#[test]
fn test_running_header_counts_only_in_flight_changes() {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
create_test_change("change-c"),
create_test_change("change-d"),
]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "queued".to_string();
app.changes[1].display_status_cache = "applying".to_string();
app.changes[2].display_status_cache = "archiving".to_string();
app.changes[3].display_status_cache = "not queued".to_string();
app.add_log(LogEntry::info("test"));
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[Running 2]"),
"Header should show 'Running 2' (only in-flight changes), but got:\n{}",
content
);
assert!(
!content.contains("[Running 3]") && !content.contains("[Running 4]"),
"Header should not count Queued changes, but got:\n{}",
content
);
}
#[test]
fn test_running_header_counts_resolving_as_in_flight() {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "resolving".to_string();
app.changes[1].display_status_cache = "queued".to_string();
app.add_log(LogEntry::info("test"));
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[Running 1]"),
"Header should show 'Running 1' (Resolving is in-flight), but got:\n{}",
content
);
}
#[test]
fn running_header_count_reflects_reducer_synced_active_status_after_refresh() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "queued".to_string();
app.changes[0].selected = true;
app.apply_display_statuses_from_reducer(&std::collections::HashMap::from([(
"change-a".to_string(),
"accepting",
)]));
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[Running 1]"),
"header should count reducer-synced accepting row, but got:\n{}",
content
);
assert!(
!content.contains("[Running 2]"),
"header should not count queued rows in addition to active row, but got:\n{}",
content
);
}
#[test]
fn running_header_counts_every_shared_active_status() {
for status in crate::orchestration::operator_command::ACTIVE_STATUSES {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = status.to_string();
let content = buffer_to_string(&render_buffer(&mut app, 80, 24));
assert!(
content.contains("[Running 1]"),
"{status}: an actively executing row is a running row:\n{content}"
);
}
}
#[test]
fn running_header_counts_no_inactive_status() {
for status in [
"not queued",
"queued",
"blocked",
"stalled",
"merge wait",
"resolve pending",
"reject pending",
"archived",
"merged",
"pushed",
"rejected",
"error",
"stopped",
] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = status.to_string();
let content = buffer_to_string(&render_buffer(&mut app, 80, 24));
assert!(
!content.contains("[Running 1]"),
"{status}: nothing is executing, so the header carries no count:\n{content}"
);
}
}
#[test]
fn test_select_mode_shows_ready_even_when_resolving_exists() {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.execution_mode = AppExecutionMode::Select;
app.changes[0].display_status_cache = "resolving".to_string();
app.changes[1].display_status_cache = "queued".to_string();
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[Ready]"),
"Header should show 'Ready' in Select mode, but got:\n{}",
content
);
assert!(
!content.contains("[Running 1]"),
"Header should not show '[Running 1]' in Select mode, but got:\n{}",
content
);
}
#[test]
fn test_running_mode_shows_running_without_count_when_no_in_flight() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "queued".to_string();
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[Running]"),
"Header should show '[Running]' in Running mode with zero in-flight, but got:\n{}",
content
);
assert!(
!content.contains("[Running 1]"),
"Header should not show count when in-flight is zero, but got:\n{}",
content
);
assert!(
!content.contains("[Ready]"),
"Header should not show '[Ready]' in Running mode, but got:\n{}",
content
);
}
#[test]
fn test_stopping_mode_header_shows_stopping() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Stopping;
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[Stopping]"),
"Header should show '[Stopping]' in Stopping mode, but got:\n{}",
content
);
assert!(
!content.contains("[Ready]"),
"Header should not show '[Ready]' in Stopping mode, but got:\n{}",
content
);
}
#[test]
fn stopped_mode_header_shows_ready_with_resume_controls() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Stopped;
app.add_log(LogEntry::info("stop requested"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("[Ready]"),
"Stopped mode must project the Ready header, but got:\n{}",
content
);
assert_eq!(
fg_at(&buffer, "[Ready]"),
Color::Cyan,
"the stopped Ready label must use the existing cyan Ready presentation"
);
assert!(
content.contains(&format!("{}: resume", app.start_key_label())),
"Stopped mode must keep its resume control alongside the Ready header, but got:\n{}",
content
);
assert!(
!content.contains("[Stopped]"),
"the header must never expose a Stopped execution status, but got:\n{}",
content
);
assert_eq!(
app.execution_mode,
AppExecutionMode::Stopped,
"rendering must not mutate the internal execution mode"
);
}
#[test]
fn error_mode_header_remains_unlabeled_without_modal() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Error;
app.modal = None;
app.add_log(LogEntry::info("apply failed"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
!content.contains("[Ready]") && !content.contains("[Stopped]"),
"Error mode without a modal must render no status label, but got:\n{}",
content
);
assert!(
content.contains(&format!("{}: retry", app.start_key_label())),
"Error mode must keep its retry control, but got:\n{}",
content
);
assert_eq!(
app.execution_mode,
AppExecutionMode::Error,
"rendering must not mutate the internal execution mode"
);
}
#[test]
fn test_log_panel_toggle_hides_logs() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.add_log(LogEntry::info("Test log message"));
assert!(app.logs_panel_enabled);
app.logs_panel_enabled = false;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
!content.contains("Test log message"),
"Log message should not be visible when logs panel is disabled"
);
assert!(
content.contains("Status"),
"Status panel should be visible even when logs are hidden"
);
}
#[test]
fn test_log_panel_toggle_shows_logs_when_enabled() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.add_log(LogEntry::info("Test log message"));
assert!(app.logs_panel_enabled);
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("Test log message"),
"Log message should be visible when logs panel is enabled"
);
}
fn logs_panel_content(buffer: &Buffer) -> String {
let title_row = find_row_containing(buffer, "f: filter").expect("logs panel title row");
let mut lines = Vec::new();
for y in title_row..buffer.area.height {
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, y)].symbol());
}
lines.push(line);
}
lines.join("\n")
}
fn mixed_proposal_log_app() -> AppState {
let mut app = create_test_app(vec![
create_test_change("alpha"),
create_test_change("beta"),
]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("alpha-apply-output").with_change_id("alpha"));
app.add_log(LogEntry::info("beta-apply-output").with_change_id("beta"));
app.add_log(LogEntry::info("global-orchestration-output"));
app
}
#[test]
fn logs_panel_shows_all_entries_while_filter_is_disabled() {
let mut app = mixed_proposal_log_app();
let content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(content.contains("alpha-apply-output"));
assert!(content.contains("beta-apply-output"));
assert!(content.contains("global-orchestration-output"));
}
#[test]
fn logs_panel_shows_only_cursor_proposal_entries_while_filter_is_enabled() {
let mut app = mixed_proposal_log_app();
app.toggle_selected_proposal_log_filter();
let content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(content.contains("alpha-apply-output"));
assert!(!content.contains("beta-apply-output"));
assert!(!content.contains("global-orchestration-output"));
assert_eq!(app.logs.len(), 3);
}
#[test]
fn logs_panel_filter_follows_cursor_movement() {
let mut app = mixed_proposal_log_app();
app.toggle_selected_proposal_log_filter();
app.cursor_down();
let content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(content.contains("beta-apply-output"));
assert!(!content.contains("alpha-apply-output"));
}
#[test]
fn logs_panel_filter_hides_global_only_buffers_without_panicking() {
let mut app = create_test_app(vec![create_test_change("alpha")]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("global-orchestration-output"));
app.toggle_selected_proposal_log_filter();
let content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(!content.contains("global-orchestration-output"));
assert!(content.contains("f: filter"));
assert_eq!(app.logs.len(), 1);
}
#[test]
fn logs_panel_filter_renders_zero_matches_from_the_filtered_line_counts() {
let mut app = mixed_proposal_log_app();
for index in 0..80 {
app.add_log(LogEntry::info(format!("beta-noise-{index}")).with_change_id("beta"));
}
app.logs
.retain(|entry| entry.change_id.as_deref() != Some("alpha"));
app.toggle_selected_proposal_log_filter();
app.scroll_logs_up(40);
let content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(!content.contains("beta-noise-"));
assert!(!content.contains("global-orchestration-output"));
assert!(!content.contains("Logs ["));
assert!(content.contains("f: filter=alpha"));
}
#[test]
fn logs_panel_filter_scroll_bounds_use_the_filtered_entry_set() {
let mut app = create_test_app(vec![
create_test_change("alpha"),
create_test_change("beta"),
]);
app.execution_mode = AppExecutionMode::Running;
for index in 0..40 {
app.add_log(LogEntry::info(format!("beta-noise-{index}")).with_change_id("beta"));
}
for index in 0..6 {
app.add_log(LogEntry::info(format!("alpha-line-{index}")).with_change_id("alpha"));
}
app.toggle_selected_proposal_log_filter();
let content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(!content.contains("Logs ["));
assert!(content.contains("alpha-line-0"));
assert!(content.contains("alpha-line-5"));
assert!(!content.contains("beta-noise-"));
}
#[test]
fn logs_panel_buffer_shows_filter_key_and_state() {
let mut app = mixed_proposal_log_app();
let off_content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(off_content.contains("f: filter off"));
app.toggle_selected_proposal_log_filter();
let on_content = logs_panel_content(&render_buffer(&mut app, 140, 24));
assert!(on_content.contains("f: filter=alpha"));
assert!(on_content.contains("PgUp/PgDn"));
assert!(on_content.contains("Home/End"));
assert!(on_content.contains("l: hide"));
}
fn title_params(filter_enabled: bool, filter_target: Option<&str>) -> LogsPanelTitle<'_> {
LogsPanelTitle {
lines_below: 0,
log_auto_scroll: true,
total_display_lines: 4,
visible_height: 10,
start_line: 0,
end_line: 4,
filter_enabled,
filter_target,
panel_width: 200,
}
}
#[test]
fn logs_panel_title_preserves_position_and_shows_navigation_guidance() {
let title = logs_panel_title(LogsPanelTitle {
lines_below: 12,
log_auto_scroll: false,
total_display_lines: 42,
visible_height: 10,
start_line: 20,
end_line: 30,
filter_enabled: false,
filter_target: Some("alpha"),
panel_width: 200,
});
assert!(title.contains("Logs [21-30/42 lines]"));
assert!(title.contains("lines_below=12"));
assert!(title.contains("⏸"));
assert!(title.contains("PgUp/PgDn: older/newer"));
assert!(title.contains("Home/End: oldest/newest"));
assert!(title.contains("l: hide"));
}
#[test]
fn logs_panel_title_reports_disabled_selected_proposal_filter() {
let title = logs_panel_title(title_params(false, Some("alpha")));
assert!(title.contains("f: filter off"));
assert!(!title.contains("filter=alpha"));
assert!(title.contains("l: hide"));
}
#[test]
fn logs_panel_title_reports_active_filter_target() {
let title = logs_panel_title(title_params(true, Some("alpha")));
assert!(title.contains("f: filter=alpha"));
}
#[test]
fn logs_panel_title_falls_back_to_compact_filter_hint_for_narrow_panels() {
let long_id = "a-very-long-proposal-identifier-that-will-not-fit";
let title = logs_panel_title(LogsPanelTitle {
panel_width: 80,
..title_params(true, Some(long_id))
});
assert!(title.contains("f: filter on"));
assert!(!title.contains(long_id));
}
#[test]
fn logs_panel_title_reports_active_filter_without_a_cursor_proposal() {
let title = logs_panel_title(title_params(true, None));
assert!(title.contains("f: filter on"));
}
#[test]
fn logs_panel_visible_buffer_shows_navigation_guidance() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("Test log message"));
let buffer = render_buffer(&mut app, 120, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("PgUp/PgDn"));
assert!(content.contains("Home/End"));
assert!(content.contains("l: hide"));
}
#[test]
fn test_log_panel_key_hint_always_shows() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("l: logs"),
"Key hint 'l: logs' should be visible in select mode"
);
app.add_log(LogEntry::info("Test log"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("l: logs"),
"Key hint 'l: logs' should be visible in running mode"
);
}
#[test]
fn test_japanese_log_preview_truncation_no_panic() {
use super::super::utils::truncate_to_display_width_with_suffix;
let japanese_text = "日本語のログメッセージです。これは長いメッセージで切り詰められます。";
let truncated = truncate_to_display_width_with_suffix(japanese_text, 20, "…");
assert!(
truncated.contains("…"),
"Should be truncated with ellipsis, got: {}",
truncated
);
assert_eq!(
truncated.chars().count(),
truncated.chars().count(), "Truncated string should be valid UTF-8"
);
for width in 1..50 {
let result = truncate_to_display_width_with_suffix(japanese_text, width, "…");
assert!(
!result.is_empty(),
"Should never return empty string for width {}",
width
);
}
}
fn render_logs_panel(app: &mut AppState, width: u16, height: u16) -> Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal init");
terminal
.draw(|frame| {
let area = frame.area();
render_logs(frame, app, area);
})
.expect("draw");
terminal.backend().buffer().clone()
}
fn buffer_row_text(buffer: &Buffer, y: u16, from: u16, to: u16) -> String {
let mut text = String::new();
let mut x = from;
while x < to {
let symbol = buffer[(x, y)].symbol();
text.push_str(symbol);
let width = unicode_width::UnicodeWidthStr::width(symbol).max(1) as u16;
x += width;
}
text
}
fn logs_panel_rows(buffer: &Buffer) -> Vec<String> {
(1..buffer.area.height.saturating_sub(1))
.map(|y| {
buffer_row_text(buffer, y, 1, buffer.area.width.saturating_sub(1))
.trim_end()
.to_string()
})
.collect()
}
fn long_ascii_message(len: usize) -> String {
(0..len)
.map(|i| char::from(b'a' + (i % 26) as u8))
.collect()
}
fn app_with_single_log(message: &str) -> AppState {
let mut app = create_test_app(vec![create_test_change("alpha")]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info(message));
app
}
fn first_row_message(rows: &[String]) -> String {
rows[0].chars().skip(9).collect()
}
#[test]
fn wider_logs_panel_shows_more_retained_content_per_line() {
let message = long_ascii_message(400);
let narrow = logs_panel_rows(&render_logs_panel(
&mut app_with_single_log(&message),
60,
12,
));
let wide = logs_panel_rows(&render_logs_panel(
&mut app_with_single_log(&message),
120,
12,
));
let narrow_first = first_row_message(&narrow);
let wide_first = first_row_message(&wide);
assert_eq!(narrow_first.chars().count(), 49);
assert_eq!(wide_first.chars().count(), 109);
assert!(wide_first.starts_with(&narrow_first));
assert!(!wide_first.contains("..."));
}
#[test]
fn logs_panel_renders_the_whole_retained_message_when_it_fits() {
let message = long_ascii_message(400);
let rows = logs_panel_rows(&render_logs_panel(
&mut app_with_single_log(&message),
120,
12,
));
let rebuilt: String = std::iter::once(first_row_message(&rows))
.chain(rows[1..].iter().cloned())
.collect();
assert_eq!(rebuilt, message);
}
#[test]
fn navigation_reaches_every_wrapped_segment_of_an_oversized_entry() {
let message = long_ascii_message(1000);
let mut app = app_with_single_log(&message);
let mut seen: Vec<String> = Vec::new();
let record = |app: &mut AppState, seen: &mut Vec<String>| {
let rows = logs_panel_rows(&render_logs_panel(app, 60, 7));
let lines = app.log_display_lines();
let top_is_entry_head = lines
.get(app.log_start_line(&lines))
.is_some_and(|line| line.is_first);
let mut merged = String::new();
for (index, row) in rows.iter().enumerate() {
if index == 0 && top_is_entry_head {
merged.extend(row.chars().skip(9));
} else {
merged.push_str(row);
}
}
seen.push(merged);
};
record(&mut app, &mut seen);
assert!(
!seen[0].starts_with(&message[..49]),
"auto-scroll starts at the tail of the entry"
);
app.scroll_logs_to_top();
record(&mut app, &mut seen);
assert_eq!(seen[1], message[..281]);
assert!(seen[1].contains(&message[..200]));
for _ in 0..5 {
app.scroll_logs_down(5);
record(&mut app, &mut seen);
}
assert!(
app.log_auto_scroll,
"PgDn eventually reaches the newest line"
);
let lines = {
app.set_log_viewport(60, 7);
app.log_display_lines()
};
assert_eq!(lines.len(), 18);
for line in &lines {
assert!(
seen.iter().any(|frame| frame.contains(&line.text)),
"wrapped segment at offset {} never reached a rendered buffer",
line.source_byte_offset
);
}
app.scroll_logs_to_top();
record(&mut app, &mut seen);
assert_eq!(seen.last().unwrap(), &message[..281]);
}
#[test]
fn logs_panel_continuation_rows_use_the_full_inner_width_without_indent() {
let message = long_ascii_message(400);
let mut app = app_with_single_log(&message);
app.scroll_logs_to_top();
let rows = logs_panel_rows(&render_logs_panel(&mut app, 60, 7));
assert_eq!(first_row_message(&rows), message[..49]);
for (index, row) in rows[1..].iter().enumerate() {
assert!(
!row.starts_with(' '),
"continuation row {index} must not be indented: {row:?}"
);
let start = 49 + index * 58;
assert_eq!(row, &message[start..start + 58]);
}
}
#[test]
fn auto_scroll_keeps_the_newest_line_visible_behind_a_tall_entry() {
let mut app = app_with_single_log(&long_ascii_message(1000));
app.add_log(LogEntry::info("newest-line-marker"));
let rows = logs_panel_rows(&render_logs_panel(&mut app, 60, 7));
assert!(
rows.last().unwrap().contains("newest-line-marker"),
"auto-scroll must keep the newest line in view: {rows:?}"
);
}
#[test]
fn resizing_keeps_the_anchor_on_the_same_source_content() {
let message = long_ascii_message(1000);
let mut app = app_with_single_log(&message);
let _ = render_logs_panel(&mut app, 60, 7);
app.scroll_logs_to_top();
app.scroll_logs_down(3);
let anchored = app.log_anchor.unwrap().source_byte_offset;
assert_eq!(anchored, 49 + 2 * 58);
let rows = logs_panel_rows(&render_logs_panel(&mut app, 120, 7));
assert!(!app.log_auto_scroll);
let lines = app.log_display_lines();
let top = &lines[app.log_start_line(&lines)];
assert!(top.source_byte_offset <= anchored);
assert!(anchored < top.source_byte_offset + top.text.len());
assert_eq!(rows[0], top.text, "the anchored line is the one drawn");
}
#[test]
fn logs_panel_title_reports_display_lines_not_entry_offsets() {
let mut app = app_with_single_log(&long_ascii_message(1000));
app.scroll_logs_to_top();
let buffer = render_logs_panel(&mut app, 120, 7);
let title: String = (0..buffer.area.width)
.map(|x| buffer[(x, 0)].symbol())
.collect();
assert!(
title.contains("lines"),
"title must count display lines: {title}"
);
assert!(!title.contains("logs_off="));
}
#[test]
fn logs_panel_wraps_cjk_and_emoji_within_the_inner_display_width() {
let message = "漢字テスト🎉".repeat(40);
let mut app = app_with_single_log(&message);
app.scroll_logs_to_top();
for width in [41u16, 60, 121] {
let buffer = render_logs_panel(&mut app, width, 10);
let rows = logs_panel_rows(&buffer);
let inner_width = (width - 2) as usize;
for row in &rows {
assert!(
unicode_width::UnicodeWidthStr::width(row.as_str()) <= inner_width,
"row exceeds inner width {inner_width}: {row:?}"
);
}
let rebuilt: String = std::iter::once(first_row_message(&rows))
.chain(rows[1..].iter().cloned())
.collect();
assert!(
message.starts_with(&rebuilt),
"width {width} lost retained content: {rebuilt:?}"
);
}
}
#[test]
fn filtering_returns_the_logs_panel_to_the_newest_matching_line() {
let message = long_ascii_message(1000);
let mut app = create_test_app(vec![
create_test_change("alpha"),
create_test_change("beta"),
]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info(message.clone()).with_change_id("alpha"));
app.add_log(LogEntry::info("beta-only-line").with_change_id("beta"));
let _ = render_logs_panel(&mut app, 60, 7);
app.scroll_logs_to_top();
assert!(!app.log_auto_scroll);
app.toggle_selected_proposal_log_filter();
assert!(app.log_auto_scroll);
assert_eq!(app.log_anchor, None);
let rows = logs_panel_rows(&render_logs_panel(&mut app, 60, 7));
assert!(
!rows.iter().any(|row| row.contains("beta-only-line")),
"filtered-out entries must not render: {rows:?}"
);
assert!(rows
.last()
.unwrap()
.ends_with(&message[message.len() - 5..]));
}
#[test]
fn buffer_trimming_keeps_the_anchored_view_on_a_rendered_line() {
let mut app = create_test_app(vec![create_test_change("alpha")]);
app.execution_mode = AppExecutionMode::Running;
for index in 0..crate::tui::state::MAX_LOG_ENTRIES {
app.add_log(LogEntry::info(format!("log-{index}")));
}
let _ = render_logs_panel(&mut app, 60, 7);
app.scroll_logs_to_top();
for index in 0..5 {
app.add_log(LogEntry::info(format!("overflow-{index}")));
}
let rows = logs_panel_rows(&render_logs_panel(&mut app, 60, 7));
assert!(
rows[0].contains("log-5"),
"the view must clamp to the oldest surviving line: {rows:?}"
);
assert!(!app.log_auto_scroll, "trimming must not resume auto-scroll");
}
fn preview_app(mode: AppExecutionMode, message: &str) -> AppState {
let mut app = create_test_app(vec![
create_test_change("alpha"),
create_test_change("beta"),
]);
app.execution_mode = mode;
app.add_log(
LogEntry::info(message)
.with_change_id("alpha")
.with_operation("apply")
.with_iteration(1),
);
app
}
fn row_index_containing(buffer: &Buffer, needle: &str) -> u16 {
find_row_containing(buffer, needle).expect("row present")
}
fn row_text(buffer: &Buffer, y: u16) -> String {
buffer_row_text(buffer, y, 0, buffer.area.width)
}
#[test]
fn wider_change_row_reveals_more_retained_preview_content() {
for mode in [AppExecutionMode::Select, AppExecutionMode::Running] {
let message = long_ascii_message(300);
let mut narrow_app = preview_app(mode, &message);
let narrow = render_buffer(&mut narrow_app, 110, 24);
let narrow_row = row_text(&narrow, row_index_containing(&narrow, "alpha"));
let mut wide_app = preview_app(mode, &message);
let wide = render_buffer(&mut wide_app, 200, 24);
let wide_row = row_text(&wide, row_index_containing(&wide, "alpha"));
let narrow_preview = narrow_row.matches('a').count()
+ narrow_row.matches('b').count()
+ narrow_row.matches('c').count();
let wide_preview = wide_row.matches('a').count()
+ wide_row.matches('b').count()
+ wide_row.matches('c').count();
assert!(
wide_preview > narrow_preview,
"{mode:?}: a wider row must reveal more retained preview content"
);
assert!(
narrow_row.contains('…'),
"{mode:?}: a narrow row truncates at its actual width"
);
assert!(
!wide_row.contains("..."),
"{mode:?}: no producer-fixed cutoff may reach the preview"
);
}
}
#[test]
fn change_row_preview_never_wraps_or_shifts_the_following_row() {
for mode in [AppExecutionMode::Select, AppExecutionMode::Running] {
let mut without = preview_app(mode, "short");
without.logs.clear();
let baseline = render_buffer(&mut without, 110, 24);
let beta_without = row_index_containing(&baseline, "beta");
let mut with = preview_app(mode, &long_ascii_message(400));
let buffer = render_buffer(&mut with, 110, 24);
let alpha = row_index_containing(&buffer, "alpha");
let beta = row_index_containing(&buffer, "beta");
assert_eq!(
beta, beta_without,
"{mode:?}: an oversized preview must not move the next row"
);
assert_eq!(beta, alpha + 1, "{mode:?}: no continuation row is created");
}
}
#[test]
fn change_row_preview_truncates_cjk_and_emoji_within_the_row_width() {
for mode in [AppExecutionMode::Select, AppExecutionMode::Running] {
for width in [90u16, 110, 160] {
let mut app = preview_app(mode, &"日本語ログ🎉".repeat(40));
let buffer = render_buffer(&mut app, width, 24);
let alpha = row_index_containing(&buffer, "alpha");
let row = row_text(&buffer, alpha);
assert!(
unicode_width::UnicodeWidthStr::width(row.trim_end()) <= width as usize,
"{mode:?} at {width}: preview overflowed the row"
);
assert_eq!(row_index_containing(&buffer, "beta"), alpha + 1);
}
}
}
#[test]
fn test_logs_wrap_no_indent_continuation_lines() {
let message = "This is a very long message that will definitely wrap across multiple lines when rendered in the logs view with a narrow width";
let available_width = 40;
let header_width = 0; let prefix_width = 15;
let wrapped = wrap_log_message(message, available_width, header_width, prefix_width);
assert!(wrapped.len() > 1, "Message should wrap to multiple lines");
assert!(
!wrapped[0].starts_with(' '),
"First line should not start with spaces, got: '{}'",
wrapped[0]
);
for (idx, line) in wrapped.iter().skip(1).enumerate() {
assert!(
!line.starts_with(' '),
"Continuation line {} should NOT be indented, got: '{}'",
idx + 2,
line
);
}
}
#[test]
fn test_wrap_log_message_continuation_uses_full_width() {
let message = "A".repeat(200);
let available_width = 60; let header_width = 10; let prefix_width = 19;
let wrapped = wrap_log_message(&message, available_width, header_width, prefix_width);
assert_eq!(wrapped[0].len(), 50, "First line should be 50 chars");
let continuation_width = available_width + (prefix_width - header_width);
for (idx, line) in wrapped.iter().skip(1).enumerate() {
assert!(
line.len() <= continuation_width,
"Continuation line {} len {} exceeds expected continuation_width {}",
idx + 2,
line.len(),
continuation_width
);
if idx + 2 < wrapped.len() {
assert_eq!(
line.len(),
continuation_width,
"Non-last continuation line {} should be exactly {} chars",
idx + 2,
continuation_width
);
}
}
}
#[test]
fn test_logs_visible_range_not_broken_by_wrapped_entry() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.add_log(LogEntry::info("Short log 1"));
let long_message = "A".repeat(200);
app.add_log(LogEntry::info(&long_message).with_operation("apply"));
app.add_log(LogEntry::info("Short log 3"));
let buffer = render_buffer(&mut app, 80, 30);
let content = buffer_to_string(&buffer);
assert!(
content.contains("Short log 3"),
"Latest log should be visible in the rendered output, but got:\n{}",
content
);
let a_count = content.matches('A').count();
assert!(
a_count > 0,
"Wrapped log should have continuation lines visible, but got:\n{}",
content
);
}
#[test]
fn test_wrap_log_message_handles_empty_message() {
let wrapped = wrap_log_message("", 40, 0, 10);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0], "");
}
#[test]
fn test_wrap_log_message_handles_zero_width() {
let wrapped = wrap_log_message("test message", 0, 0, 10);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0], "test message");
}
#[test]
fn test_wrap_log_message_no_wrap_needed() {
let message = "Short message";
let wrapped = wrap_log_message(message, 40, 0, 10);
assert_eq!(wrapped.len(), 1);
assert_eq!(wrapped[0], message);
}
#[test]
fn test_wrap_log_message_unicode_boundaries() {
let message = "日本語のログメッセージです。これは長いメッセージで折り返されます。";
let wrapped = wrap_log_message(message, 30, 0, 10);
assert!(wrapped.len() > 1);
for line in &wrapped {
assert!(line.is_char_boundary(0));
assert!(line.is_char_boundary(line.len()));
}
for line in wrapped.iter().skip(1) {
assert!(
!line.starts_with(' '),
"Continuation line should NOT be indented, got: '{}'",
line
);
}
}
#[test]
fn test_wrap_log_message_no_panic_arrow_unicode_prefix() {
let message = "\u{2192} Skill \"cflx-workflow\"";
for width in 1..=30 {
let wrapped = wrap_log_message(message, width, 0, 0);
let reconstructed: String = wrapped.join("");
assert_eq!(
reconstructed, message,
"Content must be preserved for width={}",
width
);
}
}
#[test]
fn test_wrap_log_message_available_width_1_no_panic() {
let messages = ["hello", "\u{2192} arrow", "日本語", "abc\u{2192}def"];
for message in &messages {
let wrapped = wrap_log_message(message, 1, 0, 0);
let reconstructed: String = wrapped.join("");
assert_eq!(
reconstructed, *message,
"Content must be preserved for message={:?} at width=1",
message
);
}
}
#[test]
fn test_uncommitted_change_still_advertises_its_mark_hint() {
use crate::openspec::Change;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).unwrap();
let changes = vec![Change {
id: "test-change".to_string(),
completed_tasks: 0,
total_tasks: 5,
last_modified: "2024-01-01".to_string(),
dependencies: vec![],
metadata: ProposalMetadata::default(),
}];
let mut app = AppState::new(changes);
app.changes[0].parallel_eligibility = ParallelEligibility::UncommittedProposalFiles;
app.changes[0].selected = false;
app.changes[0].display_status_cache = "not queued".to_string();
terminal
.draw(|f| {
super::render(f, &mut app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let content = buffer
.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("");
assert!(
content.contains("Space: mark"),
"an uncommitted row is still a mark target:\n{content}"
);
assert!(
!content.contains("Space: queue") && !content.contains("Space: unqueue"),
"Space must not be described as a queue control:\n{content}"
);
assert!(
content.contains("UNCOMMITTED"),
"UNCOMMITTED badge should be shown"
);
}
#[test]
fn test_committed_change_shows_space_hint() {
use crate::openspec::Change;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).unwrap();
let changes = vec![Change {
id: "test-change".to_string(),
completed_tasks: 0,
total_tasks: 5,
last_modified: "2024-01-01".to_string(),
dependencies: vec![],
metadata: ProposalMetadata::default(),
}];
let mut app = AppState::new(changes);
app.changes[0].parallel_eligibility = ParallelEligibility::Eligible;
app.changes[0].selected = false;
app.changes[0].display_status_cache = "not queued".to_string();
terminal
.draw(|f| {
super::render(f, &mut app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let content = buffer
.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.join("");
assert!(
content.contains("Space: mark"),
"an unmarked row advertises `Space: mark`:\n{content}"
);
app.changes[0].selected = true;
let content = buffer_to_string(&render_buffer(&mut app, 120, 30));
assert!(
content.contains("Space: unmark"),
"a marked row advertises `Space: unmark`:\n{content}"
);
}
#[test]
fn test_toggle_all_hint_shown_in_select_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Select;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("x: toggle all"),
"Should show 'x: toggle all' hint in Select mode"
);
}
#[test]
fn test_toggle_all_hint_shown_in_stopped_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Stopped;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("x: toggle all"),
"Should show 'x: toggle all' hint in Stopped mode"
);
}
#[test]
fn test_toggle_all_hint_shown_in_running_mode_with_non_active_target() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "not queued".to_string();
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("x: toggle all"),
"Should show 'x: toggle all' hint in Running mode when non-active target exists"
);
}
#[test]
fn test_toggle_all_hint_shown_in_running_mode_for_an_active_row() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "resolving".to_string();
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("x: toggle all"),
"an active row is a bulk-mark target like any other non-terminal row"
);
}
#[test]
fn test_toggle_all_hint_shown_in_stopping_and_error_modes() {
for mode in [AppExecutionMode::Stopping, AppExecutionMode::Error] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = mode;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("x: toggle all"),
"{mode:?} must offer the bulk toggle for a visible non-terminal row"
);
}
}
#[test]
fn test_toggle_all_hint_not_shown_for_terminal_rows_only() {
for mode in [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopping,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
] {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = mode;
app.changes[0].display_status_cache = "archived".to_string();
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
!content.contains("x: toggle all"),
"{mode:?} must not offer the bulk toggle when every row is terminal"
);
}
}
#[test]
fn test_toggle_all_hint_shown_in_select_mode_with_logs() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Select;
app.add_log(LogEntry::info("Test log"));
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
content.contains("x: toggle all"),
"Should show 'x: toggle all' hint in Select mode with logs present"
);
}
#[test]
fn analysis_fallback_running_status_header_keeps_running_controls() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.current_change = Some("change-a".to_string());
app.changes[0].display_status_cache = "applying".to_string();
app.orchestration_started_at = Some(std::time::Instant::now());
app.add_log(LogEntry::info("log"));
let fallback = format!(
"{}: error=Missing change IDs in response, queued=[\"change-a\"], in_flight=[]",
crate::events::RECOVERABLE_ANALYSIS_FALLBACK_MARKER
);
app.handle_orchestrator_event(crate::tui::events::OrchestratorEvent::Log(LogEntry::warn(
fallback,
)));
let content = buffer_to_string(&render_buffer(&mut app, 100, 24));
assert!(
content.contains("Esc: stop"),
"status header must retain running controls after fallback:\n{content}"
);
assert!(
!content.contains(": retry, Ctrl+C: quit"),
"status header must not offer error-mode retry controls after fallback:\n{content}"
);
assert!(
content.contains("Elapsed"),
"status header must retain elapsed orchestration display:\n{content}"
);
}
#[test]
fn fatal_global_error_status_header_still_shows_retry_controls() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.current_change = Some("change-a".to_string());
app.orchestration_started_at = Some(std::time::Instant::now());
app.add_log(LogEntry::info("log"));
app.handle_orchestrator_event(crate::tui::events::OrchestratorEvent::Error {
message: "Parallel execution failed: base worktree is unusable".to_string(),
});
let content = buffer_to_string(&render_buffer(&mut app, 100, 24));
assert!(
content.contains(": retry, Ctrl+C: quit"),
"fatal errors must still expose retry controls:\n{content}"
);
assert!(
!content.contains("Esc: stop"),
"fatal errors must not keep running controls:\n{content}"
);
}
#[test]
fn fatal_global_error_status_header_quoting_fallback_shows_retry_controls() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.execution_mode = AppExecutionMode::Running;
app.current_change = Some("change-a".to_string());
app.orchestration_started_at = Some(std::time::Instant::now());
app.add_log(LogEntry::info("log"));
let fatal = format!(
"Parallel execution failed: base worktree is unusable (last diagnostic: \"{}: error=Missing change IDs in response, queued=[\"change-a\"], in_flight=[]\")",
crate::events::RECOVERABLE_ANALYSIS_FALLBACK_MARKER
);
app.handle_orchestrator_event(crate::tui::events::OrchestratorEvent::Error {
message: fatal,
});
let content = buffer_to_string(&render_buffer(&mut app, 100, 24));
assert!(
content.contains(": retry, Ctrl+C: quit"),
"a fatal error quoting fallback text must still expose retry controls:\n{content}"
);
assert!(
!content.contains("Esc: stop"),
"a fatal error quoting fallback text must not keep running controls:\n{content}"
);
}
#[test]
fn test_split_remote_change_id_local() {
let parsed = split_remote_change_id("my-change");
assert_eq!(parsed.project, None);
assert_eq!(parsed.change, "my-change");
}
#[test]
fn test_split_remote_change_id_remote() {
let parsed = split_remote_change_id("abc123::myproject/add-feature");
assert_eq!(parsed.project, Some("myproject"));
assert_eq!(parsed.change, "add-feature");
}
#[test]
fn test_split_remote_change_id_remote_nested_path() {
let parsed = split_remote_change_id("abc123::org/project/fix-bug");
assert_eq!(parsed.project, Some("org/project"));
assert_eq!(parsed.change, "fix-bug");
}
#[test]
fn test_split_remote_change_id_no_slash_after_colon() {
let parsed = split_remote_change_id("abc123::mychange");
assert_eq!(parsed.project, None);
assert_eq!(parsed.change, "mychange");
}
fn make_change_state(id: &str) -> ChangeState {
ChangeState {
id: id.to_string(),
completed_tasks: 0,
total_tasks: 3,
display_status_cache: "not queued".to_string(),
blocker_kind_cache: crate::orchestration::state::BlockerKind::None,
blocker_detail_cache: None,
display_color_cache: Color::DarkGray,
error_message_cache: None,
selected: false,
is_new: false,
parallel_eligibility: ParallelEligibility::Eligible,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: None,
apply_operation_cache: "apply".to_string(),
archive_complete_cache: false,
}
}
#[test]
fn test_build_change_rows_all_local() {
let changes = vec![make_change_state("change-a"), make_change_state("change-b")];
let (rows, c2v) = build_change_rows(&changes);
assert_eq!(rows.len(), 2);
assert!(matches!(rows[0], ChangeRow::Item { change_index: 0 }));
assert!(matches!(rows[1], ChangeRow::Item { change_index: 1 }));
assert_eq!(c2v[0], 0);
assert_eq!(c2v[1], 1);
}
#[test]
fn test_build_change_rows_remote_grouping() {
let changes = vec![
make_change_state("p1::proj-a/change-x"),
make_change_state("p1::proj-a/change-y"),
make_change_state("p2::proj-b/change-z"),
];
let (rows, c2v) = build_change_rows(&changes);
assert_eq!(rows.len(), 5);
assert!(matches!(&rows[0], ChangeRow::Header(h) if h == "proj-a"));
assert!(matches!(rows[1], ChangeRow::Item { change_index: 0 }));
assert!(matches!(rows[2], ChangeRow::Item { change_index: 1 }));
assert!(matches!(&rows[3], ChangeRow::Header(h) if h == "proj-b"));
assert!(matches!(rows[4], ChangeRow::Item { change_index: 2 }));
assert_eq!(c2v[0], 1);
assert_eq!(c2v[1], 2);
assert_eq!(c2v[2], 4);
}
#[test]
fn test_build_change_rows_mixed_local_and_remote() {
let changes = vec![
make_change_state("local-change"),
make_change_state("pid::remote-proj/remote-change"),
];
let (rows, c2v) = build_change_rows(&changes);
assert_eq!(rows.len(), 4);
assert!(matches!(&rows[0], ChangeRow::Header(h) if h == "(local)"));
assert!(matches!(rows[1], ChangeRow::Item { change_index: 0 }));
assert!(matches!(&rows[2], ChangeRow::Header(h) if h == "remote-proj"));
assert!(matches!(rows[3], ChangeRow::Item { change_index: 1 }));
assert_eq!(c2v[0], 1);
assert_eq!(c2v[1], 3);
}
#[test]
fn test_grouped_display_shows_project_header() {
let app_changes = vec![
create_test_change("pid::myproject/feat-a"),
create_test_change("pid::myproject/feat-b"),
];
let mut app = create_test_app(app_changes);
let buffer = render_buffer(&mut app, 120, 30);
let content = buffer_to_string(&buffer);
assert!(
content.contains("myproject"),
"Should show project name as header in grouped display"
);
assert!(
content.contains("feat-a"),
"Should show bare change id feat-a"
);
assert!(
content.contains("feat-b"),
"Should show bare change id feat-b"
);
}
const ALL_EXECUTION_MODES: [AppExecutionMode; 5] = [
AppExecutionMode::Select,
AppExecutionMode::Running,
AppExecutionMode::Stopping,
AppExecutionMode::Stopped,
AppExecutionMode::Error,
];
fn overlay_app() -> AppState {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.web_url = Some("http://127.0.0.1:8080".to_string());
app.add_log(LogEntry::info("orchestration log"));
app
}
fn delete_overlay() -> ModalState {
ModalState::ConfirmWorktreeDelete {
path: std::path::PathBuf::from("/tmp/worktree-a"),
branch: "change-a".to_string(),
}
}
#[test]
fn qr_overlay_renders_above_every_execution_mode() {
for mode in ALL_EXECUTION_MODES {
let mut app = overlay_app();
app.execution_mode = mode;
app.show_qr_popup();
let content = buffer_to_string(&render_buffer(&mut app, 100, 40));
assert!(
content.contains("Web UI QR Code"),
"QR overlay must render above {mode:?}"
);
assert!(content.contains("http://127.0.0.1:8080"));
}
}
#[test]
fn worktree_confirmation_renders_above_every_execution_mode() {
for mode in ALL_EXECUTION_MODES {
let mut app = overlay_app();
app.execution_mode = mode;
app.modal = Some(delete_overlay());
let content = buffer_to_string(&render_buffer(&mut app, 100, 40));
assert!(
content.contains("Delete Worktree"),
"worktree confirmation must render above {mode:?}"
);
assert!(content.contains("/tmp/worktree-a"));
}
}
#[test]
fn worktree_confirmation_over_error_keeps_the_error_base_and_retry_control() {
let mut app = overlay_app();
app.execution_mode = AppExecutionMode::Error;
app.modal = Some(delete_overlay());
let content = buffer_to_string(&render_buffer(&mut app, 100, 40));
assert!(
content.contains(&format!("{}: retry", app.start_key_label())),
"the Error base must keep its retry control under an overlay: {content}"
);
assert!(content.contains("Delete Worktree"));
assert!(
!content.contains("[Ready]") && !content.contains("[Running]"),
"an overlay must not rewrite the base to Select or Running"
);
}
#[test]
fn force_kill_over_stopping_keeps_the_stopping_base_and_shows_confirm_hints() {
let mut app = overlay_app();
app.execution_mode = AppExecutionMode::Stopping;
app.changes[0].set_display_status_cache("applying");
app.modal = Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
});
let content = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(
content.contains("force stop"),
"the Stopping base must keep its own controls: {content}"
);
assert!(content.contains("Y: confirm kill"));
assert!(content.contains("N: cancel"));
assert!(
!content.contains("K: kill"),
"the confirmation replaces the kill hint while it owns input"
);
}
#[test]
fn invalidated_force_kill_reveals_the_current_base_without_conversion() {
let mut app = overlay_app();
app.execution_mode = AppExecutionMode::Stopped;
app.changes[0].set_display_status_cache("applying");
app.modal = None;
let content = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(!content.contains("Y: confirm kill"));
assert!(content.contains("K: kill"));
assert!(
content.contains(&format!("{}: resume", app.start_key_label())),
"the Stopped base renders as itself, not as Running: {content}"
);
}
#[test]
fn base_status_controls_come_from_execution_state_only() {
let expectations = [
(AppExecutionMode::Select, "Status (Ctrl+C: quit)"),
(AppExecutionMode::Running, "Status (Esc: stop"),
(AppExecutionMode::Stopping, "force stop"),
(AppExecutionMode::Stopped, ": resume"),
(AppExecutionMode::Error, ": retry"),
];
for (mode, expected) in expectations {
for modal in [
None,
Some(delete_overlay()),
Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
}),
] {
let mut app = overlay_app();
app.execution_mode = mode;
app.modal = modal.clone();
let content = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(
content.contains(expected),
"{mode:?} with {modal:?} must keep base control {expected:?}: {content}"
);
}
}
}
#[test]
fn overlay_header_label_is_presentation_only() {
let mut app = overlay_app();
app.execution_mode = AppExecutionMode::Running;
let running = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(running.contains("[Running]"));
app.show_qr_popup();
let qr = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(qr.contains("[QR Code]"));
assert_eq!(
app.execution_mode,
AppExecutionMode::Running,
"rendering must never mutate the execution axis"
);
app.modal = Some(delete_overlay());
let delete = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(delete.contains("[Confirm Delete]"));
app.modal = Some(ModalState::ConfirmForceKill {
change_id: "change-a".to_string(),
});
let kill = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(kill.contains("[Confirm Kill]"));
}
#[test]
fn warning_popup_renders_above_an_interaction_modal() {
let mut app = overlay_app();
app.execution_mode = AppExecutionMode::Running;
app.show_qr_popup();
app.show_warning_popup("Merge warning", "conflict in file.rs");
let content = buffer_to_string(&render_buffer(&mut app, 120, 40));
assert!(content.contains("Merge warning"));
assert!(content.contains("conflict in file.rs"));
}
const STALLED: &str = "Apply failed: stalled after 5 empty WIP commits";
fn error_row_app(detail: Option<&str>) -> AppState {
let mut app = create_test_app(vec![create_test_change("change-a")]);
match detail {
Some(detail) => app.changes[0].set_error_message_cache(detail.to_string()),
None => app.changes[0].set_display_status_cache("error"),
}
app
}
fn fg_at(buffer: &Buffer, needle: &str) -> Color {
for y in 0..buffer.area.height {
let mut line = String::new();
for x in 0..buffer.area.width {
line.push_str(buffer[(x, y)].symbol());
}
if let Some(byte_index) = line.find(needle) {
let column = line[..byte_index].chars().count() as u16;
return buffer[(column, y)].fg;
}
}
panic!("{needle:?} not found in rendered buffer");
}
#[test]
fn error_row_preview_survives_log_eviction() {
let mut app = error_row_app(Some(STALLED));
assert!(
app.get_latest_log_for_change("change-a").is_none(),
"the failure entry is gone from the bounded buffer"
);
let content = buffer_to_string(&render_buffer(&mut app, 160, 20));
assert!(
content.contains(&format!("Error: {STALLED}")),
"an error row must name its failure without any retained log: {content}"
);
}
#[test]
fn error_row_preview_takes_precedence_over_the_latest_log() {
let mut app = error_row_app(Some(STALLED));
app.add_log(LogEntry::info("refreshed workspace status").with_change_id("change-a"));
let content = buffer_to_string(&render_buffer(&mut app, 160, 20));
let changes_panel = content
.lines()
.find(|line| line.contains("change-a") && line.contains("[error]"))
.expect("the error row is rendered")
.to_string();
assert!(
changes_panel.contains(&format!("Error: {STALLED}")),
"the retained diagnostic must win: {changes_panel}"
);
assert!(
!changes_panel.contains("refreshed workspace status"),
"an unrelated log must not be presented as the failure reason: {changes_panel}"
);
}
#[test]
fn error_row_without_a_diagnostic_states_that_details_are_unavailable() {
let mut app = error_row_app(None);
app.add_log(LogEntry::info("refreshed workspace status").with_change_id("change-a"));
let content = buffer_to_string(&render_buffer(&mut app, 160, 20));
let changes_panel = content
.lines()
.find(|line| line.contains("change-a") && line.contains("[error]"))
.expect("the error row is rendered")
.to_string();
assert!(
changes_panel.contains(ERROR_DETAILS_UNAVAILABLE),
"a missing diagnostic must be explicit: {changes_panel}"
);
assert!(
!changes_panel.contains("refreshed workspace status"),
"no error reason may be inferred from an ordinary log: {changes_panel}"
);
}
#[test]
fn non_error_rows_keep_the_latest_log_preview() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.changes[0].set_display_status_cache("applying");
app.add_log(LogEntry::info("applying tasks").with_change_id("change-a"));
let content = buffer_to_string(&render_buffer(&mut app, 160, 20));
assert!(
content.contains("applying tasks"),
"non-error preview behavior is unchanged: {content}"
);
assert!(!content.contains("Error: "));
}
#[test]
fn error_preview_is_omitted_when_the_remaining_width_is_too_small() {
let mut app = error_row_app(Some(STALLED));
let content = buffer_to_string(&render_buffer(&mut app, 72, 20));
assert!(
content.contains("change-a"),
"the row itself is still rendered: {content}"
);
assert!(
!content.contains("Error: "),
"no preview fits below the minimum preview width: {content}"
);
}
#[test]
fn error_preview_truncation_is_unicode_safe() {
let diagnostic = "適用に失敗しました🚀 スタックしています 追記済みです。";
for width in [90u16, 100, 110, 120, 140, 160] {
let mut app = error_row_app(Some(diagnostic));
let content = buffer_to_string(&render_buffer(&mut app, width, 20));
let preview_lines: Vec<&str> =
content.lines().filter(|line| line.contains('適')).collect();
assert_eq!(
preview_lines.len(),
1,
"the error preview must be rendered on exactly one line at width {width}: {content}"
);
let preview = preview_lines[0];
if width >= 140 {
assert!(
preview.contains('。') && !preview.contains('…'),
"the whole diagnostic fits at width {width}: {preview}"
);
} else {
assert!(
preview.contains('…'),
"a diagnostic wider than the row must be truncated at width {width}: {preview}"
);
}
}
}
#[test]
fn error_preview_uses_error_styling_on_focused_and_unfocused_rows() {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.changes[0].set_error_message_cache("first failure".to_string());
app.changes[1].set_error_message_cache("second failure".to_string());
app.cursor_index = 0;
let buffer = render_buffer(&mut app, 160, 20);
assert_eq!(
fg_at(&buffer, "Error: first failure"),
Color::LightRed,
"the focused row needs a color readable on the highlight background"
);
assert_eq!(fg_at(&buffer, "Error: second failure"), Color::Red);
}
#[test]
fn changes_panel_advertises_details_only_on_an_error_row() {
for mode in [AppExecutionMode::Select, AppExecutionMode::Running] {
let mut app = create_test_app(vec![
create_test_change("change-a"),
create_test_change("change-b"),
]);
app.execution_mode = mode;
if mode == AppExecutionMode::Running {
app.add_log(LogEntry::info("orchestration log"));
}
app.changes[0].set_error_message_cache(STALLED.to_string());
app.cursor_index = 0;
let on_error = buffer_to_string(&render_buffer(&mut app, 200, 24));
assert!(
on_error.contains("Enter: details"),
"{mode:?} must advertise the details action on an error row: {on_error}"
);
app.cursor_index = 1;
let off_error = buffer_to_string(&render_buffer(&mut app, 200, 24));
assert!(
!off_error.contains("Enter: details"),
"{mode:?} must not advertise it on a non-error row: {off_error}"
);
}
}
#[test]
fn error_details_popup_shows_the_change_id_and_complete_diagnostic() {
let mut app = error_row_app(Some(STALLED));
assert!(app.open_error_details_popup());
let content = buffer_to_string(&render_buffer(&mut app, 160, 30));
assert!(content.contains("Error Details"), "{content}");
assert!(content.contains("Change: change-a"), "{content}");
assert!(content.contains(STALLED), "{content}");
}
#[test]
fn error_details_popup_advertises_scroll_copy_and_close() {
let mut app = error_row_app(Some(STALLED));
assert!(app.open_error_details_popup());
let content = buffer_to_string(&render_buffer(&mut app, 160, 30));
assert!(content.contains("scroll"), "{content}");
assert!(content.contains("c: copy"), "{content}");
assert!(content.contains("Esc"), "{content}");
}
#[test]
fn error_details_popup_reports_copy_outcomes_without_losing_the_diagnostic() {
let mut app = error_row_app(Some(STALLED));
assert!(app.open_error_details_popup());
app.set_clipboard(std::sync::Arc::new(
crate::tui::clipboard::test_doubles::RecordingClipboard::default(),
));
app.copy_error_details();
let success = buffer_to_string(&render_buffer(&mut app, 160, 30));
assert!(success.contains("Copied to clipboard"), "{success}");
assert!(success.contains(STALLED), "{success}");
app.set_clipboard(std::sync::Arc::new(
crate::tui::clipboard::test_doubles::FailingClipboard::new("no clipboard provider"),
));
app.copy_error_details();
let failure = buffer_to_string(&render_buffer(&mut app, 160, 30));
assert!(
failure.contains("Copy failed: no clipboard provider"),
"{failure}"
);
assert!(
failure.contains("manually"),
"failure feedback must be actionable: {failure}"
);
assert!(failure.contains(STALLED), "{failure}");
}
#[test]
fn error_details_popup_renders_above_an_interaction_modal_and_below_a_warning() {
let mut app = error_row_app(Some(STALLED));
app.web_url = Some("http://127.0.0.1:8080".to_string());
assert!(app.open_error_details_popup());
app.show_qr_popup();
let over_modal = buffer_to_string(&render_buffer(&mut app, 160, 30));
assert!(over_modal.contains("Error Details"), "{over_modal}");
assert!(over_modal.contains(STALLED), "{over_modal}");
app.show_warning_popup("Merge warning", "conflict in file.rs");
let under_warning = buffer_to_string(&render_buffer(&mut app, 160, 30));
assert!(under_warning.contains("Merge warning"), "{under_warning}");
assert!(
under_warning.contains("conflict in file.rs"),
"{under_warning}"
);
assert!(
!under_warning.contains("Error Details"),
"the warning popup owns the top of the stack: {under_warning}"
);
}
fn dismissal_app(rows: &[(&str, &str)]) -> AppState {
let mut app = create_test_app(rows.iter().map(|(id, _)| create_test_change(id)).collect());
for (index, (_, status)) in rows.iter().enumerate() {
app.changes[index].set_display_status_cache(status);
}
app
}
const DISMISSAL_WIDTH: u16 = 120;
#[test]
fn a_focused_merged_row_advertises_both_dismissal_hints() {
let mut app = dismissal_app(&[("alpha", "merged"), ("beta", "not queued")]);
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 24));
assert!(content.contains("d: dismiss,"), "{content}");
assert!(content.contains("D: dismiss all merged"), "{content}");
}
#[test]
fn a_focused_non_merged_row_advertises_only_the_bulk_hint() {
let mut app = dismissal_app(&[("alpha", "not queued"), ("beta", "merged")]);
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 24));
assert!(
!content.contains("d: dismiss,"),
"an individual hint on a non-merged row would advertise a no-op: {content}"
);
assert!(content.contains("D: dismiss all merged"), "{content}");
}
#[test]
fn no_merged_row_advertises_neither_dismissal_hint() {
let mut app = dismissal_app(&[("alpha", "archived"), ("beta", "pushed")]);
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 24));
assert!(!content.contains("d: dismiss,"), "{content}");
assert!(!content.contains("D: dismiss all merged"), "{content}");
}
#[test]
fn running_mode_shows_the_same_target_dependent_hints() {
let mut app = dismissal_app(&[("alpha", "merged"), ("beta", "applying")]);
app.execution_mode = AppExecutionMode::Running;
app.add_log(LogEntry::info("Applying beta").with_change_id("beta"));
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 30));
assert!(content.contains("d: dismiss,"), "{content}");
assert!(content.contains("D: dismiss all merged"), "{content}");
assert!(content.contains("Tab: worktrees"), "{content}");
assert!(content.contains("l: logs"), "{content}");
}
#[test]
fn an_individual_dismissal_paints_no_overlay_at_all() {
let mut app = dismissal_app(&[("alpha", "merged"), ("beta", "not queued")]);
assert!(app.dismiss_merged_row());
app.logs.clear();
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 30));
assert_eq!(app.modal, None, "{content}");
assert!(!content.contains("Dismiss Merged Rows"), "{content}");
assert!(!content.contains("Y: confirm"), "{content}");
assert!(!content.contains("N or Esc: cancel"), "{content}");
assert!(!content.contains("this TUI session only"), "{content}");
}
#[test]
fn a_bulk_dismissal_paints_no_overlay_at_all() {
let mut app = dismissal_app(&[
("alpha", "merged"),
("beta", "not queued"),
("gamma", "merged"),
]);
assert!(app.dismiss_all_merged_rows());
app.logs.clear();
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 30));
assert_eq!(app.modal, None, "{content}");
assert!(!content.contains("Dismiss 2 merged rows?"), "{content}");
assert!(!content.contains("Dismiss Merged Rows"), "{content}");
assert!(!content.contains("Y: confirm"), "{content}");
}
#[test]
fn an_ineligible_dismissal_repaints_the_very_same_screen() {
let mut app = dismissal_app(&[("alpha", "archived"), ("beta", "pushed")]);
let before = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 24));
assert!(!app.dismiss_merged_row());
assert!(!app.dismiss_all_merged_rows());
let after = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 24));
assert_eq!(before, after, "an inert key must change nothing on screen");
}
#[test]
fn an_immediate_dismissal_removes_the_row_and_retires_its_hints() {
let mut app = dismissal_app(&[("alpha", "merged"), ("beta", "not queued")]);
assert!(app.dismiss_merged_row());
app.logs.clear();
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 24));
assert!(!content.contains("alpha"), "{content}");
assert!(content.contains("beta"), "{content}");
assert!(!content.contains("d: dismiss,"), "{content}");
assert!(!content.contains("D: dismiss all merged"), "{content}");
}
#[test]
fn dismissing_every_row_still_renders_a_valid_empty_changes_pane() {
let mut app = dismissal_app(&[("alpha", "merged"), ("gamma", "merged")]);
assert!(app.dismiss_all_merged_rows());
app.logs.clear();
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 24));
assert!(content.contains("Changes"), "{content}");
assert!(!content.contains("alpha"), "{content}");
assert!(!content.contains("gamma"), "{content}");
assert!(!content.contains("dismiss"), "{content}");
}
#[test]
fn bulk_dismissal_reaches_a_merged_row_the_viewport_never_showed() {
let filler: Vec<String> = (0..20).map(|n| format!("filler-{n:02}")).collect();
let mut rows: Vec<(&str, &str)> = vec![("alpha", "merged")];
rows.extend(filler.iter().map(|id| (id.as_str(), "not queued")));
rows.push(("omega", "merged"));
let mut app = dismissal_app(&rows);
let before = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 16));
assert!(
!before.contains("omega"),
"the fixture must keep omega off the viewport: {before}"
);
assert!(app.dismiss_all_merged_rows());
assert!(app.dismissed_merged_ids().contains("omega"));
assert!(!app.changes.iter().any(|c| c.id == "omega"));
assert!(!app.changes.iter().any(|c| c.id == "alpha"));
}
#[test]
fn the_worktrees_view_never_advertises_merged_row_dismissal() {
let mut app = dismissal_app(&[("alpha", "merged")]);
app.view_mode = ViewMode::Worktrees;
app.worktrees = vec![create_test_worktree("/tmp/worktree-a", "feature-a")];
let content = buffer_to_string(&render_buffer(&mut app, DISMISSAL_WIDTH, 30));
assert!(content.contains("D: delete"), "{content}");
assert!(!content.contains("dismiss"), "{content}");
}
}