diff options
Diffstat (limited to 'makima/src/bin/makima.rs')
| -rw-r--r-- | makima/src/bin/makima.rs | 349 |
1 files changed, 305 insertions, 44 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index e103742..8b3e4dc 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,9 +6,9 @@ use std::sync::Arc; use makima::daemon::api::ApiClient; use makima::daemon::cli::{ - Cli, Commands, ContractCommand, SupervisorCommand, ViewCommand, ViewArgs, + Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs, }; -use makima::daemon::tui::{self, App, ListItem, ViewType}; +use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState}; use makima::daemon::config::{DaemonConfig, RepoEntry}; use makima::daemon::db::LocalDb; use makima::daemon::error::DaemonError; @@ -558,58 +558,92 @@ async fn run_contract( } /// Run the TUI view command. -async fn run_view(cmd: ViewCommand) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - // Extract view type and args from command - let (view_type, args) = match cmd { - ViewCommand::Tasks(args) => (ViewType::Tasks, args), - ViewCommand::Contracts(args) => (ViewType::Contracts, args), - ViewCommand::Files(args) => (ViewType::Files, args), - }; - +async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { // Create API client let client = ApiClient::new(args.api_url.clone(), args.api_key.clone())?; - // Fetch initial data based on view type - let items = match view_type { - ViewType::Tasks => { - let contract_id = args.contract_id - .ok_or("Contract ID is required for tasks view (use --contract-id or MAKIMA_CONTRACT_ID)")?; - let result = client.supervisor_tasks(contract_id).await?; - // Parse tasks from JSON array - result.0.as_array() - .map(|arr| arr.iter().filter_map(ListItem::from_task).collect()) - .unwrap_or_default() - } - ViewType::Contracts => { - // For contracts, we would need a list contracts endpoint - // For now, return empty or fetch from a different endpoint - eprintln!("Contracts view not yet implemented - requires list contracts endpoint"); - Vec::new() - } - ViewType::Files => { - let contract_id = args.contract_id - .ok_or("Contract ID is required for files view (use --contract-id or MAKIMA_CONTRACT_ID)")?; - let result = client.contract_files(contract_id).await?; - // Parse files from JSON array - result.0.as_array() - .map(|arr| arr.iter().filter_map(ListItem::from_file).collect()) - .unwrap_or_default() - } - }; + // Start WebSocket client for task output streaming + let ws_client = TuiWsClient::start(args.api_url.clone(), args.api_key.clone()); + + // Start at contracts view + let mut app = App::new(ViewType::Contracts); + + // Set initial search query if provided + if let Some(ref query) = args.query { + app.search_query = query.clone(); + } - // Create TUI app - let mut app = App::new(view_type); - app.contract_id = args.contract_id; + // Load initial contracts + let items = load_contracts(&client).await?; app.set_items(items); - // Run TUI - match tui::run(app) { + // Run TUI with navigation support + let result = run_tui_with_navigation(app, client, ws_client).await; + + result +} + +/// Load contracts from API +async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { + let result = client.list_contracts().await?; + let items = result.0.get("contracts") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(ListItem::from_contract).collect()) + .unwrap_or_default(); + Ok(items) +} + +/// Load tasks for a contract from API +async fn load_tasks(client: &ApiClient, contract_id: uuid::Uuid) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { + let result = client.supervisor_tasks(contract_id).await?; + let items = result.0.as_array() + .map(|arr| arr.iter().filter_map(ListItem::from_task).collect()) + .unwrap_or_default(); + Ok(items) +} + +/// Run the TUI with navigation support for drill-down views +async fn run_tui_with_navigation( + mut app: App, + client: ApiClient, + ws_client: TuiWsClient, +) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + }; + use ratatui::backend::CrosstermBackend; + use std::io; + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = ratatui::Terminal::new(backend)?; + + let result = run_tui_loop(&mut terminal, &mut app, &client, &ws_client).await; + + // Cleanup WebSocket + ws_client.shutdown(); + + // Cleanup terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + match result { Ok(Some(path)) => { - // Output the path for shell integration (e.g., cd $(makima view tasks)) + // Output the path for shell integration tui::print_path(&path); } Ok(None) => { - // Normal exit, no output needed + // Normal exit } Err(e) => { eprintln!("TUI error: {}", e); @@ -620,6 +654,233 @@ async fn run_view(cmd: ViewCommand) -> Result<(), Box<dyn std::error::Error + Se Ok(()) } +/// Main TUI event loop with async data loading +async fn run_tui_loop( + terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>, + app: &mut App, + client: &ApiClient, + ws_client: &TuiWsClient, +) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> { + use crossterm::event::{self, Event}; + use std::time::Duration; + + // Track currently subscribed task for cleanup + let mut subscribed_task_id: Option<uuid::Uuid> = None; + + loop { + terminal.draw(|f| tui::ui::render(f, app))?; + + // Process WebSocket events (non-blocking) + while let Some(ws_event) = ws_client.try_recv() { + handle_ws_event(app, ws_event); + } + + // Poll for keyboard events with short timeout (50ms for responsive WS handling) + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + let action = tui::event::handle_key_event(app, key); + match action { + Action::Quit => break, + Action::OutputPath(path) => return Ok(Some(path)), + Action::None => {} + _ => { + let result = app.handle_action(action); + match result { + Action::OutputPath(path) => return Ok(Some(path)), + Action::LoadTasks { contract_id, contract_name: _ } => { + // Unsubscribe from any previous task + if let Some(task_id) = subscribed_task_id.take() { + ws_client.unsubscribe(task_id); + } + // Load tasks for the selected contract + match load_tasks(client, contract_id).await { + Ok(items) => { + app.set_items(items); + } + Err(e) => { + app.status_message = Some(format!("Failed to load tasks: {}", e)); + } + } + } + Action::LoadTaskOutput { task_id, task_name: _ } => { + // Clear previous output + app.output_buffer.clear(); + app.ws_state = WsConnectionState::Connecting; + + // Unsubscribe from previous task if any + if let Some(old_task_id) = subscribed_task_id.take() { + ws_client.unsubscribe(old_task_id); + } + + // Subscribe to new task output + ws_client.subscribe(task_id); + subscribed_task_id = Some(task_id); + app.status_message = Some("Connecting to task output...".to_string()); + } + Action::PerformDelete { id, item_type } => { + // Perform the delete API call + let delete_result = match item_type { + ViewType::Contracts => { + client.delete_contract(id).await + } + ViewType::Tasks => { + client.delete_task(id).await + } + ViewType::TaskOutput => { + // Can't delete from output view + Ok(()) + } + }; + + match delete_result { + Ok(()) => { + app.status_message = Some("Deleted successfully".to_string()); + // Remove item from list + app.items.retain(|item| item.id != id); + app.update_filtered_items(); + } + Err(e) => { + app.status_message = Some(format!("Delete failed: {}", e)); + } + } + } + Action::PerformUpdate { id, item_type, name, description } => { + // Perform the update API call + let update_result = match item_type { + ViewType::Contracts => { + client.update_contract(id, Some(name.clone()), Some(description.clone())).await.map(|_| ()) + } + ViewType::Tasks => { + // For tasks, description is the plan + client.update_task(id, Some(name.clone()), Some(description.clone())).await.map(|_| ()) + } + ViewType::TaskOutput => { + // Can't edit from output view + Ok(()) + } + }; + + match update_result { + Ok(()) => { + app.status_message = Some("Updated successfully".to_string()); + // Update item in list + for item in &mut app.items { + if item.id == id { + item.name = name.clone(); + item.description = Some(description.clone()); + break; + } + } + app.update_filtered_items(); + } + Err(e) => { + app.status_message = Some(format!("Update failed: {}", e)); + } + } + } + Action::Refresh => { + // Reload data for current view + match app.view_type { + ViewType::Contracts => { + // Unsubscribe from task when going back to contracts + if let Some(task_id) = subscribed_task_id.take() { + ws_client.unsubscribe(task_id); + } + match load_contracts(client).await { + Ok(items) => app.set_items(items), + Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)), + } + } + ViewType::Tasks => { + // Unsubscribe from task when going back to tasks + if let Some(task_id) = subscribed_task_id.take() { + ws_client.unsubscribe(task_id); + } + if let Some(contract_id) = app.contract_id { + match load_tasks(client, contract_id).await { + Ok(items) => app.set_items(items), + Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)), + } + } + } + ViewType::TaskOutput => { + // Re-subscribe to the task output + if let Some(task_id) = app.task_id { + app.output_buffer.clear(); + app.ws_state = WsConnectionState::Connecting; + ws_client.subscribe(task_id); + subscribed_task_id = Some(task_id); + app.status_message = Some("Reconnecting...".to_string()); + } + } + } + } + Action::GoBack => { + // Unsubscribe when going back from output view + if let Some(task_id) = subscribed_task_id.take() { + ws_client.unsubscribe(task_id); + app.ws_state = WsConnectionState::Disconnected; + } + } + _ => {} + } + } + } + } + } + + if app.should_quit { + break; + } + } + + Ok(None) +} + +/// Handle a WebSocket event and update app state +fn handle_ws_event(app: &mut App, event: WsEvent) { + match event { + WsEvent::Connected => { + app.ws_state = WsConnectionState::Connected; + app.status_message = Some("Connected".to_string()); + } + WsEvent::Disconnected => { + app.ws_state = WsConnectionState::Disconnected; + app.status_message = Some("Disconnected".to_string()); + } + WsEvent::Reconnecting { attempt } => { + app.ws_state = WsConnectionState::Reconnecting; + app.status_message = Some(format!("Reconnecting (attempt {})...", attempt)); + } + WsEvent::Subscribed { task_id: _ } => { + app.ws_state = WsConnectionState::Connected; + app.status_message = Some("Subscribed to task output".to_string()); + } + WsEvent::Unsubscribed { task_id: _ } => { + // No status update needed + } + WsEvent::TaskOutput(output) => { + // Convert WebSocket event to OutputLine + let line = OutputLine { + message_type: OutputMessageType::from_str(&output.message_type), + content: output.content, + tool_name: output.tool_name, + is_error: output.is_error.unwrap_or(false), + cost_usd: output.cost_usd, + duration_ms: output.duration_ms, + }; + app.output_buffer.add_line(line); + // Clear status message once we're receiving output + if app.status_message.as_ref().map(|s| s.contains("Subscribed")).unwrap_or(false) { + app.status_message = None; + } + } + WsEvent::Error { message } => { + app.status_message = Some(format!("WS Error: {}", message)); + } + } +} + fn init_logging(level: &str, format: &str) { let filter = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new(level)) |
