diff options
| author | soryu <soryu@soryu.co> | 2026-01-21 15:58:34 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-21 15:58:34 +0000 |
| commit | da246c4c4e23c9ad976705f9a3fa80e0d75b4425 (patch) | |
| tree | ddc3b93ed269e60dac1aa9113000daeac4a1b6e6 | |
| parent | 7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc (diff) | |
| download | soryu-da246c4c4e23c9ad976705f9a3fa80e0d75b4425.tar.gz soryu-da246c4c4e23c9ad976705f9a3fa80e0d75b4425.zip | |
Update CLI to show repo suggestions
| -rw-r--r-- | makima/src/bin/makima.rs | 69 | ||||
| -rw-r--r-- | makima/src/daemon/api/contract.rs | 23 | ||||
| -rw-r--r-- | makima/src/daemon/tui/app.rs | 85 | ||||
| -rw-r--r-- | makima/src/daemon/tui/event.rs | 24 | ||||
| -rw-r--r-- | makima/src/daemon/tui/mod.rs | 2 | ||||
| -rw-r--r-- | makima/src/daemon/tui/ui.rs | 87 |
6 files changed, 275 insertions, 15 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index f91ceef..29388e1 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -8,7 +8,7 @@ use makima::daemon::api::{ApiClient, CreateContractRequest}; use makima::daemon::cli::{ Cli, CliConfig, Commands, ConfigCommand, ContractCommand, SupervisorCommand, ViewArgs, }; -use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState}; +use makima::daemon::tui::{self, Action, App, ListItem, ViewType, TuiWsClient, WsEvent, OutputLine, OutputMessageType, WsConnectionState, RepositorySuggestion}; use makima::daemon::config::{DaemonConfig, RepoEntry}; use makima::daemon::db::LocalDb; use makima::daemon::error::DaemonError; @@ -988,6 +988,73 @@ async fn run_tui_loop( } } } + Action::LoadRepoSuggestions => { + // Load repository suggestions for the create form + app.status_message = Some("Loading recent repositories...".to_string()); + // Force a redraw to show the status + terminal.draw(|f| tui::ui::render(f, app)).ok(); + + // Fetch all repository types (remote and local) + match client.get_repository_suggestions(None, Some(10)).await { + Ok(result) => { + // Parse suggestions from API response + let suggestions: Vec<RepositorySuggestion> = result.0 + .get("entries") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().filter_map(|entry| { + let name = entry.get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let repository_url = entry.get("repositoryUrl") + .or_else(|| entry.get("repository_url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let local_path = entry.get("localPath") + .or_else(|| entry.get("local_path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let source_type = entry.get("sourceType") + .or_else(|| entry.get("source_type")) + .and_then(|v| v.as_str()) + .unwrap_or("remote") + .to_string(); + let use_count = entry.get("useCount") + .or_else(|| entry.get("use_count")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + + // Only include if we have a URL or path + if repository_url.is_some() || local_path.is_some() { + Some(RepositorySuggestion { + name, + repository_url, + local_path, + source_type, + use_count, + }) + } else { + None + } + }).collect() + }) + .unwrap_or_default(); + + let count = suggestions.len(); + app.create_state.set_suggestions(suggestions); + if count > 0 { + app.status_message = Some(format!("Found {} recent repositories", count)); + } else { + app.status_message = Some("No recent repositories found".to_string()); + } + } + Err(e) => { + app.status_message = Some(format!("Could not load suggestions: {}", e)); + app.create_state.suggestions_loaded = true; + } + } + } _ => {} } } diff --git a/makima/src/daemon/api/contract.rs b/makima/src/daemon/api/contract.rs index 955fe41..50fd64f 100644 --- a/makima/src/daemon/api/contract.rs +++ b/makima/src/daemon/api/contract.rs @@ -224,4 +224,27 @@ impl ApiClient { self.get(&format!("/api/v1/mesh/tasks/{}/output", task_id)) .await } + + /// Get repository suggestions for autocomplete. + /// Returns recently used repositories sorted by usage frequency and recency. + pub async fn get_repository_suggestions( + &self, + source_type: Option<&str>, + limit: Option<i32>, + ) -> Result<JsonValue, ApiError> { + let mut params = Vec::new(); + if let Some(st) = source_type { + params.push(format!("source_type={}", st)); + } + if let Some(l) = limit { + params.push(format!("limit={}", l)); + } + let query_string = if params.is_empty() { + String::new() + } else { + format!("?{}", params.join("&")) + }; + self.get(&format!("/api/v1/settings/repository-history/suggestions{}", query_string)) + .await + } } diff --git a/makima/src/daemon/tui/app.rs b/makima/src/daemon/tui/app.rs index ea431b8..cb0e8f3 100644 --- a/makima/src/daemon/tui/app.rs +++ b/makima/src/daemon/tui/app.rs @@ -78,6 +78,16 @@ pub enum CreateFormField { Repository, } +/// Repository suggestion from history +#[derive(Debug, Clone)] +pub struct RepositorySuggestion { + pub name: String, + pub repository_url: Option<String>, + pub local_path: Option<String>, + pub source_type: String, + pub use_count: i32, +} + /// State for create contract form #[derive(Debug, Clone, Default)] pub struct CreateContractState { @@ -94,11 +104,13 @@ pub struct CreateContractState { /// Cursor position in current text field pub cursor: usize, /// Available repository suggestions - pub repo_suggestions: Vec<String>, + pub repo_suggestions: Vec<RepositorySuggestion>, /// Selected suggestion index (for repository field) pub selected_suggestion: usize, /// Whether suggestions popup is visible pub show_suggestions: bool, + /// Whether suggestions have been loaded + pub suggestions_loaded: bool, } impl CreateContractState { @@ -113,6 +125,47 @@ impl CreateContractState { repo_suggestions: Vec::new(), selected_suggestion: 0, show_suggestions: false, + suggestions_loaded: false, + } + } + + /// Set repository suggestions + pub fn set_suggestions(&mut self, suggestions: Vec<RepositorySuggestion>) { + self.repo_suggestions = suggestions; + self.selected_suggestion = 0; + self.show_suggestions = !self.repo_suggestions.is_empty(); + self.suggestions_loaded = true; + } + + /// Apply the selected suggestion to the form + pub fn apply_selected_suggestion(&mut self) { + if let Some(suggestion) = self.repo_suggestions.get(self.selected_suggestion) { + // Apply the suggestion + if let Some(ref url) = suggestion.repository_url { + self.repository_url = url.clone(); + } else if let Some(ref path) = suggestion.local_path { + self.repository_url = path.clone(); + } + self.cursor = self.repository_url.len(); + self.show_suggestions = false; + } + } + + /// Navigate to next suggestion + pub fn next_suggestion(&mut self) { + if !self.repo_suggestions.is_empty() { + self.selected_suggestion = (self.selected_suggestion + 1) % self.repo_suggestions.len(); + } + } + + /// Navigate to previous suggestion + pub fn prev_suggestion(&mut self) { + if !self.repo_suggestions.is_empty() { + self.selected_suggestion = if self.selected_suggestion == 0 { + self.repo_suggestions.len() - 1 + } else { + self.selected_suggestion - 1 + }; } } @@ -156,14 +209,14 @@ impl CreateContractState { 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; + // Don't hide suggestions - they stay visible } /// 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; + // Don't hide suggestions - they stay visible } /// Toggle contract type @@ -445,6 +498,14 @@ pub enum Action { contract_type: String, repository_url: Option<String>, }, + /// Request to load repository suggestions (internal) + LoadRepoSuggestions, + /// Navigate to next suggestion in create form + CreateNextSuggestion, + /// Navigate to previous suggestion in create form + CreatePrevSuggestion, + /// Apply selected suggestion in create form + CreateApplySuggestion, } /// A displayable item in the TUI @@ -1068,6 +1129,8 @@ impl App { if self.view_type == ViewType::Contracts { self.create_state = CreateContractState::new(); self.input_mode = InputMode::CreateName; + // Request to load repository suggestions + return Action::LoadRepoSuggestions; } Action::None } @@ -1125,6 +1188,22 @@ impl App { // Pass through to caller for API call Action::PerformCreateContract { name, description, contract_type, repository_url } } + Action::LoadRepoSuggestions => { + // Pass through to caller for API call + Action::LoadRepoSuggestions + } + Action::CreateNextSuggestion => { + self.create_state.next_suggestion(); + Action::None + } + Action::CreatePrevSuggestion => { + self.create_state.prev_suggestion(); + Action::None + } + Action::CreateApplySuggestion => { + self.create_state.apply_selected_suggestion(); + Action::None + } Action::None => Action::None, } } diff --git a/makima/src/daemon/tui/event.rs b/makima/src/daemon/tui/event.rs index 2fed55a..d5ca569 100644 --- a/makima/src/daemon/tui/event.rs +++ b/makima/src/daemon/tui/event.rs @@ -194,6 +194,30 @@ fn handle_create_mode(app: &App, key: KeyEvent) -> Action { } let current_field = app.create_state.current_field(); + let has_suggestions = app.create_state.show_suggestions + && !app.create_state.repo_suggestions.is_empty(); + + // Allow Ctrl+N/Ctrl+P to navigate suggestions from any field + if has_suggestions && key.modifiers.contains(KeyModifiers::CONTROL) { + match key.code { + KeyCode::Char('n') => return Action::CreateNextSuggestion, + KeyCode::Char('p') => return Action::CreatePrevSuggestion, + _ => {} + } + } + + // Special handling when on Repository field with suggestions visible + let on_repo_field = current_field == CreateFormField::Repository; + if has_suggestions && on_repo_field { + match key.code { + // Up/Down navigate suggestions when on repo field + KeyCode::Up => return Action::CreatePrevSuggestion, + KeyCode::Down => return Action::CreateNextSuggestion, + // Enter applies suggestion instead of submitting form + KeyCode::Enter => return Action::CreateApplySuggestion, + _ => {} + } + } match key.code { // Submit form diff --git a/makima/src/daemon/tui/mod.rs b/makima/src/daemon/tui/mod.rs index 06cac34..e52b12a 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, CreateContractState, CreateFormField}; +pub use app::{App, ListItem, ViewType, ViewState, InputMode, Action, OutputBuffer, OutputLine, OutputMessageType, WsConnectionState, CreateContractState, CreateFormField, RepositorySuggestion}; 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 de15320..2a5a6ce 100644 --- a/makima/src/daemon/tui/ui.rs +++ b/makima/src/daemon/tui/ui.rs @@ -362,10 +362,21 @@ fn render_edit_dialog(frame: &mut Frame, app: &App) { /// Render the create contract dialog fn render_create_dialog(frame: &mut Frame, app: &App) { - // Calculate popup size and position + // Calculate popup size and position - make it taller if suggestions are shown let area = frame.area(); + let state = &app.create_state; + let current_field = state.current_field(); + // Show suggestions whenever we have them (like the frontend does) + let show_suggestions = state.show_suggestions && !state.repo_suggestions.is_empty(); + let popup_width = 70.min(area.width.saturating_sub(4)); - let popup_height = 20; + let base_height = 20; + let suggestion_height = if show_suggestions { + (state.repo_suggestions.len().min(5) + 2) as u16 + } else { + 0 + }; + let popup_height = base_height + suggestion_height; let popup_x = (area.width.saturating_sub(popup_width)) / 2; let popup_y = (area.height.saturating_sub(popup_height)) / 2; @@ -380,8 +391,6 @@ fn render_create_dialog(frame: &mut Frame, app: &App) { // 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 @@ -389,6 +398,8 @@ fn render_create_dialog(frame: &mut Frame, app: &App) { 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); + let suggestion_style = Style::default().fg(Color::White); + let selected_suggestion_style = Style::default().fg(Color::Black).bg(Color::Cyan); // Helper to build text field with cursor let build_field = |value: &str, cursor: usize, is_active: bool| -> String { @@ -435,7 +446,7 @@ fn render_create_dialog(frame: &mut Frame, app: &App) { ] }; - let text = vec![ + let mut text = vec![ Line::from(""), Line::from(Span::styled( " New Contract", @@ -486,9 +497,65 @@ fn render_create_dialog(frame: &mut Frame, app: &App) { ), ]), Line::from(Span::styled(" Git repository URL (optional)", hint_style)), - Line::from(""), - // Help line - Line::from(vec![ + ]; + + // Add suggestions section + if show_suggestions { + text.push(Line::from("")); + text.push(Line::from(Span::styled( + " Recent repositories (↑/↓ to select, Enter to apply):", + Style::default().fg(Color::Cyan), + ))); + + for (i, suggestion) in state.repo_suggestions.iter().take(5).enumerate() { + let is_selected = i == state.selected_suggestion; + let url_or_path = suggestion.repository_url.as_ref() + .or(suggestion.local_path.as_ref()) + .map(|s| s.as_str()) + .unwrap_or(""); + + // Truncate if too long + let display_url = if url_or_path.len() > max_field_width - 10 { + format!("...{}", &url_or_path[url_or_path.len().saturating_sub(max_field_width - 13)..]) + } else { + url_or_path.to_string() + }; + + let prefix = if is_selected { " → " } else { " " }; + let count_suffix = format!(" ({}×)", suggestion.use_count); + + text.push(Line::from(vec![ + Span::styled( + format!("{}{}{}", prefix, display_url, count_suffix), + if is_selected { selected_suggestion_style } else { suggestion_style }, + ), + ])); + } + } else if state.suggestions_loaded && state.repo_suggestions.is_empty() { + // Show message when suggestions loaded but empty + text.push(Line::from("")); + text.push(Line::from(Span::styled( + " (No recent repositories - add repos to contracts to see suggestions here)", + hint_style, + ))); + } + + text.push(Line::from("")); + + // Help line - show different help when suggestions are visible + if show_suggestions { + text.push(Line::from(vec![ + Span::styled(" ↑/↓", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(": select "), + Span::styled("Enter", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(": apply "), + Span::styled("Tab", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw(": next field "), + Span::styled("Esc", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw(": cancel"), + ])); + } else { + text.push(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)), @@ -497,8 +564,8 @@ fn render_create_dialog(frame: &mut Frame, app: &App) { 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() |
