Ir al contenido
Evaluar mi proyecto
Componentes · Mundos de estiloDisponible

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.

mundo-terminal.tsx104 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/mundo-terminal

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)