diff options
| author | soryu <soryu@soryu.co> | 2026-01-20 16:45:34 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-20 16:45:34 +0000 |
| commit | c6c69af39b29276920b07e0d220f7016335f1019 (patch) | |
| tree | 13a4b58b00f67076b532933b9f8f208122d54bf9 /makima/src/daemon/tui | |
| parent | 36233b7cb834223878aa075bb379846eb6d7bb05 (diff) | |
| parent | a3ecb076a4f83f9c33fc3e4ad64af72c81b3ffd0 (diff) | |
| download | soryu-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>
Diffstat (limited to 'makima/src/daemon/tui')
| -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 |
6 files changed, 1215 insertions, 79 deletions
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 + } + _ => {} + } + } + } + } + } +} |
