tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! `llimphi-module-shuma-term` — terminal integrado al estilo Ctrl+` de
2//! VS Code o "Terminal" de JetBrains, pero enchufable en cualquier app
3//! Llimphi.
4//!
5//! Lo monta sobre dos piezas que ya existen:
6//!
7//! - [`shuma_exec::Exec::Pty`] aloja un pseudo-terminal cross-platform
8//! (`portable-pty`), lanza el shell con `TERM=xterm-256color`, y
9//! entrega los bytes crudos por un canal MPSC. El módulo no toca
10//! syscalls — sólo consume eventos.
11//! - [`vt100::Parser`] convierte esos bytes en un buffer de pantalla
12//! ANSI: cursor, erase, OSC, scrollback. El módulo le pasa los bytes
13//! y al renderizar pide `screen().contents()`.
14//!
15//! Sigue el contrato Llimphi de `docs/MODULES.md`: `State + Msg +
16//! Action + apply/on_key/open_shortcut/view + Palette`.
17//!
18//! ## Cómo lo enchufa una app (resumen)
19//!
20//! ```ignore
21//! struct Model { term: Option<ShumaTermState>, … }
22//! enum Msg { Term(ShumaTermMsg), Tick, … }
23//!
24//! // open: shuma_term::spawn("/home/user", 100, 30)?
25//! // on_key: si term.is_some() y on_key devuelve Some(msg) → Msg::Term(msg)
26//! // si term.is_none() y open_shortcut(ev) → Msg::Term(Open)
27//! // tick periódico: dispatch Msg::Term(Tick) para drenar PTY
28//! // apply: match action { Close → model.term = None, SetStatus(s) → … }
29//! // view: si term.is_some() → push view(...)
30//! ```
31
32#![forbid(unsafe_code)]
33
34use std::time::Instant;
35
36use llimphi_compositor::llimphi_layout::taffy::{
37 prelude::{length, percent, FlexDirection, Size, Style},
38 AlignItems, Rect,
39};
40use llimphi_compositor::llimphi_text::Alignment;
41use llimphi_compositor::{Key, KeyEvent, KeyState, NamedKey, View};
42use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle};
43
44/// Capabilities que aporta este módulo al host. El host las puede
45/// agregar a `provides` en su `card_core::Card` para que el broker
46/// chasqui descubra que la instancia ofrece terminal integrado.
47pub const CAPABILITIES: &[&str] = &["editor.terminal"];
48
49/// Dimensiones por defecto del PTY. Cubren un panel inferior tipo
50/// VS Code en una pantalla 1080p. Las apps pueden pasar otras a
51/// [`spawn_with`].
52pub const DEFAULT_COLS: u16 = 100;
53pub const DEFAULT_ROWS: u16 = 24;
54
55const SCROLLBACK: usize = 2000;
56
57// =====================================================================
58// State
59// =====================================================================
60
61/// Estado del panel terminal. Encapsula el `RunHandle` del shell y un
62/// `vt100::Parser` que mantiene el buffer de pantalla. No es `Clone`
63/// (los handles son únicos), y el host lo embebe como
64/// `Option<ShumaTermState>`.
65pub struct ShumaTermState {
66 handle: RunHandle,
67 killer: Killer,
68 parser: vt100::Parser,
69 cols: u16,
70 rows: u16,
71 /// Si el shell ya emitió `Exited(code)`. El panel se queda visible
72 /// para que el usuario pueda leer la última salida antes de cerrar.
73 exit_code: Option<i32>,
74 /// CWD inicial — útil para el header sin tener que tocar /proc.
75 cwd: String,
76 started_at: Instant,
77}
78
79impl ShumaTermState {
80 /// Bytes que el módulo ya consumió desde el PTY. Útil para tests y
81 /// debug — no es parte del contrato Tier 1.
82 pub fn screen_contents(&self) -> String {
83 self.parser.screen().contents()
84 }
85
86 pub fn cols(&self) -> u16 {
87 self.cols
88 }
89 pub fn rows(&self) -> u16 {
90 self.rows
91 }
92 pub fn exit_code(&self) -> Option<i32> {
93 self.exit_code
94 }
95 pub fn cwd(&self) -> &str {
96 &self.cwd
97 }
98
99 /// Envía bytes crudos al stdin del PTY. Para hosts que ya producen sus
100 /// propios códigos de tecla y no quieren pasar por [`apply`] —p. ej. el
101 /// backend layer-shell de pata, que recibe `Keysym` de SCTK y no
102 /// `KeyEvent` de llimphi. Los hosts con `KeyEvent` deberían usar
103 /// `apply(ShumaTermMsg::KeyInput(ev))` (que internamente llama a esto).
104 pub fn send_input(&self, bytes: Vec<u8>) -> bool {
105 if bytes.is_empty() {
106 return false;
107 }
108 self.handle.write_input(bytes)
109 }
110
111 /// Reescala el PTY y el buffer vt100 a `cols`×`rows`. No-op si el tamaño
112 /// no cambió. El host la llama cuando el panel que lo hospeda cambia de
113 /// tamaño (en pata: al abrir/redimensionar el drawer Quake).
114 pub fn resize(&mut self, cols: u16, rows: u16) {
115 let cols = cols.max(2);
116 let rows = rows.max(1);
117 if cols == self.cols && rows == self.rows {
118 return;
119 }
120 self.cols = cols;
121 self.rows = rows;
122 self.handle.resize(rows, cols);
123 self.parser.screen_mut().set_size(rows, cols);
124 }
125
126 /// Drena los eventos pendientes del PTY (bytes + exit). Atajo de
127 /// `apply(ShumaTermMsg::Tick)` para hosts que tickean a mano.
128 pub fn tick(&mut self) -> ShumaTermAction {
129 drain(self)
130 }
131}
132
133impl Drop for ShumaTermState {
134 fn drop(&mut self) {
135 // Si el host descarta el state (panel cerrado), no dejamos al
136 // shell huérfano consumiendo CPU. SIGTERM educado primero;
137 // shuma-exec se encarga del SIGKILL si hace falta.
138 self.killer.term();
139 }
140}
141
142/// Lanza el shell por defecto (`$SHELL`, fallback `/bin/sh`) en `cwd`
143/// con tamaño de PTY por defecto.
144pub fn spawn(cwd: impl Into<String>) -> ShumaTermState {
145 spawn_with(cwd, default_shell(), Vec::new(), DEFAULT_COLS, DEFAULT_ROWS)
146}
147
148/// Variante con control fino de programa, args y tamaño.
149pub fn spawn_with(
150 cwd: impl Into<String>,
151 program: String,
152 args: Vec<String>,
153 cols: u16,
154 rows: u16,
155) -> ShumaTermState {
156 let cwd = cwd.into();
157 let spec = CommandSpec {
158 exec: Exec::Pty { program, args, cols, rows },
159 cwd: cwd.clone(),
160 capture_limit: 0,
161 spill_path: None,
162 stdin_data: None,
163 env: Vec::new(),
164 capture_stages: false,
165 };
166 let handle = shuma_exec::run(&spec);
167 let killer = handle.killer();
168 ShumaTermState {
169 handle,
170 killer,
171 parser: vt100::Parser::new(rows, cols, SCROLLBACK),
172 cols,
173 rows,
174 exit_code: None,
175 cwd,
176 started_at: Instant::now(),
177 }
178}
179
180fn default_shell() -> String {
181 std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
182}
183
184// =====================================================================
185// Msg / Action
186// =====================================================================
187
188/// Vocabulario interno. El host lo wrapea en su `Msg`.
189#[derive(Debug, Clone)]
190pub enum ShumaTermMsg {
191 /// Símbolo conveniente para que el host dispatche al detectar el
192 /// shortcut. El módulo no crea el state él mismo — el host lo crea
193 /// con [`spawn`] porque conoce el cwd canónico de la app.
194 Open,
195 /// El usuario pidió cerrar el panel.
196 Close,
197 /// Tecla mientras el panel está enfocado. Se traduce a bytes y se
198 /// reenvía al PTY.
199 KeyInput(KeyEvent),
200 /// Tick del host: drena eventos pendientes del PTY (bytes y exit).
201 /// El host debe enviar este Msg de forma periódica (en cada frame,
202 /// o cuando hay actividad). Sin Tick el terminal no avanza.
203 Tick,
204 /// Mata el shell (SIGTERM); el panel queda visible mostrando el
205 /// estado final hasta que el host reciba `Close`.
206 Terminate,
207}
208
209/// Efecto solicitado al host.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum ShumaTermAction {
212 None,
213 /// El host debería remover el state del modelo.
214 Close,
215 /// El host debería actualizar su barra de estado.
216 SetStatus(String),
217}
218
219// =====================================================================
220// apply / on_key / open_shortcut
221// =====================================================================
222
223/// Aplica un mensaje al estado.
224pub fn apply(state: &mut ShumaTermState, msg: ShumaTermMsg) -> ShumaTermAction {
225 match msg {
226 ShumaTermMsg::Open => ShumaTermAction::None,
227 ShumaTermMsg::Close => ShumaTermAction::Close,
228 ShumaTermMsg::Terminate => {
229 state.killer.term();
230 ShumaTermAction::SetStatus("shuma · SIGTERM".into())
231 }
232 ShumaTermMsg::Tick => drain(state),
233 ShumaTermMsg::KeyInput(ev) => {
234 // Interceptaciones del módulo (no llegan al PTY):
235 // Ctrl+Shift+W → cierra el panel.
236 // Cualquier otra combinación se traduce a bytes y se envía.
237 if ev.state == KeyState::Pressed
238 && ev.modifiers.ctrl
239 && ev.modifiers.shift
240 && matches!(&ev.key, Key::Character(s) if s.eq_ignore_ascii_case("w"))
241 {
242 return ShumaTermAction::Close;
243 }
244 let bytes = key_to_bytes(&ev);
245 if !bytes.is_empty() {
246 state.handle.write_input(bytes);
247 }
248 ShumaTermAction::None
249 }
250 }
251}
252
253/// Routing de teclas cuando el panel está enfocado. Devuelve `Some` para
254/// todo evento `Pressed` — el terminal **traga** las teclas; el host no
255/// debe reusarlas para sus propios atajos mientras este panel esté
256/// activo (la excepción es el atajo de apertura, que el host filtra
257/// antes de delegar).
258pub fn on_key(_state: &ShumaTermState, event: &KeyEvent) -> Option<ShumaTermMsg> {
259 if event.state != KeyState::Pressed {
260 return None;
261 }
262 Some(ShumaTermMsg::KeyInput(event.clone()))
263}
264
265/// El atajo recomendado para abrir: **Ctrl+`** (backtick), igual que
266/// VS Code. Los hosts pueden ignorarlo y usar otro.
267pub fn open_shortcut(event: &KeyEvent) -> bool {
268 event.state == KeyState::Pressed
269 && event.modifiers.ctrl
270 && !event.modifiers.shift
271 && matches!(&event.key, Key::Character(s) if s == "`")
272}
273
274// =====================================================================
275// Drenado del PTY
276// =====================================================================
277
278fn drain(state: &mut ShumaTermState) -> ShumaTermAction {
279 let mut bytes_in = 0usize;
280 let mut final_action = ShumaTermAction::None;
281 for ev in state.handle.try_events() {
282 match ev {
283 RunEvent::Bytes(b) => {
284 bytes_in += b.len();
285 state.parser.process(&b);
286 }
287 RunEvent::Exited(code) => {
288 state.exit_code = Some(code);
289 let elapsed = state.started_at.elapsed().as_secs_f64();
290 final_action = ShumaTermAction::SetStatus(format!(
291 "shuma · exit {code} · {elapsed:.1}s"
292 ));
293 }
294 RunEvent::Failed(err) => {
295 state.exit_code = Some(-1);
296 final_action =
297 ShumaTermAction::SetStatus(format!("shuma · falló: {err}"));
298 }
299 // Stdout/Stderr/Truncated/Spilled no aplican al modo Pty.
300 _ => {}
301 }
302 }
303 if matches!(final_action, ShumaTermAction::None) && bytes_in > 0 {
304 // Nada que reportar — el repaint que el host hará por el frame
305 // basta para mostrar lo nuevo.
306 ShumaTermAction::None
307 } else {
308 final_action
309 }
310}
311
312// =====================================================================
313// Mapeo KeyEvent → bytes
314// =====================================================================
315
316/// Convierte un `KeyEvent` ya recibido en los bytes que un terminal
317/// xterm espera. Cubre el subset usable (chars + control + flechas +
318/// home/end/page + fn keys), suficiente para shells modernos, TUIs
319/// (vim, htop, less) y CLIs interactivas (claude code, fzf).
320pub fn key_to_bytes(ev: &KeyEvent) -> Vec<u8> {
321 if ev.state != KeyState::Pressed {
322 return Vec::new();
323 }
324
325 // Teclas con nombre primero: flechas, etc. Se mapean a CSI/SS3
326 // estándar (xterm-256color).
327 if let Key::Named(named) = &ev.key {
328 return named_to_bytes(*named);
329 }
330
331 // Caracter: si hay Ctrl+letra → control byte (Ctrl+C = 0x03).
332 if let Key::Character(s) = &ev.key {
333 if ev.modifiers.ctrl && !ev.modifiers.alt {
334 if let Some(b) = ctrl_byte(s) {
335 return vec![b];
336 }
337 }
338 // Alt+x → ESC + x (convención xterm meta-sends-escape).
339 if ev.modifiers.alt {
340 let mut out = vec![0x1b];
341 out.extend_from_slice(s.as_bytes());
342 return out;
343 }
344 }
345
346 // Caso general: si el backend ya nos dio el texto resultante
347 // (con shift/IME aplicados), eso es lo correcto para mandar.
348 if let Some(text) = &ev.text {
349 return text.as_bytes().to_vec();
350 }
351 Vec::new()
352}
353
354fn named_to_bytes(k: NamedKey) -> Vec<u8> {
355 match k {
356 // PTYs en modo raw esperan CR para Enter; el driver convierte a LF.
357 NamedKey::Enter => b"\r".to_vec(),
358 // Backspace moderno = DEL (0x7f). Los shells lo entienden mejor
359 // que 0x08, que se reserva para ^H en TUIs viejos.
360 NamedKey::Backspace => vec![0x7f],
361 NamedKey::Tab => b"\t".to_vec(),
362 NamedKey::Escape => vec![0x1b],
363 NamedKey::ArrowUp => b"\x1b[A".to_vec(),
364 NamedKey::ArrowDown => b"\x1b[B".to_vec(),
365 NamedKey::ArrowRight => b"\x1b[C".to_vec(),
366 NamedKey::ArrowLeft => b"\x1b[D".to_vec(),
367 NamedKey::Home => b"\x1b[H".to_vec(),
368 NamedKey::End => b"\x1b[F".to_vec(),
369 NamedKey::PageUp => b"\x1b[5~".to_vec(),
370 NamedKey::PageDown => b"\x1b[6~".to_vec(),
371 NamedKey::Delete => b"\x1b[3~".to_vec(),
372 NamedKey::Insert => b"\x1b[2~".to_vec(),
373 NamedKey::F1 => b"\x1bOP".to_vec(),
374 NamedKey::F2 => b"\x1bOQ".to_vec(),
375 NamedKey::F3 => b"\x1bOR".to_vec(),
376 NamedKey::F4 => b"\x1bOS".to_vec(),
377 NamedKey::F5 => b"\x1b[15~".to_vec(),
378 NamedKey::F6 => b"\x1b[17~".to_vec(),
379 NamedKey::F7 => b"\x1b[18~".to_vec(),
380 NamedKey::F8 => b"\x1b[19~".to_vec(),
381 NamedKey::F9 => b"\x1b[20~".to_vec(),
382 NamedKey::F10 => b"\x1b[21~".to_vec(),
383 NamedKey::F11 => b"\x1b[23~".to_vec(),
384 NamedKey::F12 => b"\x1b[24~".to_vec(),
385 _ => Vec::new(),
386 }
387}
388
389/// Ctrl+letter → byte de control ASCII (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26).
390/// Maneja también Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\\ (FS), Ctrl+] (GS),
391/// Ctrl+^ (RS), Ctrl+_ (US), Ctrl+? (DEL).
392fn ctrl_byte(s: &str) -> Option<u8> {
393 let c = s.chars().next()?;
394 match c {
395 'a'..='z' => Some((c as u8) - b'a' + 1),
396 'A'..='Z' => Some((c as u8) - b'A' + 1),
397 '@' => Some(0),
398 '[' => Some(0x1b),
399 '\\' => Some(0x1c),
400 ']' => Some(0x1d),
401 '^' => Some(0x1e),
402 '_' => Some(0x1f),
403 '?' => Some(0x7f),
404 ' ' => Some(0), // Ctrl+Space = NUL, convención xterm
405 _ => None,
406 }
407}
408
409// =====================================================================
410// View
411// =====================================================================
412
413/// Paleta visual del terminal. Monospace; fondo más oscuro que el
414/// panel general para que el terminal "viva" visualmente.
415#[derive(Debug, Clone)]
416pub struct ShumaTermPalette {
417 pub bg_panel: llimphi_compositor::peniko::Color,
418 pub bg_header: llimphi_compositor::peniko::Color,
419 pub fg_text: llimphi_compositor::peniko::Color,
420 pub fg_muted: llimphi_compositor::peniko::Color,
421}
422
423impl ShumaTermPalette {
424 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
425 Self {
426 bg_panel: t.bg_panel_alt,
427 bg_header: t.bg_panel,
428 fg_text: t.fg_text,
429 fg_muted: t.fg_muted,
430 }
431 }
432}
433
434const HEADER_H: f32 = 18.0;
435const ROW_H: f32 = 14.0;
436const CHAR_W: f32 = 7.5;
437
438/// Render del panel. `to_host` mapea cada `ShumaTermMsg` al `Msg` del
439/// host. `height_px` es la altura total del panel — el módulo divide
440/// entre header + grid.
441pub fn view<HostMsg, F>(
442 state: &ShumaTermState,
443 palette: &ShumaTermPalette,
444 height_px: f32,
445 to_host: F,
446) -> View<HostMsg>
447where
448 HostMsg: Clone + 'static,
449 F: Fn(ShumaTermMsg) -> HostMsg + Copy + 'static,
450{
451 let _ = to_host; // v0 no monta eventos puntuales sobre el grid
452
453 let header_text = match state.exit_code {
454 Some(code) => format!(
455 "shuma · {} · exit {code} · Ctrl+Shift+W cierra",
456 state.cwd
457 ),
458 None => format!(
459 "shuma · {} · {}×{} · Ctrl+Shift+W cierra · Esc envía al shell",
460 state.cwd, state.cols, state.rows
461 ),
462 };
463
464 let header = View::new(Style {
465 size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
466 padding: Rect {
467 left: length(8.0_f32),
468 right: length(8.0_f32),
469 top: length(0.0_f32),
470 bottom: length(0.0_f32),
471 },
472 align_items: Some(AlignItems::CENTER),
473 flex_shrink: 0.0,
474 ..Default::default()
475 })
476 .fill(palette.bg_header)
477 .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
478
479 let contents = state.parser.screen().contents();
480 let grid_h = (height_px - HEADER_H).max(0.0);
481 let max_rows = ((grid_h / ROW_H) as usize).max(1);
482
483 // Tomamos las últimas `max_rows` líneas — preferimos mostrar el
484 // tail (donde está el cursor / prompt) si el render no alcanza
485 // para toda la pantalla.
486 let all_lines: Vec<&str> = contents.split('\n').collect();
487 let start = all_lines.len().saturating_sub(max_rows);
488 let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
489 for line in &all_lines[start..] {
490 rows.push(
491 View::new(Style {
492 size: Size { width: percent(1.0_f32), height: length(ROW_H) },
493 padding: Rect {
494 left: length(8.0_f32),
495 right: length(8.0_f32),
496 top: length(0.0_f32),
497 bottom: length(0.0_f32),
498 },
499 align_items: Some(AlignItems::CENTER),
500 flex_shrink: 0.0,
501 ..Default::default()
502 })
503 .fill(palette.bg_panel)
504 .text_aligned((*line).to_string(), 11.0, palette.fg_text, Alignment::Start),
505 );
506 }
507 // Si el render quedó corto, rellenamos con líneas vacías para que el
508 // panel mantenga su altura visual.
509 while rows.len() < max_rows {
510 rows.push(
511 View::new(Style {
512 size: Size { width: percent(1.0_f32), height: length(ROW_H) },
513 flex_shrink: 0.0,
514 ..Default::default()
515 })
516 .fill(palette.bg_panel),
517 );
518 }
519
520 let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
521 children.push(header);
522 children.extend(rows);
523
524 View::new(Style {
525 flex_direction: FlexDirection::Column,
526 size: Size { width: percent(1.0_f32), height: length(height_px) },
527 flex_shrink: 0.0,
528 ..Default::default()
529 })
530 .fill(palette.bg_panel)
531 .children(children)
532}
533
534/// Estimación heurística de cuántas columnas caben en `width_px` con la
535/// fuente actual. Útil para que el host calcule el tamaño antes de
536/// llamar a [`spawn_with`].
537pub fn cols_for_width(width_px: f32) -> u16 {
538 ((width_px / CHAR_W).floor() as u16).max(20)
539}
540
541/// Idem para filas a partir de la altura disponible del panel
542/// (descontando el header).
543pub fn rows_for_height(height_px: f32) -> u16 {
544 (((height_px - HEADER_H) / ROW_H).floor() as u16).max(5)
545}