Chat/agent UI components for the terminal, built on opentui.
Building a Claude Code / Codex style CLI means rebuilding the same chat surface every time: a multi-line composer with slash-command and mention completion, streaming transcript with thinking and tool-call rendering, approval prompts, layered Ctrl+C semantics. chat-tui packages that surface as reusable components behind one small protocol, and keeps everything agent-specific out.
State snapshots in, intents out. chat-tui deliberately knows nothing about sessions, providers, LLM APIs, or wire protocols. Your harness (local agent loop, or a thin client for a remote one) implements ChatProtocol; chat-tui renders and interacts.
bun add chat-tui @opentui/core @opentui/react reactImplement ChatProtocol and hand it to ChatShell:
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import {
ChatShell,
createChatStore,
type ChatProtocol,
type InteractionResponse,
} from "chat-tui";
class MyHarness implements ChatProtocol {
// outputs: harness → TUI
readonly stateStore = createChatStore({
timeline: { items: [] },
composer: {},
activity: {},
footer: {},
sidecar: undefined,
});
// inputs: TUI → harness
submit(text: string) { /* send to local or remote agent */ }
command(name: string, argument: string) { /* /model, /exit, … */ }
cancel() { /* interrupt the running turn */ }
exit() { /* graceful shutdown */ }
searchPicker(id: string, query: string) { /* refresh a remote-search picker */ }
resolvePicker(id: string, value: string | null) { /* … */ }
resolveInteraction(id: string, response: InteractionResponse) { /* … */ }
}
const renderer = await createCliRenderer({ exitOnCtrlC: false, autoFocus: false });
createRoot(renderer).render(
<ChatShell protocol={new MyHarness()} commands={[{ name: "exit", description: "Exit" }]} />,
);Run the full demo (fake streaming harness, no agent required):
bun install
bun examples/echo.tsx- Persistent composer — multi-line editing, slash commands, mentions, input history, queued follow-ups, and layered terminal key behavior
- Streaming timeline — plain or Markdown messages, activity blocks, plans, code, commands, output, and diffs with display-only clipping
- Human interaction — searchable pickers, permission decisions, structured questions, and suggested inputs anchored near the composer
- Independent Surfaces — Timeline, Composer, Activity, Footer, and Sidecar subscribe only to the State they consume
- Optional sidecar — generic auxiliary State renders beside the main chat when space allows, or as an explicit overlay
- Composable UI — use
ChatShellfor the complete interface or compose exported Surfaces and focused building blocks with an injectable theme
chat-tui describes UI capabilities, not provider capabilities. Your harness decides what an agent provider supports and how each operation maps to it.
| Interaction | Boundary |
|---|---|
| Text and commands | submit() handles text; registered slash commands use command(). Attachments and arbitrary structured input are not modeled |
| Interrupt and exit | cancel() and exit() express intent; the harness owns provider interruption and process shutdown |
| Queue and history | Display, recall, and navigation are supported; queue ownership and same-turn steering remain in the harness |
| Generic choice | Picker supports static options, local filtering, and harness-owned remote search |
| Human interaction | Permission, structured question, and suggested-input variants share resolveInteraction(); arbitrary forms are outside the current contract |
| Output | Boundary |
|---|---|
| Messages and activity | Plain/Markdown messages and open-ended activity blocks are display shapes, not provider events |
| Streaming updates | Publish complete State snapshots; Store notifies only consumers of changed State |
| Long content | Clipping is display-only. The harness always supplies complete content |
| Status and plan | Activity, toast, footer, and plan labels are display-ready; lifecycle and visibility policy remain in the harness |
| Auxiliary information | Sidecar accepts generic sections and items without understanding Board, context, or diagnostic semantics |
provider events → harness → State snapshots → Store → Surface
user input → Surface → ChatProtocol intent → harness
State organizes display data, Store publishes and subscribes, and Surface renders independently.
They correspond naturally but are not required to be one-to-one. ChatShell composes the default
Surfaces without subscribing to their State.
docs/kernel.md— core model, bidirectional flow, dependency boundaries, and verification constraintsdocs/surfaces.md— visual hierarchy, Surface responsibilities, and interaction invariants
bun install
bun run check # typecheck + testsRuntime target is Bun; the package exports TypeScript source directly (no build step), same as consuming opentui from Bun.
Apache-2.0