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