Skip to main content

rust_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, probe_provider_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    DEFAULT_MUX_CONFIG_PATH, DEFAULT_MUX_SERVICE_NAME, DEFAULT_MUX_SOCKET_PATH, ExtendedHostKind,
15    HostDetection, detect_extended_hosts, generate_extended_snippet, generate_extended_snippet_mux,
16    write_extended_host_config, write_extended_host_config_mux, write_mux_service_config,
17};
18use crate::tui::indexer::{
19    DataSetupOption, DataSetupState, DataSetupSubStep, FanOut, ImportMode, IndexControl,
20    IndexEventSink, IndexTelemetrySnapshot, IndexingJob, SharedIndexTelemetry, TracingSink,
21    TuiTelemetrySink, collect_indexable_files, import_lancedb, new_index_telemetry, start_indexing,
22    validate_path,
23};
24use crate::tui::monitor::{MonitorSnapshot, spawn_monitor};
25use anyhow::{Result, anyhow};
26use crossterm::ExecutableCommand;
27use crossterm::event::{self, Event, KeyCode, KeyEventKind};
28use crossterm::terminal::{
29    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
30};
31use ratatui::prelude::*;
32use reqwest::Client;
33use std::io::{Stdout, stdout};
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::time::Duration;
37use tokio::sync::{mpsc, watch};
38use tokio::task::JoinHandle;
39
40const DEFAULT_INDEX_PARALLELISM: usize = 4;
41const DEFAULT_MEMEX_CONFIG_PATH: &str = "~/.rmcp-servers/rust-memex/config.toml";
42
43/// Configuration for running the wizard.
44#[derive(Debug, Clone, Default)]
45pub struct WizardConfig {
46    pub config_path: Option<String>,
47    pub dry_run: bool,
48}
49
50/// Wizard step enum - new flow with EmbedderSetup first.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum WizardStep {
53    Welcome,
54    EmbedderSetup,
55    MemexSettings,
56    HostSelection,
57    SnippetPreview,
58    HealthCheck,
59    DataSetup,
60    Summary,
61}
62
63impl WizardStep {
64    pub fn title(&self) -> &'static str {
65        match self {
66            WizardStep::Welcome => "Welcome",
67            WizardStep::EmbedderSetup => "Embedder Setup",
68            WizardStep::MemexSettings => "Database Setup",
69            WizardStep::HostSelection => "Host Selection",
70            WizardStep::SnippetPreview => "Config Preview",
71            WizardStep::HealthCheck => "Health Check",
72            WizardStep::DataSetup => "Data Setup",
73            WizardStep::Summary => "Summary & Write",
74        }
75    }
76
77    pub fn next(&self) -> Option<WizardStep> {
78        match self {
79            WizardStep::Welcome => Some(WizardStep::EmbedderSetup),
80            WizardStep::EmbedderSetup => Some(WizardStep::MemexSettings),
81            WizardStep::MemexSettings => Some(WizardStep::HostSelection),
82            WizardStep::HostSelection => Some(WizardStep::SnippetPreview),
83            WizardStep::SnippetPreview => Some(WizardStep::HealthCheck),
84            WizardStep::HealthCheck => Some(WizardStep::DataSetup),
85            WizardStep::DataSetup => Some(WizardStep::Summary),
86            WizardStep::Summary => None,
87        }
88    }
89
90    pub fn prev(&self) -> Option<WizardStep> {
91        match self {
92            WizardStep::Welcome => None,
93            WizardStep::EmbedderSetup => Some(WizardStep::Welcome),
94            WizardStep::MemexSettings => Some(WizardStep::EmbedderSetup),
95            WizardStep::HostSelection => Some(WizardStep::MemexSettings),
96            WizardStep::SnippetPreview => Some(WizardStep::HostSelection),
97            WizardStep::HealthCheck => Some(WizardStep::SnippetPreview),
98            WizardStep::DataSetup => Some(WizardStep::HealthCheck),
99            WizardStep::Summary => Some(WizardStep::DataSetup),
100        }
101    }
102
103    pub fn step_number(&self) -> usize {
104        match self {
105            WizardStep::Welcome => 1,
106            WizardStep::EmbedderSetup => 2,
107            WizardStep::MemexSettings => 3,
108            WizardStep::HostSelection => 4,
109            WizardStep::SnippetPreview => 5,
110            WizardStep::HealthCheck => 6,
111            WizardStep::DataSetup => 7,
112            WizardStep::Summary => 8,
113        }
114    }
115
116    pub fn total_steps() -> usize {
117        8
118    }
119}
120
121/// How trustworthy the currently selected embedding dimension is.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum DimensionTruth {
124    /// No verified dimension yet — requires probe or manual entry.
125    Pending,
126    /// Verified by a live embedding probe against the provider.
127    Probed,
128    /// Explicitly set by the operator.
129    Manual,
130}
131
132/// Embedder configuration state for the wizard.
133#[derive(Debug, Clone)]
134pub struct EmbedderState {
135    /// Detected embedding providers from auto-detection
136    pub detected_providers: Vec<DetectedProvider>,
137    /// Whether detection is currently running
138    pub detecting: bool,
139    /// Selected provider (from detection or manual)
140    pub selected_provider: Option<DetectedProvider>,
141    /// Manual base URL (if configuring manually)
142    pub manual_url: String,
143    /// Manual model name
144    pub manual_model: String,
145    /// Required embedding dimension
146    pub dimension: usize,
147    /// Where the current dimension came from.
148    pub dimension_truth: DimensionTruth,
149    /// Whether a live dimension probe is currently pending.
150    pub dimension_probe_in_flight: bool,
151    /// Last live probe error, if any.
152    pub dimension_probe_error: Option<String>,
153    /// Provider config queued for the next live probe attempt.
154    pub pending_dimension_probe: Option<ProviderConfig>,
155    /// Whether to use manual configuration instead of detected
156    pub use_manual: bool,
157}
158
159impl Default for EmbedderState {
160    fn default() -> Self {
161        Self {
162            detected_providers: Vec::new(),
163            detecting: false,
164            selected_provider: None,
165            manual_url: "http://localhost:11434".to_string(),
166            manual_model: String::new(),
167            dimension: DEFAULT_REQUIRED_DIMENSION,
168            dimension_truth: DimensionTruth::Pending,
169            dimension_probe_in_flight: false,
170            dimension_probe_error: None,
171            pending_dimension_probe: None,
172            use_manual: false,
173        }
174    }
175}
176
177impl EmbedderState {
178    pub fn selected_model(&self) -> Option<String> {
179        if self.use_manual {
180            let model = self.manual_model.trim();
181            if model.is_empty() {
182                None
183            } else {
184                Some(model.to_string())
185            }
186        } else if let Some(ref detected) = self.selected_provider {
187            detected
188                .model()
189                .map(str::trim)
190                .filter(|m| !m.is_empty())
191                .map(ToOwned::to_owned)
192        } else {
193            None
194        }
195    }
196
197    pub fn selected_base_url(&self) -> Option<&str> {
198        if self.use_manual {
199            let url = self.manual_url.trim();
200            if url.is_empty() { None } else { Some(url) }
201        } else {
202            self.selected_provider
203                .as_ref()
204                .map(|provider| provider.base_url.trim())
205                .filter(|url| !url.is_empty())
206        }
207    }
208
209    pub fn dimension_display(&self) -> String {
210        if self.dimension_probe_in_flight {
211            return "probing...".to_string();
212        }
213
214        let suffix = match self.dimension_truth {
215            DimensionTruth::Pending => "pending",
216            DimensionTruth::Probed => "probed",
217            DimensionTruth::Manual => "manual",
218        };
219
220        format!("{} [{}]", self.dimension, suffix)
221    }
222
223    /// Get dimension explanation text
224    pub fn dimension_hint(&self) -> String {
225        if self.dimension_probe_in_flight {
226            return "Probing the provider for the actual vector size.".to_string();
227        }
228
229        if let Some(error) = &self.dimension_probe_error {
230            let concise_error = error.lines().next().unwrap_or(error).trim();
231            return match self.dimension_truth {
232                DimensionTruth::Manual => format!(
233                    "Manual override is active. Probe failed, but the operator-supplied dimension will be used. Probe error: {concise_error}"
234                ),
235                _ => format!(
236                    "Live probe failed. Run Health Check or set the dimension manually before writing config. Probe error: {concise_error}"
237                ),
238            };
239        }
240
241        match self.dimension_truth {
242            DimensionTruth::Pending => {
243                if let Some(model) = self.selected_model() {
244                    format!(
245                        "No verified dimension for `{model}` yet. Run a probe or enter the dimension manually."
246                    )
247                } else {
248                    "Select an embedding model or enter one manually.".to_string()
249                }
250            }
251            DimensionTruth::Probed => format!(
252                "Verified live against the provider. {}",
253                dimension_explanation(self.dimension)
254            ),
255            DimensionTruth::Manual => format!(
256                "Set manually by the operator. {}",
257                dimension_explanation(self.dimension)
258            ),
259        }
260    }
261
262    pub fn dimension_write_blocker(&self) -> Option<String> {
263        if self.dimension_probe_in_flight {
264            return Some(
265                "Embedding dimension is still being probed. Wait for the live probe to finish or set a manual dimension.".to_string(),
266            );
267        }
268
269        match self.dimension_truth {
270            DimensionTruth::Pending => Some(
271                "Embedding dimension has not been verified. Let the live probe succeed or enter the dimension manually before writing config.".to_string(),
272            ),
273            DimensionTruth::Probed | DimensionTruth::Manual => None,
274        }
275    }
276
277    fn reset_probe_state(&mut self) {
278        self.dimension_probe_in_flight = false;
279        self.dimension_probe_error = None;
280        self.pending_dimension_probe = None;
281    }
282
283    fn schedule_dimension_probe(&mut self, provider: ProviderConfig) {
284        self.pending_dimension_probe = Some(provider);
285        self.dimension_probe_in_flight = true;
286        self.dimension_probe_error = None;
287    }
288
289    fn current_provider_config(&self) -> Option<ProviderConfig> {
290        let model = self.selected_model()?;
291        let base_url = self.selected_base_url()?.to_string();
292
293        Some(ProviderConfig {
294            name: if self.use_manual {
295                "manual".to_string()
296            } else if let Some(provider) = &self.selected_provider {
297                match provider.kind {
298                    ProviderKind::Ollama => "ollama-local".to_string(),
299                    ProviderKind::Mlx => "mlx-local".to_string(),
300                    ProviderKind::OpenAICompat => "openai-compat".to_string(),
301                    ProviderKind::Manual => "manual".to_string(),
302                }
303            } else {
304                "manual".to_string()
305            },
306            base_url,
307            model,
308            priority: 1,
309            ..Default::default()
310        })
311    }
312
313    fn refresh_manual_dimension_state(&mut self) {
314        self.selected_provider = None;
315        self.dimension_probe_error = None;
316        self.dimension = DEFAULT_REQUIRED_DIMENSION;
317        self.dimension_truth = DimensionTruth::Pending;
318
319        let model = self.manual_model.trim();
320        if model.is_empty() {
321            self.pending_dimension_probe = None;
322            self.dimension_probe_in_flight = false;
323            return;
324        }
325
326        if let Some(provider) = self.current_provider_config() {
327            self.schedule_dimension_probe(provider);
328        } else {
329            self.dimension_probe_in_flight = false;
330            self.pending_dimension_probe = None;
331        }
332    }
333
334    fn set_manual_dimension(&mut self, dimension: usize) {
335        self.dimension = dimension;
336        self.dimension_truth = DimensionTruth::Manual;
337        self.reset_probe_state();
338    }
339
340    fn apply_detected_provider(&mut self, provider: DetectedProvider) {
341        self.use_manual = false;
342        self.selected_provider = Some(provider);
343        self.dimension_probe_error = None;
344        self.dimension = DEFAULT_REQUIRED_DIMENSION;
345        self.dimension_truth = DimensionTruth::Pending;
346
347        if let Some(provider) = self.current_provider_config() {
348            self.schedule_dimension_probe(provider);
349        } else {
350            self.dimension_probe_in_flight = false;
351            self.pending_dimension_probe = None;
352        }
353    }
354
355    fn apply_probe_result(&mut self, result: Result<usize>) {
356        self.dimension_probe_in_flight = false;
357
358        match result {
359            Ok(dimension) => {
360                self.dimension = dimension;
361                self.dimension_truth = DimensionTruth::Probed;
362                self.dimension_probe_error = None;
363            }
364            Err(error) => {
365                self.dimension_probe_error = Some(error.to_string());
366            }
367        }
368
369        self.pending_dimension_probe = None;
370    }
371
372    /// Update embedding config from state
373    pub fn build_embedding_config(&self) -> EmbeddingConfig {
374        let provider = if self.use_manual {
375            ProviderConfig {
376                name: "manual".to_string(),
377                base_url: self.manual_url.clone(),
378                model: self.manual_model.clone(),
379                priority: 1,
380                ..Default::default()
381            }
382        } else if let Some(ref detected) = self.selected_provider {
383            ProviderConfig {
384                name: match detected.kind {
385                    ProviderKind::Ollama => "ollama-local".to_string(),
386                    ProviderKind::Mlx => "mlx-local".to_string(),
387                    ProviderKind::OpenAICompat => "openai-compat".to_string(),
388                    ProviderKind::Manual => "manual".to_string(),
389                },
390                base_url: detected.base_url.clone(),
391                model: detected.model().unwrap_or("unknown").to_string(),
392                priority: 1,
393                ..Default::default()
394            }
395        } else {
396            // Fallback default
397            ProviderConfig {
398                name: "ollama-local".to_string(),
399                base_url: "http://localhost:11434".to_string(),
400                model: self.selected_model().unwrap_or_default(),
401                priority: 1,
402                ..Default::default()
403            }
404        };
405
406        EmbeddingConfig {
407            required_dimension: self.dimension,
408            providers: vec![provider],
409            ..Default::default()
410        }
411    }
412}
413
414/// Get current hostname (machine-agnostic)
415fn get_hostname() -> String {
416    // Try gethostname syscall first
417    if let Some(name) = std::process::Command::new("hostname")
418        .arg("-s") // short name without domain
419        .output()
420        .ok()
421        .filter(|o| o.status.success())
422    {
423        let hostname = String::from_utf8_lossy(&name.stdout).trim().to_string();
424        if !hostname.is_empty() {
425            return hostname;
426        }
427    }
428
429    // Fallback to environment variables
430    std::env::var("HOSTNAME")
431        .or_else(|_| std::env::var("COMPUTERNAME"))
432        .unwrap_or_else(|_| "local".to_string())
433}
434
435/// Database path mode for multi-host setups
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum DbPathMode {
438    /// Single shared path (e.g., ~/.ai-memories/lancedb)
439    Shared,
440    /// Per-host path with hostname suffix (e.g., ~/.ai-memories/lancedb.dragon)
441    PerHost,
442}
443
444/// How hosts connect to rust-memex.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
446pub enum DeploymentMode {
447    #[default]
448    PerHostStdio,
449    SharedMux,
450}
451
452/// Editable memex configuration.
453#[derive(Debug, Clone)]
454pub struct MemexCfg {
455    pub db_path: String,
456    pub cache_mb: usize,
457    pub log_level: String,
458    pub max_request_bytes: usize,
459    /// Current machine hostname (auto-detected)
460    pub hostname: String,
461    /// Database path mode (shared vs per-host)
462    pub db_path_mode: DbPathMode,
463    /// HTTP/SSE server port (None = disabled, Some(port) = enabled)
464    pub http_port: Option<u16>,
465    /// Whether hosts launch rust-memex directly or via rust_mux_proxy.
466    pub deployment_mode: DeploymentMode,
467}
468
469impl Default for MemexCfg {
470    fn default() -> Self {
471        let hostname = get_hostname();
472        Self {
473            // New default path per requirements
474            db_path: "~/.ai-memories/lancedb".to_string(),
475            cache_mb: 4096,
476            log_level: "info".to_string(),
477            max_request_bytes: 10 * 1024 * 1024, // 10MB
478            hostname,
479            db_path_mode: DbPathMode::Shared,
480            http_port: None,
481            deployment_mode: DeploymentMode::PerHostStdio,
482        }
483    }
484}
485
486impl MemexCfg {
487    /// Get the effective database path (with hostname suffix if per-host mode)
488    pub fn resolved_db_path(&self) -> String {
489        match self.db_path_mode {
490            DbPathMode::Shared => self.db_path.clone(),
491            DbPathMode::PerHost => format!("{}.{}", self.db_path, self.hostname),
492        }
493    }
494}
495
496/// Main application state.
497pub struct App {
498    pub step: WizardStep,
499    pub memex_cfg: MemexCfg,
500    pub config_path: String,
501    /// Embedder configuration state (new EmbedderSetup step)
502    pub embedder_state: EmbedderState,
503    /// Derived embedding config (updated from embedder_state)
504    pub embedding_config: EmbeddingConfig,
505    /// Extended hosts with their kind and detection info
506    pub hosts: Vec<(ExtendedHostKind, HostDetection)>,
507    pub selected_hosts: Vec<usize>,
508    pub dry_run: bool,
509    pub messages: Vec<String>,
510    pub focus: usize,
511    pub binary_path: String,
512    pub health_status: Option<String>,
513    pub should_quit: bool,
514    pub input_mode: bool,
515    pub input_buffer: String,
516    pub editing_field: Option<usize>,
517    /// Enhanced health check result
518    pub health_result: Option<HealthCheckResult>,
519    /// Whether health check is currently running
520    pub health_running: bool,
521    /// Data setup state
522    pub data_setup: DataSetupState,
523    /// Latest telemetry receiver for the indexing dashboard.
524    pub telemetry_rx: Option<watch::Receiver<IndexTelemetrySnapshot>>,
525    /// Latest system monitor receiver for the dashboard.
526    pub monitor_rx: Option<watch::Receiver<MonitorSnapshot>>,
527    /// Scheduler control sender.
528    pub index_control_tx: Option<mpsc::Sender<IndexControl>>,
529    /// Running indexing task.
530    pub index_task: Option<JoinHandle<Result<()>>>,
531    /// Running monitor sampler task.
532    pub monitor_task: Option<JoinHandle<()>>,
533    /// Background dimension probe task paired with its generation token.
534    pub dimension_probe_task: Option<(u64, JoinHandle<Result<usize>>)>,
535    /// Monotonic token that invalidates stale probe completions.
536    pub dimension_probe_generation: u64,
537    /// Current requested indexer parallelism.
538    pub index_parallelism: usize,
539    /// Whether the indexer is currently paused.
540    pub index_paused: bool,
541    /// Whether rust-memex config has been written
542    pub config_written: bool,
543    /// Resolved mux proxy command/path if available.
544    pub mux_proxy_command: Option<String>,
545}
546
547fn which_mux_proxy() -> Option<String> {
548    which_binary(&["rust_mux_proxy", "rust-mux-proxy"])
549}
550
551impl App {
552    pub fn mux_proxy_on_path(&self) -> bool {
553        self.mux_proxy_command.is_some()
554    }
555
556    pub fn mux_proxy_command(&self) -> Option<&str> {
557        self.mux_proxy_command.as_deref()
558    }
559
560    fn required_mux_proxy_command(&self) -> Result<&str> {
561        self.mux_proxy_command().ok_or_else(|| {
562            anyhow!(
563                "Shared mux mode requires `rust_mux_proxy` or `rust-mux-proxy` on PATH before writing host configs."
564            )
565        })
566    }
567
568    fn toggle_deployment_mode(&mut self) {
569        self.memex_cfg.deployment_mode = match self.memex_cfg.deployment_mode {
570            DeploymentMode::PerHostStdio => {
571                if self.mux_proxy_on_path() {
572                    DeploymentMode::SharedMux
573                } else {
574                    self.messages.push(
575                        "[WARN] Shared mux mode is unavailable until `rust_mux_proxy` or `rust-mux-proxy` is on PATH.".to_string(),
576                    );
577                    DeploymentMode::PerHostStdio
578                }
579            }
580            DeploymentMode::SharedMux => DeploymentMode::PerHostStdio,
581        };
582    }
583
584    pub fn new(config: WizardConfig) -> Self {
585        let WizardConfig {
586            config_path,
587            dry_run,
588        } = config;
589        let hosts = detect_extended_hosts();
590        let binary_path = which_rust_memex().unwrap_or_else(|| "rust-memex".to_string());
591        let embedder_state = EmbedderState::default();
592        let embedding_config = embedder_state.build_embedding_config();
593        let mux_proxy_command = which_mux_proxy();
594
595        Self {
596            step: WizardStep::Welcome,
597            memex_cfg: MemexCfg::default(),
598            config_path: config_path.unwrap_or_else(|| DEFAULT_MEMEX_CONFIG_PATH.to_string()),
599            embedder_state,
600            embedding_config,
601            hosts,
602            selected_hosts: Vec::new(),
603            dry_run,
604            messages: Vec::new(),
605            focus: 0,
606            binary_path,
607            health_status: None,
608            should_quit: false,
609            input_mode: false,
610            input_buffer: String::new(),
611            editing_field: None,
612            health_result: None,
613            health_running: false,
614            data_setup: DataSetupState::new(),
615            telemetry_rx: None,
616            monitor_rx: None,
617            index_control_tx: None,
618            index_task: None,
619            monitor_task: None,
620            dimension_probe_task: None,
621            dimension_probe_generation: 0,
622            index_parallelism: DEFAULT_INDEX_PARALLELISM,
623            index_paused: false,
624            config_written: false,
625            mux_proxy_command,
626        }
627    }
628
629    pub fn next_step(&mut self) {
630        if let Some(next) = self.step.next() {
631            // On leaving EmbedderSetup, update the embedding config
632            if self.step == WizardStep::EmbedderSetup {
633                self.refresh_embedding_config();
634            }
635            self.step = next;
636            self.focus = 0;
637            self.input_mode = false;
638            self.editing_field = None;
639
640            // Trigger actions on entering specific steps
641            if self.step == WizardStep::EmbedderSetup
642                && self.embedder_state.detected_providers.is_empty()
643            {
644                self.embedder_state.detecting = true;
645            }
646
647            // Auto-trigger health check when entering HealthCheck step
648            if self.step == WizardStep::HealthCheck && !self.health_running {
649                self.run_health_check();
650                self.trigger_health_check();
651            }
652        }
653    }
654
655    pub fn prev_step(&mut self) {
656        if let Some(prev) = self.step.prev() {
657            self.step = prev;
658            self.focus = 0;
659        }
660    }
661
662    pub fn toggle_host(&mut self, idx: usize) {
663        if self.selected_hosts.contains(&idx) {
664            self.selected_hosts.retain(|&i| i != idx);
665        } else {
666            self.selected_hosts.push(idx);
667        }
668    }
669
670    pub fn get_selected_hosts(&self) -> Vec<&(ExtendedHostKind, HostDetection)> {
671        self.selected_hosts
672            .iter()
673            .filter_map(|&i| self.hosts.get(i))
674            .collect()
675    }
676
677    pub fn generate_snippets(&self) -> Vec<(ExtendedHostKind, String)> {
678        let config_path = self.resolved_config_path();
679        self.get_selected_hosts()
680            .iter()
681            .map(|(kind, _detection)| {
682                let snippet = match self.memex_cfg.deployment_mode {
683                    DeploymentMode::PerHostStdio => generate_extended_snippet(
684                        *kind,
685                        &self.binary_path,
686                        &config_path,
687                        self.memex_cfg.http_port,
688                    ),
689                    DeploymentMode::SharedMux => self
690                        .mux_proxy_command()
691                        .map(|proxy_command| {
692                            generate_extended_snippet_mux(
693                                *kind,
694                                proxy_command,
695                                DEFAULT_MUX_SOCKET_PATH,
696                            )
697                        })
698                        .unwrap_or_else(|| {
699                            "Shared mux unavailable: install `rust_mux_proxy` or `rust-mux-proxy` on PATH before generating host snippets.".to_string()
700                        }),
701                };
702                (*kind, snippet)
703            })
704            .collect()
705    }
706
707    pub fn run_health_check(&mut self) {
708        self.health_status = Some("Checking...".to_string());
709
710        // Prefer the canonical binary name, but allow the legacy alias when present.
711        match std::process::Command::new(&self.binary_path)
712            .arg("--version")
713            .output()
714        {
715            Ok(output) => {
716                if output.status.success() {
717                    let version = String::from_utf8_lossy(&output.stdout);
718                    self.health_status = Some(format!("[OK] Binary OK: {}", version.trim()));
719                } else {
720                    self.health_status = Some("[ERR] Binary found but failed to run".to_string());
721                }
722            }
723            Err(e) => {
724                self.health_status = Some(format!("[ERR] Binary not found: {}", e));
725            }
726        }
727
728        // Show hostname info
729        self.messages.push(format!(
730            "[INFO] Host: {} (path mode: {:?})",
731            self.memex_cfg.hostname, self.memex_cfg.db_path_mode
732        ));
733        self.messages
734            .push(format!("[INFO] Config path: {}", self.config_path));
735
736        // Check db_path (use effective path)
737        let effective_path = self.memex_cfg.resolved_db_path();
738        let expanded_path = shellexpand::tilde(&effective_path).to_string();
739        let db_path = PathBuf::from(&expanded_path);
740        if db_path.exists() {
741            self.messages
742                .push(format!("[OK] DB path exists: {}", expanded_path));
743        } else {
744            self.messages
745                .push(format!("[-] DB path will be created: {}", expanded_path));
746        }
747
748        // Show HTTP port info
749        if let Some(port) = self.memex_cfg.http_port {
750            self.messages
751                .push(format!("[INFO] HTTP/SSE server will run on port {}", port));
752        }
753    }
754
755    pub fn write_configs(&mut self) -> Result<()> {
756        let config_path = self.resolved_config_path();
757        let mux_proxy_command = if self.memex_cfg.deployment_mode == DeploymentMode::SharedMux {
758            Some(self.required_mux_proxy_command()?.to_string())
759        } else {
760            None
761        };
762
763        if self.dry_run {
764            self.messages.push("DRY RUN: No files written".to_string());
765            self.messages.push(format!(
766                "Host: {} | Path mode: {:?}",
767                self.memex_cfg.hostname, self.memex_cfg.db_path_mode
768            ));
769            for &idx in &self.selected_hosts.clone() {
770                if let Some((kind, detection)) = self.hosts.get(idx) {
771                    let snippet = match self.memex_cfg.deployment_mode {
772                        DeploymentMode::PerHostStdio => generate_extended_snippet(
773                            *kind,
774                            &self.binary_path,
775                            &config_path,
776                            self.memex_cfg.http_port,
777                        ),
778                        DeploymentMode::SharedMux => generate_extended_snippet_mux(
779                            *kind,
780                            mux_proxy_command
781                                .as_deref()
782                                .expect("mux proxy command must exist in shared mode"),
783                            DEFAULT_MUX_SOCKET_PATH,
784                        ),
785                    };
786                    self.messages.push(format!(
787                        "Would write to {} ({}):\n{}",
788                        kind.label(),
789                        detection.path.display(),
790                        snippet
791                    ));
792                }
793            }
794            if self.memex_cfg.deployment_mode == DeploymentMode::SharedMux {
795                self.messages.push(format!(
796                    "Would write mux service config to {}",
797                    DEFAULT_MUX_CONFIG_PATH
798                ));
799            }
800            return Ok(());
801        }
802
803        let mut success_count = 0;
804        let mut error_count = 0;
805
806        if self.memex_cfg.deployment_mode == DeploymentMode::SharedMux {
807            match write_mux_service_config(
808                &self.binary_path,
809                &config_path,
810                self.memex_cfg.http_port,
811                self.memex_cfg.max_request_bytes,
812                &self.memex_cfg.log_level,
813            ) {
814                Ok(result) => {
815                    if let Some(backup) = result.backup_path {
816                        self.messages.push(format!(
817                            "[OK] {} backup: {}",
818                            result.host_name,
819                            backup.display()
820                        ));
821                    }
822                    if result.created {
823                        self.messages.push(format!(
824                            "[OK] {} created: {}",
825                            result.host_name,
826                            result.config_path.display()
827                        ));
828                    } else {
829                        self.messages.push(format!(
830                            "[OK] {} updated: {}",
831                            result.host_name,
832                            result.config_path.display()
833                        ));
834                    }
835                }
836                Err(error) => {
837                    self.messages
838                        .push(format!("[ERR] rust-mux service config failed: {}", error));
839                    return Err(error);
840                }
841            }
842        }
843
844        for &idx in &self.selected_hosts.clone() {
845            if let Some((kind, _detection)) = self.hosts.get(idx) {
846                let write_result = match self.memex_cfg.deployment_mode {
847                    DeploymentMode::PerHostStdio => write_extended_host_config(
848                        *kind,
849                        &self.binary_path,
850                        &config_path,
851                        self.memex_cfg.http_port,
852                    ),
853                    DeploymentMode::SharedMux => write_extended_host_config_mux(
854                        *kind,
855                        mux_proxy_command
856                            .as_deref()
857                            .expect("mux proxy command must exist in shared mode"),
858                        DEFAULT_MUX_SOCKET_PATH,
859                    ),
860                };
861
862                match write_result {
863                    Ok(result) => {
864                        success_count += 1;
865                        if let Some(backup) = result.backup_path {
866                            self.messages.push(format!(
867                                "[OK] {} backup: {}",
868                                result.host_name,
869                                backup.display()
870                            ));
871                        }
872                        if result.created {
873                            self.messages.push(format!(
874                                "[OK] {} created: {}",
875                                result.host_name,
876                                result.config_path.display()
877                            ));
878                        } else {
879                            self.messages.push(format!(
880                                "[OK] {} updated: {}",
881                                result.host_name,
882                                result.config_path.display()
883                            ));
884                        }
885                    }
886                    Err(e) => {
887                        error_count += 1;
888                        self.messages
889                            .push(format!("[ERR] {} failed: {}", kind.label(), e));
890                    }
891                }
892            }
893        }
894
895        if success_count > 0 {
896            self.messages.push(format!(
897                "\nConfiguration complete! {} host(s) configured.",
898                success_count
899            ));
900            if self.memex_cfg.deployment_mode == DeploymentMode::SharedMux {
901                self.messages.push(format!(
902                    "Start the shared daemon with: rust_mux --config {} --service {}",
903                    DEFAULT_MUX_CONFIG_PATH, DEFAULT_MUX_SERVICE_NAME
904                ));
905            }
906        }
907        if error_count > 0 {
908            self.messages.push(format!(
909                "Warning: {} host(s) failed to configure.",
910                error_count
911            ));
912        }
913
914        Ok(())
915    }
916
917    pub(crate) fn settings_field_count(&self) -> usize {
918        7
919    }
920
921    pub fn get_field_value(&self, field: usize) -> String {
922        match field {
923            0 => self.memex_cfg.db_path.clone(),
924            1 => match self.memex_cfg.db_path_mode {
925                DbPathMode::Shared => "shared".to_string(),
926                DbPathMode::PerHost => format!("per-host ({})", self.memex_cfg.hostname),
927            },
928            2 => match self.memex_cfg.http_port {
929                Some(port) => port.to_string(),
930                None => "disabled".to_string(),
931            },
932            3 => self.memex_cfg.cache_mb.to_string(),
933            4 => self.memex_cfg.log_level.clone(),
934            5 => self.memex_cfg.max_request_bytes.to_string(),
935            6 => match self.memex_cfg.deployment_mode {
936                DeploymentMode::PerHostStdio => {
937                    if self.mux_proxy_on_path() {
938                        "Per-host (direct)".to_string()
939                    } else {
940                        "Per-host (shared unavailable)".to_string()
941                    }
942                }
943                DeploymentMode::SharedMux => {
944                    if self.mux_proxy_on_path() {
945                        "Shared (mux)".to_string()
946                    } else {
947                        "Shared (blocked: proxy missing)".to_string()
948                    }
949                }
950            },
951            _ => String::new(),
952        }
953    }
954
955    pub fn resolved_config_path(&self) -> String {
956        let expanded = shellexpand::tilde(&self.config_path).to_string();
957        let path = PathBuf::from(&expanded);
958        if path.is_absolute() {
959            expanded
960        } else if let Ok(cwd) = std::env::current_dir() {
961            cwd.join(path).display().to_string()
962        } else {
963            expanded
964        }
965    }
966
967    fn refresh_embedding_config(&mut self) {
968        self.embedding_config = self.embedder_state.build_embedding_config();
969    }
970
971    fn invalidate_dimension_probe_generation(&mut self) {
972        self.dimension_probe_generation = self.dimension_probe_generation.wrapping_add(1);
973    }
974
975    fn cancel_dimension_probe_task(&mut self) {
976        if let Some((_, handle)) = self.dimension_probe_task.take() {
977            handle.abort();
978        }
979        self.invalidate_dimension_probe_generation();
980    }
981
982    fn start_dimension_probe_task(&mut self, rt: &tokio::runtime::Handle) {
983        if self.dimension_probe_task.is_some() {
984            return;
985        }
986
987        let Some(provider) = self.embedder_state.pending_dimension_probe.take() else {
988            return;
989        };
990
991        let generation = self.dimension_probe_generation;
992        let task = rt.spawn(async move {
993            let client = Client::builder()
994                .timeout(Duration::from_secs(8))
995                .connect_timeout(Duration::from_secs(3))
996                .build()
997                .unwrap_or_default();
998
999            probe_provider_dimension(&client, &provider).await
1000        });
1001
1002        self.dimension_probe_task = Some((generation, task));
1003    }
1004
1005    fn apply_dimension_probe_completion(&mut self, generation: u64, result: Result<usize>) -> bool {
1006        if generation != self.dimension_probe_generation {
1007            return false;
1008        }
1009
1010        self.embedder_state.apply_probe_result(result);
1011        self.refresh_embedding_config();
1012        true
1013    }
1014
1015    fn poll_dimension_probe_task(&mut self, rt: &tokio::runtime::Handle) {
1016        let Some((generation, handle)) = self.dimension_probe_task.take() else {
1017            return;
1018        };
1019
1020        if !handle.is_finished() {
1021            self.dimension_probe_task = Some((generation, handle));
1022            return;
1023        }
1024
1025        let join_result = tokio::task::block_in_place(|| rt.block_on(handle));
1026        match join_result {
1027            Ok(result) => {
1028                let _ = self.apply_dimension_probe_completion(generation, result);
1029            }
1030            Err(error) if error.is_cancelled() => {}
1031            Err(error) => {
1032                let _ = self.apply_dimension_probe_completion(
1033                    generation,
1034                    Err(anyhow!("dimension probe task failed: {}", error)),
1035                );
1036            }
1037        }
1038    }
1039
1040    pub fn set_field_value(&mut self, field: usize, value: String) {
1041        match field {
1042            0 => self.memex_cfg.db_path = value,
1043            1 => {
1044                // Toggle between shared and per-host
1045                self.memex_cfg.db_path_mode = match self.memex_cfg.db_path_mode {
1046                    DbPathMode::Shared => DbPathMode::PerHost,
1047                    DbPathMode::PerHost => DbPathMode::Shared,
1048                };
1049            }
1050            2 => {
1051                // Parse port or disable
1052                if value.to_lowercase() == "disabled" || value.is_empty() {
1053                    self.memex_cfg.http_port = None;
1054                } else if let Ok(port) = value.parse() {
1055                    self.memex_cfg.http_port = Some(port);
1056                }
1057            }
1058            3 => {
1059                if let Ok(v) = value.parse() {
1060                    self.memex_cfg.cache_mb = v;
1061                }
1062            }
1063            4 => self.memex_cfg.log_level = value,
1064            5 => {
1065                if let Ok(v) = value.parse() {
1066                    self.memex_cfg.max_request_bytes = v;
1067                }
1068            }
1069            6 => self.toggle_deployment_mode(),
1070            _ => {}
1071        }
1072    }
1073
1074    pub fn handle_key(&mut self, key: KeyCode) {
1075        // Handle input mode for settings and data setup
1076        if self.input_mode || self.data_setup.input_mode {
1077            self.handle_input_key(key);
1078            return;
1079        }
1080
1081        if self.step == WizardStep::DataSetup
1082            && self.data_setup.sub_step == DataSetupSubStep::Indexing
1083        {
1084            match key {
1085                KeyCode::Char(' ') => {
1086                    let next = if self.index_paused {
1087                        IndexControl::Resume
1088                    } else {
1089                        IndexControl::Pause
1090                    };
1091                    if self.send_index_control(next) {
1092                        self.index_paused = !self.index_paused;
1093                    }
1094                    return;
1095                }
1096                KeyCode::Char('+') | KeyCode::Char('=') => {
1097                    self.index_parallelism = self.index_parallelism.saturating_add(1);
1098                    if !self
1099                        .send_index_control(IndexControl::SetParallelism(self.index_parallelism))
1100                    {
1101                        self.index_parallelism = self.index_parallelism.saturating_sub(1).max(1);
1102                    }
1103                    return;
1104                }
1105                KeyCode::Char('-') => {
1106                    let previous = self.index_parallelism;
1107                    self.index_parallelism = self.index_parallelism.saturating_sub(1).max(1);
1108                    if previous != self.index_parallelism
1109                        && !self.send_index_control(IndexControl::SetParallelism(
1110                            self.index_parallelism,
1111                        ))
1112                    {
1113                        self.index_parallelism = previous;
1114                    }
1115                    return;
1116                }
1117                KeyCode::Char('s') => {
1118                    let _ = self.send_index_control(IndexControl::Stop);
1119                    return;
1120                }
1121                _ => {}
1122            }
1123        }
1124
1125        match key {
1126            KeyCode::Char('q') => self.should_quit = true,
1127            KeyCode::Esc => {
1128                if self.step != WizardStep::Welcome {
1129                    self.prev_step();
1130                } else {
1131                    self.should_quit = true;
1132                }
1133            }
1134            KeyCode::Enter | KeyCode::Tab => self.handle_enter(),
1135            KeyCode::Right | KeyCode::Char('n') => self.handle_next(),
1136            KeyCode::Left | KeyCode::Char('p') => self.prev_step(),
1137            KeyCode::Up | KeyCode::Char('k') => self.handle_up(),
1138            KeyCode::Down | KeyCode::Char('j') => self.handle_down(),
1139            KeyCode::Char(' ') => self.handle_space(),
1140            KeyCode::Char('r') if self.step == WizardStep::HealthCheck && !self.health_running => {
1141                self.trigger_health_check();
1142            }
1143            _ => {}
1144        }
1145    }
1146
1147    fn handle_input_key(&mut self, key: KeyCode) {
1148        // Handle data setup input mode
1149        if self.data_setup.input_mode {
1150            match key {
1151                KeyCode::Enter => {
1152                    match self.data_setup.sub_step {
1153                        DataSetupSubStep::EnterPath => {
1154                            self.data_setup.confirm_path();
1155                        }
1156                        DataSetupSubStep::EnterNamespace => {
1157                            self.data_setup.confirm_namespace();
1158                            // Start indexing
1159                            if self.data_setup.is_indexing() {
1160                                self.start_indexing_task();
1161                            }
1162                        }
1163                        _ => {}
1164                    }
1165                }
1166                KeyCode::Esc => {
1167                    self.data_setup.input_mode = false;
1168                    self.data_setup.input_buffer.clear();
1169                    self.data_setup.sub_step = DataSetupSubStep::SelectOption;
1170                }
1171                KeyCode::Backspace => {
1172                    self.data_setup.input_buffer.pop();
1173                }
1174                KeyCode::Char(c) => {
1175                    self.data_setup.input_buffer.push(c);
1176                }
1177                _ => {}
1178            }
1179            return;
1180        }
1181
1182        // Handle settings or embedder input mode
1183        if self.input_mode {
1184            match key {
1185                KeyCode::Enter => {
1186                    if let Some(field) = self.editing_field {
1187                        // Handle embedder setup fields
1188                        if self.step == WizardStep::EmbedderSetup && self.embedder_state.use_manual
1189                        {
1190                            self.cancel_dimension_probe_task();
1191                            match field {
1192                                0 => self.embedder_state.manual_url = self.input_buffer.clone(),
1193                                1 => {
1194                                    self.embedder_state.manual_model = self.input_buffer.clone();
1195                                    self.embedder_state.refresh_manual_dimension_state();
1196                                }
1197                                2 => {
1198                                    if let Ok(dim) = self.input_buffer.parse() {
1199                                        self.embedder_state.set_manual_dimension(dim);
1200                                    }
1201                                }
1202                                _ => {}
1203                            }
1204                            if field == 0 {
1205                                self.embedder_state.refresh_manual_dimension_state();
1206                            }
1207                            self.refresh_embedding_config();
1208                        } else {
1209                            self.set_field_value(field, self.input_buffer.clone());
1210                        }
1211                    }
1212                    self.input_mode = false;
1213                    self.editing_field = None;
1214                    self.input_buffer.clear();
1215                }
1216                KeyCode::Esc => {
1217                    // In manual embedder mode, go back to provider selection
1218                    if self.step == WizardStep::EmbedderSetup && self.embedder_state.use_manual {
1219                        self.embedder_state.use_manual = false;
1220                        self.focus = 0;
1221                    }
1222                    self.input_mode = false;
1223                    self.editing_field = None;
1224                    self.input_buffer.clear();
1225                }
1226                KeyCode::Backspace => {
1227                    self.input_buffer.pop();
1228                }
1229                KeyCode::Char(c) => {
1230                    self.input_buffer.push(c);
1231                }
1232                _ => {}
1233            }
1234        }
1235    }
1236
1237    fn handle_enter(&mut self) {
1238        match self.step {
1239            WizardStep::EmbedderSetup => {
1240                self.handle_embedder_setup_enter();
1241            }
1242            WizardStep::MemexSettings => {
1243                // Enter edit mode for current field
1244                self.input_mode = true;
1245                self.editing_field = Some(self.focus);
1246                self.input_buffer = self.get_field_value(self.focus);
1247            }
1248            WizardStep::HostSelection if self.focus < self.hosts.len() => {
1249                self.toggle_host(self.focus);
1250            }
1251            WizardStep::HealthCheck if !self.health_running => {
1252                self.trigger_health_check();
1253            }
1254            WizardStep::DataSetup => {
1255                self.handle_data_setup_enter();
1256            }
1257            WizardStep::Summary => {
1258                // First write rust-memex config, then write host configs
1259                if !self.config_written
1260                    && let Err(e) = self.write_memex_config()
1261                {
1262                    self.messages.push(format!("[ERR] {}", e));
1263                }
1264                // Also write host configs
1265                if let Err(e) = self.write_configs() {
1266                    self.messages.push(format!("[ERR] {}", e));
1267                }
1268            }
1269            _ => {}
1270        }
1271    }
1272
1273    fn handle_embedder_setup_enter(&mut self) {
1274        if self.embedder_state.use_manual {
1275            // In manual mode, edit the focused field
1276            self.input_mode = true;
1277            self.editing_field = Some(self.focus);
1278            self.input_buffer = match self.focus {
1279                0 => self.embedder_state.manual_url.clone(),
1280                1 => self.embedder_state.manual_model.clone(),
1281                2 => self.embedder_state.dimension.to_string(),
1282                _ => String::new(),
1283            };
1284        } else if self.focus < self.embedder_state.detected_providers.len() {
1285            // Select a detected provider
1286            self.cancel_dimension_probe_task();
1287            let provider = self.embedder_state.detected_providers[self.focus].clone();
1288            self.embedder_state.apply_detected_provider(provider);
1289            self.refresh_embedding_config();
1290        } else {
1291            // Switch to manual configuration (last option)
1292            self.cancel_dimension_probe_task();
1293            self.embedder_state.use_manual = true;
1294            self.focus = 0;
1295            self.embedder_state.refresh_manual_dimension_state();
1296            self.refresh_embedding_config();
1297        }
1298    }
1299
1300    fn handle_data_setup_enter(&mut self) {
1301        match self.data_setup.sub_step {
1302            DataSetupSubStep::SelectOption => {
1303                self.data_setup.select_focused();
1304            }
1305            DataSetupSubStep::SelectImportMode => {
1306                let modes = ImportMode::all();
1307                if let Some(mode) = modes.get(self.data_setup.focus).cloned() {
1308                    self.data_setup.select_import_mode(mode);
1309                    // If import mode is selected, perform the import
1310                    if self.data_setup.is_done()
1311                        && self.data_setup.option == DataSetupOption::ImportLanceDB
1312                    {
1313                        self.perform_import();
1314                    }
1315                }
1316            }
1317            _ => {}
1318        }
1319    }
1320
1321    fn handle_next(&mut self) {
1322        // For DataSetup, only proceed if complete or skip
1323        if self.step == WizardStep::DataSetup {
1324            if self.data_setup.is_done() || self.data_setup.option == DataSetupOption::Skip {
1325                self.next_step();
1326            }
1327        } else if self.step == WizardStep::HealthCheck {
1328            // Allow proceeding even if health check failed (with warning)
1329            self.next_step();
1330        } else {
1331            self.next_step();
1332        }
1333    }
1334
1335    fn handle_up(&mut self) {
1336        if self.focus > 0 {
1337            self.focus -= 1;
1338        }
1339        // Sync focus with data setup
1340        if self.step == WizardStep::DataSetup {
1341            self.data_setup.focus = self.focus;
1342        }
1343    }
1344
1345    fn handle_down(&mut self) {
1346        let max = self.get_max_focus();
1347        if self.focus < max {
1348            self.focus += 1;
1349        }
1350        // Sync focus with data setup
1351        if self.step == WizardStep::DataSetup {
1352            self.data_setup.focus = self.focus;
1353        }
1354    }
1355
1356    fn handle_space(&mut self) {
1357        if self.step == WizardStep::HostSelection && self.focus < self.hosts.len() {
1358            self.toggle_host(self.focus);
1359        }
1360    }
1361
1362    fn get_max_focus(&self) -> usize {
1363        match self.step {
1364            WizardStep::EmbedderSetup => {
1365                if self.embedder_state.use_manual {
1366                    2 // URL, model, dimension
1367                } else {
1368                    // providers + manual option
1369                    self.embedder_state.detected_providers.len()
1370                }
1371            }
1372            WizardStep::MemexSettings => self.settings_field_count().saturating_sub(1),
1373            WizardStep::HostSelection => self.hosts.len().saturating_sub(1),
1374            WizardStep::DataSetup => match self.data_setup.sub_step {
1375                DataSetupSubStep::SelectOption => DataSetupOption::all().len().saturating_sub(1),
1376                DataSetupSubStep::SelectImportMode => ImportMode::all().len().saturating_sub(1),
1377                _ => 0,
1378            },
1379            _ => 0,
1380        }
1381    }
1382
1383    fn send_index_control(&mut self, control: IndexControl) -> bool {
1384        let Some(tx) = self.index_control_tx.clone() else {
1385            return false;
1386        };
1387
1388        match tx.try_send(control) {
1389            Ok(()) => true,
1390            Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
1391                self.messages
1392                    .push("[WARN] Index control queue is full; try again in a moment.".to_string());
1393                false
1394            }
1395            Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
1396                self.messages
1397                    .push("[WARN] Indexing controls are no longer available.".to_string());
1398                self.index_control_tx = None;
1399                false
1400            }
1401        }
1402    }
1403
1404    pub fn current_index_telemetry(&self) -> Option<IndexTelemetrySnapshot> {
1405        self.telemetry_rx
1406            .as_ref()
1407            .map(|receiver| receiver.borrow().clone())
1408    }
1409
1410    pub fn current_monitor_snapshot(&self) -> Option<MonitorSnapshot> {
1411        self.monitor_rx
1412            .as_ref()
1413            .map(|receiver| receiver.borrow().clone())
1414    }
1415
1416    fn finish_indexing_from_snapshot(&mut self, snapshot: &IndexTelemetrySnapshot) {
1417        if let Some(error) = &snapshot.fatal_error {
1418            self.messages.push(format!(
1419                "[ERR] Indexing failed after {}/{} files: {}",
1420                snapshot.processed, snapshot.total, error
1421            ));
1422        } else if snapshot.stopped_early {
1423            self.messages.push(format!(
1424                "[WARN] Indexing stopped after {}/{} files ({} indexed, {} skipped, {} failed).",
1425                snapshot.processed,
1426                snapshot.total,
1427                snapshot.indexed,
1428                snapshot.skipped,
1429                snapshot.failed
1430            ));
1431        } else {
1432            self.messages.push(format!(
1433                "[OK] Indexing finished: {} indexed, {} skipped, {} failed, {} chunks.",
1434                snapshot.indexed, snapshot.skipped, snapshot.failed, snapshot.total_chunks
1435            ));
1436        }
1437
1438        self.data_setup.sub_step = DataSetupSubStep::Complete;
1439        self.stop_indexing_tasks();
1440    }
1441
1442    /// Trigger the async health check
1443    pub fn trigger_health_check(&mut self) {
1444        self.health_running = true;
1445        self.health_result = None;
1446        self.health_status = Some("Running health checks...".to_string());
1447        self.messages.clear();
1448
1449        // Also run the old basic check for binary version
1450        if let Ok(output) = std::process::Command::new(&self.binary_path)
1451            .arg("--version")
1452            .output()
1453            && output.status.success()
1454        {
1455            let version = String::from_utf8_lossy(&output.stdout);
1456            self.health_status = Some(format!("Binary: {} - Running checks...", version.trim()));
1457        }
1458    }
1459
1460    /// Run the async health check (called from event loop)
1461    pub async fn run_async_health_check(&mut self) {
1462        // Quick connectivity check for selected provider
1463        if let Some(ref provider) = self.embedder_state.selected_provider {
1464            let url = format!("{}/v1/models", provider.base_url);
1465            if check_health(&url).await {
1466                self.messages
1467                    .push(format!("[OK] Provider {} is reachable", provider.base_url));
1468            } else {
1469                self.messages.push(format!(
1470                    "[WARN] Provider {} may be offline",
1471                    provider.base_url
1472                ));
1473            }
1474        }
1475
1476        let checker = HealthChecker::new();
1477        let effective_path = self.memex_cfg.resolved_db_path();
1478        let result = checker
1479            .run_all(&self.embedding_config, &effective_path)
1480            .await;
1481
1482        self.health_result = Some(result.clone());
1483        self.health_running = false;
1484
1485        // Update status based on results
1486        if result.all_passed() {
1487            self.health_status = Some("All health checks passed!".to_string());
1488        } else if result.any_failed() {
1489            self.health_status =
1490                Some("Some health checks failed. Review details below.".to_string());
1491        } else {
1492            self.health_status = Some("Health checks complete.".to_string());
1493        }
1494    }
1495
1496    /// Start the indexing task
1497    fn start_indexing_task(&mut self) {
1498        let Some(source_path) = self.data_setup.source_path.clone() else {
1499            return;
1500        };
1501        let Some(namespace) = self.data_setup.namespace.clone() else {
1502            return;
1503        };
1504
1505        let path = match validate_path(&source_path) {
1506            Ok(path) => path,
1507            Err(error) => {
1508                self.data_setup.validation_error = Some(error.to_string());
1509                self.data_setup.sub_step = DataSetupSubStep::EnterPath;
1510                self.data_setup.input_mode = true;
1511                self.data_setup.input_buffer = source_path;
1512                return;
1513            }
1514        };
1515
1516        let files = match collect_indexable_files(&path) {
1517            Ok(files) if !files.is_empty() => files,
1518            Ok(_) => {
1519                self.data_setup.validation_error =
1520                    Some("No indexable files found in the selected directory.".to_string());
1521                self.data_setup.sub_step = DataSetupSubStep::EnterPath;
1522                self.data_setup.input_mode = true;
1523                self.data_setup.input_buffer = source_path;
1524                return;
1525            }
1526            Err(error) => {
1527                self.data_setup.validation_error = Some(error.to_string());
1528                self.data_setup.sub_step = DataSetupSubStep::EnterPath;
1529                self.data_setup.input_mode = true;
1530                self.data_setup.input_buffer = source_path;
1531                return;
1532            }
1533        };
1534
1535        self.data_setup.validation_error = None;
1536        self.messages.clear();
1537        self.stop_indexing_tasks();
1538
1539        let total_files = files.len();
1540        let (telemetry_tx, telemetry_rx) = new_index_telemetry();
1541        let telemetry_tx: SharedIndexTelemetry = telemetry_tx;
1542        let tui_sink = Arc::new(TuiTelemetrySink::new(Arc::new(telemetry_tx)));
1543        let tracing_sink = Arc::new(TracingSink);
1544        let sinks: Vec<Arc<dyn IndexEventSink>> = vec![tui_sink, tracing_sink];
1545        let sink: Arc<dyn IndexEventSink> = Arc::new(FanOut::new(sinks));
1546        let (control_tx, control_rx) =
1547            mpsc::channel(crate::tui::indexer::INDEX_CONTROL_CHANNEL_CAPACITY);
1548
1549        self.index_task = Some(start_indexing(
1550            IndexingJob {
1551                source_dir: path,
1552                files,
1553                namespace: namespace.clone(),
1554                embedding_config: self.embedding_config.clone(),
1555                db_path: self.memex_cfg.resolved_db_path(),
1556                initial_parallelism: self.index_parallelism,
1557            },
1558            sink,
1559            control_rx,
1560        ));
1561
1562        let (monitor_rx, monitor_task) = spawn_monitor(Duration::from_secs(1));
1563
1564        self.telemetry_rx = Some(telemetry_rx);
1565        self.monitor_rx = Some(monitor_rx);
1566        self.index_control_tx = Some(control_tx);
1567        self.monitor_task = Some(monitor_task);
1568        self.index_paused = false;
1569        self.messages.push(format!(
1570            "[INFO] Indexing {} files into namespace {}.",
1571            total_files, namespace
1572        ));
1573    }
1574
1575    fn stop_indexing_tasks(&mut self) {
1576        if let Some(handle) = self.index_task.take() {
1577            handle.abort();
1578        }
1579        if let Some(handle) = self.monitor_task.take() {
1580            handle.abort();
1581        }
1582
1583        self.telemetry_rx = None;
1584        self.monitor_rx = None;
1585        self.index_control_tx = None;
1586        self.index_paused = false;
1587    }
1588
1589    /// Perform LanceDB import
1590    fn perform_import(&mut self) {
1591        if let Some(ref source_path) = self.data_setup.source_path {
1592            let source = PathBuf::from(shellexpand::tilde(source_path).to_string());
1593            let target =
1594                PathBuf::from(shellexpand::tilde(&self.memex_cfg.resolved_db_path()).to_string());
1595
1596            // Run import synchronously for now (it's mostly IO)
1597            let rt = tokio::runtime::Handle::try_current();
1598            if let Ok(handle) = rt {
1599                let mode = self.data_setup.import_mode.clone();
1600                let result = tokio::task::block_in_place(|| {
1601                    handle.block_on(import_lancedb(&source, &target, mode))
1602                });
1603                match result {
1604                    Ok(msg) => {
1605                        self.messages.push(format!("[OK] {}", msg));
1606                    }
1607                    Err(e) => {
1608                        self.messages.push(format!("[ERR] Import failed: {}", e));
1609                    }
1610                }
1611            } else {
1612                // Fallback for non-async context
1613                self.messages
1614                    .push("[INFO] Import will use config path directly".to_string());
1615            }
1616        }
1617    }
1618
1619    /// Run provider detection asynchronously
1620    pub async fn run_provider_detection(&mut self) {
1621        if self.embedder_state.detecting {
1622            self.embedder_state.detected_providers = detect_providers().await;
1623            self.embedder_state.detecting = false;
1624
1625            // Auto-select first usable provider
1626            if let Some(provider) = self
1627                .embedder_state
1628                .detected_providers
1629                .iter()
1630                .find(|p| p.is_usable())
1631                .cloned()
1632            {
1633                self.cancel_dimension_probe_task();
1634                self.embedder_state.apply_detected_provider(provider);
1635            } else {
1636                self.cancel_dimension_probe_task();
1637                self.embedder_state.reset_probe_state();
1638            }
1639            self.refresh_embedding_config();
1640        }
1641    }
1642
1643    /// Generate the complete config TOML for rust-memex
1644    pub fn generate_config_toml(&self) -> String {
1645        const MODEL_PLACEHOLDER: &str = "<set-your-embedding-model>";
1646        let mut toml = String::new();
1647
1648        // Header
1649        toml.push_str("# rust-memex configuration\n");
1650        toml.push_str(&format!(
1651            "# Generated by wizard on host: {}\n",
1652            self.memex_cfg.hostname
1653        ));
1654        toml.push_str(&format!(
1655            "# Path mode: {:?}\n\n",
1656            self.memex_cfg.db_path_mode
1657        ));
1658
1659        // Database settings (use effective path which includes hostname suffix if per-host)
1660        toml.push_str("# Database configuration\n");
1661        toml.push_str(&format!(
1662            "db_path = \"{}\"\n",
1663            self.memex_cfg.resolved_db_path()
1664        ));
1665        toml.push_str(&format!("cache_mb = {}\n", self.memex_cfg.cache_mb));
1666        toml.push_str(&format!("log_level = \"{}\"\n", self.memex_cfg.log_level));
1667        toml.push_str(&format!(
1668            "max_request_bytes = {}\n",
1669            self.memex_cfg.max_request_bytes
1670        ));
1671
1672        toml.push('\n');
1673
1674        // Embeddings configuration
1675        toml.push_str("# Embedding provider configuration\n");
1676        toml.push_str("[embeddings]\n");
1677        toml.push_str(&format!(
1678            "required_dimension = {}\n\n",
1679            self.embedder_state.dimension
1680        ));
1681
1682        // Provider
1683        toml.push_str("[[embeddings.providers]]\n");
1684        if self.embedder_state.use_manual {
1685            toml.push_str("name = \"manual\"\n");
1686            toml.push_str(&format!(
1687                "base_url = \"{}\"\n",
1688                self.embedder_state.manual_url
1689            ));
1690            toml.push_str(&format!(
1691                "model = \"{}\"\n",
1692                self.embedder_state
1693                    .selected_model()
1694                    .unwrap_or_else(|| MODEL_PLACEHOLDER.to_string())
1695            ));
1696        } else if let Some(ref provider) = self.embedder_state.selected_provider {
1697            let name = match provider.kind {
1698                ProviderKind::Ollama => "ollama-local",
1699                ProviderKind::Mlx => "mlx-local",
1700                ProviderKind::OpenAICompat => "openai-compat",
1701                ProviderKind::Manual => "manual",
1702            };
1703            toml.push_str(&format!("name = \"{}\"\n", name));
1704            toml.push_str(&format!("base_url = \"{}\"\n", provider.base_url));
1705            toml.push_str(&format!(
1706                "model = \"{}\"\n",
1707                provider.model().unwrap_or(MODEL_PLACEHOLDER)
1708            ));
1709        } else {
1710            // No provider selected yet: write an explicit placeholder instead of a false default.
1711            toml.push_str("name = \"ollama-local\"\n");
1712            toml.push_str("base_url = \"http://localhost:11434\"\n");
1713            toml.push_str(&format!("model = \"{}\"\n", MODEL_PLACEHOLDER));
1714        }
1715        toml.push_str("priority = 1\n");
1716        toml.push_str("endpoint = \"/v1/embeddings\"\n");
1717
1718        toml
1719    }
1720
1721    /// Write rust-memex config file to disk
1722    pub fn write_memex_config(&mut self) -> Result<()> {
1723        if self.embedder_state.selected_model().is_none() {
1724            return Err(anyhow!(
1725                "No embedding model selected. Pick a detected provider or enter a manual model before writing config."
1726            ));
1727        }
1728
1729        if let Some(reason) = self.embedder_state.dimension_write_blocker() {
1730            return Err(anyhow!(reason));
1731        }
1732
1733        if self.dry_run {
1734            self.messages
1735                .push("DRY RUN: Config would be written to:".to_string());
1736            self.messages.push(format!("  {}", self.config_path));
1737            self.messages.push(String::new());
1738            self.messages.push("Generated config:".to_string());
1739            self.messages.push("---".to_string());
1740            for line in self.generate_config_toml().lines() {
1741                self.messages.push(format!("  {}", line));
1742            }
1743            self.messages.push("---".to_string());
1744            self.config_written = true;
1745            return Ok(());
1746        }
1747
1748        let config_path = self.resolved_config_path();
1749        let config_file = PathBuf::from(&config_path);
1750        let config_dir = config_file.parent().ok_or_else(|| {
1751            anyhow!(
1752                "Cannot determine parent directory for config path {}",
1753                self.config_path
1754            )
1755        })?;
1756        std::fs::create_dir_all(config_dir)?;
1757
1758        // Backup existing config if present
1759        if config_file.exists() {
1760            let backup_path = format!("{}.bak.{}", config_path, timestamp());
1761            std::fs::copy(&config_file, &backup_path)?;
1762            self.messages
1763                .push(format!("[OK] Backup created: {}", backup_path));
1764        }
1765
1766        // Write new config
1767        let toml_content = self.generate_config_toml();
1768        std::fs::write(&config_path, &toml_content)?;
1769        self.messages
1770            .push(format!("[OK] Config written: {}", config_path));
1771
1772        // Create database directory if needed
1773        let db_path = shellexpand::tilde(&self.memex_cfg.resolved_db_path()).to_string();
1774        if let Some(parent) = PathBuf::from(&db_path).parent()
1775            && !parent.exists()
1776        {
1777            std::fs::create_dir_all(parent)?;
1778            self.messages
1779                .push(format!("[OK] Created directory: {}", parent.display()));
1780        }
1781
1782        self.config_written = true;
1783        self.messages.push(String::new());
1784        self.messages.push("Configuration complete!".to_string());
1785        if self.config_path == DEFAULT_MEMEX_CONFIG_PATH {
1786            self.messages
1787                .push("Run 'rust-memex serve' to start the server.".to_string());
1788        } else {
1789            self.messages.push(format!(
1790                "Run 'rust-memex serve --config {}' to start the server.",
1791                self.config_path
1792            ));
1793        }
1794
1795        Ok(())
1796    }
1797}
1798
1799fn timestamp() -> String {
1800    use std::time::{SystemTime, UNIX_EPOCH};
1801    let secs = SystemTime::now()
1802        .duration_since(UNIX_EPOCH)
1803        .unwrap_or_default()
1804        .as_secs();
1805    format!("{}", secs)
1806}
1807
1808fn which_rust_memex() -> Option<String> {
1809    which_binary(&["rust-memex", "rust_memex"])
1810}
1811
1812fn which_binary(candidates: &[&str]) -> Option<String> {
1813    candidates.iter().find_map(|binary| {
1814        std::process::Command::new("which")
1815            .arg(binary)
1816            .output()
1817            .ok()
1818            .filter(|output| output.status.success())
1819            .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
1820    })
1821}
1822
1823type Tui = Terminal<CrosstermBackend<Stdout>>;
1824
1825fn init_terminal() -> Result<Tui> {
1826    enable_raw_mode()?;
1827    stdout().execute(EnterAlternateScreen)?;
1828    let backend = CrosstermBackend::new(stdout());
1829    let terminal = Terminal::new(backend)?;
1830    Ok(terminal)
1831}
1832
1833fn restore_terminal() -> Result<()> {
1834    disable_raw_mode()?;
1835    stdout().execute(LeaveAlternateScreen)?;
1836    Ok(())
1837}
1838
1839/// Run the TUI wizard.
1840pub fn run_wizard(config: WizardConfig) -> Result<()> {
1841    let mut terminal = init_terminal()?;
1842    let mut app = App::new(config);
1843
1844    let result = run_app(&mut terminal, &mut app);
1845
1846    restore_terminal()?;
1847    result
1848}
1849
1850fn run_app(terminal: &mut Tui, app: &mut App) -> Result<()> {
1851    use crate::tui::ui::render;
1852
1853    // Get handle to existing runtime (from async main) or create new one
1854    let rt = match tokio::runtime::Handle::try_current() {
1855        Ok(handle) => handle,
1856        Err(_) => {
1857            // No runtime exists, create one (shouldn't happen with async main)
1858            let rt = tokio::runtime::Builder::new_current_thread()
1859                .enable_all()
1860                .build()?;
1861            // Leak to keep it alive - this is a fallback path
1862            Box::leak(Box::new(rt)).handle().clone()
1863        }
1864    };
1865
1866    loop {
1867        app.start_dimension_probe_task(&rt);
1868        app.poll_dimension_probe_task(&rt);
1869
1870        let current_telemetry = app.current_index_telemetry();
1871        if app.step == WizardStep::DataSetup
1872            && app.data_setup.sub_step == DataSetupSubStep::Indexing
1873            && let Some(snapshot) = current_telemetry.as_ref()
1874            && snapshot.complete
1875        {
1876            app.finish_indexing_from_snapshot(snapshot);
1877        }
1878        let current_monitor = app.current_monitor_snapshot();
1879
1880        terminal.draw(|frame| {
1881            render(
1882                frame,
1883                app,
1884                current_telemetry.as_ref(),
1885                current_monitor.as_ref(),
1886            )
1887        })?;
1888
1889        // Handle async provider detection
1890        if app.embedder_state.detecting {
1891            let rt_clone = rt.clone();
1892            tokio::task::block_in_place(|| {
1893                rt_clone.block_on(async {
1894                    app.run_provider_detection().await;
1895                });
1896            });
1897        }
1898
1899        // Handle async health check if triggered
1900        if app.health_running && app.health_result.is_none() {
1901            let rt_clone = rt.clone();
1902            tokio::task::block_in_place(|| {
1903                rt_clone.block_on(async {
1904                    app.run_async_health_check().await;
1905                });
1906            });
1907        }
1908
1909        if event::poll(Duration::from_millis(100))?
1910            && let Event::Key(key) = event::read()?
1911            && key.kind == KeyEventKind::Press
1912        {
1913            app.handle_key(key.code);
1914        }
1915
1916        if app.should_quit {
1917            app.cancel_dimension_probe_task();
1918            app.stop_indexing_tasks();
1919            break;
1920        }
1921    }
1922
1923    Ok(())
1924}
1925
1926#[cfg(test)]
1927mod tests {
1928    use super::*;
1929    use crate::tui::detection::ProviderStatus;
1930
1931    fn detected_provider(model: &str) -> DetectedProvider {
1932        DetectedProvider {
1933            kind: ProviderKind::Ollama,
1934            base_url: "http://localhost:11434".to_string(),
1935            port: 11434,
1936            models: vec![model.to_string()],
1937            suggested_model: Some(model.to_string()),
1938            status: ProviderStatus::Online(model.to_string()),
1939        }
1940    }
1941
1942    #[test]
1943    fn detected_provider_selection_queues_probe_as_pending() {
1944        let mut state = EmbedderState::default();
1945        state.apply_detected_provider(detected_provider("qwen3-embedding:8b"));
1946
1947        assert_eq!(state.dimension, DEFAULT_REQUIRED_DIMENSION);
1948        assert_eq!(state.dimension_truth, DimensionTruth::Pending);
1949        assert!(state.dimension_probe_in_flight);
1950        assert!(state.pending_dimension_probe.is_some());
1951        assert!(state.dimension_write_blocker().is_some());
1952    }
1953
1954    #[test]
1955    fn manual_dimension_override_is_writable_without_probe() {
1956        let mut state = EmbedderState {
1957            use_manual: true,
1958            manual_url: "http://localhost:11434".to_string(),
1959            manual_model: "custom-embed".to_string(),
1960            ..EmbedderState::default()
1961        };
1962
1963        state.set_manual_dimension(1536);
1964
1965        assert_eq!(state.dimension, 1536);
1966        assert_eq!(state.dimension_truth, DimensionTruth::Manual);
1967        assert!(!state.dimension_probe_in_flight);
1968        assert!(state.dimension_write_blocker().is_none());
1969    }
1970
1971    #[test]
1972    fn unknown_manual_model_without_probe_stays_blocked() {
1973        let mut state = EmbedderState {
1974            use_manual: true,
1975            manual_model: "custom-embed".to_string(),
1976            manual_url: String::new(),
1977            ..EmbedderState::default()
1978        };
1979
1980        state.refresh_manual_dimension_state();
1981
1982        assert_eq!(state.dimension_truth, DimensionTruth::Pending);
1983        assert!(state.dimension_write_blocker().is_some());
1984    }
1985
1986    #[test]
1987    fn stale_probe_completion_cannot_override_newer_manual_choice() {
1988        let mut app = App::new(WizardConfig::default());
1989        app.embedder_state
1990            .apply_detected_provider(detected_provider("qwen3-embedding:8b"));
1991        app.refresh_embedding_config();
1992
1993        app.dimension_probe_generation = 5;
1994        app.cancel_dimension_probe_task();
1995        app.embedder_state.set_manual_dimension(1536);
1996        app.refresh_embedding_config();
1997
1998        let applied = app.apply_dimension_probe_completion(5, Ok(4096));
1999
2000        assert!(!applied);
2001        assert_eq!(app.embedder_state.dimension, 1536);
2002        assert_eq!(app.embedder_state.dimension_truth, DimensionTruth::Manual);
2003        assert_eq!(app.embedding_config.required_dimension, 1536);
2004    }
2005
2006    #[test]
2007    fn shared_mux_write_requires_resolved_proxy_command() {
2008        let mut app = App::new(WizardConfig {
2009            dry_run: true,
2010            ..WizardConfig::default()
2011        });
2012        app.memex_cfg.deployment_mode = DeploymentMode::SharedMux;
2013        app.mux_proxy_command = None;
2014
2015        let error = app
2016            .write_configs()
2017            .expect_err("shared mux should be blocked");
2018        assert!(
2019            error
2020                .to_string()
2021                .contains("Shared mux mode requires `rust_mux_proxy` or `rust-mux-proxy` on PATH")
2022        );
2023    }
2024
2025    #[test]
2026    fn deployment_mode_toggle_without_proxy_stays_direct_and_warns() {
2027        let mut app = App::new(WizardConfig::default());
2028        app.mux_proxy_command = None;
2029
2030        app.set_field_value(6, String::new());
2031
2032        assert_eq!(app.memex_cfg.deployment_mode, DeploymentMode::PerHostStdio);
2033        assert_eq!(
2034            app.get_field_value(6),
2035            "Per-host (shared unavailable)".to_string()
2036        );
2037        assert!(
2038            app.messages
2039                .last()
2040                .expect("warning message")
2041                .contains("Shared mux mode is unavailable")
2042        );
2043    }
2044
2045    #[test]
2046    fn deployment_mode_toggle_with_proxy_enables_shared_mux() {
2047        let mut app = App::new(WizardConfig::default());
2048        app.mux_proxy_command = Some("/usr/local/bin/rust-mux-proxy".to_string());
2049
2050        app.set_field_value(6, String::new());
2051
2052        assert_eq!(app.memex_cfg.deployment_mode, DeploymentMode::SharedMux);
2053        assert_eq!(app.get_field_value(6), "Shared (mux)".to_string());
2054    }
2055}