Files
codex-controller-loop/src/app/mod.rs
eric ebb6b488fe
Some checks failed
distribution-gate / distribution-gate (push) Failing after 1m56s
feat: 0.1.0
2026-04-04 18:41:34 +02:00

397 lines
12 KiB
Rust

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<SessionEntry>,
pub(crate) event_rx: Receiver<AppEvent>,
pub(crate) control_tx: Sender<ControlCommand>,
pub(crate) session_input_tokens: Option<u64>,
pub(crate) session_output_tokens: Option<u64>,
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<SessionRenderRow>,
pub(crate) session_view: Option<SessionView>,
pub(crate) session_view_area: ratatui::layout::Rect,
pub(crate) sidebar_follow_output: bool,
pub(crate) sidebar_scrollbar: VerticalScrollState,
pub(crate) sidebar_view: Option<SidebarView>,
pub(crate) sidebar_view_area: ratatui::layout::Rect,
pub(crate) session_selection: Option<SessionSelection>,
pub(crate) session_drag_active: bool,
}
pub struct App {
pub screen: Screen,
pub picker_items: Vec<crate::model::ControllerSummary>,
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<String>,
pub default_task_path: PathBuf,
pub(crate) frame_tick: u64,
pub(crate) workspace: Option<WorkspaceRuntime>,
}
impl App {
pub fn bootstrap(task_path: Option<PathBuf>) -> Result<Self> {
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<StatusSnapshot> {
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<CrosstermBackend<std::io::Stdout>>,
) -> 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<CrosstermBackend<std::io::Stdout>>,
) -> Result<bool> {
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);
}
}
}
}