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.rs540
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()