mod input; mod picker; mod runtime; mod session; mod workspace_input; #[cfg(test)] mod tests; use std::io::Write; use std::path::PathBuf; use std::sync::mpsc::{Receiver, Sender}; use std::time::{Duration, Instant}; use anyhow::Result; use crossterm::{ event::{self, Event}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; use crate::cli::DEFAULT_TASK_CONFIG_PATH; use crate::model::{ ControllerState, Plan, Screen, SessionEntry, SessionSelection, StatusSnapshot, TaskConfig, UsageSnapshot, }; use crate::ui::{self, scroll::VerticalScrollState, SessionRenderRow, SessionView, SidebarView}; pub(crate) const USAGE_REFRESH_INTERVAL: Duration = Duration::from_secs(120); pub(crate) const CREATE_MODELS: [&str; 4] = [ "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.3-codex-spark", ]; pub(crate) const CREATE_MENU_ROWS: usize = 3; pub(crate) const PICKER_MENU_ROWS: usize = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PickerFocus { List, Settings, } #[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum AppEvent { Session(SessionEntry), Snapshot { goal_md: String, standards_md: String, plan: Plan, state: ControllerState, }, CodexUsage { input_tokens: u64, output_tokens: u64, }, } #[derive(Debug)] pub enum ControlCommand { Submit(String), Pause, Resume, Stop, Quit, } pub(crate) struct WorkspaceRuntime { pub(crate) task_config: TaskConfig, pub(crate) goal_md: String, pub(crate) standards_md: String, pub(crate) plan: Plan, pub(crate) state: ControllerState, pub(crate) input: String, pub(crate) session_entries: Vec, pub(crate) event_rx: Receiver, pub(crate) control_tx: Sender, pub(crate) session_input_tokens: Option, pub(crate) session_output_tokens: Option, pub(crate) usage_snapshot: UsageSnapshot, pub(crate) last_usage_refresh: Instant, pub(crate) session_follow_output: bool, pub(crate) session_scrollbar: VerticalScrollState, pub(crate) session_rows: Vec, pub(crate) session_view: Option, pub(crate) session_view_area: ratatui::layout::Rect, pub(crate) sidebar_follow_output: bool, pub(crate) sidebar_scrollbar: VerticalScrollState, pub(crate) sidebar_view: Option, pub(crate) sidebar_view_area: ratatui::layout::Rect, pub(crate) session_selection: Option, pub(crate) session_drag_active: bool, } pub struct App { pub screen: Screen, pub picker_items: Vec, pub picker_selected: usize, pub picker_focus: PickerFocus, pub picker_menu_selected: usize, pub picker_model_index: usize, pub picker_fast_mode: bool, pub picker_allow_branching: bool, pub create_input: String, pub create_model_index: usize, pub create_menu_selected: usize, pub create_fast_mode: bool, pub create_allow_branching: bool, pub create_error: Option, pub default_task_path: PathBuf, pub(crate) frame_tick: u64, pub(crate) workspace: Option, } impl App { pub fn bootstrap(task_path: Option) -> Result { let default_task_path = PathBuf::from(DEFAULT_TASK_CONFIG_PATH); let mut app = Self { screen: Screen::ControllerPicker, picker_items: Vec::new(), picker_selected: 0, picker_focus: PickerFocus::List, picker_menu_selected: 0, picker_model_index: 0, picker_fast_mode: false, picker_allow_branching: false, create_input: String::new(), create_model_index: 0, create_menu_selected: 0, create_fast_mode: false, create_allow_branching: false, create_error: None, default_task_path: default_task_path.clone(), frame_tick: 0, workspace: None, }; if let Some(task_path) = task_path { app.open_workspace_from_task_file(task_path)?; } else { app.refresh_picker()?; } Ok(app) } pub fn run(&mut self) -> Result<()> { enable_raw_mode()?; let mut stdout = std::io::stdout(); execute!( stdout, EnterAlternateScreen, crossterm::event::EnableMouseCapture )?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let result = self.run_loop(&mut terminal); self.shutdown_runtime(); disable_raw_mode()?; execute!( terminal.backend_mut(), crossterm::event::DisableMouseCapture, LeaveAlternateScreen )?; terminal.show_cursor()?; result } pub fn workspace_status_snapshot(&self) -> Option { let workspace = self.workspace.as_ref()?; Some(StatusSnapshot { controller_id: workspace.task_config.controller_id(), branch: if workspace.state.allow_branching { workspace.task_config.branch.clone() } else { "current".to_string() }, started_at: workspace.state.started_at.clone(), phase: workspace.state.phase.clone(), iteration: workspace.state.iteration, session_input_tokens: workspace.session_input_tokens, session_output_tokens: workspace.session_output_tokens, usage: workspace.usage_snapshot.clone(), }) } pub(crate) fn create_model(&self) -> &'static str { CREATE_MODELS .get(self.create_model_index) .copied() .unwrap_or(CREATE_MODELS[0]) } pub(crate) fn picker_model(&self) -> &'static str { CREATE_MODELS .get(self.picker_model_index) .copied() .unwrap_or(CREATE_MODELS[0]) } pub(crate) fn selected_picker_controller_id(&self) -> Option<&str> { self.picker_items .get(self.picker_selected) .map(|controller| controller.id.as_str()) } pub(crate) fn shift_create_model(&mut self, delta: isize) { let len = CREATE_MODELS.len() as isize; let next = (self.create_model_index as isize + delta).rem_euclid(len); self.create_model_index = next as usize; } pub(crate) fn toggle_create_fast_mode(&mut self) { self.create_fast_mode = !self.create_fast_mode; } pub(crate) fn toggle_create_allow_branching(&mut self) { self.create_allow_branching = !self.create_allow_branching; } pub(crate) fn move_create_menu_selection(&mut self, delta: isize) { let next = (self.create_menu_selected as isize + delta) .clamp(0, (CREATE_MENU_ROWS.saturating_sub(1)) as isize); self.create_menu_selected = next as usize; } pub(crate) fn shift_picker_model(&mut self, delta: isize) { let len = CREATE_MODELS.len() as isize; let next = (self.picker_model_index as isize + delta).rem_euclid(len); self.picker_model_index = next as usize; } pub(crate) fn toggle_picker_fast_mode(&mut self) { self.picker_fast_mode = !self.picker_fast_mode; } pub(crate) fn toggle_picker_allow_branching(&mut self) { self.picker_allow_branching = !self.picker_allow_branching; } pub(crate) fn move_picker_menu_selection(&mut self, delta: isize) { let next = (self.picker_menu_selected as isize + delta) .clamp(0, (PICKER_MENU_ROWS.saturating_sub(1)) as isize); self.picker_menu_selected = next as usize; } pub(crate) fn reset_picker_menu(&mut self) { self.picker_focus = PickerFocus::List; self.picker_menu_selected = 0; } pub(crate) fn reset_create_form(&mut self) { self.create_input.clear(); self.create_model_index = 0; self.create_menu_selected = 0; self.create_fast_mode = false; self.create_allow_branching = false; self.create_error = None; } pub(crate) fn reset_picker_form(&mut self) { self.picker_focus = PickerFocus::List; self.picker_menu_selected = 0; self.picker_model_index = 0; self.picker_fast_mode = false; self.picker_allow_branching = false; } pub(crate) fn sync_picker_settings_from_selected_controller(&mut self) { let Some(controller) = self.picker_items.get(self.picker_selected) else { self.reset_picker_form(); return; }; self.picker_model_index = CREATE_MODELS .iter() .position(|model| *model == controller.run_model) .unwrap_or(0); self.picker_fast_mode = controller.fast_mode; self.picker_allow_branching = controller.allow_branching; self.picker_menu_selected = 0; self.picker_focus = PickerFocus::List; } pub(crate) fn workspace(&self) -> Option<&WorkspaceRuntime> { self.workspace.as_ref() } pub(crate) fn workspace_input(&self) -> Option<&str> { self.workspace .as_ref() .map(|workspace| workspace.input.as_str()) } pub(crate) fn workspace_session_selection(&self) -> Option<&SessionSelection> { self.workspace .as_ref() .and_then(|workspace| workspace.session_selection.as_ref()) } pub(crate) fn workspace_session_rows(&self) -> Option<&[SessionRenderRow]> { self.workspace .as_ref() .map(|workspace| workspace.session_rows.as_slice()) } pub(crate) fn workspace_session_view(&self) -> Option<&SessionView> { self.workspace .as_ref() .and_then(|workspace| workspace.session_view.as_ref()) } pub(crate) fn workspace_sidebar_view(&self) -> Option<&SidebarView> { self.workspace .as_ref() .and_then(|workspace| workspace.sidebar_view.as_ref()) } pub(crate) fn heartbeat_frame(&self) -> u64 { self.frame_tick } pub(crate) fn workspace_session_scroll(&self) -> usize { let Some(workspace) = self.workspace.as_ref() else { return 0; }; let max_scroll = self .workspace_session_line_count() .saturating_sub(workspace.session_scrollbar.viewport_lines); if workspace.session_follow_output { max_scroll } else { workspace.session_scrollbar.position_lines.min(max_scroll) } } pub(crate) fn workspace_session_line_count(&self) -> usize { self.workspace .as_ref() .map(|workspace| workspace.session_scrollbar.content_lines) .unwrap_or_default() } fn run_loop( &mut self, terminal: &mut Terminal>, ) -> Result<()> { loop { self.drain_workspace_events()?; self.maybe_refresh_usage()?; self.update_workspace_viewport(terminal.size()?.into()); self.tick_session_scroll_repeat(); self.frame_tick = self.frame_tick.wrapping_add(1); terminal.backend_mut().write_all(b"\x1b[?2026h")?; terminal.draw(|frame| ui::render(frame, self))?; terminal.backend_mut().write_all(b"\x1b[?2026l")?; terminal.backend_mut().flush()?; if event::poll(Duration::from_millis(50))? && self.handle_pending_events(terminal)? { break; } } Ok(()) } fn handle_pending_events( &mut self, terminal: &mut Terminal>, ) -> Result { loop { match event::read()? { Event::Key(key) => { if self.handle_key(key)? { return Ok(true); } } Event::Mouse(mouse) => self.handle_mouse(mouse, terminal.size()?.into())?, Event::Resize(_, _) => self.update_workspace_viewport(terminal.size()?.into()), _ => {} } if !event::poll(Duration::ZERO)? { return Ok(false); } } } }