mod agent_events;
mod agent_lifecycle;
mod helpers;
mod input_ops;
mod key_handlers;
mod modals;
mod mouse;
mod search;
mod streaming;
mod tabs;
use std::collections::HashMap;
use std::io::Stdout;
use bitrouter_providers::acp::discovery::discover_agents;
use bitrouter_providers::acp::provider::AcpAgentProvider;
use bitrouter_providers::acp::types::AgentAvailability;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use crate::TuiConfig;
use crate::error::TuiError;
use crate::event::{AppEvent, EventHandler};
use crate::model::{
AgentStatus, AutocompleteState, InlineInput, InputTarget, Modal, ObsLog, ScrollbackState,
SearchState, Tab, agent_color,
};
use crate::ui;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputMode {
Normal,
Scroll,
Tab,
Agent,
Search,
Permission,
}
pub struct AppState {
pub mode: InputMode,
pub agents: Vec<crate::model::Agent>,
pub tabs: Vec<Tab>,
pub active_tab: usize,
pub input: InlineInput,
pub input_target: InputTarget,
pub autocomplete: Option<AutocompleteState>,
pub modal: Option<Modal>,
pub obs_log: ObsLog,
pub config: TuiConfig,
pub agent_list_selected: usize,
pub search: Option<SearchState>,
pub last_layout: Option<crate::ui::layout::AppLayout>,
}
impl AppState {
pub fn active_scrollback(&self) -> Option<&ScrollbackState> {
self.tabs.get(self.active_tab).map(|t| &t.scrollback)
}
pub fn active_scrollback_mut(&mut self) -> Option<&mut ScrollbackState> {
self.tabs
.get_mut(self.active_tab)
.map(|t| &mut t.scrollback)
}
pub fn active_agent_name(&self) -> Option<&str> {
self.tabs
.get(self.active_tab)
.map(|t| t.agent_name.as_str())
}
}
pub struct App {
pub running: bool,
pub state: AppState,
agent_providers: HashMap<String, AcpAgentProvider>,
event_tx: mpsc::Sender<AppEvent>,
}
impl App {
pub fn new(
config: TuiConfig,
bitrouter_config: &bitrouter_config::BitrouterConfig,
event_tx: mpsc::Sender<AppEvent>,
) -> Self {
let discovered = discover_agents(&bitrouter_config.agents);
let mut agents: Vec<crate::model::Agent> = bitrouter_config
.agents
.iter()
.filter(|(_, ac)| ac.enabled)
.enumerate()
.map(|(i, (name, ac))| {
let status = discovered
.iter()
.find(|da| da.name == *name)
.map(|da| match &da.availability {
AgentAvailability::OnPath(_) => AgentStatus::Idle,
AgentAvailability::Distributable => AgentStatus::Available,
})
.unwrap_or_else(|| {
if ac.distribution.is_empty() {
AgentStatus::Idle } else {
AgentStatus::Available
}
});
crate::model::Agent {
name: name.clone(),
config: Some(ac.clone()),
status,
session_id: None,
color: agent_color(i),
}
})
.collect();
for da in &discovered {
if !agents.iter().any(|a| a.name == da.name) {
let idx = agents.len();
let status = match &da.availability {
AgentAvailability::OnPath(_) => AgentStatus::Idle,
AgentAvailability::Distributable => AgentStatus::Available,
};
let known_config = bitrouter_config.agents.get(&da.name);
let distribution = known_config
.map(|c| c.distribution.clone())
.unwrap_or_default();
agents.push(crate::model::Agent {
name: da.name.clone(),
config: Some(bitrouter_config::AgentConfig {
protocol: bitrouter_config::AgentProtocol::Acp,
binary: da.binary.to_string_lossy().into_owned(),
args: da.args.clone(),
enabled: true,
distribution,
}),
status,
session_id: None,
color: agent_color(idx),
});
}
}
Self {
running: true,
state: AppState {
mode: InputMode::Normal,
agents,
tabs: Vec::new(),
active_tab: 0,
input: InlineInput::new(),
input_target: InputTarget::Default,
autocomplete: None,
modal: None,
obs_log: ObsLog::new(),
config,
agent_list_selected: 0,
search: None,
last_layout: None,
},
agent_providers: HashMap::new(),
event_tx,
}
}
fn handle_event(&mut self, event: AppEvent) {
match event {
AppEvent::Key(key) => self.handle_key(key),
AppEvent::Mouse(mouse_event) => self.handle_mouse(mouse_event),
AppEvent::Resize { .. } | AppEvent::Tick => {}
AppEvent::Agent(agent_event) => self.handle_agent_event(agent_event),
AppEvent::InstallProgress { agent_id, percent } => {
if let Some(agent) = self.state.agents.iter_mut().find(|a| a.name == agent_id) {
agent.status = AgentStatus::Installing { percent };
}
}
AppEvent::InstallComplete {
agent_id,
binary_path,
} => {
self.handle_install_complete(&agent_id, binary_path);
}
AppEvent::InstallFailed { agent_id, message } => {
if let Some(agent) = self.state.agents.iter_mut().find(|a| a.name == agent_id) {
agent.status = AgentStatus::Error(message.clone());
}
self.push_system_msg(&format!("[{agent_id}] Install failed: {message}"));
}
}
}
}
pub async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
config: TuiConfig,
bitrouter_config: &bitrouter_config::BitrouterConfig,
) -> Result<(), TuiError> {
let mut events = EventHandler::new();
let mut app = App::new(config, bitrouter_config, events.sender());
while app.running {
terminal.draw(|frame| ui::render(frame, &mut app.state))?;
match events.next().await {
Some(event) => app.handle_event(event),
None => break,
}
}
app.agent_providers.clear();
Ok(())
}