/

Feature

Introducing Terminal UI for agent developing and testing

No headings found on page

Start building
with Spectrum

Turn messages into structured, intelligent workflows.

Learn more about Spectrum

Preface

Preface

Testing agents means leaving your dev environment. You write code, then switch to a frontend, an app, or a messaging client to see if it works. If you use a basic terminal instead, you get a synchronous readline loop — type, wait, read — which doesn't match how real conversations work.

Spectrum v0.9.0 replaces the old readline-based terminal provider with tuichat, a standalone TUI binary. It runs a fully async chat interface in your terminal — input and output are decoupled, so you can type while the agent is responding, and the agent can push messages at any time.

Because Spectrum's provider interface is unified, you don't change your agent code to use this. Your existing agent already works — terminal is just another provider alongside imessage and whatsapp. Add it to your providers array and your agent runs in the terminal with full async I/O, replies, reactions, and everything else.

What's new

  • Async I/O — input and output are independent. No blocking. The agent can send messages, react, and show typing indicators without waiting for your input.

  • Multi-chat — sidebar with multiple conversations. Ctrl+N to create, Ctrl+J / Ctrl+K to switch. Each chat has its own space ID.

  • Replies and reactions — select a message to reply; react with emoji. Both arrive as first-class events in your agent code.

  • Typing indicatorsstartTyping / stopTyping work like they do on iMessage and WhatsApp.

  • File attachments — drag and drop. Arrives as attachment content with name, MIME type, and a buffer.

  • Inline images — Kitty graphics protocol on supported terminals, half-block fallback elsewhere.

  • Console captureconsole.log is forwarded to a pinned system chat so it doesn't corrupt the TUI.

All of these map 1:1 to Spectrum's production providers (iMessage, WhatsApp). If it works here, it works there.

Example

This is examples/basic — it handles messages, replies, and reactions in one loop:

import { Spectrum, text } from "spectrum-ts";
import { terminal } from "spectrum-ts/providers/terminal";

const app = await Spectrum({ providers: [terminal.config()] });

const seeded = new Set<string>();

for await (const [space, message] of app.messages) {
  console.log("RAW:", JSON.stringify(message, null, 0));

  if (!seeded.has(space.id)) {
    seeded.add(space.id);
    await space.send(text("hi! reply to me or react with ↑ → r/e"));
  }

  if (message.content.type === "reaction") {
    console.log(
      `reaction ${message.content.emoji} on msg ${message.content.target.slice(0, 8)}…`
    );
    continue;
  }

  if (message.content.type !== "text") {
    continue;
  }

  await message.react("👀");

  const replyTo = (message as { replyTo?: { messageId: string } }).replyTo;
  if (replyTo) {
    console.log(
      `REPLY to ${replyTo.messageId.slice(0, 8)}…: "${message.content.text}"`
    );
    await message.reply(text("acknowledged your reply"));
  } else {
    console.log(`message: "${message.content.text}"`);
    await space.send(text(`echo: ${message.content.text}`));
  }
}
import { Spectrum, text } from "spectrum-ts";
import { terminal } from "spectrum-ts/providers/terminal";

const app = await Spectrum({ providers: [terminal.config()] });

const seeded = new Set<string>();

for await (const [space, message] of app.messages) {
  console.log("RAW:", JSON.stringify(message, null, 0));

  if (!seeded.has(space.id)) {
    seeded.add(space.id);
    await space.send(text("hi! reply to me or react with ↑ → r/e"));
  }

  if (message.content.type === "reaction") {
    console.log(
      `reaction ${message.content.emoji} on msg ${message.content.target.slice(0, 8)}…`
    );
    continue;
  }

  if (message.content.type !== "text") {
    continue;
  }

  await message.react("👀");

  const replyTo = (message as { replyTo?: { messageId: string } }).replyTo;
  if (replyTo) {
    console.log(
      `REPLY to ${replyTo.messageId.slice(0, 8)}…: "${message.content.text}"`
    );
    await message.reply(text("acknowledged your reply"));
  } else {
    console.log(`message: "${message.content.text}"`);
    await space.send(text(`echo: ${message.content.text}`));
  }
}
import { Spectrum, text } from "spectrum-ts";
import { terminal } from "spectrum-ts/providers/terminal";

const app = await Spectrum({ providers: [terminal.config()] });

const seeded = new Set<string>();

for await (const [space, message] of app.messages) {
  console.log("RAW:", JSON.stringify(message, null, 0));

  if (!seeded.has(space.id)) {
    seeded.add(space.id);
    await space.send(text("hi! reply to me or react with ↑ → r/e"));
  }

  if (message.content.type === "reaction") {
    console.log(
      `reaction ${message.content.emoji} on msg ${message.content.target.slice(0, 8)}…`
    );
    continue;
  }

  if (message.content.type !== "text") {
    continue;
  }

  await message.react("👀");

  const replyTo = (message as { replyTo?: { messageId: string } }).replyTo;
  if (replyTo) {
    console.log(
      `REPLY to ${replyTo.messageId.slice(0, 8)}…: "${message.content.text}"`
    );
    await message.reply(text("acknowledged your reply"));
  } else {
    console.log(`message: "${message.content.text}"`);
    await space.send(text(`echo: ${message.content.text}`));
  }
}

This is the same code you'd ship on iMessage or WhatsApp. Nothing here is terminal-specific — reaction, replyTo, space.send, message.reply are all standard Spectrum APIs. The only thing that changes between providers is the import and the config line.

CleanShot 2026-04-24 at 12.23.43@2x.png

How it works

tuichat is a compiled Go binary (Bubble Tea + Lip Gloss). The Spectrum adapter spawns it as a subprocess and communicates over JSON-RPC 2.0 on a local TCP socket.

The binary auto-downloads from GitHub Releases on first run, verified against SHA256 checksums, and cached locally. No new runtime dependencies.

Why a separate binary: Spectrum supports multiple languages (TypeScript today, Python/Go/Rust planned). A shared UI binary means every SDK gets the same terminal experience without reimplementing rendering, input handling, and image support in each language. The adapter just speaks the protocol.

Known issues

  • Message editing not yet supported.

  • macOS Gatekeeper may flag the binary on first run. xattr -d com.apple.quarantine /path/to/tuichat clears it. Notarization is coming.

  • replyTo is surfaced via a type cast. A future release will promote it to the core message type.

Get started

bun add spectrum-ts@latest
cd examples/basic
bun run start
bun add spectrum-ts@latest
cd examples/basic
bun run start
bun add spectrum-ts@latest
cd examples/basic
bun run start