summaryrefslogtreecommitdiff
path: root/makima/src/bin/makima.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/bin/makima.rs')
-rw-r--r--makima/src/bin/makima.rs349
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))