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>
This commit is contained in:
sergio
2026-05-20 19:30:57 +00:00
parent 37ea535cb7
commit be99ac3bbb
4 changed files with 206 additions and 13 deletions
@@ -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 {
@@ -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);
}
}
@@ -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};