GUIDE

Build a TUI
Dashboard App

Terminal UI para monitorear tu bot en tiempo real
React + Ink + RxJS · paso a paso desde cero
heteronimos-semi-asistidos-sdk · v0.0.0
de zero a dashboard en 8 pasos — sin repetición — cada archivo una sola vez

☞ Lo que vas a construir

Una app de terminal interactiva que conecta al SDK mediante streams RxJS y muestra en tiempo real:
 ┌─ heteronimos-semi-asistidos-sdk ── dashboard ──────────────────┐
 │                                                                │
 │  ┌──────────────────────────────────────────────────────────┐  │
 │  │  [Overview]  [Logs]  [Chats]                       [q]   │  │
 │  ├──────────────────────────────────────────────────────────┤  │
 │  │                                                          │  │
 │  │  Bot Status                                              │  │
 │  │  ● RUNNING          uptime: 12m 34s                      │  │
 │  │                                                          │  │
 │  │  Stats                                                   │  │
 │  │  chats: 3    commands: 7    plugins: 1                   │  │
 │  │                                                          │  │
 │  │  Plugins                                                 │  │
 │  │  [rb] RabbitBot (4 cmds)                                 │  │
 │  │                                                          │  │
 │  ├──────────────────────────────────────────────────────────┤  │
 │  │  [1] Overview  [2] Logs  [3] Chats  [4] Config  [5] Commands  [Tab]  [q] │  │
 │  └──────────────────────────────────────────────────────────┘  │
 │                                                                │
 └────────────────────────────────────────────────────────────────┘
5 paneles: Overview (estado del bot + stats), Logs (tail en vivo con filtros), Chats (chats activos + mensajes recientes), Config (modo mock / setup token) y Commands (ejecutar comandos desde la UI — solo modo mock). Navegas entre ellos con 1 2 3 4 5 o Tab.

☞ Conceptos clave antes de empezar

React en la terminal. Ink usa React como motor de layout pero renderiza en la terminal en vez del navegador. Componentes con JSX, hooks (useState, useEffect), props — mismo modelo mental. Ink aporta Box (flexbox), Text (estilos ANSI) y useInput (keybindings).
RxJS como bus de eventos. El SDK expone un RuntimeEmitter con un Subject<RuntimeEvent> interno. Cada acción del bot (log, mensaje, plugin cargado, comando sincronizado) produce un evento en el stream. Puedes suscribirte a events$ (todo), logs$ (solo logs), messages$ (solo mensajes) o snapshot$ (estado acumulado del bot).
Store reactivo → re-render. Un mini-store de ~30 líneas (getState/setState/subscribe) conecta los eventos RxJS con React. Cada evento muta el estado del store. React se re-renderiza al suscribirse con useEffect.
El SDK no impone la UI. RuntimeEmitter, las interfaces y los tipos son tu contrato. Puedes hacer una dashboard Ink, una web, o solo scripts headless que consuman events$.

☞ Arquitectura de la app

 SDK (core)                        Tu App (dashboard)
 ──────────────                    ───────────────────

 Logger ──────┐
 ChatTracker ─┤                    ┌────────────────┐
 bot-handler ─┼─→ RuntimeEmitter ─→│ emitter─bridge │
              │   events$/logs$/  │ (switch/case)   │
              │   messages$/      └───────┬─────────┘
              │   snapshot$               │
              │                    ┌──────▼───────┐
              │                    │    Store     │
              │                    │ (state + sub)│
              │                    └──────┬───────┘
              │                           │ subscribe()
              │                    ┌──────▼─────────────┐
              │                    │   <App />    │
              │                    │  React/Ink         │
              │                    └────────────────────┘
El flujo es unidireccional: SDK emite → bridge reduce → store muta → React re-renderiza.

☞ Prerrequisitos

HerramientaVersiónPor qué
Bun≥1.3Runtime + package manager + test runner
TypeScript≥5.0Tipos + JSX en tsconfig
Token de TelegramNecesitas un bot token de @BotFather
El SDK ya incluye Ink, React, y RxJS como devDependencies. Si estás en el repo, solo bun install.

Paso 0 · Estructura de carpetas

