A CLI command can run in different output modes depending on context. We want to decouple command state/logic from output rendering to enable code sharing and principled divergence.
Commands must model their state explicitly using Effect Schema. The state is the single source of truth for all renderers.
JSON output must be derived from Effect Schema definitions (using Schema.encode, not JSON.stringify), enabling:
- Type-safe serialization/deserialization
- Schema can be shared with external consumers
- Tagged structs (
_tagfield) for discriminated unions
The architecture must support input handling for interactive modes while gracefully degrading for non-interactive modes.
Modes that support it should receive real-time state updates. Non-progressive modes should receive final state only (or optionally stream updates as NDJSON).
A single canonical React-based renderer should be the foundation, with mode-specific refinements where divergence is necessary.
The tui-react package must provide a reliable, mode-specific test suite. Consuming packages should focus on business logic without worrying about rendering correctness.
The state management primitive must support multiple concurrent consumers (e.g., render to terminal AND log to file simultaneously).
Output behavior varies along several independent dimensions. Understanding these dimensions is crucial for designing a flexible architecture.
How output evolves over time.
| Value | Description | Use Case |
|---|---|---|
progressive |
Updates stream over time | Progress bars, live status |
final |
Single output at completion | CI logs, scripts |
The structure and encoding of output.
| Value | Description | Use Case |
|---|---|---|
visual |
Human-readable, ANSI colors | Interactive terminal |
json |
Structured, machine-readable | Scripting, tooling integration |
How the output relates to terminal screen buffer (only applicable to visual format).
| Value | Description | Use Case |
|---|---|---|
inline |
Within scrollback, dynamic height | Short operations, progress |
alternate |
Takes over screen, fixed dimensions | Dashboards, interactive apps |
Whether and how input is accepted.
| Value | Description | Use Case |
|---|---|---|
interactive |
Accepts keyboard/mouse input | Navigation, selection |
passive |
Output only, no input handling | CI, piped output |
Not all combinations are valid:
| Format | Screen | Interactivity | Temporality | Valid? | Mode Name |
|---|---|---|---|---|---|
| visual | inline | passive | progressive | ✅ | progressive-visual-inline |
| visual | inline | interactive | progressive | ✅ | progressive-visual-inline-interactive |
| visual | alternate | interactive | progressive | ✅ | progressive-visual-alternate |
| visual | inline | passive | final | ✅ | final-visual-inline |
| json | n/a | passive | final | ✅ | final-json |
| json | n/a | passive | progressive | ✅ | progressive-json |
| json | n/a | interactive | * | ❌ | (invalid) |
Use full descriptive names containing all dimension information:
| Mode Name | Description |
|---|---|
progressive-visual-inline |
Real-time updates in scrolling terminal |
progressive-visual-alternate |
Full-screen interactive application |
final-visual-inline |
Single text output at completion |
final-json |
Structured JSON at completion |
progressive-json |
NDJSON streaming |
┌─────────────────────────────────────────────────────────────┐
│ Command Logic │
│ (Effect.gen) │
└─────────────────────────┬───────────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ │
┌─────────────────┐ ┌─────────┴─────────┐
│ SubscriptionRef │ │ Event Queue │
│ <State> │ │ (input, resize) │
│ │ │ │
│ state.changes ──┼─────┐ │◀── InputEvent │
└─────────────────┘ │ │◀── ResizeEvent │
│ └───────────────────┘
│ ▲
▼ │
┌─────────────────────────────────────┐
│ Renderer │
│ │
│ - Subscribes to state.changes │
│ - Publishes events (input, resize) │
│ - Provides viewport via hook │
└─────────────────────────────────────┘
import { Schema, PubSub, Effect } from 'effect'
// Input events from renderer to command
const KeyEvent = Schema.TaggedStruct('Event.Key', {
key: Schema.String,
ctrl: Schema.optional(Schema.Boolean),
alt: Schema.optional(Schema.Boolean),
shift: Schema.optional(Schema.Boolean),
})
const ResizeEvent = Schema.TaggedStruct('Event.Resize', {
rows: Schema.Number,
cols: Schema.Number,
})
const InputEvent = Schema.Union(KeyEvent, ResizeEvent)
type InputEvent = Schema.Schema.Type<typeof InputEvent>
// Command IO: bidirectional communication
interface CommandIO<S, A> {
// State flows down (command → renderer)
state: SubscriptionRef.SubscriptionRef<S>
// Events flow up (renderer → command)
events: PubSub.PubSub<InputEvent>
// Actions for command to dispatch state changes
dispatch: (action: A) => Effect.Effect<void>
}// Renderer captures input and publishes to event queue
const setupInputHandling = (events: PubSub.PubSub<InputEvent>) =>
Effect.gen(function* () {
// Keyboard input
yield* Effect.fork(
Terminal.readInput.pipe(
Stream.runForEach(key => PubSub.publish(events, {
_tag: 'Event.Key',
key: key.name,
ctrl: key.ctrl,
alt: key.alt,
shift: key.shift,
}))
)
)
// Terminal resize
yield* Effect.fork(
Terminal.onResize.pipe(
Stream.runForEach(size => PubSub.publish(events, {
_tag: 'Event.Resize',
rows: size.rows,
cols: size.cols,
}))
)
)
})// Command can subscribe to events
const runCommand = <S, A>(io: CommandIO<S, A>) =>
Effect.gen(function* () {
// Handle events from renderer
yield* Effect.fork(
PubSub.subscribe(io.events).pipe(
Stream.runForEach(event => {
switch (event._tag) {
case 'Event.Key':
if (event.key === 'q') return Effect.interrupt
if (event.key === 'j') return io.dispatch({ _tag: 'Action.SelectNext' })
// ...
case 'Event.Resize':
return io.dispatch({ _tag: 'Action.Resize', ...event })
}
})
)
)
// Main command logic
yield* doWork(io)
})The terminal provides row/column information that we expose to React components:
interface Viewport {
rows: number // Available rows
cols: number // Available columns
mode: 'inline' | 'alternate'
}
// For inline mode, we may reserve lines and expose available space
interface InlineViewport extends Viewport {
mode: 'inline'
maxLines: number // Lines we're allowed to use (may be < rows)
}
// For alternate mode, we have the full screen
interface AlternateViewport extends Viewport {
mode: 'alternate'
}// Hook for components to access viewport
const useViewport = (): Viewport => {
const [viewport, setViewport] = useState<Viewport>(getInitialViewport())
useEffect(() => {
const handler = () => setViewport(getCurrentViewport())
process.stdout.on('resize', handler)
return () => process.stdout.off('resize', handler)
}, [])
return viewport
}
// Components adapt to available space
const MemberList = ({ members }: Props) => {
const { rows, maxLines } = useViewport()
// In inline mode, show limited items
const visibleCount = Math.min(members.length, maxLines - 2) // reserve for header/footer
return (
<Box flexDirection="column">
{members.slice(0, visibleCount).map(m => (
<MemberRow key={m.name} member={m} />
))}
{members.length > visibleCount && (
<Text dimColor>... and {members.length - visibleCount} more</Text>
)}
</Box>
)
}For inline progressive rendering, we need to track available lines:
interface InlineRenderContext {
// Initial terminal size
initialRows: number
initialCols: number
// How many lines we've used (for cursor management)
usedLines: number
// Maximum lines we're allowed to use
// Could be: min(terminalRows - 1, configuredMax)
maxLines: number
// Current cursor position relative to our output region
cursorLine: number
}
// Calculate available lines for inline mode
const calculateAvailableLines = (config: Config): number => {
const terminalRows = process.stdout.rows ?? 24
// Leave at least 1 line for prompt, use at most configured max
const available = terminalRows - 1
const configuredMax = config.maxInlineLines ?? 20
return Math.min(available, configuredMax)
}interface OutputConfig {
temporality: 'progressive' | 'final'
format: 'visual' | 'json'
screen: 'inline' | 'alternate' // only relevant for visual
interactive: boolean
}
// Derive mode name from config
const toModeName = (config: OutputConfig): string => {
if (config.format === 'json') {
return config.temporality === 'progressive' ? 'progressive-json' : 'final-json'
}
return `${config.temporality}-visual-${config.screen}`
}const presets: Record<string, OutputConfig> = {
'progressive-visual-inline': {
temporality: 'progressive',
format: 'visual',
screen: 'inline',
interactive: false,
},
'progressive-visual-alternate': {
temporality: 'progressive',
format: 'visual',
screen: 'alternate',
interactive: true,
},
'final-visual-inline': {
temporality: 'final',
format: 'visual',
screen: 'inline',
interactive: false,
},
'final-json': {
temporality: 'final',
format: 'json',
screen: 'inline',
interactive: false,
},
'progressive-json': {
temporality: 'progressive',
format: 'json',
screen: 'inline',
interactive: false,
},
}# Use preset by name
mr sync --output=progressive-visual-inline
mr sync --output=final-json
# Dimensional overrides
mr sync --json # format=json, temporality=final (default for json)
mr sync --json --stream # format=json, temporality=progressive
mr sync --alternate # screen=alternate
mr sync --no-tty # temporality=final, interactive=false
# Resolution
mr sync # Auto: progressive-visual-inline if TTY, final-visual-inline otherwiseconst resolveOutputConfig = (flags: Flags, env: Environment): OutputConfig => {
// Start with environment-based default
let config: OutputConfig = env.isTTY
? presets['progressive-visual-inline']
: presets['final-visual-inline']
// Apply preset if specified
if (flags.output && flags.output in presets) {
config = { ...presets[flags.output] }
}
// Apply dimensional overrides
if (flags.json) {
config.format = 'json'
config.temporality = config.temporality ?? 'final'
}
if (flags.stream) config.temporality = 'progressive'
if (flags.alternate) config.screen = 'alternate'
if (flags.noTty) {
config.temporality = 'final'
config.interactive = false
}
// Validate
if (config.format === 'json' && config.interactive) {
throw new Error('JSON format cannot be interactive')
}
return config
}Use Schema.encode instead of JSON.stringify:
import { Schema, JSONSchema } from 'effect'
// Define state schema
const SyncState = Schema.Union(
Schema.TaggedStruct('Sync.Progress', {
member: Schema.String,
progress: Schema.Number,
}),
Schema.TaggedStruct('Sync.Complete', {
results: Schema.Array(Schema.Struct({
member: Schema.String,
status: Schema.Literal('cloned', 'updated', 'unchanged'),
})),
duration: Schema.Number,
}),
)
// Encoder for JSON output
const encodeState = Schema.encode(SyncState)
// JSON renderer uses schema encoding
const jsonRenderer = <S>(schema: Schema.Schema<S>) => ({
render: (output: CommandOutput<S>) =>
output.state.changes.pipe(
Stream.runLast,
Effect.flatMap(state => Schema.encode(schema)(state)),
Effect.flatMap(encoded => Console.log(JSON.stringify(encoded))),
)
})
// For NDJSON streaming
const jsonStreamRenderer = <S>(schema: Schema.Schema<S>) => ({
render: (output: CommandOutput<S>) =>
output.state.changes.pipe(
Stream.mapEffect(state => Schema.encode(schema)(state)),
Stream.runForEach(encoded => Console.log(JSON.stringify(encoded))),
)
})| ID | Decision | Choice |
|---|---|---|
| D1 | State Separation | Separate public (Schema) and internal (TypeScript) types |
| D2 | Input Events | Reducer pattern with Schema-defined actions |
| D3 | Mode Selection | Presets + dimensional overrides |
| D4 | effectAtom | Future work - start with SubscriptionRef |
| D5 | Streaming JSON | Same schema union, NDJSON format |
| D6 | JSON Encoding | Use Effect Schema.encode, not JSON.stringify |
| D7 | Event Flow | PubSub for renderer→command events |
Commands model state explicitly as Effect Schema, not as side effects.
Command implementation should not know which renderer will display output.
Richer modes extend simpler ones.
Output mode is explicitly configured via flags, with smart defaults based on environment.
All state and events defined with Effect Schema. Use Schema.encode for JSON.
JSON output represents semantic domain data, not visual structure.
Error handling and output must stay within the selected modality.
Inline progressive rendering falls back gracefully in unsupported environments.
State flows down (command→renderer), events flow up (renderer→command).
See also: Ink Rendering Internals Research
tui-react has a custom implementation (not using ink) that provides many rendering features out of the box:
| Feature | Status | Location | Notes |
|---|---|---|---|
| Differential line rendering | ✅ | tui-core/InlineRenderer |
Only rewrites changed lines |
| Static/Dynamic regions | ✅ | tui-core/InlineRenderer |
Logs persist above progress |
| Synchronized output (CSI 2026) | ✅ | tui-core/InlineRenderer |
Atomic updates prevent flicker |
| Terminal resize detection | ✅ | tui-core/InlineRenderer |
Forces full re-render on width change |
| Cursor management | ✅ | tui-core/InlineRenderer |
Hidden during render |
| TTY/non-TTY fallback | ✅ | tui-core/InlineRenderer |
Non-TTY just prints lines |
| Yoga flexbox layout | ✅ | tui-react/reconciler |
Full flexbox support |
| Custom React reconciler | ✅ | tui-react/reconciler |
Using react-reconciler |
| Update throttling | ❌ | Not implemented | Need to add |
| Output size limits | ❌ | Not implemented | Need to add |
| Viewport context | ❌ | Not implemented | Need to add |
┌─────────────────────────────────────────────────────────────────┐
│ React Component Tree │
│ <Box><Text>Hello</Text></Box> │
└─────────────────────────────────────────────────────────────────┘
│
▼ React reconciliation
┌─────────────────────────────────────────────────────────────────┐
│ TuiReconciler (react-reconciler) │
│ - Creates TuiElement tree (tui-box, tui-text, tui-static) │
│ - Maintains Yoga nodes for layout │
│ - Calls container.onRender() after each commit │
└─────────────────────────────────────────────────────────────────┘
│
▼ Tree to lines
┌─────────────────────────────────────────────────────────────────┐
│ Output Renderer (output.ts) │
│ - calculateLayout() via Yoga │
│ - renderTreeSimple() → string[] │
│ - extractStaticContent() for <Static> elements │
└─────────────────────────────────────────────────────────────────┘
│
▼ Line-level diffing
┌─────────────────────────────────────────────────────────────────┐
│ InlineRenderer (tui-core) │
│ - Compares with previousDynamic[] │
│ - Only writes changed lines to terminal │
│ - Handles static content separately │
└─────────────────────────────────────────────────────────────────┘
Dirty tracking happens at two levels:
Level 1: React Reconciler
- React's internal dirty tracking determines which components re-render
- Only components with changed state/props are re-rendered
- Our reconciler receives the delta and updates the TuiElement tree
Level 2: Terminal Output (InlineRenderer)
- Line-level string comparison
- Only writes lines that differ from previous render
- Efficient for typical CLI output (< 100 lines)
Gap: Tree → Lines rendering always re-renders entire tree. This is acceptable because:
- React already filtered to only changed components
- Line diffing catches identical output
- Terminal I/O is the bottleneck, not tree rendering
- For typical CLI (50 lines), this is negligible
For high-frequency state updates, add throttling to prevent excessive rendering:
interface CreateRootOptions {
/** Minimum milliseconds between renders. Default: 16 (~60fps) */
throttleMs?: number
}
// Implementation in root.ts
const scheduleRender = (() => {
let lastRender = 0
let pending = false
return () => {
const now = Date.now()
if (now - lastRender >= throttleMs) {
doRender()
lastRender = now
} else if (!pending) {
pending = true
setTimeout(() => {
pending = false
doRender()
lastRender = Date.now()
}, throttleMs - (now - lastRender))
}
}
})()Prevent runaway output from consuming terminal:
interface CreateRootOptions {
/** Maximum lines for dynamic region. Default: 100 */
maxDynamicLines?: number
}
// Truncate if too large
if (lines.length > maxDynamicLines) {
const truncated = lines.slice(0, maxDynamicLines - 1)
truncated.push(`... ${lines.length - maxDynamicLines + 1} more lines`)
lines = truncated
}Let components know terminal dimensions:
// Context
const ViewportContext = createContext<Viewport>({
columns: 80,
rows: 24,
mode: 'inline'
})
// Hook
export const useViewport = () => useContext(ViewportContext)
// Provider in createRoot
<ViewportContext.Provider value={currentViewport}>
{element}
</ViewportContext.Provider>- Define output dimensions
- Document bidirectional event flow
- Design viewport hook
- Design CLI flag structure
- Research existing rendering infrastructure
- Implement throttling in createRoot
- Implement output size safeguards
- Implement viewport context and useViewport hook
- Implement OutputConfig types
- Implement event system (PubSub)
- Add JSON renderer with Schema.encode
- Explore alternate screen / OpenTUI