From 055e2c4a72e3b2331a18fdc9f8132ef990af7e38 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 20 Jan 2026 17:23:34 +0000 Subject: Update CLI to show log history as well --- makima/src/daemon/tui/app.rs | 264 +++++++++++++++++++++++++++++++++++++++-- makima/src/daemon/tui/event.rs | 57 ++++++++- makima/src/daemon/tui/mod.rs | 2 +- makima/src/daemon/tui/ui.rs | 156 +++++++++++++++++++++++- 4 files changed, 465 insertions(+), 14 deletions(-) (limited to 'makima/src/daemon/tui') 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, + /// 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, + }, } /// A displayable item in the TUI @@ -461,6 +642,8 @@ pub struct App { pub pending_delete: Option, /// Edit state for inline editing pub edit_state: EditState, + /// Create contract form state + pub create_state: CreateContractState, /// Status message pub status_message: Option, /// 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> { @@ -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::>() + } else { + type_display.into_iter().map(|mut s| { + s.style = s.style.fg(Color::DarkGray); + s + }).collect() + } + ).collect::>()), + 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; -- cgit v1.2.3