El dashboard es un paquete npm independiente dentro de examples/dashboard/. Instala el SDK vía file:../../ y consume t odo desde el nombre de paquete — exactamente como lo harías desde npm.
mkdir -p examples/dashboard/components
Resultado final:
examples/dashboard/ ├── main.tsx # entrypoint — crea bot, emitter, store, monta App ├── App.tsx # layout raíz con navegación por tabs ├── state.ts # DashboardState extends BaseRuntimeState (campos propios) ├── theme.ts # paleta de colores (objeto plano) └── components/ ├── StatusPanel.tsx # panel de estado del bot ├── LogViewer.tsx # logs en vivo con filtros ├── ChatList.tsx # chats activos + mensajes ├── ConfigPanel.tsx # modo mock / setup token └── CommandPanel.tsx # ejecutar comandos (solo mock)
Store<T>, createStore, connectEmitterToStore, LogEntry y MessageEntry vienen del SDK — no hay que crearlos.

Paso 1 · Dependencias + tsconfig

1a. Instalar devDependencies
bun add -d ink@5 react@19 @types/react@19 rxjs
ink es el renderer React para terminal. rxjs es el bus de eventos que expone el SDK. Ambos son peerDependencies del SDK.
1b. Habilitar JSX en tsconfig.json
Asegúrate de que tu tsconfig.json incluye:
// tsconfig.json (compilerOptions) { "jsx": "react-jsx", "jsxImportSource": "react" }
1c. Script de desarrollo
El package.json del ejemplo ya incluye los scripts. Desde la raíz del repo:
// desde la raíz del repo bun run dev:dashboard # delega a examples/dashboard
--watch recarga la app al guardar. Perfecto para iterar en la UI.

Paso 2 · La API del SDK que vas a usar

El SDK exporta todo desde un barrel (src/index.ts). Estas son las piezas que necesitas:
ExportTipoPara qué
RuntimeEmitterclassBus central de eventos — creas una instancia y la pasas al Logger, ChatTracker, bot-handler
RuntimeEventtype union9 tipos de evento: log, message, status-change, chat-tracked, broadcast, commands-synced, plugins-registered, command-executed, command-response
BotRuntimeinterfaceSnapshot acumulado (status, startedAt, plugins, commandCount, chatCount)
reduceRuntimefunctionReducer puro: (BotRuntime, RuntimeEvent) → BotRuntime. Usado por snapshot$ internamente
DEFAULT_BOT_RUNTIMEconstEstado inicial del snapshot
Botclass (grammY)Re-export del bot de Telegram
LoggerclassAcepta emitter en sus options — cada log.info() emite un evento
ChatTrackerclassAcepta emitter en su constructor — trackear chats emite eventos
registerPluginsfunctionAcepta emitter opcional — emite plugins-registered y message
syncCommandsfunctionAcepta emitter opcional — emite commands-synced
Patrón clave: creás un RuntimeEmitter y lo pasás a cada componente core. Ellos emiten; tu dashboard consume.
RuntimeEmitter — API completa
// Crear const emitter = new RuntimeEmitter(); // Observables (RxJS) emitter.events$ // Observable<RuntimeEvent> — todo emitter.logs$ // Observable<{type:"log", level, scope, message, timestamp}> emitter.messages$ // Observable<{type:"message", chatId, text, ...}> emitter.snapshot$ // Observable<BotRuntime> — estado acumulado (scan + shareReplay) // Emitir (lo hacen los componentes core, no tú normalmente) emitter.emit({ type: "status-change", status: "running", timestamp: new Date().toISOString() }); // Suscripción legacy (callback) const unsub = emitter.on(event => console.log(event)); unsub(); // desubscribir // Shutdown emitter.complete(); // completa todos los streams
RuntimeEvent — los 7 tipos
// Cada variante del union type: { type: "log", level, scope, message, timestamp } { type: "message", chatId, userId?, username?, text, timestamp } { type: "status-change", status: "starting"|"running"|"stopped"|"error", timestamp } { type: "chat-tracked", chatId, total, timestamp } { type: "broadcast", chatCount, message, timestamp } { type: "commands-synced", commandCount, timestamp } { type: "plugins-registered", plugins: PluginInfo[], timestamp } // Nuevos en SDS-12 (mock command execution): { type: "command-executed", command, chatId, userId, username, timestamp } { type: "command-response", command, text, chatId, timestamp }

Paso 3 · Estado + Store

