use std::io;
use std::sync::Arc;
use std::time::Duration;
use crate::terminal_widget::VtTerminal;
use crate::ui;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::{broadcast, mpsc, watch};
use uuid::Uuid;
use vex_hub::{FrontendCommand, FrontendEvent, Hub, HubState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Panel {
Sidebar,
Main,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SidebarSection {
Repos,
Agents,
Shells,
Config,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Dashboard,
ShellTerminal(Uuid),
AgentConversation(Uuid),
PromptInput(Uuid),
SpawnPicker,
}
pub struct App {
pub state: HubState,
pub panel: Panel,
pub sidebar_section: SidebarSection,
pub sidebar_index: usize,
pub main_scroll: usize,
pub screen: Screen,
pub running: bool,
pub status_message: Option<String>,
pub vt_terminals: std::collections::HashMap<Uuid, VtTerminal>,
pub agent_lines: std::collections::HashMap<Uuid, Vec<String>>,
pub prompt_buf: String,
pub spawn_repo_index: usize,
pub port: u16,
#[allow(dead_code)]
pub web_running: bool,
}
impl App {
fn new(port: u16) -> Self {
Self {
state: HubState::default(),
panel: Panel::Sidebar,
sidebar_section: SidebarSection::Agents,
sidebar_index: 0,
main_scroll: 0,
screen: Screen::Dashboard,
running: true,
status_message: None,
vt_terminals: std::collections::HashMap::new(),
agent_lines: std::collections::HashMap::new(),
prompt_buf: String::new(),
spawn_repo_index: 0,
port,
web_running: false,
}
}
pub(crate) fn sidebar_items(&self) -> Vec<String> {
match self.sidebar_section {
SidebarSection::Repos => self.state.repos.iter().map(|r| r.name.clone()).collect(),
SidebarSection::Agents => self
.state
.agents
.iter()
.map(|a| {
let short_id = &a.vex_shell_id.to_string()[..8];
let status = if a.needs_intervention {
"NEEDS"
} else {
"idle"
};
format!("{} {}", short_id, status)
})
.collect(),
SidebarSection::Shells => self
.state
.shells
.iter()
.map(|s| s.id.to_string()[..8].to_string())
.collect(),
SidebarSection::Config => vec![
"Web UI".to_string(),
"Discord".to_string(),
"Telegram".to_string(),
],
}
}
fn clamp_sidebar_index(&mut self) {
let len = self.sidebar_items().len();
if len == 0 {
self.sidebar_index = 0;
} else if self.sidebar_index >= len {
self.sidebar_index = len - 1;
}
}
}
pub async fn run(port: u16) -> Result<()> {
let hub = Arc::new(Hub::new(port));
let mut state_rx = hub.state_rx();
let command_tx = hub.command_tx();
let mut event_rx = hub.event_rx();
let hub_clone = Arc::clone(&hub);
tokio::spawn(async move {
if let Err(e) = hub_clone.run().await {
eprintln!("hub error: {}", e);
}
});
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(port);
if let Ok(()) = state_rx.changed().await {
app.state = state_rx.borrow_and_update().clone();
}
let result = run_loop(
&mut terminal,
&mut app,
&mut state_rx,
&mut event_rx,
&command_tx,
)
.await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
state_rx: &mut watch::Receiver<HubState>,
event_rx: &mut broadcast::Receiver<FrontendEvent>,
command_tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
loop {
if !app.running {
break;
}
terminal.draw(|f| ui::draw(f, app))?;
tokio::select! {
_ = state_rx.changed() => {
app.state = state_rx.borrow_and_update().clone();
app.clamp_sidebar_index();
app.vt_terminals.retain(|id, _| {
app.state.shells.iter().any(|s| s.id == *id)
});
app.agent_lines.retain(|id, _| {
app.state.agents.iter().any(|a| a.vex_shell_id == *id)
});
}
result = event_rx.recv() => {
match result {
Ok(evt) => match evt {
FrontendEvent::ShellOutput { shell_id, data } => {
if let Some(vt) = app.vt_terminals.get_mut(&shell_id) {
vt.process(&data);
}
}
FrontendEvent::AgentConversationLine { shell_id, line } => {
app.agent_lines.entry(shell_id).or_default().push(line);
}
FrontendEvent::AgentWatchEnd { shell_id } => {
app.agent_lines.entry(shell_id).or_default().push("[watch ended]".to_string());
}
_ => {}
},
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
_ = tokio::time::sleep(Duration::from_millis(50)) => {
while event::poll(Duration::ZERO)? {
match event::read()? {
Event::Key(key) => {
handle_key(app, key, command_tx).await?;
}
Event::Resize(cols, rows) => {
if let Screen::ShellTerminal(id) = app.screen {
if let Some(vt) = app.vt_terminals.get_mut(&id) {
vt.resize(rows, cols);
}
let _ = command_tx
.send(FrontendCommand::ShellResize { id, cols, rows })
.await;
}
}
_ => {}
}
}
}
}
}
Ok(())
}
async fn handle_key(
app: &mut App,
key: KeyEvent,
command_tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.running = false;
return Ok(());
}
match app.screen {
Screen::Dashboard => handle_dashboard_key(app, key, command_tx).await,
Screen::ShellTerminal(id) => handle_terminal_key(app, key, id, command_tx).await,
Screen::AgentConversation(id) => handle_conversation_key(app, key, id, command_tx).await,
Screen::PromptInput(id) => handle_prompt_key(app, key, id, command_tx).await,
Screen::SpawnPicker => handle_spawn_key(app, key, command_tx).await,
}
}
async fn handle_dashboard_key(
app: &mut App,
key: KeyEvent,
command_tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
match key.code {
KeyCode::Char('q') => {
app.running = false;
}
KeyCode::Char('?') => {
app.status_message = Some(
"j/k:nav h/l:panel Enter:select s:spawn c:create k:kill p:prompt w:web q:quit"
.to_string(),
);
}
KeyCode::Char('j') | KeyCode::Down => {
if app.panel == Panel::Sidebar {
app.sidebar_index = app.sidebar_index.saturating_add(1);
app.clamp_sidebar_index();
} else {
app.main_scroll = app.main_scroll.saturating_add(1);
}
}
KeyCode::Char('k') | KeyCode::Up => {
if app.panel == Panel::Sidebar {
app.sidebar_index = app.sidebar_index.saturating_sub(1);
} else {
app.main_scroll = app.main_scroll.saturating_sub(1);
}
}
KeyCode::Char('h') | KeyCode::Left => {
app.panel = Panel::Sidebar;
}
KeyCode::Char('l') | KeyCode::Right => {
app.panel = Panel::Main;
}
KeyCode::Tab => {
app.sidebar_section = match app.sidebar_section {
SidebarSection::Repos => SidebarSection::Agents,
SidebarSection::Agents => SidebarSection::Shells,
SidebarSection::Shells => SidebarSection::Config,
SidebarSection::Config => SidebarSection::Repos,
};
app.sidebar_index = 0;
}
KeyCode::BackTab => {
app.sidebar_section = match app.sidebar_section {
SidebarSection::Repos => SidebarSection::Config,
SidebarSection::Agents => SidebarSection::Repos,
SidebarSection::Shells => SidebarSection::Agents,
SidebarSection::Config => SidebarSection::Shells,
};
app.sidebar_index = 0;
}
KeyCode::Enter => {
match app.sidebar_section {
SidebarSection::Agents => {
if let Some(agent) = app.state.agents.get(app.sidebar_index) {
let id = agent.vex_shell_id;
let _ = command_tx
.send(FrontendCommand::AgentWatch { shell_id: id })
.await;
app.main_scroll = 0;
app.screen = Screen::AgentConversation(id);
}
}
SidebarSection::Shells => {
if let Some(shell) = app.state.shells.get(app.sidebar_index) {
let id = shell.id;
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
app.vt_terminals
.entry(id)
.or_insert_with(|| VtTerminal::new(cols, rows));
let _ = command_tx
.send(FrontendCommand::ShellAttach { id, cols, rows })
.await;
app.main_scroll = 0;
app.screen = Screen::ShellTerminal(id);
}
}
_ => {}
}
}
KeyCode::Char('s') => {
if !app.state.repos.is_empty() {
app.spawn_repo_index = 0;
app.screen = Screen::SpawnPicker;
} else {
app.status_message = Some("no repos registered".to_string());
}
}
KeyCode::Char('c') => {
let _ = command_tx
.send(FrontendCommand::ShellCreate {
repo: None,
workstream: None,
})
.await;
app.status_message = Some("creating shell...".to_string());
}
KeyCode::Char('K') => {
match app.sidebar_section {
SidebarSection::Shells => {
if let Some(shell) = app.state.shells.get(app.sidebar_index) {
let _ = command_tx
.send(FrontendCommand::ShellKill { id: shell.id })
.await;
app.status_message =
Some(format!("killing shell {}...", &shell.id.to_string()[..8]));
}
}
SidebarSection::Agents => {
if let Some(agent) = app.state.agents.get(app.sidebar_index) {
let _ = command_tx
.send(FrontendCommand::ShellKill {
id: agent.vex_shell_id,
})
.await;
}
}
_ => {}
}
}
KeyCode::Char('p') => {
if app.sidebar_section == SidebarSection::Agents
&& let Some(agent) = app.state.agents.get(app.sidebar_index)
{
app.prompt_buf.clear();
app.screen = Screen::PromptInput(agent.vex_shell_id);
}
}
KeyCode::Char('r') => {
let _ = command_tx.send(FrontendCommand::RefreshState).await;
}
KeyCode::Esc => {
app.status_message = None;
}
_ => {}
}
Ok(())
}
async fn handle_terminal_key(
app: &mut App,
key: KeyEvent,
shell_id: Uuid,
command_tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
if key.code == KeyCode::Char(']') && key.modifiers.contains(KeyModifiers::CONTROL) {
let _ = command_tx
.send(FrontendCommand::ShellDetach { id: shell_id })
.await;
app.main_scroll = 0;
app.screen = Screen::Dashboard;
return Ok(());
}
let data = key_to_bytes(key);
if !data.is_empty() {
let _ = command_tx
.send(FrontendCommand::ShellInput { id: shell_id, data })
.await;
}
Ok(())
}
async fn handle_conversation_key(
app: &mut App,
key: KeyEvent,
shell_id: Uuid,
_command_tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.main_scroll = 0;
app.screen = Screen::Dashboard;
}
KeyCode::Char('j') | KeyCode::Down => {
app.main_scroll = app.main_scroll.saturating_add(1);
}
KeyCode::Char('k') | KeyCode::Up => {
app.main_scroll = app.main_scroll.saturating_sub(1);
}
KeyCode::Char('p') => {
app.prompt_buf.clear();
app.screen = Screen::PromptInput(shell_id);
}
_ => {}
}
Ok(())
}
async fn handle_prompt_key(
app: &mut App,
key: KeyEvent,
shell_id: Uuid,
command_tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
match key.code {
KeyCode::Esc => {
app.screen = Screen::AgentConversation(shell_id);
}
KeyCode::Enter => {
if !app.prompt_buf.is_empty() {
let text = app.prompt_buf.clone();
let _ = command_tx
.send(FrontendCommand::AgentPrompt { shell_id, text })
.await;
app.prompt_buf.clear();
app.screen = Screen::AgentConversation(shell_id);
let _ = command_tx
.send(FrontendCommand::AgentWatch { shell_id })
.await;
}
}
KeyCode::Backspace => {
app.prompt_buf.pop();
}
KeyCode::Char(c) => {
app.prompt_buf.push(c);
}
_ => {}
}
Ok(())
}
async fn handle_spawn_key(
app: &mut App,
key: KeyEvent,
command_tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
match key.code {
KeyCode::Esc => {
app.screen = Screen::Dashboard;
}
KeyCode::Char('j') | KeyCode::Down => {
if app.spawn_repo_index + 1 < app.state.repos.len() {
app.spawn_repo_index += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
app.spawn_repo_index = app.spawn_repo_index.saturating_sub(1);
}
KeyCode::Enter => {
if let Some(repo) = app.state.repos.get(app.spawn_repo_index) {
let _ = command_tx
.send(FrontendCommand::AgentSpawn {
repo: repo.name.clone(),
workstream: None,
})
.await;
app.status_message = Some(format!("spawning agent in {}...", repo.name));
app.screen = Screen::Dashboard;
}
}
_ => {}
}
Ok(())
}
fn key_to_bytes(key: KeyEvent) -> Vec<u8> {
match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
let ctrl = (c as u8).wrapping_sub(b'a').wrapping_add(1);
vec![ctrl]
} else {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
s.as_bytes().to_vec()
}
}
KeyCode::Enter => vec![b'\r'],
KeyCode::Backspace => vec![0x7f],
KeyCode::Tab => vec![b'\t'],
KeyCode::Esc => vec![0x1b],
KeyCode::Up => b"\x1b[A".to_vec(),
KeyCode::Down => b"\x1b[B".to_vec(),
KeyCode::Right => b"\x1b[C".to_vec(),
KeyCode::Left => b"\x1b[D".to_vec(),
KeyCode::Home => b"\x1b[H".to_vec(),
KeyCode::End => b"\x1b[F".to_vec(),
KeyCode::Delete => b"\x1b[3~".to_vec(),
_ => vec![],
}
}