[Wip] CLI & Daemon

This commit is contained in:
Alex 2026-07-25 18:23:36 +02:00
commit 6a535099bb
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
44 changed files with 4417 additions and 303 deletions

View file

@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
@ -35,6 +35,10 @@ pub struct ConsoleCard {
last_swap: Arc<Mutex<Instant>>,
pending_restore: Arc<Mutex<Option<String>>>,
pending_confirmation: Option<String>,
history: Vec<String>,
history_index: Option<usize>,
history_draft: String,
message: Option<String>,
}
impl ConsoleCard {
@ -51,6 +55,10 @@ impl ConsoleCard {
last_swap: Arc::new(Mutex::new(Instant::now())),
pending_restore: Arc::new(Mutex::new(None)),
pending_confirmation: None,
history: Vec::new(),
history_index: None,
history_draft: String::new(),
message: None,
}
}
@ -151,6 +159,9 @@ impl ConsoleCard {
}
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
if let Some(message) = &self.message {
return vec![Span::styled(message.clone(), theme.console.error)];
}
if let Some(command) = &self.pending_confirmation {
return vec![Span::styled(
format!("Confirm `{command}`? [y/N]"),
@ -163,11 +174,17 @@ impl ConsoleCard {
fn is_destructive(command: &str) -> bool {
matches!(
command.trim_start_matches('/').trim(),
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
"restart"
| "reload"
| "stop"
| "shutdown"
| "regenerate keys"
| "identity rotate"
) || command
.trim_start_matches('/')
.trim_start()
.starts_with("user remove ")
.split_once(" remove ")
.is_some_and(|(noun, _)| matches!(noun, "user" | "users"))
}
fn dispatch_command(&self, command: String) {
@ -214,6 +231,69 @@ impl ConsoleCard {
self.content.insert(idx, c);
self.cursor_position += 1;
}
pub fn handle_paste(&mut self, text: &str) {
let sanitized = text.replace(['\r', '\n'], " ");
let index = self.byte_index();
self.content.insert_str(index, &sanitized);
self.cursor_position += sanitized.chars().count();
self.message = None;
}
fn set_editor(&mut self, value: String) {
self.content = value;
self.cursor_position = self.content.chars().count();
}
fn history_previous(&mut self) {
if self.history.is_empty() {
return;
}
let index = match self.history_index {
None => {
self.history_draft = self.content.clone();
self.history.len() - 1
}
Some(index) => index.saturating_sub(1),
};
self.history_index = Some(index);
self.set_editor(self.history[index].clone());
self.message = None;
}
fn history_next(&mut self) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.history.len() {
self.history_index = Some(index + 1);
self.set_editor(self.history[index + 1].clone());
} else {
self.history_index = None;
let draft = std::mem::take(&mut self.history_draft);
self.set_editor(draft);
}
self.message = None;
}
fn complete(&mut self) -> bool {
let completions = iota_ipc::text_commands::completions(&self.content);
if completions.len() == 1 {
let leading_slash = self.content.starts_with('/');
self.set_editor(format!(
"{}{}",
if leading_slash { "/" } else { "" },
completions[0]
));
self.message = None;
true
} else if completions.len() > 1 {
self.message = Some(format!("Matches: {}", completions.join(", ")));
true
} else {
false
}
}
}
impl Element for ConsoleCard {
@ -226,6 +306,21 @@ impl Element for ConsoleCard {
}
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
let inner = crate::controls::panel::render_panel(
f,
r,
&self.title,
self.focused,
context.theme,
);
f.render_widget(
Paragraph::new(Line::from(self.render_cursor_spans(context.theme)))
.style(context.theme.console.text),
inner,
);
return;
}
let block = Block::default()
.borders(self.borders)
.title(self.title.clone())
@ -304,6 +399,7 @@ impl InteractableElement for ConsoleCard {
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
self.content = restored;
self.cursor_position = self.content.chars().count();
self.message = Some("Command failed; restored for retry.".into());
}
if let Some(command) = self.pending_confirmation.take() {
@ -320,6 +416,22 @@ impl InteractableElement for ConsoleCard {
}
let command = self.content.clone();
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
self.message = Some(error);
return InteractionResult::Handled;
}
if command.trim_start_matches('/').trim() == "help" {
self.message = Some(format!(
"Commands: {}",
iota_ipc::text_commands::COMMANDS.join(", ")
));
return InteractionResult::Handled;
}
if self.history.last() != Some(&command) {
self.history.push(command.clone());
}
self.history_index = None;
self.history_draft.clear();
self.content.clear();
self.cursor_position = 0;
if Self::is_destructive(&command) {
@ -330,10 +442,12 @@ impl InteractableElement for ConsoleCard {
InteractionResult::Handled
}
KeyCode::Backspace => {
self.message = None;
self.delete_at_cursor();
InteractionResult::Handled
}
KeyCode::Delete => {
self.message = None;
let len = self.content.chars().count();
if self.cursor_position < len {
let start = self.byte_index();
@ -363,15 +477,36 @@ impl InteractableElement for ConsoleCard {
self.cursor_position = self.content.chars().count();
InteractionResult::Handled
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
KeyCode::Up => {
self.history_previous();
InteractionResult::Handled
}
KeyCode::Down => {
self.history_next();
InteractionResult::Handled
}
KeyCode::Tab if !self.content.is_empty() => {
if self.complete() {
InteractionResult::Handled
} else {
InteractionResult::Unhandled
self.message = Some("No command completion.".into());
InteractionResult::Handled
}
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
self.message = None;
return InteractionResult::Handled;
}
}
InteractionResult::Unhandled
}
}
}

View file

@ -34,15 +34,15 @@ impl GRAPHS {
}
}
pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> {
let state = match state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
match self {
GRAPHS::Ram => state.with_width(28).ram.clone(),
GRAPHS::Cpu => state.with_width(28).cpu.clone(),
GRAPHS::Ping => state.with_width(28).ping.clone(),
GRAPHS::Ram => state.with_width(sample_width.min(u16::MAX as usize) as u16).ram.clone(),
GRAPHS::Cpu => state.with_width(sample_width.min(u16::MAX as usize) as u16).cpu.clone(),
GRAPHS::Ping => state.with_width(sample_width.min(u16::MAX as usize) as u16).ping.clone(),
}
}
@ -69,6 +69,7 @@ pub struct GraphCard {
joins: Borders,
open: bool,
sample_width: usize,
}
impl GraphCard {
@ -82,12 +83,17 @@ impl GraphCard {
borders: Borders::ALL,
joins: Borders::NONE,
open: true,
sample_width: 28,
}
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
pub fn set_sample_width(&mut self, sample_width: usize) {
self.sample_width = sample_width.max(1);
}
}
impl Element for GraphCard {
fn as_any(&self) -> &dyn Any {
@ -100,7 +106,24 @@ impl Element for GraphCard {
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if self.open {
let graph = self.graph_type.get_graph(&self.state);
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
if graph.is_empty() {
let block = Block::default()
.title(format!(" {} ", self.title))
.borders(self.borders)
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
context.theme.graphs.border
});
f.render_widget(
ratatui::widgets::Paragraph::new("No metric samples yet.")
.style(context.theme.text.muted)
.block(block),
r,
);
return;
}
let unit = self.graph_type.get_unit();
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
@ -117,16 +140,19 @@ impl Element for GraphCard {
GRAPHS::Ping => (max_y * 1.2).max(10.0),
};
let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces);
let title = format!("{}: {}{} {}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64);
let plot_area = if surface { crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) } else { r };
let block = Block::default()
.title(format!(
.title(if surface { String::new() } else { format!(
"{}:─{}{}─{}min/{}max",
self.title,
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
unit,
min_y as i64,
max_y as i64,
))
.borders(self.borders)
) })
.borders(if surface { Borders::NONE } else { self.borders })
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
@ -148,7 +174,7 @@ impl Element for GraphCard {
});
}
});
f.render_widget(canvas, r);
f.render_widget(canvas, plot_area);
} else {
let block = Block::default()
.title("")
@ -160,7 +186,7 @@ impl Element for GraphCard {
});
f.render_widget(block, r);
}
draw_block_joins(
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins(
f,
r,
self.borders,
@ -170,7 +196,7 @@ impl Element for GraphCard {
} else {
context.theme.borders.normal
},
);
); }
}
}

