397 lines
12 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|
|
}
|