fdf820edbb
matilda deja de ser sólo un ejecutable aparte: el shell lo incorpora como herramienta. Meta-comando `:matilda plan|script|apply <inventario.json>` — reconcilia contra el estado real de la máquina (matilda-discover) y vuelca el resultado al feed del shell: - `plan`/`script` → una tarjeta sintética con el plan o el script. - `apply` → ejecuta el script de verdad; su salida fluye en una tarjeta como cualquier comando (streaming, captura acotada, kill). El panel [RUN] gana una sección [tools] con «⚙ matilda» que precarga el comando. Reusa todo lo del shell —feed, ejecución, sesión— sin panel nuevo ni peso extra: la herramienta es no invasiva. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1784 lines
65 KiB
Rust
1784 lines
65 KiB
Rust
//! `shuma-shell` — el shell de brahman, ejecutando de verdad.
|
||
//!
|
||
//! El shell trabaja *dentro de una sesión* ([`shuma_session::WorkSession`]):
|
||
//! un directorio actual —que es además el identificador de aislamiento—,
|
||
//! el historial de comandos ejecutados y los grupos reutilizables.
|
||
//!
|
||
//! ```text
|
||
//! ┌─ estado · cwd · aislamiento ──────────────────────┐
|
||
//! │ [RUN] │ comandos ejecutados + su salida │ [SENS] │
|
||
//! │ grupos │ (streaming en vivo) │ monit. │
|
||
//! └─ prompt inteligente ─────────────────────────────┘
|
||
//! ```
|
||
//!
|
||
//! El input se analiza con `shuma-line` (resaltado + autocompletado);
|
||
//! al ejecutar, `shuma-exec` lanza el comando y transmite su salida
|
||
//! línea a línea, que se vuelca en el panel central. El cerebro vive en
|
||
//! crates agnósticos — este binario sólo es el frontend GPUI.
|
||
|
||
use std::panic;
|
||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use gpui::{
|
||
div, point, prelude::*, px, App, Bounds, Context, CursorStyle, Element, ElementId, FocusHandle,
|
||
GlobalElementId, Hsla, InspectorElementId, IntoElement, KeyDownEvent, LayoutId, MouseButton,
|
||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, PathBuilder, Pixels, Render, ScrollHandle,
|
||
SharedString, Style, Window,
|
||
};
|
||
use nahual_launcher::launch_app;
|
||
use nahual_theme::Theme;
|
||
use shuma_exec::{run as exec_run, CommandSpec, Exec, RunEvent, RunHandle, StageSpec};
|
||
use shuma_line::{CompletionKind, CompletionSource, LineState, TokenKind};
|
||
use shuma_session::{CommandRun, RunId, RunStatus, Stream, WorkSession};
|
||
use shuma_sysmon::{Snapshot, SystemSampler};
|
||
|
||
/// Cuántas muestras guarda la curva de cada monitor.
|
||
const HISTORY: usize = 80;
|
||
/// Archivos/directorios que delatan la estructura de un proyecto.
|
||
const PROJECT_MARKERS: &[&str] = &[
|
||
".git",
|
||
"Cargo.toml",
|
||
"package.json",
|
||
"go.mod",
|
||
"Makefile",
|
||
"pyproject.toml",
|
||
"pom.xml",
|
||
"build.gradle",
|
||
];
|
||
|
||
/// Marcadores de proyecto presentes en `dir`.
|
||
fn markers_in(dir: &str) -> Vec<String> {
|
||
let base = std::path::Path::new(dir);
|
||
PROJECT_MARKERS
|
||
.iter()
|
||
.filter(|m| base.join(m).exists())
|
||
.map(|m| m.to_string())
|
||
.collect()
|
||
}
|
||
|
||
/// Segundo Unix actual.
|
||
fn unix_now() -> u64 {
|
||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||
}
|
||
|
||
/// Índice de grupo (0-based) de una tecla de función `f1`..`f8`.
|
||
fn fkey_index(key: &str) -> Option<usize> {
|
||
let n: usize = key.strip_prefix('f')?.parse().ok()?;
|
||
(1..=8).contains(&n).then_some(n - 1)
|
||
}
|
||
|
||
/// Quita las comillas exteriores de un argumento (`"hola"` → `hola`).
|
||
fn unquote(arg: &str) -> String {
|
||
let b = arg.as_bytes();
|
||
if b.len() >= 2 && (b[0] == b'"' || b[0] == b'\'') && b[b.len() - 1] == b[0] {
|
||
let inner = &arg[1..arg.len() - 1];
|
||
if b[0] == b'"' {
|
||
inner.replace("\\\"", "\"").replace("\\\\", "\\")
|
||
} else {
|
||
inner.to_string()
|
||
}
|
||
} else {
|
||
arg.to_string()
|
||
}
|
||
}
|
||
|
||
/// Decide cómo ejecutar una línea. Si es un pipe «simple» —sólo comandos,
|
||
/// argumentos y `|`, sin `$`, redirecciones, operadores ni globs— brahman
|
||
/// la ejecuta **directo**, conectando los procesos él mismo. Si tiene
|
||
/// sintaxis que el modo directo aún no absorbe, cae a `bash -c`: bash
|
||
/// queda como un parser de sintaxis, no como el ejecutor por defecto.
|
||
fn plan_exec(line: &str) -> Exec {
|
||
use shuma_line::TokenKind::*;
|
||
let tokens = shuma_line::tokenize(line, shuma_line::Dialect::Bash);
|
||
let simple = !tokens.is_empty()
|
||
&& tokens.iter().all(|t| {
|
||
matches!(t.kind, Command | Argument | Flag | StringLit | Pipe | Whitespace)
|
||
&& !t.text.contains(['*', '?', '[', ']', '{', '}'])
|
||
&& !t.text.starts_with('~')
|
||
});
|
||
if simple {
|
||
let pipeline = shuma_line::split_pipeline(&tokens);
|
||
let mut stages = Vec::new();
|
||
for st in &pipeline.stages {
|
||
match &st.command {
|
||
Some(cmd) => stages.push(StageSpec {
|
||
program: cmd.clone(),
|
||
args: st.args.iter().map(|a| unquote(a)).collect(),
|
||
}),
|
||
// Una etapa sin comando (línea incompleta) → al shell.
|
||
None => return Exec::Shell { line: line.into(), program: "bash".into() },
|
||
}
|
||
}
|
||
if !stages.is_empty() {
|
||
return Exec::Direct { stages };
|
||
}
|
||
}
|
||
Exec::Shell { line: line.into(), program: "bash".into() }
|
||
}
|
||
|
||
// =====================================================================
|
||
// Fuente de autocompletado.
|
||
// =====================================================================
|
||
|
||
struct ShellCompletionSource {
|
||
commands: Vec<String>,
|
||
cwd: String,
|
||
}
|
||
|
||
impl ShellCompletionSource {
|
||
fn scan(cwd: String) -> Self {
|
||
let mut commands = Vec::new();
|
||
if let Ok(path) = std::env::var("PATH") {
|
||
for dir in path.split(':') {
|
||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||
for e in entries.flatten() {
|
||
if let Some(name) = e.file_name().to_str() {
|
||
commands.push(name.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
commands.sort();
|
||
commands.dedup();
|
||
Self { commands, cwd }
|
||
}
|
||
}
|
||
|
||
impl CompletionSource for ShellCompletionSource {
|
||
fn commands(&self) -> Vec<String> {
|
||
self.commands.clone()
|
||
}
|
||
|
||
fn paths(&self, prefix: &str) -> Vec<String> {
|
||
let (dir, partial) = match prefix.rfind('/') {
|
||
Some(i) => (&prefix[..=i], &prefix[i + 1..]),
|
||
None => ("", prefix),
|
||
};
|
||
// Una ruta relativa se resuelve contra el cwd de la sesión.
|
||
let base = if dir.starts_with('/') {
|
||
dir.to_string()
|
||
} else if dir.is_empty() {
|
||
self.cwd.clone()
|
||
} else {
|
||
format!("{}/{}", self.cwd, dir)
|
||
};
|
||
let mut out = Vec::new();
|
||
if let Ok(entries) = std::fs::read_dir(&base) {
|
||
for e in entries.flatten() {
|
||
if let Some(name) = e.file_name().to_str() {
|
||
if name.starts_with(partial) {
|
||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||
let slash = if is_dir { "/" } else { "" };
|
||
out.push(format!("{dir}{name}{slash}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out.sort();
|
||
out
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// CurveElement — la curva de un monitor.
|
||
// =====================================================================
|
||
|
||
struct CurveElement {
|
||
values: Vec<f32>,
|
||
color: Hsla,
|
||
}
|
||
|
||
impl CurveElement {
|
||
fn new(values: Vec<f32>, color: Hsla) -> Self {
|
||
Self { values, color }
|
||
}
|
||
}
|
||
|
||
impl IntoElement for CurveElement {
|
||
type Element = Self;
|
||
fn into_element(self) -> Self::Element {
|
||
self
|
||
}
|
||
}
|
||
|
||
impl Element for CurveElement {
|
||
type RequestLayoutState = ();
|
||
type PrepaintState = ();
|
||
|
||
fn id(&self) -> Option<ElementId> {
|
||
None
|
||
}
|
||
fn source_location(&self) -> Option<&'static panic::Location<'static>> {
|
||
None
|
||
}
|
||
|
||
fn request_layout(
|
||
&mut self,
|
||
_id: Option<&GlobalElementId>,
|
||
_inspector: Option<&InspectorElementId>,
|
||
window: &mut Window,
|
||
cx: &mut App,
|
||
) -> (LayoutId, ()) {
|
||
let mut style = Style::default();
|
||
style.size.width = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
|
||
style.size.height = gpui::Length::Definite(gpui::DefiniteLength::Fraction(1.0));
|
||
(window.request_layout(style, [], cx), ())
|
||
}
|
||
|
||
fn prepaint(
|
||
&mut self,
|
||
_id: Option<&GlobalElementId>,
|
||
_inspector: Option<&InspectorElementId>,
|
||
_bounds: Bounds<Pixels>,
|
||
_layout: &mut (),
|
||
_window: &mut Window,
|
||
_cx: &mut App,
|
||
) {
|
||
}
|
||
|
||
fn paint(
|
||
&mut self,
|
||
_id: Option<&GlobalElementId>,
|
||
_inspector: Option<&InspectorElementId>,
|
||
bounds: Bounds<Pixels>,
|
||
_layout: &mut (),
|
||
_prepaint: &mut (),
|
||
window: &mut Window,
|
||
_cx: &mut App,
|
||
) {
|
||
let n = self.values.len();
|
||
if n < 2 {
|
||
return;
|
||
}
|
||
let ox: f32 = bounds.origin.x.into();
|
||
let oy: f32 = bounds.origin.y.into();
|
||
let bw: f32 = bounds.size.width.into();
|
||
let bh: f32 = bounds.size.height.into();
|
||
let mut pb = PathBuilder::stroke(px(1.6));
|
||
for (i, v) in self.values.iter().enumerate() {
|
||
let x = ox + bw * (i as f32 / (n - 1) as f32);
|
||
let y = oy + bh - (v.clamp(0.0, 100.0) / 100.0) * bh;
|
||
let p = point(px(x), px(y));
|
||
if i == 0 {
|
||
pb.move_to(p);
|
||
} else {
|
||
pb.line_to(p);
|
||
}
|
||
}
|
||
if let Ok(path) = pb.build() {
|
||
window.paint_path(path, self.color);
|
||
}
|
||
}
|
||
}
|
||
|
||
// =====================================================================
|
||
// El shell.
|
||
// =====================================================================
|
||
|
||
/// Qué panel lateral está redimensionando un drag activo.
|
||
#[derive(Clone, Copy)]
|
||
enum Side {
|
||
Left,
|
||
Right,
|
||
}
|
||
|
||
/// Estado de un arrastre de divisor en curso.
|
||
struct Drag {
|
||
side: Side,
|
||
/// Posición X del cursor al iniciar el arrastre.
|
||
start_x: f32,
|
||
/// Ancho del panel al iniciar el arrastre.
|
||
start_w: f32,
|
||
}
|
||
|
||
/// Estado de presentación de la tarjeta de un comando.
|
||
#[derive(Default, Clone, Copy)]
|
||
struct RunUi {
|
||
/// Acordeón cerrado — sólo se ve la cabecera.
|
||
collapsed: bool,
|
||
/// El filtro muestra stderr en vez de stdout.
|
||
show_stderr: bool,
|
||
/// El usuario tocó el acordeón a mano — ya no se autocolapsa.
|
||
user_touched: bool,
|
||
}
|
||
|
||
struct Shell {
|
||
line: LineState,
|
||
/// La sesión de trabajo: cwd, historial y grupos.
|
||
session: WorkSession,
|
||
/// Comandos en curso, con su canal de salida.
|
||
active: Vec<(RunId, RunHandle)>,
|
||
completion: Option<shuma_line::Completion>,
|
||
completion_index: usize,
|
||
show_completion: bool,
|
||
source: ShellCompletionSource,
|
||
sampler: SystemSampler,
|
||
snapshot: Snapshot,
|
||
left_collapsed: bool,
|
||
right_collapsed: bool,
|
||
/// Anchos de los paneles laterales (los divisores los ajustan).
|
||
left_width: f32,
|
||
right_width: f32,
|
||
/// Arrastre de divisor en curso, si lo hay.
|
||
drag: Option<Drag>,
|
||
/// Estado de presentación por comando (acordeón, filtro stderr).
|
||
run_ui: HashMap<RunId, RunUi>,
|
||
/// Largo del historial en el último `:save` — define qué comandos
|
||
/// entran al próximo grupo guardado.
|
||
group_anchor: usize,
|
||
/// Patrones detectados por el motor de inferencia (cache).
|
||
patterns: Vec<shuma_infer::EmergingPattern>,
|
||
/// Si está activo, el próximo comando reprocesa la salida de este run.
|
||
reprocess_source: Option<RunId>,
|
||
/// Scroll del feed central — sigue al comando más reciente.
|
||
scroll: ScrollHandle,
|
||
focus: FocusHandle,
|
||
focused_once: bool,
|
||
}
|
||
|
||
impl Shell {
|
||
fn new(cx: &mut Context<Self>) -> Self {
|
||
let cwd = std::env::current_dir()
|
||
.map(|p| p.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|_| "/".to_string());
|
||
|
||
let mut session = WorkSession::new("sesión", &cwd);
|
||
// Grupos de ejemplo — recetas reutilizables.
|
||
session.save_group("estado git", vec!["git status --short".into()]);
|
||
session.save_group("build", vec!["cargo build --release".into()]);
|
||
|
||
let shell = Self {
|
||
line: LineState::new(),
|
||
session,
|
||
active: Vec::new(),
|
||
completion: None,
|
||
completion_index: 0,
|
||
show_completion: false,
|
||
source: ShellCompletionSource::scan(cwd),
|
||
sampler: SystemSampler::new(HISTORY),
|
||
snapshot: Snapshot {
|
||
cpu_percent: 0.0,
|
||
mem_percent: 0.0,
|
||
mem_used_mb: 0,
|
||
mem_total_mb: 0,
|
||
valid: false,
|
||
},
|
||
left_collapsed: false,
|
||
right_collapsed: false,
|
||
left_width: 176.0,
|
||
right_width: 188.0,
|
||
drag: None,
|
||
run_ui: HashMap::new(),
|
||
group_anchor: 0,
|
||
patterns: Vec::new(),
|
||
reprocess_source: None,
|
||
scroll: ScrollHandle::new(),
|
||
focus: cx.focus_handle(),
|
||
focused_once: false,
|
||
};
|
||
shell.start_loop(cx);
|
||
shell
|
||
}
|
||
|
||
/// Bucle de fondo: drena la salida de los comandos (~9/s) y refresca
|
||
/// los monitores (~1/s).
|
||
fn start_loop(&self, cx: &mut Context<Self>) {
|
||
cx.spawn(async move |this, cx| {
|
||
let mut tick: u32 = 0;
|
||
loop {
|
||
cx.background_executor().timer(Duration::from_millis(110)).await;
|
||
tick += 1;
|
||
let sysmon = tick % 10 == 0;
|
||
let alive = this.update(cx, |shell, cx| {
|
||
let mut changed = shell.drain_exec();
|
||
if sysmon {
|
||
shell.snapshot = shell.sampler.sample();
|
||
changed = true;
|
||
}
|
||
if changed {
|
||
cx.notify();
|
||
}
|
||
});
|
||
if alive.is_err() {
|
||
break;
|
||
}
|
||
}
|
||
})
|
||
.detach();
|
||
}
|
||
|
||
/// Vuelca la salida disponible de los comandos en curso al historial.
|
||
fn drain_exec(&mut self) -> bool {
|
||
let now = unix_now();
|
||
let mut changed = false;
|
||
for (id, handle) in &mut self.active {
|
||
for ev in handle.try_events() {
|
||
changed = true;
|
||
match ev {
|
||
RunEvent::Stdout(l) => {
|
||
self.session.append_output(*id, Stream::Stdout, l)
|
||
}
|
||
RunEvent::Stderr(l) => {
|
||
self.session.append_output(*id, Stream::Stderr, l)
|
||
}
|
||
RunEvent::Truncated => self.session.mark_truncated(*id),
|
||
RunEvent::Spilled(path) => {
|
||
self.session.mark_truncated(*id);
|
||
self.session.append_output(
|
||
*id,
|
||
Stream::Stdout,
|
||
format!("↡ salida excedente volcada a {path}"),
|
||
);
|
||
}
|
||
RunEvent::Exited(code) => self.session.finish_run(*id, code, now),
|
||
RunEvent::Failed(msg) => {
|
||
self.session.append_output(
|
||
*id,
|
||
Stream::Stderr,
|
||
format!("✗ no se pudo lanzar: {msg}"),
|
||
);
|
||
self.session.finish_run(*id, -1, now);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let finished = self.active.iter().any(|(_, h)| h.is_finished());
|
||
self.active.retain(|(_, h)| !h.is_finished());
|
||
if finished {
|
||
// Al cerrarse un comando, el motor de inferencia revisa si
|
||
// emergió un patrón repetido y lo promueve a un grupo.
|
||
self.infer_patterns();
|
||
}
|
||
if changed {
|
||
self.scroll.scroll_to_bottom();
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// Comandos del historial reducidos a registros de inferencia.
|
||
fn infer_records(&self) -> Vec<shuma_infer::CommandRecord> {
|
||
self.session
|
||
.history()
|
||
.iter()
|
||
.map(|r| {
|
||
shuma_infer::CommandRecord::parse(&r.line, &r.cwd, r.status == RunStatus::Ok)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Corre el motor de inferencia, cachea los patrones y promueve el
|
||
/// más fuerte a un grupo reutilizable (rehidratación).
|
||
fn infer_patterns(&mut self) {
|
||
let records = self.infer_records();
|
||
self.patterns =
|
||
shuma_infer::detect_patterns(&records, &shuma_infer::InferConfig::default());
|
||
if let Some(top) = self.patterns.first() {
|
||
let name = format!("✨ {}", top.suggested_name());
|
||
if self.session.group(&name).is_none() {
|
||
self.session.save_group(name, top.example.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Condición de disparo de un patrón: los marcadores de proyecto
|
||
/// comunes a todos los directorios donde corrió.
|
||
fn pattern_trigger(&self, p: &shuma_infer::EmergingPattern) -> Vec<String> {
|
||
let mut dirs = p.directories.iter();
|
||
let Some(first) = dirs.next() else {
|
||
return Vec::new();
|
||
};
|
||
let mut common = markers_in(first);
|
||
for d in dirs {
|
||
let here = markers_in(d);
|
||
common.retain(|m| here.contains(m));
|
||
}
|
||
common
|
||
}
|
||
|
||
/// La secuencia que el motor predice como continuación, si la hay.
|
||
fn predicted_sequence(&self) -> Option<String> {
|
||
if self.patterns.is_empty() {
|
||
return None;
|
||
}
|
||
let records = self.infer_records();
|
||
let tail = &records[records.len().saturating_sub(6)..];
|
||
let (pi, next) = shuma_infer::predict_next(tail, &self.patterns)?;
|
||
if next.is_empty() {
|
||
return None;
|
||
}
|
||
// Disparo por estructura: no anticipar un patrón en un directorio
|
||
// que no comparte su forma (no sugerir `cargo` sin `Cargo.toml`).
|
||
let trigger = self.pattern_trigger(&self.patterns[pi]);
|
||
if !trigger.is_empty() {
|
||
let here = markers_in(self.session.cwd());
|
||
if !trigger.iter().all(|m| here.contains(m)) {
|
||
return None;
|
||
}
|
||
}
|
||
Some(next.join(" && "))
|
||
}
|
||
|
||
/// Calcula el sufijo fantasma del prompt: el resto de la línea que el
|
||
/// shell predice. Sólo con el cursor al final.
|
||
fn compute_ghost(&self) -> Option<String> {
|
||
let line = self.line.text();
|
||
if line.is_empty() || self.line.cursor() != line.len() {
|
||
return None;
|
||
}
|
||
// Corpus por prioridad: secuencia predicha, luego historial
|
||
// reciente.
|
||
let mut corpus: Vec<String> = Vec::new();
|
||
if let Some(seq) = self.predicted_sequence() {
|
||
corpus.push(seq);
|
||
}
|
||
for r in self.session.history().iter().rev() {
|
||
corpus.push(r.line.clone());
|
||
}
|
||
shuma_line::ghost_suggestion(line, &corpus)
|
||
}
|
||
|
||
/// Resuelve el destino de un `cd` contra el cwd de la sesión.
|
||
fn resolve_cd(&self, arg: &str) -> Result<String, String> {
|
||
let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
|
||
let target = if arg.is_empty() || arg == "~" {
|
||
home
|
||
} else if let Some(rest) = arg.strip_prefix("~/") {
|
||
format!("{home}/{rest}")
|
||
} else if arg.starts_with('/') {
|
||
arg.to_string()
|
||
} else {
|
||
format!("{}/{}", self.session.cwd(), arg)
|
||
};
|
||
match std::fs::canonicalize(&target) {
|
||
Ok(p) if p.is_dir() => Ok(p.to_string_lossy().into_owned()),
|
||
Ok(_) => Err(format!("cd: no es un directorio: {target}")),
|
||
Err(e) => Err(format!("cd: {target}: {e}")),
|
||
}
|
||
}
|
||
|
||
/// Ejecuta una línea: `cd` se maneja internamente (cambia el cwd y,
|
||
/// con él, el aislamiento); el resto se lanza con `shuma-exec` y su
|
||
/// salida se transmite al panel central.
|
||
/// Guarda como grupo los comandos ejecutados desde el último `:save`.
|
||
fn save_group(&mut self, name: &str) {
|
||
let name = name.trim();
|
||
if name.is_empty() {
|
||
return;
|
||
}
|
||
let n = self.session.history().len().saturating_sub(self.group_anchor);
|
||
if n > 0 {
|
||
self.session.save_recent_as_group(name, n);
|
||
self.group_anchor = self.session.history().len();
|
||
}
|
||
}
|
||
|
||
fn run_command(&mut self, line: String) {
|
||
let line = line.trim().to_string();
|
||
if line.is_empty() {
|
||
return;
|
||
}
|
||
let now = unix_now();
|
||
|
||
// Meta-comandos del shell — configuran la sesión, no se ejecutan.
|
||
if let Some(name) = line.strip_prefix(":save ") {
|
||
self.save_group(name);
|
||
return;
|
||
}
|
||
if let Some(arg) = line.strip_prefix(":limit ") {
|
||
// `:limit <MB>` — tope de captura de la sesión; 0 = sin tope.
|
||
if let Ok(mb) = arg.trim().parse::<usize>() {
|
||
self.session.set_capture_limit(mb * 1024 * 1024);
|
||
}
|
||
return;
|
||
}
|
||
if let Some(arg) = line.strip_prefix(":spill ") {
|
||
// `:spill on|off` — volcar a disco la salida excedente.
|
||
self.session
|
||
.set_spill(matches!(arg.trim(), "on" | "si" | "sí" | "1" | "true"));
|
||
return;
|
||
}
|
||
if let Some(args) = line.strip_prefix(":matilda ") {
|
||
// Herramienta matilda embebida — administración de servidores.
|
||
self.matilda_command(args);
|
||
return;
|
||
}
|
||
|
||
// Los comandos anteriores que el usuario no fijó se autocolapsan
|
||
// al aparecer uno nuevo abajo — orden de terminal tradicional.
|
||
for ui in self.run_ui.values_mut() {
|
||
if !ui.user_touched {
|
||
ui.collapsed = true;
|
||
}
|
||
}
|
||
|
||
// `cd` interno — un subproceso no podría cambiar nuestro cwd.
|
||
if line == "cd" || line.starts_with("cd ") {
|
||
let arg = line.strip_prefix("cd").unwrap_or("").trim();
|
||
let id = self.session.begin_run(&line, now);
|
||
self.run_ui.insert(id, RunUi::default());
|
||
match self.resolve_cd(arg) {
|
||
Ok(new_cwd) => {
|
||
self.session.set_cwd(new_cwd.clone());
|
||
self.source.cwd = new_cwd.clone();
|
||
self.session
|
||
.append_output(id, Stream::Stdout, format!("→ {new_cwd}"));
|
||
self.session.finish_run(id, 0, now);
|
||
}
|
||
Err(e) => {
|
||
self.session.append_output(id, Stream::Stderr, e);
|
||
self.session.finish_run(id, 1, now);
|
||
}
|
||
}
|
||
self.scroll.scroll_to_bottom();
|
||
return;
|
||
}
|
||
|
||
let id = self.session.begin_run(&line, now);
|
||
self.run_ui.insert(id, RunUi::default());
|
||
let spec = self.build_spec(&line, None, id);
|
||
self.active.push((id, exec_run(&spec)));
|
||
self.scroll.scroll_to_bottom();
|
||
}
|
||
|
||
/// Arma la `CommandSpec` de una línea: decide directo vs shell y
|
||
/// aplica la política de captura de la sesión.
|
||
fn build_spec(&self, line: &str, stdin: Option<String>, run_id: RunId) -> CommandSpec {
|
||
self.build_spec_exec(plan_exec(line), stdin, run_id)
|
||
}
|
||
|
||
/// `build_spec` con el modo de ejecución ya decidido (lo usa la
|
||
/// herramienta matilda, que ejecuta un script de shell completo).
|
||
fn build_spec_exec(&self, exec: Exec, stdin: Option<String>, run_id: RunId) -> CommandSpec {
|
||
let policy = self.session.capture();
|
||
let spill_path = (policy.spill && policy.limit_bytes > 0).then(|| {
|
||
std::env::temp_dir()
|
||
.join(format!("shuma-spill-{}-{run_id}.log", std::process::id()))
|
||
});
|
||
CommandSpec {
|
||
exec,
|
||
cwd: self.session.cwd().to_string(),
|
||
capture_limit: policy.limit_bytes,
|
||
spill_path,
|
||
stdin_data: stdin,
|
||
}
|
||
}
|
||
|
||
/// Registra un comando "sintético" ya terminado — su salida la
|
||
/// produce el shell mismo, no un proceso (la usa `:matilda plan`).
|
||
fn synthetic_run(&mut self, label: &str, output: Vec<String>, ok: bool) {
|
||
for ui in self.run_ui.values_mut() {
|
||
if !ui.user_touched {
|
||
ui.collapsed = true;
|
||
}
|
||
}
|
||
let now = unix_now();
|
||
let id = self.session.begin_run(label, now);
|
||
self.run_ui.insert(id, RunUi::default());
|
||
for line in output {
|
||
self.session.append_output(id, Stream::Stdout, line);
|
||
}
|
||
self.session.finish_run(id, if ok { 0 } else { 1 }, now);
|
||
self.scroll.scroll_to_bottom();
|
||
}
|
||
|
||
/// Ejecuta `exec_line` mostrando `label` en la tarjeta — el comando
|
||
/// real puede diferir de lo que se ve (matilda corre un script).
|
||
fn spawn_labeled(&mut self, label: String, exec_line: String) {
|
||
for ui in self.run_ui.values_mut() {
|
||
if !ui.user_touched {
|
||
ui.collapsed = true;
|
||
}
|
||
}
|
||
let now = unix_now();
|
||
let id = self.session.begin_run(&label, now);
|
||
self.run_ui.insert(id, RunUi::default());
|
||
let exec = Exec::Shell { line: exec_line, program: "bash".into() };
|
||
let spec = self.build_spec_exec(exec, None, id);
|
||
self.active.push((id, exec_run(&spec)));
|
||
self.scroll.scroll_to_bottom();
|
||
}
|
||
|
||
/// Carga un inventario JSON, resolviendo la ruta contra el cwd.
|
||
fn load_inventory(&self, file: &str) -> Result<matilda_core::Inventory, String> {
|
||
let path = if file.starts_with('/') {
|
||
file.to_string()
|
||
} else {
|
||
format!("{}/{}", self.session.cwd(), file)
|
||
};
|
||
let text = std::fs::read_to_string(&path)
|
||
.map_err(|e| format!("no se pudo leer {path}: {e}"))?;
|
||
serde_json::from_str(&text).map_err(|e| format!("JSON inválido: {e}"))
|
||
}
|
||
|
||
/// La herramienta matilda, embebida: `:matilda plan|script|apply
|
||
/// <inventario.json>`. Reconcilia contra el estado real de la
|
||
/// máquina y vuelca el resultado al feed del shell.
|
||
fn matilda_command(&mut self, args: &str) {
|
||
let label = format!(":matilda {args}");
|
||
let parts: Vec<&str> = args.split_whitespace().collect();
|
||
let (sub, file) = match parts.as_slice() {
|
||
[s, f] => (*s, *f),
|
||
_ => {
|
||
self.synthetic_run(
|
||
&label,
|
||
vec!["uso: :matilda plan|script|apply <inventario.json>".into()],
|
||
false,
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
let desired = match self.load_inventory(file) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
self.synthetic_run(&label, vec![e], false);
|
||
return;
|
||
}
|
||
};
|
||
// Reconcilia contra el estado observado de esta máquina.
|
||
let current =
|
||
matilda_discover::observed_inventory(&matilda_discover::discover_local(), &desired);
|
||
let p = matilda_plan::plan(¤t, &desired);
|
||
|
||
match sub {
|
||
"plan" => {
|
||
let lines: Vec<String> = if p.is_empty() {
|
||
vec!["sin cambios: el servidor ya está al día".into()]
|
||
} else {
|
||
p.actions
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, a)| format!("{:>2}. {}", i + 1, a.describe()))
|
||
.collect()
|
||
};
|
||
self.synthetic_run(&label, lines, true);
|
||
}
|
||
"script" => {
|
||
let script = matilda_apply::steps_to_script(&matilda_apply::plan_to_steps(
|
||
&p, &desired,
|
||
));
|
||
self.synthetic_run(&label, script.lines().map(String::from).collect(), true);
|
||
}
|
||
"apply" => {
|
||
let steps = matilda_apply::plan_to_steps(&p, &desired);
|
||
if steps.is_empty() {
|
||
self.synthetic_run(&label, vec!["sin cambios: nada que aplicar".into()], true);
|
||
} else {
|
||
// El script se ejecuta de verdad — fluye al feed.
|
||
self.spawn_labeled(label, matilda_apply::steps_to_script(&steps));
|
||
}
|
||
}
|
||
other => {
|
||
self.synthetic_run(&label, vec![format!("subcomando desconocido: {other}")], false)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Reprocesa la salida capturada del comando `source`: ejecuta `line`
|
||
/// alimentándole esa salida por stdin, sin volver a correr el
|
||
/// original. Así un resultado se filtra con distintas herramientas.
|
||
fn run_reprocess(&mut self, line: String, source: RunId) {
|
||
let line = line.trim().to_string();
|
||
if line.is_empty() {
|
||
return;
|
||
}
|
||
let data: String = self
|
||
.session
|
||
.run(source)
|
||
.map(|r| r.lines_of(Stream::Stdout).collect::<Vec<_>>().join("\n"))
|
||
.unwrap_or_default();
|
||
for ui in self.run_ui.values_mut() {
|
||
if !ui.user_touched {
|
||
ui.collapsed = true;
|
||
}
|
||
}
|
||
let now = unix_now();
|
||
let id = self.session.begin_run(&line, now);
|
||
self.run_ui.insert(id, RunUi::default());
|
||
let spec = self.build_spec(&line, Some(data), id);
|
||
self.active.push((id, exec_run(&spec)));
|
||
self.scroll.scroll_to_bottom();
|
||
}
|
||
|
||
/// Mata el proceso de un comando en curso.
|
||
fn kill_run(&self, id: RunId) {
|
||
if let Some((_, handle)) = self.active.iter().find(|(rid, _)| *rid == id) {
|
||
handle.kill();
|
||
}
|
||
}
|
||
|
||
fn refresh_completion(&mut self) {
|
||
let comp = self.line.complete(&self.source);
|
||
self.show_completion =
|
||
!comp.candidates.is_empty() && comp.replace_end > comp.replace_start;
|
||
self.completion_index = 0;
|
||
self.completion = Some(comp);
|
||
}
|
||
|
||
fn on_tab(&mut self) {
|
||
let comp = self.line.complete(&self.source);
|
||
if comp.candidates.is_empty() {
|
||
return;
|
||
}
|
||
if self.show_completion {
|
||
let idx = self.completion_index.min(comp.candidates.len() - 1);
|
||
let candidate = comp.candidates[idx].clone();
|
||
self.line.apply_completion(&comp, &candidate);
|
||
self.show_completion = false;
|
||
self.completion = None;
|
||
} else {
|
||
self.completion_index = 0;
|
||
self.completion = Some(comp);
|
||
self.show_completion = true;
|
||
}
|
||
}
|
||
|
||
fn cycle_completion(&mut self, delta: i32) {
|
||
if !self.show_completion {
|
||
return;
|
||
}
|
||
if let Some(comp) = &self.completion {
|
||
let n = comp.candidates.len();
|
||
if n > 0 {
|
||
let i = self.completion_index as i32 + delta;
|
||
self.completion_index = i.rem_euclid(n as i32) as usize;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Enter — ejecuta el contenido del input, o reprocesa una salida
|
||
/// previa si hay un origen de reproceso activo.
|
||
fn submit(&mut self) {
|
||
let line = self.line.text().to_string();
|
||
self.line.clear();
|
||
self.completion = None;
|
||
self.show_completion = false;
|
||
if let Some(source) = self.reprocess_source.take() {
|
||
self.run_reprocess(line, source);
|
||
} else {
|
||
self.run_command(line);
|
||
}
|
||
}
|
||
|
||
fn handle_key(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context<Self>) {
|
||
let ks = &event.keystroke;
|
||
let key = ks.key.as_str();
|
||
let ctrl = ks.modifiers.control;
|
||
let mut changed = false;
|
||
|
||
match key {
|
||
"enter" => {
|
||
self.submit();
|
||
cx.notify();
|
||
return;
|
||
}
|
||
"escape" => {
|
||
self.show_completion = false;
|
||
self.reprocess_source = None;
|
||
cx.notify();
|
||
return;
|
||
}
|
||
"tab" => {
|
||
self.on_tab();
|
||
cx.notify();
|
||
return;
|
||
}
|
||
"up" => {
|
||
self.cycle_completion(-1);
|
||
cx.notify();
|
||
return;
|
||
}
|
||
"down" => {
|
||
self.cycle_completion(1);
|
||
cx.notify();
|
||
return;
|
||
}
|
||
"backspace" => {
|
||
self.line.backspace();
|
||
changed = true;
|
||
}
|
||
"delete" => {
|
||
self.line.delete();
|
||
changed = true;
|
||
}
|
||
"left" => {
|
||
if ctrl {
|
||
self.line.move_word_left();
|
||
} else {
|
||
self.line.move_left();
|
||
}
|
||
}
|
||
"right" => {
|
||
if ctrl {
|
||
self.line.move_word_right();
|
||
} else if self.line.cursor() == self.line.text().len() {
|
||
// En el extremo, la flecha derecha acepta el fantasma.
|
||
if let Some(g) = self.compute_ghost() {
|
||
self.line.insert(&g);
|
||
changed = true;
|
||
}
|
||
} else {
|
||
self.line.move_right();
|
||
}
|
||
}
|
||
"home" => self.line.move_home(),
|
||
"end" => self.line.move_end(),
|
||
"a" if ctrl => self.line.move_home(),
|
||
"e" if ctrl => self.line.move_end(),
|
||
"u" if ctrl => {
|
||
self.line.clear();
|
||
changed = true;
|
||
}
|
||
"space" if ctrl => {
|
||
// Ctrl+Space también acepta el fantasma predicho.
|
||
if let Some(g) = self.compute_ghost() {
|
||
self.line.insert(&g);
|
||
changed = true;
|
||
}
|
||
}
|
||
_ if fkey_index(key).is_some() => {
|
||
// F1..F8 ejecutan el grupo de esa posición en [RUN].
|
||
let idx = fkey_index(key).unwrap();
|
||
let joined =
|
||
self.session.groups().get(idx).map(|g| g.lines.join(" && "));
|
||
if let Some(j) = joined {
|
||
self.run_command(j);
|
||
}
|
||
cx.notify();
|
||
return;
|
||
}
|
||
_ => {
|
||
if !ctrl {
|
||
if let Some(ch) = ks.key_char.as_deref() {
|
||
if !ch.chars().any(|c| c.is_control()) {
|
||
self.line.insert(ch);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if changed {
|
||
self.refresh_completion();
|
||
}
|
||
cx.notify();
|
||
}
|
||
}
|
||
|
||
/// Color de resaltado de cada clase de token.
|
||
fn token_color(kind: TokenKind, theme: &Theme) -> Hsla {
|
||
match kind {
|
||
TokenKind::Command => gpui::hsla(190.0 / 360.0, 0.65, 0.62, 1.0),
|
||
TokenKind::Argument => theme.fg_text,
|
||
TokenKind::Flag => gpui::hsla(38.0 / 360.0, 0.80, 0.62, 1.0),
|
||
TokenKind::StringLit => gpui::hsla(95.0 / 360.0, 0.42, 0.60, 1.0),
|
||
TokenKind::Variable => gpui::hsla(280.0 / 360.0, 0.55, 0.72, 1.0),
|
||
TokenKind::Pipe => gpui::hsla(190.0 / 360.0, 0.90, 0.72, 1.0),
|
||
TokenKind::Redirect => gpui::hsla(20.0 / 360.0, 0.78, 0.62, 1.0),
|
||
TokenKind::Operator => gpui::hsla(0.0, 0.66, 0.66, 1.0),
|
||
TokenKind::Comment => theme.fg_muted,
|
||
TokenKind::Whitespace => theme.fg_text,
|
||
TokenKind::Unknown => gpui::hsla(0.0, 0.70, 0.60, 1.0),
|
||
}
|
||
}
|
||
|
||
/// Resalta un texto estático (la línea de un comando del historial).
|
||
fn highlight(text: &str, theme: &Theme) -> Vec<gpui::Div> {
|
||
shuma_line::tokenize(text, shuma_line::Dialect::Bash)
|
||
.into_iter()
|
||
.map(|t| {
|
||
div()
|
||
.flex_none()
|
||
.text_color(token_color(t.kind, theme))
|
||
.child(t.text)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Acorta el cwd: el `$HOME` se muestra como `~`.
|
||
fn pretty_cwd(cwd: &str) -> String {
|
||
match std::env::var("HOME") {
|
||
Ok(home) if cwd == home => "~".to_string(),
|
||
Ok(home) if cwd.starts_with(&format!("{home}/")) => format!("~{}", &cwd[home.len()..]),
|
||
_ => cwd.to_string(),
|
||
}
|
||
}
|
||
|
||
/// Renderiza la tarjeta de un comando ejecutado: cabecera-acordeón +
|
||
/// filtro stdout/stderr + cuerpo de salida.
|
||
fn render_run(
|
||
r: &CommandRun,
|
||
ui: RunUi,
|
||
theme: &Theme,
|
||
node_bg: Hsla,
|
||
cx: &mut Context<Shell>,
|
||
) -> impl IntoElement {
|
||
let id = r.id;
|
||
let dim = theme.fg_muted;
|
||
let (glyph, gcolor) = match r.status {
|
||
RunStatus::Running => ("▷", gpui::hsla(45.0 / 360.0, 0.75, 0.60, 1.0)),
|
||
RunStatus::Ok => ("✓", gpui::hsla(140.0 / 360.0, 0.48, 0.55, 1.0)),
|
||
RunStatus::Failed => ("✗", gpui::hsla(2.0 / 360.0, 0.68, 0.60, 1.0)),
|
||
};
|
||
let stderr_color = gpui::hsla(8.0 / 360.0, 0.62, 0.66, 1.0);
|
||
let accent = gpui::hsla(190.0 / 360.0, 0.60, 0.62, 1.0);
|
||
|
||
// Nota a la derecha: salida no-cero, truncado, y conteo si colapsada.
|
||
let mut parts: Vec<String> = Vec::new();
|
||
if let Some(c) = r.exit_code {
|
||
if c != 0 {
|
||
parts.push(format!("salió {c}"));
|
||
}
|
||
}
|
||
if r.truncated {
|
||
parts.push("⚠ truncado".to_string());
|
||
}
|
||
if ui.collapsed {
|
||
let n = r.count_of(Stream::Stdout);
|
||
if n > 0 {
|
||
parts.push(format!("{n} líneas"));
|
||
}
|
||
}
|
||
let note = parts.join(" · ");
|
||
|
||
// Cabecera-acordeón: un clic colapsa/expande.
|
||
let caret = if ui.collapsed { "▸" } else { "▾" };
|
||
let header_left = div()
|
||
.id(SharedString::from(format!("hdr-{id}")))
|
||
.flex()
|
||
.flex_row()
|
||
.items_center()
|
||
.gap(px(6.))
|
||
.flex_1()
|
||
.cursor_pointer()
|
||
.child(div().flex_none().text_color(dim).child(caret))
|
||
.child(div().flex_none().text_color(gcolor).child(glyph))
|
||
.children(highlight(&r.line, theme))
|
||
.child(
|
||
div()
|
||
.flex_none()
|
||
.text_size(px(11.))
|
||
.text_color(dim)
|
||
.child(SharedString::from(note)),
|
||
)
|
||
.on_click(cx.listener(move |shell, _, _, cx| {
|
||
let e = shell.run_ui.entry(id).or_default();
|
||
e.collapsed = !e.collapsed;
|
||
e.user_touched = true;
|
||
cx.notify();
|
||
}));
|
||
|
||
// Filtro de stderr — sólo aparece si el comando emitió errores.
|
||
let stderr_chip = if r.has_stderr() {
|
||
let n = r.count_of(Stream::Stderr);
|
||
Some(
|
||
div()
|
||
.id(SharedString::from(format!("err-{id}")))
|
||
.flex_none()
|
||
.px(px(6.))
|
||
.py(px(1.))
|
||
.rounded(px(3.))
|
||
.text_size(px(11.))
|
||
.cursor_pointer()
|
||
.when(ui.show_stderr, |d| {
|
||
d.bg(stderr_color).text_color(gpui::hsla(0.0, 0.0, 0.12, 1.0))
|
||
})
|
||
.when(!ui.show_stderr, |d| d.text_color(stderr_color))
|
||
.child(SharedString::from(format!("⚠ {n}")))
|
||
.on_click(cx.listener(move |shell, _, _, cx| {
|
||
let e = shell.run_ui.entry(id).or_default();
|
||
e.show_stderr = !e.show_stderr;
|
||
cx.notify();
|
||
})),
|
||
)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Botón de matar — sólo mientras el comando sigue corriendo.
|
||
let kill_chip = if r.status == RunStatus::Running {
|
||
Some(
|
||
div()
|
||
.id(SharedString::from(format!("kill-{id}")))
|
||
.flex_none()
|
||
.px(px(6.))
|
||
.py(px(1.))
|
||
.rounded(px(3.))
|
||
.text_size(px(11.))
|
||
.text_color(gpui::hsla(2.0 / 360.0, 0.66, 0.64, 1.0))
|
||
.cursor_pointer()
|
||
.hover(|s| s.bg(gpui::hsla(2.0 / 360.0, 0.55, 0.28, 1.0)))
|
||
.child("✕ matar")
|
||
.on_click(cx.listener(move |shell, _, _, cx| {
|
||
shell.kill_run(id);
|
||
cx.notify();
|
||
})),
|
||
)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Reprocesar — sólo si el comando dejó algo en stdout que filtrar.
|
||
let reprocess_chip = if r.count_of(Stream::Stdout) > 0 {
|
||
Some(
|
||
div()
|
||
.id(SharedString::from(format!("repro-{id}")))
|
||
.flex_none()
|
||
.px(px(6.))
|
||
.py(px(1.))
|
||
.rounded(px(3.))
|
||
.text_size(px(11.))
|
||
.text_color(accent)
|
||
.cursor_pointer()
|
||
.hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
|
||
.child("⤳ reprocesar")
|
||
.on_click(cx.listener(move |shell, _, _, cx| {
|
||
shell.reprocess_source = Some(id);
|
||
cx.notify();
|
||
})),
|
||
)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let header = div()
|
||
.flex()
|
||
.flex_row()
|
||
.items_center()
|
||
.gap(px(6.))
|
||
.child(header_left)
|
||
.children(stderr_chip)
|
||
.children(reprocess_chip)
|
||
.children(kill_chip);
|
||
|
||
// Cuerpo: sólo con el acordeón abierto. El filtro elige el flujo.
|
||
let mut body: Vec<gpui::Div> = Vec::new();
|
||
if !ui.collapsed {
|
||
// Etapas del pipe: un clic re-ejecuta la línea hasta esa etapa,
|
||
// como un comando nuevo — así se inspeccionan los intermedios.
|
||
if r.line.contains('|') {
|
||
let toks = shuma_line::tokenize(&r.line, shuma_line::Dialect::Bash);
|
||
let pipe = shuma_line::split_pipeline(&toks);
|
||
if pipe.stages.len() >= 2 {
|
||
let chip_bg = gpui::hsla(220.0 / 360.0, 0.18, 0.24, 1.0);
|
||
let accent = gpui::hsla(190.0 / 360.0, 0.62, 0.62, 1.0);
|
||
let mut chips: Vec<gpui::AnyElement> = vec![div()
|
||
.flex_none()
|
||
.text_size(px(10.))
|
||
.text_color(dim)
|
||
.child("⇢ etapas")
|
||
.into_any_element()];
|
||
for (i, st) in pipe.stages.iter().enumerate() {
|
||
let end = st.tokens.last().map(|t| t.end).unwrap_or(r.line.len());
|
||
let prefix = r.line[..end].trim().to_string();
|
||
let name =
|
||
st.command.clone().unwrap_or_else(|| format!("{}", i + 1));
|
||
chips.push(
|
||
div()
|
||
.id(SharedString::from(format!("stage-{id}-{i}")))
|
||
.flex_none()
|
||
.px(px(6.))
|
||
.py(px(1.))
|
||
.rounded(px(3.))
|
||
.bg(chip_bg)
|
||
.text_size(px(11.))
|
||
.text_color(accent)
|
||
.cursor_pointer()
|
||
.hover(|s| s.text_color(gpui::hsla(0.0, 0.0, 0.95, 1.0)))
|
||
.child(SharedString::from(name))
|
||
.on_click(cx.listener(move |shell, _, _, cx| {
|
||
shell.run_command(prefix.clone());
|
||
cx.notify();
|
||
}))
|
||
.into_any_element(),
|
||
);
|
||
}
|
||
body.push(
|
||
div()
|
||
.flex()
|
||
.flex_row()
|
||
.flex_wrap()
|
||
.gap(px(4.))
|
||
.items_center()
|
||
.children(chips),
|
||
);
|
||
}
|
||
}
|
||
|
||
let stream = if ui.show_stderr { Stream::Stderr } else { Stream::Stdout };
|
||
let lines: Vec<&str> = r.lines_of(stream).collect();
|
||
let color = if ui.show_stderr { stderr_color } else { theme.fg_text };
|
||
if lines.is_empty() {
|
||
body.push(div().text_size(px(11.)).text_color(dim).child(
|
||
if ui.show_stderr { "sin errores" } else { "sin salida" },
|
||
));
|
||
} else {
|
||
// Sin truncar: si hay contenido, se muestra entero.
|
||
for l in &lines {
|
||
body.push(
|
||
div()
|
||
.text_size(px(12.))
|
||
.text_color(color)
|
||
.child(SharedString::from(l.to_string())),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(3.))
|
||
.p(px(8.))
|
||
.bg(node_bg)
|
||
.border_l_2()
|
||
.border_color(gcolor)
|
||
.rounded(px(5.))
|
||
.child(header)
|
||
.children(body)
|
||
}
|
||
|
||
impl Render for Shell {
|
||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||
if !self.focused_once {
|
||
window.focus(&self.focus);
|
||
self.focused_once = true;
|
||
}
|
||
let theme = Theme::global(cx).clone();
|
||
let bg = theme.bg_app.clone();
|
||
let panel = gpui::hsla(220.0 / 360.0, 0.16, 0.11, 1.0);
|
||
let node_bg = gpui::hsla(220.0 / 360.0, 0.14, 0.16, 1.0);
|
||
let accent = gpui::hsla(190.0 / 360.0, 0.70, 0.62, 1.0);
|
||
let text = theme.fg_text;
|
||
let dim = theme.fg_muted;
|
||
|
||
let pipeline = self.line.pipeline();
|
||
let piped = pipeline.stages.iter().filter(|s| s.command.is_some()).count();
|
||
|
||
// --- Barra de estado: cwd + identificador de aislamiento ---
|
||
let status = div()
|
||
.h(px(32.))
|
||
.flex()
|
||
.flex_row()
|
||
.items_center()
|
||
.justify_between()
|
||
.px(px(14.))
|
||
.bg(panel)
|
||
.text_color(text)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_row()
|
||
.gap(px(10.))
|
||
.items_baseline()
|
||
.child("● shuma")
|
||
.child(div().text_color(accent).child(SharedString::from(format!(
|
||
"📁 {}",
|
||
pretty_cwd(self.session.cwd())
|
||
))))
|
||
.child(div().text_color(dim).text_size(px(11.)).child(SharedString::from(
|
||
format!("aisl:{}", self.session.isolation_id()),
|
||
))),
|
||
)
|
||
.child(
|
||
div().text_color(dim).text_size(px(12.)).child(SharedString::from({
|
||
// Política de captura de la sesión: tope + volcado.
|
||
let pol = self.session.capture();
|
||
let cap = if pol.limit_bytes == 0 {
|
||
"cap ∞".to_string()
|
||
} else {
|
||
format!(
|
||
"cap {}M{}",
|
||
pol.limit_bytes / (1024 * 1024),
|
||
if pol.spill { "↡" } else { "" }
|
||
)
|
||
};
|
||
let running = if piped > 1 {
|
||
format!("⇄ {piped} etapas · {} en curso", self.active.len())
|
||
} else {
|
||
format!("{} en curso", self.active.len())
|
||
};
|
||
format!("{cap} · {running}")
|
||
})),
|
||
);
|
||
|
||
// --- Panel izquierdo: grupos reutilizables [RUN] ---
|
||
let left = if self.left_collapsed {
|
||
div()
|
||
.id("expand-left")
|
||
.w(px(26.))
|
||
.flex()
|
||
.flex_col()
|
||
.items_center()
|
||
.pt(px(8.))
|
||
.bg(panel)
|
||
.text_color(dim)
|
||
.cursor_pointer()
|
||
.hover(|s| s.bg(node_bg))
|
||
.child("»")
|
||
.on_click(cx.listener(|s, _, _, cx| {
|
||
s.left_collapsed = false;
|
||
cx.notify();
|
||
}))
|
||
} else {
|
||
let groups: Vec<_> = self
|
||
.session
|
||
.groups()
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(idx, g)| {
|
||
let joined = g.lines.join(" && ");
|
||
let count = g.lines.len();
|
||
// Los 8 primeros grupos llevan atajo dinámico F1..F8.
|
||
let label = if idx < 8 {
|
||
format!("F{} ▸ {} ·{count}", idx + 1, g.name)
|
||
} else {
|
||
format!("▸ {} ·{count}", g.name)
|
||
};
|
||
div()
|
||
.id(SharedString::from(format!("group-{}", g.name)))
|
||
.px(px(8.))
|
||
.py(px(6.))
|
||
.bg(node_bg)
|
||
.rounded(px(4.))
|
||
.text_color(text)
|
||
.text_size(px(13.))
|
||
.cursor_pointer()
|
||
.hover(|s| s.bg(theme.bg_row_hover))
|
||
.child(SharedString::from(label))
|
||
.on_click(cx.listener(move |shell, _, _, cx| {
|
||
shell.run_command(joined.clone());
|
||
cx.notify();
|
||
}))
|
||
})
|
||
.collect();
|
||
div()
|
||
.id("run-panel")
|
||
.w(px(self.left_width))
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(6.))
|
||
.p(px(10.))
|
||
.bg(panel)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_row()
|
||
.justify_between()
|
||
.items_center()
|
||
.child(div().text_color(dim).text_size(px(12.)).child("[RUN] grupos"))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_row()
|
||
.gap(px(4.))
|
||
.items_center()
|
||
.child(
|
||
div()
|
||
.id("save-group")
|
||
.px(px(5.))
|
||
.text_color(dim)
|
||
.cursor_pointer()
|
||
.hover(|s| s.text_color(accent))
|
||
.child("+")
|
||
.on_click(cx.listener(|s, _, _, cx| {
|
||
s.line.set_text(":save ");
|
||
cx.notify();
|
||
})),
|
||
)
|
||
.child(
|
||
div()
|
||
.id("collapse-left")
|
||
.px(px(5.))
|
||
.text_color(dim)
|
||
.cursor_pointer()
|
||
.hover(|s| s.text_color(accent))
|
||
.child("«")
|
||
.on_click(cx.listener(|s, _, _, cx| {
|
||
s.left_collapsed = true;
|
||
cx.notify();
|
||
})),
|
||
),
|
||
),
|
||
)
|
||
.children(groups)
|
||
.child(
|
||
div()
|
||
.text_size(px(10.))
|
||
.text_color(dim)
|
||
.child("clic ejecuta · + guarda lo último"),
|
||
)
|
||
.child(div().h(px(1.)).bg(theme.border))
|
||
.child(div().text_color(dim).text_size(px(12.)).child("[tools]"))
|
||
.child(
|
||
div()
|
||
.id("tool-matilda")
|
||
.px(px(8.))
|
||
.py(px(6.))
|
||
.bg(node_bg)
|
||
.rounded(px(4.))
|
||
.text_color(text)
|
||
.text_size(px(13.))
|
||
.cursor_pointer()
|
||
.hover(|s| s.bg(theme.bg_row_hover))
|
||
.child("⚙ matilda")
|
||
.on_click(cx.listener(|shell, _, _, cx| {
|
||
// Precarga el comando para que el usuario nombre el inventario.
|
||
shell.line.set_text(":matilda plan ");
|
||
cx.notify();
|
||
})),
|
||
)
|
||
};
|
||
|
||
// --- Lienzo central: comandos ejecutados + su salida ---
|
||
// Orden de terminal: los más viejos arriba, los nuevos abajo.
|
||
let hist = self.session.history();
|
||
let start = hist.len().saturating_sub(40);
|
||
let runs: Vec<_> = hist[start..]
|
||
.iter()
|
||
.map(|r| {
|
||
let ui = self.run_ui.get(&r.id).copied().unwrap_or_default();
|
||
render_run(r, ui, &theme, node_bg, cx)
|
||
})
|
||
.collect();
|
||
let runs_empty = runs.is_empty();
|
||
let canvas = div()
|
||
.id("runs")
|
||
.flex_1()
|
||
.overflow_y_scroll()
|
||
.track_scroll(&self.scroll)
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(8.))
|
||
.p(px(10.))
|
||
.bg(bg.clone())
|
||
.when(runs_empty, |d| {
|
||
d.child(div().text_color(dim).child(
|
||
"Escribe un comando abajo y presiona Enter — su salida aparece aquí.",
|
||
))
|
||
})
|
||
.children(runs);
|
||
|
||
// --- Panel derecho: monitores [SENS] ---
|
||
let right = if self.right_collapsed {
|
||
div()
|
||
.id("expand-right")
|
||
.w(px(26.))
|
||
.flex()
|
||
.flex_col()
|
||
.items_center()
|
||
.pt(px(8.))
|
||
.bg(panel)
|
||
.text_color(dim)
|
||
.cursor_pointer()
|
||
.hover(|s| s.bg(node_bg))
|
||
.child("«")
|
||
.on_click(cx.listener(|s, _, _, cx| {
|
||
s.right_collapsed = false;
|
||
cx.notify();
|
||
}))
|
||
} else {
|
||
let cpu = self.snapshot.cpu_percent;
|
||
let cpu_curve = self.sampler.cpu_history().values();
|
||
let mem_curve = self.sampler.mem_history().values();
|
||
let cpu_color = gpui::hsla(190.0 / 360.0, 0.72, 0.62, 1.0);
|
||
let mem_color = gpui::hsla(265.0 / 360.0, 0.55, 0.70, 1.0);
|
||
|
||
let monitor = |title: &str, value: String, curve: Vec<f32>, color: Hsla| {
|
||
div()
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(4.))
|
||
.p(px(8.))
|
||
.bg(node_bg)
|
||
.rounded(px(5.))
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_row()
|
||
.justify_between()
|
||
.items_baseline()
|
||
.child(div().text_color(dim).text_size(px(11.)).child(title.to_string()))
|
||
.child(div().text_color(color).child(SharedString::from(value))),
|
||
)
|
||
.child(div().h(px(44.)).child(CurveElement::new(curve, color)))
|
||
};
|
||
|
||
div()
|
||
.id("sens-panel")
|
||
.w(px(self.right_width))
|
||
.flex()
|
||
.flex_col()
|
||
.gap(px(10.))
|
||
.p(px(10.))
|
||
.bg(panel)
|
||
.text_color(text)
|
||
.child(
|
||
div()
|
||
.flex()
|
||
.flex_row()
|
||
.justify_between()
|
||
.items_center()
|
||
.child(div().text_color(dim).text_size(px(12.)).child("[SENS]"))
|
||
.child(
|
||
div()
|
||
.id("collapse-right")
|
||
.px(px(5.))
|
||
.text_color(dim)
|
||
.cursor_pointer()
|
||
.hover(|s| s.text_color(accent))
|
||
.child("»")
|
||
.on_click(cx.listener(|s, _, _, cx| {
|
||
s.right_collapsed = true;
|
||
cx.notify();
|
||
})),
|
||
),
|
||
)
|
||
.child(monitor("CPU", format!("{cpu:.0} %"), cpu_curve, cpu_color))
|
||
.child(monitor(
|
||
"MEM",
|
||
if self.snapshot.valid {
|
||
format!(
|
||
"{:.1}/{:.0} GB",
|
||
self.snapshot.mem_used_mb as f32 / 1024.0,
|
||
self.snapshot.mem_total_mb as f32 / 1024.0
|
||
)
|
||
} else {
|
||
"— GB".to_string()
|
||
},
|
||
mem_curve,
|
||
mem_color,
|
||
))
|
||
};
|
||
|
||
// --- Zona prompt: el input inteligente ---
|
||
let mut input_row: Vec<gpui::Div> = vec![div().flex_none().text_color(accent).child("› ")];
|
||
let cursor = self.line.cursor();
|
||
let tokens = self.line.tokens();
|
||
let caret = || div().w(px(2.)).h(px(19.)).bg(accent);
|
||
if tokens.is_empty() {
|
||
input_row.push(caret());
|
||
input_row.push(
|
||
div()
|
||
.text_color(dim)
|
||
.child("escribe un comando… (Tab autocompleta · Enter ejecuta)"),
|
||
);
|
||
} else {
|
||
let mut caret_done = false;
|
||
for t in &tokens {
|
||
let color = token_color(t.kind, &theme);
|
||
if !caret_done && cursor >= t.start && cursor < t.end {
|
||
let local = cursor - t.start;
|
||
let (left_s, right_s) = t.text.split_at(local);
|
||
if !left_s.is_empty() {
|
||
input_row
|
||
.push(div().flex_none().text_color(color).child(left_s.to_string()));
|
||
}
|
||
input_row.push(caret());
|
||
input_row
|
||
.push(div().flex_none().text_color(color).child(right_s.to_string()));
|
||
caret_done = true;
|
||
} else {
|
||
input_row.push(div().flex_none().text_color(color).child(t.text.clone()));
|
||
}
|
||
}
|
||
if !caret_done {
|
||
input_row.push(caret());
|
||
}
|
||
}
|
||
// Sugerencia fantasma — el resto que el shell predice, en gris.
|
||
if let Some(ghost) = self.compute_ghost() {
|
||
input_row.push(
|
||
div()
|
||
.flex_none()
|
||
.text_color(theme.fg_disabled)
|
||
.child(SharedString::from(ghost)),
|
||
);
|
||
}
|
||
let input_bar = div()
|
||
.h(px(46.))
|
||
.flex()
|
||
.flex_row()
|
||
.items_center()
|
||
.px(px(14.))
|
||
.text_color(text)
|
||
.text_size(px(14.))
|
||
.children(input_row);
|
||
// Banner del modo reproceso — escribí un filtro para la salida.
|
||
let banner = self.reprocess_source.map(|src| {
|
||
div()
|
||
.px(px(14.))
|
||
.py(px(3.))
|
||
.bg(gpui::hsla(190.0 / 360.0, 0.30, 0.22, 1.0))
|
||
.text_size(px(11.))
|
||
.text_color(accent)
|
||
.child(SharedString::from(format!(
|
||
"⤳ reprocesando la salida de #{src} — escribí un filtro · Esc cancela"
|
||
)))
|
||
});
|
||
let prompt = div()
|
||
.flex()
|
||
.flex_col()
|
||
.bg(panel)
|
||
.children(banner)
|
||
.child(input_bar);
|
||
|
||
// --- Popup de autocompletado ---
|
||
let mut popup_layer: Vec<gpui::Div> = Vec::new();
|
||
if self.show_completion {
|
||
if let Some(comp) = &self.completion {
|
||
if !comp.candidates.is_empty() {
|
||
let kind_label = match comp.kind {
|
||
CompletionKind::Command => "comando",
|
||
CompletionKind::Flag => "flag",
|
||
CompletionKind::Path => "ruta",
|
||
};
|
||
let total = comp.candidates.len();
|
||
let start = self
|
||
.completion_index
|
||
.saturating_sub(3)
|
||
.min(total.saturating_sub(8));
|
||
let rows: Vec<_> = comp
|
||
.candidates
|
||
.iter()
|
||
.enumerate()
|
||
.skip(start)
|
||
.take(8)
|
||
.map(|(i, cand)| {
|
||
let selected = i == self.completion_index;
|
||
div()
|
||
.px(px(8.))
|
||
.py(px(3.))
|
||
.when(selected, |d| {
|
||
d.bg(accent).text_color(gpui::hsla(0.0, 0.0, 0.1, 1.0))
|
||
})
|
||
.when(!selected, |d| d.text_color(text))
|
||
.child(SharedString::from(cand.clone()))
|
||
})
|
||
.collect();
|
||
popup_layer.push(
|
||
div()
|
||
.absolute()
|
||
.left(px(28.))
|
||
.bottom(px(52.))
|
||
.w(px(320.))
|
||
.flex()
|
||
.flex_col()
|
||
.bg(node_bg)
|
||
.border_1()
|
||
.border_color(accent)
|
||
.rounded(px(5.))
|
||
.text_size(px(13.))
|
||
.child(
|
||
div()
|
||
.px(px(8.))
|
||
.py(px(3.))
|
||
.text_color(dim)
|
||
.text_size(px(11.))
|
||
.child(SharedString::from(format!(
|
||
"{kind_label} · {total} · ↑↓ Tab"
|
||
))),
|
||
)
|
||
.children(rows),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Divisores arrastrables ---
|
||
let divider = |side: Side, cx: &mut Context<Self>| {
|
||
div()
|
||
.w(px(5.))
|
||
.bg(node_bg)
|
||
.cursor(CursorStyle::ResizeLeftRight)
|
||
.hover(|s| s.bg(accent))
|
||
.on_mouse_down(
|
||
MouseButton::Left,
|
||
cx.listener(move |shell, ev: &MouseDownEvent, _w, cx| {
|
||
let start_w = match side {
|
||
Side::Left => shell.left_width,
|
||
Side::Right => shell.right_width,
|
||
};
|
||
shell.drag = Some(Drag {
|
||
side,
|
||
start_x: ev.position.x.into(),
|
||
start_w,
|
||
});
|
||
cx.notify();
|
||
}),
|
||
)
|
||
};
|
||
|
||
let mut middle = div().flex().flex_row().flex_1().overflow_hidden().child(left);
|
||
if !self.left_collapsed {
|
||
middle = middle.child(divider(Side::Left, cx));
|
||
}
|
||
middle = middle.child(canvas);
|
||
if !self.right_collapsed {
|
||
middle = middle.child(divider(Side::Right, cx));
|
||
}
|
||
middle = middle.child(right);
|
||
|
||
// --- Composición ---
|
||
div()
|
||
.size_full()
|
||
.relative()
|
||
.flex()
|
||
.flex_col()
|
||
.bg(bg)
|
||
.track_focus(&self.focus)
|
||
.key_context("ShumaShell")
|
||
.on_key_down(cx.listener(Self::handle_key))
|
||
.on_mouse_move(cx.listener(|shell, ev: &MouseMoveEvent, _w, cx| {
|
||
if let Some(drag) = &shell.drag {
|
||
let cur: f32 = ev.position.x.into();
|
||
let delta = cur - drag.start_x;
|
||
match drag.side {
|
||
// El panel izquierdo crece al arrastrar a la derecha.
|
||
Side::Left => {
|
||
shell.left_width = (drag.start_w + delta).clamp(130.0, 420.0)
|
||
}
|
||
// El derecho crece al arrastrar a la izquierda.
|
||
Side::Right => {
|
||
shell.right_width = (drag.start_w - delta).clamp(130.0, 420.0)
|
||
}
|
||
}
|
||
cx.notify();
|
||
}
|
||
}))
|
||
.on_mouse_up(
|
||
MouseButton::Left,
|
||
cx.listener(|shell, _ev: &MouseUpEvent, _w, cx| {
|
||
if shell.drag.take().is_some() {
|
||
cx.notify();
|
||
}
|
||
}),
|
||
)
|
||
.child(status)
|
||
.child(middle)
|
||
.child(prompt)
|
||
.children(popup_layer)
|
||
}
|
||
}
|
||
|
||
impl Drop for Shell {
|
||
/// Al cerrar la sesión, limpia sus archivos de volcado temporales.
|
||
fn drop(&mut self) {
|
||
let prefix = format!("shuma-spill-{}-", std::process::id());
|
||
if let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) {
|
||
for e in entries.flatten() {
|
||
if e.file_name().to_string_lossy().starts_with(&prefix) {
|
||
let _ = std::fs::remove_file(e.path());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn main() {
|
||
launch_app("brahman · shuma shell", (1100., 700.), Shell::new);
|
||
}
|