1use 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#[derive(Debug, Clone, Default)]
45pub struct WizardConfig {
46 pub config_path: Option<String>,
47 pub dry_run: bool,
48}
49
50#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum DimensionTruth {
124 Pending,
126 Probed,
128 Manual,
130}
131
132#[derive(Debug, Clone)]
134pub struct EmbedderState {
135 pub detected_providers: Vec<DetectedProvider>,
137 pub detecting: bool,
139 pub selected_provider: Option<DetectedProvider>,
141 pub manual_url: String,
143 pub manual_model: String,
145 pub dimension: usize,
147 pub dimension_truth: DimensionTruth,
149 pub dimension_probe_in_flight: bool,
151 pub dimension_probe_error: Option<String>,
153 pub pending_dimension_probe: Option<ProviderConfig>,
155 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 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 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 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
414fn get_hostname() -> String {
416 if let Some(name) = std::process::Command::new("hostname")
418 .arg("-s") .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 std::env::var("HOSTNAME")
431 .or_else(|_| std::env::var("COMPUTERNAME"))
432 .unwrap_or_else(|_| "local".to_string())
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum DbPathMode {
438 Shared,
440 PerHost,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
446pub enum DeploymentMode {
447 #[default]
448 PerHostStdio,
449 SharedMux,
450}
451
452#[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 pub hostname: String,
461 pub db_path_mode: DbPathMode,
463 pub http_port: Option<u16>,
465 pub deployment_mode: DeploymentMode,
467}
468
469impl Default for MemexCfg {
470 fn default() -> Self {
471 let hostname = get_hostname();
472 Self {
473 db_path: "~/.ai-memories/lancedb".to_string(),
475 cache_mb: 4096,
476 log_level: "info".to_string(),
477 max_request_bytes: 10 * 1024 * 1024, hostname,
479 db_path_mode: DbPathMode::Shared,
480 http_port: None,
481 deployment_mode: DeploymentMode::PerHostStdio,
482 }
483 }
484}
485
486impl MemexCfg {
487 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
496pub struct App {
498 pub step: WizardStep,
499 pub memex_cfg: MemexCfg,
500 pub config_path: String,
501 pub embedder_state: EmbedderState,
503 pub embedding_config: EmbeddingConfig,
505 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 pub health_result: Option<HealthCheckResult>,
519 pub health_running: bool,
521 pub data_setup: DataSetupState,
523 pub telemetry_rx: Option<watch::Receiver<IndexTelemetrySnapshot>>,
525 pub monitor_rx: Option<watch::Receiver<MonitorSnapshot>>,
527 pub index_control_tx: Option<mpsc::Sender<IndexControl>>,
529 pub index_task: Option<JoinHandle<Result<()>>>,
531 pub monitor_task: Option<JoinHandle<()>>,
533 pub dimension_probe_task: Option<(u64, JoinHandle<Result<usize>>)>,
535 pub dimension_probe_generation: u64,
537 pub index_parallelism: usize,
539 pub index_paused: bool,
541 pub config_written: bool,
543 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 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 if self.step == WizardStep::EmbedderSetup
642 && self.embedder_state.detected_providers.is_empty()
643 {
644 self.embedder_state.detecting = true;
645 }
646
647 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 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 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 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 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 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 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 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 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 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 if self.input_mode {
1184 match key {
1185 KeyCode::Enter => {
1186 if let Some(field) = self.editing_field {
1187 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 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 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 if !self.config_written
1260 && let Err(e) = self.write_memex_config()
1261 {
1262 self.messages.push(format!("[ERR] {}", e));
1263 }
1264 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 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 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 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 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 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 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 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 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 } else {
1368 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 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 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 pub async fn run_async_health_check(&mut self) {
1462 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 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 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 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 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 self.messages
1614 .push("[INFO] Import will use config path directly".to_string());
1615 }
1616 }
1617 }
1618
1619 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 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 pub fn generate_config_toml(&self) -> String {
1645 const MODEL_PLACEHOLDER: &str = "<set-your-embedding-model>";
1646 let mut toml = String::new();
1647
1648 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 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 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 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 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 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 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 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 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
1839pub 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 let rt = match tokio::runtime::Handle::try_current() {
1855 Ok(handle) => handle,
1856 Err(_) => {
1857 let rt = tokio::runtime::Builder::new_current_thread()
1859 .enable_all()
1860 .build()?;
1861 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 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 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}