diff options
Diffstat (limited to 'makima/src/bin')
| -rw-r--r-- | makima/src/bin/makima.rs | 540 |
1 files changed, 1 insertions, 539 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 7b8cdb6..bf8c35f 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -6,9 +6,8 @@ use std::sync::Arc; use makima::daemon::api::ApiClient; use makima::daemon::cli::{ - Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, DirectiveCommand, }; -use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; use makima::daemon::db::LocalDb; use makima::daemon::error::DaemonError; @@ -27,7 +26,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::Server(args) => run_server(args).await, Commands::Daemon(args) => run_daemon(args).await, Commands::Directive(cmd) => run_directive(cmd).await, - Commands::View(args) => run_view(args).await, Commands::Config(cmd) => run_config(cmd).await, } } @@ -625,51 +623,6 @@ async fn run_directive_verify( } /// Run the TUI view command. -async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { - // Load CLI config for defaults - let config = CliConfig::load(); - - // Get API URL and key, preferring CLI args > env vars > config file - // Filter out empty strings - let api_url = args.api_url - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| config.get_api_url()); - let api_key = match args.api_key.filter(|s| !s.is_empty()) { - Some(key) => key, - None => config.get_api_key().ok_or_else(|| { - eprintln!("Error: No API key provided."); - eprintln!(); - eprintln!("Set your API key using one of these methods:"); - eprintln!(" 1. Run: makima config set-key YOUR_API_KEY"); - eprintln!(" 2. Set environment variable: export MAKIMA_API_KEY=YOUR_API_KEY"); - eprintln!(" 3. Pass via CLI: makima view --api-key YOUR_API_KEY"); - "No API key configured" - })?, - }; - - // Create API client - let client = ApiClient::new(api_url.clone(), api_key.clone())?; - - // Start WebSocket client for task output streaming - let ws_client = TuiWsClient::start(api_url, api_key); - - // 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(); - } - - // Load initial contracts - let items = load_contracts(&client).await?; - app.set_items(items); - - // Run TUI with navigation support - let result = run_tui_with_navigation(app, client, ws_client).await; - - result -} /// Run config commands. async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { @@ -715,497 +668,6 @@ async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error } /// 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>> { - // Use get_contract which returns tasks as part of the response (works with regular API key auth) - let result = client.get_contract(contract_id).await?; - let mut items: Vec<ListItem> = result.0.get("tasks") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(ListItem::from_task).collect()) - .unwrap_or_default(); - - // Sort tasks: supervisor first, then by status (running first), then by name - items.sort_by(|a, b| { - // Check if task is supervisor (role field in extra data) - let a_is_supervisor = a.extra.get("role") - .and_then(|v| v.as_str()) - .map(|s| s == "supervisor") - .unwrap_or(false); - let b_is_supervisor = b.extra.get("role") - .and_then(|v| v.as_str()) - .map(|s| s == "supervisor") - .unwrap_or(false); - - // Supervisor first - match (a_is_supervisor, b_is_supervisor) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => { - // Then by status: running/working tasks first - let status_order = |s: Option<&String>| -> i32 { - match s.map(|x| x.as_str()) { - Some("running") | Some("working") => 0, - Some("pending") | Some("queued") => 1, - Some("completed") | Some("done") => 2, - Some("failed") | Some("error") => 3, - _ => 4, - } - }; - let a_order = status_order(a.status.as_ref()); - let b_order = status_order(b.status.as_ref()); - - match a_order.cmp(&b_order) { - std::cmp::Ordering::Equal => a.name.cmp(&b.name), - other => other, - } - } - } - }); - - 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 - tui::print_path(&path); - } - Ok(None) => { - // Normal exit - } - Err(e) => { - eprintln!("TUI error: {}", e); - std::process::exit(1); - } - } - - 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); - } - - // Load task output history first - app.status_message = Some("Loading output history...".to_string()); - match client.get_task_output(task_id).await { - Ok(result) => { - // Parse the entries array from response - if let Some(entries) = result.0.get("entries").and_then(|v| v.as_array()) { - for entry in entries { - if let Some(line) = parse_output_entry(entry) { - app.output_buffer.add_line(line); - } - } - let count = entries.len(); - app.status_message = Some(format!("Loaded {} history entries, streaming live...", count)); - } - } - Err(e) => { - app.status_message = Some(format!("Failed to load history: {}", e)); - } - } - - // Subscribe to new task output for live updates - ws_client.subscribe(task_id); - subscribed_task_id = Some(task_id); - } - 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; - } - } - Action::PerformCreateContract { name: _, description: _, contract_type: _, repository_url: _ } => { - // Contracts removed in Phase 5 — directives are - // the only way to organise multi-task work now. - // The TUI's contract create form is dead code - // pending a wider TUI refresh. - app.status_message = Some( - "Contracts have been removed. Use directives instead.".to_string() - ); - } - Action::LoadRepoSuggestions => { - // Load repository suggestions for the create form - app.status_message = Some("Loading recent repositories...".to_string()); - // Force a redraw to show the status - terminal.draw(|f| tui::ui::render(f, app)).ok(); - - // Fetch all repository types (remote and local) - match client.get_repository_suggestions(None, Some(10)).await { - Ok(result) => { - // Parse suggestions from API response - let suggestions: Vec<RepositorySuggestion> = result.0 - .get("entries") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter().filter_map(|entry| { - let name = entry.get("name") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let repository_url = entry.get("repositoryUrl") - .or_else(|| entry.get("repository_url")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let local_path = entry.get("localPath") - .or_else(|| entry.get("local_path")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let source_type = entry.get("sourceType") - .or_else(|| entry.get("source_type")) - .and_then(|v| v.as_str()) - .unwrap_or("remote") - .to_string(); - let use_count = entry.get("useCount") - .or_else(|| entry.get("use_count")) - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; - - // Only include if we have a URL or path - if repository_url.is_some() || local_path.is_some() { - Some(RepositorySuggestion { - name, - repository_url, - local_path, - source_type, - use_count, - }) - } else { - None - } - }).collect() - }) - .unwrap_or_default(); - - let count = suggestions.len(); - app.create_state.set_suggestions(suggestions); - if count > 0 { - app.status_message = Some(format!("Found {} recent repositories", count)); - } else { - app.status_message = Some("No recent repositories found".to_string()); - } - } - Err(e) => { - app.status_message = Some(format!("Could not load suggestions: {}", e)); - app.create_state.suggestions_loaded = true; - } - } - } - _ => {} - } - } - } - } - } - - if app.should_quit { - break; - } - } - - Ok(None) -} - -/// Extract a repository name from a URL. -/// E.g., "https://github.com/owner/repo.git" -> "owner/repo" -fn extract_repo_name(url: &str) -> String { - // Remove .git suffix if present - let url = url.trim_end_matches(".git"); - - // Try to extract owner/repo from common Git hosting URLs - if let Some(path) = url.strip_prefix("https://github.com/") - .or_else(|| url.strip_prefix("https://gitlab.com/")) - .or_else(|| url.strip_prefix("https://bitbucket.org/")) - .or_else(|| url.strip_prefix("git@github.com:")) - .or_else(|| url.strip_prefix("git@gitlab.com:")) - .or_else(|| url.strip_prefix("git@bitbucket.org:")) - { - // Return owner/repo - return path.to_string(); - } - - // Fallback: try to get the last path segment - if let Some(last_segment) = url.rsplit('/').next() { - if !last_segment.is_empty() { - return last_segment.to_string(); - } - } - - // Last resort: use the full URL as the name - url.to_string() -} - -/// Parse an output entry from the API response into an OutputLine -fn parse_output_entry(entry: &serde_json::Value) -> Option<OutputLine> { - let message_type = entry.get("messageType") - .and_then(|v| v.as_str()) - .unwrap_or("raw"); - let content = entry.get("content") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let tool_name = entry.get("toolName") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let is_error = entry.get("isError") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let cost_usd = entry.get("costUsd") - .and_then(|v| v.as_f64()); - let duration_ms = entry.get("durationMs") - .and_then(|v| v.as_u64()); - - Some(OutputLine { - message_type: OutputMessageType::from_str(message_type), - content, - tool_name, - is_error, - cost_usd, - duration_ms, - }) -} - -/// 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() |
