diff options
| author | soryu <soryu@soryu.co> | 2026-01-20 00:23:49 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-20 00:23:49 +0000 |
| commit | 5d8e3f80254f20eb6672701fad5f116a3b05dbc3 (patch) | |
| tree | 02cc73bd49a416112c9dd7f21c480c156068827c | |
| parent | 9aac84bb20c4ca73f113fe74b9a293e4d20cdc93 (diff) | |
| download | soryu-5d8e3f80254f20eb6672701fad5f116a3b05dbc3.tar.gz soryu-5d8e3f80254f20eb6672701fad5f116a3b05dbc3.zip | |
Fix: auth for CLI and CLI SIGTERM
| -rw-r--r-- | makima/src/bin/makima.rs | 349 | ||||
| -rw-r--r-- | makima/src/daemon/api/client.rs | 22 | ||||
| -rw-r--r-- | makima/src/daemon/api/contract.rs | 33 | ||||
| -rw-r--r-- | makima/src/daemon/api/supervisor.rs | 25 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 16 | ||||
| -rw-r--r-- | makima/src/daemon/cli/view.rs | 111 | ||||
| -rw-r--r-- | makima/src/daemon/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/daemon/tui/app.rs | 536 | ||||
| -rw-r--r-- | makima/src/daemon/tui/event.rs | 96 | ||||
| -rw-r--r-- | makima/src/daemon/tui/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 289 | ||||
| -rw-r--r-- | makima/src/daemon/tui/views/contracts.rs | 16 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ws_client.rs | 353 |
13 files changed, 1645 insertions, 207 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 8fc8b60..972e575 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; @@ -533,58 +533,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); - // Create TUI app - let mut app = App::new(view_type); - app.contract_id = args.contract_id; + // 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 - 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); @@ -595,6 +629,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)) diff --git a/makima/src/daemon/api/client.rs b/makima/src/daemon/api/client.rs index 2318d5a..ca1b2a8 100644 --- a/makima/src/daemon/api/client.rs +++ b/makima/src/daemon/api/client.rs @@ -105,6 +105,28 @@ impl ApiClient { self.handle_response(response).await } + /// Make a DELETE request. + pub async fn delete(&self, path: &str) -> Result<(), ApiError> { + let url = format!("{}{}", self.base_url, path); + let response = self.client + .delete(&url) + .header("X-Makima-Tool-Key", &self.api_key) + .header("X-Makima-API-Key", &self.api_key) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(ApiError::Api { + status: status.as_u16(), + message: body, + }); + } + + Ok(()) + } + /// Handle API response. async fn handle_response<T: DeserializeOwned>( &self, diff --git a/makima/src/daemon/api/contract.rs b/makima/src/daemon/api/contract.rs index aac6b94..f7fa696 100644 --- a/makima/src/daemon/api/contract.rs +++ b/makima/src/daemon/api/contract.rs @@ -41,7 +41,40 @@ pub struct CreateFileRequest { pub content: String, } +/// Request to update a contract. +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct UpdateContractRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, +} + impl ApiClient { + /// List all contracts for the authenticated user. + pub async fn list_contracts(&self) -> Result<JsonValue, ApiError> { + self.get("/api/v1/contracts").await + } + + /// Delete a contract. + pub async fn delete_contract(&self, contract_id: Uuid) -> Result<(), ApiError> { + self.delete(&format!("/api/v1/contracts/{}", contract_id)) + .await + } + + /// Update a contract. + pub async fn update_contract( + &self, + contract_id: Uuid, + name: Option<String>, + description: Option<String>, + ) -> Result<JsonValue, ApiError> { + let req = UpdateContractRequest { name, description }; + self.put(&format!("/api/v1/contracts/{}", contract_id), &req) + .await + } + /// Get contract status. pub async fn contract_status(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { self.get(&format!("/api/v1/contracts/{}/daemon/status", contract_id)) diff --git a/makima/src/daemon/api/supervisor.rs b/makima/src/daemon/api/supervisor.rs index 1dc699e..9614cfc 100644 --- a/makima/src/daemon/api/supervisor.rs +++ b/makima/src/daemon/api/supervisor.rs @@ -248,4 +248,29 @@ impl ApiClient { self.get(&format!("/api/v1/mesh/tasks/{}/output", task_id)) .await } + + /// Delete a task. + pub async fn delete_task(&self, task_id: Uuid) -> Result<(), ApiError> { + self.delete(&format!("/api/v1/mesh/tasks/{}", task_id)).await + } + + /// Update a task. + pub async fn update_task( + &self, + task_id: Uuid, + name: Option<String>, + plan: Option<String>, + ) -> Result<JsonValue, ApiError> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct UpdateTaskRequest { + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + plan: Option<String>, + } + let req = UpdateTaskRequest { name, plan }; + self.put(&format!("/api/v1/mesh/tasks/{}", task_id), &req) + .await + } } diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 842fa63..ba71c28 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -12,7 +12,7 @@ pub use contract::ContractArgs; pub use daemon::DaemonArgs; pub use server::ServerArgs; pub use supervisor::SupervisorArgs; -pub use view::{ViewArgs, ViewCommand}; +pub use view::ViewArgs; /// Makima - unified CLI for server, daemon, and task management. #[derive(Parser, Debug)] @@ -39,16 +39,16 @@ pub enum Commands { #[command(subcommand)] Contract(ContractCommand), - /// Interactive TUI browser for tasks, contracts, and files + /// Interactive TUI browser for contracts and tasks /// - /// Provides a fuzzy-searchable interface with keyboard navigation. + /// Provides a drill-down interface for browsing contracts, viewing their + /// tasks, and streaming real-time task output. /// /// Keyboard shortcuts: - /// ↑/k: Move up ↓/j: Move down Enter: Select - /// /: Search Tab: Toggle preview q: Quit - /// e: Edit d: Delete c: cd to worktree - #[command(subcommand)] - View(ViewCommand), + /// ↑/k: Move up ↓/j: Move down Enter/l: Drill in + /// Esc/h: Go back /: Search q: Quit + /// e: Edit d: Delete c: cd to worktree + View(ViewArgs), } /// Supervisor subcommands for contract orchestration. diff --git a/makima/src/daemon/cli/view.rs b/makima/src/daemon/cli/view.rs index f42c490..b5b516f 100644 --- a/makima/src/daemon/cli/view.rs +++ b/makima/src/daemon/cli/view.rs @@ -1,94 +1,69 @@ -//! View subcommand - interactive TUI browser for tasks, contracts, and files. +//! View subcommand - interactive TUI browser for contracts and tasks. //! //! The `makima view` command provides an interactive Terminal User Interface (TUI) -//! for browsing and managing makima entities. It features fuzzy search filtering, -//! keyboard navigation, and quick actions. +//! for browsing and managing makima contracts and their tasks. It features +//! drill-down navigation, fuzzy search filtering, and real-time task output streaming. //! //! # Usage //! //! ```bash -//! # Browse tasks interactively -//! makima view tasks +//! # Browse contracts interactively +//! makima view //! -//! # Browse contracts with an initial search query -//! makima view contracts "my project" -//! -//! # Browse files without preview pane -//! makima view files --no-preview -//! -//! # Browse tasks for a specific contract -//! makima view tasks --contract-id <uuid> +//! # Browse with an initial search query +//! makima view "my project" //! //! # Change directory to selected task's worktree -//! cd $(makima view tasks) +//! cd $(makima view) //! ``` //! //! # Keyboard Shortcuts //! -//! | Key | Action | -//! |-------------|---------------------------| -//! | `↑` / `k` | Move selection up | -//! | `↓` / `j` | Move selection down | -//! | `Enter` | View/select item | -//! | `e` | Open in editor ($EDITOR) | -//! | `d` | Delete item (with confirm)| -//! | `Tab` | Toggle preview pane | -//! | `/` | Focus search input | -//! | `Esc` | Clear search / cancel | -//! | `q` | Quit | -//! | `c` | Navigate to worktree (cd) | -//! | `Ctrl+r` | Refresh data | -//! | `?` | Show help | +//! | Key | Action | +//! |---------------|-------------------------------| +//! | `↑` / `k` | Move selection up | +//! | `↓` / `j` | Move selection down | +//! | `Enter` / `l` | Drill into item | +//! | `Esc` / `h` | Go back to previous view | +//! | `e` | Edit item (inline) | +//! | `d` | Delete item (with confirm) | +//! | `/` | Focus search input | +//! | `Space` | Show details in preview pane | +//! | `q` | Quit | +//! | `c` | Navigate to worktree (cd) | +//! | `r` | Refresh data | +//! +//! # Navigation +//! +//! - **Contracts view**: Lists all contracts. Press Enter to see tasks. +//! - **Tasks view**: Shows tasks for a contract. Press Enter to view output. +//! - **Output view**: Streams real-time task output with tool call formatting. //! //! # Features //! +//! - **Drill-down Navigation**: Contracts → Tasks → Task Output //! - **Fuzzy Search**: Type to filter items in real-time -//! - **Multi-term Search**: Use space-separated terms (e.g., "fix bug") -//! - **Recency Sorting**: Recent items appear higher in results +//! - **Real-time Streaming**: View live task output via WebSocket //! - **Preview Pane**: See item details without leaving the list -//! - **Status Indicators**: Visual icons for task states -use clap::{Args, Subcommand}; -use uuid::Uuid; +use clap::Args; -/// Interactive TUI browser for tasks, contracts, and files. +/// Interactive TUI browser for contracts and tasks. /// -/// Provides a fuzzy-searchable interface for browsing and managing -/// makima entities with keyboard navigation and quick actions. +/// Provides a fuzzy-searchable interface for browsing contracts, +/// viewing their tasks, and streaming real-time task output. /// /// # Examples /// -/// Browse tasks: +/// Browse contracts: /// ```bash -/// makima view tasks +/// makima view /// ``` /// /// Browse with initial search: /// ```bash -/// makima view contracts "auth" +/// makima view "auth" /// ``` -#[derive(Subcommand, Debug)] -pub enum ViewCommand { - /// Browse tasks interactively - /// - /// Shows all tasks for the current contract with status indicators, - /// fuzzy search filtering, and quick actions. - Tasks(ViewArgs), - - /// Browse contracts interactively - /// - /// Lists all contracts with their phase, status, and task counts. - Contracts(ViewArgs), - - /// Browse files interactively - /// - /// Shows contract files with preview of their content. - Files(ViewArgs), -} - -/// Common arguments for view commands. -/// -/// These arguments are shared across all view subcommands (tasks, contracts, files). #[derive(Args, Debug, Clone)] pub struct ViewArgs { /// API URL for the makima server @@ -99,12 +74,6 @@ pub struct ViewArgs { #[arg(long, env = "MAKIMA_API_KEY")] pub api_key: String, - /// Contract ID to filter results (optional) - /// - /// When specified, only shows items belonging to this contract. - #[arg(long, env = "MAKIMA_CONTRACT_ID")] - pub contract_id: Option<Uuid>, - /// Initial search query /// /// Pre-populates the search field with this query when the TUI opens. @@ -117,12 +86,4 @@ pub struct ViewArgs { /// Useful for smaller terminal windows. #[arg(long)] pub no_preview: bool, - - /// Sort order for results - /// - /// - `recent`: Sort by last updated time (default) - /// - `name`: Sort alphabetically by name - /// - `status`: Group by status, then by name - #[arg(long, default_value = "recent")] - pub sort: String, } diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs index c348838..349a769 100644 --- a/makima/src/daemon/mod.rs +++ b/makima/src/daemon/mod.rs @@ -19,6 +19,6 @@ pub mod tui; pub mod worktree; pub mod ws; -pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand, ViewCommand}; +pub use cli::{Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs}; pub use config::DaemonConfig; pub use error::{DaemonError, Result}; diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs index a2c82a2..3eff998 100644 --- a/makima/src/daemon/tui/app.rs +++ b/makima/src/daemon/tui/app.rs @@ -1,28 +1,55 @@ //! TUI application state and logic. +use std::collections::VecDeque; + use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use serde_json::Value; use uuid::Uuid; +/// Maximum number of output lines to keep in buffer +const MAX_OUTPUT_LINES: usize = 10000; + /// Available views/resource types #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ViewType { - Tasks, + /// List of contracts Contracts, - Files, + /// Tasks for a specific contract + Tasks, + /// Task output streaming view + TaskOutput, } impl ViewType { pub fn as_str(&self) -> &'static str { match self { - ViewType::Tasks => "tasks", ViewType::Contracts => "contracts", - ViewType::Files => "files", + ViewType::Tasks => "tasks", + ViewType::TaskOutput => "output", } } } +/// A saved view state for navigation stack +#[derive(Debug, Clone)] +pub struct ViewState { + /// The type of view + pub view_type: ViewType, + /// Contract ID (for Tasks view) + pub contract_id: Option<Uuid>, + /// Contract name (for breadcrumb display) + pub contract_name: Option<String>, + /// Task ID (for TaskOutput view) + pub task_id: Option<Uuid>, + /// Task name (for breadcrumb display) + pub task_name: Option<String>, + /// Selected index at time of navigation + pub selected_index: usize, + /// Scroll offset at time of navigation + pub scroll_offset: usize, +} + /// Input mode for the TUI #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputMode { @@ -32,6 +59,142 @@ pub enum InputMode { Search, /// Confirmation dialog (e.g., for delete) Confirm, + /// Edit mode - editing name + EditName, + /// Edit mode - editing description/plan + EditDescription, +} + +/// Edit state for inline editing +#[derive(Debug, Clone, Default)] +pub struct EditState { + /// ID of the item being edited + pub item_id: Option<Uuid>, + /// Original name + pub original_name: String, + /// Original description + pub original_description: String, + /// Current name value + pub name: String, + /// Current description value + pub description: String, + /// Cursor position in current field + pub cursor: usize, +} + +/// Output line type for rendering +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutputMessageType { + /// Assistant text response + Assistant, + /// Tool being called + ToolUse, + /// Result from tool + ToolResult, + /// Final result/summary + Result, + /// System message + System, + /// Error message + Error, + /// Raw/unformatted output + Raw, +} + +impl OutputMessageType { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "assistant" => Self::Assistant, + "tool_use" => Self::ToolUse, + "tool_result" => Self::ToolResult, + "result" => Self::Result, + "system" => Self::System, + "error" => Self::Error, + _ => Self::Raw, + } + } +} + +/// A single line of task output +#[derive(Debug, Clone)] +pub struct OutputLine { + /// The type of message + pub message_type: OutputMessageType, + /// The content text + pub content: String, + /// Tool name (for tool_use messages) + pub tool_name: Option<String>, + /// Whether this is an error (for tool_result) + pub is_error: bool, + /// Cost in USD (for result messages) + pub cost_usd: Option<f64>, + /// Duration in ms (for result messages) + pub duration_ms: Option<u64>, +} + +/// Output buffer for task output view +#[derive(Debug, Clone, Default)] +pub struct OutputBuffer { + /// Lines of output + pub lines: VecDeque<OutputLine>, + /// Current scroll offset (0 = bottom, auto-scroll) + pub scroll_offset: usize, + /// Auto-scroll enabled + pub auto_scroll: bool, +} + +impl OutputBuffer { + pub fn new() -> Self { + Self { + lines: VecDeque::new(), + scroll_offset: 0, + auto_scroll: true, + } + } + + pub fn add_line(&mut self, line: OutputLine) { + self.lines.push_back(line); + // Trim to max size + while self.lines.len() > MAX_OUTPUT_LINES { + self.lines.pop_front(); + } + // Auto-scroll to bottom + if self.auto_scroll { + self.scroll_offset = 0; + } + } + + pub fn clear(&mut self) { + self.lines.clear(); + self.scroll_offset = 0; + self.auto_scroll = true; + } + + pub fn scroll_up(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_add(amount); + self.auto_scroll = false; + } + + pub fn scroll_down(&mut self, amount: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(amount); + if self.scroll_offset == 0 { + self.auto_scroll = true; + } + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll_offset = 0; + self.auto_scroll = true; + } +} + +/// WebSocket connection state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WsConnectionState { + Disconnected, + Connecting, + Connected, + Reconnecting, } /// Actions that can be performed @@ -43,9 +206,13 @@ pub enum Action { Up, /// Move selection down Down, - /// Select current item (show details) + /// Select current item (show details in preview) Select, - /// Edit the selected item (open in editor) + /// Drill down into selected item (contracts -> tasks, tasks -> output) + DrillDown, + /// Go back to previous view + GoBack, + /// Edit the selected item (inline editing) Edit, /// Delete the selected item Delete, @@ -73,6 +240,30 @@ pub enum Action { LaunchEditor(String), /// Refresh data Refresh, + /// Request to load tasks for a contract (internal) + LoadTasks { contract_id: Uuid, contract_name: String }, + /// Request to load task output (internal) + LoadTaskOutput { task_id: Uuid, task_name: String }, + /// Request to delete an item (internal) + PerformDelete { id: Uuid, item_type: ViewType }, + /// Add character in edit mode + EditChar(char), + /// Backspace in edit mode + EditBackspace, + /// Switch to next edit field (Tab) + EditNextField, + /// Save edit changes + EditSave, + /// Cancel edit + EditCancel, + /// Request to perform update (internal) + PerformUpdate { id: Uuid, item_type: ViewType, name: String, description: String }, + /// Scroll output up + ScrollUp, + /// Scroll output down + ScrollDown, + /// Scroll to bottom of output + ScrollToBottom, } /// A displayable item in the TUI @@ -179,6 +370,27 @@ impl ListItem { let mut lines = Vec::new(); match view_type { + ViewType::Contracts => { + lines.push(format!("╭─ Contract Details ─────────────────────")); + lines.push(format!("│ Name: {}", self.name)); + lines.push(format!("│ ID: {}", self.id)); + if let Some(ref status) = self.status { + lines.push(format!("│ Phase: {}", status)); + } + if let Some(ref desc) = self.description { + lines.push(format!("│ Description: {}", desc)); + } + // Show task count if available + if let Some(count) = self.extra.get("taskCount").and_then(|v| v.as_i64()) { + lines.push(format!("│ Tasks: {}", count)); + } + if let Some(count) = self.extra.get("fileCount").and_then(|v| v.as_i64()) { + lines.push(format!("│ Files: {}", count)); + } + lines.push(format!("│")); + lines.push(format!("│ Press Enter to view tasks")); + lines.push(format!("╰────────────────────────────────────────")); + } ViewType::Tasks => { lines.push(format!("╭─ Task Details ─────────────────────────")); lines.push(format!("│ Name: {}", self.name)); @@ -193,44 +405,20 @@ impl ListItem { lines.push(format!("│ Worktree: {}", path)); } // Add progress if available - if let Some(progress) = self.extra.get("progress").and_then(|v| v.as_str()) { + if let Some(progress) = self.extra.get("progressSummary").and_then(|v| v.as_str()) { lines.push(format!("│ Progress: {}", progress)); } - if let Some(error) = self.extra.get("error").and_then(|v| v.as_str()) { + if let Some(error) = self.extra.get("errorMessage").and_then(|v| v.as_str()) { lines.push(format!("│ Error: {}", error)); } + lines.push(format!("│")); + lines.push(format!("│ Press Enter to view output")); lines.push(format!("╰────────────────────────────────────────")); } - ViewType::Contracts => { - lines.push(format!("╭─ Contract Details ─────────────────────")); - lines.push(format!("│ Name: {}", self.name)); - lines.push(format!("│ ID: {}", self.id)); - if let Some(ref status) = self.status { - lines.push(format!("│ Phase: {}", status)); - } - if let Some(ref desc) = self.description { - lines.push(format!("│ Description: {}", desc)); - } - lines.push(format!("╰────────────────────────────────────────")); - } - ViewType::Files => { - lines.push(format!("╭─ File Details ─────────────────────────")); - lines.push(format!("│ Name: {}", self.name)); - lines.push(format!("│ ID: {}", self.id)); - if let Some(ref desc) = self.description { - lines.push(format!("│ {}", desc)); - } - // Add content preview if available - if let Some(content) = self.extra.get("content").and_then(|v| v.as_str()) { - lines.push(format!("│")); - lines.push(format!("│ Content:")); - for line in content.lines().take(10) { - lines.push(format!("│ {}", line)); - } - if content.lines().count() > 10 { - lines.push(format!("│ ... ({} more lines)", content.lines().count() - 10)); - } - } + ViewType::TaskOutput => { + // Output view doesn't use this preview pane + lines.push(format!("╭─ Task Output ──────────────────────────")); + lines.push(format!("│ Streaming task output...")); lines.push(format!("╰────────────────────────────────────────")); } } @@ -243,6 +431,16 @@ impl ListItem { pub struct App { /// Current view type pub view_type: ViewType, + /// Navigation stack for drill-down views + pub view_stack: Vec<ViewState>, + /// Current contract ID (when viewing tasks) + pub contract_id: Option<Uuid>, + /// Current contract name (for breadcrumb) + pub contract_name: Option<String>, + /// Current task ID (when viewing output) + pub task_id: Option<Uuid>, + /// Current task name (for breadcrumb) + pub task_name: Option<String>, /// All items (unfiltered) pub items: Vec<ListItem>, /// Filtered items (based on search) @@ -261,20 +459,29 @@ pub struct App { pub preview_visible: bool, /// Pending delete item (for confirmation) pub pending_delete: Option<Uuid>, + /// Edit state for inline editing + pub edit_state: EditState, /// Status message pub status_message: Option<String>, /// Whether the app should quit pub should_quit: bool, /// Action to return when exiting (for OutputPath, LaunchEditor) pub exit_action: Option<Action>, - /// Contract ID (for API calls) - pub contract_id: Option<Uuid>, + /// Output buffer for task output view + pub output_buffer: OutputBuffer, + /// WebSocket connection state + pub ws_state: WsConnectionState, } impl App { pub fn new(view_type: ViewType) -> Self { Self { view_type, + view_stack: Vec::new(), + contract_id: None, + contract_name: None, + task_id: None, + task_name: None, items: Vec::new(), filtered_items: Vec::new(), selected_index: 0, @@ -284,13 +491,85 @@ impl App { preview_content: String::new(), preview_visible: false, pending_delete: None, + edit_state: EditState::default(), status_message: None, should_quit: false, exit_action: None, - contract_id: None, + output_buffer: OutputBuffer::new(), + ws_state: WsConnectionState::Disconnected, } } + /// Push current state to navigation stack and prepare for new view + pub fn push_view(&mut self, new_view: ViewType) { + // Save current state + let state = ViewState { + view_type: self.view_type, + contract_id: self.contract_id, + contract_name: self.contract_name.clone(), + task_id: self.task_id, + task_name: self.task_name.clone(), + selected_index: self.selected_index, + scroll_offset: 0, // TODO: track scroll offset if needed + }; + self.view_stack.push(state); + + // Switch to new view + self.view_type = new_view; + self.items.clear(); + self.filtered_items.clear(); + self.selected_index = 0; + self.search_query.clear(); + self.preview_content.clear(); + self.preview_visible = false; + } + + /// Pop from navigation stack and restore previous view state + pub fn pop_view(&mut self) -> bool { + if let Some(state) = self.view_stack.pop() { + self.view_type = state.view_type; + self.contract_id = state.contract_id; + self.contract_name = state.contract_name; + self.task_id = state.task_id; + self.task_name = state.task_name; + self.selected_index = state.selected_index; + self.items.clear(); + self.filtered_items.clear(); + self.search_query.clear(); + self.preview_content.clear(); + self.preview_visible = false; + true + } else { + false + } + } + + /// Check if we can go back + pub fn can_go_back(&self) -> bool { + !self.view_stack.is_empty() + } + + /// Get breadcrumb path for current view + pub fn get_breadcrumb(&self) -> String { + let mut parts = vec!["Contracts".to_string()]; + + if self.view_type == ViewType::Tasks || self.view_type == ViewType::TaskOutput { + if let Some(ref name) = self.contract_name { + parts.push(name.clone()); + } + parts.push("Tasks".to_string()); + } + + if self.view_type == ViewType::TaskOutput { + if let Some(ref name) = self.task_name { + parts.push(name.clone()); + } + parts.push("Output".to_string()); + } + + parts.join(" > ") + } + /// Set items and update filtered list pub fn set_items(&mut self, items: Vec<ListItem>) { self.items = items; @@ -349,22 +628,130 @@ impl App { } Action::None } + Action::DrillDown => { + // Drill down into selected item + match self.view_type { + ViewType::Contracts => { + // From contracts, drill into tasks + if let Some(item) = self.selected_item() { + let contract_id = item.id; + let contract_name = item.name.clone(); + return Action::LoadTasks { contract_id, contract_name }; + } + } + ViewType::Tasks => { + // From tasks, drill into task output + if let Some(item) = self.selected_item() { + let task_id = item.id; + let task_name = item.name.clone(); + return Action::LoadTaskOutput { task_id, task_name }; + } + } + ViewType::TaskOutput => { + // No further drill-down from output view + } + } + Action::None + } + Action::GoBack => { + if self.can_go_back() { + self.pop_view(); + // Signal to caller to refresh data for the restored view + Action::Refresh + } else { + // At root level, quit + self.should_quit = true; + Action::Quit + } + } Action::Edit => { - // Get worktree path and signal to launch editor + // Enter edit mode for selected item if let Some(item) = self.selected_item() { - if let Some(path) = item.get_worktree_path() { - self.should_quit = true; - self.exit_action = Some(Action::LaunchEditor(path.clone())); - return Action::LaunchEditor(path); - } else { - self.status_message = Some("No worktree path for this item".to_string()); + let name = item.name.clone(); + let description = item.description.clone().unwrap_or_default(); + self.edit_state = EditState { + item_id: Some(item.id), + original_name: name.clone(), + original_description: description.clone(), + name, + description, + cursor: 0, + }; + self.edit_state.cursor = self.edit_state.name.len(); + self.input_mode = InputMode::EditName; + } + Action::None + } + Action::EditChar(c) => { + match self.input_mode { + InputMode::EditName => { + self.edit_state.name.insert(self.edit_state.cursor, c); + self.edit_state.cursor += 1; } + InputMode::EditDescription => { + self.edit_state.description.insert(self.edit_state.cursor, c); + self.edit_state.cursor += 1; + } + _ => {} } Action::None } + Action::EditBackspace => { + match self.input_mode { + InputMode::EditName => { + if self.edit_state.cursor > 0 { + self.edit_state.cursor -= 1; + self.edit_state.name.remove(self.edit_state.cursor); + } + } + InputMode::EditDescription => { + if self.edit_state.cursor > 0 { + self.edit_state.cursor -= 1; + self.edit_state.description.remove(self.edit_state.cursor); + } + } + _ => {} + } + Action::None + } + Action::EditNextField => { + match self.input_mode { + InputMode::EditName => { + self.input_mode = InputMode::EditDescription; + self.edit_state.cursor = self.edit_state.description.len(); + } + InputMode::EditDescription => { + self.input_mode = InputMode::EditName; + self.edit_state.cursor = self.edit_state.name.len(); + } + _ => {} + } + Action::None + } + Action::EditSave => { + if let Some(id) = self.edit_state.item_id { + let name = self.edit_state.name.clone(); + let description = self.edit_state.description.clone(); + self.input_mode = InputMode::Normal; + // Return action to perform the update + return Action::PerformUpdate { + id, + item_type: self.view_type, + name, + description, + }; + } + self.input_mode = InputMode::Normal; + Action::None + } + Action::EditCancel => { + self.edit_state = EditState::default(); + self.input_mode = InputMode::Normal; + self.status_message = Some("Edit cancelled".to_string()); + Action::None + } Action::Delete => { // First press: enter confirm mode - // Clone the values we need to avoid borrow issues if let Some(item) = self.selected_item() { let id = item.id; let name = item.name.clone(); @@ -389,10 +776,13 @@ impl App { } Action::ConfirmYes => { if self.input_mode == InputMode::Confirm { - if let Some(_delete_id) = self.pending_delete.take() { - // TODO: Make API call to delete the item - // For now, just show status - self.status_message = Some("Delete confirmed (API call not implemented)".to_string()); + if let Some(delete_id) = self.pending_delete.take() { + self.input_mode = InputMode::Normal; + // Return action to perform the delete + return Action::PerformDelete { + id: delete_id, + item_type: self.view_type, + }; } self.input_mode = InputMode::Normal; } @@ -447,6 +837,46 @@ impl App { self.exit_action = Some(Action::LaunchEditor(path.clone())); Action::LaunchEditor(path) } + Action::LoadTasks { contract_id, contract_name } => { + // Prepare for tasks view + self.push_view(ViewType::Tasks); + self.contract_id = Some(contract_id); + self.contract_name = Some(contract_name.clone()); + Action::LoadTasks { contract_id, contract_name } + } + Action::LoadTaskOutput { task_id, task_name } => { + // Prepare for output view + self.push_view(ViewType::TaskOutput); + self.task_id = Some(task_id); + self.task_name = Some(task_name.clone()); + Action::LoadTaskOutput { task_id, task_name } + } + Action::PerformDelete { id, item_type } => { + // Pass through to caller for API call + Action::PerformDelete { id, item_type } + } + Action::PerformUpdate { id, item_type, name, description } => { + // Pass through to caller for API call + Action::PerformUpdate { id, item_type, name, description } + } + Action::ScrollUp => { + if self.view_type == ViewType::TaskOutput { + self.output_buffer.scroll_up(5); + } + Action::None + } + Action::ScrollDown => { + if self.view_type == ViewType::TaskOutput { + self.output_buffer.scroll_down(5); + } + Action::None + } + Action::ScrollToBottom => { + if self.view_type == ViewType::TaskOutput { + self.output_buffer.scroll_to_bottom(); + } + Action::None + } Action::None => Action::None, } } diff --git a/makima/src/daemon/tui/event.rs b/makima/src/daemon/tui/event.rs index 12a6890..0e3874b 100644 --- a/makima/src/daemon/tui/event.rs +++ b/makima/src/daemon/tui/event.rs @@ -3,7 +3,7 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use std::time::Duration; -use super::app::{Action, App, InputMode}; +use super::app::{Action, App, InputMode, ViewType}; /// Poll for events with timeout pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> { @@ -16,10 +16,16 @@ pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> { /// Handle a key event and return the resulting action pub fn handle_key_event(app: &App, key: KeyEvent) -> Action { + // Special handling for TaskOutput view + if app.view_type == ViewType::TaskOutput && app.input_mode == InputMode::Normal { + return handle_output_mode(key); + } + match app.input_mode { InputMode::Normal => handle_normal_mode(key), InputMode::Search => handle_search_mode(key), InputMode::Confirm => handle_confirm_mode(key), + InputMode::EditName | InputMode::EditDescription => handle_edit_mode(key), } } @@ -38,23 +44,29 @@ fn handle_normal_mode(key: KeyEvent) -> Action { KeyCode::Up | KeyCode::Char('k') => Action::Up, KeyCode::Down | KeyCode::Char('j') => Action::Down, - // Actions - KeyCode::Enter => Action::Select, + // Drill-down into selected item (Enter or l for vim-style) + KeyCode::Enter | KeyCode::Char('l') => Action::DrillDown, + + // Go back (Backspace, h for vim-style, or Esc) + KeyCode::Backspace | KeyCode::Char('h') => Action::GoBack, + + // Other actions KeyCode::Char('e') => Action::Edit, KeyCode::Char('d') => Action::Delete, KeyCode::Char('c') => Action::Navigate, // cd to worktree + // Preview toggle (space to show details in preview pane) + KeyCode::Char(' ') => Action::Select, + // Search KeyCode::Char('/') => Action::EnterSearch, - // Preview toggle (space to toggle preview visibility) - KeyCode::Char(' ') => Action::Select, - // Refresh KeyCode::Char('r') => Action::Refresh, - // Quit - KeyCode::Char('q') | KeyCode::Esc => Action::Quit, + // Quit (only q, Esc now goes back) + KeyCode::Char('q') => Action::Quit, + KeyCode::Esc => Action::GoBack, _ => Action::None, } @@ -108,11 +120,77 @@ fn handle_confirm_mode(key: KeyEvent) -> Action { } } +/// Handle key events in task output view mode +fn handle_output_mode(key: KeyEvent) -> Action { + // Check for Ctrl+C first + if key.modifiers.contains(KeyModifiers::CONTROL) { + if let KeyCode::Char('c') = key.code { + return Action::Quit; + } + } + + match key.code { + // Scroll + KeyCode::Up | KeyCode::Char('k') => Action::ScrollUp, + KeyCode::Down | KeyCode::Char('j') => Action::ScrollDown, + KeyCode::PageUp => Action::ScrollUp, + KeyCode::PageDown => Action::ScrollDown, + + // Scroll to bottom + KeyCode::Char('G') | KeyCode::End => Action::ScrollToBottom, + + // Go back (Backspace, h for vim-style, q, or Esc) + KeyCode::Backspace | KeyCode::Char('h') | KeyCode::Esc => Action::GoBack, + KeyCode::Char('q') => Action::GoBack, + + // Refresh (re-connect WebSocket) + KeyCode::Char('r') => Action::Refresh, + + // Navigate to worktree + KeyCode::Char('c') => Action::Navigate, + + _ => Action::None, + } +} + +/// Handle key events in edit mode +fn handle_edit_mode(key: KeyEvent) -> Action { + // Check for Ctrl+C first + if key.modifiers.contains(KeyModifiers::CONTROL) { + if let KeyCode::Char('c') = key.code { + return Action::Quit; + } + } + + match key.code { + // Save + KeyCode::Enter => Action::EditSave, + + // Cancel + KeyCode::Esc => Action::EditCancel, + + // Switch fields + KeyCode::Tab => Action::EditNextField, + + // Text input + KeyCode::Char(c) => Action::EditChar(c), + KeyCode::Backspace => Action::EditBackspace, + + _ => Action::None, + } +} + /// Get help text for current mode pub fn get_help_text(mode: InputMode) -> &'static str { match mode { - InputMode::Normal => "j/k: navigate | Enter: details | e: edit | d: delete | c: cd | /: search | q: quit", + InputMode::Normal => "j/k: nav | Enter: open | Esc/h: back | e: edit | d: del | c: cd | /: search | q: quit", InputMode::Search => "Type to search | Enter/Esc: exit search | Up/Down: navigate", InputMode::Confirm => "y: confirm | n/Esc: cancel", + InputMode::EditName | InputMode::EditDescription => "Type to edit | Tab: switch field | Enter: save | Esc: cancel", } } + +/// Get help text for output view +pub fn get_output_help_text() -> &'static str { + "j/k: scroll | G: bottom | c: cd | q/Esc: back | r: refresh" +} diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs index fd1d44d..4cb4f60 100644 --- a/makima/src/daemon/tui/mod.rs +++ b/makima/src/daemon/tui/mod.rs @@ -14,8 +14,10 @@ pub mod app; pub mod event; pub mod fuzzy; pub mod ui; +pub mod ws_client; -pub use app::{App, ListItem, ViewType, InputMode, Action}; +pub use app::{App, ListItem, ViewType, ViewState, InputMode, Action, OutputBuffer, OutputLine, OutputMessageType, WsConnectionState}; +pub use ws_client::{TuiWsClient, WsCommand, WsEvent, TaskOutputEvent}; pub use fuzzy::FuzzyMatcher; use std::io; diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs index 4003344..9349183 100644 --- a/makima/src/daemon/tui/ui.rs +++ b/makima/src/daemon/tui/ui.rs @@ -8,8 +8,8 @@ use ratatui::{ Frame, }; -use super::app::{App, InputMode, ViewType}; -use super::event::get_help_text; +use super::app::{App, InputMode, ViewType, OutputMessageType, WsConnectionState}; +use super::event::{get_help_text, get_output_help_text}; /// Main render function pub fn render(frame: &mut Frame, app: &App) { @@ -31,20 +31,21 @@ pub fn render(frame: &mut Frame, app: &App) { if app.input_mode == InputMode::Confirm { render_confirm_dialog(frame, app); } + + // Render edit dialog if in edit mode + if matches!(app.input_mode, InputMode::EditName | InputMode::EditDescription) { + render_edit_dialog(frame, app); + } } -/// Render header with title and search bar +/// Render header with breadcrumb and search bar fn render_header(frame: &mut Frame, app: &App, area: Rect) { - let title = match app.view_type { - ViewType::Tasks => "Tasks", - ViewType::Contracts => "Contracts", - ViewType::Files => "Files", - }; + let breadcrumb = app.get_breadcrumb(); let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() { - format!("{} [Search: {}]", title, app.search_query) + format!("{} [Search: {}]", breadcrumb, app.search_query) } else { - format!("{} ({} items)", title, app.filtered_items.len()) + format!("{} ({} items)", breadcrumb, app.filtered_items.len()) }; let header = Paragraph::new(header_text) @@ -62,6 +63,12 @@ fn render_header(frame: &mut Frame, app: &App, area: Rect) { /// Render main content (list + optional preview) fn render_main_content(frame: &mut Frame, app: &App, area: Rect) { + // TaskOutput view has its own rendering + if app.view_type == ViewType::TaskOutput { + render_output_view(frame, app, area); + return; + } + if app.preview_visible && !app.preview_content.is_empty() { // Split horizontally: list on left, preview on right let chunks = Layout::default() @@ -141,14 +148,31 @@ fn render_preview(frame: &mut Frame, app: &App, area: Rect) { /// Render footer with help text and status fn render_footer(frame: &mut Frame, app: &App, area: Rect) { - let help_text = get_help_text(app.input_mode); + // Use output-specific help text when in output view + let help_text = if app.view_type == ViewType::TaskOutput { + get_output_help_text() + } else { + get_help_text(app.input_mode) + }; + + // Build status text with WS connection state for output view + let ws_status = if app.view_type == ViewType::TaskOutput { + match app.ws_state { + WsConnectionState::Connected => " [WS: Connected]", + WsConnectionState::Connecting => " [WS: Connecting...]", + WsConnectionState::Reconnecting => " [WS: Reconnecting...]", + WsConnectionState::Disconnected => " [WS: Disconnected]", + } + } else { + "" + }; let status_text = app.status_message .as_ref() .map(|s| format!(" | {}", s)) .unwrap_or_default(); - let footer_text = format!("{}{}", help_text, status_text); + let footer_text = format!("{}{}{}", help_text, ws_status, status_text); let footer = Paragraph::new(footer_text) .style(Style::default().fg(Color::DarkGray)) @@ -207,3 +231,244 @@ fn render_confirm_dialog(frame: &mut Frame, app: &App) { frame.render_widget(popup, popup_area); } + +/// Render edit dialog as a centered popup +fn render_edit_dialog(frame: &mut Frame, app: &App) { + // Calculate popup size and position - make it wider + let area = frame.area(); + let popup_width = 80.min(area.width.saturating_sub(4)); + let popup_height = 14; + + let popup_x = (area.width.saturating_sub(popup_width)) / 2; + let popup_y = (area.height.saturating_sub(popup_height)) / 2; + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + // Clear the area behind the popup + frame.render_widget(Clear, popup_area); + + // Determine which field is active + let editing_name = app.input_mode == InputMode::EditName; + + // Calculate max display width (popup width - borders - label) + let max_field_width = (popup_width as usize).saturating_sub(16); + + // Build the name field with cursor and truncation + let name_display = if editing_name { + let cursor_pos = app.edit_state.cursor.min(app.edit_state.name.len()); + let (before, after) = app.edit_state.name.split_at(cursor_pos); + let display = format!("{}|{}", before, after); + // Show end of string if cursor is past visible area + if display.len() > max_field_width { + let start = display.len().saturating_sub(max_field_width); + format!("...{}", &display[start..]) + } else { + display + } + } else { + if app.edit_state.name.len() > max_field_width { + format!("{}...", &app.edit_state.name[..max_field_width.saturating_sub(3)]) + } else { + app.edit_state.name.clone() + } + }; + + // Build the description field with cursor and truncation + let desc_display = if !editing_name { + let cursor_pos = app.edit_state.cursor.min(app.edit_state.description.len()); + let (before, after) = app.edit_state.description.split_at(cursor_pos); + let display = format!("{}|{}", before, after); + // Show end of string if cursor is past visible area + if display.len() > max_field_width { + let start = display.len().saturating_sub(max_field_width); + format!("...{}", &display[start..]) + } else { + display + } + } else { + if app.edit_state.description.len() > max_field_width { + format!("{}...", &app.edit_state.description[..max_field_width.saturating_sub(3)]) + } else { + app.edit_state.description.clone() + } + }; + + // Determine field label based on view type + let desc_label = match app.view_type { + ViewType::Tasks => "Plan", + _ => "Desc", + }; + + // Style for active vs inactive fields + let active_style = Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let inactive_style = Style::default().fg(Color::White); + let label_style = Style::default().fg(Color::DarkGray); + + // Build popup content - use left alignment for fields + let text = vec![ + Line::from(""), + Line::from(Span::styled( + " Edit Item", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )), + Line::from(""), + // Name field + Line::from(vec![ + Span::styled(" Name: ", label_style), + Span::styled( + name_display, + if editing_name { active_style } else { inactive_style }, + ), + ]), + Line::from(""), + // Description field + Line::from(vec![ + Span::styled(format!(" {}: ", desc_label), label_style), + Span::styled( + desc_display, + if !editing_name { active_style } else { inactive_style }, + ), + ]), + Line::from(""), + Line::from(""), + Line::from(vec![ + Span::styled(" Tab", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(": switch "), + Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(": save "), + Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw(": cancel"), + ]), + ]; + + let popup = Paragraph::new(text) + .block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Edit ")); + + frame.render_widget(popup, popup_area); +} + +/// Render the task output view +fn render_output_view(frame: &mut Frame, app: &App, area: Rect) { + let buffer = &app.output_buffer; + + // Calculate visible area (subtract 2 for borders) + let visible_height = area.height.saturating_sub(2) as usize; + + // Build lines to display + let total_lines = buffer.lines.len(); + let start_idx = if total_lines > visible_height { + total_lines + .saturating_sub(visible_height) + .saturating_sub(buffer.scroll_offset) + } else { + 0 + }; + + let lines: Vec<Line> = buffer.lines + .iter() + .skip(start_idx) + .take(visible_height) + .map(|line| render_output_line(line)) + .collect(); + + // Build title with scroll indicator + let scroll_indicator = if buffer.auto_scroll { + "[auto-scroll]".to_string() + } else if buffer.scroll_offset > 0 { + format!("[+{}]", buffer.scroll_offset) + } else { + String::new() + }; + + let title = format!(" Task Output {} ", scroll_indicator); + + let paragraph = Paragraph::new(lines) + .block(Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(match app.ws_state { + WsConnectionState::Connected => Color::Green, + WsConnectionState::Connecting | WsConnectionState::Reconnecting => Color::Yellow, + WsConnectionState::Disconnected => Color::Red, + }))); + + frame.render_widget(paragraph, area); +} + +/// Render a single output line with appropriate styling +fn render_output_line(line: &super::app::OutputLine) -> Line<'static> { + match line.message_type { + OutputMessageType::Assistant => { + // Blue left indicator for assistant messages + Line::from(vec![ + Span::styled("│ ", Style::default().fg(Color::Blue)), + Span::styled(line.content.clone(), Style::default().fg(Color::White)), + ]) + } + OutputMessageType::ToolUse => { + // Yellow asterisk for tool calls + let tool_name = line.tool_name.clone().unwrap_or_else(|| "tool".to_string()); + Line::from(vec![ + Span::styled("* ", Style::default().fg(Color::Yellow)), + Span::styled(format!("[{}] ", tool_name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled(line.content.clone(), Style::default().fg(Color::DarkGray)), + ]) + } + OutputMessageType::ToolResult => { + // Green/red indicator for tool results + let indicator = if line.is_error { "✗ " } else { " + " }; + let color = if line.is_error { Color::Red } else { Color::Green }; + Line::from(vec![ + Span::styled(indicator, Style::default().fg(color)), + Span::styled(line.content.clone(), Style::default().fg(Color::Gray)), + ]) + } + OutputMessageType::Result => { + // Green checkmark for final results + let mut spans = vec![ + Span::styled("✓ ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::styled(line.content.clone(), Style::default().fg(Color::Green)), + ]; + // Add cost/duration if available + if let Some(cost) = line.cost_usd { + spans.push(Span::styled( + format!(" [${:.4}]", cost), + Style::default().fg(Color::DarkGray), + )); + } + if let Some(ms) = line.duration_ms { + spans.push(Span::styled( + format!(" [{}ms]", ms), + Style::default().fg(Color::DarkGray), + )); + } + Line::from(spans) + } + OutputMessageType::System => { + // Dim gray for system messages + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(line.content.clone(), Style::default().fg(Color::DarkGray)), + ]) + } + OutputMessageType::Error => { + // Red for errors + Line::from(vec![ + Span::styled("! ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(line.content.clone(), Style::default().fg(Color::Red)), + ]) + } + OutputMessageType::Raw => { + // Plain text + Line::from(line.content.clone()) + } + } +} diff --git a/makima/src/daemon/tui/views/contracts.rs b/makima/src/daemon/tui/views/contracts.rs index e2219b7..73b7c33 100644 --- a/makima/src/daemon/tui/views/contracts.rs +++ b/makima/src/daemon/tui/views/contracts.rs @@ -7,11 +7,19 @@ use crate::daemon::tui::app::ListItem; /// Load contracts from API pub async fn load_contracts( - _client: &ApiClient, + client: &ApiClient, ) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> { - // TODO: Implement listing all contracts - // This would require a new API endpoint - Ok(Vec::new()) + let result = client.list_contracts().await?; + + // Response is { "contracts": [...], "total": N } + let contracts = result + .0 + .get("contracts") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(ListItem::from_contract).collect()) + .unwrap_or_default(); + + Ok(contracts) } /// Get full contract details for preview diff --git a/makima/src/daemon/tui/ws_client.rs b/makima/src/daemon/tui/ws_client.rs new file mode 100644 index 0000000..3462467 --- /dev/null +++ b/makima/src/daemon/tui/ws_client.rs @@ -0,0 +1,353 @@ +//! TUI WebSocket client for task output streaming. +//! +//! Uses a dedicated async thread to handle WebSocket communication, +//! bridging async/sync worlds via channels. + +use std::sync::mpsc as std_mpsc; +use std::thread; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::runtime::Runtime; +use tokio::sync::mpsc as tokio_mpsc; +use uuid::Uuid; + +/// Commands sent from TUI to WebSocket client +#[derive(Debug, Clone)] +pub enum WsCommand { + /// Subscribe to task output + Subscribe { task_id: Uuid }, + /// Unsubscribe from task output + Unsubscribe { task_id: Uuid }, + /// Shutdown the WebSocket client + Shutdown, +} + +/// Events sent from WebSocket client to TUI +#[derive(Debug, Clone)] +pub enum WsEvent { + /// WebSocket connected + Connected, + /// WebSocket disconnected + Disconnected, + /// WebSocket reconnecting + Reconnecting { attempt: u32 }, + /// Subscription confirmed + Subscribed { task_id: Uuid }, + /// Unsubscription confirmed + Unsubscribed { task_id: Uuid }, + /// Task output received + TaskOutput(TaskOutputEvent), + /// Error occurred + Error { message: String }, +} + +/// Task output event from server +#[derive(Debug, Clone)] +pub struct TaskOutputEvent { + pub task_id: Uuid, + pub message_type: String, + pub content: String, + pub tool_name: Option<String>, + pub tool_input: Option<serde_json::Value>, + pub is_error: Option<bool>, + pub cost_usd: Option<f64>, + pub duration_ms: Option<u64>, + pub is_partial: bool, +} + +/// Messages sent to the WebSocket server +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum ClientMessage { + SubscribeOutput { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + UnsubscribeOutput { + #[serde(rename = "taskId")] + task_id: Uuid, + }, +} + +/// Messages received from the WebSocket server +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum ServerMessage { + OutputSubscribed { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + OutputUnsubscribed { + #[serde(rename = "taskId")] + task_id: Uuid, + }, + TaskOutput { + #[serde(rename = "taskId")] + task_id: Uuid, + #[serde(rename = "messageType")] + message_type: String, + content: String, + #[serde(rename = "toolName")] + tool_name: Option<String>, + #[serde(rename = "toolInput")] + tool_input: Option<serde_json::Value>, + #[serde(rename = "isError")] + is_error: Option<bool>, + #[serde(rename = "costUsd")] + cost_usd: Option<f64>, + #[serde(rename = "durationMs")] + duration_ms: Option<u64>, + #[serde(rename = "isPartial")] + is_partial: bool, + }, + Error { + code: String, + message: String, + }, + // Other message types we don't care about + #[serde(other)] + Other, +} + +/// TUI WebSocket client handle +pub struct TuiWsClient { + /// Command sender to WebSocket thread + command_tx: tokio_mpsc::Sender<WsCommand>, + /// Event receiver from WebSocket thread + event_rx: std_mpsc::Receiver<WsEvent>, +} + +impl TuiWsClient { + /// Start a new WebSocket client in a dedicated thread + pub fn start(api_url: String, api_key: String) -> Self { + let (command_tx, command_rx) = tokio_mpsc::channel(32); + let (event_tx, event_rx) = std_mpsc::channel(); + + // Spawn as daemon thread so it doesn't block process exit + thread::Builder::new() + .name("ws-client".to_string()) + .spawn(move || { + let rt = match Runtime::new() { + Ok(rt) => rt, + Err(e) => { + let _ = event_tx.send(WsEvent::Error { + message: format!("Failed to create tokio runtime: {}", e), + }); + return; + } + }; + rt.block_on(run_ws_client(api_url, api_key, command_rx, event_tx)); + }) + .ok(); + + Self { + command_tx, + event_rx, + } + } + + /// Send a command to the WebSocket client (non-blocking) + pub fn send(&self, command: WsCommand) { + // Use try_send to avoid blocking on shutdown + let _ = self.command_tx.try_send(command); + } + + /// Subscribe to task output + pub fn subscribe(&self, task_id: Uuid) { + self.send(WsCommand::Subscribe { task_id }); + } + + /// Unsubscribe from task output + pub fn unsubscribe(&self, task_id: Uuid) { + self.send(WsCommand::Unsubscribe { task_id }); + } + + /// Shutdown the WebSocket client + pub fn shutdown(&self) { + self.send(WsCommand::Shutdown); + } + + /// Try to receive an event (non-blocking) + pub fn try_recv(&self) -> Option<WsEvent> { + self.event_rx.try_recv().ok() + } + + /// Receive an event with timeout + pub fn recv_timeout(&self, timeout: Duration) -> Option<WsEvent> { + self.event_rx.recv_timeout(timeout).ok() + } +} + +impl Drop for TuiWsClient { + fn drop(&mut self) { + // Try to send shutdown command, but don't wait + let _ = self.command_tx.try_send(WsCommand::Shutdown); + } +} + +/// WebSocket client main loop +async fn run_ws_client( + api_url: String, + api_key: String, + mut command_rx: tokio_mpsc::Receiver<WsCommand>, + event_tx: std_mpsc::Sender<WsEvent>, +) { + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::{connect_async, tungstenite::client::IntoClientRequest, tungstenite::Message}; + + // Build WebSocket URL from HTTP URL + let ws_url = api_url + .replace("https://", "wss://") + .replace("http://", "ws://"); + let ws_url = format!("{}/api/v1/mesh/tasks/subscribe", ws_url); + + let mut reconnect_attempt = 0u32; + let max_reconnect_delay = Duration::from_secs(30); + let initial_delay = Duration::from_secs(1); + + loop { + // Build request with API key header + let mut request = match ws_url.clone().into_client_request() { + Ok(r) => r, + Err(e) => { + let _ = event_tx.send(WsEvent::Error { + message: format!("Invalid URL: {}", e), + }); + return; + } + }; + + // Send both headers - server will try tool key first, then API key + if let Ok(header_value) = api_key.parse() { + request.headers_mut().insert("x-makima-tool-key", header_value); + } + if let Ok(header_value) = api_key.parse() { + request.headers_mut().insert("x-makima-api-key", header_value); + } + + if reconnect_attempt > 0 { + let _ = event_tx.send(WsEvent::Reconnecting { + attempt: reconnect_attempt, + }); + + // Exponential backoff + let delay = std::cmp::min( + initial_delay * 2u32.saturating_pow(reconnect_attempt - 1), + max_reconnect_delay, + ); + tokio::time::sleep(delay).await; + } + + // Try to connect + let (ws_stream, _) = match connect_async(request).await { + Ok(result) => { + reconnect_attempt = 0; + let _ = event_tx.send(WsEvent::Connected); + result + } + Err(e) => { + reconnect_attempt += 1; + let _ = event_tx.send(WsEvent::Error { + message: format!("Connection failed: {}", e), + }); + continue; + } + }; + + let (mut write, mut read) = ws_stream.split(); + + // Main message loop + loop { + tokio::select! { + // Handle commands from TUI + cmd = command_rx.recv() => { + match cmd { + Some(WsCommand::Subscribe { task_id }) => { + let msg = ClientMessage::SubscribeOutput { task_id }; + if let Ok(json) = serde_json::to_string(&msg) { + let _ = write.send(Message::Text(json)).await; + } + } + Some(WsCommand::Unsubscribe { task_id }) => { + let msg = ClientMessage::UnsubscribeOutput { task_id }; + if let Ok(json) = serde_json::to_string(&msg) { + let _ = write.send(Message::Text(json)).await; + } + } + Some(WsCommand::Shutdown) | None => { + let _ = write.close().await; + return; + } + } + } + + // Handle messages from server + msg = read.next() => { + match msg { + Some(Ok(Message::Text(text))) => { + if let Ok(server_msg) = serde_json::from_str::<ServerMessage>(&text) { + match server_msg { + ServerMessage::OutputSubscribed { task_id } => { + let _ = event_tx.send(WsEvent::Subscribed { task_id }); + } + ServerMessage::OutputUnsubscribed { task_id } => { + let _ = event_tx.send(WsEvent::Unsubscribed { task_id }); + } + ServerMessage::TaskOutput { + task_id, + message_type, + content, + tool_name, + tool_input, + is_error, + cost_usd, + duration_ms, + is_partial, + } => { + let _ = event_tx.send(WsEvent::TaskOutput(TaskOutputEvent { + task_id, + message_type, + content, + tool_name, + tool_input, + is_error, + cost_usd, + duration_ms, + is_partial, + })); + } + ServerMessage::Error { code, message } => { + let _ = event_tx.send(WsEvent::Error { + message: format!("{}: {}", code, message), + }); + } + ServerMessage::Other => { + // Ignore other message types + } + } + } + } + Some(Ok(Message::Ping(data))) => { + let _ = write.send(Message::Pong(data)).await; + } + Some(Ok(Message::Close(_))) | None => { + let _ = event_tx.send(WsEvent::Disconnected); + reconnect_attempt += 1; + break; // Reconnect + } + Some(Err(e)) => { + let _ = event_tx.send(WsEvent::Error { + message: format!("WebSocket error: {}", e), + }); + let _ = event_tx.send(WsEvent::Disconnected); + reconnect_attempt += 1; + break; // Reconnect + } + _ => {} + } + } + } + } + } +} |
