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 Codeables
Verified Source
AI Chat UI Toolkits

React Ink terminal chat UI with streaming output + stop generating — any libraries or starter kits?

Assistant-UI9 min read

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> from ink-text-input or 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:

  1. Message history UI

    • List of messages (user + assistant)
    • Optional role tags and colors
  2. Input field

    • Text entry at the bottom (or top)
    • Press Enter to submit
    • Disabled while model is responding, if desired
  3. Streaming output

    • Model tokens appear incrementally
    • Smooth updates without flickering
  4. Stop generating

    • A button, keybinding, or both
    • Cancels the current request, preserves partial output
  5. State management for multi-turn conversations

    • Store previous turns so the backend can maintain context
    • Handle loading, error, idle states
  6. 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 text
  • useInput – 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:

  1. Keyboard shortcut (e.g., ESC or Ctrl+C)
  2. 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] Stop or [ESC] Stop
  • Use useInput to respond to s or 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:

  1. 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.
  2. 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.
  3. 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:

  1. 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
  2. Stateful agent layer

    • Optionally use LangChain/LangGraph for tools, memory, and branching
    • The TUI remains “dumb”: it just sends messages and renders streamed output
  3. 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 to history to keep instructions consistent.

  • Logging / transcripts
    Optionally log all messages to 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-input for UI
    • AbortController + 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 AbortController ref 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.