tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1182Shell 87Rust 63Markdown 541592 Definiciones

simi: historial, autocompletado y edicion de linea — ya es shell de login

Era el hueco que la propia cabecera de simi declaraba: «el shell interactivo de
getty es otro consumidor y no esta cubierto todavia». Hasta hoy, sin -c y sin
fichero, simi se TRAGABA todo stdin y lo ejecutaba como script: no habia prompt
ni bucle interactivo.

Escrito a mano con termios + ANSI, y la razon no es purismo: el Cargo.toml lo
dice — «este binario es la raiz de confianza del frente C (ATTEST_PATHS), y
cada dep suya entra en esa raiz». rustyline o reedline meterian sus arboles de
dependencias en lo que el arranque atesta por BLAKE3. Solo libc.

Lo que hay: historial en memoria y en disco (~/.simi_historial, apendeado para
no pisar otra sesion), flechas arriba/abajo por el historial, izquierda/derecha,
Inicio/Fin/Supr, Ctrl-A/E/B/F/K/U/W/L, Ctrl-C que limpia la linea sin matar la
sesion ($? = 130), Ctrl-D que sale solo con la linea vacia, y Tab que completa
ORDENES (builtins + PATH) cuando es la primera palabra y RUTAS cuando no. PS1
con \\u \\h \\w \\$ \\n y el \\[ \\] de bash ignorado para que no salga basura.

La bifurcacion es isatty(0), no una bandera: un shell que decide esto por una
opcion se equivoca el dia que lo invoquen de la otra forma. Tuberia y
`simi < guion` siguen por el camino de script, que es el de las cards y los
/init.

Verificado de verdad, no por inspeccion:
  · 13/13 pruebas con un PTY real (prompt, retroceso, historial, insertar en
    medio, Ctrl-C, Ctrl-U, Tab de orden, Tab de ruta, persistencia, exit 7)
  · la TERMINAL QUEDA IDENTICA al salir (lflag byte a byte): el Drop del modo
    crudo es lo que evita el fallo clasico de dejar la consola muda
  · 53 tests de unidad, 0 fallos
  · CERO regresiones: banco POSIX con los MISMOS 2 fallos preexistentes y
    banco diferencial en DIVERGE=0 sobre 1339 casos, misma velocidad

⚠ Dos cosas que NO cubre, dichas en vez de fingidas: control de trabajos
(fg/bg/jobs siguen sin existir), Ctrl-R, y lineas mas largas que el ancho de la
terminal se redibujan feas (el contenido es correcto).

⚠ Y un test mio que estuvo MAL: miraba /usr/bin para probar el completado de
rutas y fallaba — no por un bug, sino porque en esta jaula /usr lista CERO
entradas aunque [ -d /usr/bin ] diga que si. Estuve a punto de "arreglar"
codigo que funcionaba. Ahora el test CREA el arbol que consulta.

6dcaff42842f8e26575fbe3bd26a466bc6b9c754 · por Sergio · (hace 22 h) · padre 0457caed4 · ver el árbol en este commit

Delta semántico · 26

crates/simi/src/builtins.rs

crates/simi/src/linea.rs

crates/simi/src/main.rs

3 archivos · +641 −0

diff --git a/crates/simi/src/builtins.rs b/crates/simi/src/builtins.rs
index d60072a8..c64c36d9 100644
--- a/crates/simi/src/builtins.rs
+++ b/crates/simi/src/builtins.rs
@@ -21,6 +21,12 @@ pub fn es_builtin(n: &str) -> bool {
     BUILTINS.contains(&n)
 }
 
