gioser

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Sutichaykuna

feat(shuma): ghosting predictivo en el prompt

shuma-line: ghost_suggestion(line, corpus) — el resto de la línea que
el shell predice, a partir de un corpus priorizado.
shuma-infer: predict_next(recent, patterns) — si los últimos comandos
coinciden con el prefijo de un patrón, devuelve los pasos que faltan.

shuma-shell: mientras se escribe, el prompt pinta en gris tenue la
continuación predicha — historial reciente o, con prioridad, la
secuencia que el motor de inferencia anticipa (cd a un proyecto →
fantasma «git pull && cargo build»). La flecha → al final de la
línea, o Ctrl+Space, aceptan el fantasma.

13 tests shuma-infer, 37 shuma-line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

be99ac3bbbf77ecc2035849b099da7a6fd1bc779 · ruwaq sergio · (5 killa ñawpaq) · tayta 37ea535cb · kay commitpi sach'ata qhaway

Semántico sayk'uy · 21

crates/apps/shuma-shell/src/main.rs

crates/modules/shuma/shuma-infer/src/lib.rs

crates/modules/shuma/shuma-line/src/ghost.rs

crates/modules/shuma/shuma-line/src/lib.rs

4 qillqakuna · +206 −13

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<shuma_infer::EmergingPattern>,
     /// 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<shuma_infer::CommandRecord> = self
-            .session
+    /// Comandos del historial reducidos a registros de inferencia.
+    fn infer_records(&self) -> Vec<shuma_infer::CommandRecord> {
+        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<String> {
+        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<String> {
+        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<String> = 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<String, String> {
         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<Emer
     patterns
 }
 
+/// Predice la continuación de un patrón en curso.
+///
+/// Mira el final del historial `recent`: si sus últimos comandos
+/// coinciden con el prefijo de la firma de algún patrón, devuelve las
+/// líneas que faltan para completarlo —tomadas de la ocurrencia más
+/// reciente, así son ejecutables—. Ante varios, gana el patrón cuyo
+/// prefijo coincidente sea más largo. Es lo que alimenta el "ghosting".
+pub fn predict_next(
+    recent: &[CommandRecord],
+    patterns: &[EmergingPattern],
+) -> Option<Vec<String>> {
+    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<EmergingPattern> {
+        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<String> {
+    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};