summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-20 17:23:34 +0000
committersoryu <soryu@soryu.co>2026-01-20 17:23:46 +0000
commit055e2c4a72e3b2331a18fdc9f8132ef990af7e38 (patch)
tree75b92637b9132594b76a5a86f5f854bca1ddee49
parent54c6d409e1d5667f4ab7f63a43e1459e68575c94 (diff)
downloadsoryu-055e2c4a72e3b2331a18fdc9f8132ef990af7e38.tar.gz
soryu-055e2c4a72e3b2331a18fdc9f8132ef990af7e38.zip
Update CLI to show log history as well
-rw-r--r--Cargo.lock1
-rw-r--r--frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/Cargo.toml1
-rw-r--r--makima/src/bin/makima.rs212
-rw-r--r--makima/src/daemon/api/contract.rs33
-rw-r--r--makima/src/daemon/api/mod.rs1
-rw-r--r--makima/src/daemon/cli/config.rs128
-rw-r--r--makima/src/daemon/cli/mod.rs29
-rw-r--r--makima/src/daemon/cli/view.rs10
-rw-r--r--makima/src/daemon/tui/app.rs264
-rw-r--r--makima/src/daemon/tui/event.rs57
-rw-r--r--makima/src/daemon/tui/mod.rs2
-rw-r--r--makima/src/daemon/tui/ui.rs156
13 files changed, 870 insertions, 26 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 2383bf5..1e1be6c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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;