diff options
| author | soryu <soryu@soryu.co> | 2026-01-20 17:23:34 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-20 17:23:46 +0000 |
| commit | 055e2c4a72e3b2331a18fdc9f8132ef990af7e38 (patch) | |
| tree | 75b92637b9132594b76a5a86f5f854bca1ddee49 | |
| parent | 54c6d409e1d5667f4ab7f63a43e1459e68575c94 (diff) | |
| download | soryu-055e2c4a72e3b2331a18fdc9f8132ef990af7e38.tar.gz soryu-055e2c4a72e3b2331a18fdc9f8132ef990af7e38.zip | |
Update CLI to show log history as well
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | frontend/tsconfig.tsbuildinfo | 2 | ||||
| -rw-r--r-- | makima/Cargo.toml | 1 | ||||
| -rw-r--r-- | makima/src/bin/makima.rs | 212 | ||||
| -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 |
13 files changed, 870 insertions, 26 deletions
@@ -2039,6 +2039,7 @@ dependencies = [ "tokenizers 0.21.4", "tokio", "tokio-tungstenite 0.24.0", + "toml", "tower-http", "tracing", "tracing-subscriber", diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 4c2c550..93d1fec 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/dialoguebox.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/missiondrawer.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/dialoguebox.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file diff --git a/makima/Cargo.toml b/makima/Cargo.toml index 94e268c..650628a 100644 --- a/makima/Cargo.toml +++ b/makima/Cargo.toml @@ -23,6 +23,7 @@ tokio = { version = "1.0", features = ["full", "signal", "process"] } tower-http = { version = "0.6", features = ["cors", "trace"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = "0.8" futures = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index 8b3e4dc..37aa045 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -4,9 +4,9 @@ use std::io::{self, Read}; use std::path::Path; use std::sync::Arc; -use makima::daemon::api::ApiClient; +use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ - Cli, Commands, ContractCommand, SupervisorCommand, ViewArgs, + Cli, CliConfig, Commands, ConfigCommand, ContractCommand, SupervisorCommand, ViewArgs, }; use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState}; use makima::daemon::config::{DaemonConfig, RepoEntry}; @@ -28,6 +28,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { Commands::Supervisor(cmd) => run_supervisor(cmd).await, Commands::Contract(cmd) => run_contract(cmd).await, Commands::View(args) => run_view(args).await, + Commands::Config(cmd) => run_config(cmd).await, } } @@ -559,11 +560,32 @@ async fn run_contract( /// Run the TUI view command. async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + // Load CLI config for defaults + let config = CliConfig::load(); + + // Get API URL and key, preferring CLI args > env vars > config file + // Filter out empty strings + let api_url = args.api_url + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| config.get_api_url()); + let api_key = match args.api_key.filter(|s| !s.is_empty()) { + Some(key) => key, + None => config.get_api_key().ok_or_else(|| { + eprintln!("Error: No API key provided."); + eprintln!(); + eprintln!("Set your API key using one of these methods:"); + eprintln!(" 1. Run: makima config set-key YOUR_API_KEY"); + eprintln!(" 2. Set environment variable: export MAKIMA_API_KEY=YOUR_API_KEY"); + eprintln!(" 3. Pass via CLI: makima view --api-key YOUR_API_KEY"); + "No API key configured" + })?, + }; + // Create API client - let client = ApiClient::new(args.api_url.clone(), args.api_key.clone())?; + let client = ApiClient::new(api_url.clone(), api_key.clone())?; // Start WebSocket client for task output streaming - let ws_client = TuiWsClient::start(args.api_url.clone(), args.api_key.clone()); + let ws_client = TuiWsClient::start(api_url, api_key); // Start at contracts view let mut app = App::new(ViewType::Contracts); @@ -583,6 +605,49 @@ async fn run_view(args: ViewArgs) -> Result<(), Box<dyn std::error::Error + Send result } +/// Run config commands. +async fn run_config(cmd: ConfigCommand) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { + match cmd { + ConfigCommand::SetKey(args) => { + let mut config = CliConfig::load(); + config.api_key = Some(args.api_key); + config.save()?; + println!("API key saved to {:?}", CliConfig::config_path().unwrap_or_default()); + Ok(()) + } + ConfigCommand::SetUrl(args) => { + let mut config = CliConfig::load(); + config.api_url = args.api_url; + config.save()?; + println!("API URL saved to {:?}", CliConfig::config_path().unwrap_or_default()); + Ok(()) + } + ConfigCommand::Show => { + let config = CliConfig::load(); + println!("Configuration:"); + println!(" API URL: {}", config.api_url); + println!(" API Key: {}", config.api_key.as_ref().map(|k| { + if k.len() > 10 { + format!("{}...{}", &k[..6], &k[k.len()-4..]) + } else { + "***".to_string() + } + }).unwrap_or_else(|| "(not set)".to_string())); + println!(); + println!("Config file: {:?}", CliConfig::config_path().unwrap_or_default()); + Ok(()) + } + ConfigCommand::Path => { + if let Some(path) = CliConfig::config_path() { + println!("{}", path.display()); + } else { + eprintln!("Could not determine config path"); + } + Ok(()) + } + } +} + /// Load contracts from API async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { let result = client.list_contracts().await?; @@ -595,10 +660,51 @@ async fn load_contracts(client: &ApiClient) -> Result<Vec<ListItem>, Box<dyn std /// Load tasks for a contract from API async fn load_tasks(client: &ApiClient, contract_id: uuid::Uuid) -> Result<Vec<ListItem>, Box<dyn std::error::Error + Send + Sync>> { - let result = client.supervisor_tasks(contract_id).await?; - let items = result.0.as_array() + // Use get_contract which returns tasks as part of the response (works with regular API key auth) + let result = client.get_contract(contract_id).await?; + let mut items: Vec<ListItem> = result.0.get("tasks") + .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(ListItem::from_task).collect()) .unwrap_or_default(); + + // Sort tasks: supervisor first, then by status (running first), then by name + items.sort_by(|a, b| { + // Check if task is supervisor (role field in extra data) + let a_is_supervisor = a.extra.get("role") + .and_then(|v| v.as_str()) + .map(|s| s == "supervisor") + .unwrap_or(false); + let b_is_supervisor = b.extra.get("role") + .and_then(|v| v.as_str()) + .map(|s| s == "supervisor") + .unwrap_or(false); + + // Supervisor first + match (a_is_supervisor, b_is_supervisor) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => { + // Then by status: running/working tasks first + let status_order = |s: Option<&String>| -> i32 { + match s.map(|x| x.as_str()) { + Some("running") | Some("working") => 0, + Some("pending") | Some("queued") => 1, + Some("completed") | Some("done") => 2, + Some("failed") | Some("error") => 3, + _ => 4, + } + }; + let a_order = status_order(a.status.as_ref()); + let b_order = status_order(b.status.as_ref()); + + match a_order.cmp(&b_order) { + std::cmp::Ordering::Equal => a.name.cmp(&b.name), + other => other, + } + } + } + }); + Ok(items) } @@ -712,10 +818,29 @@ async fn run_tui_loop( ws_client.unsubscribe(old_task_id); } - // Subscribe to new task output + // Load task output history first + app.status_message = Some("Loading output history...".to_string()); + match client.get_task_output(task_id).await { + Ok(result) => { + // Parse the entries array from response + if let Some(entries) = result.0.get("entries").and_then(|v| v.as_array()) { + for entry in entries { + if let Some(line) = parse_output_entry(entry) { + app.output_buffer.add_line(line); + } + } + let count = entries.len(); + app.status_message = Some(format!("Loaded {} history entries, streaming live...", count)); + } + } + Err(e) => { + app.status_message = Some(format!("Failed to load history: {}", e)); + } + } + + // Subscribe to new task output for live updates ws_client.subscribe(task_id); subscribed_task_id = Some(task_id); - app.status_message = Some("Connecting to task output...".to_string()); } Action::PerformDelete { id, item_type } => { // Perform the delete API call @@ -822,6 +947,47 @@ async fn run_tui_loop( app.ws_state = WsConnectionState::Disconnected; } } + Action::PerformCreateContract { name, description, contract_type, repository_url } => { + // Create the contract via API + let req = CreateContractRequest { + name: name.clone(), + description: if description.is_empty() { None } else { Some(description) }, + contract_type: Some(contract_type), + initial_phase: None, + autonomous_loop: None, + phase_guard: None, + }; + + match client.create_contract(req).await { + Ok(result) => { + let contract_name = result.0.get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&name); + app.status_message = Some(format!("Created contract: {}", contract_name)); + + // Refresh the contracts list + match load_contracts(client).await { + Ok(items) => app.set_items(items), + Err(e) => app.status_message = Some(format!("Created but refresh failed: {}", e)), + } + + // TODO: If repository_url was provided, add it to the contract + if let Some(repo_url) = repository_url { + if !repo_url.is_empty() { + // We'd need to add a method to add repository to contract + // For now, just note it in the status + app.status_message = Some(format!( + "Created contract: {} (Note: Add repository {} manually)", + contract_name, repo_url + )); + } + } + } + Err(e) => { + app.status_message = Some(format!("Create failed: {}", e)); + } + } + } _ => {} } } @@ -837,6 +1003,36 @@ async fn run_tui_loop( Ok(None) } +/// Parse an output entry from the API response into an OutputLine +fn parse_output_entry(entry: &serde_json::Value) -> Option<OutputLine> { + let message_type = entry.get("messageType") + .and_then(|v| v.as_str()) + .unwrap_or("raw"); + let content = entry.get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_name = entry.get("toolName") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let is_error = entry.get("isError") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let cost_usd = entry.get("costUsd") + .and_then(|v| v.as_f64()); + let duration_ms = entry.get("durationMs") + .and_then(|v| v.as_u64()); + + Some(OutputLine { + message_type: OutputMessageType::from_str(message_type), + content, + tool_name, + is_error, + cost_usd, + duration_ms, + }) +} + /// Handle a WebSocket event and update app state fn handle_ws_event(app: &mut App, event: WsEvent) { match event { 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; |
