use std::{
cell::RefCell,
io,
io::{Stdout, Write},
};
use crossterm::{
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use crate::{
cli::pairing::{ApprovedBrowsersState, safe_human_label, short_id},
cli::state::{RuntimeScreenModel, STAGES, StatusViewModel, WizardState},
config::app::ProviderId,
domain::errors::{AgentError, AgentResult, ErrorCode},
providers::descriptor::descriptor,
setup::flow::THIRD_PARTY_CODE_WARNING,
setup::readiness::{CheckId, CheckStatus},
};
const FOOTER: &str = "↑↓ navigate · enter continue · esc back";
const ACCENT: Color = Color::Cyan;
const MUTED: Color = Color::DarkGray;
pub(crate) fn render_wizard(frame: &mut Frame<'_>, state: &WizardState) {
let [header, body, footer] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(1),
])
.areas(frame.area());
render_header(frame, header, "Guided setup");
let [stages, detail] =
Layout::horizontal([Constraint::Length(24), Constraint::Min(24)]).areas(body);
let items = STAGES.into_iter().map(|stage| {
let active = stage == state.active();
let marker = if active { "› " } else { " " };
let style = if active {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(MUTED)
};
ListItem::new(Line::from(vec![
Span::styled(marker, style),
Span::styled(stage_label(stage), style),
]))
});
frame.render_widget(
List::new(items).block(Block::default().borders(Borders::ALL).title("Setup")),
stages,
);
let active = state.active();
let mut detail_lines = vec![
Line::styled(
stage_label(active),
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
),
Line::default(),
Line::from(stage_description(active)),
];
if active == CheckId::Providers {
detail_lines.extend(provider_selection_lines(state));
}
if active == CheckId::Pairing {
detail_lines.extend(approved_browser_lines(state.approved_browsers()));
}
frame.render_widget(
Paragraph::new(detail_lines)
.wrap(Wrap { trim: true })
.block(
Block::default()
.borders(Borders::ALL)
.title("Current stage"),
),
detail,
);
render_footer(frame, footer);
}
#[cfg(test)]
pub(crate) fn render_approved_browsers(
frame: &mut Frame<'_>,
area: Rect,
state: &ApprovedBrowsersState,
) {
frame.render_widget(
Paragraph::new(approved_browser_lines(state))
.wrap(Wrap { trim: true })
.block(
Block::default()
.borders(Borders::ALL)
.title("Approved browsers"),
),
area,
);
}
fn approved_browser_lines(state: &ApprovedBrowsersState) -> Vec<Line<'static>> {
if state.clients().is_empty() {
return vec![Line::from("No approved browsers yet")];
}
state
.clients()
.iter()
.map(|client| {
Line::from(format!(
"{} {} created {} last used {}",
short_id(&client.id),
safe_human_label(&client.label),
client.created_at.to_rfc3339(),
client.last_used_at.to_rfc3339()
))
})
.collect()
}
fn provider_selection_lines(state: &WizardState) -> Vec<Line<'static>> {
let providers = [
ProviderId::OpenaiCodex,
ProviderId::ClaudeCode,
ProviderId::Antigravity,
];
let mut lines = vec![Line::default(), Line::from("1/2/3 toggle providers")];
lines.extend(providers.into_iter().enumerate().map(|(index, provider)| {
let selected = state.selected_providers().contains(&provider);
Line::from(format!(
"{} [{}] {}",
index + 1,
if selected { "x" } else { " " },
descriptor(provider).display_name
))
}));
lines.extend([
Line::default(),
Line::styled(THIRD_PARTY_CODE_WARNING, Color::Yellow),
]);
lines
}
pub(crate) fn render_status(frame: &mut Frame<'_>, model: &StatusViewModel) {
let [header, checks, actions, footer] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(7),
Constraint::Min(6),
Constraint::Length(1),
])
.areas(frame.area());
render_header(frame, header, "Status");
let rows = model.checks().iter().map(|check| {
let (status, color) = status_label(check.status);
Line::from(vec![
Span::styled(format!("{:<18}", stage_label(check.id)), Color::White),
Span::styled(status, color),
])
});
frame.render_widget(
Paragraph::new(rows.collect::<Vec<_>>())
.block(Block::default().borders(Borders::ALL).title("Readiness")),
checks,
);
let start_style = if model.start_enabled {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(MUTED)
};
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled("Start server", start_style)),
Line::from("Configure"),
Line::from("Doctor"),
Line::from("Quit"),
])
.block(Block::default().borders(Borders::ALL).title("Actions")),
actions,
);
render_footer(frame, footer);
}
pub(crate) fn render_running(frame: &mut Frame<'_>, model: &RuntimeScreenModel) {
let [header, status_area, events_area, footer] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(11),
Constraint::Min(6),
Constraint::Length(1),
])
.areas(frame.area());
render_header(frame, header, "Running");
let status = model.status();
let mut lines = vec![
Line::from(format!(
"Runtime state: {}",
runtime_phase_label(status.phase)
)),
Line::from(format!(
"Pairing code: {}",
status.pairing_code.as_deref().unwrap_or("not active")
)),
Line::from(format!(
"Pairing expiry: {}",
status
.pairing_expires_at
.as_deref()
.unwrap_or("not active")
.to_owned()
)),
Line::from(format!(
"Frontend link: {}",
status
.frontend_deep_link
.as_deref()
.unwrap_or("not available")
)),
Line::from(format!(
"Approved browsers: {}",
model.approved_client_count()
)),
];
if let Some(endpoint_id) = &status.iroh_endpoint_id {
lines.insert(1, Line::from(format!("Iroh Endpoint ID: {endpoint_id}")));
}
if let Some(message) = &status.message {
lines.push(Line::from(format!("Status: {message}")));
}
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: true })
.block(Block::default().borders(Borders::ALL).title("Runtime")),
status_area,
);
let events = if model.events().is_empty() {
vec![Line::from("No lifecycle events yet")]
} else {
model
.events()
.iter()
.map(|event| Line::from(format!("{event:?}")))
.collect()
};
frame.render_widget(
Paragraph::new(events).wrap(Wrap { trim: true }).block(
Block::default()
.borders(Borders::ALL)
.title("Lifecycle events"),
),
events_area,
);
frame.render_widget(
Paragraph::new("Ctrl-C to stop").style(Style::default().fg(MUTED)),
footer,
);
}
fn runtime_phase_label(phase: crate::runtime::RuntimePhase) -> &'static str {
match phase {
crate::runtime::RuntimePhase::Starting => "starting",
crate::runtime::RuntimePhase::Ready => "ready",
crate::runtime::RuntimePhase::Degraded => "degraded",
crate::runtime::RuntimePhase::Stopping => "stopping",
crate::runtime::RuntimePhase::Stopped => "stopped",
}
}
fn render_header(frame: &mut Frame<'_>, area: Rect, screen: &str) {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"Regy PC Agent",
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" · {screen}"), MUTED),
]))
.block(Block::default().borders(Borders::BOTTOM)),
area,
);
}
fn render_footer(frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(
Paragraph::new(FOOTER).style(Style::default().fg(MUTED)),
area,
);
}
fn stage_label(stage: CheckId) -> &'static str {
match stage {
CheckId::System => "System",
CheckId::Pi => "Pi",
CheckId::Providers => "Models",
CheckId::Pairing => "Pairing security",
CheckId::Tunnel => "Connectivity",
}
}
fn stage_description(stage: CheckId) -> &'static str {
match stage {
CheckId::System => "Validate this computer and its local paths.",
CheckId::Pi => "Locate or install the Pi executable.",
CheckId::Providers => "Configure at least one usable model provider.",
CheckId::Pairing => "Prepare the server identity and approved-browser store.",
CheckId::Tunnel => "Configure the exact HTTPS frontend origin for Iroh access.",
}
}
fn status_label(status: CheckStatus) -> (&'static str, Color) {
match status {
CheckStatus::Checking => ("checking", MUTED),
CheckStatus::Ready => ("ready", Color::Green),
CheckStatus::Warning => ("warning", Color::Yellow),
CheckStatus::ActionRequired => ("action required", Color::Red),
}
}
pub(crate) struct TerminalSession {
terminal: Terminal<CrosstermBackend<Stdout>>,
mode: TerminalModeState,
}
pub(crate) trait TerminalControl: Send {
fn suspend(&mut self) -> AgentResult<()>;
fn resume(&mut self) -> AgentResult<()>;
fn show_instruction(&mut self, instruction: &'static str) -> AgentResult<()>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TerminalModeState {
alternate_screen: bool,
raw_enabled: bool,
}
impl TerminalModeState {
pub(crate) fn active() -> Self {
Self {
alternate_screen: true,
raw_enabled: true,
}
}
#[cfg(test)]
pub(crate) fn suspended() -> Self {
Self {
alternate_screen: false,
raw_enabled: false,
}
}
#[cfg(test)]
pub(crate) fn alternate_screen(self) -> bool {
self.alternate_screen
}
#[cfg(test)]
pub(crate) fn raw_enabled(self) -> bool {
self.raw_enabled
}
pub(crate) fn suspend_with<L, D, A, E>(
&mut self,
mut leave_alternate_screen: L,
mut disable_raw: D,
mut enter_alternate_screen: A,
mut enable_raw: E,
) -> io::Result<()>
where
L: FnMut() -> io::Result<()>,
D: FnMut() -> io::Result<()>,
A: FnMut() -> io::Result<()>,
E: FnMut() -> io::Result<()>,
{
let started_active = self.alternate_screen && self.raw_enabled;
let mut first_error = None;
let mut left_alternate_screen = false;
let mut disabled_raw = false;
if self.alternate_screen {
match leave_alternate_screen() {
Ok(()) => {
self.alternate_screen = false;
left_alternate_screen = true;
}
Err(error) => first_error = Some(error),
}
}
if self.raw_enabled {
match disable_raw() {
Ok(()) => {
self.raw_enabled = false;
disabled_raw = true;
}
Err(error) if first_error.is_none() => first_error = Some(error),
Err(_) => {}
}
}
if first_error.is_some() && started_active {
if left_alternate_screen && !disabled_raw {
if enter_alternate_screen().is_ok() {
self.alternate_screen = true;
}
} else if !left_alternate_screen && disabled_raw && enable_raw().is_ok() {
self.raw_enabled = true;
}
}
first_error.map_or(Ok(()), Err)
}
pub(crate) fn resume_with<E, A, D, R>(
&mut self,
mut enable_raw: E,
mut enter_alternate_screen: A,
mut disable_raw: D,
mut redraw: R,
) -> io::Result<()>
where
E: FnMut() -> io::Result<()>,
A: FnMut() -> io::Result<()>,
D: FnMut() -> io::Result<()>,
R: FnMut() -> io::Result<()>,
{
let enabled_here = !self.raw_enabled;
if enabled_here {
enable_raw()?;
self.raw_enabled = true;
}
if !self.alternate_screen {
if let Err(error) = enter_alternate_screen() {
if enabled_here && disable_raw().is_ok() {
self.raw_enabled = false;
}
return Err(error);
}
self.alternate_screen = true;
}
redraw()
}
pub(crate) fn finish_suspending_with<L, D>(
&mut self,
mut leave_alternate_screen: L,
mut disable_raw: D,
) -> io::Result<()>
where
L: FnMut() -> io::Result<()>,
D: FnMut() -> io::Result<()>,
{
let mut first_error = None;
if self.alternate_screen {
match leave_alternate_screen() {
Ok(()) => self.alternate_screen = false,
Err(error) => first_error = Some(error),
}
}
if self.raw_enabled {
match disable_raw() {
Ok(()) => self.raw_enabled = false,
Err(error) if first_error.is_none() => first_error = Some(error),
Err(_) => {}
}
}
first_error.map_or(Ok(()), Err)
}
}
impl TerminalSession {
pub(crate) fn new() -> io::Result<Self> {
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
enable_raw_mode()?;
if let Err(error) = execute!(terminal.backend_mut(), EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(error);
}
Ok(Self {
terminal,
mode: TerminalModeState::active(),
})
}
pub(crate) fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<Stdout>> {
&mut self.terminal
}
fn suspend_io(&mut self) -> io::Result<()> {
let terminal = RefCell::new(&mut self.terminal);
self.mode.suspend_with(
|| {
let mut terminal = terminal.borrow_mut();
execute!(terminal.backend_mut(), LeaveAlternateScreen)
},
disable_raw_mode,
|| {
let mut terminal = terminal.borrow_mut();
execute!(terminal.backend_mut(), EnterAlternateScreen)
},
enable_raw_mode,
)
}
fn resume_io(&mut self) -> io::Result<()> {
let terminal = RefCell::new(&mut self.terminal);
self.mode.resume_with(
enable_raw_mode,
|| {
let mut terminal = terminal.borrow_mut();
execute!(terminal.backend_mut(), EnterAlternateScreen)
},
disable_raw_mode,
|| {
let mut terminal = terminal.borrow_mut();
terminal.autoresize()?;
terminal.clear()
},
)
}
fn finish_suspending_io(&mut self) -> io::Result<()> {
let terminal = &mut self.terminal;
self.mode.finish_suspending_with(
|| execute!(terminal.backend_mut(), LeaveAlternateScreen),
disable_raw_mode,
)
}
}
impl TerminalControl for TerminalSession {
fn suspend(&mut self) -> AgentResult<()> {
self.suspend_io()
.map_err(|_| terminal_control_error("terminal suspension failed"))
}
fn resume(&mut self) -> AgentResult<()> {
self.resume_io()
.map_err(|_| terminal_control_error("terminal restore failed"))
}
fn show_instruction(&mut self, instruction: &'static str) -> AgentResult<()> {
writeln!(self.terminal.backend_mut(), "{instruction}")
.and_then(|()| self.terminal.backend_mut().flush())
.map_err(|_| terminal_control_error("terminal instruction display failed"))
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
if self.finish_suspending_io().is_err() {
let _ = self.finish_suspending_io();
}
}
}
fn terminal_control_error(message: &'static str) -> AgentError {
AgentError::new(ErrorCode::InvalidMessage, message)
}