diff --git a/Cargo.lock b/Cargo.lock index 06001b6..8b442d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,7 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" dependencies = [ "termcolor", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -591,6 +591,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -718,6 +727,33 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -841,6 +877,28 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -922,6 +980,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2105,6 +2172,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2198,6 +2271,7 @@ dependencies = [ name = "metacrate-grid-agent" version = "0.0.1" dependencies = [ + "crossterm", "libremetaverse", "libremetaverse-types", "reqwest", @@ -2207,6 +2281,7 @@ dependencies = [ "sha2 0.11.0", "tokio", "tokio-rustls", + "unicode-width 0.2.2", "url", ] @@ -2249,6 +2324,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -3521,6 +3597,27 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7f45b8998ced5134fb1d75732c77842a3e888f19c1ff98481822e8fbfbf930b" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4146,12 +4243,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4394,6 +4503,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4403,6 +4528,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" diff --git a/ci/release-matrix.json b/ci/release-matrix.json index cfd0d15..b6c87f9 100644 --- a/ci/release-matrix.json +++ b/ci/release-matrix.json @@ -162,7 +162,7 @@ "commands": [ { "label": "Cross-check portable crates for Windows GNU", - "args": ["check", "--locked", "-j", "1", "--target", "x86_64-pc-windows-gnu", "--all-targets", "--no-default-features", "--features", "libremetaverse-imaging/rust-j2k,libremetaverse-imaging-skia/rust-skia", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-ci-matrix"] + "args": ["check", "--locked", "-j", "1", "--target", "x86_64-pc-windows-gnu", "--all-targets", "--no-default-features", "--features", "libremetaverse-imaging/rust-j2k,libremetaverse-imaging-skia/rust-skia", "-p", "libremetaverse-types", "-p", "libremetaverse-structured-data", "-p", "libremetaverse-imaging", "-p", "libremetaverse-imaging-skia", "-p", "libremetaverse-prim-mesher", "-p", "libremetaverse-lsl-tools", "-p", "libremetaverse-rlv", "-p", "metacrate-grid-agent", "-p", "metacrate-ci-matrix"] } ] }, diff --git a/crates/metacrate-grid-agent/Cargo.toml b/crates/metacrate-grid-agent/Cargo.toml index d2e0494..78e241b 100644 --- a/crates/metacrate-grid-agent/Cargo.toml +++ b/crates/metacrate-grid-agent/Cargo.toml @@ -9,6 +9,7 @@ description = "Bounded pure-Rust OpenSim grid-agent service foundation" publish = false [dependencies] +crossterm = "0.29" libremetaverse = { version = "0.0.1", path = "../libremetaverse", default-features = false, optional = true } libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" } reqwest = { version = "0.13.4", default-features = false, features = ["rustls"] } @@ -19,6 +20,7 @@ sha2 = "0.11" tokio = { version = "1.53.1", features = ["macros", "rt", "sync", "time"] } tokio-rustls = { version = "0.26.4", default-features = false, features = ["aws_lc_rs", "tls12"] } url = "2.5.8" +unicode-width = "0.2" [dev-dependencies] tokio = { version = "1.53.1", features = ["test-util"] } diff --git a/crates/metacrate-grid-agent/README.md b/crates/metacrate-grid-agent/README.md index 533f464..7fdbf86 100644 --- a/crates/metacrate-grid-agent/README.md +++ b/crates/metacrate-grid-agent/README.md @@ -61,3 +61,6 @@ The unified event schema, pseudonymous correlations, bounded JSONL rotation, fixed-cardinality metrics, diagnostic-capture warning, and non-executing replay contract are specified in [`../../docs/grid-agent-observability.md`](../../docs/grid-agent-observability.md). +The embedded/split cross-platform operator interface, keyboard controls, +privacy boundary, and focused gates are documented in +[`../../docs/grid-agent-tui.md`](../../docs/grid-agent-tui.md). diff --git a/crates/metacrate-grid-agent/src/lib.rs b/crates/metacrate-grid-agent/src/lib.rs index 690b1c8..370f343 100644 --- a/crates/metacrate-grid-agent/src/lib.rs +++ b/crates/metacrate-grid-agent/src/lib.rs @@ -18,6 +18,7 @@ pub mod policy; pub mod service; pub mod session; pub mod tool_loop; +pub mod tui; pub mod types; #[cfg(test)] @@ -38,6 +39,8 @@ mod perception_tests; mod policy_tests; #[cfg(test)] mod session_tests; +#[cfg(test)] +mod tui_tests; pub use backend::{ AuthorizedToolBackend, BackendError, BackendFuture, GridBackend, OfflineGridBackend, @@ -124,6 +127,11 @@ pub use tool_loop::{ HistorySummarizer, SessionGeneration, ToolExecution, ToolExecutor, ToolFuture, ToolLoop, ToolLoopError, ToolLoopLimits, ToolLoopOutcome, }; +pub use tui::{ + CommandConfirmation, EventFilter, OperatorCommand, OperatorScreen, OperatorSnapshot, + OperatorTui, ReconnectingTcpTransport, TuiAction, TuiError, TuiInput, TuiRenderOptions, + TuiTransport, +}; pub use types::{ BoundaryError, BoundedText, BoundedVec, ControlCommand, Conversation, ConversationMessage, GridEvent, GridEventKind, LlmRequest, LlmResult, MessageRole, ObservableEvent, diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index dc75095..a2ccdff 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -22,11 +22,25 @@ impl fmt::Display for CliError { impl Error for CliError {} +fn color_disabled() -> bool { + std::env::var_os("NO_COLOR").is_some() + || std::env::var_os("TERM").is_some_and(|value| value == "dumb") +} + +#[derive(Clone, Copy, Default, Eq, PartialEq)] +enum Operation { + #[default] + Serve, + CheckConfig, + RunOnce, + Tui, + TuiClient, +} + #[derive(Default)] struct Options { config: Option, - check_config: bool, - run_once: bool, + operation: Operation, } fn options() -> Result, CliError> { @@ -42,15 +56,19 @@ fn options() -> Result, CliError> { } if argument == "--help" || argument == "-h" { println!( - "metacrate-grid-agent [--config PATH] [--check-config | --run-once]\n\ + "metacrate-grid-agent [--config PATH] [--check-config | --run-once | --tui | --tui-client]\n\ Configuration precedence: defaults < JSON < secret files < environment." ); return Ok(None); } if argument == "--check-config" { - result.check_config = true; + set_operation(&mut result, Operation::CheckConfig)?; } else if argument == "--run-once" { - result.run_once = true; + set_operation(&mut result, Operation::RunOnce)?; + } else if argument == "--tui" { + set_operation(&mut result, Operation::Tui)?; + } else if argument == "--tui-client" { + set_operation(&mut result, Operation::TuiClient)?; } else if argument == "--config" { let path = arguments .next() @@ -65,14 +83,17 @@ fn options() -> Result, CliError> { ))); } } - if result.check_config && result.run_once { - return Err(CliError( - "--check-config and --run-once are mutually exclusive".into(), - )); - } Ok(Some(result)) } +fn set_operation(options: &mut Options, operation: Operation) -> Result<(), CliError> { + if options.operation != Operation::Serve { + return Err(CliError("operation flags are mutually exclusive".into())); + } + options.operation = operation; + Ok(()) +} + #[tokio::main] async fn main() -> Result<(), Box> { let Some(options) = options()? else { @@ -83,13 +104,33 @@ async fn main() -> Result<(), Box> { loader = loader.with_file(path); } let config = loader.load()?; - if options.check_config { + if options.operation == Operation::CheckConfig { println!("configuration is valid for {:?} mode", config.mode); return Ok(()); } + if options.operation == Operation::TuiClient { + let token = config + .control + .operator_token + .as_ref() + .ok_or_else(|| CliError("split TUI requires a configured operator token".into()))?; + let client = metacrate_grid_agent::ReconnectingTcpTransport::new( + config.control.listen, + token.clone(), + config.control.limits, + ); + metacrate_grid_agent::tui::run_terminal(std::sync::Arc::new(client), !color_disabled()) + .await?; + return Ok(()); + } if config.mode != OperatingMode::OfflineFake { #[cfg(feature = "live-grid")] - return run_live(config, options.run_once).await; + return run_live( + config, + options.operation == Operation::RunOnce, + options.operation == Operation::Tui, + ) + .await; #[cfg(not(feature = "live-grid"))] return Err(CliError( "live grid mode requires rebuilding with --features live-grid".into(), @@ -97,9 +138,12 @@ async fn main() -> Result<(), Box> { .into()); } + if options.operation == Operation::Tui { + return Err(CliError("embedded TUI requires live-grid mode".into()).into()); + } let startup_timeout = config.timeouts.startup; let mut handle = AgentService::offline(config)?.start()?; - if options.run_once { + if options.operation == Operation::RunOnce { tokio::time::timeout(startup_timeout, async { loop { match handle.next_event().await { @@ -145,6 +189,7 @@ async fn main() -> Result<(), Box> { async fn run_live( config: metacrate_grid_agent::AgentConfig, run_once: bool, + tui: bool, ) -> Result<(), Box> { use metacrate_grid_agent::{ AgentControlTarget, BehaviorObservation, ControlEventKind, ControlPlane, ControlTarget, @@ -248,7 +293,7 @@ async fn run_live( control_target.attach_observability(live.observability.clone()); control_target.update_session(handle.status()); let erased_target: Arc = control_target.clone(); - let (control_plane, _integrated_client, control_server) = match config.mode { + let (control_plane, integrated_client, control_server) = match config.mode { OperatingMode::Integrated => { let (plane, client) = ControlPlane::integrated(erased_target, config.control.limits)?; (plane, Some(client), None) @@ -286,6 +331,18 @@ async fn run_live( } }; + let _tui_task = if tui { + let client = integrated_client.ok_or_else(|| { + CliError("--tui requires integrated mode; use --tui-client for split mode".into()) + })?; + Some(tokio::spawn(metacrate_grid_agent::tui::run_terminal( + Arc::new(client), + !color_disabled(), + ))) + } else { + None + }; + println!("grid agent session supervisor started; press Ctrl-C to stop"); let mut signal_error = None; let mut control_failure = None; diff --git a/crates/metacrate-grid-agent/src/tui.rs b/crates/metacrate-grid-agent/src/tui.rs new file mode 100644 index 0000000..00574a9 --- /dev/null +++ b/crates/metacrate-grid-agent/src/tui.rs @@ -0,0 +1,865 @@ +//! 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. + +#![allow(clippy::missing_errors_doc)] + +use crate::control_plane::{ + AuditEventView, CONTROL_PROTOCOL_VERSION, ControlEvent, ControlEventKind, ControlPayload, + ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, HealthView, + InProcessControlClient, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView, + SessionMetadataView, TcpControlClient, +}; +use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent}; +use crate::{ControlLimits, SecretString}; +use crossterm::{cursor, event, execute, queue, style, terminal}; +use std::collections::VecDeque; +use std::fmt; +use std::future::Future; +use std::io::{self, Write}; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const MAX_TIMELINE: usize = 2_000; +const MAX_ERRORS: usize = 100; +const PAGE_LIMIT: u16 = 100; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OperatorScreen { + Overview, + Sessions, + QueuesAndBudgets, + Roaming, + Approvals, + Timeline, + Health, + Errors, + Diagnostics, +} + +impl OperatorScreen { + const ALL: [Self; 9] = [ + Self::Overview, + Self::Sessions, + Self::QueuesAndBudgets, + Self::Roaming, + Self::Approvals, + Self::Timeline, + Self::Health, + Self::Errors, + Self::Diagnostics, + ]; + + const fn title(self) -> &'static str { + match self { + Self::Overview => "Overview", + Self::Sessions => "Sessions", + Self::QueuesAndBudgets => "Queues & budgets", + Self::Roaming => "Roaming", + Self::Approvals => "Approvals", + Self::Timeline => "Timeline", + Self::Health => "Health & metrics", + Self::Errors => "Recent errors", + Self::Diagnostics => "Diagnostics", + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct EventFilter { + pub query: String, + pub severity: Option, + pub component: Option, + pub avatar: Option, + pub session: Option, + pub action: Option, + pub since_unix_millis: Option, +} + +impl EventFilter { + fn matches(&self, item: &StructuredEvent) -> bool { + if self.severity.is_some_and(|value| value != item.severity) + || self + .component + .as_ref() + .is_some_and(|value| value != &item.component) + || self + .since_unix_millis + .is_some_and(|value| item.unix_millis < value) + || self + .avatar + .as_ref() + .is_some_and(|value| item.correlation.avatar_id.as_ref() != Some(value)) + || self + .session + .as_ref() + .is_some_and(|value| item.correlation.session_id.as_ref() != Some(value)) + || self + .action + .as_ref() + .is_some_and(|value| item.correlation.action_id.as_ref() != Some(value)) + { + return false; + } + let query = self.query.to_lowercase(); + query.is_empty() + || item.component.to_lowercase().contains(&query) + || item.family.to_lowercase().contains(&query) + || item + .result_code + .as_ref() + .is_some_and(|v| v.to_lowercase().contains(&query)) + || item + .reason_code + .as_ref() + .is_some_and(|v| v.to_lowercase().contains(&query)) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OperatorSnapshot { + pub health: Option, + pub runtime: Option, + pub metrics: Option, + pub sessions: Vec, + pub schedules: Vec, + pub approvals: Vec, + pub audit: Vec, + pub timeline: VecDeque, + pub gap_count: u64, + pub last_sequence: Option, +} + +impl Default for OperatorSnapshot { + fn default() -> Self { + Self { + health: None, + runtime: None, + metrics: None, + sessions: Vec::new(), + schedules: Vec::new(), + approvals: Vec::new(), + audit: Vec::new(), + timeline: VecDeque::with_capacity(MAX_TIMELINE), + gap_count: 0, + last_sequence: None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OperatorCommand { + Pause, + Resume, + CancelAction(String), + DecideApproval { id: u64, approve: bool }, + Reconnect, + ExpireSession { avatar_id: String, direct_im: bool }, + ToggleSchedule { job_id: String, enabled: bool }, + Shutdown, +} + +impl OperatorCommand { + fn request(&self) -> ControlRequest { + match self { + Self::Pause => ControlRequest::PauseAutonomy, + Self::Resume => ControlRequest::ResumeAutonomy, + Self::CancelAction(action_id) => ControlRequest::CancelAction { + action_id: action_id.clone(), + }, + Self::DecideApproval { id, approve } => ControlRequest::DecideApproval { + approval_id: *id, + approve: *approve, + }, + Self::Reconnect => ControlRequest::ForceReconnect, + Self::ExpireSession { + avatar_id, + direct_im, + } => ControlRequest::ExpireConversation { + avatar_id: avatar_id.clone(), + channel: if *direct_im { + crate::control_plane::ConversationChannelView::DirectIm + } else { + crate::control_plane::ConversationChannelView::PublicChat + }, + }, + Self::ToggleSchedule { job_id, enabled } => ControlRequest::SetRoamingJob { + job_id: job_id.clone(), + enabled: *enabled, + }, + Self::Shutdown => ControlRequest::GracefulShutdown, + } + } + + #[must_use] + pub const fn confirmation(&self) -> CommandConfirmation { + match self { + Self::Pause | Self::Resume => CommandConfirmation::None, + Self::CancelAction(_) + | Self::DecideApproval { .. } + | Self::Reconnect + | Self::ExpireSession { .. } + | Self::ToggleSchedule { .. } => CommandConfirmation::Normal, + Self::Shutdown => CommandConfirmation::HighRisk, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandConfirmation { + None, + Normal, + HighRisk, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TuiInput { + NextScreen, + PreviousScreen, + ScrollUp, + ScrollDown, + Refresh, + Command(OperatorCommand), + Confirm, + Reject, + Quit, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TuiAction { + None, + Refresh, + Execute(OperatorCommand), + Exit, +} + +#[derive(Debug)] +pub struct TuiError(pub String); +impl fmt::Display for TuiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl std::error::Error for TuiError {} + +pub type TuiFuture<'a> = + Pin> + Send + 'a>>; + +/// Common authenticated client boundary for embedded and split operation. +pub trait TuiTransport: Send + Sync { + fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_>; +} + +impl TuiTransport for InProcessControlClient { + fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> { + Box::pin(async move { Ok(InProcessControlClient::request(self, request).await) }) + } +} + +impl TuiTransport for TcpControlClient { + fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> { + Box::pin(async move { + TcpControlClient::request(self, request) + .await + .map_err(|e| TuiError(e.to_string())) + }) + } +} + +/// Loopback split-mode transport which reconnects after a service restart. +/// The capability is held in the redacting secret wrapper and never appears in +/// diagnostics. Requests are retried once only when the transport itself died; +/// application errors are not replayed. +pub struct ReconnectingTcpTransport { + address: SocketAddr, + token: SecretString, + limits: ControlLimits, + client: tokio::sync::Mutex>, +} + +impl ReconnectingTcpTransport { + #[must_use] + pub fn new(address: SocketAddr, token: SecretString, limits: ControlLimits) -> Self { + Self { + address, + token, + limits, + client: tokio::sync::Mutex::new(None), + } + } +} + +impl fmt::Debug for ReconnectingTcpTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReconnectingTcpTransport") + .field("address", &self.address) + .field("token", &"[REDACTED]") + .field("limits", &self.limits) + .finish_non_exhaustive() + } +} + +impl TuiTransport for ReconnectingTcpTransport { + fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> { + Box::pin(async move { + let mut client = self.client.lock().await; + if client.is_none() { + *client = Some( + TcpControlClient::connect( + self.address, + self.token.expose_secret(), + self.limits, + ) + .await + .map_err(|error| TuiError(error.to_string()))?, + ); + } + let result = client + .as_ref() + .expect("client initialized") + .request(request.clone()) + .await; + match result { + Ok(response) => Ok(response), + Err(error) if error.retryable => { + *client = None; + let replacement = TcpControlClient::connect( + self.address, + self.token.expose_secret(), + self.limits, + ) + .await + .map_err(|connect| TuiError(connect.to_string()))?; + let response = replacement + .request(request) + .await + .map_err(|retry| TuiError(retry.to_string()))?; + *client = Some(replacement); + Ok(response) + } + Err(error) => Err(TuiError(error.to_string())), + } + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TuiRenderOptions { + pub width: u16, + pub height: u16, + pub color: bool, +} + +pub struct OperatorTui { + pub snapshot: OperatorSnapshot, + pub screen: OperatorScreen, + pub filter: EventFilter, + pending: Option, + request_number: u64, + scroll: usize, + status: String, + errors: VecDeque, +} + +impl Default for OperatorTui { + fn default() -> Self { + Self { + snapshot: OperatorSnapshot::default(), + screen: OperatorScreen::Overview, + filter: EventFilter::default(), + pending: None, + request_number: 1, + scroll: 0, + status: "connecting".into(), + errors: VecDeque::with_capacity(MAX_ERRORS), + } + } +} + +impl OperatorTui { + #[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', + }) + } + 'x' => self.snapshot.timeline.iter().rev().find_map(|event| { + event + .correlation + .action_id + .clone() + .map(OperatorCommand::CancelAction) + }), + 'e' => self + .snapshot + .sessions + .first() + .map(|session| OperatorCommand::ExpireSession { + avatar_id: session.avatar_id.clone(), + direct_im: session.channel == "direct_im", + }), + 't' => self + .snapshot + .schedules + .first() + .map(|job| OperatorCommand::ToggleSchedule { + job_id: job.job_id.clone(), + enabled: !job.enabled, + }), + 's' => Some(OperatorCommand::Shutdown), + _ => None, + } + } + + pub fn reduce(&mut self, input: TuiInput) -> TuiAction { + match input { + TuiInput::NextScreen => { + let i = OperatorScreen::ALL + .iter() + .position(|v| *v == self.screen) + .unwrap_or(0); + self.screen = OperatorScreen::ALL[(i + 1) % OperatorScreen::ALL.len()]; + self.scroll = 0; + TuiAction::None + } + TuiInput::PreviousScreen => { + let i = OperatorScreen::ALL + .iter() + .position(|v| *v == self.screen) + .unwrap_or(0); + self.screen = OperatorScreen::ALL + [(i + OperatorScreen::ALL.len() - 1) % OperatorScreen::ALL.len()]; + self.scroll = 0; + TuiAction::None + } + TuiInput::ScrollUp => { + self.scroll = self.scroll.saturating_sub(1); + TuiAction::None + } + TuiInput::ScrollDown => { + self.scroll = self.scroll.saturating_add(1); + TuiAction::None + } + TuiInput::Refresh => TuiAction::Refresh, + TuiInput::Quit => TuiAction::Exit, + TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => { + TuiAction::Execute(command) + } + TuiInput::Command(command) => { + self.status = format!("confirm {:?} (y/n)", command.confirmation()); + self.pending = Some(command); + TuiAction::None + } + TuiInput::Confirm => self + .pending + .take() + .map_or(TuiAction::None, TuiAction::Execute), + TuiInput::Reject => { + self.pending = None; + self.status = "command cancelled".into(); + TuiAction::None + } + } + } + + pub fn ingest_event(&mut self, event: ControlEvent) { + self.snapshot.last_sequence = Some(event.sequence); + match event.event { + ControlEventKind::Gap { + first_available, + last_missed, + } => { + self.snapshot.gap_count = self.snapshot.gap_count.saturating_add( + last_missed + .saturating_sub(first_available) + .saturating_add(1), + ); + self.status = format!("event gap before {first_available}"); + } + ControlEventKind::Mutation { + operation, outcome, .. + } => self.status = format!("{operation}: {outcome}"), + ControlEventKind::StateChanged { component, state } => { + self.status = format!("{component}: {state}"); + } + } + } + + pub async fn refresh(&mut self, client: &dyn TuiTransport) -> Result<(), TuiError> { + let requests = [ + 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() }, + ]; + for request in requests { + self.send_and_apply(client, request).await?; + } + self.status = "connected".into(); + Ok(()) + } + + pub async fn execute( + &mut self, + client: &dyn TuiTransport, + command: OperatorCommand, + ) -> Result<(), TuiError> { + self.send_and_apply(client, command.request()).await?; + self.refresh(client).await + } + + async fn send_and_apply( + &mut self, + client: &dyn TuiTransport, + request: ControlRequest, + ) -> Result<(), TuiError> { + let envelope = ControlRequestEnvelope { + version: CONTROL_PROTOCOL_VERSION, + request_id: format!("tui-{}", self.request_number), + request, + }; + self.request_number = self.request_number.saturating_add(1); + let response = client.request(envelope).await?; + match response.result { + Ok(payload) => self.apply(payload), + Err(error) => { + let message = format!("{:?}: {}", error.code, error.message); + self.record_error(message.clone()); + return Err(TuiError(message)); + } + } + Ok(()) + } + + 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::ObservabilityEvents(Page { items, .. }) => { + for event in items { + if self.snapshot.timeline.len() == MAX_TIMELINE { + self.snapshot.timeline.pop_front(); + } + self.snapshot.timeline.push_back(event); + } + } + ControlPayload::Accepted { operation_id } => { + self.status = format!("accepted {operation_id}"); + } + ControlPayload::Completed => self.status = "completed".into(), + ControlPayload::Subscribed { current_sequence } => { + self.snapshot.last_sequence = Some(current_sequence); + } + ControlPayload::Cancelled { request_id } => { + self.status = format!("cancelled {request_id}"); + } + } + } + + fn record_error(&mut self, message: String) { + if self.errors.len() == MAX_ERRORS { + self.errors.pop_front(); + } + self.errors.push_back(message); + } + + #[must_use] + 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(",") + )); + } + } + } + let body_height = height.saturating_sub(1); + lines + .into_iter() + .skip(self.scroll.min(body_height)) + .take(body_height) + .map(|line| truncate_width(&line, width)) + .collect::>() + .join("\n") + } +} + +fn page() -> PageRequest { + PageRequest { + cursor: None, + 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 + )); + } +} +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 truncate_width(value: &str, width: usize) -> String { + if UnicodeWidthStr::width(value) <= width { + return value.to_owned(); + } + 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; +impl TerminalGuard { + fn enter() -> io::Result { + terminal::enable_raw_mode()?; + execute!(io::stdout(), terminal::EnterAlternateScreen, cursor::Hide)?; + Ok(Self) + } +} +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen); + let _ = terminal::disable_raw_mode(); + } +} + +/// Runs an event-driven keyboard UI. Terminal state is restored on normal +/// return, errors, Ctrl-C, and unwinding because restoration is guard-owned. +pub async fn run_terminal(client: Arc, color: bool) -> Result<(), TuiError> { + let _guard = TerminalGuard::enter().map_err(|e| TuiError(e.to_string()))?; + let (input_tx, mut input_rx) = tokio::sync::mpsc::channel(64); + std::thread::spawn(move || { + while let Ok(value) = event::read() { + if input_tx.blocking_send(value).is_err() { + break; + } + } + }); + let mut app = OperatorTui::default(); + app.refresh(client.as_ref()).await?; + 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! { + value = input_rx.recv() => { + let Some(value) = value else { return Ok(()); }; + value + } + _ = 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 input = match value { + event::Event::Resize(_, _) => TuiInput::Refresh, + 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?, + } + } +} diff --git a/crates/metacrate-grid-agent/src/tui_tests.rs b/crates/metacrate-grid-agent/src/tui_tests.rs new file mode 100644 index 0000000..4525ef7 --- /dev/null +++ b/crates/metacrate-grid-agent/src/tui_tests.rs @@ -0,0 +1,214 @@ +use crate::CONTROL_PROTOCOL_VERSION; +use crate::control_plane::{ + ControlPayload, ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, HealthView, +}; +use crate::tui::{ + CommandConfirmation, OperatorCommand, OperatorScreen, OperatorTui, TuiAction, TuiError, + TuiFuture, TuiInput, TuiRenderOptions, TuiTransport, +}; +use std::sync::Mutex; + +struct FakeTransport(Mutex>); + +impl FakeTransport { + fn new() -> Self { + Self(Mutex::new(Vec::new())) + } +} + +impl TuiTransport for FakeTransport { + fn request(&self, request: ControlRequestEnvelope) -> TuiFuture<'_> { + self.0 + .lock() + .expect("requests") + .push(request.request.clone()); + Box::pin(async move { + let result = match request.request { + ControlRequest::Health => ControlPayload::Health(HealthView { + service_state: "running".into(), + ready: true, + uptime_seconds: 7, + protocol_version: CONTROL_PROTOCOL_VERSION, + }), + ControlRequest::Runtime => ControlPayload::Runtime(crate::RuntimeView { + grid_state: "ready".into(), + generation: 2, + transport_connected: true, + agent_ready: true, + region_id: Some("region".into()), + region_name: Some("Café 世界".into()), + position: Some([1.0, 2.0, 3.0]), + behavior_mode: "active".into(), + control_queue_used: 1, + control_queue_capacity: 8, + budget_tool_calls_used: 2, + budget_movement_millimeters_used: 3, + }), + ControlRequest::ListSessions { .. } => ControlPayload::Sessions(crate::Page { + items: Vec::new(), + next_cursor: None, + }), + ControlRequest::ListScheduledJobs { .. } => { + ControlPayload::ScheduledJobs(crate::Page { + items: Vec::new(), + next_cursor: None, + }) + } + ControlRequest::ListPendingApprovals { .. } => { + ControlPayload::PendingApprovals(crate::Page { + items: Vec::new(), + next_cursor: None, + }) + } + ControlRequest::ListAuditEvents { .. } => { + ControlPayload::AuditEvents(crate::Page { + items: Vec::new(), + next_cursor: None, + }) + } + ControlRequest::ListObservabilityEvents { .. } => { + ControlPayload::ObservabilityEvents(crate::Page { + items: Vec::new(), + next_cursor: None, + }) + } + ControlRequest::Metrics => ControlPayload::Metrics(empty_metrics()), + _ => ControlPayload::Completed, + }; + Ok(ControlResponseEnvelope { + version: CONTROL_PROTOCOL_VERSION, + request_id: request.request_id, + result: Ok(result), + }) + }) + } +} + +fn empty_metrics() -> crate::MetricsSnapshot { + serde_json::from_value(serde_json::json!({ + "ready":true,"reconnects":0,"active_sessions":0,"active_tasks":0, + "queue_depth":0,"queue_capacity":8, + "inference_latency":{"samples":0,"total_millis":0,"maximum_millis":0}, + "tool_latency":{"samples":0,"total_millis":0,"maximum_millis":0}, + "inference_outcomes":{"allowed":0,"denied":0,"approval_required":0,"completed":0,"failed":0,"cancelled":0}, + "tool_outcomes":{"allowed":0,"denied":0,"approval_required":0,"completed":0,"failed":0,"cancelled":0}, + "policy_outcomes":{"allowed":0,"denied":0,"approval_required":0,"completed":0,"failed":0,"cancelled":0}, + "rate_limit_used":0,"rate_limit_capacity":10,"recorded_events":0, + "dropped_ring_events":0,"dropped_subscriber_events":0,"dropped_journal_events":0, + "diagnostic_entries":0 + })).expect("metrics fixture") +} + +#[tokio::test] +async fn snapshot_refresh_is_transport_neutral_and_bounded() { + let transport = FakeTransport::new(); + let mut app = OperatorTui::default(); + app.refresh(&transport).await.expect("refresh"); + assert_eq!( + app.snapshot.health.as_ref().expect("health").service_state, + "running" + ); + assert_eq!(transport.0.lock().expect("requests").len(), 8); +} + +#[test] +fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() { + let mut app = OperatorTui::default(); + for screen in [ + OperatorScreen::Overview, + OperatorScreen::Sessions, + OperatorScreen::QueuesAndBudgets, + OperatorScreen::Roaming, + OperatorScreen::Approvals, + OperatorScreen::Timeline, + OperatorScreen::Health, + OperatorScreen::Errors, + OperatorScreen::Diagnostics, + ] { + while app.screen != screen { + let _ = app.reduce(TuiInput::NextScreen); + } + let output = app.render(TuiRenderOptions { + width: 20, + height: 5, + color: false, + }); + assert!(!output.is_empty()); + assert!(output.lines().count() <= 4); + assert!( + output + .lines() + .all(|line| unicode_width::UnicodeWidthStr::width(line) <= 20) + ); + } +} + +#[test] +fn navigation_wraps_and_every_management_command_has_risk_confirmation() { + let mut app = OperatorTui::default(); + assert_eq!(app.reduce(TuiInput::PreviousScreen), TuiAction::None); + assert_eq!(app.screen, OperatorScreen::Diagnostics); + let commands = [ + OperatorCommand::Pause, + OperatorCommand::Resume, + OperatorCommand::CancelAction("action-1".into()), + OperatorCommand::DecideApproval { + id: 1, + approve: true, + }, + OperatorCommand::Reconnect, + OperatorCommand::ExpireSession { + avatar_id: "00000000-0000-0000-0000-000000000001".into(), + direct_im: true, + }, + OperatorCommand::ToggleSchedule { + job_id: "default-roaming".into(), + enabled: false, + }, + OperatorCommand::Shutdown, + ]; + for command in commands { + let confirmation = command.confirmation(); + let action = app.reduce(TuiInput::Command(command.clone())); + if confirmation == CommandConfirmation::None { + assert_eq!(action, TuiAction::Execute(command)); + } else { + assert_eq!(action, TuiAction::None); + assert_eq!(app.reduce(TuiInput::Confirm), TuiAction::Execute(command)); + } + } +} + +#[test] +fn every_global_command_has_a_keyboard_path() { + let app = OperatorTui::default(); + assert_eq!(app.command_shortcut('p'), Some(OperatorCommand::Pause)); + assert_eq!(app.command_shortcut('u'), Some(OperatorCommand::Resume)); + assert_eq!(app.command_shortcut('f'), Some(OperatorCommand::Reconnect)); + assert_eq!(app.command_shortcut('s'), Some(OperatorCommand::Shutdown)); +} + +#[test] +fn transport_errors_are_safe_display_text() { + struct Broken; + impl TuiTransport for Broken { + fn request(&self, _: ControlRequestEnvelope) -> TuiFuture<'_> { + Box::pin(async { Err(TuiError("transport closed".into())) }) + } + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let mut app = OperatorTui::default(); + let error = runtime.block_on(app.refresh(&Broken)).expect_err("failure"); + assert_eq!(error.to_string(), "transport closed"); + assert!( + !app.render(TuiRenderOptions { + width: 80, + height: 24, + color: false + }) + .contains("token") + ); +} diff --git a/crates/metacrate-grid-agent/tests/dependency_policy.rs b/crates/metacrate-grid-agent/tests/dependency_policy.rs index 72f71e7..155fcc4 100644 --- a/crates/metacrate-grid-agent/tests/dependency_policy.rs +++ b/crates/metacrate-grid-agent/tests/dependency_policy.rs @@ -2,7 +2,8 @@ use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -const ALLOWED_DEPENDENCIES: [&str; 10] = [ +const ALLOWED_DEPENDENCIES: [&str; 12] = [ + "crossterm", "libremetaverse", "libremetaverse-types", "reqwest", @@ -13,6 +14,7 @@ const ALLOWED_DEPENDENCIES: [&str; 10] = [ "tokio", "tokio-rustls", "url", + "unicode-width", ]; #[test] @@ -48,7 +50,7 @@ fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() { let mut files = Vec::with_capacity(20); collect_rust_files(&source, &mut files); assert!( - files.len() <= 26, + files.len() <= 28, "source-file count needs a reviewed bound update" ); for path in files { diff --git a/docs/grid-agent-tui.md b/docs/grid-agent-tui.md new file mode 100644 index 0000000..98a2bd2 --- /dev/null +++ b/docs/grid-agent-tui.md @@ -0,0 +1,51 @@ +# 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. + +Run an embedded UI with a live-grid integrated configuration: + +```sh +cargo run --locked -p metacrate-grid-agent --features live-grid -- \ + --config config/grid-agent.json --tui +``` + +Run a separate UI against a loopback split service: + +```sh +cargo run --locked -p metacrate-grid-agent -- \ + --config config/grid-agent.json --tui-client +``` + +The split client reads its operator capability through the normal secret-file +or environment configuration. It never displays or copies that value. Remote +TLS clients must embed `TuiTransport` with their explicitly configured trust +roots; the command-line client intentionally accepts loopback TCP only. + +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. + +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. + +Diagnostic panels show only explicitly captured redacted envelopes. Prompt +and response content, API keys, grid passwords, operator tokens, capability +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 --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 +```