feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-module-shuma-term"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-shuma-term — terminal integrado tipo Ctrl+\\` de VS Code. Módulo Llimphi sobre shuma-exec (PTY real) + vt100 (emulación). Cualquier app Llimphi puede enchufar un terminal sandboxeado por el shell del usuario."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
shuma-exec = { path = "../../../shuma/sandbox/shuma-exec" }
vt100 = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-shuma-term
> Terminal embebida (shell shuma) de [llimphi](../../README.md).
Wrapper de [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) montable adentro de cualquier app. Útil para `nada`, IDE-like setups.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-shuma-term
> Embedded terminal (shuma shell) of [llimphi](../../README.md).
Wrapper of [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) mountable inside any app. Useful for `nada`, IDE-like setups.
+511
View File
@@ -0,0 +1,511 @@
//! `llimphi-module-shuma-term` — terminal integrado al estilo Ctrl+` de
//! VS Code o "Terminal" de JetBrains, pero enchufable en cualquier app
//! Llimphi.
//!
//! Lo monta sobre dos piezas que ya existen:
//!
//! - [`shuma_exec::Exec::Pty`] aloja un pseudo-terminal cross-platform
//! (`portable-pty`), lanza el shell con `TERM=xterm-256color`, y
//! entrega los bytes crudos por un canal MPSC. El módulo no toca
//! syscalls — sólo consume eventos.
//! - [`vt100::Parser`] convierte esos bytes en un buffer de pantalla
//! ANSI: cursor, erase, OSC, scrollback. El módulo le pasa los bytes
//! y al renderizar pide `screen().contents()`.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`: `State + Msg +
//! Action + apply/on_key/open_shortcut/view + Palette`.
//!
//! ## Cómo lo enchufa una app (resumen)
//!
//! ```ignore
//! struct Model { term: Option<ShumaTermState>, … }
//! enum Msg { Term(ShumaTermMsg), Tick, … }
//!
//! // open: shuma_term::spawn("/home/user", 100, 30)?
//! // on_key: si term.is_some() y on_key devuelve Some(msg) → Msg::Term(msg)
//! // si term.is_none() y open_shortcut(ev) → Msg::Term(Open)
//! // tick periódico: dispatch Msg::Term(Tick) para drenar PTY
//! // apply: match action { Close → model.term = None, SetStatus(s) → … }
//! // view: si term.is_some() → push view(...)
//! ```
#![forbid(unsafe_code)]
use std::time::Instant;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle};
/// Capabilities que aporta este módulo al host. El host las puede
/// agregar a `provides` en su `card_core::Card` para que el broker
/// chasqui descubra que la instancia ofrece terminal integrado.
pub const CAPABILITIES: &[&str] = &["editor.terminal"];
/// Dimensiones por defecto del PTY. Cubren un panel inferior tipo
/// VS Code en una pantalla 1080p. Las apps pueden pasar otras a
/// [`spawn_with`].
pub const DEFAULT_COLS: u16 = 100;
pub const DEFAULT_ROWS: u16 = 24;
const SCROLLBACK: usize = 2000;
// =====================================================================
// State
// =====================================================================
/// Estado del panel terminal. Encapsula el `RunHandle` del shell y un
/// `vt100::Parser` que mantiene el buffer de pantalla. No es `Clone`
/// (los handles son únicos), y el host lo embebe como
/// `Option<ShumaTermState>`.
pub struct ShumaTermState {
handle: RunHandle,
killer: Killer,
parser: vt100::Parser,
cols: u16,
rows: u16,
/// Si el shell ya emitió `Exited(code)`. El panel se queda visible
/// para que el usuario pueda leer la última salida antes de cerrar.
exit_code: Option<i32>,
/// CWD inicial — útil para el header sin tener que tocar /proc.
cwd: String,
started_at: Instant,
}
impl ShumaTermState {
/// Bytes que el módulo ya consumió desde el PTY. Útil para tests y
/// debug — no es parte del contrato Tier 1.
pub fn screen_contents(&self) -> String {
self.parser.screen().contents()
}
pub fn cols(&self) -> u16 {
self.cols
}
pub fn rows(&self) -> u16 {
self.rows
}
pub fn exit_code(&self) -> Option<i32> {
self.exit_code
}
pub fn cwd(&self) -> &str {
&self.cwd
}
}
impl Drop for ShumaTermState {
fn drop(&mut self) {
// Si el host descarta el state (panel cerrado), no dejamos al
// shell huérfano consumiendo CPU. SIGTERM educado primero;
// shuma-exec se encarga del SIGKILL si hace falta.
self.killer.term();
}
}
/// Lanza el shell por defecto (`$SHELL`, fallback `/bin/sh`) en `cwd`
/// con tamaño de PTY por defecto.
pub fn spawn(cwd: impl Into<String>) -> ShumaTermState {
spawn_with(cwd, default_shell(), Vec::new(), DEFAULT_COLS, DEFAULT_ROWS)
}
/// Variante con control fino de programa, args y tamaño.
pub fn spawn_with(
cwd: impl Into<String>,
program: String,
args: Vec<String>,
cols: u16,
rows: u16,
) -> ShumaTermState {
let cwd = cwd.into();
let spec = CommandSpec {
exec: Exec::Pty { program, args, cols, rows },
cwd: cwd.clone(),
capture_limit: 0,
spill_path: None,
stdin_data: None,
capture_stages: false,
};
let handle = shuma_exec::run(&spec);
let killer = handle.killer();
ShumaTermState {
handle,
killer,
parser: vt100::Parser::new(rows, cols, SCROLLBACK),
cols,
rows,
exit_code: None,
cwd,
started_at: Instant::now(),
}
}
fn default_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
// =====================================================================
// Msg / Action
// =====================================================================
/// Vocabulario interno. El host lo wrapea en su `Msg`.
#[derive(Debug, Clone)]
pub enum ShumaTermMsg {
/// Símbolo conveniente para que el host dispatche al detectar el
/// shortcut. El módulo no crea el state él mismo — el host lo crea
/// con [`spawn`] porque conoce el cwd canónico de la app.
Open,
/// El usuario pidió cerrar el panel.
Close,
/// Tecla mientras el panel está enfocado. Se traduce a bytes y se
/// reenvía al PTY.
KeyInput(KeyEvent),
/// Tick del host: drena eventos pendientes del PTY (bytes y exit).
/// El host debe enviar este Msg de forma periódica (en cada frame,
/// o cuando hay actividad). Sin Tick el terminal no avanza.
Tick,
/// Mata el shell (SIGTERM); el panel queda visible mostrando el
/// estado final hasta que el host reciba `Close`.
Terminate,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShumaTermAction {
None,
/// El host debería remover el state del modelo.
Close,
/// El host debería actualizar su barra de estado.
SetStatus(String),
}
// =====================================================================
// apply / on_key / open_shortcut
// =====================================================================
/// Aplica un mensaje al estado.
pub fn apply(state: &mut ShumaTermState, msg: ShumaTermMsg) -> ShumaTermAction {
match msg {
ShumaTermMsg::Open => ShumaTermAction::None,
ShumaTermMsg::Close => ShumaTermAction::Close,
ShumaTermMsg::Terminate => {
state.killer.term();
ShumaTermAction::SetStatus("shuma · SIGTERM".into())
}
ShumaTermMsg::Tick => drain(state),
ShumaTermMsg::KeyInput(ev) => {
// Interceptaciones del módulo (no llegan al PTY):
// Ctrl+Shift+W → cierra el panel.
// Cualquier otra combinación se traduce a bytes y se envía.
if ev.state == KeyState::Pressed
&& ev.modifiers.ctrl
&& ev.modifiers.shift
&& matches!(&ev.key, Key::Character(s) if s.eq_ignore_ascii_case("w"))
{
return ShumaTermAction::Close;
}
let bytes = key_to_bytes(&ev);
if !bytes.is_empty() {
state.handle.write_input(bytes);
}
ShumaTermAction::None
}
}
}
/// Routing de teclas cuando el panel está enfocado. Devuelve `Some` para
/// todo evento `Pressed` — el terminal **traga** las teclas; el host no
/// debe reusarlas para sus propios atajos mientras este panel esté
/// activo (la excepción es el atajo de apertura, que el host filtra
/// antes de delegar).
pub fn on_key(_state: &ShumaTermState, event: &KeyEvent) -> Option<ShumaTermMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(ShumaTermMsg::KeyInput(event.clone()))
}
/// El atajo recomendado para abrir: **Ctrl+`** (backtick), igual que
/// VS Code. Los hosts pueden ignorarlo y usar otro.
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& !event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s == "`")
}
// =====================================================================
// Drenado del PTY
// =====================================================================
fn drain(state: &mut ShumaTermState) -> ShumaTermAction {
let mut bytes_in = 0usize;
let mut final_action = ShumaTermAction::None;
for ev in state.handle.try_events() {
match ev {
RunEvent::Bytes(b) => {
bytes_in += b.len();
state.parser.process(&b);
}
RunEvent::Exited(code) => {
state.exit_code = Some(code);
let elapsed = state.started_at.elapsed().as_secs_f64();
final_action = ShumaTermAction::SetStatus(format!(
"shuma · exit {code} · {elapsed:.1}s"
));
}
RunEvent::Failed(err) => {
state.exit_code = Some(-1);
final_action =
ShumaTermAction::SetStatus(format!("shuma · falló: {err}"));
}
// Stdout/Stderr/Truncated/Spilled no aplican al modo Pty.
_ => {}
}
}
if matches!(final_action, ShumaTermAction::None) && bytes_in > 0 {
// Nada que reportar — el repaint que el host hará por el frame
// basta para mostrar lo nuevo.
ShumaTermAction::None
} else {
final_action
}
}
// =====================================================================
// Mapeo KeyEvent → bytes
// =====================================================================
/// Convierte un `KeyEvent` ya recibido en los bytes que un terminal
/// xterm espera. Cubre el subset usable (chars + control + flechas +
/// home/end/page + fn keys), suficiente para shells modernos, TUIs
/// (vim, htop, less) y CLIs interactivas (claude code, fzf).
pub fn key_to_bytes(ev: &KeyEvent) -> Vec<u8> {
if ev.state != KeyState::Pressed {
return Vec::new();
}
// Teclas con nombre primero: flechas, etc. Se mapean a CSI/SS3
// estándar (xterm-256color).
if let Key::Named(named) = &ev.key {
return named_to_bytes(*named);
}
// Caracter: si hay Ctrl+letra → control byte (Ctrl+C = 0x03).
if let Key::Character(s) = &ev.key {
if ev.modifiers.ctrl && !ev.modifiers.alt {
if let Some(b) = ctrl_byte(s) {
return vec![b];
}
}
// Alt+x → ESC + x (convención xterm meta-sends-escape).
if ev.modifiers.alt {
let mut out = vec![0x1b];
out.extend_from_slice(s.as_bytes());
return out;
}
}
// Caso general: si el backend ya nos dio el texto resultante
// (con shift/IME aplicados), eso es lo correcto para mandar.
if let Some(text) = &ev.text {
return text.as_bytes().to_vec();
}
Vec::new()
}
fn named_to_bytes(k: NamedKey) -> Vec<u8> {
match k {
// PTYs en modo raw esperan CR para Enter; el driver convierte a LF.
NamedKey::Enter => b"\r".to_vec(),
// Backspace moderno = DEL (0x7f). Los shells lo entienden mejor
// que 0x08, que se reserva para ^H en TUIs viejos.
NamedKey::Backspace => vec![0x7f],
NamedKey::Tab => b"\t".to_vec(),
NamedKey::Escape => vec![0x1b],
NamedKey::ArrowUp => b"\x1b[A".to_vec(),
NamedKey::ArrowDown => b"\x1b[B".to_vec(),
NamedKey::ArrowRight => b"\x1b[C".to_vec(),
NamedKey::ArrowLeft => b"\x1b[D".to_vec(),
NamedKey::Home => b"\x1b[H".to_vec(),
NamedKey::End => b"\x1b[F".to_vec(),
NamedKey::PageUp => b"\x1b[5~".to_vec(),
NamedKey::PageDown => b"\x1b[6~".to_vec(),
NamedKey::Delete => b"\x1b[3~".to_vec(),
NamedKey::Insert => b"\x1b[2~".to_vec(),
NamedKey::F1 => b"\x1bOP".to_vec(),
NamedKey::F2 => b"\x1bOQ".to_vec(),
NamedKey::F3 => b"\x1bOR".to_vec(),
NamedKey::F4 => b"\x1bOS".to_vec(),
NamedKey::F5 => b"\x1b[15~".to_vec(),
NamedKey::F6 => b"\x1b[17~".to_vec(),
NamedKey::F7 => b"\x1b[18~".to_vec(),
NamedKey::F8 => b"\x1b[19~".to_vec(),
NamedKey::F9 => b"\x1b[20~".to_vec(),
NamedKey::F10 => b"\x1b[21~".to_vec(),
NamedKey::F11 => b"\x1b[23~".to_vec(),
NamedKey::F12 => b"\x1b[24~".to_vec(),
_ => Vec::new(),
}
}
/// Ctrl+letter → byte de control ASCII (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26).
/// Maneja también Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\\ (FS), Ctrl+] (GS),
/// Ctrl+^ (RS), Ctrl+_ (US), Ctrl+? (DEL).
fn ctrl_byte(s: &str) -> Option<u8> {
let c = s.chars().next()?;
match c {
'a'..='z' => Some((c as u8) - b'a' + 1),
'A'..='Z' => Some((c as u8) - b'A' + 1),
'@' => Some(0),
'[' => Some(0x1b),
'\\' => Some(0x1c),
']' => Some(0x1d),
'^' => Some(0x1e),
'_' => Some(0x1f),
'?' => Some(0x7f),
' ' => Some(0), // Ctrl+Space = NUL, convención xterm
_ => None,
}
}
// =====================================================================
// View
// =====================================================================
/// Paleta visual del terminal. Monospace; fondo más oscuro que el
/// panel general para que el terminal "viva" visualmente.
#[derive(Debug, Clone)]
pub struct ShumaTermPalette {
pub bg_panel: llimphi_ui::llimphi_raster::peniko::Color,
pub bg_header: llimphi_ui::llimphi_raster::peniko::Color,
pub fg_text: llimphi_ui::llimphi_raster::peniko::Color,
pub fg_muted: llimphi_ui::llimphi_raster::peniko::Color,
}
impl ShumaTermPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel_alt,
bg_header: t.bg_panel,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
}
}
}
const HEADER_H: f32 = 18.0;
const ROW_H: f32 = 14.0;
const CHAR_W: f32 = 7.5;
/// Render del panel. `to_host` mapea cada `ShumaTermMsg` al `Msg` del
/// host. `height_px` es la altura total del panel — el módulo divide
/// entre header + grid.
pub fn view<HostMsg, F>(
state: &ShumaTermState,
palette: &ShumaTermPalette,
height_px: f32,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(ShumaTermMsg) -> HostMsg + Copy + 'static,
{
let _ = to_host; // v0 no monta eventos puntuales sobre el grid
let header_text = match state.exit_code {
Some(code) => format!(
"shuma · {} · exit {code} · Ctrl+Shift+W cierra",
state.cwd
),
None => format!(
"shuma · {} · {}×{} · Ctrl+Shift+W cierra · Esc envía al shell",
state.cwd, state.cols, state.rows
),
};
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let contents = state.parser.screen().contents();
let grid_h = (height_px - HEADER_H).max(0.0);
let max_rows = ((grid_h / ROW_H) as usize).max(1);
// Tomamos las últimas `max_rows` líneas — preferimos mostrar el
// tail (donde está el cursor / prompt) si el render no alcanza
// para toda la pantalla.
let all_lines: Vec<&str> = contents.split('\n').collect();
let start = all_lines.len().saturating_sub(max_rows);
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
for line in &all_lines[start..] {
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.text_aligned((*line).to_string(), 11.0, palette.fg_text, Alignment::Start),
);
}
// Si el render quedó corto, rellenamos con líneas vacías para que el
// panel mantenga su altura visual.
while rows.len() < max_rows {
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
children.push(header);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(height_px) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
/// Estimación heurística de cuántas columnas caben en `width_px` con la
/// fuente actual. Útil para que el host calcule el tamaño antes de
/// llamar a [`spawn_with`].
pub fn cols_for_width(width_px: f32) -> u16 {
((width_px / CHAR_W).floor() as u16).max(20)
}
/// Idem para filas a partir de la altura disponible del panel
/// (descontando el header).
pub fn rows_for_height(height_px: f32) -> u16 {
(((height_px - HEADER_H) / ROW_H).floor() as u16).max(5)
}
+157
View File
@@ -0,0 +1,157 @@
//! Smoke test del terminal: spawnea un shell, le tipea `echo hola`,
//! drena hasta ver el output, y verifica que el contenido del screen
//! contenga "hola". Cierre con SIGTERM se valida por el Drop.
//!
//! Requiere `/bin/sh` y un sistema Linux real (no corre en sandbox
//! puro). Es razonable porque shuma-exec ya lo asume.
use std::time::{Duration, Instant};
use llimphi_module_shuma_term::{self as term, ShumaTermAction, ShumaTermMsg};
#[test]
fn echo_a_traves_del_pty_aparece_en_el_screen() {
let mut state = term::spawn_with(
"/tmp".to_string(),
"/bin/sh".to_string(),
Vec::new(),
80,
24,
);
// El shell escribe su prompt al arrancar; lo drenamos sin asumir
// su contenido (cambia por distro).
spin_drain(&mut state, Duration::from_millis(200));
// Tipeamos el comando. Sin Llimphi alrededor llamamos a write_input
// directamente — el módulo permite hacerlo via KeyInput, pero
// construir KeyEvents acá es ruido para este test.
write_raw(&mut state, b"echo hola_del_test\n");
// Esperamos hasta 2s a que el output llegue al screen.
let deadline = Instant::now() + Duration::from_secs(2);
let mut visto = false;
while Instant::now() < deadline {
spin_drain(&mut state, Duration::from_millis(50));
if state.screen_contents().contains("hola_del_test") {
visto = true;
break;
}
}
assert!(
visto,
"esperaba ver 'hola_del_test' en el screen, contenido actual:\n{}",
state.screen_contents()
);
}
#[test]
fn ctrl_shift_w_emite_action_close_sin_pasar_al_pty() {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
let mut state = term::spawn_with(
"/tmp".to_string(),
"/bin/sh".to_string(),
Vec::new(),
80,
24,
);
spin_drain(&mut state, Duration::from_millis(100));
let ev = KeyEvent {
key: Key::Character("w".into()),
state: KeyState::Pressed,
text: Some("w".into()),
modifiers: Modifiers { ctrl: true, shift: true, ..Modifiers::default() },
repeat: false,
};
let action = term::apply(&mut state, ShumaTermMsg::KeyInput(ev));
assert_eq!(action, ShumaTermAction::Close);
}
#[test]
fn key_to_bytes_mapea_los_casos_canonicos() {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
let mk = |key: Key, mods: Modifiers, text: Option<&str>| KeyEvent {
key,
state: KeyState::Pressed,
text: text.map(|s| s.to_string()),
modifiers: mods,
repeat: false,
};
// Enter → CR (no LF — el driver del PTY lo expande).
assert_eq!(
term::key_to_bytes(&mk(Key::Named(NamedKey::Enter), Modifiers::default(), None)),
b"\r"
);
// Backspace → DEL.
assert_eq!(
term::key_to_bytes(&mk(
Key::Named(NamedKey::Backspace),
Modifiers::default(),
None
)),
vec![0x7f]
);
// ArrowUp → CSI A.
assert_eq!(
term::key_to_bytes(&mk(
Key::Named(NamedKey::ArrowUp),
Modifiers::default(),
None
)),
b"\x1b[A"
);
// Ctrl+C → 0x03.
let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
assert_eq!(
term::key_to_bytes(&mk(Key::Character("c".into()), ctrl, Some("c"))),
vec![0x03]
);
// Texto plano (con shift aplicado por el backend) → ese mismo texto.
assert_eq!(
term::key_to_bytes(&mk(Key::Character("A".into()), Modifiers::default(), Some("A"))),
b"A"
);
// Alt+x → ESC + x.
let alt = Modifiers { alt: true, ..Modifiers::default() };
assert_eq!(
term::key_to_bytes(&mk(Key::Character("x".into()), alt, Some("x"))),
vec![0x1b, b'x']
);
}
// ---------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------
/// Pequeño polling: dispara Tick varias veces durante `total` para que
/// el módulo drene los bytes que el reader thread haya emitido.
fn spin_drain(state: &mut llimphi_module_shuma_term::ShumaTermState, total: Duration) {
let deadline = Instant::now() + total;
while Instant::now() < deadline {
term::apply(state, ShumaTermMsg::Tick);
std::thread::sleep(Duration::from_millis(10));
}
}
/// Atajo: enviar bytes crudos al PTY sin construir un KeyEvent. Usa la
/// API pública via un truco — convertimos a un KeyEvent "texto" para
/// evitar exponer write_input crudo en el contrato.
fn write_raw(state: &mut llimphi_module_shuma_term::ShumaTermState, bytes: &[u8]) {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
// Texto entero (incluyendo el LF) en un solo KeyInput. apply() lo
// copia tal cual al PTY via la rama `text`.
let s = std::str::from_utf8(bytes).expect("test usa ascii");
let ev = KeyEvent {
// Key::Character vacío para que no entremos por la rama ctrl/alt.
key: Key::Character("".into()),
state: KeyState::Pressed,
text: Some(s.to_string()),
modifiers: Modifiers::default(),
repeat: false,
};
term::apply(state, ShumaTermMsg::KeyInput(ev));
}