diff --git a/crates/apps/shuma-shell/src/main.rs b/crates/apps/shuma-shell/src/main.rs index fea64b7..09479c3 100644 --- a/crates/apps/shuma-shell/src/main.rs +++ b/crates/apps/shuma-shell/src/main.rs @@ -255,6 +255,8 @@ struct Shell { /// Largo del historial en el último `:save` — define qué comandos /// entran al próximo grupo guardado. group_anchor: usize, + /// Patrones detectados por el motor de inferencia (cache). + patterns: Vec, /// Scroll del feed central — sigue al comando más reciente. scroll: ScrollHandle, focus: FocusHandle, @@ -295,6 +297,7 @@ impl Shell { drag: None, run_ui: HashMap::new(), group_anchor: 0, + patterns: Vec::new(), scroll: ScrollHandle::new(), focus: cx.focus_handle(), focused_once: false, @@ -369,24 +372,24 @@ impl Shell { changed } - /// Corre el motor de inferencia sobre el historial y promueve el - /// patrón más fuerte a un grupo reutilizable (rehidratación). - fn infer_patterns(&mut self) { - let records: Vec = self - .session + /// Comandos del historial reducidos a registros de inferencia. + fn infer_records(&self) -> Vec { + self.session .history() .iter() .map(|r| { - shuma_infer::CommandRecord::parse( - &r.line, - &r.cwd, - r.status == RunStatus::Ok, - ) + shuma_infer::CommandRecord::parse(&r.line, &r.cwd, r.status == RunStatus::Ok) }) - .collect(); - let patterns = + .collect() + } + + /// Corre el motor de inferencia, cachea los patrones y promueve el + /// más fuerte a un grupo reutilizable (rehidratación). + fn infer_patterns(&mut self) { + let records = self.infer_records(); + self.patterns = shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default()); - if let Some(top) = patterns.first() { + if let Some(top) = self.patterns.first() { let name = format!("✨ {}", top.suggested_name()); if self.session.group(&name).is_none() { self.session.save_group(name, top.example.clone()); @@ -394,6 +397,36 @@ impl Shell { } } + /// La secuencia que el motor predice como continuación, si la hay. + fn predicted_sequence(&self) -> Option { + if self.patterns.is_empty() { + return None; + } + let records = self.infer_records(); + let tail = &records[records.len().saturating_sub(6)..]; + let next = shuma_infer::predict_next(tail, &self.patterns)?; + (!next.is_empty()).then(|| next.join(" && ")) + } + + /// Calcula el sufijo fantasma del prompt: el resto de la línea que el + /// shell predice. Sólo con el cursor al final. + fn compute_ghost(&self) -> Option { + let line = self.line.text(); + if line.is_empty() || self.line.cursor() != line.len() { + return None; + } + // Corpus por prioridad: secuencia predicha, luego historial + // reciente. + let mut corpus: Vec = Vec::new(); + if let Some(seq) = self.predicted_sequence() { + corpus.push(seq); + } + for r in self.session.history().iter().rev() { + corpus.push(r.line.clone()); + } + shuma_line::ghost_suggestion(line, &corpus) + } + /// Resuelve el destino de un `cd` contra el cwd de la sesión. fn resolve_cd(&self, arg: &str) -> Result { let home = std::env::var("HOME").unwrap_or_else(|_| "/".into()); @@ -584,6 +617,12 @@ impl Shell { "right" => { if ctrl { self.line.move_word_right(); + } else if self.line.cursor() == self.line.text().len() { + // En el extremo, la flecha derecha acepta el fantasma. + if let Some(g) = self.compute_ghost() { + self.line.insert(&g); + changed = true; + } } else { self.line.move_right(); } @@ -596,6 +635,13 @@ impl Shell { self.line.clear(); changed = true; } + "space" if ctrl => { + // Ctrl+Space también acepta el fantasma predicho. + if let Some(g) = self.compute_ghost() { + self.line.insert(&g); + changed = true; + } + } _ => { if !ctrl { if let Some(ch) = ks.key_char.as_deref() { @@ -1123,6 +1169,15 @@ impl Render for Shell { input_row.push(caret()); } } + // Sugerencia fantasma — el resto que el shell predice, en gris. + if let Some(ghost) = self.compute_ghost() { + input_row.push( + div() + .flex_none() + .text_color(theme.fg_disabled) + .child(SharedString::from(ghost)), + ); + } let prompt = div() .h(px(46.)) .flex() diff --git a/crates/modules/shuma/shuma-infer/src/lib.rs b/crates/modules/shuma/shuma-infer/src/lib.rs index 8589906..405bb39 100644 --- a/crates/modules/shuma/shuma-infer/src/lib.rs +++ b/crates/modules/shuma/shuma-infer/src/lib.rs @@ -233,6 +233,41 @@ pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec Option> { + let bins: Vec<&str> = recent.iter().map(|r| r.binary.as_str()).collect(); + let mut best: Option<(usize, &EmergingPattern)> = None; + for p in patterns { + // Tiene que quedar al menos un paso por predecir. + let max_k = p.signature.len().saturating_sub(1).min(bins.len()); + for k in (1..=max_k).rev() { + let tail = &bins[bins.len() - k..]; + let prefix_matches = p + .signature + .iter() + .take(k) + .map(String::as_str) + .eq(tail.iter().copied()); + if prefix_matches { + if best.map(|(bk, _)| k > bk).unwrap_or(true) { + best = Some((k, p)); + } + break; + } + } + } + best.map(|(k, p)| p.example[k..].to_vec()) +} + #[cfg(test)] mod tests { use super::*; @@ -361,6 +396,44 @@ mod tests { assert_eq!(p.suggested_name(), "git+cargo"); } + /// Mundo de prueba: el patrón cd → git pull → cargo build, visto dos + /// veces, y la lista de patrones que produce. + fn pattern_world() -> Vec { + let history = vec![ + ok("cd /a", "/h"), + ok("git pull", "/a"), + ok("cargo build", "/a"), + ok("cd /b", "/h"), + ok("git pull", "/b"), + ok("cargo build", "/b"), + ]; + detect_patterns(&history, &InferConfig::default()) + } + + #[test] + fn predicts_the_rest_after_a_cd() { + let patterns = pattern_world(); + // El usuario acaba de hacer `cd` → se predicen los pasos que faltan. + let recent = vec![ok("cd /nuevo", "/h")]; + let next = predict_next(&recent, &patterns).unwrap(); + assert_eq!(next, vec!["git pull", "cargo build"]); + } + + #[test] + fn prediction_shrinks_as_the_pattern_advances() { + let patterns = pattern_world(); + let recent = vec![ok("cd /nuevo", "/h"), ok("git pull", "/nuevo")]; + let next = predict_next(&recent, &patterns).unwrap(); + assert_eq!(next, vec!["cargo build"]); + } + + #[test] + fn no_prediction_when_nothing_matches() { + let patterns = pattern_world(); + let recent = vec![ok("ls", "/h"), ok("pwd", "/h")]; + assert!(predict_next(&recent, &patterns).is_none()); + } + #[test] fn score_ranks_longer_and_more_frequent_higher() { let short = EmergingPattern { diff --git a/crates/modules/shuma/shuma-line/src/ghost.rs b/crates/modules/shuma/shuma-line/src/ghost.rs new file mode 100644 index 0000000..39487bc --- /dev/null +++ b/crates/modules/shuma/shuma-line/src/ghost.rs @@ -0,0 +1,63 @@ +//! Sugerencia fantasma — el "ghosting" predictivo del prompt. +//! +//! Mientras se escribe, el shell predice el resto de la línea y lo pinta +//! en gris tenue. Esta función es el cerebro de esa predicción: dada la +//! línea parcial y un corpus de líneas conocidas (historial, secuencias +//! inferidas), devuelve el sufijo que falta. +//! +//! El orden del corpus es la prioridad: el caller pone primero lo más +//! relevante (la secuencia predicha por `shuma-infer`), luego el +//! historial de lo más reciente a lo más viejo. + +/// Devuelve el sufijo fantasma: lo que falta para completar la primera +/// entrada del `corpus` que empieza con `line` y es estrictamente más +/// larga. `None` si nada coincide. +pub fn ghost_suggestion(line: &str, corpus: &[String]) -> Option { + if line.is_empty() { + return None; + } + corpus + .iter() + .find(|c| c.len() > line.len() && c.starts_with(line)) + .map(|c| c[line.len()..].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suggests_the_remainder_of_a_known_line() { + let corpus = vec!["git pull".to_string(), "cargo build".to_string()]; + assert_eq!(ghost_suggestion("git pu", &corpus), Some("ll".to_string())); + } + + #[test] + fn corpus_order_is_priority() { + // Dos coinciden; gana la primera del corpus. + let corpus = vec!["cargo build --release".to_string(), "cargo build".to_string()]; + assert_eq!( + ghost_suggestion("cargo b", &corpus), + Some("uild --release".to_string()) + ); + } + + #[test] + fn no_match_yields_none() { + let corpus = vec!["ls -la".to_string()]; + assert_eq!(ghost_suggestion("git", &corpus), None); + } + + #[test] + fn exact_line_is_not_a_suggestion() { + // El corpus contiene exactamente la línea: nada que sugerir. + let corpus = vec!["git pull".to_string()]; + assert_eq!(ghost_suggestion("git pull", &corpus), None); + } + + #[test] + fn empty_line_yields_none() { + let corpus = vec!["git pull".to_string()]; + assert_eq!(ghost_suggestion("", &corpus), None); + } +} diff --git a/crates/modules/shuma/shuma-line/src/lib.rs b/crates/modules/shuma/shuma-line/src/lib.rs index 89fbb22..71b4c42 100644 --- a/crates/modules/shuma/shuma-line/src/lib.rs +++ b/crates/modules/shuma/shuma-line/src/lib.rs @@ -23,6 +23,7 @@ pub mod complete; pub mod dialect; pub mod editor; +pub mod ghost; pub mod lexer; pub mod pipeline; pub mod token; @@ -30,6 +31,7 @@ pub mod token; pub use complete::{complete, Completion, CompletionKind, CompletionSource, StaticSource}; pub use dialect::Dialect; pub use editor::LineState; +pub use ghost::ghost_suggestion; pub use lexer::tokenize; pub use pipeline::{split_pipeline, Pipeline, Stage}; pub use token::{Token, TokenKind};