summaryrefslogtreecommitdiff
path: root/makima/src/daemon
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon')
-rw-r--r--makima/src/daemon/cli/mod.rs5
-rw-r--r--makima/src/daemon/cli/view.rs93
-rw-r--r--makima/src/daemon/mod.rs11
-rw-r--r--makima/src/daemon/tui/app.rs1219
-rw-r--r--makima/src/daemon/tui/event.rs269
-rw-r--r--makima/src/daemon/tui/fuzzy.rs217
-rw-r--r--makima/src/daemon/tui/mod.rs98
-rw-r--r--makima/src/daemon/tui/ui.rs695
-rw-r--r--makima/src/daemon/tui/views/contracts.rs32
-rw-r--r--makima/src/daemon/tui/views/files.rs90
-rw-r--r--makima/src/daemon/tui/views/mod.rs3
-rw-r--r--makima/src/daemon/tui/views/tasks.rs71
-rw-r--r--makima/src/daemon/tui/widgets/list_view.rs127
-rw-r--r--makima/src/daemon/tui/widgets/mod.rs4
-rw-r--r--makima/src/daemon/tui/widgets/preview_pane.rs21
-rw-r--r--makima/src/daemon/tui/widgets/search_input.rs82
-rw-r--r--makima/src/daemon/tui/widgets/status_bar.rs19
-rw-r--r--makima/src/daemon/tui/ws_client.rs353
18 files changed, 6 insertions, 3403 deletions
diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs
index acad9ad..077a37e 100644
--- a/makima/src/daemon/cli/mod.rs
+++ b/makima/src/daemon/cli/mod.rs
@@ -4,7 +4,6 @@ pub mod config;
pub mod daemon;
pub mod directive;
pub mod server;
-pub mod view;
use clap::{Parser, Subcommand};
@@ -12,7 +11,6 @@ pub use config::CliConfig;
pub use daemon::DaemonArgs;
pub use directive::DirectiveArgs;
pub use server::ServerArgs;
-pub use view::ViewArgs;
/// Makima - unified CLI for server, daemon, and task management.
#[derive(Parser, Debug)]
@@ -35,9 +33,6 @@ pub enum Commands {
#[command(subcommand)]
Directive(DirectiveCommand),
- /// Interactive TUI browser for directives and tasks
- View(ViewArgs),
-
/// Configure CLI settings (API key, server URL)
///
/// Saves configuration to ~/.makima/config.toml for use by CLI commands.
diff --git a/makima/src/daemon/cli/view.rs b/makima/src/daemon/cli/view.rs
deleted file mode 100644
index b9fa82f..0000000
--- a/makima/src/daemon/cli/view.rs
+++ /dev/null
@@ -1,93 +0,0 @@
-//! 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 contracts and their tasks. It features
-//! drill-down navigation, fuzzy search filtering, and real-time task output streaming.
-//!
-//! # Usage
-//!
-//! ```bash
-//! # Browse contracts interactively
-//! makima view
-//!
-//! # Browse with an initial search query
-//! makima view "my project"
-//!
-//! # Change directory to selected task's worktree
-//! cd $(makima view)
-//! ```
-//!
-//! # Keyboard Shortcuts
-//!
-//! | 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
-//! - **Real-time Streaming**: View live task output via WebSocket
-//! - **Preview Pane**: See item details without leaving the list
-
-use clap::Args;
-
-/// Interactive TUI browser for contracts and tasks.
-///
-/// Provides a fuzzy-searchable interface for browsing contracts,
-/// viewing their tasks, and streaming real-time task output.
-///
-/// # Examples
-///
-/// Browse contracts:
-/// ```bash
-/// makima view
-/// ```
-///
-/// Browse with initial search:
-/// ```bash
-/// makima view "auth"
-/// ```
-#[derive(Args, Debug, Clone)]
-pub struct ViewArgs {
- /// API URL for the makima server
- ///
- /// If not provided, uses MAKIMA_API_URL env var or ~/.makima/config.toml
- #[arg(long, env = "MAKIMA_API_URL")]
- pub api_url: Option<String>,
-
- /// API key for authentication
- ///
- /// If not provided, uses MAKIMA_API_KEY env var or ~/.makima/config.toml
- #[arg(long, env = "MAKIMA_API_KEY")]
- pub api_key: Option<String>,
-
- /// Initial search query
- ///
- /// Pre-populates the search field with this query when the TUI opens.
- #[arg(index = 1)]
- pub query: Option<String>,
-
- /// Disable the preview pane
- ///
- /// Shows only the item list without the side preview panel.
- /// Useful for smaller terminal windows.
- #[arg(long)]
- pub no_preview: bool,
-}
diff --git a/makima/src/daemon/mod.rs b/makima/src/daemon/mod.rs
index e15608b..014b6d7 100644
--- a/makima/src/daemon/mod.rs
+++ b/makima/src/daemon/mod.rs
@@ -3,9 +3,11 @@
//! This crate provides:
//! - `makima server` - Run the makima server
//! - `makima daemon` - Run the daemon (connect to server, manage tasks)
-//! - `makima supervisor` - Contract orchestration commands
-//! - `makima contract` - Task-contract interaction commands
-//! - `makima view` - Interactive TUI browser for tasks, contracts, and files
+//! - `makima directive` - Directive command group (ask, create-order, etc.)
+//!
+//! The legacy `makima supervisor` / `makima contract` / `makima view`
+//! command groups were removed alongside the legacy contracts +
+//! supervisor task-grouping system.
pub mod api;
pub mod cli;
@@ -19,10 +21,9 @@ pub mod skills;
pub mod storage;
pub mod task;
pub mod temp;
-pub mod tui;
pub mod worktree;
pub mod ws;
-pub use cli::{Cli, Commands, ViewArgs};
+pub use cli::{Cli, Commands};
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
deleted file mode 100644
index cb0e8f3..0000000
--- a/makima/src/daemon/tui/app.rs
+++ /dev/null
@@ -1,1219 +0,0 @@
-//! 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 {
- /// List of contracts
- Contracts,
- /// Tasks for a specific contract
- Tasks,
- /// Task output streaming view
- TaskOutput,
-}
-
-impl ViewType {
- pub fn as_str(&self) -> &'static str {
- match self {
- ViewType::Contracts => "contracts",
- 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 {
- /// Normal navigation mode
- Normal,
- /// Fuzzy search mode
- Search,
- /// Confirmation dialog (e.g., for delete)
- Confirm,
- /// Edit mode - editing name
- EditName,
- /// Edit mode - editing description/plan
- EditDescription,
- /// Create contract - editing name
- CreateName,
- /// Create contract - editing description
- CreateDescription,
-}
-
-/// Create contract form field
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum CreateFormField {
- Name,
- Description,
- ContractType,
- Repository,
-}
-
-/// Repository suggestion from history
-#[derive(Debug, Clone)]
-pub struct RepositorySuggestion {
- pub name: String,
- pub repository_url: Option<String>,
- pub local_path: Option<String>,
- pub source_type: String,
- pub use_count: i32,
-}
-
-/// State for create contract form
-#[derive(Debug, Clone, Default)]
-pub struct CreateContractState {
- /// Contract name
- pub name: String,
- /// Contract description
- pub description: String,
- /// Contract type: "simple" or "specification"
- pub contract_type: String,
- /// Repository URL (optional)
- pub repository_url: String,
- /// Currently focused field
- pub focused_field: usize,
- /// Cursor position in current text field
- pub cursor: usize,
- /// Available repository suggestions
- pub repo_suggestions: Vec<RepositorySuggestion>,
- /// Selected suggestion index (for repository field)
- pub selected_suggestion: usize,
- /// Whether suggestions popup is visible
- pub show_suggestions: bool,
- /// Whether suggestions have been loaded
- pub suggestions_loaded: bool,
-}
-
-impl CreateContractState {
- pub fn new() -> Self {
- Self {
- name: String::new(),
- description: String::new(),
- contract_type: "simple".to_string(),
- repository_url: String::new(),
- focused_field: 0,
- cursor: 0,
- repo_suggestions: Vec::new(),
- selected_suggestion: 0,
- show_suggestions: false,
- suggestions_loaded: false,
- }
- }
-
- /// Set repository suggestions
- pub fn set_suggestions(&mut self, suggestions: Vec<RepositorySuggestion>) {
- self.repo_suggestions = suggestions;
- self.selected_suggestion = 0;
- self.show_suggestions = !self.repo_suggestions.is_empty();
- self.suggestions_loaded = true;
- }
-
- /// Apply the selected suggestion to the form
- pub fn apply_selected_suggestion(&mut self) {
- if let Some(suggestion) = self.repo_suggestions.get(self.selected_suggestion) {
- // Apply the suggestion
- if let Some(ref url) = suggestion.repository_url {
- self.repository_url = url.clone();
- } else if let Some(ref path) = suggestion.local_path {
- self.repository_url = path.clone();
- }
- self.cursor = self.repository_url.len();
- self.show_suggestions = false;
- }
- }
-
- /// Navigate to next suggestion
- pub fn next_suggestion(&mut self) {
- if !self.repo_suggestions.is_empty() {
- self.selected_suggestion = (self.selected_suggestion + 1) % self.repo_suggestions.len();
- }
- }
-
- /// Navigate to previous suggestion
- pub fn prev_suggestion(&mut self) {
- if !self.repo_suggestions.is_empty() {
- self.selected_suggestion = if self.selected_suggestion == 0 {
- self.repo_suggestions.len() - 1
- } else {
- self.selected_suggestion - 1
- };
- }
- }
-
- /// Get the field at the given index
- pub fn field_at(&self, index: usize) -> CreateFormField {
- match index {
- 0 => CreateFormField::Name,
- 1 => CreateFormField::Description,
- 2 => CreateFormField::ContractType,
- 3 => CreateFormField::Repository,
- _ => CreateFormField::Name,
- }
- }
-
- /// Get the current field
- pub fn current_field(&self) -> CreateFormField {
- self.field_at(self.focused_field)
- }
-
- /// Get mutable reference to the current text field value
- pub fn current_value_mut(&mut self) -> Option<&mut String> {
- match self.current_field() {
- CreateFormField::Name => Some(&mut self.name),
- CreateFormField::Description => Some(&mut self.description),
- CreateFormField::Repository => Some(&mut self.repository_url),
- CreateFormField::ContractType => None, // Not a text field
- }
- }
-
- /// Get the current text field value
- pub fn current_value(&self) -> Option<&str> {
- match self.current_field() {
- CreateFormField::Name => Some(&self.name),
- CreateFormField::Description => Some(&self.description),
- CreateFormField::Repository => Some(&self.repository_url),
- CreateFormField::ContractType => None,
- }
- }
-
- /// Move to next field
- pub fn next_field(&mut self) {
- self.focused_field = (self.focused_field + 1) % 4;
- self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0);
- // Don't hide suggestions - they stay visible
- }
-
- /// Move to previous field
- pub fn prev_field(&mut self) {
- self.focused_field = if self.focused_field == 0 { 3 } else { self.focused_field - 1 };
- self.cursor = self.current_value().map(|v| v.len()).unwrap_or(0);
- // Don't hide suggestions - they stay visible
- }
-
- /// Toggle contract type
- pub fn toggle_contract_type(&mut self) {
- self.contract_type = if self.contract_type == "simple" {
- "specification".to_string()
- } else {
- "simple".to_string()
- };
- }
-
- /// Insert character at cursor
- pub fn insert_char(&mut self, c: char) {
- let cursor = self.cursor;
- match self.current_field() {
- CreateFormField::Name => {
- self.name.insert(cursor, c);
- self.cursor += 1;
- }
- CreateFormField::Description => {
- self.description.insert(cursor, c);
- self.cursor += 1;
- }
- CreateFormField::Repository => {
- self.repository_url.insert(cursor, c);
- self.cursor += 1;
- }
- CreateFormField::ContractType => {}
- }
- }
-
- /// Delete character before cursor
- pub fn backspace(&mut self) {
- if self.cursor > 0 {
- let cursor = self.cursor - 1;
- match self.current_field() {
- CreateFormField::Name => {
- self.name.remove(cursor);
- self.cursor = cursor;
- }
- CreateFormField::Description => {
- self.description.remove(cursor);
- self.cursor = cursor;
- }
- CreateFormField::Repository => {
- self.repository_url.remove(cursor);
- self.cursor = cursor;
- }
- CreateFormField::ContractType => {}
- }
- }
- }
-
- /// Check if form is valid (name is required)
- pub fn is_valid(&self) -> bool {
- !self.name.trim().is_empty()
- }
-}
-
-/// 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
-#[derive(Debug, Clone, PartialEq)]
-pub enum Action {
- /// Do nothing
- None,
- /// Move selection up
- Up,
- /// Move selection down
- Down,
- /// Select current item (show details in preview)
- Select,
- /// 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,
- /// Navigate to worktree (output path and exit)
- Navigate,
- /// Confirm pending action
- ConfirmYes,
- /// Cancel pending action
- ConfirmNo,
- /// Enter search mode
- EnterSearch,
- /// Exit search mode
- ExitSearch,
- /// Add character to search
- SearchChar(char),
- /// Backspace in search
- SearchBackspace,
- /// Clear search
- ClearSearch,
- /// Quit the application
- Quit,
- /// Output a path to stdout and exit (for cd integration)
- OutputPath(String),
- /// Launch editor with path
- 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,
- /// Open create contract form
- NewContract,
- /// Add character in create form
- CreateChar(char),
- /// Backspace in create form
- CreateBackspace,
- /// Move to next field in create form
- CreateNextField,
- /// Move to previous field in create form
- CreatePrevField,
- /// Toggle value (for contract type)
- CreateToggle,
- /// Submit create form
- CreateSubmit,
- /// Cancel create form
- CreateCancel,
- /// Request to create contract (internal)
- PerformCreateContract {
- name: String,
- description: String,
- contract_type: String,
- repository_url: Option<String>,
- },
- /// Request to load repository suggestions (internal)
- LoadRepoSuggestions,
- /// Navigate to next suggestion in create form
- CreateNextSuggestion,
- /// Navigate to previous suggestion in create form
- CreatePrevSuggestion,
- /// Apply selected suggestion in create form
- CreateApplySuggestion,
-}
-
-/// A displayable item in the TUI
-#[derive(Debug, Clone)]
-pub struct ListItem {
- pub id: Uuid,
- pub name: String,
- pub status: Option<String>,
- pub description: Option<String>,
- /// Extra data for actions (e.g., worktree path)
- pub extra: Value,
-}
-
-impl ListItem {
- pub fn from_task(value: &Value) -> Option<Self> {
- let id = value.get("id")
- .and_then(|v| v.as_str())
- .and_then(|s| Uuid::parse_str(s).ok())?;
-
- let name = value.get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("Unnamed")
- .to_string();
-
- let status = value.get("status")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string());
-
- let description = value.get("plan")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string());
-
- Some(Self {
- id,
- name,
- status,
- description,
- extra: value.clone(),
- })
- }
-
- pub fn from_contract(value: &Value) -> Option<Self> {
- let id = value.get("id")
- .and_then(|v| v.as_str())
- .and_then(|s| Uuid::parse_str(s).ok())?;
-
- let name = value.get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("Unnamed")
- .to_string();
-
- let status = value.get("phase")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string());
-
- let description = value.get("description")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string());
-
- Some(Self {
- id,
- name,
- status,
- description,
- extra: value.clone(),
- })
- }
-
- pub fn from_file(value: &Value) -> Option<Self> {
- let id = value.get("id")
- .and_then(|v| v.as_str())
- .and_then(|s| Uuid::parse_str(s).ok())?;
-
- let name = value.get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("Unnamed")
- .to_string();
-
- let description = value.get("template_name")
- .and_then(|v| v.as_str())
- .map(|s| format!("Template: {}", s));
-
- Some(Self {
- id,
- name,
- status: None,
- description,
- extra: value.clone(),
- })
- }
-
- /// Get the worktree path from task extra data
- pub fn get_worktree_path(&self) -> Option<String> {
- // Try various field names that might contain the worktree path
- self.extra.get("worktreePath")
- .or_else(|| self.extra.get("worktree_path"))
- .or_else(|| self.extra.get("workdir"))
- .and_then(|v| v.as_str())
- .map(|s| s.to_string())
- }
-
- /// Build a detailed view string for display
- pub fn build_detail_view(&self, view_type: ViewType) -> String {
- 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));
- lines.push(format!("│ ID: {}", self.id));
- if let Some(ref status) = self.status {
- lines.push(format!("│ Status: {}", status));
- }
- if let Some(ref desc) = self.description {
- lines.push(format!("│ Plan: {}", desc));
- }
- if let Some(path) = self.get_worktree_path() {
- lines.push(format!("│ Worktree: {}", path));
- }
- // Add progress if available
- 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("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::TaskOutput => {
- // Output view doesn't use this preview pane
- lines.push(format!("╭─ Task Output ──────────────────────────"));
- lines.push(format!("│ Streaming task output..."));
- lines.push(format!("╰────────────────────────────────────────"));
- }
- }
-
- lines.join("\n")
- }
-}
-
-/// TUI Application state
-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)
- pub filtered_items: Vec<ListItem>,
- /// Currently selected index in filtered list
- pub selected_index: usize,
- /// Current input mode
- pub input_mode: InputMode,
- /// Search query
- pub search_query: String,
- /// Fuzzy matcher
- matcher: SkimMatcherV2,
- /// Preview content (for selected item)
- pub preview_content: String,
- /// Whether preview is visible
- pub preview_visible: bool,
- /// Pending delete item (for confirmation)
- pub pending_delete: Option<Uuid>,
- /// Edit state for inline editing
- pub edit_state: EditState,
- /// Create contract form state
- pub create_state: CreateContractState,
- /// 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>,
- /// 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,
- input_mode: InputMode::Normal,
- search_query: String::new(),
- matcher: SkimMatcherV2::default(),
- preview_content: String::new(),
- preview_visible: false,
- pending_delete: None,
- edit_state: EditState::default(),
- create_state: CreateContractState::new(),
- status_message: None,
- should_quit: false,
- exit_action: 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;
- self.update_filtered_items();
- }
-
- /// Update filtered items based on search query
- pub fn update_filtered_items(&mut self) {
- if self.search_query.is_empty() {
- self.filtered_items = self.items.clone();
- } else {
- let mut scored: Vec<_> = self.items
- .iter()
- .filter_map(|item| {
- let score = self.matcher.fuzzy_match(&item.name, &self.search_query)?;
- Some((score, item.clone()))
- })
- .collect();
-
- // Sort by score (highest first)
- scored.sort_by(|a, b| b.0.cmp(&a.0));
- self.filtered_items = scored.into_iter().map(|(_, item)| item).collect();
- }
-
- // Reset selection if out of bounds
- if self.selected_index >= self.filtered_items.len() {
- self.selected_index = self.filtered_items.len().saturating_sub(1);
- }
- }
-
- /// Get currently selected item
- pub fn selected_item(&self) -> Option<&ListItem> {
- self.filtered_items.get(self.selected_index)
- }
-
- /// Handle an action and return the resulting action
- pub fn handle_action(&mut self, action: Action) -> Action {
- match action {
- Action::Up => {
- if self.selected_index > 0 {
- self.selected_index -= 1;
- }
- Action::None
- }
- Action::Down => {
- if self.selected_index < self.filtered_items.len().saturating_sub(1) {
- self.selected_index += 1;
- }
- Action::None
- }
- Action::Select => {
- // Build detailed view for selected item
- if let Some(item) = self.selected_item() {
- self.preview_content = item.build_detail_view(self.view_type);
- self.preview_visible = true;
- }
- 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();
- // Push view and set state before returning
- self.push_view(ViewType::Tasks);
- self.contract_id = Some(contract_id);
- self.contract_name = Some(contract_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();
- // Push view and set state before returning
- self.push_view(ViewType::TaskOutput);
- self.task_id = Some(task_id);
- self.task_name = Some(task_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 => {
- // Enter edit mode for selected item
- if let Some(item) = self.selected_item() {
- 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
- if let Some(item) = self.selected_item() {
- let id = item.id;
- let name = item.name.clone();
- self.pending_delete = Some(id);
- self.input_mode = InputMode::Confirm;
- self.status_message = Some(format!("Delete '{}'? (y/n)", name));
- }
- Action::None
- }
- Action::Navigate => {
- // Get worktree path and output it
- if let Some(item) = self.selected_item() {
- if let Some(path) = item.get_worktree_path() {
- self.should_quit = true;
- self.exit_action = Some(Action::OutputPath(path.clone()));
- return Action::OutputPath(path);
- } else {
- self.status_message = Some("No worktree path for this item".to_string());
- }
- }
- Action::None
- }
- Action::ConfirmYes => {
- if self.input_mode == InputMode::Confirm {
- 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;
- }
- Action::None
- }
- Action::ConfirmNo => {
- if self.input_mode == InputMode::Confirm {
- self.pending_delete = None;
- self.input_mode = InputMode::Normal;
- self.status_message = Some("Delete cancelled".to_string());
- }
- Action::None
- }
- Action::EnterSearch => {
- self.input_mode = InputMode::Search;
- Action::None
- }
- Action::ExitSearch => {
- self.input_mode = InputMode::Normal;
- Action::None
- }
- Action::SearchChar(c) => {
- self.search_query.push(c);
- self.update_filtered_items();
- Action::None
- }
- Action::SearchBackspace => {
- self.search_query.pop();
- self.update_filtered_items();
- Action::None
- }
- Action::ClearSearch => {
- self.search_query.clear();
- self.update_filtered_items();
- Action::None
- }
- Action::Quit => {
- self.should_quit = true;
- Action::Quit
- }
- Action::Refresh => {
- // Signal to caller to refresh data
- Action::Refresh
- }
- Action::OutputPath(path) => {
- self.should_quit = true;
- self.exit_action = Some(Action::OutputPath(path.clone()));
- Action::OutputPath(path)
- }
- Action::LaunchEditor(path) => {
- self.should_quit = true;
- self.exit_action = Some(Action::LaunchEditor(path.clone()));
- Action::LaunchEditor(path)
- }
- Action::LoadTasks { contract_id, contract_name } => {
- // Pass through to caller for data loading (view already pushed by DrillDown)
- Action::LoadTasks { contract_id, contract_name }
- }
- Action::LoadTaskOutput { task_id, task_name } => {
- // Pass through to caller for data loading (view already pushed by DrillDown)
- 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::NewContract => {
- // Only allow creating contracts from contracts view
- if self.view_type == ViewType::Contracts {
- self.create_state = CreateContractState::new();
- self.input_mode = InputMode::CreateName;
- // Request to load repository suggestions
- return Action::LoadRepoSuggestions;
- }
- Action::None
- }
- Action::CreateChar(c) => {
- self.create_state.insert_char(c);
- Action::None
- }
- Action::CreateBackspace => {
- self.create_state.backspace();
- Action::None
- }
- Action::CreateNextField => {
- self.create_state.next_field();
- Action::None
- }
- Action::CreatePrevField => {
- self.create_state.prev_field();
- Action::None
- }
- Action::CreateToggle => {
- if self.create_state.current_field() == CreateFormField::ContractType {
- self.create_state.toggle_contract_type();
- }
- Action::None
- }
- Action::CreateSubmit => {
- if self.create_state.is_valid() {
- let name = self.create_state.name.clone();
- let description = self.create_state.description.clone();
- let contract_type = self.create_state.contract_type.clone();
- let repository_url = if self.create_state.repository_url.is_empty() {
- None
- } else {
- Some(self.create_state.repository_url.clone())
- };
- self.input_mode = InputMode::Normal;
- return Action::PerformCreateContract {
- name,
- description,
- contract_type,
- repository_url,
- };
- } else {
- self.status_message = Some("Name is required".to_string());
- }
- Action::None
- }
- Action::CreateCancel => {
- self.create_state = CreateContractState::new();
- self.input_mode = InputMode::Normal;
- self.status_message = Some("Create cancelled".to_string());
- Action::None
- }
- Action::PerformCreateContract { name, description, contract_type, repository_url } => {
- // Pass through to caller for API call
- Action::PerformCreateContract { name, description, contract_type, repository_url }
- }
- Action::LoadRepoSuggestions => {
- // Pass through to caller for API call
- Action::LoadRepoSuggestions
- }
- Action::CreateNextSuggestion => {
- self.create_state.next_suggestion();
- Action::None
- }
- Action::CreatePrevSuggestion => {
- self.create_state.prev_suggestion();
- Action::None
- }
- Action::CreateApplySuggestion => {
- self.create_state.apply_selected_suggestion();
- Action::None
- }
- Action::None => Action::None,
- }
- }
-
- /// Get the name of the item being deleted (for confirmation dialog)
- pub fn get_pending_delete_name(&self) -> Option<String> {
- self.pending_delete.and_then(|id| {
- self.filtered_items.iter()
- .find(|item| item.id == id)
- .map(|item| item.name.clone())
- })
- }
-}
diff --git a/makima/src/daemon/tui/event.rs b/makima/src/daemon/tui/event.rs
deleted file mode 100644
index d5ca569..0000000
--- a/makima/src/daemon/tui/event.rs
+++ /dev/null
@@ -1,269 +0,0 @@
-//! TUI event handling.
-
-use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
-use std::time::Duration;
-
-use super::app::{Action, App, CreateFormField, InputMode, ViewType};
-
-/// Poll for events with timeout
-pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> {
- if event::poll(timeout)? {
- Ok(Some(event::read()?))
- } else {
- Ok(None)
- }
-}
-
-/// 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(app, key),
- InputMode::Search => handle_search_mode(key),
- InputMode::Confirm => handle_confirm_mode(key),
- InputMode::EditName | InputMode::EditDescription => handle_edit_mode(key),
- InputMode::CreateName | InputMode::CreateDescription => handle_create_mode(app, key),
- }
-}
-
-/// Handle key events in normal navigation mode
-fn handle_normal_mode(app: &App, key: KeyEvent) -> Action {
- // Check for Ctrl+C first
- if key.modifiers.contains(KeyModifiers::CONTROL) {
- match key.code {
- KeyCode::Char('c') => return Action::Quit,
- _ => {}
- }
- }
-
- match key.code {
- // Navigation
- KeyCode::Up | KeyCode::Char('k') => Action::Up,
- KeyCode::Down | KeyCode::Char('j') => Action::Down,
-
- // 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
-
- // New contract (only in contracts view)
- KeyCode::Char('n') if app.view_type == ViewType::Contracts => Action::NewContract,
-
- // Preview toggle (space to show details in preview pane)
- KeyCode::Char(' ') => Action::Select,
-
- // Search
- KeyCode::Char('/') => Action::EnterSearch,
-
- // Refresh
- KeyCode::Char('r') => Action::Refresh,
-
- // Quit (only q, Esc now goes back)
- KeyCode::Char('q') => Action::Quit,
- KeyCode::Esc => Action::GoBack,
-
- _ => Action::None,
- }
-}
-
-/// Handle key events in search mode
-fn handle_search_mode(key: KeyEvent) -> Action {
- // Check for Ctrl+C first
- if key.modifiers.contains(KeyModifiers::CONTROL) {
- match key.code {
- KeyCode::Char('c') => return Action::Quit,
- KeyCode::Char('u') => return Action::ClearSearch,
- _ => {}
- }
- }
-
- match key.code {
- // Exit search mode
- KeyCode::Esc => Action::ExitSearch,
- KeyCode::Enter => Action::ExitSearch,
-
- // Text input
- KeyCode::Char(c) => Action::SearchChar(c),
- KeyCode::Backspace => Action::SearchBackspace,
-
- // Navigation while searching
- KeyCode::Up => Action::Up,
- KeyCode::Down => Action::Down,
-
- _ => Action::None,
- }
-}
-
-/// Handle key events in confirmation mode
-fn handle_confirm_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 {
- // Confirm
- KeyCode::Char('y') | KeyCode::Char('Y') => Action::ConfirmYes,
-
- // Cancel
- KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Action::ConfirmNo,
-
- _ => Action::None,
- }
-}
-
-/// 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,
- }
-}
-
-/// Handle key events in create contract mode
-fn handle_create_mode(app: &App, key: KeyEvent) -> Action {
- // Check for Ctrl+C first
- if key.modifiers.contains(KeyModifiers::CONTROL) {
- if let KeyCode::Char('c') = key.code {
- return Action::Quit;
- }
- }
-
- let current_field = app.create_state.current_field();
- let has_suggestions = app.create_state.show_suggestions
- && !app.create_state.repo_suggestions.is_empty();
-
- // Allow Ctrl+N/Ctrl+P to navigate suggestions from any field
- if has_suggestions && key.modifiers.contains(KeyModifiers::CONTROL) {
- match key.code {
- KeyCode::Char('n') => return Action::CreateNextSuggestion,
- KeyCode::Char('p') => return Action::CreatePrevSuggestion,
- _ => {}
- }
- }
-
- // Special handling when on Repository field with suggestions visible
- let on_repo_field = current_field == CreateFormField::Repository;
- if has_suggestions && on_repo_field {
- match key.code {
- // Up/Down navigate suggestions when on repo field
- KeyCode::Up => return Action::CreatePrevSuggestion,
- KeyCode::Down => return Action::CreateNextSuggestion,
- // Enter applies suggestion instead of submitting form
- KeyCode::Enter => return Action::CreateApplySuggestion,
- _ => {}
- }
- }
-
- match key.code {
- // Submit form
- KeyCode::Enter => {
- // If on contract type field, toggle instead of submit
- if current_field == CreateFormField::ContractType {
- Action::CreateToggle
- } else {
- Action::CreateSubmit
- }
- }
-
- // Cancel
- KeyCode::Esc => Action::CreateCancel,
-
- // Navigate between fields
- KeyCode::Tab => Action::CreateNextField,
- KeyCode::BackTab => Action::CreatePrevField,
- KeyCode::Up => Action::CreatePrevField,
- KeyCode::Down => Action::CreateNextField,
-
- // Toggle for contract type field
- KeyCode::Char(' ') if current_field == CreateFormField::ContractType => Action::CreateToggle,
- KeyCode::Left if current_field == CreateFormField::ContractType => Action::CreateToggle,
- KeyCode::Right if current_field == CreateFormField::ContractType => Action::CreateToggle,
-
- // Text input (for text fields)
- KeyCode::Char(c) if current_field != CreateFormField::ContractType => Action::CreateChar(c),
- KeyCode::Backspace if current_field != CreateFormField::ContractType => Action::CreateBackspace,
-
- _ => Action::None,
- }
-}
-
-/// Get help text for current mode
-pub fn get_help_text(mode: InputMode) -> &'static str {
- match mode {
- InputMode::Normal => "j/k: nav | Enter: open | Esc/h: back | e: edit | d: del | n: new | /: 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",
- InputMode::CreateName | InputMode::CreateDescription => "Type to edit | Tab/↑↓: switch field | Enter: create | 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/fuzzy.rs b/makima/src/daemon/tui/fuzzy.rs
deleted file mode 100644
index 44c27ad..0000000
--- a/makima/src/daemon/tui/fuzzy.rs
+++ /dev/null
@@ -1,217 +0,0 @@
-//! Fuzzy matching wrapper for search functionality.
-//!
-//! This module provides a wrapper around the `fuzzy-matcher` crate's
-//! `SkimMatcherV2` algorithm, offering:
-//!
-//! - Single-term fuzzy matching with score and matched indices
-//! - Multi-term search (space-separated patterns)
-//! - Recency-adjusted scoring for time-aware results
-//! - Case-insensitive matching by default
-//!
-//! # Examples
-//!
-//! ```
-//! use makima::daemon::tui::fuzzy::FuzzyMatcher;
-//!
-//! let matcher = FuzzyMatcher::new();
-//!
-//! // Single pattern matching
-//! if let Some((score, indices)) = matcher.fuzzy_match("hello world", "hlo") {
-//! println!("Score: {}, Matched positions: {:?}", score, indices);
-//! }
-//!
-//! // Multi-term search
-//! if let Some(score) = matcher.fuzzy_match_all("fix authentication bug", "fix bug") {
-//! println!("All terms matched with score: {}", score);
-//! }
-//! ```
-
-use fuzzy_matcher::skim::SkimMatcherV2;
-use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait;
-
-/// Fuzzy matcher wrapper providing search functionality.
-///
-/// Wraps the `SkimMatcherV2` algorithm which provides:
-/// - Smart case matching (case-insensitive unless pattern has uppercase)
-/// - Word boundary bonuses
-/// - Consecutive character bonuses
-pub struct FuzzyMatcher {
- matcher: SkimMatcherV2,
-}
-
-impl FuzzyMatcher {
- /// Create a new fuzzy matcher with default settings.
- pub fn new() -> Self {
- Self {
- matcher: SkimMatcherV2::default(),
- }
- }
-
- /// Match a pattern against a string, returning score and matched indices.
- ///
- /// Returns `Some((score, indices))` if the pattern matches, where:
- /// - `score` is a relevance score (higher is better)
- /// - `indices` are the positions of matched characters in the text
- ///
- /// Returns `None` if the pattern doesn't match the text.
- ///
- /// # Arguments
- ///
- /// * `text` - The text to search in
- /// * `pattern` - The pattern to search for
- pub fn fuzzy_match(&self, text: &str, pattern: &str) -> Option<(i64, Vec<usize>)> {
- self.matcher.fuzzy_indices(text, pattern)
- }
-
- /// Match multiple patterns (space-separated) against a string.
- ///
- /// All patterns must match for the function to return a score.
- /// The returned score is the sum of individual pattern scores.
- ///
- /// # Arguments
- ///
- /// * `text` - The text to search in
- /// * `patterns` - Space-separated patterns (e.g., "fix bug" matches both "fix" and "bug")
- ///
- /// # Returns
- ///
- /// `Some(total_score)` if all patterns match, `None` otherwise.
- pub fn fuzzy_match_all(&self, text: &str, patterns: &str) -> Option<i64> {
- let patterns: Vec<&str> = patterns.split_whitespace().collect();
-
- if patterns.is_empty() {
- return Some(0);
- }
-
- let mut total_score = 0i64;
-
- for pattern in patterns {
- if let Some((score, _)) = self.matcher.fuzzy_indices(text, pattern) {
- total_score += score;
- } else {
- return None;
- }
- }
-
- Some(total_score)
- }
-
- /// Calculate a recency-adjusted score for time-aware sorting.
- ///
- /// Items with lower indices (more recent) receive a bonus to their score,
- /// making them rank higher in search results.
- ///
- /// # Arguments
- ///
- /// * `base_score` - The original fuzzy match score
- /// * `index` - The item's position in the list (0 = most recent)
- /// * `total_items` - Total number of items in the list
- ///
- /// # Returns
- ///
- /// An adjusted score that factors in recency.
- pub fn recency_adjusted_score(base_score: i64, index: usize, total_items: usize) -> i64 {
- if total_items == 0 {
- return base_score;
- }
-
- // Recency bonus: items at the beginning get up to 20% bonus
- // Formula: bonus = base_score * 0.2 * (1 - index/total_items)
- let recency_factor = 1.0 - (index as f64 / total_items as f64);
- let bonus = (base_score as f64 * 0.2 * recency_factor) as i64;
-
- base_score + bonus
- }
-}
-
-impl Default for FuzzyMatcher {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_fuzzy_match_exact() {
- let matcher = FuzzyMatcher::new();
- let result = matcher.fuzzy_match("hello world", "hello");
- assert!(result.is_some());
- }
-
- #[test]
- fn test_fuzzy_match_partial() {
- let matcher = FuzzyMatcher::new();
- let result = matcher.fuzzy_match("authentication", "auth");
- assert!(result.is_some());
- }
-
- #[test]
- fn test_fuzzy_match_no_match() {
- let matcher = FuzzyMatcher::new();
- let result = matcher.fuzzy_match("hello", "xyz");
- assert!(result.is_none());
- }
-
- #[test]
- fn test_multi_term_search() {
- let matcher = FuzzyMatcher::new();
- let result = matcher.fuzzy_match_all("fix authentication bug", "fix bug");
- assert!(result.is_some());
- }
-
- #[test]
- fn test_case_insensitive() {
- let matcher = FuzzyMatcher::new();
- let result = matcher.fuzzy_match("Hello World", "hello");
- assert!(result.is_some());
- }
-
- #[test]
- fn test_recency_bonus() {
- // Earlier items (lower index) should get higher recency bonus
- let score1 = FuzzyMatcher::recency_adjusted_score(100, 0, 50);
- let score2 = FuzzyMatcher::recency_adjusted_score(100, 10, 50);
- assert!(score1 > score2);
- }
-
- #[test]
- fn test_fuzzy_match_returns_indices() {
- let matcher = FuzzyMatcher::new();
- let result = matcher.fuzzy_match("hello world", "hlo");
- assert!(result.is_some());
- let (_, indices) = result.unwrap();
- // Should have matched 3 characters
- assert_eq!(indices.len(), 3);
- }
-
- #[test]
- fn test_multi_term_empty_pattern() {
- let matcher = FuzzyMatcher::new();
- let result = matcher.fuzzy_match_all("hello world", "");
- assert!(result.is_some());
- assert_eq!(result.unwrap(), 0);
- }
-
- #[test]
- fn test_multi_term_partial_match_fails() {
- let matcher = FuzzyMatcher::new();
- // "xyz" doesn't match, so the whole search should fail
- let result = matcher.fuzzy_match_all("fix authentication bug", "fix xyz");
- assert!(result.is_none());
- }
-
- #[test]
- fn test_recency_bonus_edge_cases() {
- // Zero total items should return base score
- let score = FuzzyMatcher::recency_adjusted_score(100, 0, 0);
- assert_eq!(score, 100);
-
- // Last item should get minimal bonus
- let score_last = FuzzyMatcher::recency_adjusted_score(100, 49, 50);
- let score_first = FuzzyMatcher::recency_adjusted_score(100, 0, 50);
- assert!(score_first > score_last);
- }
-}
diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs
deleted file mode 100644
index e52b12a..0000000
--- a/makima/src/daemon/tui/mod.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-//! TUI module for interactive browsing.
-//!
-//! This module provides an interactive Terminal User Interface (TUI) for
-//! browsing and managing tasks, contracts, and files in the makima system.
-//!
-//! # Features
-//!
-//! - **Fuzzy Search**: Real-time filtering with the SkimMatcherV2 algorithm
-//! - **Keyboard Navigation**: Vim-style keybindings (j/k) and arrow keys
-//! - **Preview Pane**: Side-by-side view of item details
-//! - **Multiple Views**: Browse tasks, contracts, or files
-
-pub mod app;
-pub mod event;
-pub mod fuzzy;
-pub mod ui;
-pub mod ws_client;
-
-pub use app::{App, ListItem, ViewType, ViewState, InputMode, Action, OutputBuffer, OutputLine, OutputMessageType, WsConnectionState, CreateContractState, CreateFormField, RepositorySuggestion};
-pub use ws_client::{TuiWsClient, WsCommand, WsEvent, TaskOutputEvent};
-pub use fuzzy::FuzzyMatcher;
-
-use std::io;
-use crossterm::{
- event::{DisableMouseCapture, EnableMouseCapture},
- execute,
- terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
-};
-use ratatui::prelude::*;
-use ratatui::backend::CrosstermBackend;
-
-pub type Terminal = ratatui::Terminal<CrosstermBackend<io::Stdout>>;
-
-/// Run the TUI application
-pub fn run(mut app: App) -> Result<Option<String>, Box<dyn std::error::Error>> {
- // 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)?;
-
- // Run the main loop
- let result = run_app(&mut terminal, &mut app);
-
- // Cleanup terminal
- disable_raw_mode()?;
- execute!(
- terminal.backend_mut(),
- LeaveAlternateScreen,
- DisableMouseCapture
- )?;
- terminal.show_cursor()?;
-
- result
-}
-
-fn run_app(
- terminal: &mut Terminal,
- app: &mut App,
-) -> Result<Option<String>, Box<dyn std::error::Error>> {
- use crossterm::event::Event;
- use std::time::Duration;
-
- loop {
- terminal.draw(|f| ui::render(f, app))?;
-
- // Poll for events with 100ms timeout
- if let Some(evt) = event::poll_event(Duration::from_millis(100))? {
- if let Event::Key(key) = evt {
- let action = 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);
- // Check if handle_action returned a special action
- if let Action::OutputPath(path) = result {
- return Ok(Some(path));
- }
- }
- }
- }
- }
-
- if app.should_quit {
- break;
- }
- }
-
- Ok(None)
-}
-
-/// Print a path to stdout (for cd integration)
-pub fn print_path(path: &str) {
- println!("{}", path);
-}
diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs
deleted file mode 100644
index 2a5a6ce..0000000
--- a/makima/src/daemon/tui/ui.rs
+++ /dev/null
@@ -1,695 +0,0 @@
-//! TUI rendering.
-
-use ratatui::{
- layout::{Alignment, Constraint, Direction, Layout, Rect},
- style::{Color, Modifier, Style},
- text::{Line, Span, Text},
- widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
- Frame,
-};
-
-use super::app::{App, CreateFormField, 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) {
- // Create main layout
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Length(3), // Header
- Constraint::Min(10), // Main content
- Constraint::Length(3), // Status/Help
- ])
- .split(frame.area());
-
- render_header(frame, app, chunks[0]);
- render_main_content(frame, app, chunks[1]);
- render_footer(frame, app, chunks[2]);
-
- // Render confirmation dialog if in confirm mode
- 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 create contract dialog if in create mode
- if matches!(app.input_mode, InputMode::CreateName | InputMode::CreateDescription) {
- render_create_dialog(frame, app);
- }
-}
-
-/// Render header with breadcrumb and search bar
-fn render_header(frame: &mut Frame, app: &App, area: Rect) {
- let breadcrumb = app.get_breadcrumb();
-
- let header_text = if app.input_mode == InputMode::Search || !app.search_query.is_empty() {
- format!("{} [Search: {}]", breadcrumb, app.search_query)
- } else {
- format!("{} ({} items)", breadcrumb, app.filtered_items.len())
- };
-
- let header = Paragraph::new(header_text)
- .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
- .block(Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(if app.input_mode == InputMode::Search {
- Color::Yellow
- } else {
- Color::White
- })));
-
- frame.render_widget(header, area);
-}
-
-/// 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()
- .direction(Direction::Horizontal)
- .constraints([
- Constraint::Percentage(50),
- Constraint::Percentage(50),
- ])
- .split(area);
-
- render_list(frame, app, chunks[0]);
- render_preview(frame, app, chunks[1]);
- } else {
- render_list(frame, app, area);
- }
-}
-
-/// Render the item list
-fn render_list(frame: &mut Frame, app: &App, area: Rect) {
- let items: Vec<ListItem> = app.filtered_items
- .iter()
- .enumerate()
- .map(|(i, item)| {
- let is_selected = i == app.selected_index;
-
- // Build the display line
- let status_str = item.status
- .as_ref()
- .map(|s| format!(" [{}]", s))
- .unwrap_or_default();
-
- let content = format!("{}{}", item.name, status_str);
-
- let style = if is_selected {
- Style::default()
- .fg(Color::Black)
- .bg(Color::Cyan)
- .add_modifier(Modifier::BOLD)
- } else {
- let status_color = item.status.as_ref().map(|s| {
- match s.to_lowercase().as_str() {
- "running" | "active" => Color::Green,
- "pending" | "waiting" => Color::Yellow,
- "completed" | "done" => Color::Blue,
- "failed" | "error" => Color::Red,
- _ => Color::White,
- }
- }).unwrap_or(Color::White);
-
- Style::default().fg(status_color)
- };
-
- ListItem::new(Line::from(vec![
- Span::styled(content, style),
- ]))
- })
- .collect();
-
- let list = List::new(items)
- .block(Block::default()
- .borders(Borders::ALL)
- .title(format!(" {} ", app.view_type.as_str())));
-
- frame.render_widget(list, area);
-}
-
-/// Render the preview panel
-fn render_preview(frame: &mut Frame, app: &App, area: Rect) {
- let preview = Paragraph::new(Text::raw(&app.preview_content))
- .wrap(Wrap { trim: false })
- .block(Block::default()
- .borders(Borders::ALL)
- .title(" Preview "));
-
- frame.render_widget(preview, area);
-}
-
-/// Render footer with help text and status
-fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
- // 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, ws_status, status_text);
-
- let footer = Paragraph::new(footer_text)
- .style(Style::default().fg(Color::DarkGray))
- .block(Block::default().borders(Borders::ALL));
-
- frame.render_widget(footer, area);
-}
-
-/// Render confirmation dialog as a centered popup
-fn render_confirm_dialog(frame: &mut Frame, app: &App) {
- let item_name = app.get_pending_delete_name()
- .unwrap_or_else(|| "this item".to_string());
-
- // Calculate popup size and position
- let area = frame.area();
- let popup_width = 50.min(area.width.saturating_sub(4));
- let popup_height = 7;
-
- 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);
-
- // Build popup content
- let text = vec![
- Line::from(""),
- Line::from(Span::styled(
- "Delete Confirmation",
- Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
- )),
- Line::from(""),
- Line::from(format!("Delete '{}'?", item_name)),
- Line::from(""),
- Line::from(vec![
- Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
- Span::raw(": confirm "),
- Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
- Span::raw(": cancel"),
- ]),
- ];
-
- let popup = Paragraph::new(text)
- .alignment(Alignment::Center)
- .block(Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(Color::Red))
- .title(" Confirm "));
-
- 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 create contract dialog
-fn render_create_dialog(frame: &mut Frame, app: &App) {
- // Calculate popup size and position - make it taller if suggestions are shown
- let area = frame.area();
- let state = &app.create_state;
- let current_field = state.current_field();
- // Show suggestions whenever we have them (like the frontend does)
- let show_suggestions = state.show_suggestions && !state.repo_suggestions.is_empty();
-
- let popup_width = 70.min(area.width.saturating_sub(4));
- let base_height = 20;
- let suggestion_height = if show_suggestions {
- (state.repo_suggestions.len().min(5) + 2) as u16
- } else {
- 0
- };
- let popup_height = base_height + suggestion_height;
-
- 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);
-
- let max_field_width = (popup_width as usize).saturating_sub(18);
-
- // Styles
- 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);
- let hint_style = Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC);
- let suggestion_style = Style::default().fg(Color::White);
- let selected_suggestion_style = Style::default().fg(Color::Black).bg(Color::Cyan);
-
- // Helper to build text field with cursor
- let build_field = |value: &str, cursor: usize, is_active: bool| -> String {
- if is_active {
- let cursor_pos = cursor.min(value.len());
- let (before, after) = value.split_at(cursor_pos);
- let display = format!("{}|{}", before, after);
- if display.len() > max_field_width {
- let start = display.len().saturating_sub(max_field_width);
- format!("...{}", &display[start..])
- } else {
- display
- }
- } else {
- if value.len() > max_field_width {
- format!("{}...", &value[..max_field_width.saturating_sub(3)])
- } else if value.is_empty() {
- "(empty)".to_string()
- } else {
- value.to_string()
- }
- }
- };
-
- // Build field displays
- let name_display = build_field(&state.name, state.cursor, current_field == CreateFormField::Name);
- let desc_display = build_field(&state.description, state.cursor, current_field == CreateFormField::Description);
- let repo_display = build_field(&state.repository_url, state.cursor, current_field == CreateFormField::Repository);
-
- // Contract type selector
- let type_display = if state.contract_type == "simple" {
- vec![
- Span::styled("[●] ", Style::default().fg(Color::Green)),
- Span::raw("Simple "),
- Span::styled("[ ] ", Style::default().fg(Color::DarkGray)),
- Span::styled("Specification", Style::default().fg(Color::DarkGray)),
- ]
- } else {
- vec![
- Span::styled("[ ] ", Style::default().fg(Color::DarkGray)),
- Span::styled("Simple ", Style::default().fg(Color::DarkGray)),
- Span::styled("[●] ", Style::default().fg(Color::Green)),
- Span::raw("Specification"),
- ]
- };
-
- let mut text = vec![
- Line::from(""),
- Line::from(Span::styled(
- " New Contract",
- Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
- )),
- Line::from(""),
- // Name field (required)
- Line::from(vec![
- Span::styled(" Name*: ", label_style),
- Span::styled(
- name_display,
- if current_field == CreateFormField::Name { active_style } else { inactive_style },
- ),
- ]),
- Line::from(Span::styled(" Contract name (required)", hint_style)),
- Line::from(""),
- // Description field
- Line::from(vec![
- Span::styled(" Description: ", label_style),
- Span::styled(
- desc_display,
- if current_field == CreateFormField::Description { active_style } else { inactive_style },
- ),
- ]),
- Line::from(Span::styled(" Brief description of the work", hint_style)),
- Line::from(""),
- // Contract type selector
- Line::from(vec![
- Span::styled(" Type: ", label_style),
- ].into_iter().chain(
- if current_field == CreateFormField::ContractType {
- type_display.into_iter().map(|s| s).collect::<Vec<_>>()
- } else {
- type_display.into_iter().map(|mut s| {
- s.style = s.style.fg(Color::DarkGray);
- s
- }).collect()
- }
- ).collect::<Vec<_>>()),
- Line::from(Span::styled(" Simple: Plan→Execute | Spec: Research→Specify→Plan→Execute→Review", hint_style)),
- Line::from(""),
- // Repository URL field
- Line::from(vec![
- Span::styled(" Repository: ", label_style),
- Span::styled(
- repo_display,
- if current_field == CreateFormField::Repository { active_style } else { inactive_style },
- ),
- ]),
- Line::from(Span::styled(" Git repository URL (optional)", hint_style)),
- ];
-
- // Add suggestions section
- if show_suggestions {
- text.push(Line::from(""));
- text.push(Line::from(Span::styled(
- " Recent repositories (↑/↓ to select, Enter to apply):",
- Style::default().fg(Color::Cyan),
- )));
-
- for (i, suggestion) in state.repo_suggestions.iter().take(5).enumerate() {
- let is_selected = i == state.selected_suggestion;
- let url_or_path = suggestion.repository_url.as_ref()
- .or(suggestion.local_path.as_ref())
- .map(|s| s.as_str())
- .unwrap_or("");
-
- // Truncate if too long
- let display_url = if url_or_path.len() > max_field_width - 10 {
- format!("...{}", &url_or_path[url_or_path.len().saturating_sub(max_field_width - 13)..])
- } else {
- url_or_path.to_string()
- };
-
- let prefix = if is_selected { " → " } else { " " };
- let count_suffix = format!(" ({}×)", suggestion.use_count);
-
- text.push(Line::from(vec![
- Span::styled(
- format!("{}{}{}", prefix, display_url, count_suffix),
- if is_selected { selected_suggestion_style } else { suggestion_style },
- ),
- ]));
- }
- } else if state.suggestions_loaded && state.repo_suggestions.is_empty() {
- // Show message when suggestions loaded but empty
- text.push(Line::from(""));
- text.push(Line::from(Span::styled(
- " (No recent repositories - add repos to contracts to see suggestions here)",
- hint_style,
- )));
- }
-
- text.push(Line::from(""));
-
- // Help line - show different help when suggestions are visible
- if show_suggestions {
- text.push(Line::from(vec![
- Span::styled(" ↑/↓", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
- Span::raw(": select "),
- Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
- Span::raw(": apply "),
- Span::styled("Tab", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
- Span::raw(": next field "),
- Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
- Span::raw(": cancel"),
- ]));
- } else {
- text.push(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(": create "),
- Span::styled("Space", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
- Span::raw(": toggle type "),
- 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(" Create Contract "));
-
- 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
deleted file mode 100644
index 73b7c33..0000000
--- a/makima/src/daemon/tui/views/contracts.rs
+++ /dev/null
@@ -1,32 +0,0 @@
-//! Contracts view implementation.
-
-use uuid::Uuid;
-
-use crate::daemon::api::ApiClient;
-use crate::daemon::tui::app::ListItem;
-
-/// Load contracts from API
-pub async fn load_contracts(
- client: &ApiClient,
-) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> {
- 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
-pub async fn get_contract_preview(
- _client: &ApiClient,
- _contract_id: Uuid,
-) -> Result<String, Box<dyn std::error::Error>> {
- // TODO: Implement contract preview
- Ok("Contract preview not yet implemented".to_string())
-}
diff --git a/makima/src/daemon/tui/views/files.rs b/makima/src/daemon/tui/views/files.rs
deleted file mode 100644
index e21a989..0000000
--- a/makima/src/daemon/tui/views/files.rs
+++ /dev/null
@@ -1,90 +0,0 @@
-//! Files view implementation.
-
-use uuid::Uuid;
-
-use crate::daemon::api::ApiClient;
-use crate::daemon::tui::app::ListItem;
-
-/// Load files from API
-pub async fn load_files(
- client: &ApiClient,
- contract_id: Uuid,
-) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> {
- let result = client.contract_files(contract_id).await?;
-
- // Parse JSON response into ListItem
- let files: Vec<serde_json::Value> = serde_json::from_value(result.0)?;
-
- let items = files
- .into_iter()
- .filter_map(|f| {
- let id_str = f.get("id")?.as_str()?;
- let id = Uuid::parse_str(id_str).ok()?;
-
- Some(ListItem {
- id,
- name: f
- .get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("Unnamed")
- .to_string(),
- status: None,
- description: f
- .get("description")
- .and_then(|v| v.as_str())
- .map(String::from),
- updated_at: f
- .get("updatedAt")
- .and_then(|v| v.as_str())
- .unwrap_or_default()
- .to_string(),
- extra: f,
- })
- })
- .collect();
-
- Ok(items)
-}
-
-/// Get full file details for preview
-pub async fn get_file_preview(
- client: &ApiClient,
- contract_id: Uuid,
- file_id: Uuid,
-) -> Result<String, Box<dyn std::error::Error>> {
- let result = client.contract_file(contract_id, file_id).await?;
- let file: serde_json::Value = result.0;
-
- let name = file
- .get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("Unknown");
- let description = file
- .get("description")
- .and_then(|v| v.as_str())
- .unwrap_or("-");
-
- // Try to get body content
- let body_preview = if let Some(body) = file.get("body") {
- if let Some(body_array) = body.as_array() {
- body_array
- .iter()
- .filter_map(|item| {
- let text = item.get("text").and_then(|v| v.as_str())?;
- Some(text.to_string())
- })
- .take(5)
- .collect::<Vec<_>>()
- .join("\n")
- } else {
- "-".to_string()
- }
- } else {
- "-".to_string()
- };
-
- Ok(format!(
- "Name: {}\nDescription: {}\n\nContent:\n{}",
- name, description, body_preview
- ))
-}
diff --git a/makima/src/daemon/tui/views/mod.rs b/makima/src/daemon/tui/views/mod.rs
deleted file mode 100644
index 699b6df..0000000
--- a/makima/src/daemon/tui/views/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub mod contracts;
-pub mod files;
-pub mod tasks;
diff --git a/makima/src/daemon/tui/views/tasks.rs b/makima/src/daemon/tui/views/tasks.rs
deleted file mode 100644
index fd52b11..0000000
--- a/makima/src/daemon/tui/views/tasks.rs
+++ /dev/null
@@ -1,71 +0,0 @@
-//! Tasks view implementation.
-
-use uuid::Uuid;
-
-use crate::daemon::api::ApiClient;
-use crate::daemon::tui::app::ListItem;
-
-/// Load tasks from API
-pub async fn load_tasks(
- client: &ApiClient,
- contract_id: Option<Uuid>,
-) -> Result<Vec<ListItem>, Box<dyn std::error::Error>> {
- let Some(contract_id) = contract_id else {
- // TODO: Implement listing all tasks across contracts
- return Ok(Vec::new());
- };
-
- let result = client.supervisor_tasks(contract_id).await?;
-
- // Parse JSON response into ListItem
- let tasks: Vec<serde_json::Value> = serde_json::from_value(result.0)?;
-
- let items = tasks
- .into_iter()
- .filter_map(|t| {
- let id_str = t.get("id")?.as_str()?;
- let id = Uuid::parse_str(id_str).ok()?;
-
- Some(ListItem {
- id,
- name: t
- .get("name")
- .and_then(|v| v.as_str())
- .unwrap_or("Unnamed")
- .to_string(),
- status: t.get("status").and_then(|v| v.as_str()).map(String::from),
- description: t
- .get("progressSummary")
- .and_then(|v| v.as_str())
- .map(String::from),
- updated_at: t
- .get("updatedAt")
- .and_then(|v| v.as_str())
- .unwrap_or_default()
- .to_string(),
- extra: t,
- })
- })
- .collect();
-
- Ok(items)
-}
-
-/// Get full task details for preview
-pub async fn get_task_preview(
- client: &ApiClient,
- task_id: Uuid,
-) -> Result<String, Box<dyn std::error::Error>> {
- let result = client.supervisor_get_task(task_id).await?;
- let task: serde_json::Value = result.0;
-
- Ok(format!(
- "Name: {}\nStatus: {}\nPlan: {}\n\nProgress:\n{}",
- task.get("name").and_then(|v| v.as_str()).unwrap_or("-"),
- task.get("status").and_then(|v| v.as_str()).unwrap_or("-"),
- task.get("plan").and_then(|v| v.as_str()).unwrap_or("-"),
- task.get("progressSummary")
- .and_then(|v| v.as_str())
- .unwrap_or("-"),
- ))
-}
diff --git a/makima/src/daemon/tui/widgets/list_view.rs b/makima/src/daemon/tui/widgets/list_view.rs
deleted file mode 100644
index ff8269a..0000000
--- a/makima/src/daemon/tui/widgets/list_view.rs
+++ /dev/null
@@ -1,127 +0,0 @@
-//! List view widget with fuzzy match highlighting.
-
-use std::collections::HashSet;
-
-use ratatui::{
- prelude::*,
- widgets::{Block, Borders, List, ListItem, ListState},
-};
-
-use crate::daemon::tui::app::{App, ViewMode};
-
-/// Style for matched characters in search results
-const MATCH_HIGHLIGHT_COLOR: Color = Color::Yellow;
-const MATCH_HIGHLIGHT_MODIFIER: Modifier = Modifier::BOLD;
-
-/// Build a Line with highlighted characters based on matched indices
-fn build_highlighted_name(name: &str, matched_indices: &[usize]) -> Vec<Span<'static>> {
- if matched_indices.is_empty() {
- return vec![Span::raw(name.to_string())];
- }
-
- let matched_set: HashSet<usize> = matched_indices.iter().cloned().collect();
- let mut spans = Vec::new();
- let mut current_run = String::new();
- let mut is_highlighted = false;
-
- for (byte_idx, ch) in name.char_indices() {
- let should_highlight = matched_set.contains(&byte_idx);
-
- if should_highlight != is_highlighted {
- // Flush current run
- if !current_run.is_empty() {
- if is_highlighted {
- spans.push(Span::styled(
- current_run.clone(),
- Style::default()
- .fg(MATCH_HIGHLIGHT_COLOR)
- .add_modifier(MATCH_HIGHLIGHT_MODIFIER),
- ));
- } else {
- spans.push(Span::raw(current_run.clone()));
- }
- current_run.clear();
- }
- is_highlighted = should_highlight;
- }
-
- current_run.push(ch);
- }
-
- // Flush remaining
- if !current_run.is_empty() {
- if is_highlighted {
- spans.push(Span::styled(
- current_run,
- Style::default()
- .fg(MATCH_HIGHLIGHT_COLOR)
- .add_modifier(MATCH_HIGHLIGHT_MODIFIER),
- ));
- } else {
- spans.push(Span::raw(current_run));
- }
- }
-
- spans
-}
-
-/// Get status icon and color for an item
-fn get_status_display(status: Option<&str>) -> (&'static str, Color) {
- match status {
- Some("running") => ("▸", Color::Green),
- Some("done") => ("✓", Color::Blue),
- Some("failed") => ("✗", Color::Red),
- Some("pending") => ("○", Color::Yellow),
- Some("paused") => ("⏸", Color::Cyan),
- _ => (" ", Color::Gray),
- }
-}
-
-pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
- let items: Vec<ListItem> = app
- .filtered_items
- .iter()
- .map(|filtered_item| {
- let item = &app.items[filtered_item.index];
- let (status_icon, status_color) = get_status_display(item.status.as_deref());
-
- // Build spans with highlighted matched characters
- let mut spans = vec![Span::styled(
- format!("{} ", status_icon),
- Style::default().fg(status_color),
- )];
-
- // Add name with match highlighting
- spans.extend(build_highlighted_name(&item.name, &filtered_item.matched_indices));
-
- ListItem::new(Line::from(spans))
- })
- .collect();
-
- let view_label = match app.view_mode {
- ViewMode::Tasks => "Tasks",
- ViewMode::Contracts => "Contracts",
- ViewMode::Files => "Files",
- };
-
- let title = format!(
- " {} ({}{}) ",
- view_label,
- app.filtered_items.len(),
- if app.filtered_items.len() != app.items.len() {
- format!("/{}", app.items.len())
- } else {
- String::new()
- }
- );
-
- let list = List::new(items)
- .block(Block::default().title(title).borders(Borders::ALL))
- .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
- .highlight_symbol("> ");
-
- let mut state = ListState::default();
- state.select(Some(app.selected_index));
-
- f.render_stateful_widget(list, area, &mut state);
-}
diff --git a/makima/src/daemon/tui/widgets/mod.rs b/makima/src/daemon/tui/widgets/mod.rs
deleted file mode 100644
index ddea546..0000000
--- a/makima/src/daemon/tui/widgets/mod.rs
+++ /dev/null
@@ -1,4 +0,0 @@
-pub mod list_view;
-pub mod preview_pane;
-pub mod search_input;
-pub mod status_bar;
diff --git a/makima/src/daemon/tui/widgets/preview_pane.rs b/makima/src/daemon/tui/widgets/preview_pane.rs
deleted file mode 100644
index 84095d0..0000000
--- a/makima/src/daemon/tui/widgets/preview_pane.rs
+++ /dev/null
@@ -1,21 +0,0 @@
-//! Preview pane widget.
-
-use ratatui::{
- prelude::*,
- widgets::{Block, Borders, Paragraph, Wrap},
-};
-
-use crate::daemon::tui::app::App;
-
-pub fn render(f: &mut Frame, area: Rect, app: &App) {
- let content = app
- .preview_content
- .as_deref()
- .unwrap_or("No preview available");
-
- let preview = Paragraph::new(content)
- .block(Block::default().title(" Preview ").borders(Borders::ALL))
- .wrap(Wrap { trim: true });
-
- f.render_widget(preview, area);
-}
diff --git a/makima/src/daemon/tui/widgets/search_input.rs b/makima/src/daemon/tui/widgets/search_input.rs
deleted file mode 100644
index 311b4f0..0000000
--- a/makima/src/daemon/tui/widgets/search_input.rs
+++ /dev/null
@@ -1,82 +0,0 @@
-//! Search input widget with match count and visual feedback.
-
-use ratatui::{
- prelude::*,
- widgets::{Block, Borders, Paragraph},
-};
-
-use crate::daemon::tui::app::{App, InputMode, ViewMode};
-
-/// Color for the search bar when there are no matches
-const NO_MATCH_COLOR: Color = Color::Red;
-/// Color for the search bar when actively searching
-const SEARCH_ACTIVE_COLOR: Color = Color::Yellow;
-
-pub fn render(f: &mut Frame, area: Rect, app: &App) {
- let view_label = match app.view_mode {
- ViewMode::Tasks => "Tasks",
- ViewMode::Contracts => "Contracts",
- ViewMode::Files => "Files",
- };
-
- let (matched, total) = app.match_count();
- let has_no_matches = app.has_no_matches();
- let is_searching = matches!(app.input_mode, InputMode::Search);
- let has_query = !app.search_query.is_empty();
-
- // Determine border style based on state
- let border_style = if has_no_matches {
- Style::default().fg(NO_MATCH_COLOR)
- } else if is_searching {
- Style::default().fg(SEARCH_ACTIVE_COLOR)
- } else {
- Style::default()
- };
-
- // Build the search input content
- let search_text = if app.search_query.is_empty() {
- if is_searching {
- " Type to search...".to_string()
- } else {
- " Press / to search".to_string()
- }
- } else {
- format!(" {}", app.search_query)
- };
-
- // Build the title with match count
- let title = if has_query {
- if has_no_matches {
- format!(" 🔍 Search [{}] - No matches ", view_label)
- } else {
- format!(" 🔍 Search [{}] - {}/{} matches ", view_label, matched, total)
- }
- } else {
- format!(" 🔍 Search [{}] ", view_label)
- };
-
- // Create input text with appropriate style
- let text_style = if app.search_query.is_empty() && !is_searching {
- Style::default().fg(Color::DarkGray)
- } else if has_no_matches {
- Style::default().fg(NO_MATCH_COLOR)
- } else {
- Style::default()
- };
-
- let input = Paragraph::new(Span::styled(search_text, text_style)).block(
- Block::default()
- .title(title)
- .borders(Borders::ALL)
- .border_style(border_style),
- );
-
- f.render_widget(input, area);
-
- // Show cursor in search mode
- if is_searching {
- // Calculate cursor position based on actual search query length
- let cursor_x = area.x + app.search_query.len() as u16 + 2;
- f.set_cursor_position(Position::new(cursor_x, area.y + 1));
- }
-}
diff --git a/makima/src/daemon/tui/widgets/status_bar.rs b/makima/src/daemon/tui/widgets/status_bar.rs
deleted file mode 100644
index 3357c58..0000000
--- a/makima/src/daemon/tui/widgets/status_bar.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-//! Status bar widget.
-
-use ratatui::{prelude::*, widgets::Paragraph};
-
-use crate::daemon::tui::app::{App, InputMode};
-
-pub fn render(f: &mut Frame, area: Rect, app: &App) {
- let keybindings = match app.input_mode {
- InputMode::Normal => {
- "↑↓:Navigate Enter:View e:Edit d:Delete Tab:Preview /:Search q:Quit"
- }
- InputMode::Search => "Type to search Enter:Select Esc:Cancel",
- InputMode::Confirm => "y:Confirm n:Cancel",
- };
-
- let status = Paragraph::new(keybindings).style(Style::default().bg(Color::DarkGray));
-
- f.render_widget(status, area);
-}
diff --git a/makima/src/daemon/tui/ws_client.rs b/makima/src/daemon/tui/ws_client.rs
deleted file mode 100644
index 3462467..0000000
--- a/makima/src/daemon/tui/ws_client.rs
+++ /dev/null
@@ -1,353 +0,0 @@
-//! 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
- }
- _ => {}
- }
- }
- }
- }
- }
-}