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 unicode_width::UnicodeWidthChar;
use super::state::{AppState, ChangeState};
use super::types::AppMode;
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)
}
fn get_checkbox_display(display_status: &str, is_selected: bool) -> (&'static str, Color) {
if matches!(display_status, "archived" | "merged") {
("[x]", Color::DarkGray) } else if is_selected {
("[x]", Color::Green) } else {
("[ ]", Color::Gray) }
}
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 => {
render_worktree_view(frame, app, area);
}
}
if app.mode == AppMode::QrPopup {
render_qr_popup(frame, app, area);
}
if app.mode == AppMode::ConfirmWorktreeDelete {
render_worktree_delete_confirm(frame, app, area);
}
if app.warning_popup.is_some() {
render_warning_popup(frame, app, area);
}
}
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);
render_header(frame, app, chunks[0]);
render_changes_list_select(frame, app, chunks[1]);
render_footer_select(frame, app, chunks[2]);
}
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) {
let chunks = if app.logs_panel_enabled {
let (changes_height, logs_height) =
running_logs_enabled_layout_heights(area.height, running_changes_visual_row_count(app));
Layout::vertical([
Constraint::Length(TUI_HEADER_HEIGHT), Constraint::Length(changes_height), Constraint::Length(TUI_STATUS_HEIGHT), Constraint::Length(logs_height), ])
.split(area)
} else {
Layout::vertical([
Constraint::Length(TUI_HEADER_HEIGHT), Constraint::Min(RUNNING_CHANGES_MIN_HEIGHT), Constraint::Length(TUI_STATUS_HEIGHT), ])
.split(area)
};
render_header(frame, app, chunks[0]);
render_changes_list_running(frame, app, chunks[1]);
render_status(frame, app, chunks[2]);
if app.logs_panel_enabled && chunks.len() > 3 {
render_logs(frame, app, chunks[3]);
}
}
fn render_header(frame: &mut Frame, app: &AppState, area: Rect) {
let active_count = app
.changes
.iter()
.filter(|c| {
matches!(
c.display_status_cache.as_str(),
"applying" | "accepting" | "archiving" | "resolving"
)
})
.count();
let (mode_text, mode_color, show_status) = match app.mode {
AppMode::Select => ("Ready".to_string(), Color::Cyan, true),
AppMode::Running => {
if active_count > 0 {
(format!("Running {}", active_count), Color::Yellow, true)
} else {
("Running".to_string(), Color::Yellow, true)
}
}
AppMode::Stopping => ("Stopping".to_string(), Color::Yellow, true),
AppMode::Stopped | AppMode::Error => {
(String::new(), Color::White, false)
}
AppMode::ConfirmWorktreeDelete => ("Confirm Delete".to_string(), Color::Yellow, true),
AppMode::QrPopup => ("QR Code".to_string(), Color::Green, true),
AppMode::ConfirmForceKill { .. } => ("Confirm Kill".to_string(), Color::Red, true),
};
let mut header_spans = vec![Span::styled("Conflux", Style::default().fg(Color::White))];
if show_status && !mode_text.is_empty() {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
format!("[{}]", mode_text),
Style::default().fg(mode_color).add_modifier(Modifier::BOLD),
));
}
if app.parallel_mode {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
format!("[parallel:{}:{}]", app.max_concurrent, app.vcs_backend),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
}
let header_text = Line::from(header_spans);
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 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]);
}
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_archived =
matches!(change.display_status_cache.as_str(), "archived" | "merged");
let show_uncommitted_badge = app.parallel_mode
&& !change.is_parallel_eligible
&& !is_archived
&& matches!(
change.display_status_cache.as_str(),
"not queued" | "queued"
);
let is_parallel_blocked = show_uncommitted_badge;
let is_selected_row = i == app.cursor_index;
let blocked_fg = if is_selected_row {
Color::Gray
} else {
Color::DarkGray
};
let (checkbox, checkbox_color) = if is_parallel_blocked {
("[ ]", blocked_fg)
} else {
get_checkbox_display(&change.display_status_cache, change.selected)
};
let cursor = if i == app.cursor_index { "►" } else { " " };
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 {
" UNCOMMITED"
} 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.display_status_cache.as_str());
let mut spans = vec![
Span::styled(
format!("{} {} ", checkbox, cursor),
Style::default().fg(checkbox_color),
),
Span::styled(
format!("{:<25}", 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(log) = app.get_latest_log_for_change(&change.id) {
let checkbox_cursor_text = format!("{} {} ", checkbox, cursor);
let checkbox_cursor_width = checkbox_cursor_text.len();
let id_text = format!("{:<25}", display_id);
let id_width = id_text.len();
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 { 11 } else { 0 }; let status_text = format!("[{}]", change.display_status_cache.as_str());
let status_width = format!(" {:>18}", status_text).len();
let tasks_text =
format!(" {}/{} tasks", change.completed_tasks, change.total_tasks);
let tasks_width = tasks_text.len();
let percent_text = format!(" {:>5.1}%", change.progress_percent());
let percent_width = percent_text.len();
let list_border_width = 2;
let base_width = checkbox_cursor_width
+ id_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 >= 10 {
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(),
};
let preview_text = if !header.is_empty() {
format!(" {}{} {}", relative_time, header, log.message)
} else {
format!(" {} {}", relative_time, log.message)
};
let truncated =
truncate_to_display_width_with_suffix(&preview_text, available, "…");
let preview_color = if is_selected_row {
Color::Gray
} else {
Color::DarkGray
};
spans.push(Span::styled(truncated, Style::default().fg(preview_color)));
}
}
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 mut keys = vec!["↑↓/jk: move"];
if let Some(item) = current_item {
let is_parallel_blocked = app.parallel_mode && !item.is_parallel_eligible;
if matches!(
item.display_status_cache.as_str(),
"applying" | "accepting" | "archiving" | "resolving"
) {
if let AppMode::ConfirmForceKill { .. } = app.mode {
keys.push("Y: confirm kill");
keys.push("N: cancel");
} else {
keys.push("K: kill");
}
} else if !is_parallel_blocked {
keys.push(match (item.display_status_cache.as_str(), item.selected) {
("error", true) => "Space: clear retry",
("error", false) => "Space: retry mark",
(_, true) => "Space: unqueue",
(_, false) => "Space: queue",
});
}
keys.push("e: edit");
if item.display_status_cache == "merge wait"
&& matches!(
app.mode,
AppMode::Select | AppMode::Running | AppMode::Stopped
)
{
if app.is_resolving {
keys.push("M: queue resolve");
} else {
keys.push("M: resolve");
}
}
}
if has_queue {
keys.push("F5: run");
}
if app.has_bulk_toggle_targets() {
keys.push("x: toggle all");
}
keys.push("Tab: worktrees");
if app.parallel_available {
keys.push(if app.parallel_mode {
"=: sequential"
} else {
"=: parallel"
});
}
if app.web_url.is_some() {
keys.push("w: QR");
}
keys.push("l: logs");
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_archived =
matches!(change.display_status_cache.as_str(), "archived" | "merged");
let show_uncommitted_badge = app.parallel_mode
&& !change.is_parallel_eligible
&& !is_archived
&& matches!(
change.display_status_cache.as_str(),
"not queued" | "queued"
);
let is_parallel_blocked = show_uncommitted_badge;
let is_selected_row = i == app.cursor_index;
let blocked_fg = if is_selected_row {
Color::Gray
} else {
Color::DarkGray
};
let (checkbox, checkbox_color) = if is_parallel_blocked {
("[ ]", blocked_fg)
} else {
get_checkbox_display(&change.display_status_cache, change.selected)
};
let cursor = if i == app.cursor_index { "►" } else { " " };
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 {
" UNCOMMITED"
} 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 elapsed_text = if let Some(elapsed) = change.elapsed_time {
format_duration(elapsed)
} else if let Some(started) = change.started_at {
format_duration(started.elapsed())
} else {
"--".to_string()
};
let (spinner_prefix, status_text) = match change.display_status_cache.as_str() {
"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)
}
"archived" | "merged" | "error" => (
String::new(),
format!("[{}]", change.display_status_cache.as_str()),
),
_ => (
String::new(),
format!("[{}]", change.display_status_cache.as_str()),
),
};
let (spinner_elapsed_width, status_only_width) = if !spinner_prefix.is_empty() {
let spinner_elapsed_text =
format!(" {}{:>7} ", spinner_prefix.trim(), elapsed_text);
(spinner_elapsed_text.len(), status_text.len())
} else {
let status_formatted = format!(" {:>18}", status_text);
(0, status_formatted.len())
};
let parsed = split_remote_change_id(&change.id);
let display_id = parsed.change;
let mut spans = vec![
Span::styled(
format!("{} {} ", checkbox, cursor),
Style::default().fg(checkbox_color),
),
Span::styled(
format!("{:<25}", 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),
),
];
if !spinner_prefix.is_empty() {
spans.push(Span::styled(
format!(" {}{:>7} ", spinner_prefix.trim(), elapsed_text),
Style::default().fg(dim_color),
));
spans.push(Span::styled(
status_text,
Style::default().fg(change.display_color_cache),
));
} else {
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(log) = app.get_latest_log_for_change(&change.id) {
let checkbox_cursor_text = format!("{} {} ", checkbox, cursor);
let checkbox_cursor_width = checkbox_cursor_text.len();
let id_text = format!("{:<25}", display_id);
let id_width = id_text.len();
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 { 11 } else { 0 };
let tasks_width = tasks_text.len();
let list_border_width = 2;
let base_width = checkbox_cursor_width
+ id_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 >= 10 {
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(),
};
let preview_text = if !header.is_empty() {
format!(" {}{} {}", relative_time, header, log.message)
} else {
format!(" {} {}", relative_time, log.message)
};
let truncated =
truncate_to_display_width_with_suffix(&preview_text, available, "…");
let preview_color = if is_selected_row {
Color::Gray
} else {
Color::DarkGray
};
spans.push(Span::styled(truncated, Style::default().fg(preview_color)));
}
}
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"];
if let Some(item) = current_item {
let is_parallel_blocked = app.parallel_mode && !item.is_parallel_eligible;
if matches!(
item.display_status_cache.as_str(),
"applying" | "accepting" | "archiving" | "resolving"
) {
if let AppMode::ConfirmForceKill { .. } = app.mode {
keys.push("Y: confirm kill");
keys.push("N: cancel");
} else {
keys.push("K: kill");
}
} else if !is_parallel_blocked {
keys.push(match (item.display_status_cache.as_str(), item.selected) {
("error", true) => "Space: clear retry",
("error", false) => "Space: retry mark",
(_, true) => "Space: unqueue",
(_, false) => "Space: queue",
});
}
keys.push("e: edit");
if item.display_status_cache == "merge wait"
&& matches!(
app.mode,
AppMode::Select | AppMode::Running | AppMode::Stopped
)
{
if app.is_resolving {
keys.push("M: queue resolve");
} else {
keys.push("M: resolve");
}
}
}
if app.has_bulk_toggle_targets() {
keys.push("x: toggle all");
}
keys.push("Tab: worktrees");
if app.web_url.is_some() {
keys.push("w: QR");
}
keys.push("l: logs");
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_status(frame: &mut Frame, app: &AppState, area: Rect) {
let (total_tasks, completed_tasks) = app
.changes
.iter()
.filter(|c| c.selected) .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.mode, AppMode::Running | AppMode::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 title = match app.mode {
AppMode::Running => " Status (Esc: stop, Ctrl+C: quit) ".to_string(),
AppMode::Stopping => " Status (F5: continue, Esc: force stop, Ctrl+C: quit) ".to_string(),
AppMode::Stopped => " Status (F5: resume, Ctrl+C: quit) ".to_string(),
AppMode::ConfirmWorktreeDelete => " Status (Y/N: confirm, Ctrl+C: quit) ".to_string(),
_ => " Status (Ctrl+C: quit) ".to_string(),
};
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 take_chars_by_display_width(s: &str, max_width: usize) -> (&str, &str) {
let mut current_width = 0usize;
let mut byte_pos = 0usize;
for ch in s.chars() {
let char_width = UnicodeWidthChar::width(ch).unwrap_or(1);
if current_width + char_width > max_width {
if current_width == 0 {
byte_pos += ch.len_utf8();
}
break;
}
current_width += char_width;
byte_pos += ch.len_utf8();
}
(&s[..byte_pos], &s[byte_pos..])
}
fn wrap_log_message(
message: &str,
available_width: usize,
header_width: usize,
prefix_width: usize,
) -> Vec<String> {
if available_width == 0 {
return vec![message.to_string()];
}
let mut lines = Vec::new();
let mut remaining = message;
let first_width = available_width.saturating_sub(header_width);
if first_width == 0 {
lines.push(remaining.to_string());
return lines;
}
let (first_part, rest) = take_chars_by_display_width(remaining, first_width);
lines.push(first_part.to_string());
remaining = rest;
if remaining.is_empty() {
return lines;
}
let continuation_width =
available_width.saturating_add(prefix_width.saturating_sub(header_width));
while !remaining.is_empty() {
if continuation_width == 0 {
lines.push(remaining.to_string());
break;
}
let (chunk, rest) = take_chars_by_display_width(remaining, continuation_width);
lines.push(chunk.to_string());
remaining = rest;
}
lines
}
fn render_logs(frame: &mut Frame, app: &AppState, area: Rect) {
let timestamp_width = 9; let border_width = 2;
let available_width = (area.width as usize).saturating_sub(border_width + timestamp_width);
let visible_height = (area.height as usize).saturating_sub(2);
let change_colors = [
Color::Cyan,
Color::Magenta,
Color::LightBlue,
Color::LightGreen,
Color::LightYellow,
Color::LightMagenta,
Color::LightCyan,
];
struct RenderedLog {
timestamp: String,
timestamp_style: Style,
header: String,
header_style: Style,
message_lines: Vec<String>,
message_style: Style,
}
let rendered_logs: Vec<RenderedLog> = app
.logs
.iter()
.map(|entry| {
let timestamp = format!("{} ", entry.timestamp);
let timestamp_style = Style::default().fg(Color::DarkGray);
let (header, header_style, prefix_width) = if let Some(ref operation) = entry.operation
{
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
};
let prefix_color = change_colors[color_index];
let header = match (&entry.change_id, entry.iteration) {
(Some(change_id), Some(iter)) => {
format!("[{}:{}:{}] ", change_id, operation, iter)
}
(Some(change_id), None) => format!("[{}:{}] ", change_id, operation),
(None, Some(iter)) => format!("[{}:{}] ", operation, iter),
(None, None) => {
if operation == "analysis" {
format!("[{}:1] ", operation)
} else {
format!("[{}] ", operation)
}
}
};
let prefix_width = timestamp.len() + header.len();
let header_style = Style::default()
.fg(prefix_color)
.add_modifier(Modifier::BOLD);
(header, header_style, prefix_width)
} else {
let prefix_width = timestamp.len();
(String::new(), Style::default(), prefix_width)
};
let message_lines =
wrap_log_message(&entry.message, available_width, header.len(), prefix_width);
let message_style = Style::default().fg(entry.color);
RenderedLog {
timestamp,
timestamp_style,
header,
header_style,
message_lines,
message_style,
}
})
.collect();
let total_display_lines: usize = rendered_logs.iter().map(|r| r.message_lines.len()).sum();
let total_logs = rendered_logs.len();
let skipped_logs = app.log_scroll_offset.min(total_logs);
let display_line_offset: usize = rendered_logs
.iter()
.rev()
.take(skipped_logs)
.map(|r| r.message_lines.len())
.sum();
let end_line = total_display_lines.saturating_sub(display_line_offset);
let start_line = end_line.saturating_sub(visible_height);
let mut log_items: Vec<Line> = Vec::new();
let mut current_line = 0;
for rendered in &rendered_logs {
let entry_line_count = rendered.message_lines.len();
let entry_end = current_line + entry_line_count;
if entry_end > start_line && current_line < end_line {
let visible_start_in_entry = start_line.saturating_sub(current_line);
let visible_end_in_entry = entry_line_count.min(end_line.saturating_sub(current_line));
for (line_idx, message_line) in rendered.message_lines.iter().enumerate() {
if line_idx >= visible_start_in_entry && line_idx < visible_end_in_entry {
let mut spans = Vec::new();
if line_idx == 0 {
spans.push(Span::styled(
rendered.timestamp.clone(),
rendered.timestamp_style,
));
if !rendered.header.is_empty() {
spans
.push(Span::styled(rendered.header.clone(), rendered.header_style));
}
spans.push(Span::styled(message_line.clone(), rendered.message_style));
} else {
spans.push(Span::styled(message_line.clone(), rendered.message_style));
}
log_items.push(Line::from(spans));
}
}
}
current_line = entry_end;
}
let auto_scroll_indicator = if app.log_auto_scroll { "▼" } else { "⏸" };
let title = if total_display_lines > visible_height {
let visible_start = start_line + 1;
let visible_end = end_line;
format!(
" Logs [{}-{}/{}] logs_off={} {} ",
visible_start,
visible_end,
total_display_lines,
app.log_scroll_offset,
auto_scroll_indicator
)
} else {
format!(
" Logs logs_off={} {} ",
app.log_scroll_offset, auto_scroll_indicator
)
};
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 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 has_error_changes = app
.changes
.iter()
.any(|change| change.display_status_cache == "error");
let message = if has_error_changes {
"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(
"Press F5 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_view(frame: &mut Frame, app: &mut AppState, area: Rect) {
let chunks = Layout::vertical([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(3), ])
.split(area);
render_header(frame, app, chunks[0]);
render_worktree_list(frame, app, chunks[1]);
render_footer_worktree(frame, app, chunks[2]);
}
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 deleting = app.is_worktree_deleting(&wt.path);
let delete_indicator = if deleting { " [Deleting...]" } else { "" };
let line = format!(
"{} → {}{}{}{}{}",
label, branch, indicator, merge_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 !wt.is_main
&& !wt.is_detached
&& !wt.has_merge_conflict()
&& !wt.branch.is_empty()
&& wt.has_commits_ahead
&& !app.is_resolving
&& !wt.is_merging
{
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) {
use crate::tui::types::WorktreeAction;
let Some((path, WorktreeAction::Delete)) = &app.pending_worktree_action else {
return;
};
let modal_width = (area.width * 60 / 100).clamp(40, 90);
let modal_height = (area.height * 30 / 100).clamp(7, 12);
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.",
Style::default().fg(Color::DarkGray),
)),
Line::from(""),
Line::from(Span::styled(
"Press Y to delete, N or Esc to cancel.",
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 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::*;
use crate::openspec::Change;
use crate::openspec::ProposalMetadata;
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(),
}
}
fn create_test_app(changes: Vec<Change>) -> AppState {
let mut app = AppState::new(changes);
app.logs.clear();
app.parallel_available = false;
app.parallel_mode = false;
app.web_url = None;
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,
}
}
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")
}
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.mode = AppMode::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.mode = AppMode::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_logs_disabled_layout_does_not_render_logs_panel() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.mode = AppMode::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 test_get_checkbox_display_archived_always_gray() {
let (text, color) = get_checkbox_display("archived", true);
assert_eq!(text, "[x]");
assert_eq!(color, Color::DarkGray);
let (text, color) = get_checkbox_display("archived", false);
assert_eq!(text, "[x]");
assert_eq!(color, Color::DarkGray);
}
#[test]
fn test_get_checkbox_display_not_selected() {
let (text, color) = get_checkbox_display("not queued", 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);
assert_eq!(text, "[x]");
assert_eq!(color, Color::Green);
let (text, color) = get_checkbox_display("queued", true);
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);
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);
assert_eq!(text, "[x]");
assert_eq!(color, Color::Green);
let (text, color) = get_checkbox_display("archiving", true);
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.mode = AppMode::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.mode = AppMode::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.is_resolving = false;
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.is_resolving = true;
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_f5_run_hint_while_resolving() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.mode = AppMode::Select;
app.is_resolving = true;
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"));
}
#[test]
fn test_render_uses_centralized_resolve_check_in_select_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.mode = AppMode::Select;
app.changes[0].display_status_cache = "merge wait".to_string();
app.is_resolving = false;
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.mode = AppMode::Error; app.changes[0].display_status_cache = "merge wait".to_string();
app.is_resolving = false;
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.mode = AppMode::Running;
app.changes[0].display_status_cache = "merge wait".to_string();
app.is_resolving = false;
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![
(
AppMode::Select,
"merge wait".to_string(),
false,
true,
false,
),
(AppMode::Select, "merge wait".to_string(), true, false, true),
(
AppMode::Running,
"merge wait".to_string(),
false,
true,
false,
),
(
AppMode::Running,
"merge wait".to_string(),
true,
false,
true,
),
(
AppMode::Error,
"merge wait".to_string(),
false,
false,
false,
),
(AppMode::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.mode = mode.clone();
app.changes[0].display_status_cache = display_status_cache.clone();
app.is_resolving = is_resolving;
app.cursor_index = 0;
if mode != AppMode::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() {
use crate::tui::types::WorktreeAction;
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.pending_worktree_action =
Some(("/path/to/worktree".to_string(), WorktreeAction::Delete));
app.mode = AppMode::ConfirmWorktreeDelete;
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_uncommited_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.parallel_mode = true;
app.changes[0].display_status_cache = "archived".to_string();
app.changes[0].is_parallel_eligible = false;
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(!content.contains("UNCOMMITED"));
assert!(content.contains("[x]"));
}
#[test]
fn test_render_parallel_uncommitted_queueable_row_shows_uncommited_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.parallel_mode = true;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].is_parallel_eligible = false;
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("UNCOMMITED"));
}
const SELECT_FIRST_ROW_Y: u16 = 4;
const CHANGE_ID_X: u16 = 7;
#[test]
fn test_focused_blocked_row_has_readable_fg_select_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.parallel_mode = true;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].is_parallel_eligible = false;
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.parallel_mode = true;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].is_parallel_eligible = false;
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.mode = AppMode::Running;
app.parallel_mode = true;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].is_parallel_eligible = false;
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.mode = AppMode::Running;
app.parallel_mode = true;
app.changes[0].display_status_cache = "not queued".to_string();
app.changes[0].is_parallel_eligible = false;
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"));
}
#[test]
fn test_render_shows_uncommitted_badge() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.parallel_available = true;
app.parallel_mode = true;
app.apply_parallel_eligibility(&HashSet::new(), &HashSet::new());
let buffer = render_buffer(&mut app, 80, 24);
let content = buffer_to_string(&buffer);
assert!(content.contains("UNCOMMITED"));
}
#[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.mode = AppMode::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.mode = AppMode::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.mode = AppMode::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 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.mode = AppMode::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.mode = AppMode::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.mode = AppMode::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 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"
);
}
#[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
);
}
}
#[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_parallel_mode_uncommitted_change_no_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.parallel_mode = true;
app.parallel_available = true;
app.changes[0].is_parallel_eligible = false;
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: queue"),
"Space: queue should not be shown for uncommitted changes in parallel mode"
);
assert!(
!content.contains("Space: unqueue"),
"Space: unqueue should not be shown for uncommitted changes in parallel mode"
);
assert!(
content.contains("UNCOMMITED"),
"UNCOMMITED badge should be shown"
);
}
#[test]
fn test_parallel_mode_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.parallel_mode = true;
app.parallel_available = true;
app.changes[0].is_parallel_eligible = true;
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: queue"),
"Space: queue should be shown for committed changes in parallel mode"
);
}
#[test]
fn test_toggle_all_hint_shown_in_select_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.mode = AppMode::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.mode = AppMode::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.mode = AppMode::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_not_shown_in_running_mode_without_non_active_targets() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.mode = AppMode::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"),
"Should NOT show 'x: toggle all' hint in Running mode when all changes are active"
);
}
#[test]
fn test_toggle_all_hint_not_shown_in_stopping_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.mode = AppMode::Stopping;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
!content.contains("x: toggle all"),
"Should NOT show 'x: toggle all' hint in Stopping mode"
);
}
#[test]
fn test_toggle_all_hint_not_shown_in_error_mode() {
let mut app = create_test_app(vec![create_test_change("change-a")]);
app.mode = AppMode::Error;
let buffer = render_buffer(&mut app, 100, 24);
let content = buffer_to_string(&buffer);
assert!(
!content.contains("x: toggle all"),
"Should NOT show 'x: toggle all' hint in Error mode"
);
}
#[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.mode = AppMode::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 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(),
display_color_cache: Color::DarkGray,
error_message_cache: None,
selected: false,
is_new: false,
is_parallel_eligible: true,
has_worktree: false,
started_at: None,
elapsed_time: None,
iteration_number: None,
}
}
#[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"
);
}
}