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:
@@ -255,6 +255,8 @@ struct Shell {
|
|||||||
/// Largo del historial en el último `:save` — define qué comandos
|
/// Largo del historial en el último `:save` — define qué comandos
|
||||||
/// entran al próximo grupo guardado.
|
/// entran al próximo grupo guardado.
|
||||||
group_anchor: usize,
|
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 del feed central — sigue al comando más reciente.
|
||||||
scroll: ScrollHandle,
|
scroll: ScrollHandle,
|
||||||
focus: FocusHandle,
|
focus: FocusHandle,
|
||||||
@@ -295,6 +297,7 @@ impl Shell {
|
|||||||
drag: None,
|
drag: None,
|
||||||
run_ui: HashMap::new(),
|
run_ui: HashMap::new(),
|
||||||
group_anchor: 0,
|
group_anchor: 0,
|
||||||
|
patterns: Vec::new(),
|
||||||
scroll: ScrollHandle::new(),
|
scroll: ScrollHandle::new(),
|
||||||
focus: cx.focus_handle(),
|
focus: cx.focus_handle(),
|
||||||
focused_once: false,
|
focused_once: false,
|
||||||
@@ -369,24 +372,24 @@ impl Shell {
|
|||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Corre el motor de inferencia sobre el historial y promueve el
|
/// Comandos del historial reducidos a registros de inferencia.
|
||||||
/// patrón más fuerte a un grupo reutilizable (rehidratación).
|
fn infer_records(&self) -> Vec<shuma_infer::CommandRecord> {
|
||||||
fn infer_patterns(&mut self) {
|
self.session
|
||||||
let records: Vec<shuma_infer::CommandRecord> = self
|
|
||||||
.session
|
|
||||||
.history()
|
.history()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
shuma_infer::CommandRecord::parse(
|
shuma_infer::CommandRecord::parse(&r.line, &r.cwd, r.status == RunStatus::Ok)
|
||||||
&r.line,
|
|
||||||
&r.cwd,
|
|
||||||
r.status == RunStatus::Ok,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
let patterns =
|
}
|
||||||
|
|
||||||
|
/// 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());
|
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());
|
let name = format!("✨ {}", top.suggested_name());
|
||||||
if self.session.group(&name).is_none() {
|
if self.session.group(&name).is_none() {
|
||||||
self.session.save_group(name, top.example.clone());
|
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.
|
/// Resuelve el destino de un `cd` contra el cwd de la sesión.
|
||||||
fn resolve_cd(&self, arg: &str) -> Result<String, String> {
|
fn resolve_cd(&self, arg: &str) -> Result<String, String> {
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
|
||||||
@@ -584,6 +617,12 @@ impl Shell {
|
|||||||
"right" => {
|
"right" => {
|
||||||
if ctrl {
|
if ctrl {
|
||||||
self.line.move_word_right();
|
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 {
|
} else {
|
||||||
self.line.move_right();
|
self.line.move_right();
|
||||||
}
|
}
|
||||||
@@ -596,6 +635,13 @@ impl Shell {
|
|||||||
self.line.clear();
|
self.line.clear();
|
||||||
changed = true;
|
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 !ctrl {
|
||||||
if let Some(ch) = ks.key_char.as_deref() {
|
if let Some(ch) = ks.key_char.as_deref() {
|
||||||
@@ -1123,6 +1169,15 @@ impl Render for Shell {
|
|||||||
input_row.push(caret());
|
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()
|
let prompt = div()
|
||||||
.h(px(46.))
|
.h(px(46.))
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -233,6 +233,41 @@ pub fn detect_patterns(history: &[CommandRecord], cfg: &InferConfig) -> Vec<Emer
|
|||||||
patterns
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -361,6 +396,44 @@ mod tests {
|
|||||||
assert_eq!(p.suggested_name(), "git+cargo");
|
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]
|
#[test]
|
||||||
fn score_ranks_longer_and_more_frequent_higher() {
|
fn score_ranks_longer_and_more_frequent_higher() {
|
||||||
let short = EmergingPattern {
|
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 complete;
|
||||||
pub mod dialect;
|
pub mod dialect;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
|
pub mod ghost;
|
||||||
pub mod lexer;
|
pub mod lexer;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod token;
|
pub mod token;
|
||||||
@@ -30,6 +31,7 @@ pub mod token;
|
|||||||
pub use complete::{complete, Completion, CompletionKind, CompletionSource, StaticSource};
|
pub use complete::{complete, Completion, CompletionKind, CompletionSource, StaticSource};
|
||||||
pub use dialect::Dialect;
|
pub use dialect::Dialect;
|
||||||
pub use editor::LineState;
|
pub use editor::LineState;
|
||||||
|
pub use ghost::ghost_suggestion;
|
||||||
pub use lexer::tokenize;
|
pub use lexer::tokenize;
|
||||||
pub use pipeline::{split_pipeline, Pipeline, Stage};
|
pub use pipeline::{split_pipeline, Pipeline, Stage};
|
||||||
pub use token::{Token, TokenKind};
|
pub use token::{Token, TokenKind};
|
||||||
|
|||||||
Reference in New Issue
Block a user