Definí primero qué datos necesita tu UI, luego el mecanismo para mutarlos.
3a. state.ts — Modelo de datos de la UI
import type { PluginInfo } from "heteronimos-semi-asistidos-sdk"; // Tipos para los buffers export interface LogEntry { level: "debug" | "info" | "warn" | "error"; scope: string; message: string; timestamp: string; } export interface MessageEntry { chatId: number; username?: string; text: string; timestamp: string; } // Estado completo de la dashboard export interface DashboardState { botStatus: "starting" | "running" | "stopped" | "error"; startedAt: Date | null; plugins: PluginInfo[]; commandCount: number; chatIds: number[]; logs: LogEntry[]; // buffer circular messages: MessageEntry[]; // buffer circular } export const LOG_BUFFER_SIZE = 200; export const MSG_BUFFER_SIZE = 100; export function getDefaultDashboardState(): DashboardState { return { botStatus: "starting", startedAt: null, plugins: [], commandCount: 0, chatIds: [], logs: [], messages: [], }; }
Buffer circular: se implementa con [...prev, nuevo].slice(-MAX_SIZE). Así el array nunca crece indefinidamente.
3b. store + bridge — Vienen del SDK
// No necesitas crear store.ts ni emitter-bridge.ts. // El SDK los exporta directamente: import { createStore, connectEmitterToStore, } from "heteronimos-semi-asistidos-sdk"; // Store<T>, LogEntry, MessageEntry también disponibles: import type { Store, LogEntry, MessageEntry } from "heteronimos-semi-asistidos-sdk";
30 líneas. Sin dependencias extras. Genérico sobre T. Cada setState notifica a todos los listeners — React se re-renderiza. Mismo patrón que Zustand simplificado.

Paso 4 · Emitter Bridge — conectar SDK → Store

connectEmitterToStore es la pieza más importante: transforma cada evento del SDK en una mutación de estado. Viene directo del SDK — no hay que escribirla.
main.tsx (fragmento)
import { createStore, connectEmitterToStore, getDefaultBaseState, } from "heteronimos-semi-asistidos-sdk"; import { getDefaultDashboardState } from "./state.js"; const store = createStore(getDefaultDashboardState()); connectEmitterToStore(emitter, store); // Listo — cada RuntimeEvent muta el store automáticamente.
default: return prev; } }); } // Suscripción RxJS — usa events$ en vez del legacy .on() const sub = emitter.events$.subscribe(handleEvent); return () => sub.unsubscribe(); }
El switch es exhaustivo — cubre los 7 tipos de RuntimeEvent. El default maneja futuros tipos sin romper.
¿Por qué no usar snapshot$ directamente?
snapshot$ acumula solo datos del BotRuntime (status, plugins, commandCount, chatCount). Pero la dashboard necesita también los buffers de logs y mensajes, que no son parte del snapshot del SDK. El bridge combina ambos: datos snapshot + datos de UI (buffers circulares, lista de chatIds...) en un DashboardState unificado.

Alternativa futura: suscribirte a snapshot$, logs$ y messages$ por separado, cada uno actualizando su slice del store.

Paso 5 · Theme + componentes de UI

Ink usa props como color en <Text> para colores ANSI. Centralizamos la paleta.
5a. theme.ts
export const theme = { primary: "cyan", // valores activos title: "blueBright", // títulos de panel success: "green", // ● RUNNING warning: "yellow", // ● STARTING error: "red", // ● ERROR muted: "gray", // texto secundario debug: "gray", border: "gray", // bordes de Box } as const;
Objeto plano, sin dependencias. Cambia la paleta aquí y toda la app se actualiza.
5b. components/StatusPanel.tsx
import React from "react"; import { Box, Text } from "ink"; import { theme } from "../theme.js"; import type { DashboardState } from "../state.js"; interface Props { state: DashboardState; } function formatUptime(startedAt: Date | null): string { if (!startedAt) return "—"; const secs = Math.floor((Date.now() - startedAt.getTime()) / 1000); if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); return `${h}h ${m}m`; } const STATUS_COLOR = { starting: theme.warning, running: theme.success, stopped: theme.muted, error: theme.error, }; export function StatusPanel({ state }: Props) { return ( <Box flexDirection="column" gap={1} paddingTop={1}> <Box flexDirection="column"> <Text bold color={theme.title}>Bot Status</Text> <Box gap={2}> <Text color={STATUS_COLOR[state.botStatus]}> ● {state.botStatus.toUpperCase()} </Text> <Text color={theme.muted}>uptime: </Text> <Text color={theme.primary}>{formatUptime(state.startedAt)}</Text> </Box> </Box> <Box flexDirection="column"> <Text bold color={theme.title}>Stats</Text> <Box gap={3}> <Text color={theme.muted}>chats: <Text color={theme.primary}>{state.chatIds.length}</Text></Text> <Text color={theme.muted}>commands: <Text color={theme.primary}>{state.commandCount}</Text></Text> <Text color={theme.muted}>plugins: <Text color={theme.primary}>{state.plugins.length}</Text></Text> </Box> </Box> {state.plugins.map((p) => ( <Box key={p.pluginCode} gap={2}> <Text color={theme.primary}>[{p.pluginCode}]</Text> <Text>{p.name}</Text> <Text color={theme.muted}>({p.commandCount} cmds)</Text> </Box> ))} </Box> ); }
Patrón de componente: recibe state via props, renderiza con <Box> (flexbox) y <Text> (styled output). Sin lógica de suscripción — eso lo hace <App>.
LogViewer y ChatList — mismo patrón
LogViewer muestra state.logs con colores por nivel + filtros por tecla (a/d/i/w/e) + scroll manual (↑↓).
ChatList muestra state.chatIds + state.messages (últimos N).

