diff options
Diffstat (limited to 'makima/src/daemon')
| -rw-r--r-- | makima/src/daemon/api/contract.rs | 33 | ||||
| -rw-r--r-- | makima/src/daemon/api/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/daemon/cli/config.rs | 128 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 29 | ||||
| -rw-r--r-- | makima/src/daemon/cli/view.rs | 10 | ||||
| -rw-r--r-- | makima/src/daemon/tui/app.rs | 264 | ||||
| -rw-r--r-- | makima/src/daemon/tui/event.rs | 57 | ||||
| -rw-r--r-- | makima/src/daemon/tui/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 156 |
9 files changed, 663 insertions, 17 deletions
diff --git a/makima/src/daemon/api/contract.rs b/makima/src/daemon/api/contract.rs index f7fa696..955fe41 100644 --- a/makima/src/daemon/api/contract.rs +++ b/makima/src/daemon/api/contract.rs @@ -51,12 +51,39 @@ pub struct UpdateContractRequest { pub description: Option<String>, } +/// Request to create a new contract. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateContractRequest { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub contract_type: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_phase: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub autonomous_loop: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub phase_guard: Option<bool>, +} + impl ApiClient { /// List all contracts for the authenticated user. pub async fn list_contracts(&self) -> Result<JsonValue, ApiError> { self.get("/api/v1/contracts").await } + /// Create a new contract. + pub async fn create_contract(&self, req: CreateContractRequest) -> Result<JsonValue, ApiError> { + self.post("/api/v1/contracts", &req).await + } + + /// Get a contract with its tasks, files, and repositories. + pub async fn get_contract(&self, contract_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/contracts/{}", contract_id)).await + } + /// Delete a contract. pub async fn delete_contract(&self, contract_id: Uuid) -> Result<(), ApiError> { self.delete(&format!("/api/v1/contracts/{}", contract_id)) @@ -191,4 +218,10 @@ impl ApiClient { self.post(&format!("/api/v1/contracts/{}/daemon/files", contract_id), &req) .await } + + /// Get task output history. + pub async fn get_task_output(&self, task_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/mesh/tasks/{}/output", task_id)) + .await + } } diff --git a/makima/src/daemon/api/mod.rs b/makima/src/daemon/api/mod.rs index 0c05fb4..49d80e0 100644 --- a/makima/src/daemon/api/mod.rs +++ b/makima/src/daemon/api/mod.rs @@ -5,3 +5,4 @@ pub mod contract; pub mod supervisor; pub use client::ApiClient; +pub use contract::CreateContractRequest; diff --git a/makima/src/daemon/cli/config.rs b/makima/src/daemon/cli/config.rs new file mode 100644 index 0000000..8199b88 --- /dev/null +++ b/makima/src/daemon/cli/config.rs @@ -0,0 +1,128 @@ +//! CLI configuration management. +//! +//! Handles loading and saving CLI configuration from ~/.makima/config.toml. +//! This is separate from daemon configuration and is used for interactive CLI commands. + +use clap::Args; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Arguments for setting the API key +#[derive(Args, Debug, Clone)] +pub struct SetKeyArgs { + /// The API key to save + pub api_key: String, +} + +/// Arguments for setting the API URL +#[derive(Args, Debug, Clone)] +pub struct SetUrlArgs { + /// The API URL to save + pub api_url: String, +} + +/// CLI configuration stored in ~/.makima/config.toml +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CliConfig { + /// API URL for the makima server + #[serde(default = "default_api_url")] + pub api_url: String, + + /// API key for authentication + #[serde(default)] + pub api_key: Option<String>, +} + +fn default_api_url() -> String { + "https://api.makima.jp".to_string() +} + +impl CliConfig { + /// Get the config directory path (~/.makima) + pub fn config_dir() -> Option<PathBuf> { + dirs::home_dir().map(|h| h.join(".makima")) + } + + /// Get the config file path (~/.makima/config.toml) + pub fn config_path() -> Option<PathBuf> { + Self::config_dir().map(|d| d.join("config.toml")) + } + + /// Load CLI config from ~/.makima/config.toml + /// Returns default config if file doesn't exist + pub fn load() -> Self { + let Some(path) = Self::config_path() else { + return Self::default(); + }; + + if !path.exists() { + return Self::default(); + } + + match fs::read_to_string(&path) { + Ok(contents) => { + toml::from_str(&contents).unwrap_or_else(|e| { + eprintln!("Warning: Failed to parse {}: {}", path.display(), e); + Self::default() + }) + } + Err(e) => { + eprintln!("Warning: Failed to read {}: {}", path.display(), e); + Self::default() + } + } + } + + /// Save CLI config to ~/.makima/config.toml + pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + let Some(dir) = Self::config_dir() else { + return Err("Could not determine home directory".into()); + }; + + let Some(path) = Self::config_path() else { + return Err("Could not determine config path".into()); + }; + + // Create config directory if it doesn't exist + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + + let contents = toml::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + fs::write(&path, contents)?; + + Ok(()) + } + + /// Get API key, preferring environment variable over config file + pub fn get_api_key(&self) -> Option<String> { + // Environment variable takes precedence + if let Ok(key) = std::env::var("MAKIMA_API_KEY") { + if !key.is_empty() { + return Some(key); + } + } + + // Fall back to config file + self.api_key.clone() + } + + /// Get API URL, preferring environment variable over config file + pub fn get_api_url(&self) -> String { + // Environment variable takes precedence + if let Ok(url) = std::env::var("MAKIMA_API_URL") { + if !url.is_empty() { + return url; + } + } + + // Fall back to config file, or default if empty + if self.api_url.is_empty() { + default_api_url() + } else { + self.api_url.clone() + } + } +} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index 3394b35..216f281 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -1,5 +1,6 @@ //! Command-line interface for the makima CLI. +pub mod config; pub mod contract; pub mod daemon; pub mod server; @@ -8,6 +9,7 @@ pub mod view; use clap::{Parser, Subcommand}; +pub use config::CliConfig; pub use contract::ContractArgs; pub use daemon::DaemonArgs; pub use server::ServerArgs; @@ -48,7 +50,34 @@ pub enum Commands { /// ↑/k: Move up ↓/j: Move down Enter/l: Drill in /// Esc/h: Go back /: Search q: Quit /// e: Edit d: Delete c: cd to worktree + /// n: New contract View(ViewArgs), + + /// Configure CLI settings (API key, server URL) + /// + /// Saves configuration to ~/.makima/config.toml for use by CLI commands. + #[command(subcommand)] + Config(ConfigCommand), +} + +/// Config subcommands for CLI configuration. +#[derive(Subcommand, Debug)] +pub enum ConfigCommand { + /// Set the API key + /// + /// Saves the API key to ~/.makima/config.toml + SetKey(config::SetKeyArgs), + + /// Set the API URL + /// + /// Saves the API URL to ~/.makima/config.toml + SetUrl(config::SetUrlArgs), + + /// Show current configuration + Show, + + /// Show the config file path + Path, } /// Supervisor subcommands for contract orchestration. diff --git a/makima/src/daemon/cli/view.rs b/makima/src/daemon/cli/view.rs index b5b516f..b9fa82f 100644 --- a/makima/src/daemon/cli/view.rs +++ b/makima/src/daemon/cli/view.rs @@ -67,12 +67,16 @@ use clap::Args; #[derive(Args, Debug, Clone)] pub struct ViewArgs { /// API URL for the makima server - #[arg(long, env = "MAKIMA_API_URL", default_value = "https://api.makima.jp")] - pub api_url: String, + /// + /// 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: String, + pub api_key: Option<String>, /// Initial search query /// diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs index 3eff998..ea431b8 100644 --- a/makima/src/daemon/tui/app.rs +++ b/makima/src/daemon/tui/app.rs @@ -63,6 +63,164 @@ pub enum InputMode { 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, +} + +/// 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<String>, + /// Selected suggestion index (for repository field) + pub selected_suggestion: usize, + /// Whether suggestions popup is visible + pub show_suggestions: 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, + } + } + + /// 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); + self.show_suggestions = false; + } + + /// 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); + self.show_suggestions = false; + } + + /// 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 @@ -264,6 +422,29 @@ pub enum Action { 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>, + }, } /// A displayable item in the TUI @@ -461,6 +642,8 @@ pub struct App { 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 @@ -492,6 +675,7 @@ impl App { preview_visible: false, pending_delete: None, edit_state: EditState::default(), + create_state: CreateContractState::new(), status_message: None, should_quit: false, exit_action: None, @@ -636,6 +820,10 @@ impl App { 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 }; } } @@ -644,6 +832,10 @@ impl App { 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 }; } } @@ -838,17 +1030,11 @@ impl App { Action::LaunchEditor(path) } Action::LoadTasks { contract_id, contract_name } => { - // Prepare for tasks view - self.push_view(ViewType::Tasks); - self.contract_id = Some(contract_id); - self.contract_name = Some(contract_name.clone()); + // Pass through to caller for data loading (view already pushed by DrillDown) Action::LoadTasks { contract_id, contract_name } } Action::LoadTaskOutput { task_id, task_name } => { - // Prepare for output view - self.push_view(ViewType::TaskOutput); - self.task_id = Some(task_id); - self.task_name = Some(task_name.clone()); + // Pass through to caller for data loading (view already pushed by DrillDown) Action::LoadTaskOutput { task_id, task_name } } Action::PerformDelete { id, item_type } => { @@ -877,6 +1063,68 @@ impl App { } 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; + } + 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::None => Action::None, } } diff --git a/makima/src/daemon/tui/event.rs b/makima/src/daemon/tui/event.rs index 0e3874b..2fed55a 100644 --- a/makima/src/daemon/tui/event.rs +++ b/makima/src/daemon/tui/event.rs @@ -3,7 +3,7 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; use std::time::Duration; -use super::app::{Action, App, InputMode, ViewType}; +use super::app::{Action, App, CreateFormField, InputMode, ViewType}; /// Poll for events with timeout pub fn poll_event(timeout: Duration) -> std::io::Result<Option<Event>> { @@ -22,15 +22,16 @@ pub fn handle_key_event(app: &App, key: KeyEvent) -> Action { } match app.input_mode { - InputMode::Normal => handle_normal_mode(key), + 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(key: KeyEvent) -> Action { +fn handle_normal_mode(app: &App, key: KeyEvent) -> Action { // Check for Ctrl+C first if key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { @@ -55,6 +56,9 @@ fn handle_normal_mode(key: KeyEvent) -> Action { 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, @@ -180,13 +184,58 @@ fn handle_edit_mode(key: KeyEvent) -> Action { } } +/// 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(); + + 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 | c: cd | /: search | q: quit", + 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", } } diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs index 4cb4f60..06cac34 100644 --- a/makima/src/daemon/tui/mod.rs +++ b/makima/src/daemon/tui/mod.rs @@ -16,7 +16,7 @@ pub mod fuzzy; pub mod ui; pub mod ws_client; -pub use app::{App, ListItem, ViewType, ViewState, InputMode, Action, OutputBuffer, OutputLine, OutputMessageType, WsConnectionState}; +pub use app::{App, ListItem, ViewType, ViewState, InputMode, Action, OutputBuffer, OutputLine, OutputMessageType, WsConnectionState, CreateContractState, CreateFormField}; pub use ws_client::{TuiWsClient, WsCommand, WsEvent, TaskOutputEvent}; pub use fuzzy::FuzzyMatcher; diff --git a/makima/src/daemon/tui/ui.rs b/makima/src/daemon/tui/ui.rs index 9349183..de15320 100644 --- a/makima/src/daemon/tui/ui.rs +++ b/makima/src/daemon/tui/ui.rs @@ -8,7 +8,7 @@ use ratatui::{ Frame, }; -use super::app::{App, InputMode, ViewType, OutputMessageType, WsConnectionState}; +use super::app::{App, CreateFormField, InputMode, ViewType, OutputMessageType, WsConnectionState}; use super::event::{get_help_text, get_output_help_text}; /// Main render function @@ -36,6 +36,11 @@ pub fn render(frame: &mut Frame, app: &App) { 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 @@ -355,6 +360,155 @@ fn render_edit_dialog(frame: &mut Frame, app: &App) { 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 + let area = frame.area(); + let popup_width = 70.min(area.width.saturating_sub(4)); + let popup_height = 20; + + 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 state = &app.create_state; + let current_field = state.current_field(); + 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); + + // 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 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)), + Line::from(""), + // Help line + 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; |
