use std::collections::HashMap;
use crate::risk::Risk;
use crate::tui::event::{AppEvent, Effect};
use agent_client_protocol::schema::v1::StopReason;
use bitrouter_substrate::translate::UsageCost;
use crossterm::event::{KeyCode, KeyModifiers};
pub mod diff;
pub mod pane;
use self::pane::{Ownership, PaneKind, PaneState};
pub mod overlay;
use self::overlay::{DEFAULT_LEADER, Mode, PaletteState, PickerState};
pub mod layout;
use self::layout::{ClickZone, DetailLayout, PtyArea};
pub mod events;
pub mod keys;
use self::events::reduce_inner;
const SCROLLBACK_CAP: usize = 2000;
const MAX_SHOWN: usize = 4;
const CHECK_RETRY_CAP: u8 = 2;
const REJECT_NOTE: &str =
"the human reviewed the diff and rejected it — revise and finish the task";
pub fn stop_label(stop: &StopReason) -> &'static str {
match stop {
StopReason::EndTurn => "end_turn",
StopReason::MaxTokens => "max_tokens",
StopReason::MaxTurnRequests => "max_turn_requests",
StopReason::Refusal => "refusal",
StopReason::Cancelled => "cancelled",
_ => "unknown",
}
}
const TICKS_PER_SEC: u64 = 5;
const NOTICE_DECAY_TICKS: u64 = 8 * TICKS_PER_SEC;
fn fmt_elapsed(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
#[derive(Debug, Clone)]
pub struct AppState {
pub agents: Vec<PaneState>,
pub detail: DetailLayout,
pub sessions_collapsed: bool,
pub subagents_collapsed: bool,
pub leader: (KeyCode, KeyModifiers),
perm_seq: u64,
pub harness_by_agent: HashMap<String, String>,
pub should_quit: bool,
pub mode: Mode,
pub picker: Option<PickerState>,
pub palette: Option<PaletteState>,
pub keys_help: bool,
pub tick: u64,
pub term_focused: bool,
pub no_color: bool,
pub available_agents: Vec<String>,
pub available_sessions: Vec<String>,
pub serve_ok: Option<bool>,
pub notice: Option<String>,
notice_at: u64,
pub bootstrap_cmd: Option<String>,
pub bootstrap_decision: Option<bool>,
pub confirm_agent: Option<String>,
pub pty_areas: Vec<PtyArea>,
pub click_zones: Vec<ClickZone>,
}
impl AppState {
pub fn new(pane: PaneState) -> Self {
let detail = DetailLayout::solo(pane.record_id.clone());
Self {
agents: vec![pane],
detail,
sessions_collapsed: false,
subagents_collapsed: false,
leader: DEFAULT_LEADER,
perm_seq: 0,
harness_by_agent: HashMap::new(),
should_quit: false,
mode: Mode::Normal,
picker: None,
palette: None,
keys_help: false,
tick: 0,
term_focused: true,
no_color: false,
available_agents: Vec::new(),
available_sessions: Vec::new(),
serve_ok: None,
notice: None,
notice_at: 0,
bootstrap_cmd: None,
bootstrap_decision: None,
confirm_agent: None,
pty_areas: Vec::new(),
click_zones: Vec::new(),
}
}
pub fn set_available_agents(&mut self, agents: Vec<String>) {
self.available_agents = agents;
}
pub fn set_harness_map(&mut self, map: HashMap<String, String>) {
for pane in self.agents.iter_mut() {
if let Some(h) = map.get(&pane.agent_id) {
pane.harness = h.clone();
}
}
self.harness_by_agent = map;
}
pub fn roster(&self) -> Vec<usize> {
let mut order: Vec<usize> = (0..self.agents.len())
.filter(|&i| self.agents[i].kind == PaneKind::Monitor)
.collect();
order.sort_by_key(|&i| {
let p = &self.agents[i];
match &p.pending {
Some(pending) => {
let risk_rank = match pending.risk {
Risk::High => 0u64,
Risk::Low => 1,
};
(p.bucket(), risk_rank, p.pending_seq)
}
None => (p.bucket(), 0, i as u64),
}
});
order
}
pub fn title_badge(&self) -> String {
let mut badge = String::from("bitrouter");
for (glyph, n) in self.badge_counts() {
badge.push_str(&format!(" {glyph}{n}"));
}
if badge == "bitrouter" {
badge.push_str(" tui");
}
badge
}
pub fn badge_counts(&self) -> Vec<(char, usize)> {
[(0u8, '⚠'), (1, '◆'), (2, '●'), (3, '◉')]
.into_iter()
.filter_map(|(bucket, glyph)| {
let n = self.agents.iter().filter(|p| p.bucket() == bucket).count();
(n > 0).then_some((glyph, n))
})
.collect()
}
pub fn fleet_agents(&self) -> Vec<bitrouter_substrate::fleet::FleetAgent> {
self.agents
.iter()
.filter(|p| p.kind == PaneKind::Monitor && p.owner == Ownership::Human)
.map(|p| bitrouter_substrate::fleet::FleetAgent {
record_id: p.record_id.clone(),
autonomy: p.autonomy.label().to_string(),
review: p.review,
port: p.port,
pending: p.pending.as_ref().map(|pending| pending.title.clone()),
draft: None,
turn_active: p.turn_active,
exited: p.exited,
})
.collect()
}
pub fn sessions_list(&self) -> Vec<usize> {
(0..self.agents.len())
.filter(|&i| self.agents[i].kind == PaneKind::Pty)
.collect()
}
pub fn total_cost(&self) -> Option<UsageCost> {
let mut total: Option<UsageCost> = None;
for cost in self.agents.iter().filter_map(|p| p.cost.as_ref()) {
match &mut total {
None => total = Some(cost.clone()),
Some(t) if t.currency == cost.currency => t.amount += cost.amount,
Some(_) => {}
}
}
total
}
pub fn fleet_sessions(&self) -> Vec<bitrouter_substrate::fleet::OrchestratorState> {
self.agents
.iter()
.filter(|p| p.is_session())
.map(|p| bitrouter_substrate::fleet::OrchestratorState {
binary: p.agent_id.clone(),
model: p.model.clone(),
})
.collect()
}
pub fn focused(&self) -> Option<&PaneState> {
let id = self.detail.focused_id()?;
self.agents.iter().find(|p| p.record_id == id)
}
pub fn focused_mut(&mut self) -> Option<&mut PaneState> {
let id = self.detail.focused_id()?.to_string();
self.agents.iter_mut().find(|p| p.record_id == id)
}
fn is_shown(&self, record_id: &str) -> bool {
self.detail.shown.iter().any(|r| r == record_id)
}
fn pane_by_id_mut(&mut self, record_id: &str) -> Option<&mut PaneState> {
self.agents.iter_mut().find(|p| p.record_id == record_id)
}
pub fn leader_label(&self) -> String {
match self.leader.0 {
KeyCode::Char(' ') => "⌃space".to_string(),
KeyCode::Char(c) => format!("⌃{c}"),
_ => "leader".to_string(),
}
}
}
pub fn reduce(state: &mut AppState, event: &AppEvent) -> Vec<Effect> {
let notice_before = state.notice.clone();
let effects = reduce_inner(state, event);
if state.notice != notice_before && state.notice.is_some() {
state.notice_at = state.tick;
}
let tick = state.tick;
for pane in state.agents.iter_mut() {
let bucket = pane.bucket();
if bucket != pane.last_bucket {
pane.last_bucket = bucket;
pane.since = tick;
}
}
effects
}
fn mark_shown_seen(state: &mut AppState) {
if !state.term_focused {
return;
}
let shown: Vec<String> = state.detail.shown.clone();
for pane in state.agents.iter_mut() {
if shown.iter().any(|r| r == &pane.record_id) {
pane.attention = false;
pane.done = false;
}
}
}
#[cfg(test)]
mod tests;