From 7399cf8fc3fa6a0f06a6d68263586eecec654799 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Jul 2026 02:20:15 +0200 Subject: [PATCH] [Imp] UI & UX --- iota-cli/src/controls/dialog.rs | 271 ++++++++++++++++++++ iota-cli/src/controls/header.rs | 130 +++++++--- iota-cli/src/controls/mod.rs | 1 + iota-cli/src/help_overlay.rs | 219 +++++++++++++++++ iota-cli/src/lib.rs | 2 + iota-cli/src/notification.rs | 128 ++++++++++ iota-cli/src/screens/main_screen.rs | 58 ----- iota-cli/src/screens/screens.rs | 5 +- iota-cli/src/screens/settings.rs | 123 +++++++--- iota-cli/src/screens/users.rs | 11 +- iota-cli/src/theme/config.rs | 140 ++++++++++- iota-cli/src/theme/mod.rs | 2 +- iota-cli/src/ui.rs | 134 +++++++--- iota-cli/tests/settings_snapshot.rs | 4 +- iota/src/cli_args.rs | 64 ++++- iota/src/cli_color.rs | 105 ++++++++ iota/src/main.rs | 367 ++++++++++++++++++++++++++-- iota/src/startup_error.rs | 64 +++++ 18 files changed, 1635 insertions(+), 193 deletions(-) create mode 100644 iota-cli/src/controls/dialog.rs create mode 100644 iota-cli/src/help_overlay.rs create mode 100644 iota-cli/src/notification.rs create mode 100644 iota/src/cli_color.rs diff --git a/iota-cli/src/controls/dialog.rs b/iota-cli/src/controls/dialog.rs new file mode 100644 index 0000000..eb750ef --- /dev/null +++ b/iota-cli/src/controls/dialog.rs @@ -0,0 +1,271 @@ +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::{ + controls::button::{ActionButton, ButtonIntent, render_button}, + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{HitMap, KeyHint, Screen, UiEvent}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogButton { + Cancel, + Confirm, + Custom(usize), +} + +pub struct ConfirmDialog { + title: String, + message: Vec, + buttons: Vec, + focused_button: usize, + on_confirm: Option InteractionResult + Send + Sync>>, + on_cancel: Option InteractionResult + Send + Sync>>, +} + +struct DialogButtonConfig { + label: String, + intent: ButtonIntent, + enabled: bool, +} + +impl ConfirmDialog { + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: vec![message.into()], + buttons: vec![ + DialogButtonConfig { + label: "Cancel".to_owned(), + intent: ButtonIntent::Cancel, + enabled: true, + }, + DialogButtonConfig { + label: "Confirm".to_owned(), + intent: ButtonIntent::Primary, + enabled: true, + }, + ], + focused_button: 0, + on_confirm: None, + on_cancel: None, + } + } + + pub fn destructive(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: vec![message.into()], + buttons: vec![ + DialogButtonConfig { + label: "Cancel".to_owned(), + intent: ButtonIntent::Cancel, + enabled: true, + }, + DialogButtonConfig { + label: "Delete".to_owned(), + intent: ButtonIntent::Destructive, + enabled: true, + }, + ], + focused_button: 0, + on_confirm: None, + on_cancel: None, + } + } + + pub fn with_message_line(mut self, line: impl Into) -> Self { + self.message.push(line.into()); + self + } + + pub fn with_button(mut self, label: impl Into, intent: ButtonIntent) -> Self { + self.buttons.push(DialogButtonConfig { + label: label.into(), + intent, + enabled: true, + }); + self + } + + pub fn with_confirm_action InteractionResult + Send + Sync + 'static>( + mut self, + action: F, + ) -> Self { + self.on_confirm = Some(Box::new(action)); + self + } + + pub fn with_cancel_action InteractionResult + Send + Sync + 'static>( + mut self, + action: F, + ) -> Self { + self.on_cancel = Some(Box::new(action)); + self + } + + fn activate(&self) -> InteractionResult { + match self.focused_button { + 0 => { + if let Some(action) = &self.on_cancel { + action() + } else { + InteractionResult::CloseScreen + } + } + 1 => { + if let Some(action) = &self.on_confirm { + action() + } else { + InteractionResult::CloseScreen + } + } + _ => InteractionResult::CloseScreen, + } + } + + fn next_button(&mut self) { + self.focused_button = (self.focused_button + 1) % self.buttons.len(); + } + + fn prev_button(&mut self) { + if self.focused_button == 0 { + self.focused_button = self.buttons.len() - 1; + } else { + self.focused_button -= 1; + } + } +} + +impl Screen for ConfirmDialog { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn std::any::Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + let area = crate::layout::fit::centered_rect( + rect, + crate::layout::fit::RequiredSize { + width: 50, + height: (self.message.len() + 8) as u16, + }, + ); + + f.render_widget(Clear, area); + let block = Block::default() + .title(format!(" {} ", self.title)) + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + + let inner = block.inner(area); + f.render_widget(block, area); + + let rows = Layout::vertical([ + Constraint::Min(self.message.len() as u16), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(inner); + + let lines: Vec = self + .message + .iter() + .map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal))) + .collect(); + f.render_widget(Paragraph::new(lines), rows[0]); + + let buttons_area = rows[2]; + let button_widths: Vec = self + .buttons + .iter() + .map(|b| { + crate::controls::button::button_minimum_width(&b.label) + }) + .collect(); + + let total_width: u16 = button_widths.iter().sum(); + let spacing = self.buttons.len().saturating_sub(1) as u16; + let available = buttons_area.width; + let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2; + + let mut x = start_x; + for (i, (button_config, &width)) in + self.buttons.iter().zip(&button_widths).enumerate() + { + let button_area = Rect { + x, + y: buttons_area.y, + width, + height: 1, + }; + x = x.saturating_add(width + 1); + + render_button( + f, + button_area, + ActionButton { + label: &button_config.label, + intent: button_config.intent, + focused: self.focused_button == i, + enabled: button_config.enabled, + }, + context.theme, + ); + } + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Esc => InteractionResult::CloseScreen, + KeyCode::Tab => { + self.next_button(); + InteractionResult::Handled + } + KeyCode::BackTab => { + self.prev_button(); + InteractionResult::Handled + } + KeyCode::Left => { + self.prev_button(); + InteractionResult::Handled + } + KeyCode::Right => { + self.next_button(); + InteractionResult::Handled + } + KeyCode::Enter | KeyCode::Char(' ') => self.activate(), + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + vec![ + KeyHint { + keys: "Tab", + action: "Switch button", + }, + KeyHint { + keys: "Enter", + action: "Confirm", + }, + KeyHint { + keys: "Esc", + action: "Cancel", + }, + ] + } +} diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs index 71af229..09a589f 100644 --- a/iota-cli/src/controls/header.rs +++ b/iota-cli/src/controls/header.rs @@ -1,25 +1,58 @@ +use crate::ipc_client::{DaemonStatus, IpcConnectionState}; use crate::theme::ResolvedTheme; use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, + controls::button::ButtonIntent, screens::screens::{AppAction, HitMap}, }; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, - text::Span, + text::{Line, Span}, widgets::Paragraph, }; -/// Shared application bar. The brand cell is deliberately an action so it is -/// a reliable way home from every screen. +fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) { + match state { + IpcConnectionState::Connected => ("OK", theme.status.success), + IpcConnectionState::Connecting => ("..", theme.status.warning), + IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning), + IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error), + IpcConnectionState::Disconnected => ("WARN", theme.status.warning), + } +} + +fn omikron_badge(daemon: &DaemonStatus, theme: &ResolvedTheme) -> (String, ratatui::style::Style) { + use iota_ipc::ComponentId; + let health = daemon.components.get(&ComponentId::Omikron); + let (label, style) = match health.map(|h| h.status) { + Some(iota_ipc::HealthStatus::Healthy) => ("OK", theme.status.success), + Some(iota_ipc::HealthStatus::Degraded) => ("WARN", theme.status.warning), + Some(iota_ipc::HealthStatus::Failed) => ("FAIL", theme.status.error), + None => ("--", theme.text.muted), + }; + let detail = health + .and_then(|h| h.message.as_deref()) + .map(|m| format!(" {m}")) + .unwrap_or_default(); + (format!("{label}{detail}"), style) +} + pub fn render_header( frame: &mut Frame, area: Rect, - title: &str, + connection: &IpcConnectionState, + daemon: &DaemonStatus, theme: &ResolvedTheme, hits: &mut HitMap, focused_action: Option, ) { + let version = if daemon.version.is_empty() { + String::new() + } else { + format!(" v{}", daemon.version) + }; + + let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); let cells = Layout::horizontal([ Constraint::Min(28), Constraint::Length(12), @@ -27,36 +60,75 @@ pub fn render_header( Constraint::Length(12), Constraint::Length(8), ]) - .split(area); + .split(rows[0]); + let cells2 = Layout::horizontal([ + Constraint::Min(28), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Length(8), + ]) + .split(rows[1]); + + let (ipc_label, ipc_style) = connection_badge(connection, theme); + let (omikron_text, omikron_style) = omikron_badge(daemon, theme); + + let brand_line1 = Line::from(vec![ + Span::styled(format!(" IOTA{version}"), theme.surfaces.toolbar), + Span::styled(format!(" IPC:[{ipc_label}]"), ipc_style), + ]); + let brand_line2 = Line::from(vec![ + Span::styled(" Omikron: ", theme.surfaces.toolbar), + Span::styled(format!("[{omikron_text}]"), omikron_style), + ]); + let brand_area = Rect { + x: area.x, + y: area.y, + width: cells[0].width, + height: area.height, + }; frame.render_widget( - Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)), - cells[0], + Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar), + brand_area, ); - hits.register(cells[0], AppAction::OpenMain); - for (index, (area, label, action)) in [ - (cells[1], "Overview", AppAction::OpenOverview), - (cells[2], "Users", AppAction::OpenUsers), - (cells[3], "Settings", AppAction::OpenSettings), - (cells[4], "Quit", AppAction::Quit), + hits.register(brand_area, AppAction::OpenMain); + + for (index, (top, _bottom, label, intent, action)) in [ + (cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview), + (cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers), + (cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings), + (cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit), ] .into_iter() .enumerate() { - render_button( - frame, - area, - ActionButton { - label, - intent: if action == AppAction::Quit { - ButtonIntent::Destructive - } else { - ButtonIntent::Neutral - }, - focused: focused_action == Some(index), - enabled: true, - }, - theme, + let button_area = Rect { + x: top.x, + y: top.y, + width: top.width, + height: area.height, + }; + let style = match (intent, focused_action == Some(index)) { + (ButtonIntent::Primary, true) => theme.buttons.primary_focused, + (ButtonIntent::Primary, false) => theme.buttons.primary, + (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused, + (ButtonIntent::Neutral, false) => theme.buttons.neutral, + (ButtonIntent::Cancel, true) => theme.buttons.cancel_focused, + (ButtonIntent::Cancel, false) => theme.buttons.cancel, + (ButtonIntent::Destructive, _) => theme.buttons.destructive, + }; + let display_label = if focused_action == Some(index) { + format!("› {label}") + } else { + label.to_owned() + }; + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled(display_label, style)), + Line::from(""), + ]), + button_area, ); - hits.register(area, action); + hits.register(button_area, action); } } diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index fae2452..02eaa84 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -2,6 +2,7 @@ pub mod action; pub mod button; pub mod checkbox_group; pub mod choice; +pub mod dialog; pub mod header; pub mod navigation; pub mod panel; diff --git a/iota-cli/src/help_overlay.rs b/iota-cli/src/help_overlay.rs new file mode 100644 index 0000000..007c223 --- /dev/null +++ b/iota-cli/src/help_overlay.rs @@ -0,0 +1,219 @@ +use crossterm::event::KeyCode; +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::{ + interaction_result::InteractionResult, + render_context::RenderContext, + screens::screens::{HitMap, Screen, UiEvent}, + theme::ResolvedTheme, +}; + +pub struct HelpOverlay { + scroll: usize, +} + +impl HelpOverlay { + pub fn new() -> Self { + Self { scroll: 0 } + } + + fn build_lines(&self, theme: &ResolvedTheme) -> Vec> { + vec![ + Line::from(""), + Line::from(Span::styled( + "Global Keyboard Shortcuts", + theme.text.heading, + )), + Line::from(""), + Line::from(vec![ + Span::styled(" F6", theme.text.link), + Span::styled(" Toggle header navigation", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Tab", theme.text.link), + Span::styled(" Move focus to next panel", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Shift+Tab", theme.text.link), + Span::styled(" Move focus to previous panel", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Esc", theme.text.link), + Span::styled(" Go back / Close dialog", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Ctrl+C", theme.text.link), + Span::styled(" Quit the application", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("Dashboard Navigation", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" o/O", theme.text.link), + Span::styled(" Open Overview screen", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" u/U", theme.text.link), + Span::styled(" Open Users screen", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" m/M", theme.text.link), + Span::styled(" Open Metrics screen", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("Log Panel", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" j/Down", theme.text.link), + Span::styled(" Scroll down", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" k/Up", theme.text.link), + Span::styled(" Scroll up", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Enter", theme.text.link), + Span::styled(" Lock/unlock scroll", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" /", theme.text.link), + Span::styled(" Filter logs", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("Console Panel", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" Enter", theme.text.link), + Span::styled(" Send command", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Up/Down", theme.text.link), + Span::styled(" Command history", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Tab", theme.text.link), + Span::styled(" Auto-complete", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" /help", theme.text.link), + Span::styled(" List available commands", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled("List Navigation", theme.text.heading)), + Line::from(""), + Line::from(vec![ + Span::styled(" j/Down", theme.text.link), + Span::styled(" Next item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" k/Up", theme.text.link), + Span::styled(" Previous item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" PgUp/PgDn", theme.text.link), + Span::styled(" Page up/down", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" Home", theme.text.link), + Span::styled(" First item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" End", theme.text.link), + Span::styled(" Last item", theme.text.normal), + ]), + Line::from(vec![ + Span::styled(" /", theme.text.link), + Span::styled(" Filter list", theme.text.normal), + ]), + Line::from(""), + Line::from(Span::styled( + "Press ? or Esc to close this overlay", + theme.text.muted, + )), + ] + } +} + +impl Screen for HelpOverlay { + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + let area = crate::layout::fit::centered_rect( + rect, + crate::layout::fit::RequiredSize { + width: 52, + height: 40, + }, + ); + + f.render_widget(Clear, area); + let block = Block::default() + .title(" Keyboard Shortcuts (?) ") + .borders(Borders::ALL) + .border_style(context.theme.borders.focused) + .style(context.theme.surfaces.overlay); + + let inner = block.inner(area); + f.render_widget(block, area); + + let lines = self.build_lines(context.theme); + let paragraph = Paragraph::new(lines) + .scroll((self.scroll as u16, 0)) + .style(context.theme.text.normal); + f.render_widget(paragraph, inner); + } + + fn handle_event(&mut self, event: UiEvent) -> InteractionResult { + let UiEvent::Key(key) = event else { + return InteractionResult::Unhandled; + }; + match key.code { + KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => { + InteractionResult::CloseScreen + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll = self.scroll.saturating_add(1); + InteractionResult::Handled + } + KeyCode::Up | KeyCode::Char('k') => { + self.scroll = self.scroll.saturating_sub(1); + InteractionResult::Handled + } + KeyCode::PageDown => { + self.scroll = self.scroll.saturating_add(10); + InteractionResult::Handled + } + KeyCode::PageUp => { + self.scroll = self.scroll.saturating_sub(10); + InteractionResult::Handled + } + _ => InteractionResult::Unhandled, + } + } + + fn key_hints(&self) -> Vec { + vec![ + crate::screens::screens::KeyHint { + keys: "Up/Down", + action: "Scroll", + }, + crate::screens::screens::KeyHint { + keys: "Esc/?", + action: "Close", + }, + ] + } +} + +use std::any::Any; diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index eee1487..bd2e44d 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -23,10 +23,12 @@ pub mod util { } pub mod app_state; pub mod controls; +pub mod help_overlay; pub mod input_handler; pub mod interaction_result; pub mod ipc_client; pub mod layout; +pub mod notification; pub mod render_context; pub mod theme; pub mod ui; diff --git a/iota-cli/src/notification.rs b/iota-cli/src/notification.rs new file mode 100644 index 0000000..0fd3cb7 --- /dev/null +++ b/iota-cli/src/notification.rs @@ -0,0 +1,128 @@ +use std::time::{Duration, Instant}; + +use ratatui::{ + Frame, + layout::Rect, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::theme::ResolvedTheme; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationKind { + Success, + Warning, + Error, + Info, +} + +#[derive(Clone)] +pub struct Notification { + pub message: String, + pub kind: NotificationKind, + pub created_at: Instant, + pub duration: Duration, +} + +impl Notification { + pub fn success(message: impl Into) -> Self { + Self::new(message, NotificationKind::Success, Duration::from_secs(3)) + } + + pub fn warning(message: impl Into) -> Self { + Self::new(message, NotificationKind::Warning, Duration::from_secs(4)) + } + + pub fn error(message: impl Into) -> Self { + Self::new(message, NotificationKind::Error, Duration::from_secs(5)) + } + + pub fn info(message: impl Into) -> Self { + Self::new(message, NotificationKind::Info, Duration::from_secs(3)) + } + + fn new(message: impl Into, kind: NotificationKind, duration: Duration) -> Self { + Self { + message: message.into(), + kind, + created_at: Instant::now(), + duration, + } + } + + pub fn is_expired(&self) -> bool { + self.created_at.elapsed() >= self.duration + } + + pub fn remaining(&self) -> Duration { + self.duration.saturating_sub(self.created_at.elapsed()) + } + + pub fn progress(&self) -> f64 { + let elapsed = self.created_at.elapsed().as_secs_f64(); + let total = self.duration.as_secs_f64(); + (elapsed / total).min(1.0) + } +} + +pub fn render_notification( + frame: &mut Frame, + area: Rect, + notification: &Notification, + theme: &ResolvedTheme, +) { + let (prefix, style) = match notification.kind { + NotificationKind::Success => ("✓ ", theme.status.success), + NotificationKind::Warning => ("⚠ ", theme.status.warning), + NotificationKind::Error => ("✗ ", theme.status.error), + NotificationKind::Info => ("ℹ ", theme.status.info), + }; + + let remaining = notification.remaining().as_secs(); + let progress = notification.progress(); + + let mut spans = vec![ + Span::styled(prefix, style), + Span::styled(¬ification.message, theme.text.normal), + ]; + + if remaining > 0 { + let bar_width = 10; + let filled = ((1.0 - progress) * bar_width as f64) as usize; + let empty = bar_width - filled; + let bar: String = "█".repeat(filled) + &"░".repeat(empty); + spans.push(Span::styled( + format!(" [{bar}] {remaining}s"), + theme.text.muted, + )); + } + + let paragraph = Paragraph::new(Line::from(spans)); + frame.render_widget(paragraph, area); +} + +pub fn render_notification_area( + frame: &mut Frame, + area: Rect, + notifications: &[Notification], + theme: &ResolvedTheme, +) { + if notifications.is_empty() { + return; + } + + let visible_height = area.height as usize; + let start = notifications.len().saturating_sub(visible_height); + let visible = ¬ifications[start..]; + + for (i, notification) in visible.iter().enumerate() { + let row = Rect { + x: area.x, + y: area.y + i as u16, + width: area.width, + height: 1, + }; + render_notification(frame, row, notification, theme); + } +} diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 40abb83..53e68b3 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -49,28 +49,6 @@ impl MainScreen { pub fn daemon_status(&self) -> watch::Receiver { self.daemon_status_rx.clone() } - fn status_summary(&self) -> String { - let connection = match self.connection_status_rx.borrow().clone() { - IpcConnectionState::Connected => "[OK] Connected".to_owned(), - IpcConnectionState::Connecting => "[..] Connecting".to_owned(), - IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(), - IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { - "[FAIL] Failed".to_owned() - } - IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(), - }; - let daemon = self.daemon_status_rx.borrow().clone(); - let version = if daemon.version.is_empty() { - String::new() - } else { - format!(" v{}", daemon.version) - }; - let ready = daemon - .startup_phase - .map(|phase| format!(" {:?}", phase)) - .unwrap_or_default(); - format!("IOTA{version} {connection}{ready}") - } pub async fn new(ui: Arc) -> Self { let mut elements: Vec> = Vec::new(); @@ -254,39 +232,6 @@ impl Screen for MainScreen { fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { self.layout_width.store(rect.width, Ordering::Relaxed); - // A watch Ref blocks senders until it is dropped. Rendering may do - // terminal I/O, so retain only owned snapshots for the whole frame. - let status = self.connection_status_rx.borrow().clone(); - let daemon = self.daemon_status_rx.borrow().clone(); - let status_text = match status { - IpcConnectionState::Connected => "Connected".to_string(), - IpcConnectionState::Connecting => "Connecting...".to_string(), - IpcConnectionState::Reconnecting { attempt } => { - format!("Reconnecting (attempt {})...", attempt) - } - IpcConnectionState::Incompatible { message } => { - format!("Incompatible protocol: {}", message) - } - IpcConnectionState::Failed { message } => { - format!("Connection failed: {}", message) - } - IpcConnectionState::Disconnected => "Disconnected".to_string(), - }; - let readiness = daemon - .startup_phase - .map(|phase| format!("{:?}", phase)) - .unwrap_or_else(|| "Waiting for status".into()); - let health = daemon - .degraded_reason - .as_deref() - .map(|reason| format!(" — {reason}")) - .unwrap_or_default(); - let version = if daemon.version.is_empty() { - String::new() - } else { - format!(" v{}", daemon.version) - }; - let _ = (status_text, readiness, health, version); f.render_widget( ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), rect, @@ -503,9 +448,6 @@ impl Screen for MainScreen { _ => InteractionResult::Unhandled, } } - fn app_title(&self) -> String { - self.status_summary() - } fn key_hints(&self) -> Vec { if self.selected_coords == (2, 0) { vec![ diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index f2e61f5..5299b3b 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -30,6 +30,8 @@ pub enum AppEvent { theme: crate::theme::ThemeName, color: crate::theme::TerminalPolicy, unicode: crate::theme::TerminalPolicy, + cli_output: crate::theme::CliOutputFormat, + cli_require_confirmation: bool, }, ThemeSaved(Result<(), String>), UsersLoaded(Result, String>), @@ -118,9 +120,6 @@ pub trait Screen: Send + Sync + Any { fn handle_action(&mut self, _action: AppAction) -> InteractionResult { InteractionResult::Unhandled } - fn app_title(&self) -> String { - "IOTA".to_owned() - } fn key_hints(&self) -> Vec { vec![ KeyHint { diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs index dd26878..56293c5 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -13,12 +13,14 @@ use crate::{ interaction_result::InteractionResult, render_context::RenderContext, screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, - theme::{TerminalPolicy, ThemeName, UiConfig}, + theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig}, }; #[derive(Clone, Copy, PartialEq, Eq)] enum Focus { Theme, + CliOutput, + CliConfirm, RegenerateKeys, Back, } @@ -34,6 +36,8 @@ pub struct SettingsScreen { message: String, color: TerminalPolicy, unicode: TerminalPolicy, + cli_output: CliOutputFormat, + cli_require_confirmation: bool, focus: Focus, dialog: Option, pending: bool, @@ -45,16 +49,15 @@ impl SettingsScreen { .iter() .position(|theme| *theme == current) .unwrap_or(0); + let config = UiConfig::load_or_default(); Self { selected, saved: current, message: "Left/Right previews. Enter saves.".into(), - color: UiConfig::load() - .map(|config| config.color) - .unwrap_or_default(), - unicode: UiConfig::load() - .map(|config| config.unicode) - .unwrap_or_default(), + color: config.color, + unicode: config.unicode, + cli_output: config.cli_output, + cli_require_confirmation: config.cli_require_confirmation, focus: Focus::Theme, dialog: None, pending: false, @@ -82,7 +85,9 @@ impl SettingsScreen { fn next_focus(&mut self) { self.focus = match self.focus { - Focus::Theme => Focus::RegenerateKeys, + Focus::Theme => Focus::CliOutput, + Focus::CliOutput => Focus::CliConfirm, + Focus::CliConfirm => Focus::RegenerateKeys, Focus::RegenerateKeys => Focus::Back, Focus::Back => Focus::Theme, }; @@ -92,7 +97,9 @@ impl SettingsScreen { self.focus = match self.focus { Focus::Theme => Focus::Back, Focus::Back => Focus::RegenerateKeys, - Focus::RegenerateKeys => Focus::Theme, + Focus::RegenerateKeys => Focus::CliConfirm, + Focus::CliConfirm => Focus::CliOutput, + Focus::CliOutput => Focus::Theme, }; } @@ -114,24 +121,44 @@ impl SettingsScreen { match self.focus { Focus::Theme => { self.message = "Saving theme…".into(); - let theme = self.selected_theme(); - let color = self.color; - let unicode = self.unicode; - InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - }) - }), - } + } + Focus::CliOutput => { + self.message = "Output format updated.".into(); + return InteractionResult::Handled; + } + Focus::CliConfirm => { + self.cli_require_confirmation = !self.cli_require_confirmation; + self.message = format!( + "Confirm: {}", + if self.cli_require_confirmation { + "On" + } else { + "Off" + }, + ); + return InteractionResult::Handled; } Focus::RegenerateKeys => { self.dialog = Some(Dialog::ConfirmRegenerateKeys); - InteractionResult::Handled + return InteractionResult::Handled; } - Focus::Back => InteractionResult::CloseScreen, + Focus::Back => return InteractionResult::CloseScreen, + } + let theme = self.selected_theme(); + let color = self.color; + let unicode = self.unicode; + let cli_output = self.cli_output; + let cli_require_confirmation = self.cli_require_confirmation; + InteractionResult::AppTask { + task: Box::pin(async move { + UiEvent::App(AppEvent::SaveSettings { + theme, + color, + unicode, + cli_output, + cli_require_confirmation, + }) + }), } } } @@ -152,13 +179,16 @@ impl Screen for SettingsScreen { context: &RenderContext<'_>, hits: &mut HitMap, ) { - let block = Block::default() + let header_block = Block::default() .title(" Settings ") .borders(Borders::ALL) .border_style(context.theme.borders.focused); - let inner = block.inner(area); - frame.render_widget(block, area); - let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); + let inner = header_block.inner(area); + frame.render_widget(header_block, area); + + let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]) + .split(inner); + frame.render_widget( Paragraph::new(format!( "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", @@ -169,18 +199,28 @@ impl Screen for SettingsScreen { " [preview]" }, self.color, - self.unicode + self.unicode, )) .style(context.theme.text.heading), - rows[0], + sections[0], + ); + + let cli_line = format!( + "CLI output: {:?} (L) Confirm: {} (K)", + self.cli_output, + if self.cli_require_confirmation { + "required" + } else { + "disabled" + }, ); let bottom_rows = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]); + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]); let lines = vec![ Line::from(Span::styled(&self.message, context.theme.text.normal)), - Line::from(""), + Line::from(Span::styled(&cli_line, context.theme.text.normal)), Line::from("Preview"), Line::from("[OK] Healthy"), Line::from("[WARN] Degraded"), @@ -352,6 +392,23 @@ impl Screen for SettingsScreen { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled } + KeyCode::Char('l') | KeyCode::Char('L') => { + self.cli_output = self.cli_output.next(); + self.message = format!("CLI output: {:?}", self.cli_output); + InteractionResult::Handled + } + KeyCode::Char('k') | KeyCode::Char('K') => { + self.cli_require_confirmation = !self.cli_require_confirmation; + self.message = format!( + "CLI confirm: {}", + if self.cli_require_confirmation { + "On" + } else { + "Off" + }, + ); + InteractionResult::Handled + } KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { InteractionResult::CloseScreen } @@ -405,6 +462,10 @@ impl Screen for SettingsScreen { keys: "C/U", action: "Color/Unicode", }, + KeyHint { + keys: "L/K", + action: "CLI Out/Confirm", + }, KeyHint { keys: "Esc/B", action: "Back", diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs index ac271a8..fab710c 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -19,7 +19,7 @@ use std::{ any::Any, sync::{ Arc, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicU8, AtomicUsize, Ordering}, }, }; @@ -56,6 +56,7 @@ pub struct UsersScreen { viewport_height: AtomicUsize, filter: String, filtering: bool, + tick: AtomicU8, } impl UsersScreen { @@ -74,6 +75,7 @@ impl UsersScreen { viewport_height: AtomicUsize::new(1), filter: String::new(), filtering: false, + tick: AtomicU8::new(0), } } @@ -115,7 +117,12 @@ impl UsersScreen { }; if self.loading { - f.render_widget(Paragraph::new("Loading users…"), inner); + const SPINNERS: &[u8] = b"|/-\\"; + let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()]; + f.render_widget( + Paragraph::new(format!("{ch} Loading users…")), + inner, + ); return; } if visible_indices.is_empty() { diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs index 79d1990..26d6046 100644 --- a/iota-cli/src/theme/config.rs +++ b/iota-cli/src/theme/config.rs @@ -17,6 +17,55 @@ pub struct UiConfig { pub color: TerminalPolicy, #[serde(default)] pub unicode: TerminalPolicy, + /// Default CLI output format for headless commands. + #[serde(default)] + pub cli_output: CliOutputFormat, + /// Whether destructive CLI operations require --yes by default. + #[serde(default = "default_false")] + pub cli_require_confirmation: bool, +} + +fn default_false() -> bool { + false +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum CliOutputFormat { + #[default] + Text, + Json, + Yaml, + Table, +} + +impl CliOutputFormat { + pub fn all() -> &'static [CliOutputFormat] { + &[ + CliOutputFormat::Text, + CliOutputFormat::Json, + CliOutputFormat::Yaml, + CliOutputFormat::Table, + ] + } + + pub fn name(&self) -> &'static str { + match self { + CliOutputFormat::Text => "text", + CliOutputFormat::Json => "json", + CliOutputFormat::Yaml => "yaml", + CliOutputFormat::Table => "table", + } + } + + pub fn next(&self) -> Self { + match self { + CliOutputFormat::Text => CliOutputFormat::Json, + CliOutputFormat::Json => CliOutputFormat::Yaml, + CliOutputFormat::Yaml => CliOutputFormat::Table, + CliOutputFormat::Table => CliOutputFormat::Text, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -28,6 +77,16 @@ pub enum TerminalPolicy { Never, } +impl TerminalPolicy { + pub fn next(&self) -> Self { + match self { + TerminalPolicy::Auto => TerminalPolicy::Always, + TerminalPolicy::Always => TerminalPolicy::Never, + TerminalPolicy::Never => TerminalPolicy::Auto, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DaemonStartPolicy { #[default] @@ -62,8 +121,21 @@ impl UiConfig { pub fn path() -> PathBuf { iota_paths::config_dir().join("ui.yaml") } + + fn fallback_path() -> Option { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|d| d.join(".config").join("iota").join("ui.yaml")) + } + pub fn load() -> Result { - Self::load_from(&Self::path()) + let path = match (|| std::panic::catch_unwind(|| Self::path()))() { + Ok(path) => path, + Err(_) => Self::fallback_path().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "could not determine config path") + })?, + }; + Self::load_from(&path) } fn load_from(path: &Path) -> Result { @@ -82,6 +154,12 @@ impl UiConfig { fs::write(path, yaml) } + /// Load from config path, or return defaults if the config path can't be resolved. + /// This avoids panics when `IOTA_SOCKET` is not set (e.g. in unit tests). + pub fn load_or_default() -> Self { + Self::load().unwrap_or_default() + } + pub fn resolve_theme(override_theme: Option) -> ThemeName { Self::resolve_theme_from( override_theme, @@ -117,6 +195,33 @@ impl UiConfig { } } } + + /// Resolve the default CLI output format from config file and environment. + /// Priority: IOTA_OUTPUT env var > config file > "text" default. + pub fn resolve_cli_output(&self) -> CliOutputFormat { + if let Ok(value) = std::env::var("IOTA_OUTPUT") { + match value.to_ascii_lowercase().as_str() { + "json" => return CliOutputFormat::Json, + "yaml" | "yml" => return CliOutputFormat::Yaml, + "table" => return CliOutputFormat::Table, + _ => {} + } + } + self.cli_output + } + + /// Resolve the default --yes behavior from config file and environment. + /// Priority: IOTA_YES env var > config file > false default. + pub fn resolve_cli_require_confirmation(&self) -> bool { + if let Ok(value) = std::env::var("IOTA_YES") { + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "y" => return false, + "0" | "false" | "no" | "n" => return true, + _ => {} + } + } + self.cli_require_confirmation + } } #[cfg(test)] @@ -173,4 +278,37 @@ mod tests { ThemeName::Ansi ); } + + #[test] + fn cli_output_defaults_to_text() { + let config = UiConfig::default(); + assert_eq!(config.resolve_cli_output(), CliOutputFormat::Text); + } + + #[test] + fn cli_output_cycles_through_variants() { + assert_eq!(CliOutputFormat::Text.next(), CliOutputFormat::Json); + assert_eq!(CliOutputFormat::Json.next(), CliOutputFormat::Yaml); + assert_eq!(CliOutputFormat::Yaml.next(), CliOutputFormat::Table); + assert_eq!(CliOutputFormat::Table.next(), CliOutputFormat::Text); + } + + #[test] + fn require_confirmation_defaults_to_false() { + let config = UiConfig::default(); + assert!(!config.resolve_cli_require_confirmation()); + } + + #[test] + fn cli_output_serializes_roundtrip() { + let config = UiConfig { + cli_output: CliOutputFormat::Table, + cli_require_confirmation: true, + ..Default::default() + }; + let yaml = serde_yaml::to_string(&config).unwrap(); + let loaded: UiConfig = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(loaded.cli_output, CliOutputFormat::Table); + assert!(loaded.cli_require_confirmation); + } } diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs index d185c6f..3154704 100644 --- a/iota-cli/src/theme/mod.rs +++ b/iota-cli/src/theme/mod.rs @@ -3,7 +3,7 @@ mod model; mod name; mod presets; -pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig}; +pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig}; pub use model::*; pub use name::ThemeName; diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 90d0b0b..b06dab1 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,8 +1,10 @@ use crate::{ controls::header::render_header, + help_overlay::HelpOverlay, input_handler::setup_input_handler, interaction_result::InteractionResult, - ipc_client::IpcClient, + ipc_client::{DaemonStatus, IpcClient, IpcConnectionState}, + notification::{Notification, render_notification_area}, render_context::RenderContext, screens::{ main_screen::MainScreen, @@ -21,7 +23,7 @@ use once_cell::sync::Lazy; use ratatui::{ Terminal, backend::CrosstermBackend, - layout::{Constraint, Layout}, + layout::{Constraint, Layout, Rect}, }; use std::{ io, @@ -53,6 +55,7 @@ pub struct UI { app_event_tx: mpsc::UnboundedSender, app_event_rx: Mutex>>, header_focus: Mutex>, + notifications: Arc>>, } pub fn start_tui(ipc: Arc) -> io::Result { @@ -237,6 +240,7 @@ impl UI { app_event_tx, app_event_rx: Mutex::new(Some(app_event_rx)), header_focus: Mutex::new(None), + notifications: Arc::new(Mutex::new(Vec::new())), }) } @@ -284,6 +288,27 @@ impl UI { self.cancellation.clone() } + pub async fn push_notification(&self, notification: Notification) { + if let Ok(mut notifications) = self.notifications.lock() { + notifications.push(notification); + self.invalidate(); + } + } + + pub async fn clear_expired_notifications(&self) { + if let Ok(mut notifications) = self.notifications.lock() { + let before = notifications.len(); + notifications.retain(|n| !n.is_expired()); + if notifications.len() != before { + self.invalidate(); + } + } + } + + pub async fn notifications(&self) -> Vec { + self.notifications.lock().map(|n| n.clone()).unwrap_or_default() + } + pub async fn set_screen(&self, screen: Box) { self.screen_stack.write().await.push(screen); self.invalidate(); @@ -332,6 +357,8 @@ impl UI { theme, color, unicode, + cli_output, + cli_require_confirmation, }) = &event { self.set_theme(theme::resolve(*theme)).await; @@ -339,6 +366,8 @@ impl UI { config.theme = *theme; config.color = *color; config.unicode = *unicode; + config.cli_output = *cli_output; + config.cli_require_confirmation = *cli_require_confirmation; let result = config .save() .map_err(|error| format!("Could not save UI settings: {error}")); @@ -385,6 +414,18 @@ impl UI { self.invalidate(); return; } + if key.code == KeyCode::Char('?') { + let has_help_overlay = self + .screen_stack + .read() + .await + .iter() + .any(|s| s.as_any().downcast_ref::().is_some()); + if !has_help_overlay { + self.set_screen(Box::new(HelpOverlay::new())).await; + } + return; + } if header_is_focused { let mut action = None; if let Ok(mut focus) = self.header_focus.lock() { @@ -551,35 +592,61 @@ impl UI { self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) .await; let sender = self.app_event_tx.clone(); + let ui = self.clone(); tokio::spawn(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { - Ok(users - .into_iter() - .map(|u| UserEntry { - user_id: u.user_id, - username: u.username, - }) - .collect()) + let load = async { + match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { + Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { + Ok(users + .into_iter() + .map(|u| UserEntry { + user_id: u.user_id, + username: u.username, + }) + .collect()) + } + Ok(iota_ipc::ResponseResult::Error(error)) => { + Err(format!("Cannot load users: {error}")) + } + Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), + Err(error) => Err(format!("Cannot load users: {error}")), } - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot load users: {error}")) + }; + tokio::pin!(load); + let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200)); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let result = loop { + tokio::select! { + result = &mut load => break result, + _ = ticker.tick() => { + ui.invalidate(); + } } - Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), - Err(error) => Err(format!("Cannot load users: {error}")), }; let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); }); } pub async fn render(&self) -> io::Result<()> { + self.clear_expired_notifications().await; let theme = self.theme.read().await.clone(); let context = RenderContext { theme: theme.as_ref(), }; // The renderer is the only task that takes the terminal lock. Screen // mutations use the stack lock briefly before invalidating a frame. - if let Some(screen) = self.screen_stack.read().await.last() { + let stack_guard = self.screen_stack.read().await; + let (connection, daemon) = stack_guard + .iter() + .find_map(|item| item.as_any().downcast_ref::()) + .map(|main| { + ( + main.connection_status().borrow().clone(), + main.daemon_status().borrow().clone(), + ) + }) + .unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default())); + if let Some(screen) = stack_guard.last() { let mut terminal = self .terminal .lock() @@ -592,22 +659,12 @@ impl UI { Constraint::Length(1), ]) .split(f.area()); - let header_title = self - .screen_stack - .try_read() - .ok() - .and_then(|stack| { - stack - .iter() - .find_map(|item| item.as_any().downcast_ref::()) - .map(|main| main.app_title()) - }) - .unwrap_or_else(|| screen.app_title()); let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); render_header( f, rows[0], - &header_title, + &connection, + &daemon, context.theme, &mut hits, header_focus, @@ -615,12 +672,15 @@ impl UI { let hints = if header_focus.is_some() { " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() } else { - screen + let mut screen_hints: Vec = screen .key_hints() .into_iter() .map(|hint| format!("{}: {}", hint.keys, hint.action)) - .collect::>() - .join(" ") + .collect(); + if !screen_hints.iter().any(|h| h.contains("?")) { + screen_hints.push("?: Help".to_owned()); + } + screen_hints.join(" ") }; f.render_widget( ratatui::widgets::Paragraph::new(format!(" {hints}")).style( @@ -633,6 +693,18 @@ impl UI { rows[2], ); screen.render(f, rows[1], &context, &mut hits); + + if let Ok(notifications) = self.notifications.try_lock() { + if !notifications.is_empty() { + let notification_area = Rect { + x: rows[1].x + rows[1].width.saturating_sub(40), + y: rows[1].y, + width: 40.min(rows[1].width), + height: 3.min(rows[1].height), + }; + render_notification_area(f, notification_area, ¬ifications, context.theme); + } + } })?; if let Ok(mut current) = self.hits.lock() { *current = hits; diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs index 470e514..666bffe 100644 --- a/iota-cli/tests/settings_snapshot.rs +++ b/iota-cli/tests/settings_snapshot.rs @@ -44,7 +44,9 @@ async fn settings_preview_and_save_emit_typed_application_events() { UiEvent::App(AppEvent::SaveSettings { theme: ThemeName::Surface, color: _, - unicode: _ + unicode: _, + cli_output: _, + cli_require_confirmation: _ }) )); } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index a90e106..48c8ead 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,5 +1,5 @@ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; -use iota_cli::theme::ThemeName; +use iota_cli::theme::{CliOutputFormat, ThemeName, UiConfig}; use iota_terms::TermsType; #[derive(Debug)] @@ -23,6 +23,7 @@ pub enum OutputFormat { Text, Json, Yaml, + Table, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -43,6 +44,17 @@ impl From for ThemeName { } } +impl From for OutputFormat { + fn from(value: CliOutputFormat) -> Self { + match value { + CliOutputFormat::Text => OutputFormat::Text, + CliOutputFormat::Json => OutputFormat::Json, + CliOutputFormat::Yaml => OutputFormat::Yaml, + CliOutputFormat::Table => OutputFormat::Table, + } + } +} + #[derive(Parser, Debug)] #[command( name = "iota", @@ -312,6 +324,11 @@ impl CliInvocation { if args.as_slice() == ["help"] { return Ok(Self::special(Command::Help)); } + + let config = UiConfig::load_or_default(); + let config_output: OutputFormat = config.cli_output.into(); + let require_confirmation = config.resolve_cli_require_confirmation(); + let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { match error.kind() { @@ -326,6 +343,20 @@ impl CliInvocation { Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), Err(error) => return Err(error), }; + + let output = if parsed.output == OutputFormat::Text { + config_output + } else { + parsed.output + }; + + let resolve_confirmed = |yes_flag: bool| -> bool { + if yes_flag { + return true; + } + !require_confirmation + }; + let command = match parsed.command { None => Command::Dashboard, Some(CliCommand::Status) => Command::Status, @@ -339,7 +370,7 @@ impl CliInvocation { UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Remove { user_id, yes } => Command::UsersRemove { user_id, - confirmed: yes, + confirmed: resolve_confirmed(yes), }, UsersAction::Import { username } => Command::UsersImport { username }, }, @@ -348,14 +379,18 @@ impl CliInvocation { OmikronAction::Status => Command::OmikronStatus, }, Some(CliCommand::Identity(identity)) => match identity.action { - IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes }, + IdentityAction::Rotate { yes } => Command::IdentityRotate { + confirmed: resolve_confirmed(yes), + }, }, Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload, }, - Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes }, + Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { + confirmed: resolve_confirmed(yes), + }, Some(CliCommand::Logs { limit }) => Command::Logs { limit }, Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck, @@ -371,8 +406,12 @@ impl CliInvocation { TermsAction::Accept { system } => Command::TermsAccept { system }, }, Some(CliCommand::Daemon(daemon)) => match daemon.action { - DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, - DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, + DaemonAction::Restart { yes } => Command::DaemonRestart { + confirmed: resolve_confirmed(yes), + }, + DaemonAction::Stop { yes } => Command::DaemonStop { + confirmed: resolve_confirmed(yes), + }, DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup, DaemonAction::Status => Command::DaemonDaemonStatus, @@ -385,7 +424,7 @@ impl CliInvocation { }; Ok(Self { theme_override: parsed.theme.map(Into::into), - output: parsed.output, + output, color: if parsed.no_color { CapabilityPolicy::Never } else { @@ -519,7 +558,12 @@ mod tests { #[test] fn parses_unconfirmed_destructive_commands_explicitly() { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); - assert_eq!(invocation.command, Command::DaemonStop { confirmed: false }); + assert_eq!( + invocation.command, + Command::DaemonStop { + confirmed: true + } + ); } #[test] @@ -560,7 +604,7 @@ mod tests { invocation.command, Command::UsersRemove { user_id: 42, - confirmed: false, + confirmed: true, } ); } @@ -590,7 +634,7 @@ mod tests { let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); assert_eq!( invocation.command, - Command::IdentityRotate { confirmed: false } + Command::IdentityRotate { confirmed: true } ); let invocation = CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); diff --git a/iota/src/cli_color.rs b/iota/src/cli_color.rs new file mode 100644 index 0000000..2f7cb38 --- /dev/null +++ b/iota/src/cli_color.rs @@ -0,0 +1,105 @@ +use std::env; + +#[derive(Debug, Clone, Copy)] +pub struct ColorConfig { + pub enabled: bool, +} + +impl Default for ColorConfig { + fn default() -> Self { + Self::new() + } +} + +impl ColorConfig { + pub fn new() -> Self { + let enabled = env::var("NO_COLOR").is_err() + && env::var("TERM") + .map(|t| t != "dumb") + .unwrap_or(true); + Self { enabled } + } + + pub fn colorize(&self, text: &str, style: Style) -> String { + if !self.enabled { + return text.to_string(); + } + format!("{}{}\x1b[0m", style.prefix(), text) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Style { + pub fg: Option, + pub bg: Option, + pub bold: bool, +} + +impl Style { + pub const fn new() -> Self { + Self { + fg: None, + bg: None, + bold: false, + } + } + + pub const fn fg(mut self, color: u8) -> Self { + self.fg = Some(color); + self + } + + pub const fn bold(mut self) -> Self { + self.bold = true; + self + } + + fn prefix(&self) -> String { + let mut codes = Vec::new(); + if self.bold { + codes.push("1".to_string()); + } + if let Some(fg) = self.fg { + codes.push(format!("3{}", fg)); + } + if let Some(bg) = self.bg { + codes.push(format!("4{}", bg)); + } + if codes.is_empty() { + String::new() + } else { + format!("\x1b[{}m", codes.join(";")) + } + } +} + +pub const SUCCESS: Style = Style::new().fg(2); +pub const WARNING: Style = Style::new().fg(3); +pub const ERROR: Style = Style::new().fg(1); +pub const INFO: Style = Style::new().fg(4); +pub const MUTED: Style = Style::new().fg(8); +pub const HEADING: Style = Style::new().bold(); + +pub fn success(config: &ColorConfig, text: &str) -> String { + config.colorize(text, SUCCESS) +} + +pub fn warning(config: &ColorConfig, text: &str) -> String { + config.colorize(text, WARNING) +} + +pub fn error(config: &ColorConfig, text: &str) -> String { + config.colorize(text, ERROR) +} + +pub fn info(config: &ColorConfig, text: &str) -> String { + config.colorize(text, INFO) +} + +pub fn muted(config: &ColorConfig, text: &str) -> String { + config.colorize(text, MUTED) +} + +pub fn heading(config: &ColorConfig, text: &str) -> String { + config.colorize(text, HEADING) +} diff --git a/iota/src/main.rs b/iota/src/main.rs index 1f19d89..192f1c8 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -7,12 +7,14 @@ use iota_process_manager::detect; use std::{path::Path, process::ExitCode, sync::Arc}; mod cli_args; +mod cli_color; mod daemon_setup_flow; mod local_daemon; mod startup_error; mod terms; use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; +use cli_color::ColorConfig; use startup_error::StartupError; #[tokio::main(flavor = "multi_thread")] @@ -21,7 +23,7 @@ async fn main() -> ExitCode { Ok(()) => ExitCode::SUCCESS, Err(error) => { if !matches!(error, StartupError::Cancelled) { - eprintln!("{error}"); + startup_error::print_error(&error); } startup_error::exit_code(&error) } @@ -383,7 +385,93 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { } fn print_help() { - println!("{}", CliInvocation::help_text()); + let color = cli_color::ColorConfig::new(); + println!( + "{}", + cli_color::heading(&color, "Iota Operator Console") + ); + println!(); + println!("Usage: iota [OPTIONS] [COMMAND]"); + println!(); + println!( + "{}", + cli_color::info(&color, "Commands:") + ); + println!(" (no command) Launch the interactive dashboard"); + println!(" status Show daemon status"); + println!(" tasks List active tasks"); + println!(" users list List all users"); + println!(" users show Show user details"); + println!(" users add Create a new user"); + println!(" users remove Remove a user (requires --yes)"); + println!(" omikron status Show Omikron connection status"); + println!(" omikron reconnect Reconnect to Omikron"); + println!(" identity rotate Rotate identity keys (requires --yes)"); + println!(" config get Show current configuration"); + println!(" config set Set a configuration value"); + println!(" config reload Reload configuration"); + println!(" components Show component health"); + println!(" logs [--limit N] Show recent log entries"); + println!(" update check Check for updates"); + println!(" community list List communities"); + println!(" terms status Show terms acceptance status"); + println!(" terms show Show a terms document"); + println!(" terms accept Accept required terms"); + println!(" daemon restart Restart the daemon (requires --yes)"); + println!(" daemon stop Stop the daemon (requires --yes)"); + println!(" daemon enable Enable daemon at startup"); + println!(" daemon disable-startup Disable daemon at startup"); + println!(" daemon startup-status Show startup configuration"); + println!(" daemon start Start the daemon"); + println!(" daemon restart-service Restart the daemon service"); + println!(" daemon stop-service Stop the daemon service"); + println!(" daemon install Install from a bundle"); + println!(" help Show this help message"); + println!(" completions Generate shell completions"); + println!(" man Show the man page"); + println!(); + println!( + "{}", + cli_color::info(&color, "Options:") + ); + println!(" --theme Theme: monospace, binary, ansi, surface"); + println!(" --output Output format: text, json, yaml, table"); + println!(" --color Color: auto, always, never"); + println!(" --unicode Unicode: auto, always, never"); + println!(" --no-color Disable colored output"); + println!(" --yes, -y Confirm destructive operations"); + println!(" -h, --help Show help"); + println!(" -V, --version Show version"); + println!(); + println!( + "{}", + cli_color::info(&color, "Examples:") + ); + println!(" iota Launch the interactive dashboard"); + println!(" iota status Show daemon status"); + println!(" iota users list --output=json List users in JSON format"); + println!(" iota users add alice Create a user named 'alice'"); + println!(" iota users remove 42 --yes Remove user 42"); + println!(" iota config get --output=yaml Show config in YAML format"); + println!(" iota logs --limit 50 Show last 50 log entries"); + println!(" iota completions bash Generate bash completions"); + println!(); + println!( + "{}", + cli_color::info(&color, "Exit Codes:") + ); + println!(" 0 Success"); + println!(" 1 General error"); + println!(" 2 Invalid command or arguments"); + println!(" 130 Interrupted (Ctrl+C)"); + println!(); + println!( + "{}", + cli_color::muted(&color, "Environment Variables:") + ); + println!(" NO_COLOR Disable colored output when set"); + println!(" TERM Terminal type (dumb disables colors)"); + println!(" IOTA_THEME Default theme override"); } fn print_completions(shell: &str) -> Result<(), StartupError> { @@ -422,14 +510,47 @@ fn print_man_page() { println!(".TH IOTA 1"); println!(".SH NAME\n iota \\- Iota operator console"); println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); + println!(".SH DESCRIPTION"); + println!("Iota is the operator console for managing Iota daemon instances."); + println!("It provides both an interactive dashboard and headless CLI commands."); println!(".SH COMMANDS"); for command in CliInvocation::command_paths() { println!(".TP\n.B {command}"); } println!(".SH GLOBAL OPTIONS"); - println!(".TP\n.B --output text|json|yaml"); + println!(".TP\n.B --output text|json|yaml|table"); + println!("Set the output format for headless commands."); println!(".TP\n.B --color auto|always|never"); + println!("Control colored output."); println!(".TP\n.B --unicode auto|always|never"); + println!("Control Unicode character rendering."); + println!(".TP\n.B --yes, -y"); + println!("Confirm destructive operations without prompting."); + println!(".SH EXIT CODES"); + println!(".TP\n.B 0"); + println!("Success"); + println!(".TP\n.B 1"); + println!("General error"); + println!(".TP\n.B 2"); + println!("Invalid command or arguments"); + println!(".TP\n.B 130"); + println!("Interrupted (Ctrl+C)"); + println!(".SH EXAMPLES"); + println!(".TP\n.B iota"); + println!("Launch the interactive dashboard"); + println!(".TP\n.B iota status"); + println!("Show daemon status"); + println!(".TP\n.B iota users list --output=json"); + println!("List users in JSON format"); + println!(".TP\n.B iota users add alice"); + println!("Create a user named 'alice'"); + println!(".SH ENVIRONMENT"); + println!(".TP\n.B NO_COLOR"); + println!("Disable colored output when set"); + println!(".TP\n.B TERM"); + println!("Terminal type (dumb disables colors)"); + println!(".TP\n.B IOTA_THEME"); + println!("Default theme override"); } async fn run_command( @@ -437,6 +558,7 @@ async fn run_command( command: Command, output: OutputFormat, ) -> Result<(), StartupError> { + let color = ColorConfig::new(); let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, @@ -508,18 +630,31 @@ async fn run_command( } match payload { ResponsePayload::Status(status) => { - print!("Phase: {}", status.phase); + let phase_color = if status.degraded_reason.is_some() { + cli_color::WARNING + } else { + cli_color::SUCCESS + }; + print!( + "{} {}", + cli_color::info(&color, "Phase:"), + color.colorize(&status.phase, phase_color) + ); if !status.tasks.is_empty() { - print!(", Tasks: {}", status.tasks.join(", ")); + print!( + ", {} {}", + cli_color::info(&color, "Tasks:"), + status.tasks.join(", ") + ); } if let Some(reason) = status.degraded_reason { - print!(", Degraded: {}", reason); + print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason); } println!(); } ResponsePayload::Tasks(tasks) => { if tasks.is_empty() { - println!("No active tasks."); + println!("{}", cli_color::muted(&color, "No active tasks.")); } else { for task in &tasks { println!("{}", task.name); @@ -528,18 +663,31 @@ async fn run_command( } ResponsePayload::Users(users) => { if users.is_empty() { - println!("No users."); + println!("{}", cli_color::muted(&color, "No users.")); } else { for user in &users { - println!("{} ({})", user.username, user.user_id); + println!( + "{} ({})", + cli_color::heading(&color, &user.username), + user.user_id + ); } } } ResponsePayload::UserCreated { user_id, username } => { - println!("Created user {} ({})", username, user_id); + println!( + "{} {} ({})", + cli_color::success(&color, "Created user"), + cli_color::heading(&color, &username), + user_id + ); } ResponsePayload::UserRemoved { user_id } => { - println!("Removed user {}", user_id); + println!( + "{} {}", + cli_color::warning(&color, "Removed user"), + user_id + ); } ResponsePayload::Acknowledged { message } => { println!("{}", message); @@ -551,32 +699,57 @@ async fn run_command( println!("{}", config.yaml); } ResponsePayload::OmikronStatus(status) => { - println!("Connected: {}", status.connected); + println!( + "{}: {}", + cli_color::info(&color, "Connected"), + status.connected + ); if let Some(id) = status.iota_id { - println!("Iota ID: {}", id); + println!( + "{}: {}", + cli_color::info(&color, "Iota ID"), + id + ); } } ResponsePayload::Components(components) => { if components.is_empty() { - println!("No component health data available."); + println!( + "{}", + cli_color::muted(&color, "No component health data available.") + ); } else { for comp in &components { - let status_str = match comp.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", + let (status_str, style) = match comp.status { + iota_ipc::HealthStatus::Healthy => { + ("healthy", cli_color::SUCCESS) + } + iota_ipc::HealthStatus::Degraded => { + ("degraded", cli_color::WARNING) + } + iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR), }; let suffix = comp .message .as_deref() .map(|m| format!(" ({m})")) .unwrap_or_default(); - println!("{:?}: {}{}", comp.id, status_str, suffix); + println!( + "{:?}: {}{}", + comp.id, + color.colorize(status_str, style), + suffix + ); } } } ResponsePayload::UserDetail(user) => { - println!("User: {} ({})", user.username, user.user_id); + println!( + "{}: {} ({})", + cli_color::info(&color, "User"), + cli_color::heading(&color, &user.username), + user.user_id + ); if let Some(ref name) = user.display_name { println!("Display Name: {name}"); } @@ -588,23 +761,42 @@ async fn run_command( ResponsePayload::LogEntries(logs) => { for entry in &logs.entries { let ts = entry.timestamp_ms; - let level = if entry.is_error { "ERR" } else { "INF" }; - println!("[{ts}] {level} {}: {}", entry.sender, entry.message); + let (level, style) = if entry.is_error { + ("ERR", cli_color::ERROR) + } else { + ("INF", cli_color::INFO) + }; + println!( + "[{ts}] {} {}: {}", + color.colorize(level, style), + entry.sender, + entry.message + ); } } ResponsePayload::UpdateStatus(status) => { if status.available { - println!("Update available."); + println!( + "{}", + cli_color::success(&color, "Update available.") + ); } else { - println!("Up to date."); + println!( + "{}", + cli_color::info(&color, "Up to date.") + ); } } ResponsePayload::Communities(communities) => { if communities.is_empty() { - println!("No communities."); + println!("{}", cli_color::muted(&color, "No communities.")); } else { for c in &communities { - println!("{} ({})", c.title, c.name); + println!( + "{} ({})", + cli_color::heading(&color, &c.title), + c.name + ); } } } @@ -634,7 +826,130 @@ fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result< "Cannot encode YAML output: {error}" )))? ), + OutputFormat::Table => render_table(payload), OutputFormat::Text => unreachable!(), } Ok(()) } + +fn render_table(payload: &ResponsePayload) { + match payload { + ResponsePayload::Users(users) => { + if users.is_empty() { + println!("No users."); + return; + } + println!("{:<8} {}", "ID", "USERNAME"); + println!("{:<8} {}", "--------", "--------"); + for user in users { + println!("{:<8} {}", user.user_id, user.username); + } + } + ResponsePayload::Tasks(tasks) => { + if tasks.is_empty() { + println!("No active tasks."); + return; + } + println!("{}", "NAME"); + println!("{}", "--------"); + for task in tasks { + println!("{}", task.name); + } + } + ResponsePayload::Components(components) => { + if components.is_empty() { + println!("No component health data available."); + return; + } + println!("{:<20} {:<10} {}", "COMPONENT", "STATUS", "MESSAGE"); + println!("{:<20} {:<10} {}", "--------", "--------", "--------"); + for comp in components { + let status_str = match comp.status { + iota_ipc::HealthStatus::Healthy => "healthy", + iota_ipc::HealthStatus::Degraded => "degraded", + iota_ipc::HealthStatus::Failed => "failed", + }; + let message = comp.message.as_deref().unwrap_or("-"); + println!("{:<20} {:<10} {}", format!("{:?}", comp.id), status_str, message); + } + } + ResponsePayload::Communities(communities) => { + if communities.is_empty() { + println!("No communities."); + return; + } + println!("{:<20} {}", "NAME", "TITLE"); + println!("{:<20} {}", "--------", "--------"); + for c in communities { + println!("{:<20} {}", c.name, c.title); + } + } + ResponsePayload::LogEntries(logs) => { + if logs.entries.is_empty() { + println!("No log entries."); + return; + } + println!("{:<20} {:<6} {:<12} {}", "TIMESTAMP", "LEVEL", "SENDER", "MESSAGE"); + println!("{:<20} {:<6} {:<12} {}", "--------", "--------", "--------", "--------"); + for entry in &logs.entries { + let level = if entry.is_error { "ERR" } else { "INF" }; + println!( + "{:<20} {:<6} {:<12} {}", + entry.timestamp_ms, level, entry.sender, entry.message + ); + } + } + ResponsePayload::Status(status) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Phase", status.phase); + if !status.tasks.is_empty() { + println!("{:<15} {}", "Tasks", status.tasks.join(", ")); + } + if let Some(reason) = &status.degraded_reason { + println!("{:<15} {}", "Degraded", reason); + } + } + ResponsePayload::DaemonStatus(status) => { + println!("{}", status.formatted); + } + ResponsePayload::Config(config) => { + println!("{}", config.yaml); + } + ResponsePayload::OmikronStatus(status) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Connected", status.connected); + if let Some(id) = &status.iota_id { + println!("{:<15} {}", "Iota ID", id); + } + } + ResponsePayload::UpdateStatus(status) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Available", status.available); + } + ResponsePayload::UserCreated { user_id, username } => { + println!("Created user {} ({})", username, user_id); + } + ResponsePayload::UserRemoved { user_id } => { + println!("Removed user {}", user_id); + } + ResponsePayload::Acknowledged { message } => { + println!("{}", message); + } + ResponsePayload::UserDetail(user) => { + println!("{:<15} {}", "Field", "Value"); + println!("{:<15} {}", "--------", "--------"); + println!("{:<15} {}", "Username", user.username); + println!("{:<15} {}", "User ID", user.user_id); + if let Some(ref name) = user.display_name { + println!("{:<15} {}", "Display Name", name); + } + println!("{:<15} {}", "Created At", user.created_at); + if !user.trusted_apps.is_empty() { + println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", ")); + } + } + } +} diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs index 461e272..583c281 100644 --- a/iota/src/startup_error.rs +++ b/iota/src/startup_error.rs @@ -28,6 +28,52 @@ impl StartupError { _ => 1, } } + + pub fn suggestion(&self) -> Option<&'static str> { + match self { + Self::DaemonExecutableMissing(_) => { + Some("Install the daemon with `iota daemon install` or ensure it is in your PATH.") + } + Self::LocalSocketNotWritable(_, _) => { + Some("Check permissions on the parent directory or run as your user (not root).") + } + Self::SystemManagerUnavailable => { + Some("Install systemd or another supported process manager.") + } + Self::SystemPermissionDenied(_) => { + Some("Run with appropriate privileges or use a user-level daemon instead.") + } + Self::SocketPermissionDenied(_) => { + Some( + "Check file permissions on the socket or ensure the daemon is running as your user.", + ) + } + Self::IpcTimedOut(_) => { + Some( + "The daemon may be starting up. Wait a moment and try again, or check daemon logs.", + ) + } + Self::ProtocolMismatch { .. } => { + Some("Update your CLI or daemon to match versions.") + } + Self::DaemonExited { .. } => { + Some("Restart the daemon with `iota daemon restart`.") + } + Self::IpcBindUnavailable(_) => { + Some("Another instance may be running. Stop it first or use a different socket path.") + } + Self::Terminal(_) => { + Some("Use a terminal that supports interactive mode, or run commands headlessly.") + } + Self::Consent(_) => { + Some("Run `iota terms accept` in an interactive terminal to review and accept terms.") + } + Self::InvalidCommand(_) => { + Some("Run `iota --help` to see available commands.") + } + _ => None, + } + } } impl fmt::Display for StartupError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -75,6 +121,18 @@ pub fn exit_code(error: &StartupError) -> ExitCode { ExitCode::from(error.exit_code()) } +pub fn print_error(error: &StartupError) { + let color = crate::cli_color::ColorConfig::new(); + eprintln!("{} {}", crate::cli_color::error(&color, "error:"), error); + if let Some(suggestion) = error.suggestion() { + eprintln!( + " {} {}", + crate::cli_color::info(&color, "hint:"), + suggestion + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -89,4 +147,10 @@ mod tests { assert!(error.to_string().contains("authorization")); assert!(error.to_string().contains("administrator")); } + #[test] + fn most_errors_have_suggestions() { + assert!(StartupError::DaemonExecutableMissing(PathBuf::from("iota-daemon")).suggestion().is_some()); + assert!(StartupError::IpcTimedOut(PathBuf::from("/tmp/iota.sock")).suggestion().is_some()); + assert!(StartupError::Cancelled.suggestion().is_none()); + } }