⎇ b4be5e1c72d728d5e3aaf2f294b35a3c34c7de99
crates/apps/shuma-shell/src/main.rs
Historia
Quién tocó qué
crudo
feat(shuma): captura acotada + reproceso de salidas vía stdin
1//! `shuma-shell` — el shell de brahman, ejecutando de verdad.
2//!
3//! El shell trabaja *dentro de una sesión* ([`shuma_session::WorkSession`]):
4//! un directorio actual —que es además el identificador de aislamiento—,
5//! el historial de comandos ejecutados y los grupos reutilizables.
6//!
7//! ```text
8//! ┌─ estado · cwd · aislamiento ──────────────────────┐
9//! │ [RUN] │ comandos ejecutados + su salida │ [SENS] │
10//! │ grupos │ (streaming en vivo) │ monit. │
11//! └─ prompt inteligente ─────────────────────────────┘
12//! ```
13//!
14//! El input se analiza con `shuma-line` (resaltado + autocompletado);
15//! al ejecutar, `shuma-exec` lanza el comando y transmite su salida
16//! línea a línea, que se vuelca en el panel central. El cerebro vive en
17//! crates agnósticos — este binario sólo es el frontend GPUI.
18
19use std::panic;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use std::collections::HashMap;
23
24use gpui::{
25 div, point, prelude::*, px, App, Bounds, Context, CursorStyle, Element, ElementId, FocusHandle,
26 GlobalElementId, Hsla, InspectorElementId, IntoElement, KeyDownEvent, LayoutId, MouseButton,
27 MouseDownEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, Pixels, Render, ScrollHandle,
28 SharedString, Style, Window,
29};
30use nahual_launcher::launch_app;
31use nahual_theme::Theme;
32use shuma_exec::{run as exec_run, CommandSpec, RunEvent, RunHandle};
33use shuma_line::{CompletionKind, CompletionSource, LineState, TokenKind};
34use shuma_session::{CommandRun, RunId, RunStatus, Stream, WorkSession};
35use shuma_sysmon::{Snapshot, SystemSampler};
36
37/// Cuántas muestras guarda la curva de cada monitor.
38const HISTORY: usize = 80;
39/// Tope de captura de salida por comando — 8 MiB. Pasado el tope la
40/// salida se descarta: cota dura de memoria ante un stream gigante.
41const CAPTURE_LIMIT: usize = 8 * 1024 * 1024;
42
43/// Archivos/directorios que delatan la estructura de un proyecto.
44const PROJECT_MARKERS: &[&str] = &[
45 ".git",
46 "Cargo.toml",
47 "package.json",
48 "go.mod",
49 "Makefile",
50 "pyproject.toml",
51 "pom.xml",
52 "build.gradle",
53];
54
55/// Marcadores de proyecto presentes en `dir`.
56fn markers_in(dir: &str) -> Vec<String> {
57 let base = std::path::Path::new(dir);
58 PROJECT_MARKERS
59 .iter()
60 .filter(|m| base.join(m).exists())
61 .map(|m| m.to_string())
62 .collect()
63}
64
65/// Segundo Unix actual.
66fn unix_now() -> u64 {
67 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
68}
69
70/// Índice de grupo (0-based) de una tecla de función `f1`..`f8`.
71fn fkey_index(key: &str) -> Option<usize> {
72 let n: usize = key.strip_prefix('f')?.parse().ok()?;
73 (1..=8).contains(&n).then_some(n - 1)
74}
75
76// =====================================================================
77// Fuente de autocompletado.
78// =====================================================================
79
80struct ShellCompletionSource {
81 commands: Vec<String>,
82 cwd: String,
83}
84
85impl ShellCompletionSource {
86 fn scan(cwd: String) -> Self {
87 let mut commands = Vec::new();
88 if let Ok(path) = std::env::var("PATH") {
89 for dir in path.split(':') {
90 if let Ok(entries) = std::fs::read_dir(dir) {
91 for e in entries.flatten() {
92 if let Some(name) = e.file_name().to_str() {
93 commands.push(name.to_string());
94 }
95 }
96 }
97 }
98 }
99 commands.sort();
100 commands.dedup();
101 Self { commands, cwd }
102 }
103}
104
105impl CompletionSource for ShellCompletionSource {
106 fn commands(&self) -> Vec<String> {
107 self.commands.clone()
108 }
109
110 fn paths(&self, prefix: &str) -> Vec<String> {
111 let (dir, partial) = match prefix.rfind('/') {
112 Some(i) => (&prefix[..=i], &prefix[i + 1..]),
113 None => ("", prefix),
114 };
115 // Una ruta relativa se resuelve contra el cwd de la sesión.
116 let base = if dir.starts_with('/') {
117 dir.to_string()
118 } else if dir.is_empty() {
119 self.cwd.clone()
120 } else {
121 format!("{}/{}", self.cwd, dir)
122 };
123 let mut out = Vec::new();
124 if let Ok(entries) = std::fs::read_dir(&base) {
125 for e in entries.flatten() {
126 if let Some(name) = e.file_name().to_str() {
127 if name.starts_with(partial) {
128 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
129 let slash = if is_dir { "/" } else { "" };
130 out.push(format!("{dir}{name}{slash}"));
131 }
132 }
133 }
134 }
135 out.sort();
136 out
137 }
138}
139
140// =====================================================================
141// CurveElement — la curva de un monitor.
142// =====================================================================
143
144struct CurveElement {
145 values: Vec<f32>,
146 color: Hsla,
147}
148
149impl CurveElement {
150 fn new(values: Vec<f32>, color: Hsla) -> Self {
151 Self { values, color }
152 }
153}
154
155impl IntoElement for CurveElement {
156 type Element = Self;
157 fn into_element(self) -> Self::Element {
158 self
159 }
160}
161
162impl Element for CurveElement {
163 type RequestLayoutState = ();
164 type PrepaintState = ();
165
166 fn id(&self) -> Option<ElementId> {
167 None
168 }
169 fn source_location(&self) -> Option<&'static panic::Location<'static>> {
170 None
171 }
172
173 fn request_layout(
174 &mut self,
175 _id: Option<&GlobalElementId>,
176 _inspector: Option<&InspectorElementId>,
177 window: &mut Window,
178 cx: &mut App,
179 ) -> (LayoutId, ()) {
180 let mut style = Style::default();
181 style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
182 style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
183 (window.request_layout(style, [], cx), ())
184 }
185
186 fn prepaint(
187 &mut self,
188 _id: Option<&GlobalElementId>,
189 _inspector: Option<&InspectorElementId>,
190 _bounds: Bounds<Pixels>,
191 _layout: &mut (),
192 _window: &mut Window,
193 _cx: &mut App,
194 ) {
195 }
196
197 fn paint(
198 &mut self,
199 _id: Option<&GlobalElementId>,
200 _inspector: Option<&InspectorElementId>,
201 bounds: Bounds<Pixels>,
202 _layout: &mut (),
203 _prepaint: &mut (),
204 window: &mut Window,
205 _cx: &mut App,
206 ) {
207 let n = self.values.len();
208 if n < 2 {
209 return;
210 }
211 let ox: f32 = bounds.origin.x.into();
212 let oy: f32 = bounds.origin.y.into();
213 let bw: f32 = bounds.size.width.into();
214 let bh: f32 = bounds.size.height.into();
215 let mut pb = PathBuilder::stroke(px(1.6));
216 for (i, v) in self.values.iter().enumerate() {
217 let x = ox + bw * (i as f32 / (n - 1) as f32);
218 let y = oy + bh - (v.clamp(0.0, 100.0) / 100.0) * bh;
219 let p = point(px(x), px(y));
220 if i == 0 {
221 pb.move_to(p);
222 } else {
223 pb.line_to(p);
224 }
225 }
226 if let Ok(path) = pb.build() {
227 window.paint_path(path, self.color);
228 }
229 }
230}
231
232// =====================================================================
233// El shell.
234// =====================================================================
235
236/// Qué panel lateral está redimensionando un drag activo.
237#[derive(Clone, Copy)]
238enum Side {
239 Left,
240 Right,
241}
242
243/// Estado de un arrastre de divisor en curso.
244struct Drag {
245 side: Side,
246 /// Posición X del cursor al iniciar el arrastre.
247 start_x: f32,
248 /// Ancho del panel al iniciar el arrastre.
249 start_w: f32,
250}
251
252/// Estado de presentación de la tarjeta de un comando.
253#[derive(Default, Clone, Copy)]
254struct RunUi {
255 /// Acordeón cerrado — sólo se ve la cabecera.
256 collapsed: bool,
257 /// El filtro muestra stderr en vez de stdout.
258 show_stderr: bool,
259 /// El usuario tocó el acordeón a mano — ya no se autocolapsa.
260 user_touched: bool,
261}
262
263struct Shell {
264 line: LineState,
265 /// La sesión de trabajo: cwd, historial y grupos.
266 session: WorkSession,
267 /// Comandos en curso, con su canal de salida.
268 active: Vec<(RunId, RunHandle)>,
269 completion: Option<shuma_line::Completion>,
270 completion_index: usize,
271 show_completion: bool,
272 source: ShellCompletionSource,
273 sampler: SystemSampler,
274 snapshot: Snapshot,
275 left_collapsed: bool,
276 right_collapsed: bool,
277 /// Anchos de los paneles laterales (los divisores los ajustan).
278 left_width: f32,
279 right_width: f32,
280 /// Arrastre de divisor en curso, si lo hay.
281 drag: Option<Drag>,
282 /// Estado de presentación por comando (acordeón, filtro stderr).
283 run_ui: HashMap<RunId, RunUi>,
284 /// Largo del historial en el último `:save` — define qué comandos
285 /// entran al próximo grupo guardado.
286 group_anchor: usize,
287 /// Patrones detectados por el motor de inferencia (cache).
288 patterns: Vec<shuma_infer::EmergingPattern>,
289 /// Si está activo, el próximo comando reprocesa la salida de este run.
290 reprocess_source: Option<RunId>,
291 /// Scroll del feed central — sigue al comando más reciente.
292 scroll: ScrollHandle,
293 focus: FocusHandle,
294 focused_once: bool,
295}
296
297impl Shell {
298 fn new(cx: &mut Context<Self>) -> Self {
299 let cwd = std::env::current_dir()
300 .map(|p| p.to_string_lossy().into_owned())
301 .unwrap_or_else(|_| "/".to_string());
302
303 let mut session = WorkSession::new("sesión", &cwd);
304 // Grupos de ejemplo — recetas reutilizables.
305 session.save_group("estado git", vec!["git status --short".into()]);
306 session.save_group("build", vec!["cargo build --release".into()]);
307
308 let shell = Self {
309 line: LineState::new(),
310 session,
311 active: Vec::new(),
312 completion: None,
313 completion_index: 0,
314 show_completion: false,
315 source: ShellCompletionSource::scan(cwd),
316 sampler: SystemSampler::new(HISTORY),
317 snapshot: Snapshot {
318 cpu_percent: 0.0,
319 mem_percent: 0.0,
320 mem_used_mb: 0,
321 mem_total_mb: 0,
322 valid: false,
323 },
324 left_collapsed: false,
325 right_collapsed: false,
326 left_width: 176.0,
327 right_width: 188.0,
328 drag: None,
329 run_ui: HashMap::new(),
330 group_anchor: 0,
331 patterns: Vec::new(),
332 reprocess_source: None,
333 scroll: ScrollHandle::new(),
334 focus: cx.focus_handle(),
335 focused_once: false,
336 };
337 shell.start_loop(cx);
338 shell
339 }
340
341 /// Bucle de fondo: drena la salida de los comandos (~9/s) y refresca
342 /// los monitores (~1/s).
343 fn start_loop(&self, cx: &mut Context<Self>) {
344 cx.spawn(async move |this, cx| {
345 let mut tick: u32 = 0;
346 loop {
347 cx.background_executor().timer(Duration::from_millis(110)).await;
348 tick += 1;
349 let sysmon = tick % 10 == 0;
350 let alive = this.update(cx, |shell, cx| {
351 let mut changed = shell.drain_exec();
352 if sysmon {
353 shell.snapshot = shell.sampler.sample();
354 changed = true;
355 }
356 if changed {
357 cx.notify();
358 }
359 });
360 if alive.is_err() {
361 break;
362 }
363 }
364 })
365 .detach();
366 }
367
368 /// Vuelca la salida disponible de los comandos en curso al historial.
369 fn drain_exec(&mut self) -> bool {
370 let now = unix_now();
371 let mut changed = false;
372 for (id, handle) in &mut self.active {
373 for ev in handle.try_events() {
374 changed = true;
375 match ev {
376 RunEvent::Stdout(l) => {
377 self.session.append_output(*id, Stream::Stdout, l)
378 }
379 RunEvent::Stderr(l) => {
380 self.session.append_output(*id, Stream::Stderr, l)
381 }
382 RunEvent::Truncated => self.session.mark_truncated(*id),
383 RunEvent::Exited(code) => self.session.finish_run(*id, code, now),
384 RunEvent::Failed(msg) => {
385 self.session.append_output(
386 *id,
387 Stream::Stderr,
388 format!("✗ no se pudo lanzar: {msg}"),
389 );
390 self.session.finish_run(*id, -1, now);
391 }
392 }
393 }
394 }
395 let finished = self.active.iter().any(|(_, h)| h.is_finished());
396 self.active.retain(|(_, h)| !h.is_finished());
397 if finished {
398 // Al cerrarse un comando, el motor de inferencia revisa si
399 // emergió un patrón repetido y lo promueve a un grupo.
400 self.infer_patterns();
401 }
402 if changed {
403 self.scroll.scroll_to_bottom();
404 }
405 changed
406 }
407
408 /// Comandos del historial reducidos a registros de inferencia.
409 fn infer_records(&self) -> Vec<shuma_infer::CommandRecord> {
410 self.session
411 .history()
412 .iter()
413 .map(|r| {
414 shuma_infer::CommandRecord::parse(&r.line, &r.cwd, r.status == RunStatus::Ok)
415 })
416 .collect()
417 }
418
419 /// Corre el motor de inferencia, cachea los patrones y promueve el
420 /// más fuerte a un grupo reutilizable (rehidratación).
421 fn infer_patterns(&mut self) {
422 let records = self.infer_records();
423 self.patterns =
424 shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default());
425 if let Some(top) = self.patterns.first() {
426 let name = format!("✨ {}", top.suggested_name());
427 if self.session.group(&name).is_none() {
428 self.session.save_group(name, top.example.clone());
429 }
430 }
431 }
432
433 /// Condición de disparo de un patrón: los marcadores de proyecto
434 /// comunes a todos los directorios donde corrió.
435 fn pattern_trigger(&self, p: &shuma_infer::EmergingPattern) -> Vec<String> {
436 let mut dirs = p.directories.iter();
437 let Some(first) = dirs.next() else {
438 return Vec::new();
439 };
440 let mut common = markers_in(first);
441 for d in dirs {
442 let here = markers_in(d);
443 common.retain(|m| here.contains(m));
444 }
445 common
446 }
447
448 /// La secuencia que el motor predice como continuación, si la hay.
449 fn predicted_sequence(&self) -> Option<String> {
450 if self.patterns.is_empty() {
451 return None;
452 }
453 let records = self.infer_records();
454 let tail = &records[records.len().saturating_sub(6)..];
455 let (pi, next) = shuma_infer::predict_next(tail, &self.patterns)?;
456 if next.is_empty() {
457 return None;
458 }
459 // Disparo por estructura: no anticipar un patrón en un directorio
460 // que no comparte su forma (no sugerir `cargo` sin `Cargo.toml`).
461 let trigger = self.pattern_trigger(&self.patterns[pi]);
462 if !trigger.is_empty() {
463 let here = markers_in(self.session.cwd());
464 if !trigger.iter().all(|m| here.contains(m)) {
465 return None;
466 }
467 }
468 Some(next.join(" && "))
469 }
470
471 /// Calcula el sufijo fantasma del prompt: el resto de la línea que el
472 /// shell predice. Sólo con el cursor al final.
473 fn compute_ghost(&self) -> Option<String> {
474 let line = self.line.text();
475 if line.is_empty() || self.line.cursor() != line.len() {
476 return None;
477 }
478 // Corpus por prioridad: secuencia predicha, luego historial
479 // reciente.
480 let mut corpus: Vec<String> = Vec::new();
481 if let Some(seq) = self.predicted_sequence() {
482 corpus.push(seq);
483 }
484 for r in self.session.history().iter().rev() {
485 corpus.push(r.line.clone());
486 }
487 shuma_line::ghost_suggestion(line, &corpus)
488 }
489
490 /// Resuelve el destino de un `cd` contra el cwd de la sesión.
491 fn resolve_cd(&self, arg: &str) -> Result<String, String> {
492 let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
493 let target = if arg.is_empty() || arg == "~" {
494 home
495 } else if let Some(rest) = arg.strip_prefix("~/") {
496 format!("{home}/{rest}")
497 } else if arg.starts_with('/') {
498 arg.to_string()
499 } else {
500 format!("{}/{}", self.session.cwd(), arg)
501 };
502 match std::fs::canonicalize(&target) {
503 Ok(p) if p.is_dir() => Ok(p.to_string_lossy().into_owned()),
504 Ok(_) => Err(format!("cd: no es un directorio: {target}")),
505 Err(e) => Err(format!("cd: {target}: {e}")),
506 }
507 }
508
509 /// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y,
510 /// con él, el aislamiento); el resto se lanza con `shuma-exec` y su
511 /// salida se transmite al panel central.
512 /// Guarda como grupo los comandos ejecutados desde el último `:save`.
513 fn save_group(&mut self, name: &str) {
514 let name = name.trim();
515 if name.is_empty() {
516 return;
517 }
518 let n = self.session.history().len().saturating_sub(self.group_anchor);
519 if n > 0 {
520 self.session.save_recent_as_group(name, n);
521 self.group_anchor = self.session.history().len();
522 }
523 }
524
525 fn run_command(&mut self, line: String) {
526 let line = line.trim().to_string();
527 if line.is_empty() {
528 return;
529 }
530 let now = unix_now();
531
532 // Meta-comando `:save <nombre>` — guarda un grupo, no se ejecuta.
533 if let Some(name) = line.strip_prefix(":save ") {
534 self.save_group(name);
535 return;
536 }
537
538 // Los comandos anteriores que el usuario no fijó se autocolapsan
539 // al aparecer uno nuevo abajo — orden de terminal tradicional.
540 for ui in self.run_ui.values_mut() {
541 if !ui.user_touched {
542 ui.collapsed = true;
543 }
544 }
545
546 // `cd` interno — un subproceso no podría cambiar nuestro cwd.
547 if line == "cd" || line.starts_with("cd ") {
548 let arg = line.strip_prefix("cd").unwrap_or("").trim();
549 let id = self.session.begin_run(&line, now);
550 self.run_ui.insert(id, RunUi::default());
551 match self.resolve_cd(arg) {
552 Ok(new_cwd) => {
553 self.session.set_cwd(new_cwd.clone());
554 self.source.cwd = new_cwd.clone();
555 self.session
556 .append_output(id, Stream::Stdout, format!("→ {new_cwd}"));
557 self.session.finish_run(id, 0, now);
558 }
559 Err(e) => {
560 self.session.append_output(id, Stream::Stderr, e);
561 self.session.finish_run(id, 1, now);
562 }
563 }
564 self.scroll.scroll_to_bottom();
565 return;
566 }
567
568 let id = self.session.begin_run(&line, now);
569 self.run_ui.insert(id, RunUi::default());
570 let spec = CommandSpec::bash(&line, self.session.cwd()).with_limit(CAPTURE_LIMIT);
571 self.active.push((id, exec_run(&spec)));
572 self.scroll.scroll_to_bottom();
573 }
574
575 /// Reprocesa la salida capturada del comando `source`: ejecuta `line`
576 /// alimentándole esa salida por stdin, sin volver a correr el
577 /// original. Así un resultado se filtra con distintas herramientas.
578 fn run_reprocess(&mut self, line: String, source: RunId) {
579 let line = line.trim().to_string();
580 if line.is_empty() {
581 return;
582 }
583 let data: String = self
584 .session
585 .run(source)
586 .map(|r| r.lines_of(Stream::Stdout).collect::<Vec<_>>().join("\n"))
587 .unwrap_or_default();
588 for ui in self.run_ui.values_mut() {
589 if !ui.user_touched {
590 ui.collapsed = true;
591 }
592 }
593 let now = unix_now();
594 let id = self.session.begin_run(&line, now);
595 self.run_ui.insert(id, RunUi::default());
596 let spec = CommandSpec::bash(&line, self.session.cwd())
597 .with_limit(CAPTURE_LIMIT)
598 .with_stdin(data);
599 self.active.push((id, exec_run(&spec)));
600 self.scroll.scroll_to_bottom();
601 }
602
603 /// Mata el proceso de un comando en curso.
604 fn kill_run(&self, id: RunId) {
605 if let Some((_, handle)) = self.active.iter().find(|(rid, _)| *rid == id) {
606 handle.kill();
607 }
608 }
609
610 fn refresh_completion(&mut self) {
611 let comp = self.line.complete(&self.source);
612 self.show_completion =
613 !comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
614 self.completion_index = 0;
615 self.completion = Some(comp);
616 }
617
618 fn on_tab(&mut self) {
619 let comp = self.line.complete(&self.source);
620 if comp.candidates.is_empty() {
621 return;
622 }
623 if self.show_completion {
624 let idx = self.completion_index.min(comp.candidates.len() - 1);
625 let candidate = comp.candidates[idx].clone();
626 self.line.apply_completion(&comp, &candidate);
627 self.show_completion = false;
628 self.completion = None;
629 } else {
630 self.completion_index = 0;
631 self.completion = Some(comp);
632 self.show_completion = true;
633 }
634 }
635
636 fn cycle_completion(&mut self, delta: i32) {
637 if !self.show_completion {
638 return;
639 }
640 if let Some(comp) = &self.completion {
641 let n = comp.candidates.len();
642 if n > 0 {
643 let i = self.completion_index as i32 + delta;
644 self.completion_index = i.rem_euclid(n as i32) as usize;
645 }
646 }
647 }
648
649 /// Enter — ejecuta el contenido del input, o reprocesa una salida
650 /// previa si hay un origen de reproceso activo.
651 fn submit(&mut self) {
652 let line = self.line.text().to_string();
653 self.line.clear();
654 self.completion = None;
655 self.show_completion = false;
656 if let Some(source) = self.reprocess_source.take() {
657 self.run_reprocess(line, source);
658 } else {
659 self.run_command(line);
660 }
661 }
662
663 fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
664 let ks = &event.keystroke;
665 let key = ks.key.as_str();
666 let ctrl = ks.modifiers.control;
667 let mut changed = false;
668
669 match key {
670 "enter" => {
671 self.submit();
672 cx.notify();
673 return;
674 }
675 "escape" => {
676 self.show_completion = false;
677 self.reprocess_source = None;
678 cx.notify();
679 return;
680 }
681 "tab" => {
682 self.on_tab();
683 cx.notify();
684 return;
685 }
686 "up" => {
687 self.cycle_completion(-1);
688 cx.notify();
689 return;
690 }
691 "down" => {
692 self.cycle_completion(1);
693 cx.notify();
694 return;
695 }
696 "backspace" => {
697 self.line.backspace();
698 changed = true;
699 }
700 "delete" => {
701 self.line.delete();
702 changed = true;
703 }
704 "left" => {
705 if ctrl {
706 self.line.move_word_left();
707 } else {
708 self.line.move_left();
709 }
710 }
711 "right" => {
712 if ctrl {
713 self.line.move_word_right();
714 } else if self.line.cursor() == self.line.text().len() {
715 // En el extremo, la flecha derecha acepta el fantasma.
716 if let Some(g) = self.compute_ghost() {
717 self.line.insert(&g);
718 changed = true;
719 }
720 } else {
721 self.line.move_right();
722 }
723 }
724 "home" => self.line.move_home(),
725 "end" => self.line.move_end(),
726 "a" if ctrl => self.line.move_home(),
727 "e" if ctrl => self.line.move_end(),
728 "u" if ctrl => {
729 self.line.clear();
730 changed = true;
731 }
732 "space" if ctrl => {
733 // Ctrl+Space también acepta el fantasma predicho.
734 if let Some(g) = self.compute_ghost() {
735 self.line.insert(&g);
736 changed = true;
737 }
738 }
739 _ if fkey_index(key).is_some() => {
740 // F1..F8 ejecutan el grupo de esa posición en [RUN].
741 let idx = fkey_index(key).unwrap();
742 let joined =
743 self.session.groups().get(idx).map(|g| g.lines.join(" && "));
744 if let Some(j) = joined {
745 self.run_command(j);
746 }
747 cx.notify();
748 return;
749 }
750 _ => {
751 if !ctrl {
752 if let Some(ch) = ks.key_char.as_deref() {
753 if !ch.chars().any(|c| c.is_control()) {
754 self.line.insert(ch);
755 changed = true;
756 }
757 }
758 }
759 }
760 }
761
762 if changed {
763 self.refresh_completion();
764 }
765 cx.notify();
766 }
767}
768
769/// Color de resaltado de cada clase de token.
770fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
771 match kind {
772 TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
773 TokenKind::Argument => theme.fg_text,
774 TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
775 TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
776 TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
777 TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
778 TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
779 TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
780 TokenKind::Comment => theme.fg_muted,
781 TokenKind::Whitespace => theme.fg_text,
782 TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
783 }
784}
785
786/// Resalta un texto estático (la línea de un comando del historial).
787fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
788 shuma_line::tokenize(text, shuma_line::Dialect::Bash)
789 .into_iter()
790 .map(|t| {
791 div()
792 .flex_none()
793 .text_color(token_color(t.kind, theme))
794 .child(t.text)
795 })
796 .collect()
797}
798
799/// Acorta el cwd: el `$HOME` se muestra como `~`.
800fn pretty_cwd(cwd: &str) -> String {
801 match std::env::var("HOME") {
802 Ok(home) if cwd == home => "~".to_string(),
803 Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
804 _ => cwd.to_string(),
805 }
806}
807
808/// Renderiza la tarjeta de un comando ejecutado: cabecera-acordeón +
809/// filtro stdout/stderr + cuerpo de salida.
810fn render_run(
811 r: &CommandRun,
812 ui: RunUi,
813 theme: &Theme,
814 node_bg: Hsla,
815 cx: &mut Context<Shell>,
816) -> impl IntoElement {
817 let id = r.id;
818 let dim = theme.fg_muted;
819 let (glyph, gcolor) = match r.status {
820 RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
821 RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
822 RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
823 };
824 let stderr_color = gpui::hsla(8.0 / 360.0, 0.62, 0.66, 1.0);
825 let accent = gpui::hsla(190.0 / 360.0, 0.60, 0.62, 1.0);
826
827 // Nota a la derecha: salida no-cero, truncado, y conteo si colapsada.
828 let mut parts: Vec<String> = Vec::new();
829 if let Some(c) = r.exit_code {
830 if c != 0 {
831 parts.push(format!("salió {c}"));
832 }
833 }
834 if r.truncated {
835 parts.push("⚠ truncado".to_string());
836 }
837 if ui.collapsed {
838 let n = r.count_of(Stream::Stdout);
839 if n > 0 {
840 parts.push(format!("{n} líneas"));
841 }
842 }
843 let note = parts.join(" · ");
844
845 // Cabecera-acordeón: un clic colapsa/expande.
846 let caret = if ui.collapsed { "▸" } else { "▾" };
847 let header_left = div()
848 .id(SharedString::from(format!("hdr-{id}")))
849 .flex()
850 .flex_row()
851 .items_center()
852 .gap(px(6.))
853 .flex_1()
854 .cursor_pointer()
855 .child(div().flex_none().text_color(dim).child(caret))
856 .child(div().flex_none().text_color(gcolor).child(glyph))
857 .children(highlight(&r.line, theme))
858 .child(
859 div()
860 .flex_none()
861 .text_size(px(11.))
862 .text_color(dim)
863 .child(SharedString::from(note)),
864 )
865 .on_click(cx.listener(move |shell, _, _, cx| {
866 let e = shell.run_ui.entry(id).or_default();
867 e.collapsed = !e.collapsed;
868 e.user_touched = true;
869 cx.notify();
870 }));
871
872 // Filtro de stderr — sólo aparece si el comando emitió errores.
873 let stderr_chip = if r.has_stderr() {
874 let n = r.count_of(Stream::Stderr);
875 Some(
876 div()
877 .id(SharedString::from(format!("err-{id}")))
878 .flex_none()
879 .px(px(6.))
880 .py(px(1.))
881 .rounded(px(3.))
882 .text_size(px(11.))
883 .cursor_pointer()
884 .when(ui.show_stderr, |d| {
885 d.bg(stderr_color).text_color(gpui::hsla(0.0, 0.0, 0.12, 1.0))
886 })
887 .when(!ui.show_stderr, |d| d.text_color(stderr_color))
888 .child(SharedString::from(format!("⚠ {n}")))
889 .on_click(cx.listener(move |shell, _, _, cx| {
890 let e = shell.run_ui.entry(id).or_default();
891 e.show_stderr = !e.show_stderr;
892 cx.notify();
893 })),
894 )
895 } else {
896 None
897 };
898
899 // Botón de matar — sólo mientras el comando sigue corriendo.
900 let kill_chip = if r.status == RunStatus::Running {
901 Some(
902 div()
903 .id(SharedString::from(format!("kill-{id}")))
904 .flex_none()
905 .px(px(6.))
906 .py(px(1.))
907 .rounded(px(3.))
908 .text_size(px(11.))
909 .text_color(gpui::hsla(2.0 / 360.0, 0.66, 0.64, 1.0))
910 .cursor_pointer()
911 .hover(|s| s.bg(gpui::hsla(2.0 / 360.0, 0.55, 0.28, 1.0)))
912 .child("✕ matar")
913 .on_click(cx.listener(move |shell, _, _, cx| {
914 shell.kill_run(id);
915 cx.notify();
916 })),
917 )
918 } else {
919 None
920 };
921
922 // Reprocesar — sólo si el comando dejó algo en stdout que filtrar.
923 let reprocess_chip = if r.count_of(Stream::Stdout) > 0 {
924 Some(
925 div()
926 .id(SharedString::from(format!("repro-{id}")))
927 .flex_none()
928 .px(px(6.))
929 .py(px(1.))
930 .rounded(px(3.))
931 .text_size(px(11.))
932 .text_color(accent)
933 .cursor_pointer()
934 .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
935 .child("⤳ reprocesar")
936 .on_click(cx.listener(move |shell, _, _, cx| {
937 shell.reprocess_source = Some(id);
938 cx.notify();
939 })),
940 )
941 } else {
942 None
943 };
944
945 let header = div()
946 .flex()
947 .flex_row()
948 .items_center()
949 .gap(px(6.))
950 .child(header_left)
951 .children(stderr_chip)
952 .children(reprocess_chip)
953 .children(kill_chip);
954
955 // Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
956 let mut body: Vec<gpui::Div> = Vec::new();
957 if !ui.collapsed {
958 // Etapas del pipe: un clic re-ejecuta la línea hasta esa etapa,
959 // como un comando nuevo — así se inspeccionan los intermedios.
960 if r.line.contains('|') {
961 let toks = shuma_line::tokenize(&r.line, shuma_line::Dialect::Bash);
962 let pipe = shuma_line::split_pipeline(&toks);
963 if pipe.stages.len() >= 2 {
964 let chip_bg = gpui::hsla(220.0 / 360.0, 0.18, 0.24, 1.0);
965 let accent = gpui::hsla(190.0 / 360.0, 0.62, 0.62, 1.0);
966 let mut chips: Vec<gpui::AnyElement> = vec![div()
967 .flex_none()
968 .text_size(px(10.))
969 .text_color(dim)
970 .child("⇢ etapas")
971 .into_any_element()];
972 for (i, st) in pipe.stages.iter().enumerate() {
973 let end = st.tokens.last().map(|t| t.end).unwrap_or(r.line.len());
974 let prefix = r.line[..end].trim().to_string();
975 let name =
976 st.command.clone().unwrap_or_else(|| format!("{}", i + 1));
977 chips.push(
978 div()
979 .id(SharedString::from(format!("stage-{id}-{i}")))
980 .flex_none()
981 .px(px(6.))
982 .py(px(1.))
983 .rounded(px(3.))
984 .bg(chip_bg)
985 .text_size(px(11.))
986 .text_color(accent)
987 .cursor_pointer()
988 .hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
989 .child(SharedString::from(name))
990 .on_click(cx.listener(move |shell, _, _, cx| {
991 shell.run_command(prefix.clone());
992 cx.notify();
993 }))
994 .into_any_element(),
995 );
996 }
997 body.push(
998 div()
999 .flex()
1000 .flex_row()
1001 .flex_wrap()
1002 .gap(px(4.))
1003 .items_center()
1004 .children(chips),
1005 );
1006 }
1007 }
1008
1009 let stream = if ui.show_stderr { Stream::Stderr } else { Stream::Stdout };
1010 let lines: Vec<&str> = r.lines_of(stream).collect();
1011 let color = if ui.show_stderr { stderr_color } else { theme.fg_text };
1012 if lines.is_empty() {
1013 body.push(div().text_size(px(11.)).text_color(dim).child(
1014 if ui.show_stderr { "sin errores" } else { "sin salida" },
1015 ));
1016 } else {
1017 // Sin truncar: si hay contenido, se muestra entero.
1018 for l in &lines {
1019 body.push(
1020 div()
1021 .text_size(px(12.))
1022 .text_color(color)
1023 .child(SharedString::from(l.to_string())),
1024 );
1025 }
1026 }
1027 }
1028
1029 div()
1030 .flex()
1031 .flex_col()
1032 .gap(px(3.))
1033 .p(px(8.))
1034 .bg(node_bg)
1035 .border_l_2()
1036 .border_color(gcolor)
1037 .rounded(px(5.))
1038 .child(header)
1039 .children(body)
1040}
1041
1042impl Render for Shell {
1043 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1044 if !self.focused_once {
1045 window.focus(&self.focus);
1046 self.focused_once = true;
1047 }
1048 let theme = Theme::global(cx).clone();
1049 let bg = theme.bg_app.clone();
1050 let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
1051 let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
1052 let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
1053 let text = theme.fg_text;
1054 let dim = theme.fg_muted;
1055
1056 let pipeline = self.line.pipeline();
1057 let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
1058
1059 // --- Barra de estado: cwd + identificador de aislamiento ---
1060 let status = div()
1061 .h(px(32.))
1062 .flex()
1063 .flex_row()
1064 .items_center()
1065 .justify_between()
1066 .px(px(14.))
1067 .bg(panel)
1068 .text_color(text)
1069 .child(
1070 div()
1071 .flex()
1072 .flex_row()
1073 .gap(px(10.))
1074 .items_baseline()
1075 .child("● shuma")
1076 .child(div().text_color(accent).child(SharedString::from(format!(
1077 "📁 {}",
1078 pretty_cwd(self.session.cwd())
1079 ))))
1080 .child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
1081 format!("aisl:{}", self.session.isolation_id()),
1082 ))),
1083 )
1084 .child(
1085 div().text_color(dim).text_size(px(12.)).child(SharedString::from(
1086 if piped > 1 {
1087 format!("⇄ {piped} etapas · {} en curso", self.active.len())
1088 } else {
1089 format!("{} en curso", self.active.len())
1090 },
1091 )),
1092 );
1093
1094 // --- Panel izquierdo: grupos reutilizables [RUN] ---
1095 let left = if self.left_collapsed {
1096 div()
1097 .id("expand-left")
1098 .w(px(26.))
1099 .flex()
1100 .flex_col()
1101 .items_center()
1102 .pt(px(8.))
1103 .bg(panel)
1104 .text_color(dim)
1105 .cursor_pointer()
1106 .hover(|s| s.bg(node_bg))
1107 .child("»")
1108 .on_click(cx.listener(|s, _, _, cx| {
1109 s.left_collapsed = false;
1110 cx.notify();
1111 }))
1112 } else {
1113 let groups: Vec<_> = self
1114 .session
1115 .groups()
1116 .iter()
1117 .enumerate()
1118 .map(|(idx, g)| {
1119 let joined = g.lines.join(" && ");
1120 let count = g.lines.len();
1121 // Los 8 primeros grupos llevan atajo dinámico F1..F8.
1122 let label = if idx < 8 {
1123 format!("F{} ▸ {} ·{count}", idx + 1, g.name)
1124 } else {
1125 format!("▸ {} ·{count}", g.name)
1126 };
1127 div()
1128 .id(SharedString::from(format!("group-{}", g.name)))
1129 .px(px(8.))
1130 .py(px(6.))
1131 .bg(node_bg)
1132 .rounded(px(4.))
1133 .text_color(text)
1134 .text_size(px(13.))
1135 .cursor_pointer()
1136 .hover(|s| s.bg(theme.bg_row_hover))
1137 .child(SharedString::from(label))
1138 .on_click(cx.listener(move |shell, _, _, cx| {
1139 shell.run_command(joined.clone());
1140 cx.notify();
1141 }))
1142 })
1143 .collect();
1144 div()
1145 .id("run-panel")
1146 .w(px(self.left_width))
1147 .flex()
1148 .flex_col()
1149 .gap(px(6.))
1150 .p(px(10.))
1151 .bg(panel)
1152 .child(
1153 div()
1154 .flex()
1155 .flex_row()
1156 .justify_between()
1157 .items_center()
1158 .child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
1159 .child(
1160 div()
1161 .flex()
1162 .flex_row()
1163 .gap(px(4.))
1164 .items_center()
1165 .child(
1166 div()
1167 .id("save-group")
1168 .px(px(5.))
1169 .text_color(dim)
1170 .cursor_pointer()
1171 .hover(|s| s.text_color(accent))
1172 .child("+")
1173 .on_click(cx.listener(|s, _, _, cx| {
1174 s.line.set_text(":save ");
1175 cx.notify();
1176 })),
1177 )
1178 .child(
1179 div()
1180 .id("collapse-left")
1181 .px(px(5.))
1182 .text_color(dim)
1183 .cursor_pointer()
1184 .hover(|s| s.text_color(accent))
1185 .child("«")
1186 .on_click(cx.listener(|s, _, _, cx| {
1187 s.left_collapsed = true;
1188 cx.notify();
1189 })),
1190 ),
1191 ),
1192 )
1193 .children(groups)
1194 .child(
1195 div()
1196 .text_size(px(10.))
1197 .text_color(dim)
1198 .child("clic ejecuta · + guarda lo último"),
1199 )
1200 };
1201
1202 // --- Lienzo central: comandos ejecutados + su salida ---
1203 // Orden de terminal: los más viejos arriba, los nuevos abajo.
1204 let hist = self.session.history();
1205 let start = hist.len().saturating_sub(40);
1206 let runs: Vec<_> = hist[start..]
1207 .iter()
1208 .map(|r| {
1209 let ui = self.run_ui.get(&r.id).copied().unwrap_or_default();
1210 render_run(r, ui, &theme, node_bg, cx)
1211 })
1212 .collect();
1213 let runs_empty = runs.is_empty();
1214 let canvas = div()
1215 .id("runs")
1216 .flex_1()
1217 .overflow_y_scroll()
1218 .track_scroll(&self.scroll)
1219 .flex()
1220 .flex_col()
1221 .gap(px(8.))
1222 .p(px(10.))
1223 .bg(bg.clone())
1224 .when(runs_empty, |d| {
1225 d.child(div().text_color(dim).child(
1226 "Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
1227 ))
1228 })
1229 .children(runs);
1230
1231 // --- Panel derecho: monitores [SENS] ---
1232 let right = if self.right_collapsed {
1233 div()
1234 .id("expand-right")
1235 .w(px(26.))
1236 .flex()
1237 .flex_col()
1238 .items_center()
1239 .pt(px(8.))
1240 .bg(panel)
1241 .text_color(dim)
1242 .cursor_pointer()
1243 .hover(|s| s.bg(node_bg))
1244 .child("«")
1245 .on_click(cx.listener(|s, _, _, cx| {
1246 s.right_collapsed = false;
1247 cx.notify();
1248 }))
1249 } else {
1250 let cpu = self.snapshot.cpu_percent;
1251 let cpu_curve = self.sampler.cpu_history().values();
1252 let mem_curve = self.sampler.mem_history().values();
1253 let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
1254 let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
1255
1256 let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
1257 div()
1258 .flex()
1259 .flex_col()
1260 .gap(px(4.))
1261 .p(px(8.))
1262 .bg(node_bg)
1263 .rounded(px(5.))
1264 .child(
1265 div()
1266 .flex()
1267 .flex_row()
1268 .justify_between()
1269 .items_baseline()
1270 .child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
1271 .child(div().text_color(color).child(SharedString::from(value))),
1272 )
1273 .child(div().h(px(44.)).child(CurveElement::new(curve, color)))
1274 };
1275
1276 div()
1277 .id("sens-panel")
1278 .w(px(self.right_width))
1279 .flex()
1280 .flex_col()
1281 .gap(px(10.))
1282 .p(px(10.))
1283 .bg(panel)
1284 .text_color(text)
1285 .child(
1286 div()
1287 .flex()
1288 .flex_row()
1289 .justify_between()
1290 .items_center()
1291 .child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
1292 .child(
1293 div()
1294 .id("collapse-right")
1295 .px(px(5.))
1296 .text_color(dim)
1297 .cursor_pointer()
1298 .hover(|s| s.text_color(accent))
1299 .child("»")
1300 .on_click(cx.listener(|s, _, _, cx| {
1301 s.right_collapsed = true;
1302 cx.notify();
1303 })),
1304 ),
1305 )
1306 .child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
1307 .child(monitor(
1308 "MEM",
1309 if self.snapshot.valid {
1310 format!(
1311 "{:.1}/{:.0} GB",
1312 self.snapshot.mem_used_mb as f32 / 1024.0,
1313 self.snapshot.mem_total_mb as f32 / 1024.0
1314 )
1315 } else {
1316 "— GB".to_string()
1317 },
1318 mem_curve,
1319 mem_color,
1320 ))
1321 };
1322
1323 // --- Zona prompt: el input inteligente ---
1324 let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("› ")];
1325 let cursor = self.line.cursor();
1326 let tokens = self.line.tokens();
1327 let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
1328 if tokens.is_empty() {
1329 input_row.push(caret());
1330 input_row.push(
1331 div()
1332 .text_color(dim)
1333 .child("escribe un comando… (Tab autocompleta · Enter ejecuta)"),
1334 );
1335 } else {
1336 let mut caret_done = false;
1337 for t in &tokens {
1338 let color = token_color(t.kind, &theme);
1339 if !caret_done && cursor >= t.start && cursor < t.end {
1340 let local = cursor - t.start;
1341 let (left_s, right_s) = t.text.split_at(local);
1342 if !left_s.is_empty() {
1343 input_row
1344 .push(div().flex_none().text_color(color).child(left_s.to_string()));
1345 }
1346 input_row.push(caret());
1347 input_row
1348 .push(div().flex_none().text_color(color).child(right_s.to_string()));
1349 caret_done = true;
1350 } else {
1351 input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
1352 }
1353 }
1354 if !caret_done {
1355 input_row.push(caret());
1356 }
1357 }
1358 // Sugerencia fantasma — el resto que el shell predice, en gris.
1359 if let Some(ghost) = self.compute_ghost() {
1360 input_row.push(
1361 div()
1362 .flex_none()
1363 .text_color(theme.fg_disabled)
1364 .child(SharedString::from(ghost)),
1365 );
1366 }
1367 let input_bar = div()
1368 .h(px(46.))
1369 .flex()
1370 .flex_row()
1371 .items_center()
1372 .px(px(14.))
1373 .text_color(text)
1374 .text_size(px(14.))
1375 .children(input_row);
1376 // Banner del modo reproceso — escribí un filtro para la salida.
1377 let banner = self.reprocess_source.map(|src| {
1378 div()
1379 .px(px(14.))
1380 .py(px(3.))
1381 .bg(gpui::hsla(190.0 / 360.0, 0.30, 0.22, 1.0))
1382 .text_size(px(11.))
1383 .text_color(accent)
1384 .child(SharedString::from(format!(
1385 "⤳ reprocesando la salida de #{src} — escribí un filtro · Esc cancela"
1386 )))
1387 });
1388 let prompt = div()
1389 .flex()
1390 .flex_col()
1391 .bg(panel)
1392 .children(banner)
1393 .child(input_bar);
1394
1395 // --- Popup de autocompletado ---
1396 let mut popup_layer: Vec<gpui::Div> = Vec::new();
1397 if self.show_completion {
1398 if let Some(comp) = &self.completion {
1399 if !comp.candidates.is_empty() {
1400 let kind_label = match comp.kind {
1401 CompletionKind::Command => "comando",
1402 CompletionKind::Flag => "flag",
1403 CompletionKind::Path => "ruta",
1404 };
1405 let total = comp.candidates.len();
1406 let start = self
1407 .completion_index
1408 .saturating_sub(3)
1409 .min(total.saturating_sub(8));
1410 let rows: Vec<_> = comp
1411 .candidates
1412 .iter()
1413 .enumerate()
1414 .skip(start)
1415 .take(8)
1416 .map(|(i, cand)| {
1417 let selected = i == self.completion_index;
1418 div()
1419 .px(px(8.))
1420 .py(px(3.))
1421 .when(selected, |d| {
1422 d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
1423 })
1424 .when(!selected, |d| d.text_color(text))
1425 .child(SharedString::from(cand.clone()))
1426 })
1427 .collect();
1428 popup_layer.push(
1429 div()
1430 .absolute()
1431 .left(px(28.))
1432 .bottom(px(52.))
1433 .w(px(320.))
1434 .flex()
1435 .flex_col()
1436 .bg(node_bg)
1437 .border_1()
1438 .border_color(accent)
1439 .rounded(px(5.))
1440 .text_size(px(13.))
1441 .child(
1442 div()
1443 .px(px(8.))
1444 .py(px(3.))
1445 .text_color(dim)
1446 .text_size(px(11.))
1447 .child(SharedString::from(format!(
1448 "{kind_label} · {total} · ↑↓ Tab"
1449 ))),
1450 )
1451 .children(rows),
1452 );
1453 }
1454 }
1455 }
1456
1457 // --- Divisores arrastrables ---
1458 let divider = |side: Side, cx: &mut Context<Self>| {
1459 div()
1460 .w(px(5.))
1461 .bg(node_bg)
1462 .cursor(CursorStyle::ResizeLeftRight)
1463 .hover(|s| s.bg(accent))
1464 .on_mouse_down(
1465 MouseButton::Left,
1466 cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
1467 let start_w = match side {
1468 Side::Left => shell.left_width,
1469 Side::Right => shell.right_width,
1470 };
1471 shell.drag = Some(Drag {
1472 side,
1473 start_x: ev.position.x.into(),
1474 start_w,
1475 });
1476 cx.notify();
1477 }),
1478 )
1479 };
1480
1481 let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
1482 if !self.left_collapsed {
1483 middle = middle.child(divider(Side::Left, cx));
1484 }
1485 middle = middle.child(canvas);
1486 if !self.right_collapsed {
1487 middle = middle.child(divider(Side::Right, cx));
1488 }
1489 middle = middle.child(right);
1490
1491 // --- Composición ---
1492 div()
1493 .size_full()
1494 .relative()
1495 .flex()
1496 .flex_col()
1497 .bg(bg)
1498 .track_focus(&self.focus)
1499 .key_context("ShumaShell")
1500 .on_key_down(cx.listener(Self::handle_key))
1501 .on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
1502 if let Some(drag) = &shell.drag {
1503 let cur: f32 = ev.position.x.into();
1504 let delta = cur - drag.start_x;
1505 match drag.side {
1506 // El panel izquierdo crece al arrastrar a la derecha.
1507 Side::Left => {
1508 shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
1509 }
1510 // El derecho crece al arrastrar a la izquierda.
1511 Side::Right => {
1512 shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
1513 }
1514 }
1515 cx.notify();
1516 }
1517 }))
1518 .on_mouse_up(
1519 MouseButton::Left,
1520 cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
1521 if shell.drag.take().is_some() {
1522 cx.notify();
1523 }
1524 }),
1525 )
1526 .child(status)
1527 .child(middle)
1528 .child(prompt)
1529 .children(popup_layer)
1530 }
1531}
1532
1533fn main() {
1534 launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
1535}