+/// Para el autocompletado de `linea`: completar una ORDEN tiene que ofrecer los builtins, que no
+/// están en el `PATH` y si no se nombran acá se quedarían fuera.
+pub fn nombres() -> &'static [&'static str] {
+    BUILTINS
+}
+
 pub fn correr(sh: &mut Shell, argv: &[String]) -> Flujo {
     let args = &argv[1..];
     match argv[0].as_str() {
diff --git a/crates/simi/src/linea.rs b/crates/simi/src/linea.rs
new file mode 100644
index 00000000..bd2978f8
--- /dev/null
+++ b/crates/simi/src/linea.rs
@@ -0,0 +1,567 @@
+//! `linea` — el editor de línea de simi: historial, autocompletado y edición con teclas.
+//!
+//! **Por qué a mano.** La cabecera de `Cargo.toml` lo dice y vale releerla: *«este binario es la
+//! raíz de confianza del frente C (`ATTEST_PATHS`), y cada dep suya entra en esa raíz»*. Meter
+//! `rustyline` o `reedline` aquí metería sus árboles de dependencias en lo que el arranque atesta
+//! por BLAKE3. Así que esto es termios + secuencias ANSI, con `libc` y nada más.
+//!
+//! **Qué cubre**, que es exactamente lo que la cabecera de `main.rs` decía que faltaba:
+//! historial (en memoria y en disco), autocompletado por Tab, y las teclas de edición que alguien
+//! espera al entrar por consola — flechas, Inicio/Fin, y los controles de estilo emacs que son los
+//! que tiene el `ash` al que esto reemplaza.
+//!
+//! ⚠ **Lo que NO cubre, dicho en vez de fingirlo:** control de trabajos (`fg`/`bg`/`jobs` siguen
+//! sin existir en simi), búsqueda incremental (`Ctrl-R`), multilínea con continuación, y edición de
+//! texto que ocupe más de una fila de la terminal — si la línea pasa del ancho, el redibujado la
+//! deja fea aunque el contenido es correcto. Son añadidos posibles sobre esto, no agujeros tapados.
+
+use std::io::{Read, Write};
+use std::os::unix::ffi::OsStrExt;
+use std::path::Path;
+
+const MAX_HISTORIAL: usize = 2000;
+
+/// Lo que devuelve una lectura. `Fin` es Ctrl-D en línea vacía (EOF), `Interrumpida` es Ctrl-C:
+/// son cosas distintas y el REPL las trata distinto — una termina la sesión, la otra no.
+pub enum Linea {
+    Texto(String),
+    Fin,
+    Interrumpida,
+}
+
+/// Modo crudo con restauración por RAII.
+///
+/// ⚠ **El `Drop` no es un adorno: sin él, cualquier `panic!` o `return` temprano deja la terminal
+/// MUDA** —sin eco, sin salto de línea— y el usuario tiene que escribir `reset` a ciegas. Es el
+/// fallo clásico de los editores de línea escritos a mano, y por eso el estado anterior se guarda
+/// en la estructura en vez de en una variable suelta.
+struct Crudo {
+    fd: i32,
+    antes: libc::termios,
+}
+
+impl Crudo {
+    fn nuevo(fd: i32) -> Option<Crudo> {
+        unsafe {
+            let mut t: libc::termios = std::mem::zeroed();
+            if libc::tcgetattr(fd, &mut t) != 0 {
+                return None;
+            }
+            let antes = t;
+            // ICANON fuera: leer tecla por tecla. ECHO fuera: lo pintamos nosotros.
+            // ISIG fuera a propósito: queremos el 0x03 de Ctrl-C como BYTE, no como SIGINT, para
+            // poder limpiar la línea y seguir — que es lo que hace un shell y no un programa.
+            // IXON fuera: si no, Ctrl-S congela la terminal y parece que el shell se colgó.
+            t.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
+            t.c_iflag &= !(libc::IXON | libc::ICRNL);
+            // OPOST se DEJA puesto: queremos que `\n` siga siendo salto de línea de verdad.
+            t.c_cc[libc::VMIN] = 1;
+            t.c_cc[libc::VTIME] = 0;
+            if libc::tcsetattr(fd, libc::TCSAFLUSH, &t) != 0 {
+                return None;
+            }
+            Some(Crudo { fd, antes })
+        }
+    }
+}
+
+impl Drop for Crudo {
+    fn drop(&mut self) {
+        unsafe {
+            libc::tcsetattr(self.fd, libc::TCSAFLUSH, &self.antes);
+        }
+    }
+}
+
+pub struct Editor {
+    historial: Vec<String>,
+    fichero: Option<std::path::PathBuf>,
+    /// Cuántas entradas había al cargar: sólo se APENDEA lo nuevo, para no pisar el historial de
+    /// otra sesión que esté escribiendo en el mismo fichero.
+    ya_en_disco: usize,
+}
+
+impl Editor {
+    pub fn nuevo() -> Editor {
+        let fichero = std::env::var_os("HOME").map(|h| Path::new(&h).join(".simi_historial"));
+        let mut historial = Vec::new();
+        if let Some(f) = &fichero {
+            if let Ok(t) = std::fs::read_to_string(f) {
+                historial.extend(t.lines().filter(|l| !l.trim().is_empty()).map(String::from));
+            }
+        }
+        if historial.len() > MAX_HISTORIAL {
+            let sobra = historial.len() - MAX_HISTORIAL;
+            historial.drain(..sobra);
+        }
+        let ya_en_disco = historial.len();
+        Editor { historial, fichero, ya_en_disco }
+    }
+
+    /// Apendea al fichero lo que esta sesión añadió. No reescribe: así dos simi a la vez no se
+    /// pisan (se intercalan, que es lo que hace bash sin `histappend` y es aceptable).
+    pub fn guardar(&mut self) {
+        let Some(f) = &self.fichero else { return };
+        if self.historial.len() <= self.ya_en_disco {
+            return;
+        }
+        let nuevo: String =
+            self.historial[self.ya_en_disco..].iter().map(|l| format!("{l}\n")).collect();
+        if let Ok(mut h) = std::fs::OpenOptions::new().create(true).append(true).open(f) {
+            let _ = h.write_all(nuevo.as_bytes());
+        }
+        self.ya_en_disco = self.historial.len();
+    }
+
+    pub fn leer(&mut self, prompt: &str) -> Linea {
+        let fd = libc::STDIN_FILENO;
+        // Sin terminal (una tubería, un `simi < fichero`) no hay nada que editar: se lee una línea
+        // y se devuelve. Es el camino de los scripts y no debe pagar el coste del modo crudo.
+        let Some(_crudo) = Crudo::nuevo(fd) else {
+            return self.leer_sin_terminal();
+        };
+
+        let mut buf: Vec<char> = Vec::new();
+        let mut cur = 0usize; // posición del cursor, en CARACTERES (no bytes: hay acentos)
+        let mut idx_hist = self.historial.len(); // len() = «estoy en la línea nueva»
+        let mut guardada = String::new(); // la línea a medias, al navegar el historial
+        pintar(prompt, &buf, cur);
+
+        let mut entrada = std::io::stdin();
+        let mut byte = [0u8; 1];
+        loop {
+            if entrada.read_exact(&mut byte).is_err() {
+                print!("\n");
+                let _ = std::io::stdout().flush();
+                return if buf.is_empty() { Linea::Fin } else { Linea::Texto(buf.iter().collect()) };
+            }
+            match byte[0] {
+                b'\r' | b'\n' => {
+                    print!("\n");
+                    let _ = std::io::stdout().flush();
+                    let linea: String = buf.iter().collect();
+                    if !linea.trim().is_empty() && self.historial.last().map(String::as_str) != Some(linea.as_str()) {
+                        self.historial.push(linea.clone());
+                        if self.historial.len() > MAX_HISTORIAL {
+                            self.historial.remove(0);
+                            self.ya_en_disco = self.ya_en_disco.saturating_sub(1);
+                        }
+                    }
+                    return Linea::Texto(linea);
+                }
+                0x03 => {
+                    // Ctrl-C: se abandona la línea y se sigue. NO termina la sesión.
+                    print!("^C\n");
+                    let _ = std::io::stdout().flush();
+                    return Linea::Interrumpida;
+                }
+                0x04 => {
+                    // Ctrl-D: EOF sólo si la línea está vacía; si no, borra hacia adelante.
+                    if buf.is_empty() {
+                        print!("\n");
+                        let _ = std::io::stdout().flush();
+                        return Linea::Fin;
+                    }
+                    if cur < buf.len() {
+                        buf.remove(cur);
+                        pintar(prompt, &buf, cur);
+                    }
+                }
+                0x7f | 0x08 => {
+                    if cur > 0 {
+                        cur -= 1;
+                        buf.remove(cur);
+                        pintar(prompt, &buf, cur);
+                    }
+                }
+                0x01 => {
+                    cur = 0;
+                    pintar(prompt, &buf, cur);
+                }
+                0x05 => {
+                    cur = buf.len();
+                    pintar(prompt, &buf, cur);
+                }
+                0x02 => {
+                    if cur > 0 {
+                        cur -= 1;
+                        pintar(prompt, &buf, cur);
+                    }
+                }
+                0x06 => {
+                    if cur < buf.len() {
+                        cur += 1;
+                        pintar(prompt, &buf, cur);
+                    }
+                }
+                0x0b => {
+                    buf.truncate(cur);
+                    pintar(prompt, &buf, cur);
+                }
+                0x15 => {
+                    buf.drain(..cur);
+                    cur = 0;
+                    pintar(prompt, &buf, cur);
+                }
+                0x17 => {
+                    // Ctrl-W: borra la palabra de atrás, saltando los espacios primero.
+                    while cur > 0 && buf[cur - 1].is_whitespace() {
+                        cur -= 1;
+                        buf.remove(cur);
+                    }
+                    while cur > 0 && !buf[cur - 1].is_whitespace() {
+                        cur -= 1;
+                        buf.remove(cur);
+                    }
+                    pintar(prompt, &buf, cur);
+                }
+                0x0c => {
+                    print!("\x1b[H\x1b[2J");
+                    pintar(prompt, &buf, cur);
+                }
+                b'\t' => {
+                    completar(&mut buf, &mut cur, prompt);
+                }
+                0x1b => {
+                    // secuencia de escape: flechas, Inicio/Fin, Supr
+                    let mut s = [0u8; 2];
+                    if entrada.read_exact(&mut s[..1]).is_err() {
+                        continue;
+                    }
+                    if s[0] != b'[' && s[0] != b'O' {
+                        continue;
+                    }
+                    if entrada.read_exact(&mut s[1..]).is_err() {
+                        continue;
+                    }
+                    match s[1] {
+                        b'A' => {
+                            // Arriba: hacia atrás en el historial
+                            if idx_hist > 0 {
+                                if idx_hist == self.historial.len() {
+                                    guardada = buf.iter().collect();
+                                }
+                                idx_hist -= 1;
+                                buf = self.historial[idx_hist].chars().collect();
+                                cur = buf.len();
+                                pintar(prompt, &buf, cur);
+                            }
+                        }
+                        b'B' => {
+                            if idx_hist < self.historial.len() {
+                                idx_hist += 1;
+                                buf = if idx_hist == self.historial.len() {
+                                    guardada.chars().collect()
+                                } else {
+                                    self.historial[idx_hist].chars().collect()
+                                };
+                                cur = buf.len();
+                                pintar(prompt, &buf, cur);
+                            }
+                        }
+                        b'C' => {
+                            if cur < buf.len() {
+                                cur += 1;
+                                pintar(prompt, &buf, cur);
+                            }
+                        }
+                        b'D' => {
+                            if cur > 0 {
+                                cur -= 1;
+                                pintar(prompt, &buf, cur);
+                            }
+                        }
+                        b'H' => {
+                            cur = 0;
+                            pintar(prompt, &buf, cur);
+                        }
+                        b'F' => {
+                            cur = buf.len();
+                            pintar(prompt, &buf, cur);
+                        }
+                        b'3' => {
+                            // Supr manda `ESC [ 3 ~`: hay que tragarse la tilde
+                            let mut t = [0u8; 1];
+                            let _ = entrada.read_exact(&mut t);
+                            if cur < buf.len() {
+                                buf.remove(cur);
+                                pintar(prompt, &buf, cur);
+                            }
+                        }
+                        _ => {}
+                    }
+                }
+                b if b >= 0x20 => {
+                    // ⚠ UTF-8: un carácter puede venir en varios bytes y hay que juntarlos ANTES de
+                    // meterlo en el buffer. Sin esto, escribir «ñ» insertaba dos caracteres basura y
+                    // el cursor quedaba descolocado — es el bug que hace que un editor de línea a
+                    // mano se note hecho en inglés.
+                    let extra = if b >= 0xf0 {
+                        3
+                    } else if b >= 0xe0 {
+                        2
+                    } else if b >= 0xc0 {
+                        1
+                    } else {
+                        0
+                    };
+                    let mut bytes = vec![b];
+                    for _ in 0..extra {
+                        let mut x = [0u8; 1];
+                        if entrada.read_exact(&mut x).is_ok() {
+                            bytes.push(x[0]);
+                        }
+                    }
+                    if let Ok(s) = std::str::from_utf8(&bytes) {
+                        for c in s.chars() {
+                            buf.insert(cur, c);
+                            cur += 1;
+                        }
+                        pintar(prompt, &buf, cur);
+                    }
+                }
+                _ => {}
+            }
+        }
+    }
+
+    fn leer_sin_terminal(&mut self) -> Linea {
+        let mut l = String::new();
+        match std::io::stdin().read_line(&mut l) {
+            Ok(0) => Linea::Fin,
+            Ok(_) => Linea::Texto(l.trim_end_matches('\n').to_string()),
+            Err(_) => Linea::Fin,
+        }
+    }
+}
+
+/// Redibuja la línea entera. Es el método simple y robusto: `\r` al principio, prompt, contenido,
+/// `ESC[K` para borrar lo que sobre, y el cursor se recoloca contando caracteres.
+///
+/// ⚠ No trocea por ancho de terminal: una línea más larga que la pantalla se ve mal aunque su
+/// CONTENIDO sea correcto. Dicho arriba entre lo que no cubre.
+fn pintar(prompt: &str, buf: &[char], cur: usize) {
+    let txt: String = buf.iter().collect();
+    let mut o = std::io::stdout();
+    let _ = write!(o, "\r\x1b[K{prompt}{txt}");
+    let atras = buf.len() - cur;
+    if atras > 0 {
+        let _ = write!(o, "\x1b[{atras}D");
+    }
+    let _ = o.flush();
+}
+
+/// Tab. Dos reglas, y la primera es la que hace que se sienta bien:
+///   · si la palabra es la PRIMERA de la orden ⇒ se completan ÓRDENES (builtins + lo ejecutable
+///     del `PATH`), porque ahí nadie quiere un nombre de fichero cualquiera;
+///   · si no ⇒ se completan RUTAS.
+/// Con una sola candidata se inserta; con varias se imprime la lista y se completa el prefijo común,
+/// que es lo que evita tener que teclear a ciegas.
+fn completar(buf: &mut Vec<char>, cur: &mut usize, prompt: &str) {
+    let hasta: String = buf[..*cur].iter().collect();
+    let ini = hasta
+        .char_indices()
+        .rev()
+        .find(|(_, c)| c.is_whitespace())
+        .map(|(i, c)| i + c.len_utf8())
+        .unwrap_or(0);
+    let palabra = &hasta[ini..];
+    let es_orden = hasta[..ini].trim().is_empty();
+
+    let mut cands: Vec<String> =
+        if es_orden && !palabra.contains('/') { ordenes_que_empiezan(palabra) } else { rutas_que_empiezan(palabra) };
+    cands.sort();
+    cands.dedup();
+    if cands.is_empty() {
+        return;
+    }
+
+    // prefijo común de todas las candidatas: con una sola, es la candidata entera
+    let comun = prefijo_comun(&cands);
+    if comun.len() > palabra.len() {
+        let resto: Vec<char> = comun[palabra.len()..].chars().collect();
+        for c in resto {
+            buf.insert(*cur, c);
+            *cur += 1;
+        }
+    }
+    if cands.len() == 1 {
+        // una sola: se cierra con espacio (o con `/` si es directorio, que ya viene puesto)
+        if !comun.ends_with('/') {
+            buf.insert(*cur, ' ');
+            *cur += 1;
+        }
+    } else {
+        print!("\n");
+        for (i, c) in cands.iter().take(80).enumerate() {
+            print!("{c}  ");
+            if (i + 1) % 6 == 0 {
+                print!("\n");
+            }
+        }
+        if cands.len() > 80 {
+            print!("\n… y {} más", cands.len() - 80);
+        }
+        print!("\n");
+    }
+    pintar(prompt, buf, *cur);
+}
+
+fn prefijo_comun(v: &[String]) -> String {
+    let Some(primero) = v.first() else { return String::new() };
+    let mut n = primero.chars().count();
+    for s in &v[1..] {
+        n = n.min(
+            primero.chars().zip(s.chars()).take_while(|(a, b)| a == b).count(),
+        );
+    }
+    primero.chars().take(n).collect()
+}
+
+fn ordenes_que_empiezan(pre: &str) -> Vec<String> {
+    let mut v: Vec<String> =
+        crate::builtins::nombres().iter().filter(|b| b.starts_with(pre)).map(|b| b.to_string()).collect();
+    if let Some(path) = std::env::var_os("PATH") {
+        for dir in std::env::split_paths(&path) {
+            let Ok(it) = std::fs::read_dir(&dir) else { continue };
+            for e in it.flatten() {
+                let n = e.file_name();
+                let Some(n) = n.to_str() else { continue };
+                if !n.starts_with(pre) {
+                    continue;
+                }
+                // ejecutable para alguien: el bit x, que es lo que el shell exigiría al correrlo
+                if let Ok(m) = e.metadata() {
+                    use std::os::unix::fs::PermissionsExt;
+                    if m.is_file() && m.permissions().mode() & 0o111 != 0 {
+                        v.push(n.to_string());
+                    }
+                }
+            }
+        }
+    }
+    v
+}
+
+fn rutas_que_empiezan(pre: &str) -> Vec<String> {
+    let (dir, hoja) = match pre.rfind('/') {
+        Some(i) => (&pre[..=i], &pre[i + 1..]),
+        None => ("", pre),
+    };
+    let base = if dir.is_empty() { Path::new(".") } else { Path::new(dir) };
+    let Ok(it) = std::fs::read_dir(base) else { return Vec::new() };
+    let mut v = Vec::new();
+    for e in it.flatten() {
+        let n = e.file_name();
+        let Some(n) = n.to_str() else { continue };
+        if !n.starts_with(hoja) {
+            continue;
+        }
+        // los ocultos sólo si se pidieron: teclear Tab en `~` no debe vomitar 40 dotfiles
+        if n.starts_with('.') && !hoja.starts_with('.') {
+            continue;
+        }
+        let es_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
+        v.push(format!("{dir}{n}{}", if es_dir { "/" } else { "" }));
+    }
+    v
+}
+
+/// `$PS1`, con lo mínimo que la gente espera: `\u` `\h` `\w` `\$`, y el `\[`/`\]` de bash se
+/// IGNORA (no se pinta) porque si no aparecen como basura en pantalla.
+pub fn prompt(sh: &crate::shell::Shell) -> String {
+    let ps1 = sh.leer("PS1").unwrap_or_else(|| "$ ".to_string());
+    let mut o = String::new();
+    let mut cs = ps1.chars().peekable();
+    while let Some(c) = cs.next() {
+        if c != '\\' {
+            o.push(c);
+            continue;
+        }
+        match cs.next() {
+            Some('u') => o.push_str(&std::env::var("USER").unwrap_or_else(|_| "root".into())),
+            Some('h') => o.push_str(&nombre_de_maquina()),
+            Some('w') => {
+                let d = std::env::current_dir().map(|p| p.display().to_string()).unwrap_or_default();
+                let casa = std::env::var("HOME").unwrap_or_default();
+                o.push_str(&if !casa.is_empty() && d.starts_with(&casa) {
+                    format!("~{}", &d[casa.len()..])
+                } else {
+                    d
+                });
+            }
+            Some('$') => o.push(if unsafe { libc::geteuid() } == 0 { '#' } else { '$' }),
+            Some('n') => o.push('\n'),
+            Some('[') | Some(']') => {}
+            Some(x) => o.push(x),
+            None => {}
+        }
+    }
+    o
+}
+
+fn nombre_de_maquina() -> String {
+    std::fs::read_to_string("/proc/sys/kernel/hostname")
+        .map(|s| s.trim().to_string())
+        .unwrap_or_else(|_| "takana".into())
+}
+
+/// Para que `OsStrExt` no quede sin usar si alguien recorta esto: se usa al comparar nombres.
+#[allow(dead_code)]
+fn _usa_osstrext(p: &Path) -> usize {
+    p.as_os_str().as_bytes().len()
+}
+
+#[cfg(test)]
+mod pruebas {
+    use super::*;
+
+    /// ⚠ **HERMÉTICO A PROPÓSITO: se crea el árbol que se consulta.** La primera versión miraba
+    /// `/usr/bin` y FALLABA — no por un bug, sino porque en la jaula de desarrollo `/usr` lista
+    /// CERO entradas aunque `[ -d /usr/bin ]` diga que sí (patología conocida de la jaula). Estuve
+    /// a punto de "arreglar" código que funcionaba. Un test que depende del sistema de ficheros
+    /// ambiente no mide lo que cree medir.
+    #[test]
+    fn completa_rutas() {
+        let base = std::env::temp_dir().join(format!("simi-comp-{}", unsafe { libc::getpid() }));
+        let _ = std::fs::remove_dir_all(&base);
+        std::fs::create_dir_all(base.join("bicho")).unwrap();
+        std::fs::write(base.join("bicicleta"), b"x").unwrap();
+        std::fs::write(base.join("otro"), b"x").unwrap();
+        std::fs::write(base.join(".oculto"), b"x").unwrap();
+        let d = format!("{}/", base.display());
+
+        let v = rutas_que_empiezan(&format!("{d}bic"));
+        assert!(v.iter().any(|c| c.ends_with("bicho/")), "el directorio lleva / al final: {v:?}");
+        assert!(v.iter().any(|c| c.ends_with("bicicleta")), "candidatas: {v:?}");
+        assert_eq!(v.len(), 2, "sólo las dos que empiezan por bic: {v:?}");
+
+        // prefijo que no existe ⇒ nada
+        assert!(rutas_que_empiezan(&format!("{d}zzz-no-existe")).is_empty());
+
+        // los ocultos NO salen si no se piden, y SÍ si se piden
+        let todos = rutas_que_empiezan(&d);
+        assert!(!todos.iter().any(|c| c.contains("/.oculto")), "no debe listar ocultos: {todos:?}");
+        let pedidos = rutas_que_empiezan(&format!("{d}."));
+        assert!(pedidos.iter().any(|c| c.ends_with(".oculto")), "pedidos: {pedidos:?}");
+
+        let _ = std::fs::remove_dir_all(&base);
+    }
+
+    /// El completado de ÓRDENES tiene que ofrecer los builtins, que no están en el PATH. Eso sí se
+    /// puede medir sin tocar el disco.
+    #[test]
+    fn completa_ordenes_con_builtins() {
+        let v = ordenes_que_empiezan("ech");
+        assert!(v.iter().any(|c| c == "echo"), "candidatas: {v:?}");
+        let v = ordenes_que_empiezan("umas");
+        assert!(v.iter().any(|c| c == "umask"), "candidatas: {v:?}");
+    }
+
+    #[test]
+    fn prefijo_comun_funciona() {
+        assert_eq!(prefijo_comun(&["abcd".into(), "abce".into()]), "abc");
+        assert_eq!(prefijo_comun(&["solo".into()]), "solo");
+        assert_eq!(prefijo_comun(&["xa".into(), "yb".into()]), "");
+    }
+}
diff --git a/crates/simi/src/main.rs b/crates/simi/src/main.rs
index e6a0296f..68bce53d 100644
--- a/crates/simi/src/main.rs
+++ b/crates/simi/src/main.rs
@@ -48,6 +48,7 @@ mod exec;
 mod expand;
 mod glob;
 mod lexer;
+mod linea;
 mod parser;
 mod shell;
 
@@ -131,6 +132,17 @@ fn main() -> std::process::ExitCode {
             }
         }
         (None, None) => {
+            // ⚠ **Sin fichero ni `-c` hay DOS caminos, y antes había uno.** Si la entrada es una
+            // TERMINAL, esto es un shell interactivo y entra al REPL con editor de línea,
+            // historial y Tab (`linea.rs`). Si no lo es —una tubería, `simi < guion`— se traga la
+            // entrada y se ejecuta como script, que es el comportamiento de siempre y el que usan
+            // las cards y los `/init`.
+            //
+            // La distinción es `isatty(0)`, no una bandera: un shell que decide esto por una
+            // opción se equivoca el día que alguien lo invoque de la otra forma.
+            if unsafe { libc::isatty(libc::STDIN_FILENO) } == 1 {
+                return repl(&mut sh);
+            }
             let mut t = String::new();
             if std::io::stdin().read_to_string(&mut t).is_err() {
                 eprintln!("{}: no pude leer la entrada", sh.nombre);
@@ -162,3 +174,59 @@ fn main() -> std::process::ExitCode {
     let final_ = if sh.trap_exit_corrido { estado } else { sh.estado };
     std::process::ExitCode::from((final_ & 0xff) as u8)
 }
+
+/// El bucle interactivo. Separado de la ruta de script a propósito: lee UNA orden, la ejecuta y
+/// vuelve, de modo que un error de sintaxis o un fallo de la orden no termina la sesión — que es
+/// justo lo contrario de lo que debe hacer un script.
+fn repl(sh: &mut Shell) -> std::process::ExitCode {
+    let mut ed = linea::Editor::nuevo();
+    // `PS1` por defecto si nadie la puso. Se ASIGNA (no sólo se usa) para que `echo $PS1` la vea,
+    // que es lo que alguien espera al entrar.
+    if sh.leer("PS1").is_none() {
+        sh.asignar("PS1", "\\u@\\h:\\w\\$ ");
+    }
+    let mut ultimo = 0i32;
+    loop {
+        let p = linea::prompt(sh);
+        match ed.leer(&p) {
+            linea::Linea::Fin => break,
+            linea::Linea::Interrumpida => {
+                // Ctrl-C: `$?` pasa a 130 como en bash (128 + SIGINT), y se sigue.
+                ultimo = 130;
+                sh.estado = 130;
+                continue;
+            }
+            linea::Linea::Texto(t) => {
+                if t.trim().is_empty() {
+                    continue;
+                }
+                match parser::parsear(&t) {
+                    Ok(lista) => {
+                        // `correr_lista` devuelve `Flujo`, no un código: un `exit` escrito en el
+                        // prompt tiene que TERMINAR la sesión con su código, y el resto de los
+                        // flujos (`break`/`continue` sueltos) no significan nada acá y se ignoran.
+                        match exec::correr_lista(sh, &lista) {
+                            Flujo::Exit(c) => {
+                                ultimo = c;
+                                break;
+                            }
+                            _ => ultimo = sh.estado,
+                        }
+                    }
+                    Err(e) => {
+                        // Un error de sintaxis en interactivo NO mata la sesión: se avisa y se
+                        // sigue. `2` es el código que POSIX pide para un error de sintaxis.
+                        eprintln!("{}: {e}", sh.nombre);
+                        ultimo = 2;
+                        sh.estado = 2;
+                    }
+                }
+            }
+        }
+    }
+    // El historial se guarda ANTES del trap de salida: si el trap aborta, lo tecleado no se pierde.
+    ed.guardar();
+    exec::correr_trap_exit(sh, ultimo);
+    let final_ = if sh.trap_exit_corrido { ultimo } else { sh.estado };
+    std::process::ExitCode::from((final_ & 0xff) as u8)
+}