summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-20 16:45:34 +0000
committersoryu <soryu@soryu.co>2026-01-20 16:45:34 +0000
commitc6c69af39b29276920b07e0d220f7016335f1019 (patch)
tree13a4b58b00f67076b532933b9f8f208122d54bf9
parent36233b7cb834223878aa075bb379846eb6d7bb05 (diff)
parenta3ecb076a4f83f9c33fc3e4ad64af72c81b3ffd0 (diff)
downloadsoryu-makima/contract-lifecycle-cleanup.tar.gz
soryu-makima/contract-lifecycle-cleanup.zip
Merge lifecycle improvementsmakima/contract-lifecycle-cleanup
Resolved conflict in supervisor.rs by keeping both: - supervisor_complete and supervisor_resume_contract methods (lifecycle management) - delete_task and update_task methods (task management) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--makima/frontend/src/components/contracts/ContractCliInput.tsx6
-rw-r--r--makima/frontend/src/routes/contracts.tsx2
-rw-r--r--makima/src/bin/makima.rs349
-rw-r--r--makima/src/daemon/api/client.rs22
-rw-r--r--makima/src/daemon/api/contract.rs33
-rw-r--r--makima/src/daemon/api/supervisor.rs25
-rw-r--r--makima/src/daemon/cli/mod.rs16
-rw-r--r--makima/src/daemon/cli/view.rs111
-rw-r--r--makima/src/daemon/mod.rs2
-rw-r--r--makima/src/daemon/tui/app.rs536
-rw-r--r--makima/src/daemon/tui/event.rs96
-rw-r--r--makima/src/daemon/tui/mod.rs4
-rw-r--r--makima/src/daemon/tui/ui.rs289
-rw-r--r--makima/src/daemon/tui/views/contracts.rs16
-rw-r--r--makima/src/daemon/tui/ws_client.rs353
15 files changed, 1649 insertions, 211 deletions
diff --git a/makima/frontend/src/components/contracts/ContractCliInput.tsx b/makima/frontend/src/components/contracts/ContractCliInput.tsx
index 821d03c..54d9f3a 100644
--- a/makima/frontend/src/components/contracts/ContractCliInput.tsx
+++ b/makima/frontend/src/components/contracts/ContractCliInput.tsx
@@ -279,9 +279,9 @@ export function ContractCliInput({ contractId, contract, onUpdate }: ContractCli
}
}, [messages]);
- // Auto-start supervisor when component mounts if it's pending
+ // Auto-start supervisor when component mounts if it's pending (but not for completed contracts)
useEffect(() => {
- if (supervisorTask && isSupervisorPending && !supervisorStarting) {
+ if (supervisorTask && isSupervisorPending && !supervisorStarting && contract.status !== 'completed') {
console.log("Auto-starting supervisor task on mount...");
ensureSupervisorStarted().then((started) => {
if (started) {
@@ -289,7 +289,7 @@ export function ContractCliInput({ contractId, contract, onUpdate }: ContractCli
}
});
}
- }, [supervisorTask?.id]); // Only run when task ID changes, not on every render
+ }, [supervisorTask?.id, contract.status]); // Only run when task ID or contract status changes
// Convert supervisor output events to messages
useEffect(() => {
diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx
index 0893ff6..6acda29 100644
--- a/makima/frontend/src/routes/contracts.tsx
+++ b/makima/frontend/src/routes/contracts.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback, useEffect, useMemo } from "react";
+import { useState, useCallback, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import { ContractList } from "../components/contracts/ContractList";
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs
index e103742..8b3e4dc 100644
--- a/makima/src/bin/makima.rs
+++ b/makima/src/bin/makima.rs
@@ -6,9 +6,9 @@ use std::sync::Arc;
use makima::daemon::api::ApiClient;
use makima::daemon::cli::{
- Cli, Commands, ContractCommand, SupervisorCommand, ViewCommand, ViewArgs,
+ Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs,
};
-use makima::daemon::tui::{self, App, ListItem, ViewType};
+use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState};
use makima::daemon::config::{DaemonConfig, RepoEntry};
use makima::daemon::db::LocalDb;
use makima::daemon::error::DaemonError;
@@ -558,58 +558,92 @@ async fn run_contract(
}
/// Run the TUI view command.
-async fn run_view(cmd: ViewCommand) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
- // Extract view type and args from command
- let (view_type, args) = match cmd {
- ViewCommand::Tasks(args) => (ViewType::Tasks, args),
- ViewCommand::Contracts(args) => (ViewType::Contracts, args),
- ViewCommand::Files(args) => (ViewType::Files, args),
- };
-
+async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Create API client
let client = ApiClient::new(args.api_url.clone(), args.api_key.clone())?;
- // Fetch initial data based on view type
- let items = match view_type {
- ViewType::Tasks => {
- let contract_id = args.contract_id
- .ok_or("Contract ID is required for tasks view (use --contract-id or MAKIMA_CONTRACT_ID)")?;
- let result = client.supervisor_tasks(contract_id).await?;
- // Parse tasks from JSON array
- result.0.as_array()
- .map(|arr| arr.iter().filter_map(ListItem::from_task).collect())
- .unwrap_or_default()
- }
- ViewType::Contracts => {
- // For contracts, we would need a list contracts endpoint
- // For now, return empty or fetch from a different endpoint
- eprintln!("Contracts view not yet implemented - requires list contracts endpoint");
- Vec::new()
- }
- ViewType::Files => {
- let contract_id = args.contract_id
- .ok_or("Contract ID is required for files view (use --contract-id or MAKIMA_CONTRACT_ID)")?;
- let result = client.contract_files(contract_id).await?;
- // Parse files from JSON array
- result.0.as_array()
- .map(|arr| arr.iter().filter_map(ListItem::from_file).collect())
- .unwrap_or_default()
- }
- };
+ // Start WebSocket client for task output streaming
+ let ws_client = TuiWsClient::start(args.api_url.clone(), args.api_key.clone());
+
+ // Start at contracts view
+ let mut app = App::new(ViewType::Contracts);
+
+ // Set initial search query if provided
+ if let Some(ref query) = args.query {
+ app.search_query = query.clone();
+ }
- // Create TUI app
- let mut app = App::new(view_type);
- app.contract_id = args.contract_id;
+ // Load initial contracts
+ let items = load_contracts(&client).await?;
app.set_items(items);
- // Run TUI
- match tui::run(app) {
+ // Run TUI with navigation support
+ let result = run_tui_with_navigation(app, client, ws_client).await;
+
+ result
+}
+
+/// Load contracts from API
+async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> {
+ let result = client.list_contracts().await?;
+ let items = result.0.get("contracts")
+ .and_then(|v| v.as_array())
+ .map(|arr| arr.iter().filter_map(ListItem::from_contract).collect())
+ .unwrap_or_default();
+ Ok(items)
+}
+
+/// Load tasks for a contract from API
+async fn load_tasks(client: &ApiClient, contract_id: uuid::Uuid) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> {
+ let result = client.supervisor_tasks(contract_id).await?;
+ let items = result.0.as_array()
+ .map(|arr| arr.iter().filter_map(ListItem::from_task).collect())
+ .unwrap_or_default();
+ Ok(items)
+}
+
+/// Run the TUI with navigation support for drill-down views
+async fn run_tui_with_navigation(
+ mut app: App,
+ client: ApiClient,
+ ws_client: TuiWsClient,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ use crossterm::{
+ event::{DisableMouseCapture, EnableMouseCapture},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+ };
+ use ratatui::backend::CrosstermBackend;
+ use std::io;
+
+ // Setup terminal
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = ratatui::Terminal::new(backend)?;
+
+ let result = run_tui_loop(&mut terminal, &mut app, &client, &ws_client).await;
+
+ // Cleanup WebSocket
+ ws_client.shutdown();
+
+ // Cleanup terminal
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
+ terminal.show_cursor()?;
+
+ match result {
Ok(Some(path)) => {
- // Output the path for shell integration (e.g., cd $(makima view tasks))
+ // Output the path for shell integration
tui::print_path(&path);
}
Ok(None) => {
- // Normal exit, no output needed
+ // Normal exit
}
Err(e) => {
eprintln!("TUI error: {}", e);
@@ -620,6 +654,233 @@ async fn run_view(cmd: ViewCommand) -> Result<(), Box<dyn std::error::Error + Se
Ok(())
}
+/// Main TUI event loop with async data loading
+async fn run_tui_loop(
+ terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
+ app: &mut App,
+ client: &ApiClient,
+ ws_client: &TuiWsClient,
+) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
+ use crossterm::event::{self, Event};
+ use std::time::Duration;
+
+ // Track currently subscribed task for cleanup
+ let mut subscribed_task_id: Option<uuid::Uuid> = None;
+
+ loop {
+ terminal.draw(|f| tui::ui::render(f, app))?;
+
+ // Process WebSocket events (non-blocking)
+ while let Some(ws_event) = ws_client.try_recv() {
+ handle_ws_event(app, ws_event);
+ }
+
+ // Poll for keyboard events with short timeout (50ms for responsive WS handling)
+ if event::poll(Duration::from_millis(50))? {
+ if let Event::Key(key) = event::read()? {
+ let action = tui::event::handle_key_event(app, key);
+ match action {
+ Action::Quit => break,
+ Action::OutputPath(path) => return Ok(Some(path)),
+ Action::None => {}
+ _ => {
+ let result = app.handle_action(action);
+ match result {
+ Action::OutputPath(path) => return Ok(Some(path)),
+ Action::LoadTasks { contract_id, contract_name: _ } => {
+ // Unsubscribe from any previous task
+ if let Some(task_id) = subscribed_task_id.take() {
+ ws_client.unsubscribe(task_id);
+ }
+ // Load tasks for the selected contract
+ match load_tasks(client, contract_id).await {
+ Ok(items) => {
+ app.set_items(items);
+ }
+ Err(e) => {
+ app.status_message = Some(format!("Failed to load tasks: {}", e));
+ }
+ }
+ }
+ Action::LoadTaskOutput { task_id, task_name: _ } => {
+ // Clear previous output
+ app.output_buffer.clear();
+ app.ws_state = WsConnectionState::Connecting;
+
+ // Unsubscribe from previous task if any
+ if let Some(old_task_id) = subscribed_task_id.take() {
+ ws_client.unsubscribe(old_task_id);
+ }
+
+ // Subscribe to new task output
+ ws_client.subscribe(task_id);
+ subscribed_task_id = Some(task_id);
+ app.status_message = Some("Connecting to task output...".to_string());
+ }
+ Action::PerformDelete { id, item_type } => {
+ // Perform the delete API call
+ let delete_result = match item_type {
+ ViewType::Contracts => {
+ client.delete_contract(id).await
+ }
+ ViewType::Tasks => {
+ client.delete_task(id).await
+ }
+ ViewType::TaskOutput => {
+ // Can't delete from output view
+ Ok(())
+ }
+ };
+
+ match delete_result {
+ Ok(()) => {
+ app.status_message = Some("Deleted successfully".to_string());
+ // Remove item from list
+ app.items.retain(|item| item.id != id);
+ app.update_filtered_items();
+ }
+ Err(e) => {
+ app.status_message = Some(format!("Delete failed: {}", e));
+ }
+ }
+ }
+ Action::PerformUpdate { id, item_type, name, description } => {
+ // Perform the update API call
+ let update_result = match item_type {
+ ViewType::Contracts => {
+ client.update_contract(id, Some(name.clone()), Some(description.clone())).await.map(|_| ())
+ }
+ ViewType::Tasks => {
+ // For tasks, description is the plan
+ client.update_task(id, Some(name.clone()), Some(description.clone())).await.map(|_| ())
+ }
+ ViewType::TaskOutput => {
+ // Can't edit from output view
+ Ok(())
+ }
+ };
+
+ match update_result {
+ Ok(()) => {
+ app.status_message = Some("Updated successfully".to_string());
+ // Update item in list
+ for item in &mut app.items {
+ if item.id == id {
+ item.name = name.clone();
+ item.description = Some(description.clone());
+ break;
+ }
+ }
+ app.update_filtered_items();
+ }
+ Err(e) => {
+ app.status_message = Some(format!("Update failed: {}", e));
+ }
+ }
+ }
+ Action::Refresh => {
+ // Reload data for current view
+ match app.view_type {
+ ViewType::Contracts => {
+ // Unsubscribe from task when going back to contracts
+ if let Some(task_id) = subscribed_task_id.take() {
+ ws_client.unsubscribe(task_id);
+ }
+ match load_contracts(client).await {
+ Ok(items) => app.set_items(items),
+ Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)),
+ }
+ }
+ ViewType::Tasks => {
+ // Unsubscribe from task when going back to tasks
+ if let Some(task_id) = subscribed_task_id.take() {
+ ws_client.unsubscribe(task_id);
+ }
+ if let Some(contract_id) = app.contract_id {
+ match load_tasks(client, contract_id).await {
+ Ok(items) => app.set_items(items),
+ Err(e) => app.status_message = Some(format!("Refresh failed: {}", e)),
+ }
+ }
+ }
+ ViewType::TaskOutput => {
+ // Re-subscribe to the task output
+ if let Some(task_id) = app.task_id {
+ app.output_buffer.clear();
+ app.ws_state = WsConnectionState::Connecting;
+ ws_client.subscribe(task_id);
+ subscribed_task_id = Some(task_id);
+ app.status_message = Some("Reconnecting...".to_string());
+ }
+ }
+ }
+ }
+ Action::GoBack => {
+ // Unsubscribe when going back from output view
+ if let Some(task_id) = subscribed_task_id.take() {
+ ws_client.unsubscribe(task_id);
+ app.ws_state = WsConnectionState::Disconnected;
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+
+ if app.should_quit {
+ break;
+ }
+ }
+
+ Ok(None)
+}
+
+/// Handle a WebSocket event and update app state
+fn handle_ws_event(app: &mut App, event: WsEvent) {
+ match event {
+ WsEvent::Connected => {
+ app.ws_state = WsConnectionState::Connected;
+ app.status_message = Some("Connected".to_string());
+ }
+ WsEvent::Disconnected => {
+ app.ws_state = WsConnectionState::Disconnected;
+ app.status_message = Some("Disconnected".to_string());
+ }
+ WsEvent::Reconnecting { attempt } => {
+ app.ws_state = WsConnectionState::Reconnecting;
+ app.status_message = Some(format!("Reconnecting (attempt {})...", attempt));
+ }
+ WsEvent::Subscribed { task_id: _ } => {
+ app.ws_state = WsConnectionState::Connected;
+ app.status_message = Some("Subscribed to task output".to_string());
+ }
+ WsEvent::Unsubscribed { task_id: _ } => {
+ // No status update needed
+ }
+ WsEvent::TaskOutput(output) => {
+ // Convert WebSocket event to OutputLine
+ let line = OutputLine {
+ message_type: OutputMessageType::from_str(&output.message_type),
+ content: output.content,
+ tool_name: output.tool_name,
+ is_error: output.is_error.unwrap_or(false),
+ cost_usd: output.cost_usd,
+ duration_ms: output.duration_ms,
+ };
+ app.output_buffer.add_line(line);
+ // Clear status message once we're receiving output
+ if app.status_message.as_ref().map(|s| s.contains("Subscribed")).unwrap_or(false) {
+ app.status_message = None;
+ }
+ }
+ WsEvent::Error { message } => {
+ app.status_message = Some(format!("WS Error: {}", message));
+ }
+ }
+}
+
fn init_logging(level: &str, format: &str) {
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(level))
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 26d786c..bd3aefd 100644
--- a/makima/src/daemon/api/supervisor.rs
+++ b/makima/src/daemon/api/supervisor.rs
@@ -283,4 +283,29 @@ impl ApiClient {
self.put(&format!("/api/v1/contracts/{}", contract_id), &req)
.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 44c7a06..3394b35 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
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+}