From 95cca3e777d2b3fdf035bde6fb2c3745e1689050 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 23 Aug 2026 18:40:27 +0200 Subject: [PATCH] Rebuild grid agent TUI with Ratatui dashboards --- Cargo.lock | 271 +- crates/metacrate-grid-agent/Cargo.toml | 1 + crates/metacrate-grid-agent/src/tui.rs | 2241 ++++++++++++++--- crates/metacrate-grid-agent/src/tui_tests.rs | 134 +- .../tests/dependency_policy.rs | 3 +- .../tests/preferences_config.rs | 20 + docs/grid-agent-tui.md | 48 +- 7 files changed, 2283 insertions(+), 435 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9aca6af..eb04bbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1867,6 +1867,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -1931,6 +1937,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.4.2" @@ -2151,6 +2166,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2539,6 +2568,40 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -3495,6 +3558,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -3861,6 +3926,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3917,6 +3988,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inflections" version = "1.1.1" @@ -3932,6 +4012,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "inventory" version = "0.3.24" @@ -4139,6 +4232,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + [[package]] name = "ktx2" version = "0.5.0" @@ -4411,6 +4515,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4456,6 +4569,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4544,7 +4666,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "similar", - "strum", + "strum 0.27.2", "thiserror 2.0.20", "time", "tokio", @@ -4564,7 +4686,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "strum", + "strum 0.27.2", "thiserror 2.0.20", "time", "tokio", @@ -4631,6 +4753,7 @@ dependencies = [ "metacrate-game-loop", "metacrate-lsl-tools", "metacrate-rendering-wgpu", + "ratatui", "rustls", "serde", "serde_json", @@ -4942,6 +5065,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -5509,6 +5641,39 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking" version = "2.2.1" @@ -6005,6 +6170,72 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -7149,7 +7380,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -7164,6 +7404,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -7314,7 +7566,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -7693,6 +7947,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width 0.2.2", +] + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/crates/metacrate-grid-agent/Cargo.toml b/crates/metacrate-grid-agent/Cargo.toml index 26c2b0e..fdf516a 100644 --- a/crates/metacrate-grid-agent/Cargo.toml +++ b/crates/metacrate-grid-agent/Cargo.toml @@ -22,6 +22,7 @@ metacrate-game-loop = { version = "0.0.1", path = "../metacrate-game-loop" } metacrate-rendering-wgpu = { version = "0.0.1", path = "../metacrate-rendering-wgpu" } mentra = { version = "0.18.3", default-features = false } jpeg-encoder = "0.6.1" +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] } rustls = { version = "0.23.43", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/metacrate-grid-agent/src/tui.rs b/crates/metacrate-grid-agent/src/tui.rs index b95a393..8b832ff 100644 --- a/crates/metacrate-grid-agent/src/tui.rs +++ b/crates/metacrate-grid-agent/src/tui.rs @@ -1,7 +1,7 @@ //! Cross-platform, transport-neutral terminal operator interface. //! -//! The reducer and renderer are terminal-free. [`run_terminal`] is the only -//! code which touches a terminal and uses crossterm's Windows/Unix backends. +//! The reducer is transport-neutral and the renderer targets Ratatui frames. +//! [`run_terminal`] is the only code which touches a real terminal. #![allow(clippy::missing_errors_doc)] @@ -13,20 +13,29 @@ use crate::control_plane::{ }; use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent}; use crate::{AgentPreferences, ControlLimits, OperatingMode, PreferencesSummary, SecretString}; -use crossterm::{cursor, event, execute, queue, style, terminal}; +use crossterm::{cursor, event, execute, terminal}; +use ratatui::backend::{CrosstermBackend, TestBackend}; +use ratatui::buffer::Buffer; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Gauge, Paragraph, Row, Sparkline, Table, Wrap}; +use ratatui::{Frame, Terminal}; +use std::cell::Cell as ValueCell; use std::collections::VecDeque; use std::fmt; use std::future::Future; -use std::io::{self, Write}; +use std::io; use std::net::SocketAddr; use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; -use std::time::Duration; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; const MAX_TIMELINE: usize = 2_000; const MAX_ERRORS: usize = 100; +const MAX_PERFORMANCE_HISTORY: usize = 120; const PAGE_LIMIT: u16 = 100; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -57,7 +66,8 @@ impl OperatorScreen { Self::Preferences, ]; - const fn title(self) -> &'static str { + #[must_use] + pub const fn title(self) -> &'static str { match self { Self::Overview => "Overview", Self::Sessions => "Sessions", @@ -245,63 +255,6 @@ impl PreferencesPanel { self.status = "saved; restart service to apply connection changes".into(); Ok(()) } - - fn render(&self, out: &mut Vec) { - let summary = self.summary(); - out.push(format!( - "file={} mode={:?}", - self.path.display(), - summary.mode - )); - let values = [ - format!("mode: {:?}", summary.mode), - format!( - "LLM endpoint: {}", - summary.llm_endpoint.as_deref().unwrap_or("") - ), - format!( - "LLM API key: {}", - configured(summary.llm_api_key_configured) - ), - format!( - "LLM model: {}", - summary.llm_model.as_deref().unwrap_or("") - ), - format!( - "grid login URL: {}", - summary.grid_login_url.as_deref().unwrap_or("") - ), - format!( - "grid avatar: {}", - summary.grid_avatar_name.as_deref().unwrap_or("") - ), - format!( - "grid password: {}", - configured(summary.grid_password_configured) - ), - format!("privileged users: {}", summary.privileged_users.join(", ")), - ]; - for (index, value) in values.into_iter().enumerate() { - out.push(format!( - "{} {value}", - if index == self.selected { ">" } else { " " } - )); - } - if self.editing { - out.push(format!( - "input: {}", - if self.selected_field().secret() { - "•".repeat(self.buffer.chars().count()) - } else { - self.buffer.clone() - } - )); - } - out.push(format!( - "{} | [↑/↓] field [Enter] edit/commit [Esc] cancel [w] save", - self.status - )); - } } fn configured(value: bool) -> &'static str { @@ -346,8 +299,16 @@ impl EventFilter { } let query = self.query.to_lowercase(); query.is_empty() + || item.unix_millis.to_string().contains(&query) + || format!("{:?}", item.severity) + .to_lowercase() + .contains(&query) || item.component.to_lowercase().contains(&query) || item.family.to_lowercase().contains(&query) + || item + .duration_millis + .is_some_and(|value| value.to_string().contains(&query)) + || item.retry_count.to_string().contains(&query) || item .result_code .as_ref() @@ -356,6 +317,15 @@ impl EventFilter { .reason_code .as_ref() .is_some_and(|v| v.to_lowercase().contains(&query)) + || [ + item.correlation.avatar_id.as_ref(), + item.correlation.session_id.as_ref(), + item.correlation.action_id.as_ref(), + item.correlation.request_id.as_ref(), + ] + .into_iter() + .flatten() + .any(|value| value.to_lowercase().contains(&query)) } } @@ -390,6 +360,17 @@ impl Default for OperatorSnapshot { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct PerformanceSample { + queue_percent: u64, + rate_percent: u64, + active_work: u64, + event_delta: u64, + dropped: u64, + inference_average_millis: u64, + tool_average_millis: u64, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum OperatorCommand { Pause, @@ -473,6 +454,11 @@ pub enum TuiInput { PreferenceBackspace, PreferenceSave, PreferenceCancel, + BeginFilter, + FilterCharacter(char), + FilterBackspace, + FilterCommit, + FilterCancel, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -608,9 +594,17 @@ pub struct OperatorTui { pending: Option, request_number: u64, scroll: usize, + selected: [usize; OperatorScreen::ALL.len()], status: String, errors: VecDeque, preferences: Option, + history: VecDeque, + previous_recorded_events: u64, + refresh_millis: u64, + frame_micros: ValueCell, + cadence_millis: ValueCell, + last_frame: ValueCell>, + filter_editing: bool, } impl Default for OperatorTui { @@ -622,9 +616,17 @@ impl Default for OperatorTui { pending: None, request_number: 1, scroll: 0, + selected: [0; OperatorScreen::ALL.len()], status: "connecting".into(), errors: VecDeque::with_capacity(MAX_ERRORS), preferences: None, + history: VecDeque::with_capacity(MAX_PERFORMANCE_HISTORY), + previous_recorded_events: 0, + refresh_millis: 0, + frame_micros: ValueCell::new(0), + cadence_millis: ValueCell::new(0), + last_frame: ValueCell::new(None), + filter_editing: false, } } } @@ -637,21 +639,66 @@ impl OperatorTui { }) } + const fn screen_index(screen: OperatorScreen) -> usize { + screen as usize + } + + fn selected_row(&self) -> usize { + self.selected[Self::screen_index(self.screen)] + } + + fn visible_rows(&self) -> usize { + match self.screen { + OperatorScreen::Sessions => self.snapshot.sessions.len(), + OperatorScreen::Roaming => self.snapshot.schedules.len(), + OperatorScreen::Approvals => self.snapshot.approvals.len(), + OperatorScreen::Timeline => { + let query = self.filter.query.to_lowercase(); + self.snapshot + .timeline + .iter() + .filter(|event| self.filter.matches(event)) + .count() + .saturating_add( + self.snapshot + .audit + .iter() + .filter(|audit| audit_matches(audit, &query)) + .count(), + ) + } + OperatorScreen::Errors => self.errors.len().saturating_add( + self.snapshot + .timeline + .iter() + .filter(|event| event.severity == EventSeverity::Error) + .count(), + ), + OperatorScreen::Diagnostics => self + .snapshot + .timeline + .iter() + .filter(|event| event.family == "diagnostic_envelope") + .count(), + OperatorScreen::Preferences => PreferenceField::ALL.len(), + _ => 1, + } + } + #[must_use] pub fn command_shortcut(&self, key: char) -> Option { match key { 'p' => Some(OperatorCommand::Pause), 'u' => Some(OperatorCommand::Resume), 'f' => Some(OperatorCommand::Reconnect), - 'a' | 'd' => { - self.snapshot - .approvals - .first() - .map(|approval| OperatorCommand::DecideApproval { - id: approval.approval_id, - approve: key == 'a', - }) - } + 'a' | 'd' => self + .snapshot + .approvals + .get(self.selected[Self::screen_index(OperatorScreen::Approvals)]) + .map(|approval| OperatorCommand::DecideApproval { + id: approval.approval_id, + approve: key == 'a', + }), 'x' => self.snapshot.timeline.iter().rev().find_map(|event| { event .correlation @@ -662,7 +709,7 @@ impl OperatorTui { 'e' => self .snapshot .sessions - .first() + .get(self.selected[Self::screen_index(OperatorScreen::Sessions)]) .map(|session| OperatorCommand::ExpireSession { avatar_id: session.avatar_id.clone(), direct_im: session.channel == "direct_im", @@ -670,7 +717,7 @@ impl OperatorTui { 't' => self .snapshot .schedules - .first() + .get(self.selected[Self::screen_index(OperatorScreen::Roaming)]) .map(|job| OperatorCommand::ToggleSchedule { job_id: job.job_id.clone(), enabled: !job.enabled, @@ -680,6 +727,7 @@ impl OperatorTui { } } + #[allow(clippy::too_many_lines)] pub fn reduce(&mut self, input: TuiInput) -> TuiAction { match input { TuiInput::NextScreen => { @@ -702,11 +750,17 @@ impl OperatorTui { TuiAction::None } TuiInput::ScrollUp => { - self.scroll = self.scroll.saturating_sub(1); + let index = Self::screen_index(self.screen); + self.selected[index] = self.selected[index].saturating_sub(1); + self.scroll = self.selected[index]; TuiAction::None } TuiInput::ScrollDown => { - self.scroll = self.scroll.saturating_add(1); + let index = Self::screen_index(self.screen); + self.selected[index] = self.selected[index] + .saturating_add(1) + .min(self.visible_rows().saturating_sub(1)); + self.scroll = self.selected[index]; TuiAction::None } TuiInput::Refresh => TuiAction::Refresh, @@ -727,7 +781,9 @@ impl OperatorTui { if let Some(panel) = &mut self.preferences { if panel.editing { if let Err(error) = panel.commit_edit() { - self.record_error(error.to_string()); + let message = error.to_string(); + panel.status = format!("[VALIDATION] {message}"); + self.record_error(message); } } else { panel.begin_edit(); @@ -751,7 +807,9 @@ impl OperatorTui { if let Some(panel) = &mut self.preferences && let Err(error) = panel.save() { - self.record_error(error.to_string()); + let message = error.to_string(); + panel.status = format!("[VALIDATION] {message}"); + self.record_error(message); } TuiAction::None } @@ -761,6 +819,35 @@ impl OperatorTui { } TuiAction::None } + TuiInput::BeginFilter => { + self.filter_editing = true; + self.status = "event/audit filter: type query, Enter applies, Esc clears".into(); + TuiAction::None + } + TuiInput::FilterCharacter(value) => { + if self.filter_editing && !value.is_control() && self.filter.query.len() < 256 { + self.filter.query.push(value); + } + TuiAction::None + } + TuiInput::FilterBackspace => { + if self.filter_editing { + self.filter.query.pop(); + } + TuiAction::None + } + TuiInput::FilterCommit => { + self.filter_editing = false; + self.selected[Self::screen_index(self.screen)] = 0; + self.status = format!("filter applied: {}", self.filter.query); + TuiAction::None + } + TuiInput::FilterCancel => { + self.filter_editing = false; + self.filter.query.clear(); + self.status = "filter cleared".into(); + TuiAction::None + } TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => { TuiAction::Execute(command) } @@ -805,6 +892,7 @@ impl OperatorTui { } pub async fn refresh(&mut self, client: &dyn TuiTransport) -> Result<(), TuiError> { + let started = Instant::now(); let requests = [ ControlRequest::Health, ControlRequest::Runtime, @@ -818,10 +906,52 @@ impl OperatorTui { for request in requests { self.send_and_apply(client, request).await?; } + self.refresh_millis = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX); + self.sample_performance(); self.status = "connected".into(); Ok(()) } + fn sample_performance(&mut self) { + let Some(metrics) = &self.snapshot.metrics else { + return; + }; + let percent = + |used: u64, capacity: u64| used.saturating_mul(100).checked_div(capacity).unwrap_or(0); + let average = |total: u64, samples: u64| total.checked_div(samples).unwrap_or(0); + let dropped = metrics + .dropped_ring_events + .saturating_add(metrics.dropped_subscriber_events) + .saturating_add(metrics.dropped_journal_events); + let sample = PerformanceSample { + queue_percent: percent(metrics.queue_depth, metrics.queue_capacity), + rate_percent: percent(metrics.rate_limit_used, metrics.rate_limit_capacity), + active_work: metrics.active_tasks, + event_delta: metrics + .recorded_events + .saturating_sub(self.previous_recorded_events), + dropped, + inference_average_millis: average( + metrics.inference_latency.total_millis, + metrics.inference_latency.samples, + ), + tool_average_millis: average( + metrics.tool_latency.total_millis, + metrics.tool_latency.samples, + ), + }; + self.previous_recorded_events = metrics.recorded_events; + if self.history.len() == MAX_PERFORMANCE_HISTORY { + self.history.pop_front(); + } + self.history.push_back(sample); + } + + #[must_use] + pub fn performance_history_len(&self) -> usize { + self.history.len() + } + pub async fn execute( &mut self, client: &dyn TuiTransport, @@ -854,22 +984,167 @@ impl OperatorTui { Ok(()) } + #[allow(clippy::too_many_lines)] fn apply(&mut self, payload: ControlPayload) { match payload { ControlPayload::Health(value) => self.snapshot.health = Some(value), ControlPayload::Metrics(value) => self.snapshot.metrics = Some(value), ControlPayload::Runtime(value) => self.snapshot.runtime = Some(value), - ControlPayload::Sessions(Page { items, .. }) => self.snapshot.sessions = items, - ControlPayload::ScheduledJobs(Page { items, .. }) => self.snapshot.schedules = items, - ControlPayload::PendingApprovals(Page { items, .. }) => self.snapshot.approvals = items, - ControlPayload::AuditEvents(Page { items, .. }) => self.snapshot.audit = items, + ControlPayload::Sessions(Page { items, .. }) => { + let index = Self::screen_index(OperatorScreen::Sessions); + let identity = self + .snapshot + .sessions + .get(self.selected[index]) + .map(|item| item.session_id.clone()); + self.snapshot.sessions = items; + self.selected[index] = identity + .and_then(|id| { + self.snapshot + .sessions + .iter() + .position(|item| item.session_id == id) + }) + .unwrap_or(0) + .min(self.snapshot.sessions.len().saturating_sub(1)); + } + ControlPayload::ScheduledJobs(Page { items, .. }) => { + let index = Self::screen_index(OperatorScreen::Roaming); + let identity = self + .snapshot + .schedules + .get(self.selected[index]) + .map(|item| item.job_id.clone()); + self.snapshot.schedules = items; + self.selected[index] = identity + .and_then(|id| { + self.snapshot + .schedules + .iter() + .position(|item| item.job_id == id) + }) + .unwrap_or(0) + .min(self.snapshot.schedules.len().saturating_sub(1)); + } + ControlPayload::PendingApprovals(Page { items, .. }) => { + let index = Self::screen_index(OperatorScreen::Approvals); + let identity = self + .snapshot + .approvals + .get(self.selected[index]) + .map(|item| item.approval_id); + self.snapshot.approvals = items; + self.selected[index] = identity + .and_then(|id| { + self.snapshot + .approvals + .iter() + .position(|item| item.approval_id == id) + }) + .unwrap_or(0) + .min(self.snapshot.approvals.len().saturating_sub(1)); + } + ControlPayload::AuditEvents(Page { items, .. }) => { + let index = Self::screen_index(OperatorScreen::Timeline); + let event_count = self + .snapshot + .timeline + .iter() + .filter(|event| self.filter.matches(event)) + .count(); + let query = self.filter.query.to_lowercase(); + let identity = self.selected[index] + .checked_sub(event_count) + .and_then(|selected| { + self.snapshot + .audit + .iter() + .filter(|audit| audit_matches(audit, &query)) + .nth(selected) + .map(|audit| audit.sequence) + }); + self.snapshot.audit = items; + if let Some(sequence) = identity + && let Some(position) = self + .snapshot + .audit + .iter() + .filter(|audit| audit_matches(audit, &query)) + .position(|audit| audit.sequence == sequence) + { + self.selected[index] = event_count.saturating_add(position); + } + } ControlPayload::ObservabilityEvents(Page { items, .. }) => { + let index = Self::screen_index(OperatorScreen::Timeline); + let event_count = self + .snapshot + .timeline + .iter() + .filter(|event| self.filter.matches(event)) + .count(); + let event_identity = (self.selected[index] < event_count) + .then(|| { + self.snapshot + .timeline + .iter() + .filter(|event| self.filter.matches(event)) + .nth(self.selected[index]) + .map(|event| event.event_id) + }) + .flatten(); + let query = self.filter.query.to_lowercase(); + let audit_identity = + self.selected[index] + .checked_sub(event_count) + .and_then(|selected| { + self.snapshot + .audit + .iter() + .filter(|audit| audit_matches(audit, &query)) + .nth(selected) + .map(|audit| audit.sequence) + }); for event in items { + if self + .snapshot + .timeline + .iter() + .any(|retained| retained.event_id == event.event_id) + { + continue; + } if self.snapshot.timeline.len() == MAX_TIMELINE { self.snapshot.timeline.pop_front(); } self.snapshot.timeline.push_back(event); } + if let Some(event_id) = event_identity + && let Some(position) = self + .snapshot + .timeline + .iter() + .filter(|event| self.filter.matches(event)) + .position(|event| event.event_id == event_id) + { + self.selected[index] = position; + } else if let Some(sequence) = audit_identity { + let event_count = self + .snapshot + .timeline + .iter() + .filter(|event| self.filter.matches(event)) + .count(); + if let Some(position) = self + .snapshot + .audit + .iter() + .filter(|audit| audit_matches(audit, &query)) + .position(|audit| audit.sequence == sequence) + { + self.selected[index] = event_count.saturating_add(position); + } + } } ControlPayload::Accepted { operation_id } => { self.status = format!("accepted {operation_id}"); @@ -892,109 +1167,892 @@ impl OperatorTui { } #[must_use] - #[allow(clippy::too_many_lines)] pub fn render(&self, options: TuiRenderOptions) -> String { - let width = usize::from(options.width.max(20)); - let height = usize::from(options.height.max(5)); - let mut lines = vec![ - format!( - "MetaCrate agent | {} | {}", - self.screen.title(), - self.status - ), - format!( - "[Tab] next [r] refresh [p] pause [u] resume [q] quit | gaps {}", - self.snapshot.gap_count - ), - ]; - match self.screen { - OperatorScreen::Overview => render_overview(&self.snapshot, &mut lines), - OperatorScreen::Sessions => { - lines.push("active sessions (metadata only)".into()); - for v in &self.snapshot.sessions { - lines.push(format!( - "{} {} {} turns={} bytes={}", - v.session_id, v.avatar_id, v.channel, v.turns, v.bytes - )); - } - } - OperatorScreen::QueuesAndBudgets => render_queues(&self.snapshot, &mut lines), - OperatorScreen::Roaming => { - for v in &self.snapshot.schedules { - lines.push(format!( - "{} {} enabled={} next={:?}", - v.job_id, v.kind, v.enabled, v.next_run_unix_millis - )); - } - } - OperatorScreen::Approvals => { - for v in &self.snapshot.approvals { - lines.push(format!( - "#{} tool={} principal={} expires={}", - v.approval_id, v.tool, v.principal, v.expires_unix_seconds - )); - } - } - OperatorScreen::Timeline => { - lines.push(format!("filter={:?}", self.filter)); - for v in self - .snapshot - .timeline - .iter() - .filter(|v| self.filter.matches(v)) - { - lines.push(format!( - "{} {:?} {} {} result={:?}", - v.unix_millis, v.severity, v.component, v.family, v.result_code - )); - } - } - OperatorScreen::Health => render_health(&self.snapshot, &mut lines), - OperatorScreen::Errors => { - for v in &self.errors { - lines.push(v.clone()); - } - for v in self - .snapshot - .timeline - .iter() - .filter(|v| v.severity == EventSeverity::Error) - { - lines.push(format!("{} {} {}", v.unix_millis, v.component, v.family)); - } - } - OperatorScreen::Diagnostics => { - lines.push("redacted diagnostic envelopes; content is never retained".into()); - for v in self - .snapshot - .timeline - .iter() - .filter(|v| v.family == "diagnostic_envelope") - { - lines.push(format!( - "{} {} flags={}", - v.unix_millis, - v.component, - v.redaction_flags.join(",") - )); - } - } - OperatorScreen::Preferences => { - if let Some(panel) = &self.preferences { - panel.render(&mut lines); - } else { - lines.push("preferences unavailable: no local config path".into()); - } - } + let started = Instant::now(); + let backend = TestBackend::new(options.width.max(1), options.height.max(1)); + let mut terminal = Terminal::new(backend).unwrap_or_else(|never| match never {}); + if terminal + .draw(|frame| self.draw(frame, options.color)) + .is_err() + { + return String::new(); } - let body_height = height.saturating_sub(1); - lines + self.frame_micros + .set(u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)); + buffer_text(terminal.backend().buffer()) + } + + fn draw(&self, frame: &mut Frame<'_>, color: bool) { + let now = Instant::now(); + if let Some(previous) = self.last_frame.replace(Some(now)) { + self.cadence_millis + .set(u64::try_from(now.duration_since(previous).as_millis()).unwrap_or(u64::MAX)); + } + let area = frame.area(); + if area.width < 32 || area.height < 9 { + self.draw_small(frame, area, color); + return; + } + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(3), + Constraint::Length(2), + ]) + .split(area); + self.draw_header(frame, chunks[0], color); + match self.screen { + OperatorScreen::Overview => self.draw_overview(frame, chunks[1], color), + OperatorScreen::Sessions => self.draw_sessions(frame, chunks[1], color), + OperatorScreen::QueuesAndBudgets => self.draw_performance(frame, chunks[1], color), + OperatorScreen::Roaming => self.draw_roaming(frame, chunks[1], color), + OperatorScreen::Approvals => self.draw_approvals(frame, chunks[1], color), + OperatorScreen::Timeline => self.draw_timeline(frame, chunks[1], color), + OperatorScreen::Health => self.draw_health(frame, chunks[1], color), + OperatorScreen::Errors => self.draw_errors(frame, chunks[1], color), + OperatorScreen::Diagnostics => self.draw_diagnostics(frame, chunks[1], color), + OperatorScreen::Preferences => self.draw_preferences(frame, chunks[1], color), + } + self.draw_footer(frame, chunks[2], color); + } + + fn draw_header(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let tabs = OperatorScreen::ALL + .iter() + .map(|screen| { + let title = if *screen == self.screen { + format!(">{}<", screen.title()) + } else { + screen.title().to_owned() + }; + Span::styled(title, focus_style(*screen == self.screen, color)) + }) + .collect::>(); + let health = self.snapshot.health.as_ref(); + let marker = if health.is_some_and(|view| view.ready) { + "[OK]" + } else { + "[DEGRADED]" + }; + let dropped = total_dropped(self.snapshot.metrics.as_ref()); + let title = Line::from(vec![ + Span::styled( + "MetaCrate grid agent ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled(marker, state_style(marker == "[OK]", color)), + Span::raw(format!( + " gaps={} dropped={} ", + self.snapshot.gap_count, dropped + )), + Span::raw(&self.status), + ]); + frame.render_widget( + Paragraph::new(vec![title, Line::from(tabs)]) + .block(panel_block("Operator", true, color)), + area, + ); + } + + fn draw_footer(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let filter = if self.filter.query.is_empty() { + String::new() + } else { + format!(" filter={:?}", self.filter.query) + }; + let help = format!( + "Tab/Shift-Tab screen arrows select / filter r refresh p/u pause/resume q quit{} | refresh={}ms frame={}us cadence={}ms", + filter, + self.refresh_millis, + self.frame_micros.get(), + self.cadence_millis.get() + ); + frame.render_widget( + Paragraph::new(help) + .style(if color { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }) + .wrap(Wrap { trim: true }), + area, + ); + } + + fn draw_small(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let degraded = self + .snapshot + .health + .as_ref() + .is_none_or(|health| !health.ready); + let text = vec![ + Line::styled( + format!("MetaCrate >{}<", self.screen.title()), + focus_style(true, color), + ), + Line::raw(format!( + "{} {} gaps={} dropped={}", + if degraded { "[DEGRADED]" } else { "[OK]" }, + self.status, + self.snapshot.gap_count, + total_dropped(self.snapshot.metrics.as_ref()) + )), + Line::raw(self.compact_screen_summary()), + Line::raw("Tab screen arrows select r refresh q quit"), + ]; + frame.render_widget(Paragraph::new(text).wrap(Wrap { trim: true }), area); + } + + fn compact_screen_summary(&self) -> String { + match self.screen { + OperatorScreen::Overview | OperatorScreen::Health => { + self.snapshot.runtime.as_ref().map_or_else( + || "runtime unavailable".into(), + |runtime| { + format!( + "grid={} region={} behavior={}", + runtime.grid_state, + runtime.region_name.as_deref().unwrap_or("unknown"), + runtime.behavior_mode + ) + }, + ) + } + OperatorScreen::Sessions => format!("sessions={}", self.snapshot.sessions.len()), + OperatorScreen::QueuesAndBudgets => self.snapshot.metrics.as_ref().map_or_else( + || "metrics unavailable".into(), + |metrics| { + format!( + "queue={}/{} tasks={}", + metrics.queue_depth, metrics.queue_capacity, metrics.active_tasks + ) + }, + ), + OperatorScreen::Roaming => format!("schedules={}", self.snapshot.schedules.len()), + OperatorScreen::Approvals => { + format!("pending approvals={}", self.snapshot.approvals.len()) + } + OperatorScreen::Timeline => format!( + "events={} audit={}", + self.snapshot.timeline.len(), + self.snapshot.audit.len() + ), + OperatorScreen::Errors => format!("local errors={}", self.errors.len()), + OperatorScreen::Diagnostics => format!( + "diagnostics={} (redacted)", + self.snapshot + .metrics + .as_ref() + .map_or(0, |m| m.diagnostic_entries) + ), + OperatorScreen::Preferences => self.preferences.as_ref().map_or_else( + || "preferences unavailable".into(), + |panel| panel.status.clone(), + ), + } + } + + fn draw_overview(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(columns[0]); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(columns[1]); + frame.render_widget( + Paragraph::new(runtime_lines(self.snapshot.runtime.as_ref())) + .block(panel_block("Runtime", true, color)) + .wrap(Wrap { trim: true }), + left[0], + ); + frame.render_widget( + Paragraph::new(work_lines(self.snapshot.runtime.as_ref())) + .block(panel_block("Build / visual work", false, color)) + .wrap(Wrap { trim: true }), + left[1], + ); + let active = self + .snapshot + .metrics + .as_ref() + .map_or(0, |metrics| metrics.active_tasks); + frame.render_widget( + Paragraph::new(vec![ + Line::raw(format!("sessions: {}", self.snapshot.sessions.len())), + Line::raw(format!("active tasks: {active}")), + Line::raw(format!( + "pending approvals: {}", + self.snapshot.approvals.len() + )), + Line::raw(format!("scheduled jobs: {}", self.snapshot.schedules.len())), + ]) + .block(panel_block("Active work", false, color)), + right[0], + ); + let failures = self + .snapshot + .timeline + .iter() + .rev() + .filter(|event| event.severity == EventSeverity::Error) + .take(4) + .map(|event| Line::raw(format!("[FAIL] {}:{}", event.component, event.family))) + .collect::>(); + frame.render_widget( + Paragraph::new(if failures.is_empty() { + vec![Line::raw("[OK] no recent failures")] + } else { + failures + }) + .block(panel_block("Pressure / failures", false, color)) + .wrap(Wrap { trim: true }), + right[1], + ); + } + + fn draw_sessions(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let rows = self + .snapshot + .sessions + .iter() + .enumerate() + .skip(self.selected_row().saturating_sub(5)) + .map(|(index, item)| { + styled_row( + index == self.selected_row(), + color, + vec![ + item.session_id.clone(), + item.avatar_id.clone(), + item.channel.clone(), + item.turns.to_string(), + item.bytes.to_string(), + ], + ) + }); + let detail = self.snapshot.sessions.get(self.selected_row()).map_or_else( + || vec![Line::raw("No active sessions")], + |item| { + vec![ + Line::raw(format!("session: {}", item.session_id)), + Line::raw(format!("avatar: {}", item.avatar_id)), + Line::raw(format!( + "created: {} last active: {}", + item.created_unix_millis, item.last_active_unix_millis + )), + Line::raw("[e] expire conversation (confirmation required)"), + ] + }, + ); + Self::draw_table_detail( + frame, + area, + color, + "Sessions", + ["Session", "Avatar", "Channel", "Turns", "Bytes"], + rows, + detail, + ); + } + + fn draw_roaming(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let rows = self + .snapshot + .schedules + .iter() + .enumerate() + .skip(self.selected_row().saturating_sub(5)) + .map(|(index, item)| { + styled_row( + index == self.selected_row(), + color, + vec![ + item.job_id.clone(), + item.kind.clone(), + state_word(item.enabled).into(), + item.next_run_unix_millis + .map_or_else(|| "—".into(), |value| value.to_string()), + ], + ) + }); + let detail = self + .snapshot + .schedules + .get(self.selected_row()) + .map_or_else( + || vec![Line::raw("No roaming schedules")], + |item| { + vec![ + Line::raw(format!("job: {}", item.job_id)), + Line::raw(format!("kind: {}", item.kind)), + Line::raw(format!("state: {}", state_word(item.enabled))), + Line::raw("[t] toggle schedule (confirmation required)"), + ] + }, + ); + Self::draw_table_detail( + frame, + area, + color, + "Roaming schedules", + ["Job", "Kind", "State", "Next"], + rows, + detail, + ); + } + + fn draw_approvals(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let rows = self + .snapshot + .approvals + .iter() + .enumerate() + .skip(self.selected_row().saturating_sub(5)) + .map(|(index, item)| { + styled_row( + index == self.selected_row(), + color, + vec![ + format!("#{}", item.approval_id), + item.tool.clone(), + item.principal.clone(), + item.expires_unix_seconds.to_string(), + ], + ) + }); + let detail = self + .snapshot + .approvals + .get(self.selected_row()) + .map_or_else( + || vec![Line::raw("[OK] no pending approvals")], + |item| { + vec![ + Line::raw(format!("tool: {}", item.tool)), + Line::raw(format!("principal: {}", item.principal)), + Line::raw(format!( + "movement={}mm inventory={} build-prims={}", + item.movement_millimeters, item.inventory_operations, item.build_prims + )), + Line::raw("[a] approve [d] deny (confirmation required)"), + ] + }, + ); + Self::draw_table_detail( + frame, + area, + color, + "Pending approvals", + ["ID", "Tool", "Principal", "Expires"], + rows, + detail, + ); + } + + fn draw_table_detail( + frame: &mut Frame<'_>, + area: Rect, + color: bool, + title: &str, + headings: [&str; N], + rows: impl IntoIterator>, + detail: Vec>, + ) { + let split = Layout::default() + .direction(if area.width >= 90 { + Direction::Horizontal + } else { + Direction::Vertical + }) + .constraints([Constraint::Percentage(67), Constraint::Percentage(33)]) + .split(area); + let widths = vec![Constraint::Ratio(1, u32::try_from(N).unwrap_or(1)); N]; + frame.render_widget( + Table::new(rows, widths) + .header(Row::new(headings).style(Style::default().add_modifier(Modifier::BOLD))) + .column_spacing(1) + .block(panel_block(title, true, color)), + split[0], + ); + frame.render_widget( + Paragraph::new(detail) + .block(panel_block("Selected detail", false, color)) + .wrap(Wrap { trim: true }), + split[1], + ); + } + + #[allow(clippy::too_many_lines)] + fn draw_performance(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let Some(metrics) = &self.snapshot.metrics else { + frame.render_widget( + Paragraph::new("Metrics unavailable").block(panel_block( + "Queues & performance", + true, + color, + )), + area, + ); + return; + }; + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(6), + Constraint::Length(9), + ]) + .split(area); + let gauges = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(33), + Constraint::Percentage(33), + Constraint::Percentage(34), + ]) + .split(rows[0]); + render_pressure_gauge( + frame, + gauges[0], + color, + "Work queue", + metrics.queue_depth, + metrics.queue_capacity, + ); + render_pressure_gauge( + frame, + gauges[1], + color, + "Rate limit", + metrics.rate_limit_used, + metrics.rate_limit_capacity, + ); + let control = self.snapshot.runtime.as_ref().map_or((0, 0), |runtime| { + ( + u64::try_from(runtime.control_queue_used).unwrap_or(u64::MAX), + u64::try_from(runtime.control_queue_capacity).unwrap_or(u64::MAX), + ) + }); + render_pressure_gauge( + frame, + gauges[2], + color, + "Control queue", + control.0, + control.1, + ); + let chart_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(rows[1]); + let charts = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + ]) + .split(chart_rows[0]); + self.draw_spark(frame, charts[0], color, "Queue %", |sample| { + sample.queue_percent + }); + self.draw_spark(frame, charts[1], color, "Rate %", |sample| { + sample.rate_percent + }); + self.draw_spark(frame, charts[2], color, "Active work", |sample| { + sample.active_work + }); + self.draw_spark(frame, charts[3], color, "Events / refresh", |sample| { + sample.event_delta + }); + let latency_charts = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(chart_rows[1]); + self.draw_spark( + frame, + latency_charts[0], + color, + "Inference avg ms", + |sample| sample.inference_average_millis, + ); + self.draw_spark(frame, latency_charts[1], color, "Tool avg ms", |sample| { + sample.tool_average_millis + }); + self.draw_spark(frame, latency_charts[2], color, "Dropped total", |sample| { + sample.dropped + }); + let infer_average = average_latency(&metrics.inference_latency); + let tool_average = average_latency(&metrics.tool_latency); + let text = vec![ + Line::raw(format!( + "inference latency current=unavailable average={}ms max={}ms samples={}", + infer_average, + metrics.inference_latency.maximum_millis, + metrics.inference_latency.samples + )), + Line::raw(format!( + "tool latency current=unavailable average={}ms max={}ms samples={}", + tool_average, metrics.tool_latency.maximum_millis, metrics.tool_latency.samples + )), + Line::raw(outcome_summary("inference", &metrics.inference_outcomes)), + Line::raw(outcome_summary("tool", &metrics.tool_outcomes)), + Line::raw(outcome_summary("policy", &metrics.policy_outcomes)), + Line::raw(format!( + "events={} dropped={} active sessions={} tasks={}", + metrics.recorded_events, + total_dropped(Some(metrics)), + metrics.active_sessions, + metrics.active_tasks + )), + ]; + frame.render_widget( + Paragraph::new(text) + .block(panel_block("Latency / outcomes / drops", false, color)) + .wrap(Wrap { trim: true }), + rows[2], + ); + } + + fn draw_spark( + &self, + frame: &mut Frame<'_>, + area: Rect, + color: bool, + title: &str, + value: impl Fn(&PerformanceSample) -> u64, + ) { + let data = self.history.iter().map(value).collect::>(); + frame.render_widget( + Sparkline::default() + .data(&data) + .style(if color { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }) + .block(panel_block(title, false, color)), + area, + ); + } + + #[allow(clippy::too_many_lines)] + fn draw_timeline(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let query = self.filter.query.to_lowercase(); + let events = self + .snapshot + .timeline + .iter() + .filter(|event| self.filter.matches(event)) + .collect::>(); + let audits = self + .snapshot + .audit + .iter() + .filter(|audit| audit_matches(audit, &query)) + .collect::>(); + let event_rows = events.iter().map(|event| { + vec![ + event.unix_millis.to_string(), + format!("{:?}", event.severity), + event.component.clone(), + event.family.clone(), + event.result_code.clone().unwrap_or_else(|| "—".into()), + event.reason_code.clone().unwrap_or_else(|| "—".into()), + ] + }); + let audit_rows = audits.iter().map(|audit| { + vec![ + audit.unix_millis.to_string(), + "Audit".into(), + audit.principal.clone(), + audit.operation.clone(), + audit.outcome.clone(), + audit + .authorization_id + .map_or_else(|| "—".into(), |id| format!("auth:{id}")), + ] + }); + let rows = event_rows + .chain(audit_rows) + .enumerate() + .skip(self.selected_row().saturating_sub(5)) + .map(|(index, values)| styled_row(index == self.selected_row(), color, values)); + let filter_title = if self.filter_editing { + format!("Timeline / audit SEARCH: {}_", self.filter.query) + } else { + format!("Timeline / audit filter={:?}", self.filter.query) + }; + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) + .split(area); + frame.render_widget( + Table::new( + rows, + [ + Constraint::Length(13), + Constraint::Length(8), + Constraint::Percentage(18), + Constraint::Percentage(25), + Constraint::Percentage(18), + Constraint::Percentage(18), + ], + ) + .header( + Row::new([ + "Time", + "Level", + "Component", + "Family/action", + "Result", + "Reason/auth", + ]) + .style(Style::default().add_modifier(Modifier::BOLD)), + ) + .column_spacing(1) + .block(panel_block(&filter_title, true, color)), + sections[0], + ); + let selected = self.selected_row(); + let detail = if let Some(event) = events.get(selected) { + vec![ + Line::raw(format!( + "event={} time={} severity={:?} origin={:?}", + event.event_id, event.unix_millis, event.severity, event.origin + )), + Line::raw(format!( + "component={} family={} duration={:?}ms retries={}", + event.component, event.family, event.duration_millis, event.retry_count + )), + Line::raw(format!( + "avatar={} session={} action={} request={}", + option_text(event.correlation.avatar_id.as_ref()), + option_text(event.correlation.session_id.as_ref()), + option_text(event.correlation.action_id.as_ref()), + option_text(event.correlation.request_id.as_ref()) + )), + Line::raw(format!( + "result={} reason={} redaction={}", + event.result_code.as_deref().unwrap_or("—"), + event.reason_code.as_deref().unwrap_or("—"), + event.redaction_flags.join(",") + )), + ] + } else if let Some(audit) = audits.get(selected.saturating_sub(events.len())) { + vec![ + Line::raw(format!( + "audit sequence={} time={}", + audit.sequence, audit.unix_millis + )), + Line::raw(format!( + "principal={} operation={} outcome={}", + audit.principal, audit.operation, audit.outcome + )), + Line::raw(format!( + "authorization={}", + audit + .authorization_id + .map_or_else(|| "—".into(), |id| id.to_string()) + )), + ] + } else { + vec![Line::raw("No matching event or audit record")] + }; + frame.render_widget( + Paragraph::new(detail) + .block(panel_block("Selected detail", false, color)) + .wrap(Wrap { trim: true }), + sections[1], + ); + } + + fn draw_health(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + let health = self.snapshot.health.as_ref().map_or_else( + || vec![Line::raw("[UNAVAILABLE] health snapshot")], + |view| { + vec![ + Line::raw(format!( + "{} service={}", + if view.ready { "[READY]" } else { "[NOT READY]" }, + view.service_state + )), + Line::raw(format!( + "uptime={}s protocol={}", + view.uptime_seconds, view.protocol_version + )), + ] + }, + ); + frame.render_widget( + Paragraph::new(health).block(panel_block("Health", true, color)), + columns[0], + ); + let metrics = self.snapshot.metrics.as_ref().map_or_else( + || vec![Line::raw("[UNAVAILABLE] metrics snapshot")], + |view| { + vec![ + Line::raw(format!( + "ready={} reconnects={}", + view.ready, view.reconnects + )), + Line::raw(format!( + "sessions={} tasks={}", + view.active_sessions, view.active_tasks + )), + Line::raw(format!( + "events={} diagnostics={}", + view.recorded_events, view.diagnostic_entries + )), + Line::raw(format!( + "[DROPPED] ring={} subscriber={} journal={}", + view.dropped_ring_events, + view.dropped_subscriber_events, + view.dropped_journal_events + )), + ] + }, + ); + frame.render_widget( + Paragraph::new(metrics).block(panel_block("Metrics", false, color)), + columns[1], + ); + } + + fn draw_errors(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let local = self + .errors + .iter() + .map(|message| Line::raw(format!("[LOCAL] {message}"))); + let events = self + .snapshot + .timeline + .iter() + .filter(|event| event.severity == EventSeverity::Error) + .map(|event| { + Line::raw(format!( + "[FAIL] {} {}:{} result={} reason={}", + event.unix_millis, + event.component, + event.family, + event.result_code.as_deref().unwrap_or("—"), + event.reason_code.as_deref().unwrap_or("—") + )) + }); + let lines = local.chain(events).collect::>(); + frame.render_widget( + Paragraph::new(if lines.is_empty() { + vec![Line::raw("[OK] no recent errors")] + } else { + lines + }) + .block(panel_block("Recent errors", true, color)) + .wrap(Wrap { trim: true }) + .scroll((u16::try_from(self.scroll).unwrap_or(u16::MAX), 0)), + area, + ); + } + + fn draw_diagnostics(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let mut lines = vec![Line::raw( + "Only redacted diagnostic envelope metadata is retained and displayed.", + )]; + lines.extend( + self.snapshot + .timeline + .iter() + .filter(|event| event.family == "diagnostic_envelope") + .map(|event| { + Line::raw(format!( + "{} {} duration={:?}ms retries={} result={} reason={} flags={}", + event.unix_millis, + event.component, + event.duration_millis, + event.retry_count, + event.result_code.as_deref().unwrap_or("—"), + event.reason_code.as_deref().unwrap_or("—"), + event.redaction_flags.join(",") + )) + }), + ); + frame.render_widget( + Paragraph::new(lines) + .block(panel_block("Diagnostics (redacted)", true, color)) + .wrap(Wrap { trim: true }) + .scroll((u16::try_from(self.scroll).unwrap_or(u16::MAX), 0)), + area, + ); + } + + fn draw_preferences(&self, frame: &mut Frame<'_>, area: Rect, color: bool) { + let Some(panel) = &self.preferences else { + frame.render_widget( + Paragraph::new("Preferences unavailable: no local configuration path") + .block(panel_block("Preferences", true, color)), + area, + ); + return; + }; + let summary = panel.summary(); + let values = preference_values(&summary); + let rows = values .into_iter() - .skip(self.scroll.min(body_height)) - .take(body_height) - .map(|line| truncate_width(&line, width)) - .collect::>() - .join("\n") + .enumerate() + .skip(panel.selected.saturating_sub(4)) + .map(|(index, (field, value))| { + styled_row( + index == panel.selected, + color, + vec![format!("{field} {value}")], + ) + }); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(4)]) + .split(area); + frame.render_widget( + Table::new(rows, [Constraint::Min(10)]) + .block(panel_block("Preferences", true, color)) + .column_spacing(1), + chunks[0], + ); + let input = if panel.editing { + if panel.selected_field().secret() { + "•".repeat(panel.buffer.chars().count()) + } else { + panel.buffer.clone() + } + } else { + "—".into() + }; + frame.render_widget( + Paragraph::new(vec![ + Line::raw(format!("file={} input={input}", panel.path.display())), + Line::raw(format!( + "{} | Up/Down field Enter edit/commit Esc cancel w save", + panel.status + )), + ]) + .block(panel_block("Edit / save status", panel.editing, color)) + .wrap(Wrap { trim: true }), + chunks[1], + ); } } @@ -1004,183 +2062,362 @@ fn page() -> PageRequest { limit: PAGE_LIMIT, } } -fn render_overview(s: &OperatorSnapshot, out: &mut Vec) { - if let Some(v) = &s.health { - out.push(format!( - "service={} ready={} uptime={}s", - v.service_state, v.ready, v.uptime_seconds - )); - } - if let Some(v) = &s.runtime { - out.push(format!( - "grid={} generation={} connected={} ready={}", - v.grid_state, v.generation, v.transport_connected, v.agent_ready - )); - out.push(format!( - "region={} pose={:?} behavior={}", - v.region_name.as_deref().unwrap_or("unknown"), - v.position, - v.behavior_mode - )); - if v.active_build_transaction.is_some() || !v.build_orphan_ids.is_empty() { - out.push(format!( - "build={} progress={} recoverable-orphans={}", - v.active_build_transaction.as_deref().unwrap_or("idle"), - v.build_progress.as_deref().unwrap_or("none"), - v.build_orphan_ids.len() - )); - } - if v.active_visual_capture.is_some() || v.visual_image_sha256.is_some() { - out.push(format!( - "vision={} progress={} last-image-sha256={}", - v.active_visual_capture.as_deref().unwrap_or("idle"), - v.visual_progress.as_deref().unwrap_or("none"), - v.visual_image_sha256.as_deref().unwrap_or("none") - )); - } - } -} -fn render_queues(s: &OperatorSnapshot, out: &mut Vec) { - if let Some(v) = &s.runtime { - out.push(format!( - "control queue {}/{}", - v.control_queue_used, v.control_queue_capacity - )); - out.push(format!( - "tool calls={} movement-mm={}", - v.budget_tool_calls_used, v.budget_movement_millimeters_used - )); - } - if let Some(v) = &s.metrics { - out.push(format!( - "work queue {}/{} tasks={} rate {}/{}", - v.queue_depth, - v.queue_capacity, - v.active_tasks, - v.rate_limit_used, - v.rate_limit_capacity - )); - } -} -fn render_health(s: &OperatorSnapshot, out: &mut Vec) { - render_overview(s, out); - if let Some(v) = &s.metrics { - out.push(format!( - "sessions={} reconnects={} events={}", - v.active_sessions, v.reconnects, v.recorded_events - )); - out.push(format!( - "dropped ring={} subscriber={} journal={}", - v.dropped_ring_events, v.dropped_subscriber_events, v.dropped_journal_events - )); + +fn focus_style(focused: bool, color: bool) -> Style { + let style = if focused { + Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::default() + }; + if color && focused { + style.fg(Color::Cyan) + } else { + style } } -fn truncate_width(value: &str, width: usize) -> String { - if UnicodeWidthStr::width(value) <= width { - return value.to_owned(); +fn state_style(healthy: bool, color: bool) -> Style { + let style = if healthy { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED) + }; + if color { + style.fg(if healthy { Color::Green } else { Color::Red }) + } else { + style } - let mut result = String::new(); - let mut used = 0; - for ch in value.chars() { - let next = ch.width().unwrap_or(0); - if used + next + 1 > width { - break; - } - result.push(ch); - used += next; - } - result.push('…'); - result } -struct TerminalGuard; +fn panel_block(title: &str, focused: bool, color: bool) -> Block<'_> { + Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .title_style(focus_style(focused, color)) +} + +fn styled_row(selected: bool, color: bool, mut values: Vec) -> Row<'static> { + if selected && let Some(first) = values.first_mut() { + first.insert_str(0, "> "); + } + Row::new(values.into_iter().map(Cell::from).collect::>()) + .style(focus_style(selected, color)) +} + +fn state_word(enabled: bool) -> &'static str { + if enabled { "[ENABLED]" } else { "[DISABLED]" } +} + +fn runtime_lines(runtime: Option<&RuntimeView>) -> Vec> { + runtime.map_or_else( + || vec![Line::raw("[UNAVAILABLE] runtime snapshot")], + |runtime| { + vec![ + Line::raw(format!( + "grid: {} generation={}", + runtime.grid_state, runtime.generation + )), + Line::raw(format!( + "transport: {} agent: {}", + if runtime.transport_connected { + "[CONNECTED]" + } else { + "[DISCONNECTED]" + }, + if runtime.agent_ready { + "[READY]" + } else { + "[NOT READY]" + } + )), + Line::raw(format!( + "region: {}", + runtime.region_name.as_deref().unwrap_or("unknown") + )), + Line::raw(format!("pose: {:?}", runtime.position)), + Line::raw(format!("behavior: {}", runtime.behavior_mode)), + ] + }, + ) +} + +fn work_lines(runtime: Option<&RuntimeView>) -> Vec> { + runtime.map_or_else( + || vec![Line::raw("[UNAVAILABLE] work state")], + |runtime| { + vec![ + Line::raw(format!( + "build: {}", + runtime + .active_build_transaction + .as_deref() + .unwrap_or("idle") + )), + Line::raw(format!( + "build progress: {}", + runtime.build_progress.as_deref().unwrap_or("none") + )), + Line::raw(format!( + "recoverable orphans: {}", + runtime.build_orphan_ids.len() + )), + Line::raw(format!( + "visual capture: {}", + runtime.active_visual_capture.as_deref().unwrap_or("idle") + )), + Line::raw(format!( + "visual progress: {}", + runtime.visual_progress.as_deref().unwrap_or("none") + )), + Line::raw(format!( + "last image sha256: {}", + runtime.visual_image_sha256.as_deref().unwrap_or("none") + )), + ] + }, + ) +} + +fn total_dropped(metrics: Option<&MetricsSnapshot>) -> u64 { + metrics.map_or(0, |metrics| { + metrics + .dropped_ring_events + .saturating_add(metrics.dropped_subscriber_events) + .saturating_add(metrics.dropped_journal_events) + }) +} + +fn average_latency(latency: &crate::observability::LatencyMetrics) -> u64 { + latency + .total_millis + .checked_div(latency.samples) + .unwrap_or(0) +} + +fn outcome_summary(name: &str, outcome: &crate::observability::OutcomeMetrics) -> String { + format!( + "{name} outcomes allowed={} denied={} approval={} completed={} failed={} cancelled={}", + outcome.allowed, + outcome.denied, + outcome.approval_required, + outcome.completed, + outcome.failed, + outcome.cancelled + ) +} + +fn render_pressure_gauge( + frame: &mut Frame<'_>, + area: Rect, + color: bool, + title: &str, + used: u64, + capacity: u64, +) { + let percent = u16::try_from( + used.saturating_mul(100) + .checked_div(capacity) + .unwrap_or(0) + .min(100), + ) + .unwrap_or(100); + let ratio = f64::from(percent) / 100.0; + let label = format!( + "{} {used}/{capacity} {percent}%", + if percent >= 90 { + "[SATURATED]" + } else if percent >= 70 { + "[PRESSURE]" + } else { + "[OK]" + } + ); + let style = if color { + Style::default().fg(if percent >= 90 { + Color::Red + } else if percent >= 70 { + Color::Yellow + } else { + Color::Green + }) + } else { + Style::default() + }; + frame.render_widget( + Gauge::default() + .block(panel_block(title, false, color)) + .gauge_style(style) + .ratio(ratio) + .label(label), + area, + ); +} + +fn audit_matches(audit: &AuditEventView, query: &str) -> bool { + query.is_empty() + || audit.unix_millis.to_string().contains(query) + || audit.principal.to_lowercase().contains(query) + || audit.operation.to_lowercase().contains(query) + || audit.outcome.to_lowercase().contains(query) + || audit + .authorization_id + .is_some_and(|id| id.to_string().contains(query)) +} + +fn option_text(value: Option<&String>) -> &str { + value.map_or("—", String::as_str) +} + +fn preference_values(summary: &PreferencesSummary) -> Vec<(String, String)> { + vec![ + ("mode:".into(), format!("{:?}", summary.mode)), + ( + "LLM endpoint:".into(), + summary + .llm_endpoint + .clone() + .unwrap_or_else(|| "".into()), + ), + ( + "LLM API key:".into(), + configured(summary.llm_api_key_configured).into(), + ), + ( + "LLM model:".into(), + summary + .llm_model + .clone() + .unwrap_or_else(|| "".into()), + ), + ( + "grid login URL:".into(), + summary + .grid_login_url + .clone() + .unwrap_or_else(|| "".into()), + ), + ( + "grid avatar:".into(), + summary + .grid_avatar_name + .clone() + .unwrap_or_else(|| "".into()), + ), + ( + "grid password:".into(), + configured(summary.grid_password_configured).into(), + ), + ( + "privileged users:".into(), + summary.privileged_users.join(", "), + ), + ] +} + +fn buffer_text(buffer: &Buffer) -> String { + let area = buffer.area; + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n") + .trim_end() + .to_owned() +} + +struct TerminalGuard { + terminal: Terminal>, +} + impl TerminalGuard { fn enter() -> io::Result { terminal::enable_raw_mode()?; - execute!(io::stdout(), terminal::EnterAlternateScreen, cursor::Hide)?; - Ok(Self) + let mut stdout = io::stdout(); + if let Err(error) = execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide) { + let _ = terminal::disable_raw_mode(); + return Err(error); + } + match Terminal::new(CrosstermBackend::new(stdout)) { + Ok(mut terminal) => { + if let Err(error) = terminal.clear() { + let _ = execute!( + terminal.backend_mut(), + cursor::Show, + terminal::LeaveAlternateScreen + ); + let _ = terminal::disable_raw_mode(); + Err(error) + } else { + Ok(Self { terminal }) + } + } + Err(error) => { + let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + Err(error) + } + } + } + + fn draw(&mut self, app: &OperatorTui, color: bool) -> Result<(), TuiError> { + let started = Instant::now(); + self.terminal + .draw(|frame| app.draw(frame, color)) + .map_err(|error| TuiError(error.to_string()))?; + app.frame_micros + .set(u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)); + Ok(()) } } + impl Drop for TerminalGuard { fn drop(&mut self) { - let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen); + let _ = self.terminal.show_cursor(); + let _ = execute!( + self.terminal.backend_mut(), + cursor::Show, + terminal::LeaveAlternateScreen + ); let _ = terminal::disable_raw_mode(); } } -/// Opens the local configuration preferences panel without starting or -/// connecting to the agent service. Secret fields are rendered as bullets. -pub fn run_preferences_terminal(path: PathBuf, _color: bool) -> Result<(), TuiError> { - let _guard = TerminalGuard::enter().map_err(|error| TuiError(error.to_string()))?; - let mut panel = PreferencesPanel::open(path)?; +/// Opens the same Ratatui preferences form without starting or connecting to a service. +pub fn run_preferences_terminal(path: PathBuf, color: bool) -> Result<(), TuiError> { + let mut terminal = TerminalGuard::enter().map_err(|error| TuiError(error.to_string()))?; + let mut app = OperatorTui::with_preferences(path)?; + app.screen = OperatorScreen::Preferences; + app.status = "standalone preferences".into(); loop { - let (width, height) = terminal::size().unwrap_or((80, 24)); - let mut lines = vec!["MetaCrate agent | Preferences".to_owned()]; - panel.render(&mut lines); - let text = lines - .into_iter() - .take(usize::from(height.max(5))) - .map(|line| truncate_width(&line, usize::from(width.max(20)))) - .collect::>() - .join("\n"); - let mut stdout = io::stdout(); - queue!( - stdout, - cursor::MoveTo(0, 0), - terminal::Clear(terminal::ClearType::All), - style::Print(text) - ) - .map_err(|error| TuiError(error.to_string()))?; - stdout - .flush() - .map_err(|error| TuiError(error.to_string()))?; - let event::Event::Key(key) = event::read().map_err(|error| TuiError(error.to_string()))? - else { + terminal.draw(&app, color)?; + let value = event::read().map_err(|error| TuiError(error.to_string()))?; + let Some(input) = map_event(&app, &value) else { continue; }; - if key.kind != event::KeyEventKind::Press { - continue; - } - if panel.editing { - match key.code { - event::KeyCode::Enter => panel.commit_edit()?, - event::KeyCode::Backspace => panel.backspace(), - event::KeyCode::Esc => panel.cancel_edit(), - event::KeyCode::Char(value) => panel.push(value), - _ => {} - } - } else { - match key.code { - event::KeyCode::Up => panel.previous_field(), - event::KeyCode::Down => panel.next_field(), - event::KeyCode::Enter => panel.begin_edit(), - event::KeyCode::Char('w') => panel.save()?, - event::KeyCode::Char('q') | event::KeyCode::Esc => return Ok(()), - event::KeyCode::Char('c') - if key.modifiers.contains(event::KeyModifiers::CONTROL) => - { - return Ok(()); - } - _ => {} - } + match app.reduce(input) { + TuiAction::Exit => return Ok(()), + TuiAction::None | TuiAction::Refresh | TuiAction::Execute(_) => {} } } } -/// Runs an event-driven keyboard UI. Terminal state is restored on normal -/// return, errors, Ctrl-C, and unwinding because restoration is guard-owned. +/// Runs an event-driven keyboard UI. Guard ownership restores the terminal on +/// normal exit, errors, Ctrl-C, and unwinding. pub async fn run_terminal(client: Arc, color: bool) -> Result<(), TuiError> { run_terminal_with_preferences(client, color, None).await } -#[allow(clippy::too_many_lines)] pub async fn run_terminal_with_preferences( client: Arc, color: bool, preferences_path: Option, ) -> Result<(), TuiError> { - let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?; + let mut terminal = TerminalGuard::enter().map_err(|error| TuiError(error.to_string()))?; let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64); std::thread::spawn(move || { while let Ok(value) = event::read() { @@ -1194,102 +2431,280 @@ pub async fn run_terminal_with_preferences( } else { OperatorTui::default() }; - app.refresh(client.as_ref()).await?; + let (work_tx, work_rx) = tokio::sync::mpsc::channel(4); + let (result_tx, mut result_rx) = tokio::sync::mpsc::channel(1); + let worker = tokio::spawn(tui_worker(client, work_rx, result_tx)); + work_tx + .send(WorkerRequest::Refresh) + .await + .map_err(|_| TuiError("TUI refresh worker stopped".into()))?; let mut refresh = tokio::time::interval(Duration::from_secs(1)); refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); refresh.tick().await; loop { - let (width, height) = terminal::size().unwrap_or((80, 24)); - let text = app.render(TuiRenderOptions { - width, - height, - color, - }); - let mut stdout = io::stdout(); - queue!( - stdout, - cursor::MoveTo(0, 0), - terminal::Clear(terminal::ClearType::All), - style::Print(text) - ) - .map_err(|e| TuiError(e.to_string()))?; - stdout.flush().map_err(|e| TuiError(e.to_string()))?; - let value = tokio::select! { + terminal.draw(&app, color)?; + tokio::select! { value = input_rx.recv() => { - let Some(value) = value else { return Ok(()); }; - value + let Some(value) = value else { worker.abort(); return Ok(()) }; + let Some(input) = map_event(&app, &value) else { continue }; + match app.reduce(input) { + TuiAction::None => {} + TuiAction::Exit => { worker.abort(); return Ok(()) } + TuiAction::Refresh => { + let _ = work_tx.try_send(WorkerRequest::Refresh); + app.status = "refresh requested".into(); + } + TuiAction::Execute(command) => { + work_tx.try_send(WorkerRequest::Command(command)).map_err(|_| TuiError("control worker busy".into()))?; + app.status = "control command queued".into(); + } + } } _ = refresh.tick() => { - // One coalesced snapshot replaces any number of service changes; - // skipped ticks prevent a slow service from building refresh work. - if let Err(error) = app.refresh(client.as_ref()).await { - app.record_error(error.to_string()); - } - continue; + let _ = work_tx.try_send(WorkerRequest::Refresh); } - }; - let input = match value { - event::Event::Resize(_, _) => TuiInput::Refresh, - event::Event::Key(key) - if key.kind == event::KeyEventKind::Press - && app.screen == OperatorScreen::Preferences - && app.preferences.as_ref().is_some_and(|panel| panel.editing) => - { - match key.code { - event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit, - event::KeyCode::Backspace => TuiInput::PreferenceBackspace, - event::KeyCode::Esc => TuiInput::PreferenceCancel, - event::KeyCode::Char(value) => TuiInput::PreferenceCharacter(value), - _ => continue, + result = result_rx.recv() => { + let Some(result) = result else { worker.abort(); return Err(TuiError("TUI refresh worker stopped".into())) }; + match result { + Ok(result) => { + for payload in result.payloads { app.apply(payload); } + app.refresh_millis = result.elapsed_millis; + app.sample_performance(); + app.status = result.status; + } + Err(error) => { + app.record_error(error.to_string()); + app.status = "[DEGRADED] refresh/control request failed".into(); + } } } - event::Event::Key(key) - if key.kind == event::KeyEventKind::Press - && app.screen == OperatorScreen::Preferences => - { - match key.code { - event::KeyCode::Up => TuiInput::PreferencePrevious, - event::KeyCode::Down => TuiInput::PreferenceNext, - event::KeyCode::Enter => TuiInput::PreferenceEditOrCommit, - event::KeyCode::Char('w') => TuiInput::PreferenceSave, - event::KeyCode::Esc => TuiInput::PreferenceCancel, - event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen, - event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen, - event::KeyCode::Char('q') => TuiInput::Quit, - _ => continue, - } - } - event::Event::Key(key) if key.kind == event::KeyEventKind::Press => match key.code { - event::KeyCode::Tab | event::KeyCode::Right => TuiInput::NextScreen, - event::KeyCode::BackTab | event::KeyCode::Left => TuiInput::PreviousScreen, - event::KeyCode::Up => TuiInput::ScrollUp, - event::KeyCode::Down => TuiInput::ScrollDown, - event::KeyCode::Char('r') => TuiInput::Refresh, - event::KeyCode::Char( - key @ ('p' | 'u' | 'f' | 'a' | 'd' | 'x' | 'e' | 't' | 's'), - ) => { - let Some(command) = app.command_shortcut(key) else { - continue; - }; - TuiInput::Command(command) - } - event::KeyCode::Char('y') => TuiInput::Confirm, - event::KeyCode::Char('n') | event::KeyCode::Esc => TuiInput::Reject, - event::KeyCode::Char('q') => TuiInput::Quit, - event::KeyCode::Char('c') - if key.modifiers.contains(event::KeyModifiers::CONTROL) => - { - TuiInput::Quit - } - _ => continue, - }, - _ => continue, - }; - match app.reduce(input) { - TuiAction::None => {} - TuiAction::Exit => return Ok(()), - TuiAction::Refresh => app.refresh(client.as_ref()).await?, - TuiAction::Execute(command) => app.execute(client.as_ref(), command).await?, } } } + +enum WorkerRequest { + Refresh, + Command(OperatorCommand), +} + +struct WorkerResult { + payloads: Vec, + elapsed_millis: u64, + status: String, +} + +async fn tui_worker( + client: Arc, + mut requests: tokio::sync::mpsc::Receiver, + results: tokio::sync::mpsc::Sender>, +) { + let sequence = AtomicU64::new(1); + while let Some(request) = requests.recv().await { + let started = Instant::now(); + let result = + worker_cycle(client.as_ref(), &sequence, request) + .await + .map(|(payloads, status)| WorkerResult { + payloads, + elapsed_millis: u64::try_from(started.elapsed().as_millis()) + .unwrap_or(u64::MAX), + status, + }); + if results.send(result).await.is_err() { + return; + } + } +} + +async fn worker_cycle( + client: &dyn TuiTransport, + sequence: &AtomicU64, + request: WorkerRequest, +) -> Result<(Vec, String), TuiError> { + let mut payloads = Vec::with_capacity(9); + let status = match request { + WorkerRequest::Refresh => "connected".to_owned(), + WorkerRequest::Command(command) => { + payloads.push(worker_send(client, sequence, command.request()).await?); + "command completed".to_owned() + } + }; + for request in [ + ControlRequest::Health, + ControlRequest::Runtime, + ControlRequest::Metrics, + ControlRequest::ListSessions { page: page() }, + ControlRequest::ListScheduledJobs { page: page() }, + ControlRequest::ListPendingApprovals { page: page() }, + ControlRequest::ListAuditEvents { page: page() }, + ControlRequest::ListObservabilityEvents { page: page() }, + ] { + payloads.push(worker_send(client, sequence, request).await?); + } + Ok((payloads, status)) +} + +async fn worker_send( + client: &dyn TuiTransport, + sequence: &AtomicU64, + request: ControlRequest, +) -> Result { + let id = sequence.fetch_add(1, Ordering::Relaxed); + let response = client + .request(ControlRequestEnvelope { + version: CONTROL_PROTOCOL_VERSION, + request_id: format!("tui-worker-{id}"), + request, + }) + .await?; + response + .result + .map_err(|error| TuiError(format!("{:?}: {}", error.code, error.message))) +} + +fn map_event(app: &OperatorTui, value: &event::Event) -> Option { + let event::Event::Key(key) = value else { + return matches!(value, event::Event::Resize(_, _)).then_some(TuiInput::Refresh); + }; + if key.kind != event::KeyEventKind::Press { + return None; + } + if key.code == event::KeyCode::Char('c') && key.modifiers.contains(event::KeyModifiers::CONTROL) + { + return Some(TuiInput::Quit); + } + if app.filter_editing { + return match key.code { + event::KeyCode::Enter => Some(TuiInput::FilterCommit), + event::KeyCode::Backspace => Some(TuiInput::FilterBackspace), + event::KeyCode::Esc => Some(TuiInput::FilterCancel), + event::KeyCode::Char(value) => Some(TuiInput::FilterCharacter(value)), + _ => None, + }; + } + if app.screen == OperatorScreen::Preferences + && app.preferences.as_ref().is_some_and(|panel| panel.editing) + { + return match key.code { + event::KeyCode::Enter => Some(TuiInput::PreferenceEditOrCommit), + event::KeyCode::Backspace => Some(TuiInput::PreferenceBackspace), + event::KeyCode::Esc => Some(TuiInput::PreferenceCancel), + event::KeyCode::Char(value) => Some(TuiInput::PreferenceCharacter(value)), + _ => None, + }; + } + if app.screen == OperatorScreen::Preferences { + return match key.code { + event::KeyCode::Up => Some(TuiInput::PreferencePrevious), + event::KeyCode::Down => Some(TuiInput::PreferenceNext), + event::KeyCode::Enter => Some(TuiInput::PreferenceEditOrCommit), + event::KeyCode::Char('w') => Some(TuiInput::PreferenceSave), + event::KeyCode::Esc => Some(TuiInput::PreferenceCancel), + event::KeyCode::Tab | event::KeyCode::Right => Some(TuiInput::NextScreen), + event::KeyCode::BackTab | event::KeyCode::Left => Some(TuiInput::PreviousScreen), + event::KeyCode::Char('q') => Some(TuiInput::Quit), + _ => None, + }; + } + match key.code { + event::KeyCode::Tab | event::KeyCode::Right => Some(TuiInput::NextScreen), + event::KeyCode::BackTab | event::KeyCode::Left => Some(TuiInput::PreviousScreen), + event::KeyCode::Up => Some(TuiInput::ScrollUp), + event::KeyCode::Down => Some(TuiInput::ScrollDown), + event::KeyCode::Char('/') if app.screen == OperatorScreen::Timeline => { + Some(TuiInput::BeginFilter) + } + event::KeyCode::Char('r') => Some(TuiInput::Refresh), + event::KeyCode::Char(key @ ('p' | 'u' | 'f' | 'a' | 'd' | 'x' | 'e' | 't' | 's')) => { + app.command_shortcut(key).map(TuiInput::Command) + } + event::KeyCode::Char('y') => Some(TuiInput::Confirm), + event::KeyCode::Char('n') | event::KeyCode::Esc => Some(TuiInput::Reject), + event::KeyCode::Char('q') => Some(TuiInput::Quit), + _ => None, + } +} + +#[cfg(test)] +mod ratatui_private_tests { + use super::*; + + struct SlowTransport; + + impl TuiTransport for SlowTransport { + fn request(&self, _: ControlRequestEnvelope) -> TuiFuture<'_> { + Box::pin(async { + tokio::time::sleep(Duration::from_mins(1)).await; + Err(TuiError("delayed".into())) + }) + } + } + + #[tokio::test] + async fn delayed_refresh_does_not_block_keyboard_reduction() { + let (work_tx, work_rx) = tokio::sync::mpsc::channel(4); + let (result_tx, mut result_rx) = tokio::sync::mpsc::channel(1); + let worker = tokio::spawn(tui_worker(Arc::new(SlowTransport), work_rx, result_tx)); + work_tx.send(WorkerRequest::Refresh).await.expect("worker"); + tokio::task::yield_now().await; + assert!(matches!( + result_rx.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) + )); + let mut app = OperatorTui::default(); + let key = event::Event::Key(event::KeyEvent::new( + event::KeyCode::Char('q'), + event::KeyModifiers::NONE, + )); + let input = map_event(&app, &key).expect("keyboard input"); + assert_eq!(app.reduce(input), TuiAction::Exit); + worker.abort(); + } + + #[test] + fn selected_identity_survives_reordered_refresh_data() { + let session = |id: &str| SessionMetadataView { + session_id: id.into(), + avatar_id: format!("avatar-{id}"), + channel: "direct_im".into(), + created_unix_millis: 1, + last_active_unix_millis: 2, + turns: 3, + bytes: 4, + }; + let mut app = OperatorTui { + screen: OperatorScreen::Sessions, + ..OperatorTui::default() + }; + app.apply(ControlPayload::Sessions(Page { + items: vec![session("a"), session("b")], + next_cursor: None, + })); + assert_eq!(app.reduce(TuiInput::ScrollDown), TuiAction::None); + app.apply(ControlPayload::Sessions(Page { + items: vec![session("b"), session("a")], + next_cursor: None, + })); + assert!(matches!( + app.command_shortcut('e'), + Some(OperatorCommand::ExpireSession { avatar_id, .. }) if avatar_id == "avatar-b" + )); + + app.apply(ControlPayload::Sessions(Page { + items: (0..20).map(|index| session(&index.to_string())).collect(), + next_cursor: None, + })); + for _ in 0..12 { + let _ = app.reduce(TuiInput::ScrollDown); + } + let rendered = app.render(TuiRenderOptions { + width: 100, + height: 20, + color: false, + }); + assert!( + rendered.contains("> 12"), + "selected row must remain visible" + ); + } +} diff --git a/crates/metacrate-grid-agent/src/tui_tests.rs b/crates/metacrate-grid-agent/src/tui_tests.rs index 4ce595d..290fc72 100644 --- a/crates/metacrate-grid-agent/src/tui_tests.rs +++ b/crates/metacrate-grid-agent/src/tui_tests.rs @@ -75,7 +75,7 @@ impl TuiTransport for FakeTransport { } ControlRequest::ListObservabilityEvents { .. } => { ControlPayload::ObservabilityEvents(crate::Page { - items: Vec::new(), + items: vec![structured_event()], next_cursor: None, }) } @@ -91,6 +91,31 @@ impl TuiTransport for FakeTransport { } } +fn structured_event() -> crate::StructuredEvent { + serde_json::from_value(serde_json::json!({ + "schema_version": 1, + "event_id": 7, + "unix_millis": 1234, + "severity": "warning", + "component": "control_queue", + "family": "queue_pressure", + "correlation": { + "avatar_id": "avatar-7", + "session_id": "session-7", + "request_id": null, + "action_id": "action-7" + }, + "origin": "service", + "duration_millis": 19, + "retry_count": 1, + "result_code": "delayed", + "reason_code": "capacity", + "redaction_flags": [], + "fields": {} + })) + .expect("event fixture") +} + fn empty_metrics() -> crate::MetricsSnapshot { serde_json::from_value(serde_json::json!({ "ready":true,"reconnects":0,"active_sessions":0,"active_tasks":0, @@ -116,6 +141,96 @@ async fn snapshot_refresh_is_transport_neutral_and_bounded() { "running" ); assert_eq!(transport.0.lock().expect("requests").len(), 8); + app.refresh(&transport).await.expect("stable refresh"); + assert_eq!( + app.snapshot.timeline.len(), + 1, + "refresh must deduplicate events" + ); +} + +#[tokio::test] +async fn performance_history_is_bounded_and_large_dashboards_are_deterministic() { + let transport = FakeTransport::new(); + let mut app = OperatorTui::default(); + for _ in 0..140 { + app.refresh(&transport).await.expect("refresh"); + } + assert_eq!(app.performance_history_len(), 120); + app.screen = OperatorScreen::Overview; + let unicode = app.render(TuiRenderOptions { + width: 140, + height: 42, + color: false, + }); + assert!(unicode.contains("Café") && unicode.contains('世') && unicode.contains('界')); + for (screen, marker) in [ + (OperatorScreen::Overview, "Runtime"), + (OperatorScreen::Sessions, "Sessions"), + (OperatorScreen::QueuesAndBudgets, "Work queue"), + (OperatorScreen::Roaming, "Roaming schedules"), + (OperatorScreen::Approvals, "Pending approvals"), + (OperatorScreen::Timeline, "Timeline / audit"), + (OperatorScreen::Health, "Health"), + (OperatorScreen::Errors, "Recent errors"), + (OperatorScreen::Diagnostics, "Diagnostics (redacted)"), + (OperatorScreen::Preferences, "Preferences"), + ] { + app.screen = screen; + let first = app.render(TuiRenderOptions { + width: 140, + height: 42, + color: true, + }); + let second = app.render(TuiRenderOptions { + width: 140, + height: 42, + color: true, + }); + assert!(first.contains(marker), "missing {marker}"); + assert_eq!(first.lines().count(), second.lines().count()); + } + app.screen = OperatorScreen::QueuesAndBudgets; + let output = app.render(TuiRenderOptions { + width: 140, + height: 42, + color: false, + }); + for label in [ + "Work queue", + "Rate limit", + "Inference avg ms", + "Tool avg ms", + "Dropped total", + ] { + assert!(output.contains(label), "missing {label}"); + } +} + +#[test] +fn event_search_covers_correlation_duration_result_and_reason() { + let mut app = OperatorTui::default(); + app.snapshot.timeline.push_back(structured_event()); + app.screen = OperatorScreen::Timeline; + for query in [ + "warning", + "avatar-7", + "session-7", + "action-7", + "19", + "delayed", + "capacity", + ] { + app.filter.query = query.into(); + assert!( + app.render(TuiRenderOptions { + width: 120, + height: 30, + color: false + }) + .contains("queue_pressure") + ); + } } #[test] @@ -142,7 +257,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() { color: false, }); assert!(!output.is_empty()); - assert!(output.lines().count() <= 4); + assert!(output.lines().count() <= 5); assert!( output .lines() @@ -196,6 +311,21 @@ fn every_global_command_has_a_keyboard_path() { assert_eq!(app.command_shortcut('s'), Some(OperatorCommand::Shutdown)); } +#[test] +fn confirmation_dialog_is_visible_without_color() { + let mut app = OperatorTui::default(); + assert_eq!( + app.reduce(TuiInput::Command(OperatorCommand::Shutdown)), + TuiAction::None + ); + let output = app.render(TuiRenderOptions { + width: 100, + height: 24, + color: false, + }); + assert!(output.contains("confirm HighRisk (y/n)")); +} + #[test] fn transport_errors_are_safe_display_text() { struct Broken; diff --git a/crates/metacrate-grid-agent/tests/dependency_policy.rs b/crates/metacrate-grid-agent/tests/dependency_policy.rs index 2caafa5..ce06f58 100644 --- a/crates/metacrate-grid-agent/tests/dependency_policy.rs +++ b/crates/metacrate-grid-agent/tests/dependency_policy.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -const ALLOWED_DEPENDENCIES: [&str; 22] = [ +const ALLOWED_DEPENDENCIES: [&str; 23] = [ "async-trait", "base64", "crossterm", @@ -16,6 +16,7 @@ const ALLOWED_DEPENDENCIES: [&str; 22] = [ "mentra", "jpeg-encoder", "libremetaverse-types", + "ratatui", "rustls", "serde", "serde_json", diff --git a/crates/metacrate-grid-agent/tests/preferences_config.rs b/crates/metacrate-grid-agent/tests/preferences_config.rs index 0011553..2ee21b0 100644 --- a/crates/metacrate-grid-agent/tests/preferences_config.rs +++ b/crates/metacrate-grid-agent/tests/preferences_config.rs @@ -108,3 +108,23 @@ fn dotenv_import_is_explicit_and_preferences_panel_never_renders_secrets() { let _ = tui.reduce(TuiInput::PreferenceSave); fs::remove_dir_all(directory).unwrap(); } + +#[test] +fn preferences_form_keeps_validation_feedback_visible() { + let directory = temporary("validation"); + let config = directory.join("config.yml"); + let mut tui = OperatorTui::with_preferences(config).unwrap(); + tui.screen = OperatorScreen::Preferences; + let _ = tui.reduce(TuiInput::PreferenceEditOrCommit); + for value in "not-a-mode".chars() { + let _ = tui.reduce(TuiInput::PreferenceCharacter(value)); + } + let _ = tui.reduce(TuiInput::PreferenceEditOrCommit); + let rendered = tui.render(TuiRenderOptions { + width: 100, + height: 24, + color: false, + }); + assert!(rendered.contains("[VALIDATION] mode must be offline, integrated, or split")); + fs::remove_dir_all(directory).unwrap(); +} diff --git a/docs/grid-agent-tui.md b/docs/grid-agent-tui.md index 380c083..0a02125 100644 --- a/docs/grid-agent-tui.md +++ b/docs/grid-agent-tui.md @@ -1,8 +1,9 @@ # Grid-agent operator TUI -The operator TUI is a pure-Rust, cross-platform view of the versioned control -plane. Embedded and split modes use the same reducer, snapshots, commands, and -authenticated control requests; the UI never reads private runtime state. +The operator TUI is a pure-Rust, cross-platform Ratatui dashboard over the +versioned control plane. Embedded and split modes use the same reducer, +snapshots, widgets, commands, and authenticated control requests; the UI never +reads private runtime state. Standalone preferences uses the same Ratatui form. Run an embedded UI with a live-grid integrated configuration: @@ -31,19 +32,33 @@ Up/Down to select, Enter to edit/commit, `w` to save, and Esc to cancel. Changes to connection or authorization settings apply after restart. A one-time `--import-env /path/to/.env` migrates legacy `GRID_*` and `OPENAPI_*` values. -Use Tab/arrow keys to change panels and scroll. `r` refreshes; `p`/`u` pause or -resume; `f` reconnects the grid; `a`/`d` approve or deny the first pending -approval; `x` cancels the newest visible action; `e` expires the first visible -session; `t` toggles the first roaming schedule; `s` requests shutdown; and -`q` or Ctrl-C exits. Mutations other than pause/resume require confirmation, -with shutdown marked high risk. Exiting or crashing a split TUI does not stop -the service. +The Overview dashboard shows readiness, region/pose, behavior, build and visual +work, approvals, and recent failures. Queues & budgets shows real queue and +rate-limit gauges, active work, outcome counts, dropped events, aggregate +latency, and a bounded 120-refresh history. Sessions, roaming schedules, +approvals, timeline/audit, errors, diagnostics, health, and preferences retain +focused tables or panels and contextual detail. Missing backend data is marked +unavailable rather than estimated. -Rendering is terminal-independent, Unicode display-width aware, responsive to -resize events, and monochrome when `NO_COLOR` is set or `TERM=dumb`. Input uses -a bounded queue. Snapshot ticks coalesce while a request is slow, and timeline -storage is bounded. The terminal guard restores raw mode, cursor visibility, -and the alternate screen on success, error, Ctrl-C, or panic unwinding. +Use Tab/Shift-Tab or Left/Right to change panels and Up/Down to select rows. +`/` searches event, audit, correlation, time, duration, result, and reason data; +Enter applies the search and Esc clears it. `r` refreshes; `p`/`u` pause or +resume; `f` reconnects the grid; `a`/`d` approve or deny the selected approval; +`x` cancels the newest visible action; `e` expires the selected session; `t` +toggles the selected roaming schedule; `s` requests shutdown; and `q` or +Ctrl-C exits. Mutations other than pause/resume require confirmation, with +shutdown marked high risk. Exiting or crashing a split TUI does not stop the +service. + +Ratatui's Crossterm backend provides buffered/diff rendering without clearing +the full screen on every frame. Layouts respond to resize and collapse to a +compact status view on very small terminals; focus and failure markers remain +visible without color when `NO_COLOR` is set or `TERM=dumb`. Input, refresh +work, results, event history, errors, and performance history are all bounded. +Slow snapshots run outside the input reducer, and refresh ticks are dropped +when the bounded worker queue is full. The terminal guard restores raw mode, +cursor visibility, and the alternate screen on success, error, Ctrl-C, or +panic unwinding. Diagnostic panels show only explicitly captured redacted envelopes. Prompt and response content, API keys, grid passwords, operator tokens, capability @@ -52,7 +67,8 @@ URLs, and model reasoning have no TUI representation. Focused verification: ```sh -cargo test --locked -p metacrate-grid-agent --lib tui_tests +cargo test --locked -p metacrate-grid-agent tui --lib +cargo test --locked -p metacrate-grid-agent --test preferences_config cargo test --locked -p metacrate-grid-agent --test dependency_policy cargo clippy --locked -p metacrate-grid-agent --all-targets --features live-grid -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --locked -p metacrate-grid-agent --no-deps