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 12345 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$.
El flujo es unidireccional: SDK emite → bridge reduce → store muta → React re-renderiza.
☞ Prerrequisitos
Herramienta
Versión
Por qué
Bun
≥1.3
Runtime + package manager + test runner
TypeScript
≥5.0
Tipos + JSX en tsconfig
Token de Telegram
—
Necesitas 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.
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.
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.
Deberías ver la TUI con estado ● STARTING, que cambia a ● RUNNING cuando el bot conecta a Telegram.
8c. Interactuar
Tecla
Acción
1
Panel Overview
2
Panel Logs
3
Panel Chats
Tab
Rotar paneles
q / Ctrl+C
Salir (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.