Ambos reciben { state: DashboardState } y renderizan. El código completo está en examples/dashboard/components/.

Paso 6 · App.tsx — Layout raíz + navegación

Este componente compone header, panel activo y footer. Es el único que se suscribe al store.
App.tsx
import React from "react"; import { Box, Text, useInput, useApp } from "ink"; import { theme } from "./theme.js"; import type { DashboardState } from "./state.js"; import type { Store } from "heteronimos-semi-asistidos-sdk"; import { StatusPanel } from "./components/StatusPanel.js"; import { StatusPanel } from "./components/StatusPanel.js"; import { LogViewer } from "./components/LogViewer.js"; import { ChatList } from "./components/ChatList.js"; import { ConfigPanel } from "./components/ConfigPanel.js"; import { CommandPanel } from "./components/CommandPanel.js"; const PANELS = ["Overview", "Logs", "Chats", "Config", "Commands"] as const; type Panel = (typeof PANELS)[number]; interface AppProps { store: Store<DashboardState>; } export function App({ store }: AppProps) { const { exit } = useApp(); const [activePanel, setActivePanel] = React.useState<Panel>("Overview"); const [, forceUpdate] = React.useReducer(n => n + 1, 0); // Suscribirse al store → re-render cuando cambie el estado React.useEffect(() => { return store.subscribe(forceUpdate); }, [store]); useInput((input, key) => { if (input === "q" || (key.ctrl && input === "c")) { exit(); return; } if (input === "1") setActivePanel("Overview"); if (input === "2") setActivePanel("Logs"); if (input === "3") setActivePanel("Chats"); if (input === "4") setActivePanel("Config"); if (input === "5") setActivePanel("Commands"); if (key.tab) { const idx = PANELS.indexOf(activePanel); setActivePanel(PANELS[(idx + 1) % PANELS.length]); } }); const state = store.getState(); return ( <Box flexDirection="column" height="100%"> <Box borderStyle="single" borderColor={theme.border} paddingX={1}> <Text bold color={theme.primary}>mi-dashboard </Text> {PANELS.map((p) => ( <React.Fragment key={p}> <Text color={activePanel === p ? theme.primary : theme.muted} bold={activePanel === p}>[{p}]</Text> <Text> </Text> </React.Fragment> ))} </Box> <Box flexGrow={1} paddingX={1}> {activePanel === "Overview" && <StatusPanel state={state} />} {activePanel === "Logs" && <LogViewer state={state} />} {activePanel === "Chats" && <ChatList state={state} />} {activePanel === "Config" && <ConfigPanel state={state} store={store} />} {activePanel === "Commands" && <CommandPanel state={state} />} </Box> <Box borderStyle="single" borderColor={theme.border} paddingX={1}> <Text color={theme.muted}> [1] Overview [2] Logs [3] Chats [4] Config [5] Commands [Tab] Cycle [q] Quit </Text> </Box> </Box> ); }
El truco de re-render: useReducer(n => n + 1, 0) crea un forceUpdate que Ink invoca cada vez que el store cambia. store.subscribe(forceUpdate) conecta las dos piezas. El useEffect devuelve unsub → cleanup automático.

Paso 7 · main.tsx — Entrypoint

Aquí se conecta todo: creas emitter, lo pasas al SDK, montas la UI, arrancas el bot.
main.tsx
import React from "react"; import { render } from "ink"; import { existsSync } from "fs"; import path from "path"; import { RuntimeEmitter, Logger, bootBot, createStore, connectEmitterToStore, } from "heteronimos-semi-asistidos-sdk"; import { SOLANA_ADDRESS } from "./config.js"; import { RabbitBot } from "./rabbit-bot.js"; import { getDefaultDashboardState } from "./state.js"; import { App } from "./App.js"; const appDir = import.meta.dir; // 1. Crear el emitter (bus central) y el store const emitter = new RuntimeEmitter(); const store = createStore(getDefaultDashboardState()); connectEmitterToStore(emitter, store); // 2. Arrancar el bot — nonInteractive: true SIEMPRE en el dashboard // Ink controla stdin en raw mode; readline.close() lo destruiría. // Si no hay BOT_TOKEN, bootBot va a mock automáticamente. const result = await bootBot({ plugins: [new RabbitBot(SOLANA_ADDRESS)], emitter, envDir: appDir, chatStorePath: path.join(appDir, ".chats.json"), nonInteractive: true, }); // 3. Propagar el estado de arranque al store store.setState((s) => ({ ...s, mockMode: result.mock, tokenConfigured: !!process.env.BOT_TOKEN?.trim(), envFileExists: existsSync(path.join(appDir, ".env")), envExampleExists: existsSync(path.join(appDir, ".env.example")), appDir, botStatus: result.started ? s.botStatus : "error", })); // 4. Montar React/Ink — stdin está limpio aquí const { unmount } = render(<App store={store} />); // 5. Cleanup function shutdown() { emitter.complete(); unmount(); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); // 7. Arrancar bot async function main() { try { await syncCommands(bot, plugins, tracker, { autoConfirm: true }, emitter); log.info("Bot started — polling..."); emitter.emit({ type: "status-change", status: "running", timestamp: new Date().toISOString(), }); await bot.start(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); log.error(`Bot error: ${msg}`); emitter.emit({ type: "status-change", status: "error", timestamp: new Date().toISOString(), }); shutdown(); process.exitCode = 1; } } main();
Orden importante: 1) emitter → 2) store + bridge → 3) bot setup (registra plugins) → 4) render UI → 5) start bot. Si montás la UI después de registerPlugins, el evento plugins-registered llega al store antes del render — y snapshot$ lo cachea con shareReplay.

