Implement cross-platform grid agent operator TUI (#128)
Some checks failed
CI / rust-skia (Rust only) (push) Successful in 2m47s
CI / required (push) Failing after 1m59s

This commit is contained in:
2026-08-18 10:15:46 +02:00
parent e99ee0f33d
commit 6370b3e416
10 changed files with 1351 additions and 18 deletions

133
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"]
}
]
},

View File

@@ -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"] }

View File

@@ -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).

View File

@@ -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,

View File

@@ -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<PathBuf>,
check_config: bool,
run_once: bool,
operation: Operation,
}
fn options() -> Result<Option<Options>, CliError> {
@@ -42,15 +56,19 @@ fn options() -> Result<Option<Options>, 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<Option<Options>, 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<dyn Error>> {
let Some(options) = options()? else {
@@ -83,13 +104,33 @@ async fn main() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
.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<dyn Error>> {
async fn run_live(
config: metacrate_grid_agent::AgentConfig,
run_once: bool,
tui: bool,
) -> Result<(), Box<dyn Error>> {
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<dyn ControlTarget> = 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;

View File

@@ -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<EventSeverity>,
pub component: Option<String>,
pub avatar: Option<String>,
pub session: Option<String>,
pub action: Option<String>,
pub since_unix_millis: Option<u64>,
}
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<HealthView>,
pub runtime: Option<RuntimeView>,
pub metrics: Option<MetricsSnapshot>,
pub sessions: Vec<SessionMetadataView>,
pub schedules: Vec<ScheduledJobView>,
pub approvals: Vec<PendingApprovalView>,
pub audit: Vec<AuditEventView>,
pub timeline: VecDeque<StructuredEvent>,
pub gap_count: u64,
pub last_sequence: Option<u64>,
}
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<Box<dyn Future<Output = Result<ControlResponseEnvelope, TuiError>> + 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<Option<TcpControlClient>>,
}
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<OperatorCommand>,
request_number: u64,
scroll: usize,
status: String,
errors: VecDeque<String>,
}
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<OperatorCommand> {
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::<Vec<_>>()
.join("\n")
}
}
fn page() -> PageRequest {
PageRequest {
cursor: None,
limit: PAGE_LIMIT,
}
}
fn render_overview(s: &OperatorSnapshot, out: &mut Vec<String>) {
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<String>) {
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<String>) {
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<Self> {
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<dyn TuiTransport>, 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?,
}
}
}

View File

@@ -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<Vec<ControlRequest>>);
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")
);
}

View File

@@ -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 {

51
docs/grid-agent-tui.md Normal file
View File

@@ -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
```