summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-21 15:58:34 +0000
committersoryu <soryu@soryu.co>2026-01-21 15:58:34 +0000
commitda246c4c4e23c9ad976705f9a3fa80e0d75b4425 (patch)
treeddc3b93ed269e60dac1aa9113000daeac4a1b6e6
parent7155e6cd7ddf25a5a4d4f6d6abecd49f0cf519dc (diff)
downloadsoryu-da246c4c4e23c9ad976705f9a3fa80e0d75b4425.tar.gz
soryu-da246c4c4e23c9ad976705f9a3fa80e0d75b4425.zip
Update CLI to show repo suggestions
-rw-r--r--makima/src/bin/makima.rs69
-rw-r--r--makima/src/daemon/api/contract.rs23
-rw-r--r--makima/src/daemon/tui/app.rs85
-rw-r--r--makima/src/daemon/tui/event.rs24
-rw-r--r--makima/src/daemon/tui/mod.rs2
-rw-r--r--makima/src/daemon/tui/ui.rs87
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()