diff --git a/crates/loopal-agent-hub/src/hub.rs b/crates/loopal-agent-hub/src/hub.rs index b0197ca..071a5c7 100644 --- a/crates/loopal-agent-hub/src/hub.rs +++ b/crates/loopal-agent-hub/src/hub.rs @@ -24,6 +24,8 @@ pub struct Hub { /// `None` = standalone mode (default, identical to pre-MetaHub behavior). /// `Some(...)` = cluster mode (local misses escalate to MetaHub). pub uplink: Option>, + /// TCP listener port, set after `start_hub_listener`. `None` if not listening. + pub listener_port: Option, } impl Hub { @@ -32,6 +34,7 @@ impl Hub { registry: AgentRegistry::new(event_tx), ui: UiDispatcher::new(), uplink: None, + listener_port: None, } } @@ -42,6 +45,7 @@ impl Hub { registry: AgentRegistry::new(tx), ui: UiDispatcher::new(), uplink: None, + listener_port: None, } } } diff --git a/crates/loopal-session/src/controller.rs b/crates/loopal-session/src/controller.rs index fdddad4..1fca82c 100644 --- a/crates/loopal-session/src/controller.rs +++ b/crates/loopal-session/src/controller.rs @@ -70,6 +70,11 @@ impl SessionController { &self.connections } + /// Hub TCP listener port (if listening). Returns `None` for in-process test setups. + pub async fn hub_listener_port(&self) -> Option { + self.connections.lock().await.listener_port + } + pub(crate) fn active_target(&self) -> String { self.lock().active_view.clone() } diff --git a/crates/loopal-tui/src/app/mod.rs b/crates/loopal-tui/src/app/mod.rs index 8f34248..a29063e 100644 --- a/crates/loopal-tui/src/app/mod.rs +++ b/crates/loopal-tui/src/app/mod.rs @@ -1,5 +1,7 @@ +mod status_page; mod types; +pub use status_page::*; pub use types::*; use std::collections::HashMap; diff --git a/crates/loopal-tui/src/app/status_page.rs b/crates/loopal-tui/src/app/status_page.rs new file mode 100644 index 0000000..4a6439a --- /dev/null +++ b/crates/loopal-tui/src/app/status_page.rs @@ -0,0 +1,134 @@ +//! Data types for the `/status` full-screen sub-page. + +/// Active tab in the status sub-page. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusTab { + Status, + Config, + Usage, +} + +impl StatusTab { + pub const ALL: [Self; 3] = [Self::Status, Self::Config, Self::Usage]; + + pub fn label(self) -> &'static str { + match self { + Self::Status => "Status", + Self::Config => "Config", + Self::Usage => "Usage", + } + } + + pub fn index(self) -> usize { + match self { + Self::Status => 0, + Self::Config => 1, + Self::Usage => 2, + } + } + + pub fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + pub fn prev(self) -> Self { + Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +/// A key-value pair for the Config tab display. +#[derive(Debug, Clone)] +pub struct ConfigEntry { + pub key: String, + pub value: String, +} + +/// Snapshot of runtime session state (from SessionController lock). +#[derive(Debug, Clone)] +pub struct SessionSnapshot { + pub session_id: String, + pub cwd: String, + pub model_display: String, + pub mode: String, + /// Hub TCP endpoint, e.g. "127.0.0.1:12345". Empty if not listening. + pub hub_endpoint: String, +} + +/// Snapshot of resolved configuration (from disk-loaded ResolvedConfig). +#[derive(Debug, Clone)] +pub struct ConfigSnapshot { + pub auth_env: String, + pub base_url: String, + pub mcp_configured: usize, + pub mcp_enabled: usize, + pub setting_sources: Vec, + pub entries: Vec, +} + +/// Snapshot of token/usage metrics for the Usage tab. +#[derive(Debug, Clone)] +pub struct UsageSnapshot { + pub input_tokens: u32, + pub output_tokens: u32, + pub context_window: u32, + pub context_used: u32, + pub turn_count: u32, + pub tool_count: u32, +} + +/// Full state for the status sub-page (snapshot-on-open, no live lock). +pub struct StatusPageState { + pub active_tab: StatusTab, + pub session: SessionSnapshot, + pub config: ConfigSnapshot, + pub usage: UsageSnapshot, + /// Per-tab scroll offsets indexed by `StatusTab::index()`. + pub scroll_offsets: [usize; 3], + /// Filter text for the Config tab search. + pub filter: String, + /// Cursor position within the filter text. + pub filter_cursor: usize, +} + +impl StatusPageState { + /// Return config entries matching the current filter. + pub fn filtered_config(&self) -> Vec<&ConfigEntry> { + if self.filter.is_empty() { + self.config.entries.iter().collect() + } else { + let lower = self.filter.to_ascii_lowercase(); + self.config + .entries + .iter() + .filter(|e| { + e.key.to_ascii_lowercase().contains(&lower) + || e.value.to_ascii_lowercase().contains(&lower) + }) + .collect() + } + } + + /// Mutable reference to the active tab's scroll offset. + pub fn active_scroll_mut(&mut self) -> &mut usize { + &mut self.scroll_offsets[self.active_tab.index()] + } + + /// Current tab's scroll offset. + pub fn active_scroll(&self) -> usize { + self.scroll_offsets[self.active_tab.index()] + } + + /// Number of content rows in the active tab (for scroll clamping). + pub fn active_row_count(&self) -> usize { + match self.active_tab { + StatusTab::Status => STATUS_TAB_ROWS, + StatusTab::Config => self.filtered_config().len(), + StatusTab::Usage => USAGE_TAB_ROWS, + } + } +} + +/// Fixed row count for the Status tab. +pub const STATUS_TAB_ROWS: usize = 9; +/// Fixed row count for the Usage tab (including separator rows). +pub const USAGE_TAB_ROWS: usize = 7; diff --git a/crates/loopal-tui/src/app/types.rs b/crates/loopal-tui/src/app/types.rs index ab4afc4..777d20d 100644 --- a/crates/loopal-tui/src/app/types.rs +++ b/crates/loopal-tui/src/app/types.rs @@ -79,6 +79,8 @@ impl PickerState { } } +use super::StatusPageState; + /// Active sub-page overlay that replaces the main chat area. pub enum SubPage { /// Model picker — user selects from known models. @@ -87,6 +89,8 @@ pub enum SubPage { RewindPicker(RewindPickerState), /// Session picker — user selects a session to resume. SessionPicker(PickerState), + /// Status dashboard — tabbed view of session info, config, and usage. + StatusPage(StatusPageState), } /// Which sub-panel within the panel zone is focused. diff --git a/crates/loopal-tui/src/command/builtin.rs b/crates/loopal-tui/src/command/builtin.rs index 258d28d..cc73222 100644 --- a/crates/loopal-tui/src/command/builtin.rs +++ b/crates/loopal-tui/src/command/builtin.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use async_trait::async_trait; use loopal_protocol::AgentMode; +use super::status_cmd::StatusCmd; use super::{CommandEffect, CommandHandler}; use crate::app::App; use crate::command::registry::CommandRegistry; @@ -48,39 +49,6 @@ impl CommandHandler for CompactCmd { } } -pub struct StatusCmd; - -#[async_trait] -impl CommandHandler for StatusCmd { - fn name(&self) -> &str { - "/status" - } - fn description(&self) -> &str { - "Show current status" - } - async fn execute(&self, app: &mut App, _arg: Option<&str>) -> CommandEffect { - let state = app.session.lock(); - let conv = state.active_conversation(); - let token_count = conv.token_count(); - let context_info = if conv.context_window > 0 { - format!("{}k/{}k", token_count / 1000, conv.context_window / 1000) - } else { - format!("{token_count} tokens") - }; - let status = format!( - "Mode: {} | Model: {} | Context: {} | Turns: {} | CWD: {}", - state.mode.to_uppercase(), - state.model, - context_info, - conv.turn_count, - app.cwd.display(), - ); - drop(state); - app.session.push_system_message(status); - CommandEffect::Done - } -} - pub struct PlanCmd; #[async_trait] diff --git a/crates/loopal-tui/src/command/mod.rs b/crates/loopal-tui/src/command/mod.rs index dc0ff60..684de7e 100644 --- a/crates/loopal-tui/src/command/mod.rs +++ b/crates/loopal-tui/src/command/mod.rs @@ -9,6 +9,8 @@ pub mod registry; mod resume_cmd; mod rewind_cmd; mod skill; +mod status_cmd; +mod status_config; mod topology_cmd; use async_trait::async_trait; diff --git a/crates/loopal-tui/src/command/status_cmd.rs b/crates/loopal-tui/src/command/status_cmd.rs new file mode 100644 index 0000000..345282a --- /dev/null +++ b/crates/loopal-tui/src/command/status_cmd.rs @@ -0,0 +1,114 @@ +//! `/status` command — opens the status dashboard sub-page. + +use async_trait::async_trait; + +use super::status_config::{build_config_entries, extract_provider_info}; +use super::{CommandEffect, CommandHandler}; +use crate::app::{ + App, ConfigSnapshot, SessionSnapshot, StatusPageState, StatusTab, SubPage, UsageSnapshot, +}; + +pub struct StatusCmd; + +#[async_trait] +impl CommandHandler for StatusCmd { + fn name(&self) -> &str { + "/status" + } + fn description(&self) -> &str { + "Show status dashboard" + } + async fn execute(&self, app: &mut App, _arg: Option<&str>) -> CommandEffect { + open_status_page(app).await; + CommandEffect::Done + } +} + +async fn open_status_page(app: &mut App) { + let (mut session, usage) = collect_session_data(app); + let config = collect_config_snapshot(app); + + // Hub listener port requires async lock — resolve outside the sync session lock. + if let Some(port) = app.session.hub_listener_port().await { + session.hub_endpoint = format!("127.0.0.1:{port}"); + } + + app.sub_page = Some(SubPage::StatusPage(StatusPageState { + active_tab: StatusTab::Status, + session, + config, + usage, + scroll_offsets: [0; 3], + filter: String::new(), + filter_cursor: 0, + })); +} + +/// Extract session/agent data from the locked session state. +fn collect_session_data(app: &App) -> (SessionSnapshot, UsageSnapshot) { + let state = app.session.lock(); + let conv = state.active_conversation(); + let agent = state + .agents + .get(&state.active_view) + .expect("active_view must exist in agents map"); + let obs = &agent.observable; + + let session = SessionSnapshot { + session_id: state + .root_session_id + .clone() + .unwrap_or_else(|| "N/A".to_string()), + cwd: app.cwd.display().to_string(), + model_display: state.model.clone(), + mode: state.mode.clone(), + hub_endpoint: String::new(), + }; + + let usage = UsageSnapshot { + input_tokens: obs.input_tokens, + output_tokens: obs.output_tokens, + context_window: conv.context_window, + context_used: conv.token_count(), + turn_count: obs.turn_count, + tool_count: obs.tool_count, + }; + (session, usage) +} + +/// Load config from disk and build ConfigSnapshot. +fn collect_config_snapshot(app: &App) -> ConfigSnapshot { + let config = match loopal_config::load_config(&app.cwd) { + Ok(c) => c, + Err(_) => { + return ConfigSnapshot { + auth_env: String::new(), + base_url: String::new(), + mcp_configured: 0, + mcp_enabled: 0, + setting_sources: vec!["(failed to load)".to_string()], + entries: Vec::new(), + }; + } + }; + + let sources: Vec = config.layers.iter().map(|l| l.to_string()).collect(); + let mcp_configured = config.mcp_servers.len(); + let mcp_enabled = config + .mcp_servers + .values() + .filter(|e| e.config.enabled()) + .count(); + + let (auth_env, base_url) = extract_provider_info(&config.settings.providers); + let entries = build_config_entries(&config.settings); + + ConfigSnapshot { + auth_env, + base_url, + mcp_configured, + mcp_enabled, + setting_sources: sources, + entries, + } +} diff --git a/crates/loopal-tui/src/command/status_config.rs b/crates/loopal-tui/src/command/status_config.rs new file mode 100644 index 0000000..7162dc0 --- /dev/null +++ b/crates/loopal-tui/src/command/status_config.rs @@ -0,0 +1,120 @@ +//! Config data collection for the `/status` sub-page. +//! +//! Serializes `Settings` to JSON, recursively flattens to dot-notation entries, +//! and extracts provider auth/URL info. + +use crate::app::ConfigEntry; + +/// Serialize settings to JSON and recursively flatten to dot-notation key-value pairs. +pub(super) fn build_config_entries(settings: &loopal_config::Settings) -> Vec { + let value = match serde_json::to_value(settings) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let mut entries = Vec::new(); + flatten_json("", &value, &mut entries, 0); + entries +} + +/// Extract the primary provider's auth env var name and base URL. +/// Checks providers in priority order: Anthropic → OpenAI → Google. +pub(super) fn extract_provider_info( + providers: &loopal_config::ProvidersConfig, +) -> (String, String) { + let candidates = [ + (&providers.anthropic, "ANTHROPIC_API_KEY"), + (&providers.openai, "OPENAI_API_KEY"), + (&providers.google, "GOOGLE_API_KEY"), + ]; + for (provider, default_env) in &candidates { + if let Some(p) = provider { + // Skip providers with no key configured at all. + if p.api_key.is_none() && p.api_key_env.is_none() { + continue; + } + let env = p.api_key_env.clone().unwrap_or_else(|| { + if p.api_key.is_some() { + "(direct key)".to_string() + } else { + (*default_env).to_string() + } + }); + let url = p.base_url.clone().unwrap_or_default(); + return (env, url); + } + } + (String::new(), String::new()) +} + +// --------------------------------------------------------------------------- +// JSON flattening +// --------------------------------------------------------------------------- + +const MAX_JSON_DEPTH: usize = 10; + +/// Recursively flatten a JSON value into dot-notation `ConfigEntry` pairs. +/// Secrets (keys ending with "api_key") are redacted. Depth is bounded. +fn flatten_json(prefix: &str, value: &serde_json::Value, out: &mut Vec, depth: usize) { + if depth > MAX_JSON_DEPTH { + out.push(ConfigEntry { + key: prefix.to_string(), + value: "(truncated)".to_string(), + }); + return; + } + match value { + serde_json::Value::Object(map) if map.is_empty() => { + out.push(ConfigEntry { + key: prefix.to_string(), + value: "{}".to_string(), + }); + } + serde_json::Value::Object(map) => { + for (k, v) in map { + let key = if prefix.is_empty() { + k.clone() + } else { + format!("{prefix}.{k}") + }; + flatten_json(&key, v, out, depth + 1); + } + } + serde_json::Value::Array(arr) if arr.is_empty() => { + out.push(ConfigEntry { + key: prefix.to_string(), + value: "[]".to_string(), + }); + } + serde_json::Value::Array(arr) => { + out.push(ConfigEntry { + key: prefix.to_string(), + value: format!("[{} items]", arr.len()), + }); + } + _ => { + let is_secret = prefix + .rsplit('.') + .next() + .is_some_and(|field| field == "api_key"); + let display = if is_secret && !value.is_null() { + "********".to_string() + } else { + format_scalar(value) + }; + out.push(ConfigEntry { + key: prefix.to_string(), + value: display, + }); + } + } +} + +fn format_scalar(v: &serde_json::Value) -> String { + match v { + serde_json::Value::Null => "null".to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.clone(), + _ => v.to_string(), + } +} diff --git a/crates/loopal-tui/src/input/mod.rs b/crates/loopal-tui/src/input/mod.rs index 4e790af..98417a1 100644 --- a/crates/loopal-tui/src/input/mod.rs +++ b/crates/loopal-tui/src/input/mod.rs @@ -6,6 +6,7 @@ mod modal; pub(crate) mod multiline; mod navigation; pub(crate) mod paste; +mod status_page_keys; mod sub_page; mod sub_page_rewind; diff --git a/crates/loopal-tui/src/input/status_page_keys.rs b/crates/loopal-tui/src/input/status_page_keys.rs new file mode 100644 index 0000000..5ec0172 --- /dev/null +++ b/crates/loopal-tui/src/input/status_page_keys.rs @@ -0,0 +1,63 @@ +//! Key handler for the status page sub-page. + +use crossterm::event::{KeyCode, KeyEvent}; + +use super::InputAction; +use crate::app::{App, StatusTab, SubPage}; + +/// Handle keys when the status page sub-page is active. +pub(super) fn handle_status_page_key(app: &mut App, key: &KeyEvent) -> InputAction { + let state = match app.sub_page.as_mut() { + Some(SubPage::StatusPage(s)) => s, + _ => return InputAction::None, + }; + + match key.code { + KeyCode::Esc => { + app.sub_page = None; + app.last_esc_time = None; + InputAction::None + } + KeyCode::Left => { + state.active_tab = state.active_tab.prev(); + InputAction::None + } + KeyCode::Right => { + state.active_tab = state.active_tab.next(); + InputAction::None + } + KeyCode::Up => { + let scroll = state.active_scroll_mut(); + *scroll = scroll.saturating_sub(1); + InputAction::None + } + KeyCode::Down => { + let max = state.active_row_count().saturating_sub(1); + let scroll = state.active_scroll_mut(); + if *scroll < max { + *scroll += 1; + } + InputAction::None + } + KeyCode::Char(c) if state.active_tab == StatusTab::Config => { + state.filter.insert(state.filter_cursor, c); + state.filter_cursor += c.len_utf8(); + state.scroll_offsets[StatusTab::Config.index()] = 0; + InputAction::None + } + KeyCode::Backspace if state.active_tab == StatusTab::Config => { + if state.filter_cursor > 0 { + let prev = state.filter[..state.filter_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.filter.remove(prev); + state.filter_cursor = prev; + state.scroll_offsets[StatusTab::Config.index()] = 0; + } + InputAction::None + } + _ => InputAction::None, + } +} diff --git a/crates/loopal-tui/src/input/sub_page.rs b/crates/loopal-tui/src/input/sub_page.rs index c51e640..36c7717 100644 --- a/crates/loopal-tui/src/input/sub_page.rs +++ b/crates/loopal-tui/src/input/sub_page.rs @@ -2,6 +2,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::{App, PickerState, SubPage}; +use super::status_page_keys::handle_status_page_key; use super::sub_page_rewind::handle_rewind_picker_key; use super::{InputAction, SubPageResult}; @@ -25,6 +26,7 @@ pub(super) fn handle_sub_page_key(app: &mut App, key: &KeyEvent) -> InputAction SubPage::ModelPicker(_) => handle_model_picker_key(app, key), SubPage::RewindPicker(_) => handle_rewind_picker_key(app, key), SubPage::SessionPicker(_) => handle_session_picker_key(app, key), + SubPage::StatusPage(_) => handle_status_page_key(app, key), } } diff --git a/crates/loopal-tui/src/render.rs b/crates/loopal-tui/src/render.rs index 0c8eaab..cef88cc 100644 --- a/crates/loopal-tui/src/render.rs +++ b/crates/loopal-tui/src/render.rs @@ -36,7 +36,7 @@ pub fn draw(f: &mut Frame, app: &mut App) { ); // Sub-page mode: picker replaces f₁..f₄, only f₅ remains - if let Some(ref sub_page) = app.sub_page { + if let Some(ref mut sub_page) = app.sub_page { match sub_page { SubPage::ModelPicker(p) | SubPage::SessionPicker(p) => { views::picker::render_picker(f, p, layout.picker); @@ -44,6 +44,9 @@ pub fn draw(f: &mut Frame, app: &mut App) { SubPage::RewindPicker(r) => { views::rewind_picker::render_rewind_picker(f, r, layout.picker); } + SubPage::StatusPage(s) => { + views::status_page::render_status_page(f, s, layout.picker); + } } views::unified_status::render_unified_status(f, &state, layout.status); return; diff --git a/crates/loopal-tui/src/views/mod.rs b/crates/loopal-tui/src/views/mod.rs index 0b6ea44..a7ab22b 100644 --- a/crates/loopal-tui/src/views/mod.rs +++ b/crates/loopal-tui/src/views/mod.rs @@ -9,6 +9,7 @@ pub mod question_dialog; pub mod retry_banner; pub mod rewind_picker; pub mod separator; +pub mod status_page; pub mod tool_confirm; pub mod topology_overlay; pub mod unified_status; diff --git a/crates/loopal-tui/src/views/status_page/config_tab.rs b/crates/loopal-tui/src/views/status_page/config_tab.rs new file mode 100644 index 0000000..fbbe6df --- /dev/null +++ b/crates/loopal-tui/src/views/status_page/config_tab.rs @@ -0,0 +1,105 @@ +//! Config tab — searchable settings key-value list. + +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use unicode_width::UnicodeWidthStr; + +use crate::app::StatusPageState; + +/// Render the Config tab. Returns the total filtered row count for scroll clamping. +pub(super) fn render_config_tab(f: &mut Frame, state: &StatusPageState, area: Rect) -> usize { + if area.height < 3 { + return state.filtered_config().len(); + } + + // Row 0: filter input + let filter_area = Rect::new(area.x, area.y, area.width, 1); + let filter_line = Line::from(vec![ + Span::styled(" Filter: ", Style::default().fg(Color::DarkGray)), + Span::styled(&state.filter, Style::default().fg(Color::White).bold()), + Span::styled("\u{2588}", Style::default().fg(Color::DarkGray)), + ]); + f.render_widget(Paragraph::new(filter_line), filter_area); + + // Row 1: separator + let sep_area = Rect::new(area.x, area.y + 1, area.width, 1); + let sep = "\u{2500}".repeat(area.width as usize); + f.render_widget( + Paragraph::new(sep).style(Style::default().fg(Color::Rgb(50, 50, 50))), + sep_area, + ); + + // Rows 2+: config entries + let list_y = area.y + 2; + let list_height = area.height.saturating_sub(2) as usize; + let filtered = state.filtered_config(); + let total = filtered.len(); + + if filtered.is_empty() { + let empty_area = Rect::new(area.x, list_y, area.width, 1); + f.render_widget( + Paragraph::new(" No matching settings").style(Style::default().fg(Color::DarkGray)), + empty_area, + ); + return total; + } + + let scroll = state.active_scroll(); + // Clamp: prevent scrolling past the point where last row is at bottom. + let scroll = scroll.min(filtered.len().saturating_sub(list_height)); + + for (i, entry) in filtered.iter().skip(scroll).take(list_height).enumerate() { + let y = list_y + i as u16; + if y >= area.y + area.height { + break; + } + + // Key column: fixed display-width, right-padded. Truncate if wider. + let key_width = 36.min(area.width as usize / 2); + let key_text = pad_to_width(&entry.key, key_width); + let val_text = &entry.value; + + let line = Line::from(vec![ + Span::styled(format!(" {key_text}"), Style::default().fg(Color::Cyan)), + Span::styled(val_text, Style::default().fg(Color::White)), + ]); + + let row_area = Rect::new(area.x, y, area.width, 1); + f.render_widget(Paragraph::new(line), row_area); + } + total +} + +/// Truncate or pad a string to exactly `target_width` terminal columns. +fn pad_to_width(s: &str, target_width: usize) -> String { + let w = UnicodeWidthStr::width(s); + if w <= target_width { + // Pad with spaces to fill remaining columns. + let padding = target_width - w; + let mut out = s.to_string(); + for _ in 0..padding { + out.push(' '); + } + out + } else { + // Truncate to fit, appending "…" (1 column wide). + let mut out = String::new(); + let mut used = 0; + let limit = target_width.saturating_sub(1); // reserve 1 col for "…" + for ch in s.chars() { + let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if used + cw > limit { + break; + } + out.push(ch); + used += cw; + } + out.push('\u{2026}'); + // Pad if truncation left a gap (e.g. wide char didn't fit). + let remaining = target_width.saturating_sub(used + 1); + for _ in 0..remaining { + out.push(' '); + } + out + } +} diff --git a/crates/loopal-tui/src/views/status_page/mod.rs b/crates/loopal-tui/src/views/status_page/mod.rs new file mode 100644 index 0000000..7dd5700 --- /dev/null +++ b/crates/loopal-tui/src/views/status_page/mod.rs @@ -0,0 +1,105 @@ +//! Status page sub-page — tabbed dashboard with Status / Config / Usage. + +mod config_tab; +mod status_tab; +mod usage_tab; + +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Clear}; + +use crate::app::{StatusPageState, StatusTab}; + +/// Render the full-screen status page sub-page. +/// +/// Takes `&mut` to write back the clamped scroll offset after rendering, +/// preventing scroll accumulation beyond the visible content. +pub fn render_status_page(f: &mut Frame, state: &mut StatusPageState, area: Rect) { + f.render_widget(Clear, area); + + let tab_bar = build_tab_bar(state.active_tab); + let hint_bar = build_hint_bar(state.active_tab); + + let block = Block::default() + .borders(Borders::ALL) + .title(tab_bar) + .title_bottom(hint_bar) + .border_style(Style::default().fg(Color::DarkGray)); + + let inner = block.inner(area); + f.render_widget(block, area); + + if inner.height < 2 { + let row_count = state.active_row_count(); + clamp_scroll(state, inner.height, row_count); + return; + } + + // Each tab renderer returns its total row count so clamp_scroll + // does not need to recompute filtered_config(). + let row_count = match state.active_tab { + StatusTab::Status => status_tab::render_status_tab(f, state, inner), + StatusTab::Config => config_tab::render_config_tab(f, state, inner), + StatusTab::Usage => usage_tab::render_usage_tab(f, state, inner), + }; + + // Write back clamped scroll so the key handler never accumulates + // beyond what the render can actually display. + clamp_scroll(state, inner.height, row_count); +} + +/// Clamp the active tab's scroll offset to the renderable range. +fn clamp_scroll(state: &mut StatusPageState, inner_height: u16, row_count: usize) { + let content_height = match state.active_tab { + StatusTab::Config => (inner_height.saturating_sub(2)) as usize, // header + separator + _ => inner_height as usize, + }; + let max_scroll = row_count.saturating_sub(content_height); + let scroll = state.active_scroll_mut(); + if *scroll > max_scroll { + *scroll = max_scroll; + } +} + +/// Build the tab bar title with active tab highlighted. +fn build_tab_bar(active: StatusTab) -> Line<'static> { + let mut spans = Vec::with_capacity(8); + spans.push(Span::raw(" ")); + + for (i, tab) in StatusTab::ALL.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", Style::default().fg(Color::DarkGray))); + } + if *tab == active { + spans.push(Span::styled( + tab.label(), + Style::default().fg(Color::Cyan).bold(), + )); + } else { + spans.push(Span::styled( + tab.label(), + Style::default().fg(Color::DarkGray), + )); + } + } + + spans.push(Span::raw(" ")); + Line::from(spans) +} + +/// Build the bottom hint bar. +fn build_hint_bar(active: StatusTab) -> Line<'static> { + let mut spans = vec![ + Span::raw(" "), + Span::styled("\u{2190}/\u{2192}", Style::default().fg(Color::Cyan)), + Span::raw(" tab "), + Span::styled("\u{2191}/\u{2193}", Style::default().fg(Color::Cyan)), + Span::raw(" scroll "), + ]; + if active == StatusTab::Config { + spans.push(Span::styled("type", Style::default().fg(Color::Green))); + spans.push(Span::raw(" filter ")); + } + spans.push(Span::styled("Esc", Style::default().fg(Color::Yellow))); + spans.push(Span::raw(" close ")); + Line::from(spans) +} diff --git a/crates/loopal-tui/src/views/status_page/status_tab.rs b/crates/loopal-tui/src/views/status_page/status_tab.rs new file mode 100644 index 0000000..1acbc7c --- /dev/null +++ b/crates/loopal-tui/src/views/status_page/status_tab.rs @@ -0,0 +1,106 @@ +//! Status tab — key-value display of session metadata. + +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; + +use crate::app::StatusPageState; + +/// Key-value rows for the Status tab. +struct StatusRow { + label: &'static str, + value: String, + value_style: Style, +} + +/// Render the Status tab. Returns the total row count for scroll clamping. +pub(super) fn render_status_tab(f: &mut Frame, state: &StatusPageState, area: Rect) -> usize { + let s = &state.session; + let c = &state.config; + let mode_style = if s.mode == "plan" { + Style::default().fg(Color::White).bold() + } else { + Style::default().fg(Color::Green).bold() + }; + + let rows = [ + row("Session ID", &s.session_id, default_style()), + row("CWD", &s.cwd, Style::default().fg(Color::White)), + row("Auth Token", &display_or_none(&c.auth_env), default_style()), + row( + "Base URL", + &display_or_default(&c.base_url), + default_style(), + ), + row("Model", &s.model_display, Style::default().fg(Color::Cyan)), + row("Mode", &s.mode.to_uppercase(), mode_style), + row( + "Hub Endpoint", + &display_or_none(&s.hub_endpoint), + default_style(), + ), + row( + "MCP Servers", + &mcp_summary(c.mcp_configured, c.mcp_enabled), + default_style(), + ), + row("Sources", &c.setting_sources.join(", "), default_style()), + ]; + + let scroll = state.active_scroll(); + let visible = area.height as usize; + // Clamp: when all rows fit on screen, no scrolling needed. + let scroll = scroll.min(rows.len().saturating_sub(visible)); + + for (i, r) in rows.iter().skip(scroll).take(visible).enumerate() { + let y = area.y + i as u16; + if y >= area.y + area.height { + break; + } + let row_area = Rect::new(area.x, y, area.width, 1); + let line = Line::from(vec![ + Span::styled( + format!(" {:<16}", r.label), + Style::default().fg(Color::DarkGray), + ), + Span::styled(&r.value, r.value_style), + ]); + f.render_widget(Paragraph::new(line), row_area); + } + rows.len() +} + +fn row(label: &'static str, value: &str, style: Style) -> StatusRow { + StatusRow { + label, + value: value.to_string(), + value_style: style, + } +} + +fn default_style() -> Style { + Style::default().fg(Color::White) +} + +fn display_or_default(s: &str) -> String { + if s.is_empty() { + "(default)".to_string() + } else { + s.to_string() + } +} + +fn display_or_none(s: &str) -> String { + if s.is_empty() { + "(none)".to_string() + } else { + s.to_string() + } +} + +fn mcp_summary(configured: usize, enabled: usize) -> String { + if configured == 0 { + "none configured".to_string() + } else { + format!("{configured} configured, {enabled} enabled") + } +} diff --git a/crates/loopal-tui/src/views/status_page/usage_tab.rs b/crates/loopal-tui/src/views/status_page/usage_tab.rs new file mode 100644 index 0000000..3e88313 --- /dev/null +++ b/crates/loopal-tui/src/views/status_page/usage_tab.rs @@ -0,0 +1,102 @@ +//! Usage tab — token metrics and session statistics. + +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; + +use crate::app::StatusPageState; + +/// A labeled metric row. +struct MetricRow { + label: &'static str, + value: String, + style: Style, +} + +/// Render the Usage tab. Returns the total row count for scroll clamping. +pub(super) fn render_usage_tab(f: &mut Frame, state: &StatusPageState, area: Rect) -> usize { + let u = &state.usage; + + let ctx_display = if u.context_window > 0 { + format!("{}k / {}k", u.context_used / 1000, u.context_window / 1000) + } else { + format_tokens(u.context_used) + }; + + let rows = [ + metric("Input Tokens", format_tokens(u.input_tokens), val_style()), + metric("Output Tokens", format_tokens(u.output_tokens), val_style()), + separator_row(), + metric("Context Window", ctx_display, val_style()), + separator_row(), + metric("Turns", u.turn_count.to_string(), val_style()), + metric("Tool Calls", u.tool_count.to_string(), val_style()), + ]; + + let scroll = state.active_scroll(); + let visible = area.height as usize; + // Clamp: when all rows fit on screen, no scrolling needed. + let scroll = scroll.min(rows.len().saturating_sub(visible)); + + for (i, row) in rows.iter().skip(scroll).take(visible).enumerate() { + let y = area.y + i as u16; + if y >= area.y + area.height { + break; + } + let row_area = Rect::new(area.x, y, area.width, 1); + + if row.label == "---" { + let sep = "\u{2500}".repeat((area.width as usize).min(40)); + f.render_widget( + Paragraph::new(format!(" {sep}")) + .style(Style::default().fg(Color::Rgb(50, 50, 50))), + row_area, + ); + } else { + let line = Line::from(vec![ + Span::styled( + format!(" {:<20}", row.label), + Style::default().fg(Color::DarkGray), + ), + Span::styled(&row.value, row.style), + ]); + f.render_widget(Paragraph::new(line), row_area); + } + } + rows.len() +} + +fn metric(label: &'static str, value: String, style: Style) -> MetricRow { + MetricRow { + label, + value, + style, + } +} + +fn separator_row() -> MetricRow { + MetricRow { + label: "---", + value: String::new(), + style: Style::default(), + } +} + +fn val_style() -> Style { + Style::default().fg(Color::White) +} + +/// Format token count with thousand separators. +fn format_tokens(n: u32) -> String { + if n < 1_000 { + return n.to_string(); + } + let s = n.to_string(); + let mut result = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + result.chars().rev().collect() +} diff --git a/crates/loopal-tui/tests/suite/command_edge_test.rs b/crates/loopal-tui/tests/suite/command_edge_test.rs index f13dd83..637a6e2 100644 --- a/crates/loopal-tui/tests/suite/command_edge_test.rs +++ b/crates/loopal-tui/tests/suite/command_edge_test.rs @@ -57,19 +57,13 @@ async fn test_exit_cmd_returns_quit() { } #[tokio::test] -async fn test_status_cmd_pushes_system_message() { +async fn test_status_cmd_opens_sub_page() { let mut app = make_app(); + assert!(app.sub_page.is_none()); let handler = app.command_registry.find("/status").unwrap(); let effect = handler.execute(&mut app, None).await; assert!(matches!(effect, loopal_tui::command::CommandEffect::Done)); - let state = app.session.lock(); - let last = state - .active_conversation() - .messages - .last() - .expect("expected a status message"); - assert!(last.content.contains("Model:")); - assert!(last.content.contains("Mode:")); + assert!(app.sub_page.is_some()); } #[tokio::test] diff --git a/src/bootstrap/hub_bootstrap.rs b/src/bootstrap/hub_bootstrap.rs index a4681d2..7a99453 100644 --- a/src/bootstrap/hub_bootstrap.rs +++ b/src/bootstrap/hub_bootstrap.rs @@ -32,7 +32,8 @@ pub async fn bootstrap_hub_and_agent( let (event_tx, event_rx) = mpsc::channel(256); let hub = Arc::new(Mutex::new(Hub::new(event_tx))); - let (listener, _port, hub_token) = hub_server::start_hub_listener(hub.clone()).await?; + let (listener, port, hub_token) = hub_server::start_hub_listener(hub.clone()).await?; + hub.lock().await.listener_port = Some(port); let hub_accept = hub.clone(); tokio::spawn(async move { hub_server::accept_loop(listener, hub_accept, hub_token).await;