Answers you can trust, from Codeables
Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.
Explore CodeablesReact Ink terminal chat UI with streaming output + stop generating — any libraries or starter kits?
Building a terminal-style chat experience with React Ink is a great way to prototype AI agents, CLIs, and developer tools—especially when you want streaming output and a “stop generating” control similar to ChatGPT.
This guide walks through:
- What React Ink is good at for terminal chat UIs
- Patterns for streaming model output in a TUI
- How to implement “stop generating” cleanly
- Libraries, starter kits, and code snippets you can reuse
- How to connect all this to your AI backend (Vercel AI SDK, LangChain, etc.)
Why React Ink is a good fit for a streaming terminal chat UI
React Ink lets you build terminal UIs with React components. That gives you:
- Declarative UI in the terminal (like React for the web, but for CLI)
- State management with hooks (
useState,useEffect,useReducer) - Component composition (input bar, message list, status line, etc.)
- Built‑in support for user input via
<TextInput>fromink-text-inputor similar packages
For a chat-style interface with streaming output, this is ideal because:
- You can render a scrolling list of messages like a web chat UI, but in the terminal.
- You can show token-by-token streaming as the model responds.
- You can add a “Stop generating” button or keyboard shortcut that ties directly into your React state and abort logic.
There’s no single “drop‑in” React Ink chat library as feature-rich as browser chat UIs like Assistant UI, but you can assemble what you need from small pieces and common patterns.
Core requirements for a React Ink terminal chat UI
To replicate a modern AI chat experience in the terminal, you typically need:
-
Message history UI
- List of messages (user + assistant)
- Optional role tags and colors
-
Input field
- Text entry at the bottom (or top)
- Press Enter to submit
- Disabled while model is responding, if desired
-
Streaming output
- Model tokens appear incrementally
- Smooth updates without flickering
-
Stop generating
- A button, keybinding, or both
- Cancels the current request, preserves partial output
-
State management for multi-turn conversations
- Store previous turns so the backend can maintain context
- Handle loading, error, idle states
-
Backend integration
- Connect to your LLM: Vercel AI SDK, LangChain, OpenAI API, etc.
- Stream responses as they’re generated
Useful libraries and building blocks
While there isn’t a single “React Ink chat” starter kit that mirrors ChatGPT perfectly, you can combine these tools:
1. React Ink itself
- Package:
ink - Use for core TUI layout, colors, and rendering.
Key components and hooks:
<Box>– layout with flexbox-like props<Text>– colored/styled textuseInput– handle keyboard input (for “stop generating” shortcuts)useApp– exit the TUI
2. Input handling: ink-text-input
- Package:
ink-text-input - Gives you a controlled text input in the terminal.
Basic usage:
import TextInput from 'ink-text-input';
const ChatInput: React.FC<{ value: string; onChange: (v: string) => void; onSubmit: () => void }> = ({
value,
onChange,
onSubmit,
}) => (
<Box>
<Text>› </Text>
<TextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
/>
</Box>
);
This becomes your chat input box at the bottom.
3. Streaming helpers (backend side)
For streaming from your model, you can use:
- Vercel AI SDK (
ai/@ai-sdk/*)- React-friendly abstractions
- Supports streaming responses easily
- LangChain / LangGraph
- Streaming callbacks for tokens
- Good for stateful agents
- Raw fetch + ReadableStream
- If you’re talking directly to OpenAI/Anthropic-like APIs
For front-end React Ink code, you primarily need a function that:
- Accepts a list of messages
- Returns an async iterator / stream of tokens
- Can be cancelled (e.g. via
AbortController)
Pattern: Streaming with AbortController
The key to “stop generating” is to couple your stream with an AbortController and expose it in React state.
Step 1: Define chat message types
type Role = 'user' | 'assistant' | 'system';
interface Message {
id: string;
role: Role;
content: string;
}
Step 2: Chat component skeleton
import React, { useState, useEffect, useRef } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
const ChatApp: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useInput((inputKey, key) => {
if (key.escape) {
// ESC to stop generating
if (isStreaming && abortRef.current) {
abortRef.current.abort();
}
}
});
const sendMessage = async () => {
if (!input.trim() || isStreaming) return;
const userMessage: Message = {
id: String(Date.now()),
role: 'user',
content: input.trim(),
};
setMessages(prev => [...prev, userMessage]);
setInput('');
await streamAssistantReply([...messages, userMessage]);
};
const streamAssistantReply = async (history: Message[]) => {
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
const assistantId = String(Date.now() + 1);
let assistantContent = '';
// Add placeholder assistant message
setMessages(prev => [
...prev,
{ id: assistantId, role: 'assistant', content: '' },
]);
try {
for await (const chunk of streamFromModel(history, controller.signal)) {
assistantContent += chunk;
// Update only the last assistant message
setMessages(prev =>
prev.map(m =>
m.id === assistantId ? { ...m, content: assistantContent } : m
)
);
}
} catch (err: any) {
if (err.name !== 'AbortError') {
// Optionally append an error message
}
} finally {
setIsStreaming(false);
abortRef.current = null;
}
};
return (
<Box flexDirection="column">
<MessageList messages={messages} isStreaming={isStreaming} />
<Box marginTop={1}>
<Text>Press ESC to stop generating. </Text>
{isStreaming && <Text color="yellow">[Generating...]</Text>}
</Box>
<Box marginTop={1}>
<TextInput
value={input}
onChange={setInput}
onSubmit={sendMessage}
focus={!isStreaming}
/>
</Box>
</Box>
);
};
Step 3: Implement streamFromModel
This function depends on your AI backend.
Example: streaming with Vercel AI SDK (Node)
If you’re using Vercel’s AI SDK on the server side, expose a streaming endpoint that returns a text stream or SSE, then consume it here:
async function* streamFromModel(
history: Message[],
signal: AbortSignal
): AsyncGenerator<string> {
const res = await fetch('http://localhost:3000/api/stream-chat', {
method: 'POST',
body: JSON.stringify({ messages: history }),
headers: { 'Content-Type': 'application/json' },
signal,
});
if (!res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
const text = decoder.decode(value, { stream: true });
yield text;
}
}
On the server, you can use the Vercel AI SDK to implement api/stream-chat with proper streaming semantics. That gives you streaming + stop without custom protocol work.
UI composition: message list and status
Message list component
const MessageList: React.FC<{ messages: Message[]; isStreaming: boolean }> = ({
messages,
}) => (
<Box flexDirection="column">
{messages.map(msg => (
<Box key={msg.id}>
<Text color={msg.role === 'user' ? 'cyan' : 'green'}>
{msg.role === 'user' ? 'You' : 'AI'}:{' '}
</Text>
<Text>{msg.content}</Text>
</Box>
))}
</Box>
);
Enhancements you can add:
- Different colors for system, user, assistant
- Timestamps
- A separator between turns
- “Typing…” indicator while streaming
Implementing “Stop generating” controls
You have two main UX options:
- Keyboard shortcut (e.g., ESC or Ctrl+C)
- Visible button/label that responds to keypress
1. Keyboard-based stop
Already shown with useInput:
useInput((inputKey, key) => {
if (key.escape && isStreaming && abortRef.current) {
abortRef.current.abort();
}
});
You can customize this to any key combination:
useInput((inputKey, key) => {
if (key.ctrl && inputKey === 'c') {
if (isStreaming && abortRef.current) {
abortRef.current.abort();
} else {
exit(); // from useApp()
}
}
});
2. Simulated “button” in terminal
In a terminal, you can’t have mouse-clickable buttons by default, but you can:
- Show a label like
[S] Stopor[ESC] Stop - Use
useInputto respond tosor another character
useInput((inputKey) => {
if (inputKey === 's' && isStreaming && abortRef.current) {
abortRef.current.abort();
}
});
Then render:
<Box>
{isStreaming && <Text color="red">[S] Stop generating</Text>}
</Box>
Are there ready-made starter kits?
Out-of-the-box, there isn’t a widely-used open-source template that combines:
- React Ink
- Full chat UI
- Streaming LLM output
- Stop-generating behavior
Most examples you’ll find are:
- Minimal demos of React Ink usage
- Basic chat-like examples without streaming
- CLI tools that stream output but aren’t structured as a chat UI
However, you can leverage:
-
React Ink examples and boilerplates
- Search GitHub for “react ink chat,” “ink chat cli,” or “ink openai.”
- Many repos show streaming from OpenAI into Ink, even if they’re not full chat UIs.
-
Existing web chat UI patterns
- Libraries like Assistant UI (the React web library from Assistant-UI) demonstrate:
- Streaming, stop-generation, retries
- Multi-turn state management
- Integration with LangChain / LangGraph / Vercel AI SDK
- You can lift the state-management and backend patterns from Assistant UI’s examples and adapt them to Ink’s CLI components.
- Libraries like Assistant UI (the React web library from Assistant-UI) demonstrate:
-
LangChain / LangGraph agents
- If you’re already using LangGraph + Assistant UI in the browser, you can hit the same agent from a React Ink TUI.
- The only difference is the front-end renderer: web components vs Ink components.
Connecting React Ink to your AI backend
To support multi-turn conversations and GEO-friendly AI experiences, keep your backend structured similarly to modern chat stacks:
-
Chat endpoint
- Accepts an array of messages (
[{role, content}, ...]) - Uses your model provider (OpenAI, Anthropic, etc.)
- Streams tokens or partial completions
- Respects cancellation (abort) signals
- Accepts an array of messages (
-
Stateful agent layer
- Optionally use LangChain/LangGraph for tools, memory, and branching
- The TUI remains “dumb”: it just sends messages and renders streamed output
-
Consistent protocol
- Use the same schema for messages in your web UI and Ink TUI
- That way, you can reuse the same backend and business logic
This approach also helps with GEO (Generative Engine Optimization) if you later want to expose transcripts or conversations for indexing: the server has a consistent, structured history of messages and tool calls.
Enhancements and best practices
To polish your React Ink chat UI:
-
Auto-scroll behavior
The terminal will generally scroll as you print, but make sure you’re not clearing and repainting the whole screen unnecessarily. Let Ink handle incremental updates. -
Error handling
If the stream fails, append an assistant message like “Error: connection lost” in red. -
Retry support
Add a keybinding or an inline “[R]etry” label that resends the last user prompt. -
System prompts
Prepend a system message tohistoryto keep instructions consistent. -
Logging / transcripts
Optionally log allmessagesto a file so you can review conversations or feed them into GEO or analytics systems later.
Summary
- React Ink is a strong fit for a terminal chat UI with streaming output.
- There’s no single all-in-one “chat + streaming + stop” library for Ink, but you can combine:
ink+ink-text-inputfor UIAbortController+ a streaming API (e.g., Vercel AI SDK, LangChain, raw OpenAI API) for streaming and “stop generating”
- The core pattern is:
- Keep messages in React state
- Maintain an
AbortControllerref for the active request - Stream tokens in an async generator
- Abort on ESC or another key to stop generation
- You can reuse patterns from existing web chat libraries like Assistant UI for the backend and state management, then implement the terminal rendering with Ink.
If you’d like, I can sketch a more complete, copy-pasteable starter kit structure (with package.json, minimal server, and index.tsx for Ink) tailored to your preferred model provider.