This commit is contained in:
eric
2026-04-04 05:57:58 +02:00
commit 97f329c825
55 changed files with 10026 additions and 0 deletions

215
src/app/mod.rs Normal file
View File

@@ -0,0 +1,215 @@
mod input;
mod picker;
mod runtime;
mod session;
mod workspace_input;
#[cfg(test)]
mod tests;
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::{
event::{self, Event, KeyEvent},
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::{
group_session_entries, ControllerPhase, ControllerState, Plan, Screen, SessionEntry,
SessionSelection, StatusSnapshot, TaskConfig, UsageSnapshot,
};
use crate::ui;
pub(crate) const USAGE_REFRESH_INTERVAL: Duration = Duration::from_secs(120);
#[derive(Debug, Clone)]
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_scroll: usize,
pub(crate) session_follow_output: bool,
pub(crate) session_viewport_lines: usize,
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 create_input: String,
pub create_error: Option<String>,
pub default_task_path: PathBuf,
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,
create_input: String::new(),
create_error: None,
default_task_path: default_task_path.clone(),
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_groups(&self) -> Vec<crate::model::SessionGroup> {
self.workspace
.as_ref()
.map(|workspace| group_session_entries(&workspace.session_entries))
.unwrap_or_default()
}
pub fn workspace_status_snapshot(&self) -> Option<StatusSnapshot> {
let workspace = self.workspace.as_ref()?;
Some(StatusSnapshot {
controller_id: workspace.task_config.controller_id(),
branch: workspace.task_config.branch.clone(),
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 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_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_viewport_lines);
if workspace.session_follow_output {
max_scroll
} else {
workspace.session_scroll.min(max_scroll)
}
}
pub(crate) fn workspace_session_line_count(&self) -> usize {
self.workspace
.as_ref()
.map(|workspace| Self::session_line_count_for_entries(&workspace.session_entries))
.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()?.height as usize);
terminal.draw(|frame| ui::render(frame, self))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => {
if self.handle_key(key)? {
break;
}
}
Event::Mouse(mouse) => self.handle_mouse(mouse, terminal.size()?.into())?,
Event::Resize(_, height) => self.update_workspace_viewport(height as usize),
_ => {}
}
}
}
Ok(())
}
}