use anyhow::Result;
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::{
collections::BTreeMap,
io,
path::{Path, PathBuf},
process::ExitCode,
sync::mpsc,
time::{Duration, Instant},
};
use crate::cli::TuiArgs;
use super::{
actions::{self, DrainEvent},
data::{self, Snapshot},
model::{ViewId, finding_cards, validate_fix_sha, waiver_template},
ui,
};
const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
const POLL: Duration = Duration::from_millis(80);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FocusPane {
Nav,
Main,
Detail,
Log,
}
#[derive(Clone, Debug)]
pub enum Modal {
Help,
Confirm {
title: String,
body: String,
action: ConfirmAction,
},
TextPrompt {
title: String,
hint: String,
buffer: String,
kind: TextKind,
},
Multiline {
title: String,
hint: String,
buffer: String,
kind: MultiKind,
},
DiffPreview {
title: String,
diff: String,
path: String,
value: String,
},
}
#[derive(Clone, Debug)]
pub enum ConfirmAction {
RemoveQueue { run_id: String, sha: String },
Quit,
}
#[derive(Clone, Debug)]
pub enum TextKind {
Petition { rejection_sha: String },
ConfigEdit { path: String },
}
#[derive(Clone, Debug)]
pub enum MultiKind {
Waiver { sha: String },
}
pub struct App {
pub state_dir: PathBuf,
pub config_path: PathBuf,
pub version: String,
pub auto_refresh: bool,
pub view: ViewId,
pub focus: FocusPane,
pub snapshot: Snapshot,
pub last_refresh: Instant,
pub status: String,
pub modal: Option<Modal>,
pub queue_sel: usize,
pub ledger_sel: usize,
pub config_sel: usize,
pub debt_sel: usize,
pub debt_entry_sel: usize,
pub ledger_detail: bool,
pub finding_sel: usize,
pub scroll: u16,
pub drain_log: Vec<String>,
pub drain_running: bool,
drain_rx: Option<mpsc::Receiver<DrainEvent>>,
_drain_handle: Option<std::thread::JoinHandle<()>>,
pub should_quit: bool,
subject_cache: BTreeMap<String, String>,
}
impl App {
pub fn new(
state_dir: PathBuf,
config_path: PathBuf,
version: String,
auto_refresh: bool,
) -> Result<Self> {
let mut subject_cache = BTreeMap::new();
let snapshot = data::load_snapshot(&state_dir, &config_path, &version, &mut subject_cache)?;
let warnings = if snapshot.warnings.is_empty() {
"ready".to_owned()
} else {
snapshot.warnings.join("; ")
};
Ok(Self {
state_dir,
config_path,
version,
auto_refresh,
view: ViewId::Dashboard,
focus: FocusPane::Main,
snapshot,
last_refresh: Instant::now(),
status: warnings,
modal: None,
queue_sel: 0,
ledger_sel: 0,
config_sel: 0,
debt_sel: 0,
debt_entry_sel: 0,
ledger_detail: false,
finding_sel: 0,
scroll: 0,
drain_log: Vec::new(),
drain_running: false,
drain_rx: None,
_drain_handle: None,
should_quit: false,
subject_cache,
})
}
pub fn refresh(&mut self) {
match data::load_snapshot(
&self.state_dir,
&self.config_path,
&self.version,
&mut self.subject_cache,
) {
Ok(snapshot) => {
self.clamp_selections(&snapshot);
self.snapshot = snapshot;
self.last_refresh = Instant::now();
}
Err(error) => {
self.status = format!("refresh failed: {error}");
}
}
}
fn clamp_selections(&mut self, snapshot: &Snapshot) {
if self.queue_sel >= snapshot.queue.len() {
self.queue_sel = snapshot.queue.len().saturating_sub(1);
}
if self.ledger_sel >= snapshot.ledger.len() {
self.ledger_sel = snapshot.ledger.len().saturating_sub(1);
}
if self.config_sel >= snapshot.config_rows.len() {
self.config_sel = snapshot.config_rows.len().saturating_sub(1);
}
if self.debt_sel >= snapshot.debt.len() {
self.debt_sel = snapshot.debt.len().saturating_sub(1);
}
if let Some(group) = snapshot.debt.get(self.debt_sel) {
if self.debt_entry_sel >= group.entries.len() {
self.debt_entry_sel = group.entries.len().saturating_sub(1);
}
} else {
self.debt_entry_sel = 0;
}
}
pub fn tick_drain(&mut self) {
let Some(rx) = self.drain_rx.take() else {
return;
};
let mut done = false;
let mut exit_code = 0;
let mut disconnected = false;
loop {
match rx.try_recv() {
Ok(DrainEvent::Line(line)) => {
self.drain_log.push(line);
if self.drain_log.len() > 400 {
let drain = self.drain_log.len() - 400;
self.drain_log.drain(0..drain);
}
}
Ok(DrainEvent::Done(code)) => {
done = true;
exit_code = code;
break;
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
if disconnected {
self.drain_running = false;
self.status = "drain worker disconnected unexpectedly (no exit code reported)".into();
self.refresh();
self.drain_rx = None;
} else if done {
self.drain_running = false;
self.status = format!("drain finished (exit {exit_code})");
self.refresh();
self.drain_rx = None;
} else {
self.drain_rx = Some(rx);
}
}
pub fn on_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if let Some(modal) = self.modal.clone() {
self.handle_modal_key(modal, key);
return;
}
match key.code {
KeyCode::Char('q') if key.modifiers.is_empty() => {
self.modal = Some(Modal::Confirm {
title: "Quit".into(),
body: "Exit the control panel?".into(),
action: ConfirmAction::Quit,
});
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
KeyCode::Char('?') => {
self.modal = Some(Modal::Help);
}
KeyCode::Char(c) if c.is_ascii_digit() => {
if let Some(view) = ViewId::from_digit(c) {
self.view = view;
self.ledger_detail = false;
self.focus = FocusPane::Main;
self.scroll = 0;
}
}
KeyCode::Tab => self.cycle_focus(true),
KeyCode::BackTab => self.cycle_focus(false),
KeyCode::Char('r') if key.modifiers.is_empty() => {
self.refresh();
self.status = "refreshed".into();
}
other => self.handle_view_key(other, key.modifiers),
}
}
fn cycle_focus(&mut self, forward: bool) {
let order = match self.view {
ViewId::Queue => vec![FocusPane::Nav, FocusPane::Main, FocusPane::Log],
ViewId::Ledger if self.ledger_detail => {
vec![FocusPane::Nav, FocusPane::Main, FocusPane::Detail]
}
_ => vec![FocusPane::Nav, FocusPane::Main],
};
let idx = order.iter().position(|p| *p == self.focus).unwrap_or(0);
self.focus = if forward {
order[(idx + 1) % order.len()]
} else {
order[(idx + order.len() - 1) % order.len()]
};
}
fn handle_view_key(&mut self, code: KeyCode, _mods: KeyModifiers) {
match self.focus {
FocusPane::Nav => match code {
KeyCode::Up | KeyCode::Char('k') => self.view = self.view.prev(),
KeyCode::Down | KeyCode::Char('j') => self.view = self.view.next(),
KeyCode::Enter => self.focus = FocusPane::Main,
_ => {}
},
FocusPane::Log => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.scroll = self.scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll = self.scroll.saturating_add(1);
}
_ => {}
},
FocusPane::Detail => self.handle_detail_key(code),
FocusPane::Main => self.handle_main_key(code),
}
}
fn handle_main_key(&mut self, code: KeyCode) {
match self.view {
ViewId::Dashboard => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.scroll = self.scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll = self.scroll.saturating_add(1);
}
_ => {}
},
ViewId::Queue => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.queue_sel = self.queue_sel.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.snapshot.queue.is_empty() {
self.queue_sel = (self.queue_sel + 1).min(self.snapshot.queue.len() - 1);
}
}
KeyCode::Char('d') => self.start_drain(),
KeyCode::Char('x') => self.confirm_remove_queue(),
_ => {}
},
ViewId::Ledger => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.ledger_sel = self.ledger_sel.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.snapshot.ledger.is_empty() {
self.ledger_sel = (self.ledger_sel + 1).min(self.snapshot.ledger.len() - 1);
}
}
KeyCode::Enter => {
if !self.snapshot.ledger.is_empty() {
self.ledger_detail = true;
self.finding_sel = 0;
self.focus = FocusPane::Detail;
}
}
KeyCode::Char('w') => self.open_waiver(),
KeyCode::Char('p') => self.open_petition(),
_ => {}
},
ViewId::Config => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.config_sel = self.config_sel.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.snapshot.config_rows.is_empty() {
self.config_sel =
(self.config_sel + 1).min(self.snapshot.config_rows.len() - 1);
}
}
KeyCode::Enter | KeyCode::Char('e') => self.open_config_edit(),
KeyCode::Char('s') => {
self.status = "edit a value with Enter, then save from the diff preview".into();
}
_ => {}
},
ViewId::Debt => match code {
KeyCode::Up | KeyCode::Char('k') => {
self.debt_sel = self.debt_sel.saturating_sub(1);
self.debt_entry_sel = 0;
}
KeyCode::Down | KeyCode::Char('j') => {
if !self.snapshot.debt.is_empty() {
self.debt_sel = (self.debt_sel + 1).min(self.snapshot.debt.len() - 1);
self.debt_entry_sel = 0;
}
}
KeyCode::Left | KeyCode::Char('h') => {
self.debt_entry_sel = self.debt_entry_sel.saturating_sub(1);
}
KeyCode::Right | KeyCode::Char('l') => {
if let Some(group) = self.snapshot.debt.get(self.debt_sel)
&& !group.entries.is_empty()
{
self.debt_entry_sel =
(self.debt_entry_sel + 1).min(group.entries.len() - 1);
}
}
_ => {}
},
}
}
fn handle_detail_key(&mut self, code: KeyCode) {
let cards = self
.selected_ledger_entry()
.map(finding_cards)
.unwrap_or_default();
match code {
KeyCode::Esc | KeyCode::Backspace => {
self.ledger_detail = false;
self.focus = FocusPane::Main;
}
KeyCode::Up | KeyCode::Char('k') => {
self.finding_sel = self.finding_sel.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if !cards.is_empty() {
self.finding_sel = (self.finding_sel + 1).min(cards.len() - 1);
}
}
KeyCode::Char('w') => self.open_waiver(),
KeyCode::Char('p') => self.open_petition(),
_ => {}
}
}
fn selected_ledger_entry(&self) -> Option<&crate::ledger::LedgerEntry> {
let row = self.snapshot.ledger.get(self.ledger_sel)?;
self.snapshot
.ledger_entries
.iter()
.find(|e| e.commit_sha == row.sha)
}
fn selected_queue_row(&self) -> Option<&super::model::QueueRow> {
self.snapshot.queue.get(self.queue_sel)
}
fn confirm_remove_queue(&mut self) {
let Some(row) = self.selected_queue_row().cloned() else {
self.status = "queue is empty".into();
return;
};
self.modal = Some(Modal::Confirm {
title: "Remove queue item".into(),
body: format!(
"Remove {} ({}) from the review queue?",
row.short_sha, row.subject
),
action: ConfirmAction::RemoveQueue {
run_id: row.run_id,
sha: row.sha,
},
});
}
fn start_drain(&mut self) {
if self.drain_running {
self.status = "drain already running".into();
return;
}
let (tx, rx) = mpsc::channel();
match actions::spawn_drain_once(&self.state_dir, &self.config_path, tx) {
Ok(handle) => {
self.drain_running = true;
self.drain_rx = Some(rx);
self._drain_handle = Some(handle);
self.focus = FocusPane::Log;
self.status = "draining queue (watch --once)…".into();
}
Err(error) => {
self.status = format!("drain spawn failed: {error}");
}
}
}
fn open_waiver(&mut self) {
let Some(entry) = self.selected_ledger_entry() else {
self.status = "no ledger entry selected".into();
return;
};
if !entry.is_unresolved_rejection() && !entry.is_needs_human() {
self.status = "waive only applies to open rejections or needs-human".into();
return;
}
if !crate::resolve::stdin_is_tty() {
self.status = "waive refused: stdin is not a TTY (agent lane blocked)".into();
return;
}
let sha = entry.commit_sha.clone();
self.modal = Some(Modal::Multiline {
title: format!("Waive {sha}"),
hint: "Ctrl+S submit · Esc cancel · replace template placeholders".into(),
buffer: waiver_template(&sha),
kind: MultiKind::Waiver { sha },
});
}
fn open_petition(&mut self) {
let Some(entry) = self.selected_ledger_entry() else {
self.status = "no ledger entry selected".into();
return;
};
if !entry.is_unresolved_rejection() {
self.status = "petition only applies to open rejections".into();
return;
}
let sha = entry.commit_sha.clone();
self.modal = Some(Modal::TextPrompt {
title: format!("Petition {sha}"),
hint: "Enter fix commit SHA · Enter submit · Esc cancel".into(),
buffer: String::new(),
kind: TextKind::Petition { rejection_sha: sha },
});
}
fn open_config_edit(&mut self) {
let Some(row) = self.snapshot.config_rows.get(self.config_sel).cloned() else {
self.status = "no config row".into();
return;
};
if !row.editable {
self.status = "row is not editable".into();
return;
}
self.modal = Some(Modal::TextPrompt {
title: format!("Edit {}", row.path),
hint: "Enter confirm · Esc cancel · value validated before preview".into(),
buffer: row.value.clone(),
kind: TextKind::ConfigEdit { path: row.path },
});
}
fn handle_modal_key(&mut self, modal: Modal, key: KeyEvent) {
match modal {
Modal::Help => {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter
) {
self.modal = None;
}
}
Modal::Confirm { action, .. } => match key.code {
KeyCode::Esc | KeyCode::Char('n') => self.modal = None,
KeyCode::Char('y') | KeyCode::Enter => {
self.modal = None;
self.run_confirm(action);
}
_ => {}
},
Modal::TextPrompt {
title,
hint,
mut buffer,
kind,
} => match key.code {
KeyCode::Esc => self.modal = None,
KeyCode::Enter => {
self.modal = None;
self.submit_text(kind, buffer);
}
KeyCode::Backspace => {
buffer.pop();
self.modal = Some(Modal::TextPrompt {
title,
hint,
buffer,
kind,
});
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
buffer.push(c);
self.modal = Some(Modal::TextPrompt {
title,
hint,
buffer,
kind,
});
}
_ => {
self.modal = Some(Modal::TextPrompt {
title,
hint,
buffer,
kind,
});
}
},
Modal::Multiline {
title,
hint,
mut buffer,
kind,
} => {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('s') | KeyCode::Char('S'))
{
self.modal = None;
self.submit_multi(kind, buffer);
return;
}
match key.code {
KeyCode::Esc => self.modal = None,
KeyCode::Enter => {
buffer.push('\n');
self.modal = Some(Modal::Multiline {
title,
hint,
buffer,
kind,
});
}
KeyCode::Backspace => {
buffer.pop();
self.modal = Some(Modal::Multiline {
title,
hint,
buffer,
kind,
});
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
buffer.push(c);
self.modal = Some(Modal::Multiline {
title,
hint,
buffer,
kind,
});
}
_ => {
self.modal = Some(Modal::Multiline {
title,
hint,
buffer,
kind,
});
}
}
}
Modal::DiffPreview {
title,
diff,
path,
value,
} => match key.code {
KeyCode::Esc | KeyCode::Char('n') => self.modal = None,
KeyCode::Char('s') | KeyCode::Char('y') | KeyCode::Enter => {
self.modal = None;
match actions::save_config_edit(
&self.config_path,
&self.snapshot,
&path,
&value,
) {
Ok(msg) => {
self.status = msg;
self.refresh();
}
Err(error) => self.status = format!("save failed: {error}"),
}
}
_ => {
self.modal = Some(Modal::DiffPreview {
title,
diff,
path,
value,
});
}
},
}
}
fn run_confirm(&mut self, action: ConfirmAction) {
match action {
ConfirmAction::Quit => self.should_quit = true,
ConfirmAction::RemoveQueue { run_id, sha } => {
match actions::remove_queue_item(&self.state_dir, &run_id, &sha) {
Ok(()) => {
self.status = format!("removed queue item {sha}");
self.refresh();
}
Err(error) => self.status = format!("remove failed: {error}"),
}
}
}
}
fn submit_text(&mut self, kind: TextKind, buffer: String) {
match kind {
TextKind::Petition { rejection_sha } => match validate_fix_sha(&buffer) {
Ok(fix_sha) => {
match actions::petition_rejection(&self.state_dir, &rejection_sha, &fix_sha) {
Ok(msg) => {
self.status = msg;
self.refresh();
}
Err(error) => self.status = format!("petition failed: {error}"),
}
}
Err(error) => self.status = error,
},
TextKind::ConfigEdit { path } => {
match actions::validate_and_normalize_edit(&path, &buffer) {
Ok(value) => {
match actions::preview_config_edit(&self.snapshot, &path, &value) {
Ok(diff) => {
self.modal = Some(Modal::DiffPreview {
title: format!("Save {path}?"),
diff,
path,
value,
});
}
Err(error) => self.status = format!("preview failed: {error}"),
}
}
Err(error) => self.status = format!("invalid value: {error}"),
}
}
}
}
fn submit_multi(&mut self, kind: MultiKind, buffer: String) {
match kind {
MultiKind::Waiver { sha } => {
if let Err(error) = actions::validate_waiver_reason(&buffer) {
self.status = error.to_string();
return;
}
match actions::waive_rejection(&self.state_dir, &sha, &buffer) {
Ok(msg) => {
self.status = msg;
self.ledger_detail = false;
self.focus = FocusPane::Main;
self.refresh();
}
Err(error) => self.status = format!("waive failed: {error}"),
}
}
}
}
}
pub fn run(args: TuiArgs, state_dir: &Path, config_path: Option<&Path>) -> Result<ExitCode> {
use std::io::IsTerminal;
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
anyhow::bail!(
"truth-mirror tui requires an interactive terminal (stdin and stdout must be TTYs)"
);
}
let config_path = actions::resolve_config_path(config_path, state_dir);
let version = env!("CARGO_PKG_VERSION").to_owned();
let mut app = App::new(
state_dir.to_path_buf(),
config_path,
version,
!args.no_auto_refresh,
)?;
enable_raw_mode()?;
let _terminal_guard = TerminalGuard;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = run_loop(&mut terminal, &mut app);
result?;
Ok(ExitCode::SUCCESS)
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, cursor::Show);
}
}
fn run_loop(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> Result<()> {
loop {
app.tick_drain();
if app.auto_refresh && app.last_refresh.elapsed() >= REFRESH_INTERVAL && !app.drain_running
{
app.refresh();
}
terminal.draw(|frame| ui::draw(frame, app))?;
if app.should_quit {
break;
}
if event::poll(POLL)?
&& let Event::Key(key) = event::read()?
{
app.on_key(key);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_app() -> (tempfile::TempDir, App) {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("config.toml");
let app = App::new(
temp.path().to_path_buf(),
config_path,
"0.0.0-test".to_owned(),
false,
)
.unwrap();
(temp, app)
}
#[test]
fn tick_drain_treats_a_disconnected_sender_as_done_not_a_cpu_spin() {
let (_temp, mut app) = test_app();
let (sender, receiver) = mpsc::channel::<DrainEvent>();
drop(sender);
app.drain_rx = Some(receiver);
app.drain_running = true;
app.tick_drain();
assert!(
app.drain_rx.is_none(),
"a disconnected receiver must not be restored for endless re-polling"
);
assert!(
!app.drain_running,
"a disconnected drain must not be reported as still running"
);
assert!(
app.status.contains("disconnected"),
"the status must distinguish a worker disconnect from a clean finish: {:?}",
app.status
);
}
#[test]
fn tick_drain_reports_success_when_done_is_immediately_followed_by_disconnect() {
let (_temp, mut app) = test_app();
let (sender, receiver) = mpsc::channel::<DrainEvent>();
sender.send(DrainEvent::Done(0)).unwrap();
drop(sender);
app.drain_rx = Some(receiver);
app.drain_running = true;
app.tick_drain();
assert!(
!app.drain_running,
"a successful drain must not still be reported as running"
);
assert!(
app.status.contains("drain finished"),
"a successful drain must be reported as finished, not as an \
unexpected disconnect: {:?}",
app.status
);
assert!(
!app.status.contains("disconnected"),
"a successful drain must never be misreported as disconnected: {:?}",
app.status
);
}
#[test]
fn tick_drain_keeps_polling_while_the_channel_is_merely_empty() {
let (_temp, mut app) = test_app();
let (_sender, receiver) = mpsc::channel::<DrainEvent>();
app.drain_rx = Some(receiver);
app.drain_running = true;
app.tick_drain();
assert!(
app.drain_rx.is_some(),
"an empty (but connected) channel must be restored, not discarded"
);
assert!(app.drain_running);
}
#[test]
fn terminal_guard_drop_is_safe_without_a_real_terminal() {
let guard = TerminalGuard;
drop(guard);
}
}