Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/loopal-agent-hub/src/hub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<HubUplink>>,
/// TCP listener port, set after `start_hub_listener`. `None` if not listening.
pub listener_port: Option<u16>,
}

impl Hub {
Expand All @@ -32,6 +34,7 @@ impl Hub {
registry: AgentRegistry::new(event_tx),
ui: UiDispatcher::new(),
uplink: None,
listener_port: None,
}
}

Expand All @@ -42,6 +45,7 @@ impl Hub {
registry: AgentRegistry::new(tx),
ui: UiDispatcher::new(),
uplink: None,
listener_port: None,
}
}
}
5 changes: 5 additions & 0 deletions crates/loopal-session/src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16> {
self.connections.lock().await.listener_port
}

pub(crate) fn active_target(&self) -> String {
self.lock().active_view.clone()
}
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
mod status_page;
mod types;

pub use status_page::*;
pub use types::*;

use std::collections::HashMap;
Expand Down
134 changes: 134 additions & 0 deletions crates/loopal-tui/src/app/status_page.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub entries: Vec<ConfigEntry>,
}

/// 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;
4 changes: 4 additions & 0 deletions crates/loopal-tui/src/app/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
34 changes: 1 addition & 33 deletions crates/loopal-tui/src/command/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-tui/src/command/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
114 changes: 114 additions & 0 deletions crates/loopal-tui/src/command/status_cmd.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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,
}
}
Loading
Loading