@elfenlabs/terma
v0.1.0
Published
LLM-stream terminal renderer and REPL for agent interfaces
Readme
terma
LLM-stream terminal renderer and interactive REPL. Two independent, composable layers:
StreamRenderer— Pure event→ANSI renderer. No I/O coupling. Usable standalone for non-interactive agent logs.TerminalUI— Interactive multiline REPL that drives a genericAgentRunnerand composesStreamRendererinternally.
Install
bun add @elfenlabs/termaQuick Start
StreamRenderer only (non-interactive)
import { StreamRenderer } from '@elfenlabs/terma'
const renderer = new StreamRenderer()
renderer.thinkingStart()
renderer.thinking('Analyzing the request...\n')
renderer.thinkingEnd()
renderer.outputStart()
renderer.output('Here is the answer.\n')
renderer.outputEnd()
renderer.toolCall({ id: '1', name: 'exec_command', args: { command: 'ls' } })
renderer.toolResult({ id: '1', name: 'exec_command', result: 'file.txt', isError: false })
renderer.complete('', { promptTokens: 100, completionTokens: 50 })Interactive REPL
import { TerminalUI } from '@elfenlabs/terma'
import type { AgentRunner, StreamCallbacks } from '@elfenlabs/terma'
const runner: AgentRunner = {
run: async (prompt: string, cb: StreamCallbacks) => {
// Wire your LLM SDK to emit stream events via cb.*
cb.onOutputStart?.()
cb.onOutput?.(`You said: ${prompt}\n`)
cb.onOutputEnd?.()
cb.onComplete?.('', { promptTokens: 0, completionTokens: 0 })
},
abort: () => { /* cancel in-flight request */ },
}
const ui = new TerminalUI({ runner })
ui.start()Run the demo
bun run examples/demo-repl.tsCommands: /test-transition (all state transitions), /test-markdown (formatting test).
API Reference
StreamRenderer
Renders LLM streaming events to ANSI terminal output.
import { StreamRenderer } from '@elfenlabs/terma'
const renderer = new StreamRenderer(options?: StreamRendererOptions)StreamRendererOptions
| Option | Type | Default | Description |
|---|---|---|---|
| write | WriteFn | process.stdout.write | Custom write function for output |
| columns | number | process.stdout.columns \|\| 80 | Terminal column width |
| colors | Partial<ColorPalette> | Default palette | Override individual colors |
| prefixes | Prefixes | See below | Per-state line prefixes |
| toolFormatters | Record<string, ToolFormatter> | {} | Custom per-tool call/result renderers |
| toolResultFormatters | Record<string, ToolResultFormatter> | {} | Backward-compatible alias for custom per-tool result renderers |
Methods
| Method | Description |
|---|---|
| thinkingStart() | Begin a thinking/reasoning block |
| thinking(chunk) | Stream thinking content (prefix applied per-line) |
| thinkingEnd() | End thinking block |
| outputStart() | Begin output block |
| output(chunk) | Stream output content (markdown formatted per-line) |
| outputEnd() | End output block |
| toolCall(event) | Render a tool call |
| toolResult(event) | Render a tool result (tries custom formatter first) |
| complete(response, usage) | Finalize the stream |
| error(err) | Render an error |
| reset() | Reset state for a new agent run |
Properties
| Property | Type | Description |
|---|---|---|
| outputStarted | boolean | Whether output has been rendered in the current run |
| colors | ColorPalette | The resolved color palette |
TerminalUI
Interactive multiline REPL. Composes StreamRenderer internally.
import { TerminalUI } from '@elfenlabs/terma'
const ui = new TerminalUI(options: TerminalUIOptions)
ui.start()TerminalUIOptions
| Option | Type | Default | Description |
|---|---|---|---|
| runner | AgentRunner | required | Agent that handles prompts |
| promptPrefix | string | '❯ ' | Prompt symbol |
| continuationPrefix | string | ' ' (spaces matching prefix width) | Multiline continuation prefix |
| getPromptInfo | () => string | undefined | Info line above prompt (e.g. 'cwd · model · 1.2k tokens') |
| header | string \| false | '⚡ terma — llm stream' | Startup banner. false to disable. |
| goodbyeMessage | string | 'Goodbye.' | Message on exit |
| renderer | StreamRendererOptions | {} | Options passed to internal StreamRenderer |
Input Keybindings
| Key | Action | |---|---| | Enter | Submit input | | Alt+Enter | New line (multiline input) | | Ctrl+C | Exit | | Ctrl+D | Exit (on empty input) | | Ctrl+W | Delete word back | | Ctrl+U | Kill line before cursor | | Ctrl+K | Kill to end of line | | Ctrl+←/→ | Jump word left/right | | Home / Ctrl+A | Start of line | | End / Ctrl+E | End of line |
Types
AgentRunner
type AgentRunner = {
run(prompt: string, callbacks: StreamCallbacks): Promise<void>
abort(): void
}StreamCallbacks
type StreamCallbacks = {
onThinkingStart?: () => void
onThinking?: (chunk: string) => void
onThinkingEnd?: () => void
onOutputStart?: () => void
onOutput?: (chunk: string) => void
onOutputEnd?: () => void
onToolCall?: (event: ToolCallEvent) => void
onToolResult?: (event: ToolResultEvent) => void
onComplete?: (response: string, usage: Usage) => void
onError?: (error: Error) => void
}ToolCallFormatter
Custom renderer for a specific tool call. Return true if handled, false to fall through to the default renderer.
type ToolCallFormatter = (event: ToolCallEvent, write: WriteFn) => booleanToolFormatter
Symmetric per-tool formatter hooks for calls and results.
type ToolFormatter = {
call?: ToolCallFormatter
result?: ToolResultFormatter
}ToolResultFormatter
Custom renderer for a specific tool's results. Return true if handled, false to fall through to the default renderer.
type ToolResultFormatter = (event: ToolResultEvent, write: WriteFn) => booleanTheming
ColorPalette
Override any color using ANSI 256-color codes via the fg() helper:
import { StreamRenderer, fg } from '@elfenlabs/terma'
const renderer = new StreamRenderer({
colors: {
accent: fg(208), // orange instead of violet
thinking: fg(63), // blue instead of gray
},
})Default palette:
| Key | Code | Color |
|---|---|---|
| accent | fg(98) | Violet |
| text | fg(252) | Light gray |
| dim | fg(244) | Medium gray |
| muted | fg(240) | Dark gray |
| user | fg(117) | Sky blue |
| success | fg(42) | Green |
| warning | fg(214) | Amber |
| error | fg(196) | Red |
| thinking | fg(244) | Dimmed gray |
Prefixes
Add a label prefix to each line of output per state:
const renderer = new StreamRenderer({
prefixes: {
thinking: '💭 ',
output: '',
code: ' ',
toolCall: '🔧 ',
toolResult: ' ',
},
})Defaults: all empty except code (' '), toolCall (' '), and toolResult (' ').
Note: Prefixes are applied per logical line, not per terminal-wrapped visual line.
Utilities
| Export | Description |
|---|---|
| C | Default color palette object |
| fg(code) | Generate ANSI 256-color escape sequence |
| formatLine(line) | Apply inline markdown formatting (bold, italic, code, headers, lists) |
| renderMarkdown(text) | Full markdown→ANSI rendering via marked + marked-terminal |
Transition Spacing
All section transitions produce exactly 2 newlines (one blank line) between content blocks. This is enforced by 24 unit tests covering every transition pair.
License
MIT
