zeroclaw/src/tui/mod.rs
argenis de la rosa 407dbcbdd7 feat(tui): add TUI dependencies, feature flag, and module skeleton
Add ratatui, crossterm, and tui-textarea as optional dependencies behind
the `tui` feature flag. Create the `src/tui/` module with ActiveTab enum,
TuiApp struct with event loop, and placeholder tab renderers. Wire up
the `zeroclaw tui` CLI subcommand (cfg-gated) in main.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:54:50 -04:00

185 lines
5.5 KiB
Rust

//! Terminal User Interface for ZeroClaw.
//!
//! Provides a rich terminal dashboard with tabs for status, channels,
//! chat, logs, and configuration. Enable with `--features tui`.
pub mod gateway_client;
pub mod tabs;
pub mod theme;
use anyhow::Result;
/// Which tab is currently active.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActiveTab {
Dashboard,
Channels,
Chat,
Logs,
Config,
}
impl ActiveTab {
pub fn label(&self) -> &'static str {
match self {
Self::Dashboard => "Dashboard",
Self::Channels => "Channels",
Self::Chat => "Chat",
Self::Logs => "Logs",
Self::Config => "Config",
}
}
pub fn all() -> &'static [ActiveTab] {
&[
Self::Dashboard,
Self::Channels,
Self::Chat,
Self::Logs,
Self::Config,
]
}
pub fn next(&self) -> Self {
match self {
Self::Dashboard => Self::Channels,
Self::Channels => Self::Chat,
Self::Chat => Self::Logs,
Self::Logs => Self::Config,
Self::Config => Self::Dashboard,
}
}
pub fn prev(&self) -> Self {
match self {
Self::Dashboard => Self::Config,
Self::Channels => Self::Dashboard,
Self::Chat => Self::Channels,
Self::Logs => Self::Chat,
Self::Config => Self::Logs,
}
}
}
/// Main TUI application state.
pub struct TuiApp {
active_tab: ActiveTab,
gateway_url: String,
token: Option<String>,
should_quit: bool,
}
impl TuiApp {
pub fn new(gateway_url: String, token: Option<String>) -> Self {
Self {
active_tab: ActiveTab::Dashboard,
gateway_url,
token,
should_quit: false,
}
}
/// Run the TUI event loop (stub -- wired up in PR-15).
pub async fn run(&mut self) -> Result<()> {
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
use ratatui::prelude::*;
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
while !self.should_quit {
terminal.draw(|frame| {
self.render(frame);
})?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => self.should_quit = true,
(_, KeyCode::Char('q')) => self.should_quit = true,
(_, KeyCode::Tab) => self.active_tab = self.active_tab.next(),
(KeyModifiers::SHIFT, KeyCode::BackTab) => {
self.active_tab = self.active_tab.prev();
}
_ => {}
}
}
}
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn render(&self, frame: &mut ratatui::Frame) {
use ratatui::{prelude::*, widgets::*};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Tab bar
Constraint::Min(0), // Content
Constraint::Length(1), // Status bar
])
.split(frame.area());
// Tab bar
let titles: Vec<Line> = ActiveTab::all()
.iter()
.map(|t| {
let style = if *t == self.active_tab {
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::FG_DIM)
};
Line::from(t.label()).style(style)
})
.collect();
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.title(" ZeroClaw TUI "),
)
.highlight_style(Style::default().fg(theme::ACCENT))
.select(
ActiveTab::all()
.iter()
.position(|t| *t == self.active_tab)
.unwrap_or(0),
);
frame.render_widget(tabs, chunks[0]);
// Content area -- dispatch to tab renderer
let content_area = chunks[1];
match self.active_tab {
ActiveTab::Dashboard => tabs::dashboard::render(frame, content_area),
ActiveTab::Channels => tabs::channels::render(frame, content_area),
ActiveTab::Chat => tabs::chat::render(frame, content_area),
ActiveTab::Logs => tabs::logs::render(frame, content_area),
ActiveTab::Config => tabs::config::render(frame, content_area),
}
// Status bar
let status =
Paragraph::new(" Tab/Shift+Tab: switch tabs | q: quit | Ctrl+C: force quit")
.style(Style::default().fg(theme::FG_DIM));
frame.render_widget(status, chunks[2]);
}
}