use std::collections::{HashMap, HashSet};
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)]
pub(crate) enum SidebarEntry {
RepoHeader {
name: String,
},
WorkstreamItem {
#[allow(dead_code)]
repo: String,
name: String,
},
#[allow(dead_code)]
NestedAgent {
id: Uuid,
label: String,
needs: bool,
},
#[allow(dead_code)]
NestedShell {
id: Uuid,
label: String,
},
Divider,
AgentSectionHeader,
AgentItem {
id: Uuid,
label: String,
needs: bool,
},
CreateAgentPlaceholder,
ShellSectionHeader,
ShellItem {
id: Uuid,
label: String,
},
}
impl SidebarEntry {
fn is_selectable(&self) -> bool {
matches!(
self,
SidebarEntry::RepoHeader { .. }
| SidebarEntry::WorkstreamItem { .. }
| SidebarEntry::NestedAgent { .. }
| SidebarEntry::NestedShell { .. }
| SidebarEntry::AgentItem { .. }
| SidebarEntry::CreateAgentPlaceholder
| SidebarEntry::ShellItem { .. }
)
}
#[allow(dead_code)]
fn is_in_agent_section(&self) -> bool {
matches!(
self,
SidebarEntry::AgentSectionHeader
| SidebarEntry::AgentItem { .. }
| SidebarEntry::CreateAgentPlaceholder
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RightPane {
Empty,
AgentConversation(Uuid),
ShellTerminal(Uuid),
RepoDetail(String),
WorkstreamDetail(String, String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Dialog {
None,
SpawnAgent {
repo_idx: usize,
},
PromptAgent {
shell_id: Uuid,
},
NewRepo {
current_path: String,
children: Vec<String>,
child_cursor: usize,
suggested_name: String,
name_buf: String,
editing_name: bool,
loading: bool,
filter_buf: String,
},
NewWorkstream {
repo_idx: usize,
name_buf: String,
},
}
pub struct App {
pub state: HubState,
pub cursor: usize,
pub right_pane: RightPane,
pub dialog: Dialog,
pub running: bool,
pub status_message: Option<String>,
pub vt_terminals: HashMap<Uuid, VtTerminal>,
pub agent_lines: HashMap<Uuid, Vec<String>>,
pub prompt_buf: String,
pub port: u16,
pub show_raw: bool,
}
impl App {
fn new(port: u16) -> Self {
Self {
state: HubState::default(),
cursor: 0,
right_pane: RightPane::Empty,
dialog: Dialog::None,
running: true,
status_message: None,
vt_terminals: HashMap::new(),
agent_lines: HashMap::new(),
prompt_buf: String::new(),
port,
show_raw: false,
}
}
pub(crate) fn sidebar(&self) -> Vec<SidebarEntry> {
let mut entries = Vec::new();
let agent_shell_ids: HashSet<Uuid> =
self.state.agents.iter().map(|a| a.vex_shell_id).collect();
for repo in &self.state.repos {
entries.push(SidebarEntry::RepoHeader {
name: repo.name.clone(),
});
for ws in &self.state.workstreams {
if ws.repo == repo.name {
entries.push(SidebarEntry::WorkstreamItem {
repo: ws.repo.clone(),
name: ws.name.clone(),
});
}
}
}
entries.push(SidebarEntry::Divider);
entries.push(SidebarEntry::AgentSectionHeader);
if self.state.agents.is_empty() {
entries.push(SidebarEntry::CreateAgentPlaceholder);
} else {
for agent in &self.state.agents {
let short = &agent.vex_shell_id.to_string()[..8];
let status = if agent.needs_intervention {
"NEEDS *"
} else {
"idle"
};
entries.push(SidebarEntry::AgentItem {
id: agent.vex_shell_id,
label: format!("{} {}", short, status),
needs: agent.needs_intervention,
});
}
}
let non_agent_shells: Vec<_> = self
.state
.shells
.iter()
.filter(|s| !agent_shell_ids.contains(&s.id))
.collect();
if !non_agent_shells.is_empty() {
entries.push(SidebarEntry::ShellSectionHeader);
for shell in non_agent_shells {
let short = &shell.id.to_string()[..8];
entries.push(SidebarEntry::ShellItem {
id: shell.id,
label: format!("{} ({}c)", short, shell.client_count),
});
}
}
entries
}
pub(crate) fn cursor_down(&mut self) {
let entries = self.sidebar();
let mut next = self.cursor + 1;
while next < entries.len() {
if entries[next].is_selectable() {
self.cursor = next;
return;
}
next += 1;
}
}
pub(crate) fn cursor_up(&mut self) {
let entries = self.sidebar();
if self.cursor == 0 {
return;
}
let mut prev = self.cursor - 1;
loop {
if entries[prev].is_selectable() {
self.cursor = prev;
return;
}
if prev == 0 {
break;
}
prev -= 1;
}
}
fn clamp_cursor(&mut self) {
let entries = self.sidebar();
if entries.is_empty() {
self.cursor = 0;
return;
}
if self.cursor >= entries.len() {
self.cursor = entries.len().saturating_sub(1);
}
if !entries[self.cursor].is_selectable() {
let saved = self.cursor;
self.cursor_down();
if self.cursor == saved {
self.cursor_up();
}
}
}
pub(crate) fn selected(&self) -> Option<SidebarEntry> {
self.sidebar().get(self.cursor).cloned()
}
#[allow(dead_code)]
pub(crate) fn in_agent_section(&self) -> bool {
let entries = self.sidebar();
for i in (0..=self.cursor).rev() {
match &entries[i] {
SidebarEntry::AgentSectionHeader => return true,
SidebarEntry::ShellSectionHeader
| SidebarEntry::Divider
| SidebarEntry::RepoHeader { .. } => return false,
_ => continue,
}
}
false
}
}
pub async fn run(port: u16) -> Result<()> {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_err()
{
anyhow::bail!(
"cannot connect to daemon on port {} (is it running? try `vex daemon start`)",
port
);
}
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();
app.clamp_cursor();
}
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_cursor();
let shell_ids: HashSet<Uuid> = app.state.shells.iter().map(|s| s.id).collect();
let agent_ids: HashSet<Uuid> = app.state.agents.iter().map(|a| a.vex_shell_id).collect();
app.vt_terminals.retain(|id, _| shell_ids.contains(id));
app.agent_lines.retain(|id, _| agent_ids.contains(id));
}
result = event_rx.recv() => {
match result {
Ok(FrontendEvent::ShellOutput { shell_id, data }) => {
if let Some(vt) = app.vt_terminals.get_mut(&shell_id) {
vt.process(&data);
}
}
Ok(FrontendEvent::AgentConversationLine { shell_id, line }) => {
app.agent_lines.entry(shell_id).or_default().push(line);
}
Ok(FrontendEvent::AgentWatchEnd { shell_id }) => {
app.agent_lines.entry(shell_id).or_default().push("[agent turn complete]".to_string());
}
Ok(FrontendEvent::IntrospectResult { path, suggested_name, children, .. }) => {
if let Dialog::NewRepo {
ref current_path,
children: ref mut dialog_children,
ref mut child_cursor,
suggested_name: ref mut dialog_suggested,
ref mut name_buf,
ref mut loading,
..
} = app.dialog
&& *current_path == path
{
*dialog_children = children;
*child_cursor = 0;
*dialog_suggested = suggested_name.clone();
if name_buf.is_empty() {
*name_buf = suggested_name;
}
*loading = false;
}
}
Ok(_) => {}
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 RightPane::ShellTerminal(id) = &app.right_pane {
let id = *id;
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,
tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.running = false;
return Ok(());
}
if app.dialog != Dialog::None {
return handle_dialog_key(app, key, tx).await;
}
if let RightPane::ShellTerminal(id) = &app.right_pane {
let id = *id;
if key.code == KeyCode::Char(']') && key.modifiers.contains(KeyModifiers::CONTROL) {
let _ = tx.send(FrontendCommand::ShellDetach { id }).await;
app.right_pane = RightPane::Empty;
return Ok(());
}
let data = key_to_bytes(key);
if !data.is_empty() {
let _ = tx.send(FrontendCommand::ShellInput { id, data }).await;
}
return Ok(());
}
match key.code {
KeyCode::Char('q') => app.running = false,
KeyCode::Down | KeyCode::Char('j') => app.cursor_down(),
KeyCode::Up | KeyCode::Char('k') => app.cursor_up(),
KeyCode::Enter => {
if let Some(entry) = app.selected() {
match entry {
SidebarEntry::RepoHeader { name } => {
app.right_pane = RightPane::RepoDetail(name);
}
SidebarEntry::WorkstreamItem { repo, name } => {
app.right_pane = RightPane::WorkstreamDetail(repo, name);
}
SidebarEntry::AgentItem { id, .. } | SidebarEntry::NestedAgent { id, .. } => {
let _ = tx.send(FrontendCommand::AgentWatch { shell_id: id }).await;
app.right_pane = RightPane::AgentConversation(id);
}
SidebarEntry::ShellItem { id, .. } | SidebarEntry::NestedShell { id, .. } => {
open_shell(app, id, tx).await;
}
SidebarEntry::CreateAgentPlaceholder => {
if !app.state.repos.is_empty() {
app.dialog = Dialog::SpawnAgent { repo_idx: 0 };
} else {
app.status_message =
Some("register a repo first with `vex repo add`".to_string());
}
}
_ => {}
}
}
}
KeyCode::Char('s') => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
if let RightPane::AgentConversation(id) = &app.right_pane {
let id = *id;
open_shell(app, id, tx).await;
}
} else if let Some(
SidebarEntry::AgentItem { id, .. } | SidebarEntry::NestedAgent { id, .. },
) = app.selected()
{
open_shell(app, id, tx).await;
}
}
KeyCode::Char('c') => {
if app.in_agent_section()
|| matches!(app.selected(), Some(SidebarEntry::CreateAgentPlaceholder))
{
if !app.state.repos.is_empty() {
app.dialog = Dialog::SpawnAgent { repo_idx: 0 };
} else {
app.status_message = Some("register a repo first".to_string());
}
}
}
KeyCode::Char('x') => {
if let Some(entry) = app.selected() {
match entry {
SidebarEntry::AgentItem { id, .. } | SidebarEntry::NestedAgent { id, .. } => {
let _ = tx.send(FrontendCommand::ShellKill { id }).await;
if app.right_pane == RightPane::AgentConversation(id)
|| app.right_pane == RightPane::ShellTerminal(id)
{
app.right_pane = RightPane::Empty;
}
app.status_message =
Some(format!("killing agent {}...", &id.to_string()[..8]));
}
SidebarEntry::ShellItem { id, .. } | SidebarEntry::NestedShell { id, .. } => {
let _ = tx.send(FrontendCommand::ShellKill { id }).await;
if app.right_pane == RightPane::ShellTerminal(id) {
app.right_pane = RightPane::Empty;
}
app.status_message =
Some(format!("killing shell {}...", &id.to_string()[..8]));
}
_ => {}
}
}
}
KeyCode::Char('p') => {
let agent_id = match app.selected() {
Some(SidebarEntry::AgentItem { id, .. } | SidebarEntry::NestedAgent { id, .. }) => {
Some(id)
}
_ => {
if let RightPane::AgentConversation(id) = &app.right_pane {
Some(*id)
} else {
None
}
}
};
if let Some(id) = agent_id {
app.prompt_buf.clear();
app.dialog = Dialog::PromptAgent { shell_id: id };
}
}
KeyCode::Char('R') => {
app.dialog = Dialog::NewRepo {
current_path: "/".to_string(),
children: Vec::new(),
child_cursor: 0,
suggested_name: String::new(),
name_buf: String::new(),
editing_name: false,
loading: true,
filter_buf: String::new(),
};
let _ = tx
.send(FrontendCommand::IntrospectPath {
path: "/".to_string(),
})
.await;
}
KeyCode::Char('w') => {
if !app.state.repos.is_empty() {
let repo_idx = match app.selected() {
Some(SidebarEntry::RepoHeader { ref name }) => app
.state
.repos
.iter()
.position(|r| r.name == *name)
.unwrap_or(0),
Some(SidebarEntry::WorkstreamItem { ref repo, .. }) => app
.state
.repos
.iter()
.position(|r| r.name == *repo)
.unwrap_or(0),
_ => 0,
};
app.dialog = Dialog::NewWorkstream {
repo_idx,
name_buf: String::new(),
};
} else {
app.status_message = Some("register a repo first".to_string());
}
}
KeyCode::Char('r') => {
let _ = tx.send(FrontendCommand::RefreshState).await;
}
KeyCode::Char('v') => {
if matches!(app.right_pane, RightPane::AgentConversation(_)) {
app.show_raw = !app.show_raw;
}
}
KeyCode::Char('?') => {
app.status_message = Some(
"Enter:open s:shell c:agent x:kill p:prompt R:repo w:workstream r:refresh q:quit"
.to_string(),
);
}
KeyCode::Esc => {
app.status_message = None;
if app.right_pane != RightPane::Empty {
app.right_pane = RightPane::Empty;
}
}
_ => {}
}
Ok(())
}
async fn open_shell(app: &mut App, id: Uuid, tx: &mpsc::Sender<FrontendCommand>) {
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
let pane_cols = cols / 2;
app.vt_terminals
.entry(id)
.or_insert_with(|| VtTerminal::new(pane_cols, rows.saturating_sub(2)));
let _ = tx
.send(FrontendCommand::ShellAttach {
id,
cols: pane_cols,
rows: rows.saturating_sub(2),
})
.await;
app.right_pane = RightPane::ShellTerminal(id);
}
async fn handle_dialog_key(
app: &mut App,
key: KeyEvent,
tx: &mpsc::Sender<FrontendCommand>,
) -> Result<()> {
match &mut app.dialog {
Dialog::SpawnAgent { repo_idx } => match key.code {
KeyCode::Esc => app.dialog = Dialog::None,
KeyCode::Down | KeyCode::Char('j') => {
if *repo_idx + 1 < app.state.repos.len() {
*repo_idx += 1;
}
}
KeyCode::Up | KeyCode::Char('k') => {
*repo_idx = repo_idx.saturating_sub(1);
}
KeyCode::Enter => {
if let Some(repo) = app.state.repos.get(*repo_idx) {
let _ = tx
.send(FrontendCommand::AgentSpawn {
repo: repo.name.clone(),
workstream: None,
})
.await;
app.status_message = Some(format!("spawning agent in {}...", repo.name));
}
app.dialog = Dialog::None;
}
_ => {}
},
Dialog::PromptAgent { shell_id } => match key.code {
KeyCode::Esc => app.dialog = Dialog::None,
KeyCode::Enter => {
if !app.prompt_buf.is_empty() {
let text = app.prompt_buf.clone();
let id = *shell_id;
let _ = tx
.send(FrontendCommand::AgentPrompt { shell_id: id, text })
.await;
app.prompt_buf.clear();
let _ = tx.send(FrontendCommand::AgentWatch { shell_id: id }).await;
app.right_pane = RightPane::AgentConversation(id);
}
app.dialog = Dialog::None;
}
KeyCode::Backspace => {
app.prompt_buf.pop();
}
KeyCode::Char(c) => app.prompt_buf.push(c),
_ => {}
},
Dialog::NewRepo {
current_path,
children,
child_cursor,
suggested_name: _,
name_buf,
editing_name,
loading,
filter_buf,
} => {
let filtered: Vec<usize> = children
.iter()
.enumerate()
.filter(|(_, name)| fuzzy_match(name, filter_buf))
.map(|(i, _)| i)
.collect();
match key.code {
KeyCode::Esc => app.dialog = Dialog::None,
KeyCode::Tab => {
*editing_name = !*editing_name;
if !*editing_name {
filter_buf.clear();
*child_cursor = 0;
}
}
KeyCode::Enter => {
if *editing_name {
if !current_path.is_empty() && !name_buf.is_empty() {
let _ = tx
.send(FrontendCommand::RepoAdd {
name: name_buf.clone(),
path: current_path.clone(),
})
.await;
app.status_message = Some(format!("adding repo '{}'...", name_buf));
}
app.dialog = Dialog::None;
} else if !*loading {
let actual_idx = filtered.get(*child_cursor).copied();
if let Some(idx) = actual_idx
&& let Some(child) = children.get(idx)
{
let new_path = if *current_path == "/" {
format!("/{}", child)
} else {
format!("{}/{}", current_path, child)
};
*current_path = new_path.clone();
*loading = true;
children.clear();
*child_cursor = 0;
*name_buf = String::new();
filter_buf.clear();
let _ = tx
.send(FrontendCommand::IntrospectPath { path: new_path })
.await;
}
}
}
KeyCode::Backspace => {
if *editing_name {
name_buf.pop();
} else if !filter_buf.is_empty() {
filter_buf.pop();
*child_cursor = 0;
} else if !*loading && current_path != "/" {
let new_path = if let Some(pos) = current_path.rfind('/') {
if pos == 0 {
"/".to_string()
} else {
current_path[..pos].to_string()
}
} else {
"/".to_string()
};
*current_path = new_path.clone();
*loading = true;
children.clear();
*child_cursor = 0;
*name_buf = String::new();
filter_buf.clear();
let _ = tx
.send(FrontendCommand::IntrospectPath { path: new_path })
.await;
}
}
KeyCode::Down | KeyCode::Char('j') if !*editing_name => {
if !filtered.is_empty() && *child_cursor + 1 < filtered.len() {
*child_cursor += 1;
}
}
KeyCode::Up | KeyCode::Char('k') if !*editing_name => {
*child_cursor = child_cursor.saturating_sub(1);
}
KeyCode::Char(c) => {
if *editing_name {
name_buf.push(c);
} else {
filter_buf.push(c);
*child_cursor = 0;
}
}
_ => {}
}
}
Dialog::NewWorkstream { repo_idx, name_buf } => match key.code {
KeyCode::Esc => app.dialog = Dialog::None,
KeyCode::Down | KeyCode::Char('j') => {
if *repo_idx + 1 < app.state.repos.len() {
*repo_idx += 1;
}
}
KeyCode::Up | KeyCode::Char('k') => {
*repo_idx = repo_idx.saturating_sub(1);
}
KeyCode::Enter => {
let normalized = normalize_name(name_buf);
if !normalized.is_empty()
&& let Some(repo) = app.state.repos.get(*repo_idx)
{
let _ = tx
.send(FrontendCommand::WorkstreamCreate {
repo: repo.name.clone(),
name: normalized.clone(),
})
.await;
app.status_message = Some(format!("creating workstream '{}'...", normalized));
}
app.dialog = Dialog::None;
}
KeyCode::Backspace => {
name_buf.pop();
}
KeyCode::Char(c) => name_buf.push(c),
_ => {}
},
Dialog::None => {}
}
Ok(())
}
pub(crate) fn fuzzy_match(text: &str, pattern: &str) -> bool {
if pattern.is_empty() {
return true;
}
let mut pattern_chars = pattern.chars().flat_map(|c| c.to_lowercase());
let mut current = pattern_chars.next().unwrap();
for ch in text.chars().flat_map(|c| c.to_lowercase()) {
if ch == current {
match pattern_chars.next() {
Some(next) => current = next,
None => return true,
}
}
}
false
}
fn normalize_name(s: &str) -> String {
s.to_lowercase()
.replace(' ', "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect()
}
fn key_to_bytes(key: KeyEvent) -> Vec<u8> {
match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) && c.is_ascii_lowercase() {
vec![(c as u8) - b'a' + 1]
} else if key.modifiers.contains(KeyModifiers::CONTROL) {
vec![]
} else {
let mut buf = [0u8; 4];
c.encode_utf8(&mut buf);
buf[..c.len_utf8()].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![],
}
}