Skip to main content

rmcp_memex/tui/
app.rs

1//! TUI Wizard Application Logic
2//!
3//! Main application state and step management for the configuration wizard.
4//! Implements the new wizard flow with EmbedderSetup as the first configuration step.
5
6use crate::embeddings::{
7    DEFAULT_REQUIRED_DIMENSION, EmbeddingConfig, ProviderConfig, infer_embedding_dimension,
8};
9use crate::tui::detection::{
10    DetectedProvider, ProviderKind, check_health, detect_providers, dimension_explanation,
11};
12use crate::tui::health::{HealthCheckResult, HealthChecker};
13use crate::tui::host_detection::{
14    ExtendedHostKind, HostDetection, detect_extended_hosts, generate_extended_snippet,
15    write_extended_host_config,
16};
17use crate::tui::indexer::{
18    DataSetupOption, DataSetupState, DataSetupSubStep, ImportMode, IndexProgress, import_lancedb,
19    start_indexing,
20};
21use anyhow::{Result, anyhow};
22use crossterm::ExecutableCommand;
23use crossterm::event::{self, Event, KeyCode, KeyEventKind};
24use crossterm::terminal::{
25    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
26};
27use ratatui::prelude::*;
28use std::io::{Stdout, stdout};
29use std::path::PathBuf;
30use std::time::Duration;
31use tokio::sync::mpsc;
32
33/// Configuration for running the wizard.
34#[derive(Debug, Clone, Default)]
35pub struct WizardConfig {
36    pub config_path: Option<String>,
37    pub dry_run: bool,
38}
39
40/// Wizard step enum - new flow with EmbedderSetup first.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum WizardStep {
43    Welcome,
44    EmbedderSetup,
45    MemexSettings,
46    HostSelection,
47    SnippetPreview,
48    HealthCheck,
49    DataSetup,
50    Summary,
51}
52
53impl WizardStep {
54    pub fn title(&self) -> &'static str {
55        match self {
56            WizardStep::Welcome => "Welcome",
57            WizardStep::EmbedderSetup => "Embedder Setup",
58            WizardStep::MemexSettings => "Database Setup",
59            WizardStep::HostSelection => "Host Selection",
60            WizardStep::SnippetPreview => "Config Preview",
61            WizardStep::HealthCheck => "Health Check",
62            WizardStep::DataSetup => "Data Setup",
63            WizardStep::Summary => "Summary & Write",
64        }
65    }
66
67    pub fn next(&self) -> Option<WizardStep> {
68        match self {
69            WizardStep::Welcome => Some(WizardStep::EmbedderSetup),
70            WizardStep::EmbedderSetup => Some(WizardStep::MemexSettings),
71            WizardStep::MemexSettings => Some(WizardStep::HostSelection),
72            WizardStep::HostSelection => Some(WizardStep::SnippetPreview),
73            WizardStep::SnippetPreview => Some(WizardStep::HealthCheck),
74            WizardStep::HealthCheck => Some(WizardStep::DataSetup),
75            WizardStep::DataSetup => Some(WizardStep::Summary),
76            WizardStep::Summary => None,
77        }
78    }
79
80    pub fn prev(&self) -> Option<WizardStep> {
81        match self {
82            WizardStep::Welcome => None,
83            WizardStep::EmbedderSetup => Some(WizardStep::Welcome),
84            WizardStep::MemexSettings => Some(WizardStep::EmbedderSetup),
85            WizardStep::HostSelection => Some(WizardStep::MemexSettings),
86            WizardStep::SnippetPreview => Some(WizardStep::HostSelection),
87            WizardStep::HealthCheck => Some(WizardStep::SnippetPreview),
88            WizardStep::DataSetup => Some(WizardStep::HealthCheck),
89            WizardStep::Summary => Some(WizardStep::DataSetup),
90        }
91    }
92
93    pub fn step_number(&self) -> usize {
94        match self {
95            WizardStep::Welcome => 1,
96            WizardStep::EmbedderSetup => 2,
97            WizardStep::MemexSettings => 3,
98            WizardStep::HostSelection => 4,
99            WizardStep::SnippetPreview => 5,
100            WizardStep::HealthCheck => 6,
101            WizardStep::DataSetup => 7,
102            WizardStep::Summary => 8,
103        }
104    }
105
106    pub fn total_steps() -> usize {
107        8
108    }
109}
110
111/// Embedder configuration state for the wizard.
112#[derive(Debug, Clone)]
113pub struct EmbedderState {
114    /// Detected embedding providers from auto-detection
115    pub detected_providers: Vec<DetectedProvider>,
116    /// Whether detection is currently running
117    pub detecting: bool,
118    /// Selected provider (from detection or manual)
119    pub selected_provider: Option<DetectedProvider>,
120    /// Manual base URL (if configuring manually)
121    pub manual_url: String,
122    /// Manual model name
123    pub manual_model: String,
124    /// Required embedding dimension
125    pub dimension: usize,
126    /// Whether to use manual configuration instead of detected
127    pub use_manual: bool,
128}
129
130impl Default for EmbedderState {
131    fn default() -> Self {
132        Self {
133            detected_providers: Vec::new(),
134            detecting: false,
135            selected_provider: None,
136            manual_url: "http://localhost:11434".to_string(),
137            manual_model: String::new(),
138            dimension: DEFAULT_REQUIRED_DIMENSION,
139            use_manual: false,
140        }
141    }
142}
143
144impl EmbedderState {
145    pub fn selected_model(&self) -> Option<String> {
146        if self.use_manual {
147            let model = self.manual_model.trim();
148            if model.is_empty() {
149                None
150            } else {
151                Some(model.to_string())
152            }
153        } else if let Some(ref detected) = self.selected_provider {
154            detected
155                .model()
156                .map(str::trim)
157                .filter(|m| !m.is_empty())
158                .map(ToOwned::to_owned)
159        } else {
160            None
161        }
162    }
163
164    /// Get dimension explanation text
165    pub fn dimension_hint(&self) -> &'static str {
166        dimension_explanation(self.dimension)
167    }
168
169    /// Update embedding config from state
170    pub fn build_embedding_config(&self) -> EmbeddingConfig {
171        let provider = if self.use_manual {
172            ProviderConfig {
173                name: "manual".to_string(),
174                base_url: self.manual_url.clone(),
175                model: self.manual_model.clone(),
176                priority: 1,
177                ..Default::default()
178            }
179        } else if let Some(ref detected) = self.selected_provider {
180            ProviderConfig {
181                name: match detected.kind {
182                    ProviderKind::Ollama => "ollama-local".to_string(),
183                    ProviderKind::Mlx => "mlx-local".to_string(),
184                    ProviderKind::OpenAICompat => "openai-compat".to_string(),
185                    ProviderKind::Manual => "manual".to_string(),
186                },
187                base_url: detected.base_url.clone(),
188                model: detected.model().unwrap_or("unknown").to_string(),
189                priority: 1,
190                ..Default::default()
191            }
192        } else {
193            // Fallback default
194            ProviderConfig {
195                name: "ollama-local".to_string(),
196                base_url: "http://localhost:11434".to_string(),
197                model: self.selected_model().unwrap_or_default(),
198                priority: 1,
199                ..Default::default()
200            }
201        };
202
203        EmbeddingConfig {
204            required_dimension: self.dimension,
205            providers: vec![provider],
206            ..Default::default()
207        }
208    }
209}
210
211/// Get current hostname (machine-agnostic)
212fn get_hostname() -> String {
213    // Try gethostname syscall first
214    if let Some(name) = std::process::Command::new("hostname")
215        .arg("-s") // short name without domain
216        .output()
217        .ok()
218        .filter(|o| o.status.success())
219    {
220        let hostname = String::from_utf8_lossy(&name.stdout).trim().to_string();
221        if !hostname.is_empty() {
222            return hostname;
223        }
224    }
225
226    // Fallback to environment variables
227    std::env::var("HOSTNAME")
228        .or_else(|_| std::env::var("COMPUTERNAME"))
229        .unwrap_or_else(|_| "local".to_string())
230}
231
232/// Database path mode for multi-host setups
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum DbPathMode {
235    /// Single shared path (e.g., ~/.ai-memories/lancedb)
236    Shared,
237    /// Per-host path with hostname suffix (e.g., ~/.ai-memories/lancedb.dragon)
238    PerHost,
239}
240
241/// Editable memex configuration.
242#[derive(Debug, Clone)]
243pub struct MemexCfg {
244    pub db_path: String,
245    pub cache_mb: usize,
246    pub log_level: String,
247    pub max_request_bytes: usize,
248    /// Current machine hostname (auto-detected)
249    pub hostname: String,
250    /// Database path mode (shared vs per-host)
251    pub db_path_mode: DbPathMode,
252    /// HTTP/SSE server port (None = disabled, Some(port) = enabled)
253    pub http_port: Option<u16>,
254}
255
256impl Default for MemexCfg {
257    fn default() -> Self {
258        let hostname = get_hostname();
259        Self {
260            // New default path per requirements
261            db_path: "~/.ai-memories/lancedb".to_string(),
262            cache_mb: 4096,
263            log_level: "info".to_string(),
264            max_request_bytes: 10 * 1024 * 1024, // 10MB
265            hostname,
266            db_path_mode: DbPathMode::Shared,
267            http_port: None,
268        }
269    }
270}
271
272impl MemexCfg {
273    /// Get the effective database path (with hostname suffix if per-host mode)
274    pub fn resolved_db_path(&self) -> String {
275        match self.db_path_mode {
276            DbPathMode::Shared => self.db_path.clone(),
277            DbPathMode::PerHost => format!("{}.{}", self.db_path, self.hostname),
278        }
279    }
280}
281
282/// Main application state.
283pub struct App {
284    pub step: WizardStep,
285    pub memex_cfg: MemexCfg,
286    /// Embedder configuration state (new EmbedderSetup step)
287    pub embedder_state: EmbedderState,
288    /// Derived embedding config (updated from embedder_state)
289    pub embedding_config: EmbeddingConfig,
290    /// Extended hosts with their kind and detection info
291    pub hosts: Vec<(ExtendedHostKind, HostDetection)>,
292    pub selected_hosts: Vec<usize>,
293    pub dry_run: bool,
294    pub messages: Vec<String>,
295    pub focus: usize,
296    pub binary_path: String,
297    pub health_status: Option<String>,
298    pub should_quit: bool,
299    pub input_mode: bool,
300    pub input_buffer: String,
301    pub editing_field: Option<usize>,
302    /// Enhanced health check result
303    pub health_result: Option<HealthCheckResult>,
304    /// Whether health check is currently running
305    pub health_running: bool,
306    /// Data setup state
307    pub data_setup: DataSetupState,
308    /// Progress receiver for indexing operations
309    pub index_progress_rx: Option<mpsc::Receiver<IndexProgress>>,
310    /// Whether rmcp-memex config has been written
311    pub config_written: bool,
312}
313
314impl App {
315    pub fn new(config: WizardConfig) -> Self {
316        let hosts = detect_extended_hosts();
317        let binary_path = which_rmcp_memex().unwrap_or_else(|| "rmcp-memex".to_string());
318        let embedder_state = EmbedderState::default();
319        let embedding_config = embedder_state.build_embedding_config();
320
321        Self {
322            step: WizardStep::Welcome,
323            memex_cfg: MemexCfg::default(),
324            embedder_state,
325            embedding_config,
326            hosts,
327            selected_hosts: Vec::new(),
328            dry_run: config.dry_run,
329            messages: Vec::new(),
330            focus: 0,
331            binary_path,
332            health_status: None,
333            should_quit: false,
334            input_mode: false,
335            input_buffer: String::new(),
336            editing_field: None,
337            health_result: None,
338            health_running: false,
339            data_setup: DataSetupState::new(),
340            index_progress_rx: None,
341            config_written: false,
342        }
343    }
344
345    pub fn next_step(&mut self) {
346        if let Some(next) = self.step.next() {
347            // On leaving EmbedderSetup, update the embedding config
348            if self.step == WizardStep::EmbedderSetup {
349                self.embedding_config = self.embedder_state.build_embedding_config();
350            }
351            self.step = next;
352            self.focus = 0;
353            self.input_mode = false;
354            self.editing_field = None;
355
356            // Trigger actions on entering specific steps
357            if self.step == WizardStep::EmbedderSetup
358                && self.embedder_state.detected_providers.is_empty()
359            {
360                self.embedder_state.detecting = true;
361            }
362
363            // Auto-trigger health check when entering HealthCheck step
364            if self.step == WizardStep::HealthCheck && !self.health_running {
365                self.run_health_check();
366                self.trigger_health_check();
367            }
368        }
369    }
370
371    pub fn prev_step(&mut self) {
372        if let Some(prev) = self.step.prev() {
373            self.step = prev;
374            self.focus = 0;
375        }
376    }
377
378    pub fn toggle_host(&mut self, idx: usize) {
379        if self.selected_hosts.contains(&idx) {
380            self.selected_hosts.retain(|&i| i != idx);
381        } else {
382            self.selected_hosts.push(idx);
383        }
384    }
385
386    pub fn get_selected_hosts(&self) -> Vec<&(ExtendedHostKind, HostDetection)> {
387        self.selected_hosts
388            .iter()
389            .filter_map(|&i| self.hosts.get(i))
390            .collect()
391    }
392
393    pub fn generate_snippets(&self) -> Vec<(ExtendedHostKind, String)> {
394        let effective_path = self.memex_cfg.resolved_db_path();
395        self.get_selected_hosts()
396            .iter()
397            .map(|(kind, _detection)| {
398                let mut snippet =
399                    generate_extended_snippet(*kind, &self.binary_path, &effective_path);
400                // Add HTTP port arg if configured
401                if let Some(port) = self.memex_cfg.http_port {
402                    snippet = snippet.replace(
403                        "\"serve\"",
404                        &format!("\"serve\", \"--http-port\", \"{}\"", port),
405                    );
406                }
407                (*kind, snippet)
408            })
409            .collect()
410    }
411
412    pub fn run_health_check(&mut self) {
413        self.health_status = Some("Checking...".to_string());
414
415        // Prefer the canonical binary name, but allow the legacy alias when present.
416        match std::process::Command::new(&self.binary_path)
417            .arg("--version")
418            .output()
419        {
420            Ok(output) => {
421                if output.status.success() {
422                    let version = String::from_utf8_lossy(&output.stdout);
423                    self.health_status = Some(format!("[OK] Binary OK: {}", version.trim()));
424                } else {
425                    self.health_status = Some("[ERR] Binary found but failed to run".to_string());
426                }
427            }
428            Err(e) => {
429                self.health_status = Some(format!("[ERR] Binary not found: {}", e));
430            }
431        }
432
433        // Show hostname info
434        self.messages.push(format!(
435            "[INFO] Host: {} (path mode: {:?})",
436            self.memex_cfg.hostname, self.memex_cfg.db_path_mode
437        ));
438
439        // Check db_path (use effective path)
440        let effective_path = self.memex_cfg.resolved_db_path();
441        let expanded_path = shellexpand::tilde(&effective_path).to_string();
442        let db_path = PathBuf::from(&expanded_path);
443        if db_path.exists() {
444            self.messages
445                .push(format!("[OK] DB path exists: {}", expanded_path));
446        } else {
447            self.messages
448                .push(format!("[-] DB path will be created: {}", expanded_path));
449        }
450
451        // Show HTTP port info
452        if let Some(port) = self.memex_cfg.http_port {
453            self.messages
454                .push(format!("[INFO] HTTP/SSE server will run on port {}", port));
455        }
456    }
457
458    pub fn write_configs(&mut self) -> Result<()> {
459        let effective_path = self.memex_cfg.resolved_db_path();
460
461        if self.dry_run {
462            self.messages.push("DRY RUN: No files written".to_string());
463            self.messages.push(format!(
464                "Host: {} | Path mode: {:?}",
465                self.memex_cfg.hostname, self.memex_cfg.db_path_mode
466            ));
467            for &idx in &self.selected_hosts.clone() {
468                if let Some((kind, detection)) = self.hosts.get(idx) {
469                    let snippet =
470                        generate_extended_snippet(*kind, &self.binary_path, &effective_path);
471                    self.messages.push(format!(
472                        "Would write to {} ({}):\n{}",
473                        kind.label(),
474                        detection.path.display(),
475                        snippet
476                    ));
477                }
478            }
479            return Ok(());
480        }
481
482        let mut success_count = 0;
483        let mut error_count = 0;
484
485        for &idx in &self.selected_hosts.clone() {
486            if let Some((kind, _detection)) = self.hosts.get(idx) {
487                match write_extended_host_config(*kind, &self.binary_path, &effective_path) {
488                    Ok(result) => {
489                        success_count += 1;
490                        if let Some(backup) = result.backup_path {
491                            self.messages.push(format!(
492                                "[OK] {} backup: {}",
493                                result.host_name,
494                                backup.display()
495                            ));
496                        }
497                        if result.created {
498                            self.messages.push(format!(
499                                "[OK] {} created: {}",
500                                result.host_name,
501                                result.config_path.display()
502                            ));
503                        } else {
504                            self.messages.push(format!(
505                                "[OK] {} updated: {}",
506                                result.host_name,
507                                result.config_path.display()
508                            ));
509                        }
510                    }
511                    Err(e) => {
512                        error_count += 1;
513                        self.messages
514                            .push(format!("[ERR] {} failed: {}", kind.label(), e));
515                    }
516                }
517            }
518        }
519
520        if success_count > 0 {
521            self.messages.push(format!(
522                "\nConfiguration complete! {} host(s) configured.",
523                success_count
524            ));
525        }
526        if error_count > 0 {
527            self.messages.push(format!(
528                "Warning: {} host(s) failed to configure.",
529                error_count
530            ));
531        }
532
533        Ok(())
534    }
535
536    fn settings_field_count(&self) -> usize {
537        6 // db_path, db_path_mode, http_port, cache_mb, log_level, max_request_bytes
538    }
539
540    pub fn get_field_value(&self, field: usize) -> String {
541        match field {
542            0 => self.memex_cfg.db_path.clone(),
543            1 => match self.memex_cfg.db_path_mode {
544                DbPathMode::Shared => "shared".to_string(),
545                DbPathMode::PerHost => format!("per-host ({})", self.memex_cfg.hostname),
546            },
547            2 => match self.memex_cfg.http_port {
548                Some(port) => port.to_string(),
549                None => "disabled".to_string(),
550            },
551            3 => self.memex_cfg.cache_mb.to_string(),
552            4 => self.memex_cfg.log_level.clone(),
553            5 => self.memex_cfg.max_request_bytes.to_string(),
554            _ => String::new(),
555        }
556    }
557
558    pub fn set_field_value(&mut self, field: usize, value: String) {
559        match field {
560            0 => self.memex_cfg.db_path = value,
561            1 => {
562                // Toggle between shared and per-host
563                self.memex_cfg.db_path_mode = match self.memex_cfg.db_path_mode {
564                    DbPathMode::Shared => DbPathMode::PerHost,
565                    DbPathMode::PerHost => DbPathMode::Shared,
566                };
567            }
568            2 => {
569                // Parse port or disable
570                if value.to_lowercase() == "disabled" || value.is_empty() {
571                    self.memex_cfg.http_port = None;
572                } else if let Ok(port) = value.parse() {
573                    self.memex_cfg.http_port = Some(port);
574                }
575            }
576            3 => {
577                if let Ok(v) = value.parse() {
578                    self.memex_cfg.cache_mb = v;
579                }
580            }
581            4 => self.memex_cfg.log_level = value,
582            5 => {
583                if let Ok(v) = value.parse() {
584                    self.memex_cfg.max_request_bytes = v;
585                }
586            }
587            _ => {}
588        }
589    }
590
591    pub fn handle_key(&mut self, key: KeyCode) {
592        // Handle input mode for settings and data setup
593        if self.input_mode || self.data_setup.input_mode {
594            self.handle_input_key(key);
595            return;
596        }
597
598        match key {
599            KeyCode::Char('q') => self.should_quit = true,
600            KeyCode::Esc => {
601                if self.step != WizardStep::Welcome {
602                    self.prev_step();
603                } else {
604                    self.should_quit = true;
605                }
606            }
607            KeyCode::Enter | KeyCode::Tab => self.handle_enter(),
608            KeyCode::Right | KeyCode::Char('n') => self.handle_next(),
609            KeyCode::Left | KeyCode::Char('p') => self.prev_step(),
610            KeyCode::Up | KeyCode::Char('k') => self.handle_up(),
611            KeyCode::Down | KeyCode::Char('j') => self.handle_down(),
612            KeyCode::Char(' ') => self.handle_space(),
613            KeyCode::Char('r') => {
614                // Retry health check
615                if self.step == WizardStep::HealthCheck && !self.health_running {
616                    self.trigger_health_check();
617                }
618            }
619            _ => {}
620        }
621    }
622
623    fn handle_input_key(&mut self, key: KeyCode) {
624        // Handle data setup input mode
625        if self.data_setup.input_mode {
626            match key {
627                KeyCode::Enter => {
628                    match self.data_setup.sub_step {
629                        DataSetupSubStep::EnterPath => {
630                            self.data_setup.confirm_path();
631                        }
632                        DataSetupSubStep::EnterNamespace => {
633                            self.data_setup.confirm_namespace();
634                            // Start indexing
635                            if self.data_setup.is_indexing() {
636                                self.start_indexing_task();
637                            }
638                        }
639                        _ => {}
640                    }
641                }
642                KeyCode::Esc => {
643                    self.data_setup.input_mode = false;
644                    self.data_setup.input_buffer.clear();
645                    self.data_setup.sub_step = DataSetupSubStep::SelectOption;
646                }
647                KeyCode::Backspace => {
648                    self.data_setup.input_buffer.pop();
649                }
650                KeyCode::Char(c) => {
651                    self.data_setup.input_buffer.push(c);
652                }
653                _ => {}
654            }
655            return;
656        }
657
658        // Handle settings or embedder input mode
659        if self.input_mode {
660            match key {
661                KeyCode::Enter => {
662                    if let Some(field) = self.editing_field {
663                        // Handle embedder setup fields
664                        if self.step == WizardStep::EmbedderSetup && self.embedder_state.use_manual
665                        {
666                            match field {
667                                0 => self.embedder_state.manual_url = self.input_buffer.clone(),
668                                1 => {
669                                    self.embedder_state.manual_model = self.input_buffer.clone();
670                                    if let Some(dim) =
671                                        infer_embedding_dimension(&self.embedder_state.manual_model)
672                                    {
673                                        self.embedder_state.dimension = dim;
674                                    }
675                                }
676                                2 => {
677                                    if let Ok(dim) = self.input_buffer.parse() {
678                                        self.embedder_state.dimension = dim;
679                                    }
680                                }
681                                _ => {}
682                            }
683                        } else {
684                            self.set_field_value(field, self.input_buffer.clone());
685                        }
686                    }
687                    self.input_mode = false;
688                    self.editing_field = None;
689                    self.input_buffer.clear();
690                }
691                KeyCode::Esc => {
692                    // In manual embedder mode, go back to provider selection
693                    if self.step == WizardStep::EmbedderSetup && self.embedder_state.use_manual {
694                        self.embedder_state.use_manual = false;
695                        self.focus = 0;
696                    }
697                    self.input_mode = false;
698                    self.editing_field = None;
699                    self.input_buffer.clear();
700                }
701                KeyCode::Backspace => {
702                    self.input_buffer.pop();
703                }
704                KeyCode::Char(c) => {
705                    self.input_buffer.push(c);
706                }
707                _ => {}
708            }
709        }
710    }
711
712    fn handle_enter(&mut self) {
713        match self.step {
714            WizardStep::EmbedderSetup => {
715                self.handle_embedder_setup_enter();
716            }
717            WizardStep::MemexSettings => {
718                // Enter edit mode for current field
719                self.input_mode = true;
720                self.editing_field = Some(self.focus);
721                self.input_buffer = self.get_field_value(self.focus);
722            }
723            WizardStep::HostSelection => {
724                if self.focus < self.hosts.len() {
725                    self.toggle_host(self.focus);
726                }
727            }
728            WizardStep::HealthCheck => {
729                if !self.health_running {
730                    self.trigger_health_check();
731                }
732            }
733            WizardStep::DataSetup => {
734                self.handle_data_setup_enter();
735            }
736            WizardStep::Summary => {
737                // First write rmcp-memex config, then write host configs
738                if !self.config_written
739                    && let Err(e) = self.write_memex_config()
740                {
741                    self.messages.push(format!("[ERR] {}", e));
742                }
743                // Also write host configs
744                if let Err(e) = self.write_configs() {
745                    self.messages.push(format!("[ERR] {}", e));
746                }
747            }
748            _ => {}
749        }
750    }
751
752    fn handle_embedder_setup_enter(&mut self) {
753        if self.embedder_state.use_manual {
754            // In manual mode, edit the focused field
755            self.input_mode = true;
756            self.editing_field = Some(self.focus);
757            self.input_buffer = match self.focus {
758                0 => self.embedder_state.manual_url.clone(),
759                1 => self.embedder_state.manual_model.clone(),
760                2 => self.embedder_state.dimension.to_string(),
761                _ => String::new(),
762            };
763        } else if self.focus < self.embedder_state.detected_providers.len() {
764            // Select a detected provider
765            let provider = self.embedder_state.detected_providers[self.focus].clone();
766            self.embedder_state.dimension = provider
767                .inferred_dimension()
768                .unwrap_or(self.embedder_state.dimension);
769            self.embedder_state.selected_provider = Some(provider);
770        } else {
771            // Switch to manual configuration (last option)
772            self.embedder_state.use_manual = true;
773            self.focus = 0;
774        }
775    }
776
777    fn handle_data_setup_enter(&mut self) {
778        match self.data_setup.sub_step {
779            DataSetupSubStep::SelectOption => {
780                self.data_setup.select_focused();
781            }
782            DataSetupSubStep::SelectImportMode => {
783                let modes = ImportMode::all();
784                if let Some(mode) = modes.get(self.data_setup.focus).cloned() {
785                    self.data_setup.select_import_mode(mode);
786                    // If import mode is selected, perform the import
787                    if self.data_setup.is_done()
788                        && self.data_setup.option == DataSetupOption::ImportLanceDB
789                    {
790                        self.perform_import();
791                    }
792                }
793            }
794            _ => {}
795        }
796    }
797
798    fn handle_next(&mut self) {
799        // For DataSetup, only proceed if complete or skip
800        if self.step == WizardStep::DataSetup {
801            if self.data_setup.is_done() || self.data_setup.option == DataSetupOption::Skip {
802                self.next_step();
803            }
804        } else if self.step == WizardStep::HealthCheck {
805            // Allow proceeding even if health check failed (with warning)
806            self.next_step();
807        } else {
808            self.next_step();
809        }
810    }
811
812    fn handle_up(&mut self) {
813        if self.focus > 0 {
814            self.focus -= 1;
815        }
816        // Sync focus with data setup
817        if self.step == WizardStep::DataSetup {
818            self.data_setup.focus = self.focus;
819        }
820    }
821
822    fn handle_down(&mut self) {
823        let max = self.get_max_focus();
824        if self.focus < max {
825            self.focus += 1;
826        }
827        // Sync focus with data setup
828        if self.step == WizardStep::DataSetup {
829            self.data_setup.focus = self.focus;
830        }
831    }
832
833    fn handle_space(&mut self) {
834        if self.step == WizardStep::HostSelection && self.focus < self.hosts.len() {
835            self.toggle_host(self.focus);
836        }
837    }
838
839    fn get_max_focus(&self) -> usize {
840        match self.step {
841            WizardStep::EmbedderSetup => {
842                if self.embedder_state.use_manual {
843                    2 // URL, model, dimension
844                } else {
845                    // providers + manual option
846                    self.embedder_state.detected_providers.len()
847                }
848            }
849            WizardStep::MemexSettings => self.settings_field_count().saturating_sub(1),
850            WizardStep::HostSelection => self.hosts.len().saturating_sub(1),
851            WizardStep::DataSetup => match self.data_setup.sub_step {
852                DataSetupSubStep::SelectOption => DataSetupOption::all().len().saturating_sub(1),
853                DataSetupSubStep::SelectImportMode => ImportMode::all().len().saturating_sub(1),
854                _ => 0,
855            },
856            _ => 0,
857        }
858    }
859
860    /// Trigger the async health check
861    pub fn trigger_health_check(&mut self) {
862        self.health_running = true;
863        self.health_status = Some("Running health checks...".to_string());
864        self.messages.clear();
865
866        // Also run the old basic check for binary version
867        if let Ok(output) = std::process::Command::new(&self.binary_path)
868            .arg("--version")
869            .output()
870            && output.status.success()
871        {
872            let version = String::from_utf8_lossy(&output.stdout);
873            self.health_status = Some(format!("Binary: {} - Running checks...", version.trim()));
874        }
875    }
876
877    /// Run the async health check (called from event loop)
878    pub async fn run_async_health_check(&mut self) {
879        // Quick connectivity check for selected provider
880        if let Some(ref provider) = self.embedder_state.selected_provider {
881            let url = format!("{}/v1/models", provider.base_url);
882            if check_health(&url).await {
883                self.messages
884                    .push(format!("[OK] Provider {} is reachable", provider.base_url));
885            } else {
886                self.messages.push(format!(
887                    "[WARN] Provider {} may be offline",
888                    provider.base_url
889                ));
890            }
891        }
892
893        let checker = HealthChecker::new();
894        let result = checker
895            .run_all(&self.embedding_config, &self.memex_cfg.db_path)
896            .await;
897
898        self.health_result = Some(result.clone());
899        self.health_running = false;
900
901        // Update status based on results
902        if result.all_passed() {
903            self.health_status = Some("All health checks passed!".to_string());
904        } else if result.any_failed() {
905            self.health_status =
906                Some("Some health checks failed. Review details below.".to_string());
907        } else {
908            self.health_status = Some("Health checks complete.".to_string());
909        }
910    }
911
912    /// Start the indexing task
913    fn start_indexing_task(&mut self) {
914        if let Some(ref source_path) = self.data_setup.source_path
915            && let Some(ref namespace) = self.data_setup.namespace
916        {
917            let path = PathBuf::from(shellexpand::tilde(source_path).to_string());
918            let rx = start_indexing(
919                path,
920                namespace.clone(),
921                self.embedding_config.clone(),
922                self.memex_cfg.db_path.clone(),
923            );
924            self.index_progress_rx = Some(rx);
925        }
926    }
927
928    /// Perform LanceDB import
929    fn perform_import(&mut self) {
930        if let Some(ref source_path) = self.data_setup.source_path {
931            let source = PathBuf::from(shellexpand::tilde(source_path).to_string());
932            let target = PathBuf::from(shellexpand::tilde(&self.memex_cfg.db_path).to_string());
933
934            // Run import synchronously for now (it's mostly IO)
935            let rt = tokio::runtime::Handle::try_current();
936            if let Ok(handle) = rt {
937                let mode = self.data_setup.import_mode.clone();
938                let result = tokio::task::block_in_place(|| {
939                    handle.block_on(import_lancedb(&source, &target, mode))
940                });
941                match result {
942                    Ok(msg) => {
943                        self.messages.push(format!("[OK] {}", msg));
944                    }
945                    Err(e) => {
946                        self.messages.push(format!("[ERR] Import failed: {}", e));
947                    }
948                }
949            } else {
950                // Fallback for non-async context
951                self.messages
952                    .push("[INFO] Import will use config path directly".to_string());
953            }
954        }
955    }
956
957    /// Check for indexing progress updates
958    pub fn poll_index_progress(&mut self) {
959        if let Some(ref mut rx) = self.index_progress_rx {
960            while let Ok(progress) = rx.try_recv() {
961                self.data_setup.progress = Some(progress.clone());
962                if progress.complete {
963                    if let Some(ref error) = progress.error {
964                        self.messages
965                            .push(format!("[ERR] Indexing failed: {}", error));
966                    } else {
967                        self.messages.push(format!(
968                            "[OK] Indexed {} files ({} skipped)",
969                            progress.processed - progress.skipped,
970                            progress.skipped
971                        ));
972                    }
973                    self.data_setup.sub_step = DataSetupSubStep::Complete;
974                    self.index_progress_rx = None;
975                    break;
976                }
977            }
978        }
979    }
980
981    /// Run provider detection asynchronously
982    pub async fn run_provider_detection(&mut self) {
983        if self.embedder_state.detecting {
984            self.embedder_state.detected_providers = detect_providers().await;
985            self.embedder_state.detecting = false;
986
987            // Auto-select first usable provider
988            if let Some(provider) = self
989                .embedder_state
990                .detected_providers
991                .iter()
992                .find(|p| p.is_usable())
993            {
994                self.embedder_state.selected_provider = Some(provider.clone());
995                self.embedder_state.dimension = provider
996                    .inferred_dimension()
997                    .unwrap_or(self.embedder_state.dimension);
998            }
999        }
1000    }
1001
1002    /// Generate the complete config TOML for rmcp-memex
1003    pub fn generate_config_toml(&self) -> String {
1004        const MODEL_PLACEHOLDER: &str = "<set-your-embedding-model>";
1005        let mut toml = String::new();
1006
1007        // Header
1008        toml.push_str("# rmcp-memex configuration\n");
1009        toml.push_str(&format!(
1010            "# Generated by wizard on host: {}\n",
1011            self.memex_cfg.hostname
1012        ));
1013        toml.push_str(&format!(
1014            "# Path mode: {:?}\n\n",
1015            self.memex_cfg.db_path_mode
1016        ));
1017
1018        // Database settings (use effective path which includes hostname suffix if per-host)
1019        toml.push_str("# Database configuration\n");
1020        toml.push_str(&format!(
1021            "db_path = \"{}\"\n",
1022            self.memex_cfg.resolved_db_path()
1023        ));
1024        toml.push_str(&format!("cache_mb = {}\n", self.memex_cfg.cache_mb));
1025        toml.push_str(&format!("log_level = \"{}\"\n", self.memex_cfg.log_level));
1026        toml.push_str(&format!(
1027            "max_request_bytes = {}\n",
1028            self.memex_cfg.max_request_bytes
1029        ));
1030
1031        // HTTP/SSE server configuration
1032        if let Some(port) = self.memex_cfg.http_port {
1033            toml.push_str("\n# HTTP/SSE server for multi-agent access\n");
1034            toml.push_str(&format!("http_port = {}\n", port));
1035        }
1036        toml.push('\n');
1037
1038        // Embeddings configuration
1039        toml.push_str("# Embedding provider configuration\n");
1040        toml.push_str("[embeddings]\n");
1041        toml.push_str(&format!(
1042            "required_dimension = {}\n\n",
1043            self.embedder_state.dimension
1044        ));
1045
1046        // Provider
1047        toml.push_str("[[embeddings.providers]]\n");
1048        if self.embedder_state.use_manual {
1049            toml.push_str("name = \"manual\"\n");
1050            toml.push_str(&format!(
1051                "base_url = \"{}\"\n",
1052                self.embedder_state.manual_url
1053            ));
1054            toml.push_str(&format!(
1055                "model = \"{}\"\n",
1056                self.embedder_state
1057                    .selected_model()
1058                    .unwrap_or_else(|| MODEL_PLACEHOLDER.to_string())
1059            ));
1060        } else if let Some(ref provider) = self.embedder_state.selected_provider {
1061            let name = match provider.kind {
1062                ProviderKind::Ollama => "ollama-local",
1063                ProviderKind::Mlx => "mlx-local",
1064                ProviderKind::OpenAICompat => "openai-compat",
1065                ProviderKind::Manual => "manual",
1066            };
1067            toml.push_str(&format!("name = \"{}\"\n", name));
1068            toml.push_str(&format!("base_url = \"{}\"\n", provider.base_url));
1069            toml.push_str(&format!(
1070                "model = \"{}\"\n",
1071                provider.model().unwrap_or(MODEL_PLACEHOLDER)
1072            ));
1073        } else {
1074            // No provider selected yet: write an explicit placeholder instead of a false default.
1075            toml.push_str("name = \"ollama-local\"\n");
1076            toml.push_str("base_url = \"http://localhost:11434\"\n");
1077            toml.push_str(&format!("model = \"{}\"\n", MODEL_PLACEHOLDER));
1078        }
1079        toml.push_str("priority = 1\n");
1080        toml.push_str("endpoint = \"/v1/embeddings\"\n");
1081
1082        toml
1083    }
1084
1085    /// Write rmcp-memex config file to disk
1086    pub fn write_memex_config(&mut self) -> Result<()> {
1087        if self.embedder_state.selected_model().is_none() {
1088            return Err(anyhow!(
1089                "No embedding model selected. Pick a detected provider or enter a manual model before writing config."
1090            ));
1091        }
1092
1093        if self.dry_run {
1094            self.messages
1095                .push("DRY RUN: Config would be written to:".to_string());
1096            self.messages
1097                .push("  ~/.rmcp-servers/rmcp-memex/config.toml".to_string());
1098            self.messages.push(String::new());
1099            self.messages.push("Generated config:".to_string());
1100            self.messages.push("---".to_string());
1101            for line in self.generate_config_toml().lines() {
1102                self.messages.push(format!("  {}", line));
1103            }
1104            self.messages.push("---".to_string());
1105            self.config_written = true;
1106            return Ok(());
1107        }
1108
1109        // Create config directory
1110        let config_dir = shellexpand::tilde("~/.rmcp-servers/rmcp-memex").to_string();
1111        let config_path = format!("{}/config.toml", config_dir);
1112
1113        std::fs::create_dir_all(&config_dir)?;
1114
1115        // Backup existing config if present
1116        let config_file = PathBuf::from(&config_path);
1117        if config_file.exists() {
1118            let backup_path = format!("{}.bak.{}", config_path, timestamp());
1119            std::fs::copy(&config_file, &backup_path)?;
1120            self.messages
1121                .push(format!("[OK] Backup created: {}", backup_path));
1122        }
1123
1124        // Write new config
1125        let toml_content = self.generate_config_toml();
1126        std::fs::write(&config_path, &toml_content)?;
1127        self.messages
1128            .push(format!("[OK] Config written: {}", config_path));
1129
1130        // Create database directory if needed
1131        let db_path = shellexpand::tilde(&self.memex_cfg.db_path).to_string();
1132        if let Some(parent) = PathBuf::from(&db_path).parent()
1133            && !parent.exists()
1134        {
1135            std::fs::create_dir_all(parent)?;
1136            self.messages
1137                .push(format!("[OK] Created directory: {}", parent.display()));
1138        }
1139
1140        self.config_written = true;
1141        self.messages.push(String::new());
1142        self.messages.push("Configuration complete!".to_string());
1143        self.messages
1144            .push("Run 'rmcp-memex serve' to start the server.".to_string());
1145
1146        Ok(())
1147    }
1148}
1149
1150fn timestamp() -> String {
1151    use std::time::{SystemTime, UNIX_EPOCH};
1152    let secs = SystemTime::now()
1153        .duration_since(UNIX_EPOCH)
1154        .unwrap_or_default()
1155        .as_secs();
1156    format!("{}", secs)
1157}
1158
1159fn which_rmcp_memex() -> Option<String> {
1160    ["rmcp-memex", "rmcp_memex"].into_iter().find_map(|binary| {
1161        std::process::Command::new("which")
1162            .arg(binary)
1163            .output()
1164            .ok()
1165            .filter(|output| output.status.success())
1166            .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
1167    })
1168}
1169
1170type Tui = Terminal<CrosstermBackend<Stdout>>;
1171
1172fn init_terminal() -> Result<Tui> {
1173    enable_raw_mode()?;
1174    stdout().execute(EnterAlternateScreen)?;
1175    let backend = CrosstermBackend::new(stdout());
1176    let terminal = Terminal::new(backend)?;
1177    Ok(terminal)
1178}
1179
1180fn restore_terminal() -> Result<()> {
1181    disable_raw_mode()?;
1182    stdout().execute(LeaveAlternateScreen)?;
1183    Ok(())
1184}
1185
1186/// Run the TUI wizard.
1187pub fn run_wizard(config: WizardConfig) -> Result<()> {
1188    let mut terminal = init_terminal()?;
1189    let mut app = App::new(config);
1190
1191    let result = run_app(&mut terminal, &mut app);
1192
1193    restore_terminal()?;
1194    result
1195}
1196
1197fn run_app(terminal: &mut Tui, app: &mut App) -> Result<()> {
1198    use crate::tui::ui::render;
1199
1200    // Get handle to existing runtime (from async main) or create new one
1201    let rt = match tokio::runtime::Handle::try_current() {
1202        Ok(handle) => handle,
1203        Err(_) => {
1204            // No runtime exists, create one (shouldn't happen with async main)
1205            let rt = tokio::runtime::Builder::new_current_thread()
1206                .enable_all()
1207                .build()?;
1208            // Leak to keep it alive - this is a fallback path
1209            Box::leak(Box::new(rt)).handle().clone()
1210        }
1211    };
1212
1213    loop {
1214        terminal.draw(|f| render(f, app))?;
1215
1216        // Poll for index progress updates
1217        app.poll_index_progress();
1218
1219        // Handle async provider detection
1220        if app.embedder_state.detecting {
1221            let rt_clone = rt.clone();
1222            tokio::task::block_in_place(|| {
1223                rt_clone.block_on(async {
1224                    app.run_provider_detection().await;
1225                });
1226            });
1227        }
1228
1229        // Handle async health check if triggered
1230        if app.health_running && app.health_result.is_none() {
1231            let rt_clone = rt.clone();
1232            tokio::task::block_in_place(|| {
1233                rt_clone.block_on(async {
1234                    app.run_async_health_check().await;
1235                });
1236            });
1237        }
1238
1239        if event::poll(Duration::from_millis(100))?
1240            && let Event::Key(key) = event::read()?
1241            && key.kind == KeyEventKind::Press
1242        {
1243            app.handle_key(key.code);
1244        }
1245
1246        if app.should_quit {
1247            break;
1248        }
1249    }
1250
1251    Ok(())
1252}