summaryrefslogtreecommitdiff
path: root/makima/src/daemon
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon')
-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
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;