[Imp] UI & UX

This commit is contained in:
Alex 2026-07-28 02:20:15 +02:00
commit 7399cf8fc3
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
18 changed files with 1635 additions and 193 deletions

View file

@ -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<String>,
buttons: Vec<DialogButtonConfig>,
focused_button: usize,
on_confirm: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
on_cancel: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
}
struct DialogButtonConfig {
label: String,
intent: ButtonIntent,
enabled: bool,
}
impl ConfirmDialog {
pub fn new(title: impl Into<String>, message: impl Into<String>) -> 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<String>, message: impl Into<String>) -> 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<String>) -> Self {
self.message.push(line.into());
self
}
pub fn with_button(mut self, label: impl Into<String>, intent: ButtonIntent) -> Self {
self.buttons.push(DialogButtonConfig {
label: label.into(),
intent,
enabled: true,
});
self
}
pub fn with_confirm_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
mut self,
action: F,
) -> Self {
self.on_confirm = Some(Box::new(action));
self
}
pub fn with_cancel_action<F: Fn() -> 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<Line> = 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<u16> = 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<KeyHint> {
vec![
KeyHint {
keys: "Tab",
action: "Switch button",
},
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
}
}

View file

@ -1,25 +1,58 @@
use crate::ipc_client::{DaemonStatus, IpcConnectionState};
use crate::theme::ResolvedTheme; use crate::theme::ResolvedTheme;
use crate::{ use crate::{
controls::button::{ActionButton, ButtonIntent, render_button}, controls::button::ButtonIntent,
screens::screens::{AppAction, HitMap}, screens::screens::{AppAction, HitMap},
}; };
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
text::Span, text::{Line, Span},
widgets::Paragraph, widgets::Paragraph,
}; };
/// Shared application bar. The brand cell is deliberately an action so it is fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) {
/// a reliable way home from every screen. 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( pub fn render_header(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
title: &str, connection: &IpcConnectionState,
daemon: &DaemonStatus,
theme: &ResolvedTheme, theme: &ResolvedTheme,
hits: &mut HitMap, hits: &mut HitMap,
focused_action: Option<usize>, focused_action: Option<usize>,
) { ) {
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([ let cells = Layout::horizontal([
Constraint::Min(28), Constraint::Min(28),
Constraint::Length(12), Constraint::Length(12),
@ -27,36 +60,75 @@ pub fn render_header(
Constraint::Length(12), Constraint::Length(12),
Constraint::Length(8), 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( frame.render_widget(
Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)), Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar),
cells[0], brand_area,
); );
hits.register(cells[0], AppAction::OpenMain); hits.register(brand_area, AppAction::OpenMain);
for (index, (area, label, action)) in [
(cells[1], "Overview", AppAction::OpenOverview), for (index, (top, _bottom, label, intent, action)) in [
(cells[2], "Users", AppAction::OpenUsers), (cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview),
(cells[3], "Settings", AppAction::OpenSettings), (cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers),
(cells[4], "Quit", AppAction::Quit), (cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings),
(cells[4], cells2[4], "Quit", ButtonIntent::Destructive, AppAction::Quit),
] ]
.into_iter() .into_iter()
.enumerate() .enumerate()
{ {
render_button( let button_area = Rect {
frame, x: top.x,
area, y: top.y,
ActionButton { width: top.width,
label, height: area.height,
intent: if action == AppAction::Quit { };
ButtonIntent::Destructive let style = match (intent, focused_action == Some(index)) {
} else { (ButtonIntent::Primary, true) => theme.buttons.primary_focused,
ButtonIntent::Neutral (ButtonIntent::Primary, false) => theme.buttons.primary,
}, (ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
focused: focused_action == Some(index), (ButtonIntent::Neutral, false) => theme.buttons.neutral,
enabled: true, (ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
}, (ButtonIntent::Cancel, false) => theme.buttons.cancel,
theme, (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);
} }
} }

View file

@ -2,6 +2,7 @@ pub mod action;
pub mod button; pub mod button;
pub mod checkbox_group; pub mod checkbox_group;
pub mod choice; pub mod choice;
pub mod dialog;
pub mod header; pub mod header;
pub mod navigation; pub mod navigation;
pub mod panel; pub mod panel;

View file

@ -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<Line<'static>> {
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<crate::screens::screens::KeyHint> {
vec![
crate::screens::screens::KeyHint {
keys: "Up/Down",
action: "Scroll",
},
crate::screens::screens::KeyHint {
keys: "Esc/?",
action: "Close",
},
]
}
}
use std::any::Any;

View file

@ -23,10 +23,12 @@ pub mod util {
} }
pub mod app_state; pub mod app_state;
pub mod controls; pub mod controls;
pub mod help_overlay;
pub mod input_handler; pub mod input_handler;
pub mod interaction_result; pub mod interaction_result;
pub mod ipc_client; pub mod ipc_client;
pub mod layout; pub mod layout;
pub mod notification;
pub mod render_context; pub mod render_context;
pub mod theme; pub mod theme;
pub mod ui; pub mod ui;

View file

@ -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<String>) -> Self {
Self::new(message, NotificationKind::Success, Duration::from_secs(3))
}
pub fn warning(message: impl Into<String>) -> Self {
Self::new(message, NotificationKind::Warning, Duration::from_secs(4))
}
pub fn error(message: impl Into<String>) -> Self {
Self::new(message, NotificationKind::Error, Duration::from_secs(5))
}
pub fn info(message: impl Into<String>) -> Self {
Self::new(message, NotificationKind::Info, Duration::from_secs(3))
}
fn new(message: impl Into<String>, 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(&notification.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 = &notifications[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);
}
}

View file

@ -49,28 +49,6 @@ impl MainScreen {
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> { pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
self.daemon_status_rx.clone() 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<UI>) -> Self { pub async fn new(ui: Arc<UI>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new(); let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
@ -254,39 +232,6 @@ impl Screen for MainScreen {
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
self.layout_width.store(rect.width, Ordering::Relaxed); 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( f.render_widget(
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
rect, rect,
@ -503,9 +448,6 @@ impl Screen for MainScreen {
_ => InteractionResult::Unhandled, _ => InteractionResult::Unhandled,
} }
} }
fn app_title(&self) -> String {
self.status_summary()
}
fn key_hints(&self) -> Vec<KeyHint> { fn key_hints(&self) -> Vec<KeyHint> {
if self.selected_coords == (2, 0) { if self.selected_coords == (2, 0) {
vec![ vec![

View file

@ -30,6 +30,8 @@ pub enum AppEvent {
theme: crate::theme::ThemeName, theme: crate::theme::ThemeName,
color: crate::theme::TerminalPolicy, color: crate::theme::TerminalPolicy,
unicode: crate::theme::TerminalPolicy, unicode: crate::theme::TerminalPolicy,
cli_output: crate::theme::CliOutputFormat,
cli_require_confirmation: bool,
}, },
ThemeSaved(Result<(), String>), ThemeSaved(Result<(), String>),
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>), UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
@ -118,9 +120,6 @@ pub trait Screen: Send + Sync + Any {
fn handle_action(&mut self, _action: AppAction) -> InteractionResult { fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
InteractionResult::Unhandled InteractionResult::Unhandled
} }
fn app_title(&self) -> String {
"IOTA".to_owned()
}
fn key_hints(&self) -> Vec<KeyHint> { fn key_hints(&self) -> Vec<KeyHint> {
vec![ vec![
KeyHint { KeyHint {

View file

@ -13,12 +13,14 @@ use crate::{
interaction_result::InteractionResult, interaction_result::InteractionResult,
render_context::RenderContext, render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
theme::{TerminalPolicy, ThemeName, UiConfig}, theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig},
}; };
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
enum Focus { enum Focus {
Theme, Theme,
CliOutput,
CliConfirm,
RegenerateKeys, RegenerateKeys,
Back, Back,
} }
@ -34,6 +36,8 @@ pub struct SettingsScreen {
message: String, message: String,
color: TerminalPolicy, color: TerminalPolicy,
unicode: TerminalPolicy, unicode: TerminalPolicy,
cli_output: CliOutputFormat,
cli_require_confirmation: bool,
focus: Focus, focus: Focus,
dialog: Option<Dialog>, dialog: Option<Dialog>,
pending: bool, pending: bool,
@ -45,16 +49,15 @@ impl SettingsScreen {
.iter() .iter()
.position(|theme| *theme == current) .position(|theme| *theme == current)
.unwrap_or(0); .unwrap_or(0);
let config = UiConfig::load_or_default();
Self { Self {
selected, selected,
saved: current, saved: current,
message: "Left/Right previews. Enter saves.".into(), message: "Left/Right previews. Enter saves.".into(),
color: UiConfig::load() color: config.color,
.map(|config| config.color) unicode: config.unicode,
.unwrap_or_default(), cli_output: config.cli_output,
unicode: UiConfig::load() cli_require_confirmation: config.cli_require_confirmation,
.map(|config| config.unicode)
.unwrap_or_default(),
focus: Focus::Theme, focus: Focus::Theme,
dialog: None, dialog: None,
pending: false, pending: false,
@ -82,7 +85,9 @@ impl SettingsScreen {
fn next_focus(&mut self) { fn next_focus(&mut self) {
self.focus = match self.focus { 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::RegenerateKeys => Focus::Back,
Focus::Back => Focus::Theme, Focus::Back => Focus::Theme,
}; };
@ -92,7 +97,9 @@ impl SettingsScreen {
self.focus = match self.focus { self.focus = match self.focus {
Focus::Theme => Focus::Back, Focus::Theme => Focus::Back,
Focus::Back => Focus::RegenerateKeys, 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 { match self.focus {
Focus::Theme => { Focus::Theme => {
self.message = "Saving theme…".into(); self.message = "Saving theme…".into();
let theme = self.selected_theme(); }
let color = self.color; Focus::CliOutput => {
let unicode = self.unicode; self.message = "Output format updated.".into();
InteractionResult::AppTask { return InteractionResult::Handled;
task: Box::pin(async move { }
UiEvent::App(AppEvent::SaveSettings { Focus::CliConfirm => {
theme, self.cli_require_confirmation = !self.cli_require_confirmation;
color, self.message = format!(
unicode, "Confirm: {}",
}) if self.cli_require_confirmation {
}), "On"
} } else {
"Off"
},
);
return InteractionResult::Handled;
} }
Focus::RegenerateKeys => { Focus::RegenerateKeys => {
self.dialog = Some(Dialog::ConfirmRegenerateKeys); 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<'_>, context: &RenderContext<'_>,
hits: &mut HitMap, hits: &mut HitMap,
) { ) {
let block = Block::default() let header_block = Block::default()
.title(" Settings ") .title(" Settings ")
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(context.theme.borders.focused); .border_style(context.theme.borders.focused);
let inner = block.inner(area); let inner = header_block.inner(area);
frame.render_widget(block, area); frame.render_widget(header_block, area);
let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)])
.split(inner);
frame.render_widget( frame.render_widget(
Paragraph::new(format!( Paragraph::new(format!(
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
@ -169,18 +199,28 @@ impl Screen for SettingsScreen {
" [preview]" " [preview]"
}, },
self.color, self.color,
self.unicode self.unicode,
)) ))
.style(context.theme.text.heading), .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 = 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![ let lines = vec![
Line::from(Span::styled(&self.message, context.theme.text.normal)), 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("Preview"),
Line::from("[OK] Healthy"), Line::from("[OK] Healthy"),
Line::from("[WARN] Degraded"), Line::from("[WARN] Degraded"),
@ -352,6 +392,23 @@ impl Screen for SettingsScreen {
self.unicode = Self::next_policy(self.unicode); self.unicode = Self::next_policy(self.unicode);
InteractionResult::Handled 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') => { KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen InteractionResult::CloseScreen
} }
@ -405,6 +462,10 @@ impl Screen for SettingsScreen {
keys: "C/U", keys: "C/U",
action: "Color/Unicode", action: "Color/Unicode",
}, },
KeyHint {
keys: "L/K",
action: "CLI Out/Confirm",
},
KeyHint { KeyHint {
keys: "Esc/B", keys: "Esc/B",
action: "Back", action: "Back",

View file

@ -19,7 +19,7 @@ use std::{
any::Any, any::Any,
sync::{ sync::{
Arc, Arc,
atomic::{AtomicUsize, Ordering}, atomic::{AtomicU8, AtomicUsize, Ordering},
}, },
}; };
@ -56,6 +56,7 @@ pub struct UsersScreen {
viewport_height: AtomicUsize, viewport_height: AtomicUsize,
filter: String, filter: String,
filtering: bool, filtering: bool,
tick: AtomicU8,
} }
impl UsersScreen { impl UsersScreen {
@ -74,6 +75,7 @@ impl UsersScreen {
viewport_height: AtomicUsize::new(1), viewport_height: AtomicUsize::new(1),
filter: String::new(), filter: String::new(),
filtering: false, filtering: false,
tick: AtomicU8::new(0),
} }
} }
@ -115,7 +117,12 @@ impl UsersScreen {
}; };
if self.loading { 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; return;
} }
if visible_indices.is_empty() { if visible_indices.is_empty() {

View file

@ -17,6 +17,55 @@ pub struct UiConfig {
pub color: TerminalPolicy, pub color: TerminalPolicy,
#[serde(default)] #[serde(default)]
pub unicode: TerminalPolicy, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@ -28,6 +77,16 @@ pub enum TerminalPolicy {
Never, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DaemonStartPolicy { pub enum DaemonStartPolicy {
#[default] #[default]
@ -62,8 +121,21 @@ impl UiConfig {
pub fn path() -> PathBuf { pub fn path() -> PathBuf {
iota_paths::config_dir().join("ui.yaml") iota_paths::config_dir().join("ui.yaml")
} }
fn fallback_path() -> Option<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|d| d.join(".config").join("iota").join("ui.yaml"))
}
pub fn load() -> Result<Self, io::Error> { pub fn load() -> Result<Self, io::Error> {
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<Self, io::Error> { fn load_from(path: &Path) -> Result<Self, io::Error> {
@ -82,6 +154,12 @@ impl UiConfig {
fs::write(path, yaml) 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>) -> ThemeName { pub fn resolve_theme(override_theme: Option<ThemeName>) -> ThemeName {
Self::resolve_theme_from( Self::resolve_theme_from(
override_theme, 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)] #[cfg(test)]
@ -173,4 +278,37 @@ mod tests {
ThemeName::Ansi 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);
}
} }

View file

@ -3,7 +3,7 @@ mod model;
mod name; mod name;
mod presets; mod presets;
pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig}; pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig};
pub use model::*; pub use model::*;
pub use name::ThemeName; pub use name::ThemeName;

View file

@ -1,8 +1,10 @@
use crate::{ use crate::{
controls::header::render_header, controls::header::render_header,
help_overlay::HelpOverlay,
input_handler::setup_input_handler, input_handler::setup_input_handler,
interaction_result::InteractionResult, interaction_result::InteractionResult,
ipc_client::IpcClient, ipc_client::{DaemonStatus, IpcClient, IpcConnectionState},
notification::{Notification, render_notification_area},
render_context::RenderContext, render_context::RenderContext,
screens::{ screens::{
main_screen::MainScreen, main_screen::MainScreen,
@ -21,7 +23,7 @@ use once_cell::sync::Lazy;
use ratatui::{ use ratatui::{
Terminal, Terminal,
backend::CrosstermBackend, backend::CrosstermBackend,
layout::{Constraint, Layout}, layout::{Constraint, Layout, Rect},
}; };
use std::{ use std::{
io, io,
@ -53,6 +55,7 @@ pub struct UI {
app_event_tx: mpsc::UnboundedSender<UiEvent>, app_event_tx: mpsc::UnboundedSender<UiEvent>,
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>, app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
header_focus: Mutex<Option<usize>>, header_focus: Mutex<Option<usize>>,
notifications: Arc<Mutex<Vec<Notification>>>,
} }
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> { pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
@ -237,6 +240,7 @@ impl UI {
app_event_tx, app_event_tx,
app_event_rx: Mutex::new(Some(app_event_rx)), app_event_rx: Mutex::new(Some(app_event_rx)),
header_focus: Mutex::new(None), header_focus: Mutex::new(None),
notifications: Arc::new(Mutex::new(Vec::new())),
}) })
} }
@ -284,6 +288,27 @@ impl UI {
self.cancellation.clone() 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<Notification> {
self.notifications.lock().map(|n| n.clone()).unwrap_or_default()
}
pub async fn set_screen(&self, screen: Box<dyn Screen>) { pub async fn set_screen(&self, screen: Box<dyn Screen>) {
self.screen_stack.write().await.push(screen); self.screen_stack.write().await.push(screen);
self.invalidate(); self.invalidate();
@ -332,6 +357,8 @@ impl UI {
theme, theme,
color, color,
unicode, unicode,
cli_output,
cli_require_confirmation,
}) = &event }) = &event
{ {
self.set_theme(theme::resolve(*theme)).await; self.set_theme(theme::resolve(*theme)).await;
@ -339,6 +366,8 @@ impl UI {
config.theme = *theme; config.theme = *theme;
config.color = *color; config.color = *color;
config.unicode = *unicode; config.unicode = *unicode;
config.cli_output = *cli_output;
config.cli_require_confirmation = *cli_require_confirmation;
let result = config let result = config
.save() .save()
.map_err(|error| format!("Could not save UI settings: {error}")); .map_err(|error| format!("Could not save UI settings: {error}"));
@ -385,6 +414,18 @@ impl UI {
self.invalidate(); self.invalidate();
return; return;
} }
if key.code == KeyCode::Char('?') {
let has_help_overlay = self
.screen_stack
.read()
.await
.iter()
.any(|s| s.as_any().downcast_ref::<HelpOverlay>().is_some());
if !has_help_overlay {
self.set_screen(Box::new(HelpOverlay::new())).await;
}
return;
}
if header_is_focused { if header_is_focused {
let mut action = None; let mut action = None;
if let Ok(mut focus) = self.header_focus.lock() { if let Ok(mut focus) = self.header_focus.lock() {
@ -551,35 +592,61 @@ impl UI {
self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) self.set_screen(Box::new(UsersScreen::loading(ipc.clone())))
.await; .await;
let sender = self.app_event_tx.clone(); let sender = self.app_event_tx.clone();
let ui = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { let load = async {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
Ok(users Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => {
.into_iter() Ok(users
.map(|u| UserEntry { .into_iter()
user_id: u.user_id, .map(|u| UserEntry {
username: u.username, user_id: u.user_id,
}) username: u.username,
.collect()) })
.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))); let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result)));
}); });
} }
pub async fn render(&self) -> io::Result<()> { pub async fn render(&self) -> io::Result<()> {
self.clear_expired_notifications().await;
let theme = self.theme.read().await.clone(); let theme = self.theme.read().await.clone();
let context = RenderContext { let context = RenderContext {
theme: theme.as_ref(), theme: theme.as_ref(),
}; };
// The renderer is the only task that takes the terminal lock. Screen // The renderer is the only task that takes the terminal lock. Screen
// mutations use the stack lock briefly before invalidating a frame. // 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::<MainScreen>())
.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 let mut terminal = self
.terminal .terminal
.lock() .lock()
@ -592,22 +659,12 @@ impl UI {
Constraint::Length(1), Constraint::Length(1),
]) ])
.split(f.area()); .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::<MainScreen>())
.map(|main| main.app_title())
})
.unwrap_or_else(|| screen.app_title());
let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus);
render_header( render_header(
f, f,
rows[0], rows[0],
&header_title, &connection,
&daemon,
context.theme, context.theme,
&mut hits, &mut hits,
header_focus, header_focus,
@ -615,12 +672,15 @@ impl UI {
let hints = if header_focus.is_some() { let hints = if header_focus.is_some() {
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned() " Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
} else { } else {
screen let mut screen_hints: Vec<String> = screen
.key_hints() .key_hints()
.into_iter() .into_iter()
.map(|hint| format!("{}: {}", hint.keys, hint.action)) .map(|hint| format!("{}: {}", hint.keys, hint.action))
.collect::<Vec<_>>() .collect();
.join(" ") if !screen_hints.iter().any(|h| h.contains("?")) {
screen_hints.push("?: Help".to_owned());
}
screen_hints.join(" ")
}; };
f.render_widget( f.render_widget(
ratatui::widgets::Paragraph::new(format!(" {hints}")).style( ratatui::widgets::Paragraph::new(format!(" {hints}")).style(
@ -633,6 +693,18 @@ impl UI {
rows[2], rows[2],
); );
screen.render(f, rows[1], &context, &mut hits); 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, &notifications, context.theme);
}
}
})?; })?;
if let Ok(mut current) = self.hits.lock() { if let Ok(mut current) = self.hits.lock() {
*current = hits; *current = hits;

View file

@ -44,7 +44,9 @@ async fn settings_preview_and_save_emit_typed_application_events() {
UiEvent::App(AppEvent::SaveSettings { UiEvent::App(AppEvent::SaveSettings {
theme: ThemeName::Surface, theme: ThemeName::Surface,
color: _, color: _,
unicode: _ unicode: _,
cli_output: _,
cli_require_confirmation: _
}) })
)); ));
} }

View file

@ -1,5 +1,5 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; 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; use iota_terms::TermsType;
#[derive(Debug)] #[derive(Debug)]
@ -23,6 +23,7 @@ pub enum OutputFormat {
Text, Text,
Json, Json,
Yaml, Yaml,
Table,
} }
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
@ -43,6 +44,17 @@ impl From<CliTheme> for ThemeName {
} }
} }
impl From<CliOutputFormat> 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)] #[derive(Parser, Debug)]
#[command( #[command(
name = "iota", name = "iota",
@ -312,6 +324,11 @@ impl CliInvocation {
if args.as_slice() == ["help"] { if args.as_slice() == ["help"] {
return Ok(Self::special(Command::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 = let parsed =
Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
match error.kind() { match error.kind() {
@ -326,6 +343,20 @@ impl CliInvocation {
Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)),
Err(error) => return Err(error), 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 { let command = match parsed.command {
None => Command::Dashboard, None => Command::Dashboard,
Some(CliCommand::Status) => Command::Status, Some(CliCommand::Status) => Command::Status,
@ -339,7 +370,7 @@ impl CliInvocation {
UsersAction::Add { username } => Command::UsersAdd { username }, UsersAction::Add { username } => Command::UsersAdd { username },
UsersAction::Remove { user_id, yes } => Command::UsersRemove { UsersAction::Remove { user_id, yes } => Command::UsersRemove {
user_id, user_id,
confirmed: yes, confirmed: resolve_confirmed(yes),
}, },
UsersAction::Import { username } => Command::UsersImport { username }, UsersAction::Import { username } => Command::UsersImport { username },
}, },
@ -348,14 +379,18 @@ impl CliInvocation {
OmikronAction::Status => Command::OmikronStatus, OmikronAction::Status => Command::OmikronStatus,
}, },
Some(CliCommand::Identity(identity)) => match identity.action { 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 { Some(CliCommand::Config(config)) => match config.action {
ConfigAction::Get => Command::ConfigGet, ConfigAction::Get => Command::ConfigGet,
ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Set { key, value } => Command::ConfigSet { key, value },
ConfigAction::Reload => Command::ConfigReload, 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::Logs { limit }) => Command::Logs { limit },
Some(CliCommand::Update(update)) => match update.action { Some(CliCommand::Update(update)) => match update.action {
UpdateAction::Check => Command::UpdateCheck, UpdateAction::Check => Command::UpdateCheck,
@ -371,8 +406,12 @@ impl CliInvocation {
TermsAction::Accept { system } => Command::TermsAccept { system }, TermsAction::Accept { system } => Command::TermsAccept { system },
}, },
Some(CliCommand::Daemon(daemon)) => match daemon.action { Some(CliCommand::Daemon(daemon)) => match daemon.action {
DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, DaemonAction::Restart { yes } => Command::DaemonRestart {
DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, confirmed: resolve_confirmed(yes),
},
DaemonAction::Stop { yes } => Command::DaemonStop {
confirmed: resolve_confirmed(yes),
},
DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::Enable { mode } => Command::DaemonEnable { mode },
DaemonAction::DisableStartup => Command::DaemonDisableStartup, DaemonAction::DisableStartup => Command::DaemonDisableStartup,
DaemonAction::Status => Command::DaemonDaemonStatus, DaemonAction::Status => Command::DaemonDaemonStatus,
@ -385,7 +424,7 @@ impl CliInvocation {
}; };
Ok(Self { Ok(Self {
theme_override: parsed.theme.map(Into::into), theme_override: parsed.theme.map(Into::into),
output: parsed.output, output,
color: if parsed.no_color { color: if parsed.no_color {
CapabilityPolicy::Never CapabilityPolicy::Never
} else { } else {
@ -519,7 +558,12 @@ mod tests {
#[test] #[test]
fn parses_unconfirmed_destructive_commands_explicitly() { fn parses_unconfirmed_destructive_commands_explicitly() {
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); 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] #[test]
@ -560,7 +604,7 @@ mod tests {
invocation.command, invocation.command,
Command::UsersRemove { Command::UsersRemove {
user_id: 42, user_id: 42,
confirmed: false, confirmed: true,
} }
); );
} }
@ -590,7 +634,7 @@ mod tests {
let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap();
assert_eq!( assert_eq!(
invocation.command, invocation.command,
Command::IdentityRotate { confirmed: false } Command::IdentityRotate { confirmed: true }
); );
let invocation = let invocation =
CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap();

105
iota/src/cli_color.rs Normal file
View file

@ -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<u8>,
pub bg: Option<u8>,
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)
}

View file

@ -7,12 +7,14 @@ use iota_process_manager::detect;
use std::{path::Path, process::ExitCode, sync::Arc}; use std::{path::Path, process::ExitCode, sync::Arc};
mod cli_args; mod cli_args;
mod cli_color;
mod daemon_setup_flow; mod daemon_setup_flow;
mod local_daemon; mod local_daemon;
mod startup_error; mod startup_error;
mod terms; mod terms;
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
use cli_color::ColorConfig;
use startup_error::StartupError; use startup_error::StartupError;
#[tokio::main(flavor = "multi_thread")] #[tokio::main(flavor = "multi_thread")]
@ -21,7 +23,7 @@ async fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(error) => { Err(error) => {
if !matches!(error, StartupError::Cancelled) { if !matches!(error, StartupError::Cancelled) {
eprintln!("{error}"); startup_error::print_error(&error);
} }
startup_error::exit_code(&error) startup_error::exit_code(&error)
} }
@ -383,7 +385,93 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
} }
fn print_help() { 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 <ID> Show user details");
println!(" users add <NAME> Create a new user");
println!(" users remove <ID> 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 <KEY> <VAL> 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 <DOC> 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 <SHELL> Generate shell completions");
println!(" man Show the man page");
println!();
println!(
"{}",
cli_color::info(&color, "Options:")
);
println!(" --theme <THEME> Theme: monospace, binary, ansi, surface");
println!(" --output <FORMAT> Output format: text, json, yaml, table");
println!(" --color <WHEN> Color: auto, always, never");
println!(" --unicode <WHEN> 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> { fn print_completions(shell: &str) -> Result<(), StartupError> {
@ -422,14 +510,47 @@ fn print_man_page() {
println!(".TH IOTA 1"); println!(".TH IOTA 1");
println!(".SH NAME\n iota \\- Iota operator console"); println!(".SH NAME\n iota \\- Iota operator console");
println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); 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"); println!(".SH COMMANDS");
for command in CliInvocation::command_paths() { for command in CliInvocation::command_paths() {
println!(".TP\n.B {command}"); println!(".TP\n.B {command}");
} }
println!(".SH GLOBAL OPTIONS"); 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!(".TP\n.B --color auto|always|never");
println!("Control colored output.");
println!(".TP\n.B --unicode auto|always|never"); 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( async fn run_command(
@ -437,6 +558,7 @@ async fn run_command(
command: Command, command: Command,
output: OutputFormat, output: OutputFormat,
) -> Result<(), StartupError> { ) -> Result<(), StartupError> {
let color = ColorConfig::new();
let request = match command { let request = match command {
Command::Status => LocalRequest::GetStatus, Command::Status => LocalRequest::GetStatus,
Command::Tasks => LocalRequest::ListTasks, Command::Tasks => LocalRequest::ListTasks,
@ -508,18 +630,31 @@ async fn run_command(
} }
match payload { match payload {
ResponsePayload::Status(status) => { 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() { 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 { if let Some(reason) = status.degraded_reason {
print!(", Degraded: {}", reason); print!(", {}: {}", cli_color::warning(&color, "Degraded"), reason);
} }
println!(); println!();
} }
ResponsePayload::Tasks(tasks) => { ResponsePayload::Tasks(tasks) => {
if tasks.is_empty() { if tasks.is_empty() {
println!("No active tasks."); println!("{}", cli_color::muted(&color, "No active tasks."));
} else { } else {
for task in &tasks { for task in &tasks {
println!("{}", task.name); println!("{}", task.name);
@ -528,18 +663,31 @@ async fn run_command(
} }
ResponsePayload::Users(users) => { ResponsePayload::Users(users) => {
if users.is_empty() { if users.is_empty() {
println!("No users."); println!("{}", cli_color::muted(&color, "No users."));
} else { } else {
for user in &users { 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 } => { 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 } => { ResponsePayload::UserRemoved { user_id } => {
println!("Removed user {}", user_id); println!(
"{} {}",
cli_color::warning(&color, "Removed user"),
user_id
);
} }
ResponsePayload::Acknowledged { message } => { ResponsePayload::Acknowledged { message } => {
println!("{}", message); println!("{}", message);
@ -551,32 +699,57 @@ async fn run_command(
println!("{}", config.yaml); println!("{}", config.yaml);
} }
ResponsePayload::OmikronStatus(status) => { ResponsePayload::OmikronStatus(status) => {
println!("Connected: {}", status.connected); println!(
"{}: {}",
cli_color::info(&color, "Connected"),
status.connected
);
if let Some(id) = status.iota_id { if let Some(id) = status.iota_id {
println!("Iota ID: {}", id); println!(
"{}: {}",
cli_color::info(&color, "Iota ID"),
id
);
} }
} }
ResponsePayload::Components(components) => { ResponsePayload::Components(components) => {
if components.is_empty() { if components.is_empty() {
println!("No component health data available."); println!(
"{}",
cli_color::muted(&color, "No component health data available.")
);
} else { } else {
for comp in &components { for comp in &components {
let status_str = match comp.status { let (status_str, style) = match comp.status {
iota_ipc::HealthStatus::Healthy => "healthy", iota_ipc::HealthStatus::Healthy => {
iota_ipc::HealthStatus::Degraded => "degraded", ("healthy", cli_color::SUCCESS)
iota_ipc::HealthStatus::Failed => "failed", }
iota_ipc::HealthStatus::Degraded => {
("degraded", cli_color::WARNING)
}
iota_ipc::HealthStatus::Failed => ("failed", cli_color::ERROR),
}; };
let suffix = comp let suffix = comp
.message .message
.as_deref() .as_deref()
.map(|m| format!(" ({m})")) .map(|m| format!(" ({m})"))
.unwrap_or_default(); .unwrap_or_default();
println!("{:?}: {}{}", comp.id, status_str, suffix); println!(
"{:?}: {}{}",
comp.id,
color.colorize(status_str, style),
suffix
);
} }
} }
} }
ResponsePayload::UserDetail(user) => { 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 { if let Some(ref name) = user.display_name {
println!("Display Name: {name}"); println!("Display Name: {name}");
} }
@ -588,23 +761,42 @@ async fn run_command(
ResponsePayload::LogEntries(logs) => { ResponsePayload::LogEntries(logs) => {
for entry in &logs.entries { for entry in &logs.entries {
let ts = entry.timestamp_ms; let ts = entry.timestamp_ms;
let level = if entry.is_error { "ERR" } else { "INF" }; let (level, style) = if entry.is_error {
println!("[{ts}] {level} {}: {}", entry.sender, entry.message); ("ERR", cli_color::ERROR)
} else {
("INF", cli_color::INFO)
};
println!(
"[{ts}] {} {}: {}",
color.colorize(level, style),
entry.sender,
entry.message
);
} }
} }
ResponsePayload::UpdateStatus(status) => { ResponsePayload::UpdateStatus(status) => {
if status.available { if status.available {
println!("Update available."); println!(
"{}",
cli_color::success(&color, "Update available.")
);
} else { } else {
println!("Up to date."); println!(
"{}",
cli_color::info(&color, "Up to date.")
);
} }
} }
ResponsePayload::Communities(communities) => { ResponsePayload::Communities(communities) => {
if communities.is_empty() { if communities.is_empty() {
println!("No communities."); println!("{}", cli_color::muted(&color, "No communities."));
} else { } else {
for c in &communities { 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}" "Cannot encode YAML output: {error}"
)))? )))?
), ),
OutputFormat::Table => render_table(payload),
OutputFormat::Text => unreachable!(), OutputFormat::Text => unreachable!(),
} }
Ok(()) 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(", "));
}
}
}
}

View file

@ -28,6 +28,52 @@ impl StartupError {
_ => 1, _ => 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 { impl fmt::Display for StartupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 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()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -89,4 +147,10 @@ mod tests {
assert!(error.to_string().contains("authorization")); assert!(error.to_string().contains("authorization"));
assert!(error.to_string().contains("administrator")); 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());
}
} }