feat: ui
This commit is contained in:
215
src/app/mod.rs
Normal file
215
src/app/mod.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user