summaryrefslogtreecommitdiff
path: root/makima/src/daemon/tui
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/daemon/tui')
-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
4 files changed, 465 insertions, 14 deletions
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;