Paso 8 · Correr y verificar

8a. Asegurar .env
# .env BOT_TOKEN=tu-token-de-botfather SOLANA_ADDRESS=direccion-opcional
8b. Ejecutar
bun run dev:dashboard
Deberías ver la TUI con estado ● STARTING, que cambia a ● RUNNING cuando el bot conecta a Telegram.
8c. Interactuar
TeclaAcción
1Panel Overview
2Panel Logs
3Panel Chats
TabRotar paneles
q / Ctrl+CSalir (shutdown limpio)
En el panel Logs: a=all, d=debug, i=info, w=warn, e=error, ↑↓=scroll.
8d. Verificar que no rompiste nada
bun run lint && bun run test

☞ Extender la dashboard

Ideas para seguir iterando:
Nuevo panel
1. Crear components/MiPanel.tsx
2. Añadir entrada en PANELS array de App.tsx
3. Añadir el && conditional render
4. Los datos ya están en state
Nuevo tipo de evento
1. Extender RuntimeEvent union en el SDK
2. Añadir el case en emitter-bridge.ts
3. Añadir campos en DashboardState
4. Renderizar en el componente
Usar streams RxJS directamente en un componente
// Ejemplo: hook custom que consume logs$ directamente import { useEffect, useState } from "react"; import type { RuntimeEmitter } from "heteronimos-semi-asistidos-sdk"; export function useLogCount(emitter: RuntimeEmitter) { const [count, setCount] = useState(0); useEffect(() => { const sub = emitter.logs$.subscribe(() => { setCount(c => c + 1); }); return () => sub.unsubscribe(); }, [emitter]); return count; }
Consumir snapshot$ sin el bridge
// Si solo necesitas los datos del BotRuntime: import { useEffect, useState } from "react"; import type { RuntimeEmitter, BotRuntime, DEFAULT_BOT_RUNTIME } from "heteronimos-semi-asistidos-sdk"; export function useBotRuntime(emitter: RuntimeEmitter) { const [runtime, setRuntime] = useState<BotRuntime>(DEFAULT_BOT_RUNTIME); useEffect(() => { const sub = emitter.snapshot$.subscribe(setRuntime); return () => sub.unsubscribe(); }, [emitter]); return runtime; } // snapshot$ usa shareReplay(1) — si te suscribís tarde, // igual recibís el último valor acumulado.

Cheat Sheet — Ink / React en Terminal

InkWeb equiv.Uso
<Box><div> con flexboxLayout: flexDirection, gap, padding, border
<Text><span>Color, bold, dimColor, italic, underline
useInput()addEventListener("keydown")Captura teclas: input string + key object
useApp().exit()window.close()Sale del proceso Ink limpiamente
render(<App/>)createRoot().render()Monta la app — devuelve { unmount }

Keywords

ink react rxjs tui terminal-ui dashboard runtime-emitter observable subject bun grammy telegram