View file

@ -1,7 +1,7 @@
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::util::borders::draw_block_joins;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use iota_state::{ClientState, UiLogEntry};
use ratatui::{
Frame,
@ -10,7 +10,10 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy)]
@ -55,8 +58,11 @@ pub struct LogCard {
focused: bool,
selected: bool,
scroll_offset: usize,
last_total_lines: usize,
last_visible_height: usize,
last_total_lines: AtomicUsize,
last_visible_height: AtomicUsize,
last_width: AtomicUsize,
filter: String,
filtering: bool,
pub borders: Borders,
pub joins: Borders,
}
@ -68,8 +74,11 @@ impl LogCard {
focused: false,
selected: false,
scroll_offset: 0,
last_total_lines: 0,
last_visible_height: 10,
last_total_lines: AtomicUsize::new(0),
last_visible_height: AtomicUsize::new(1),
last_width: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
borders: Borders::ALL,
joins: Borders::NONE,
}
@ -80,9 +89,15 @@ impl LogCard {
Ok(state) => state,
Err(_) => return Vec::new(),
};
let needle = self.filter.to_ascii_lowercase();
state
.get_logs()
.iter()
.filter(|entry| {
needle.is_empty()
|| entry.sender.to_ascii_lowercase().contains(&needle)
|| entry.message.to_ascii_lowercase().contains(&needle)
})
.map(|e| UiLogEntry {
timestamp_ms: e.timestamp_ms,
sender: e.sender.clone(),
@ -214,11 +229,13 @@ impl LogCard {
}
fn get_title_hints(&self) -> (bool, bool) {
if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height {
let total_lines = self.last_total_lines.load(Ordering::Relaxed);
let visible_height = self.last_visible_height.load(Ordering::Relaxed);
if total_lines == 0 || total_lines <= visible_height {
return (false, false);
}
let max_offset = self.last_total_lines - self.last_visible_height;
let max_offset = total_lines - visible_height;
let can_scroll_up = self.scroll_offset < max_offset;
let can_scroll_down = self.scroll_offset > 0;
@ -226,6 +243,12 @@ impl LogCard {
}
fn build_title(&self) -> String {
if self.filtering {
return format!("Logs filter: {}_", self.filter);
}
if !self.filter.is_empty() {
return format!("Logs [filter: {}]", self.filter);
}
if !self.focused {
return "Logs".to_string();
}
@ -250,7 +273,8 @@ impl LogCard {
fn scroll_up(&mut self) {
let max_offset = self
.last_total_lines
.saturating_sub(self.last_visible_height);
.load(Ordering::Relaxed)
.saturating_sub(self.last_visible_height.load(Ordering::Relaxed));
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
}
@ -297,17 +321,14 @@ impl Element for LogCard {
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let entries = self.get_logs();
let block = Block::default()
.title(self.build_title())
.borders(self.borders)
.border_style(if self.focused {
context.theme.logs.focused_border
} else {
context.theme.logs.border
});
let inner_area = block.inner(area);
f.render_widget(block, area);
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, context.theme)
} else {
let block = Block::default().title(self.build_title()).borders(self.borders).border_style(if self.focused { context.theme.logs.focused_border } else { context.theme.logs.border });
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
if inner_area.width == 0 || inner_area.height == 0 {
draw_block_joins(
@ -327,6 +348,11 @@ impl Element for LogCard {
let all_lines = self.build_all_lines(entries, inner_area.width as usize);
let total_lines = all_lines.len();
let visible_height = inner_area.height as usize;
self.last_width
.store(inner_area.width as usize, Ordering::Relaxed);
self.last_total_lines.store(total_lines, Ordering::Relaxed);
self.last_visible_height
.store(visible_height, Ordering::Relaxed);
let (start, end) = self.calculate_view_window(total_lines, visible_height);
let visible_lines = &all_lines[start..end];
@ -337,6 +363,7 @@ impl Element for LogCard {
let mut spans = Vec::new();
let (prefix, rest) = Self::split_line_prefix(line);
let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { "" } else { prefix };
if !prefix.is_empty() {
spans.push(Span::styled(
@ -377,7 +404,7 @@ impl Element for LogCard {
f.render_widget(Paragraph::new(line.clone()), line_area);
}
draw_block_joins(
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins(
f,
area,
self.borders,
@ -387,7 +414,7 @@ impl Element for LogCard {
} else {
context.theme.borders.normal
},
);
); }
}
}
@ -435,14 +462,44 @@ impl InteractableElement for LogCard {
}
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
if self.filtering {
match key.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(c);
}
_ => {}
}
self.scroll_offset = 0;
return InteractionResult::Handled;
}
let entries = self.get_logs();
let estimated_width = 80usize;
let all_lines = self.build_all_lines(entries, estimated_width);
let width = self.last_width.load(Ordering::Relaxed).max(1);
let all_lines = self.build_all_lines(entries, width);
self.last_total_lines = all_lines.len();
let visible_height = self.last_visible_height.max(1);
self.last_total_lines
.store(all_lines.len(), Ordering::Relaxed);
let total_lines = all_lines.len();
let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1);
match key.code {
KeyCode::Char('/') => {
self.filtering = true;
self.filter.clear();
self.scroll_offset = 0;
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => {
self.selected = !self.selected;
InteractionResult::Handled
@ -476,8 +533,8 @@ impl InteractableElement for LogCard {
InteractionResult::Handled
}
KeyCode::Home => {
if self.last_total_lines > visible_height {
self.scroll_offset = self.last_total_lines - visible_height;
if total_lines > visible_height {
self.scroll_offset = total_lines - visible_height;
}
InteractionResult::Handled
}