Mundo terminal: intérprete de comandos
Un intérprete de juguete de verdad (help, ls, cat, echo, date, clear) con historial, Tab y pantalla CRT. Sin tocar el sistema.
Bienvenido. Escribe help para ver los comandos.
Prueba: help · ls · cat servicios.txt · Tab autocompleta · ↑ historial · Ctrl+L limpia
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
/** Mundo terminal: un intérprete de comandos de juguete de verdad (help, ls, cat, whoami, date, echo, clear), con historial en flechas arriba/abajo, autocompletado con Tab y una pantalla CRT. Todo en español y sin tocar nada del sistema. React + TypeScript, sin dependencias. */
export type MundoTerminalProps = {
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcmte-root { box-sizing: border-box; container-type: inline-size; width: 100%; padding: clamp(14px, 4cqi, 32px); background: #050806; font: 500 14px/1.6 ui-monospace, "SF Mono", Menlo, Consolas, monospace; color: #7dffa1; }
.gcmte-root *, .gcmte-root *::before, .gcmte-root *::after { box-sizing: border-box; }
.gcmte-root ::selection { background: #7dffa1; color: #021006; }
@media (prefers-reduced-motion: reduce) { .gcmte-root * { animation: none !important; } }
.gcmte-screen { position: relative; max-width: 680px; height: 340px; margin: 0 auto; padding: 16px 18px; overflow-y: auto; border: 1px solid #1d5a30; border-radius: 12px; background: radial-gradient(120% 120% at 50% 40%, #07160c, #030705 85%); box-shadow: inset 0 0 60px rgba(125, 255, 161, .12), 0 0 40px rgba(125, 255, 161, .1); text-shadow: 0 0 6px rgba(125, 255, 161, .55); cursor: text; scrollbar-width: thin; scrollbar-color: #1d5a30 transparent; }
.gcmte-screen::after { content: ""; position: sticky; display: block; left: 0; right: 0; bottom: 0; height: 0; }
.gcmte-scan { position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(180deg, transparent 0 2px, rgba(0, 0, 0, .25) 2px 3px); border-radius: 12px; }
.gcmte-line { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.gcmte-line[data-k="in"] { color: #c7ffd6; }
.gcmte-line[data-k="err"] { color: #ff8a7a; text-shadow: 0 0 6px rgba(255, 90, 70, .5); }
.gcmte-prompt { display: flex; gap: 8px; align-items: center; }
.gcmte-prompt label { flex: none; color: #62b4ff; }
.gcmte-in { flex: 1; min-width: 0; border: 0; padding: 0; background: transparent; color: #c7ffd6; font: inherit; text-shadow: inherit; outline: 0; caret-color: #7dffa1; }
.gcmte-hint { max-width: 680px; margin: 10px auto 0; font-size: 12px; color: #3f8a58; }`;
type L = { k: "in" | "out" | "err"; t: string };
const FILES: Record<string, string> = {
"servicios.txt": "Software a medida\nAutomatización de procesos\nIntegraciones y APIs\nMigración a la nube\nCiberseguridad",
"contacto.txt": "Escribe a hola@ejemplo.com\nResponde en un día hábil.",
"leeme.md": "Esta terminal es de juguete: no ejecuta nada real.\nPrueba: help, ls, cat servicios.txt",
};
const CMDS = ["help", "ls", "cat", "whoami", "date", "echo", "clear", "pwd"];
export function MundoTerminal({ className = "", style }: MundoTerminalProps) {
const [lines, setLines] = useState<L[]>([{ k: "out", t: "Bienvenido. Escribe help para ver los comandos." }]);
const [value, setValue] = useState("");
const hist = useRef<string[]>([]);
const idx = useRef(-1);
const input = useRef<HTMLInputElement>(null);
const screen = useRef<HTMLDivElement>(null);
useEffect(() => { screen.current?.scrollTo({ top: screen.current.scrollHeight }); }, [lines]);
function run(raw: string) {
const [cmd, ...args] = raw.trim().split(/\s+/);
const out: L[] = [{ k: "in", t: `visitante@sitio:~$ ${raw}` }];
if (!raw.trim()) { setLines((l) => [...l, ...out]); return; }
if (cmd === "clear") { setLines([]); return; }
const add = (t: string, k: L["k"] = "out") => out.push({ k, t });
if (cmd === "help") add("Comandos: " + CMDS.join(", "));
else if (cmd === "ls") add(Object.keys(FILES).join(" "));
else if (cmd === "pwd") add("/home/visitante");
else if (cmd === "whoami") add("visitante");
else if (cmd === "date") add(new Date().toLocaleString("es-MX", { dateStyle: "full", timeStyle: "short" }));
else if (cmd === "echo") add(args.join(" "));
else if (cmd === "cat") { if (!args[0]) add("uso: cat <archivo>", "err"); else if (FILES[args[0]]) add(FILES[args[0]]); else add(`cat: ${args[0]}: no existe el archivo`, "err"); }
else add(`${cmd}: comando no encontrado. Prueba help.`, "err");
setLines((l) => [...l, ...out]);
}
function key(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
run(value);
if (value.trim()) hist.current.push(value);
idx.current = hist.current.length;
setValue("");
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (hist.current.length) { idx.current = Math.max(0, idx.current - 1); setValue(hist.current[idx.current]); }
} else if (e.key === "ArrowDown") {
e.preventDefault();
idx.current = Math.min(hist.current.length, idx.current + 1);
setValue(hist.current[idx.current] ?? "");
} else if (e.key === "Tab") {
e.preventDefault();
const parts = value.split(" ");
const last = parts[parts.length - 1];
const pool = parts.length === 1 ? CMDS : Object.keys(FILES);
const hit = pool.find((p) => p.startsWith(last) && p !== last);
if (hit) setValue([...parts.slice(0, -1), hit].join(" "));
} else if (e.key === "l" && e.ctrlKey) {
e.preventDefault();
setLines([]);
}
}
return (
<div className={`gcmte-root ${className}`} style={style}>
<style>{CSS}</style>
<div ref={screen} className="gcmte-screen" onClick={() => input.current?.focus()}>
{lines.map((l, i) => (
<p key={i} className="gcmte-line" data-k={l.k}>{l.t}</p>
))}
<div className="gcmte-prompt">
<label htmlFor="gcmte-i">visitante@sitio:~$</label>
<input id="gcmte-i" ref={input} className="gcmte-in" value={value} autoComplete="off" autoCapitalize="off" spellCheck={false} onChange={(e) => setValue(e.target.value)} onKeyDown={key} aria-label="Comando" />
</div>
<div className="gcmte-scan" aria-hidden="true" />
</div>
<p className="gcmte-hint">Prueba: help · ls · cat servicios.txt · Tab autocompleta · ↑ historial · Ctrl+L limpia</p>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/mundo-terminalEjemplo de uso de Mundo terminal: intérprete de comandos
Después de copiar mundo-terminal.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { MundoTerminal } from "./mundo-terminal";
export default function Ejemplo() {
return <MundoTerminal />;
}Propiedades de MundoTerminal
MundoTerminal acepta 2 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 104 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Mundo terminal: intérprete de comandos por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Responde al teclado, no solo al ratón.
- Es un componente de cliente de Next.js: incluye la directiva "use client".