Summary #
Ink (v6.5.1) is the definitive React-based library for building terminal UIs in Node.js[1]. It uses Yoga (Facebook's Flexbox engine) to provide CSS-like layout capabilities in the terminal. Ink has 28k+ GitHub stars, is actively maintained (last release 7 days ago), and is used by major tools including GitHub Copilot CLI, Gatsby, Prisma, and Shopify[2].
For your specific use case—viewing diffs with expandable/collapsible tool calls and a fixed chat window with stats at the bottom—Ink provides the foundation but requires custom components for expand/collapse behavior. The companion library @inkjs/ui provides pre-built components but lacks accordion/collapsible components, so you'll build these yourself using Ink's state management[3].
The key architectural pattern for your layout: use a fullscreen wrapper with alternate screen buffer, a flex column layout with flexGrow={1} for the scrollable content area, and a fixed-height footer for stats.
Philosophy & Mental Model #
Ink treats the terminal as a React render target. Every component is a Flexbox container by default (like having display: flex on every div). Key concepts:
- Box: The
<div>equivalent—a Flexbox container for layout - Text: Required wrapper for all text content (cannot put raw text in Box)
- Static: Renders content that persists above dynamic content—useful for logs that don't change
- useInput: Hook to capture keyboard input (arrow keys, enter, etc.)
- useFocus: Hook for managing focus between interactive elements
- useStdout: Hook to access terminal dimensions for responsive layouts[4]
Mental model: Think of your CLI as a single-page React app where the terminal is your viewport. You re-render the entire visible state on each update, but Ink efficiently diffs and only redraws what changed.
Setup #
1pnpm add ink ink-spinner react
2pnpm add -D @types/react
For TypeScript, ensure your tsconfig.json has:
1{
2 "compilerOptions": {
3 "jsx": "react-jsx",
4 "esModuleInterop": true
5 }
6}
Core Usage Patterns #
Pattern 1: Fullscreen Layout with Fixed Footer #
This is the foundation for your TUI—fullscreen with a fixed stats bar at the bottom:
1import React, { useEffect } from 'react';
2import { render, Box, Text, useStdout } from 'ink';
3
4// Alternate screen buffer for fullscreen apps (like vim/htop)
5const enterAltScreen = '\x1b[?1049h';
6const leaveAltScreen = '\x1b[?1049l';
7
8function FullScreen({ children }: { children: React.ReactNode }) {
9 useEffect(() => {
10 process.stdout.write(enterAltScreen);
11 return () => {
12 process.stdout.write(leaveAltScreen);
13 };
14 }, []);
15 return <>{children}</>;
16}
17
18function App() {
19 const { stdout } = useStdout();
20 const height = stdout?.rows ?? 24;
21
22 return (
23 <FullScreen>
24 <Box flexDirection="column" height={height}>
25 {/* Main scrollable content area */}
26 <Box flexDirection="column" flexGrow={1} overflow="hidden">
27 <Text>Your content here...</Text>
28 </Box>
29
30 {/* Fixed footer - stats bar */}
31 <Box
32 borderStyle="single"
33 borderColor="gray"
34 paddingX={1}
35 >
36 <Text color="cyan">Tokens: 1,234</Text>
37 <Text> | </Text>
38 <Text color="green">Cost: $0.02</Text>
39 </Box>
40 </Box>
41 </FullScreen>
42 );
43}
44
45render(<App />);
Pattern 2: Expandable/Collapsible Component #
Build a custom collapsible for tool calls and diffs:
1import React, { useState } from 'react';
2import { Box, Text, useInput } from 'ink';
3
4interface CollapsibleProps {
5 title: string;
6 children: React.ReactNode;
7 defaultExpanded?: boolean;
8 isFocused?: boolean;
9}
10
11function Collapsible({
12 title,
13 children,
14 defaultExpanded = false,
15 isFocused = false
16}: CollapsibleProps) {
17 const [expanded, setExpanded] = useState(defaultExpanded);
18
19 useInput((input, key) => {
20 if (isFocused && (key.return || input === ' ')) {
21 setExpanded(e => !e);
22 }
23 }, { isActive: isFocused });
24
25 const icon = expanded ? '▼' : '▶';
26 const borderColor = isFocused ? 'cyan' : 'gray';
27
28 return (
29 <Box flexDirection="column">
30 <Box>
31 <Text color={borderColor}>{icon} </Text>
32 <Text bold={isFocused}>{title}</Text>
33 </Box>
34 {expanded && (
35 <Box marginLeft={2} flexDirection="column">
36 {children}
37 </Box>
38 )}
39 </Box>
40 );
41}
Pattern 3: Tool Call Display with Diff #
Show tool calls with expandable results:
1import React from 'react';
2import { Box, Text } from 'ink';
3
4interface ToolCallProps {
5 name: string;
6 args: Record<string, unknown>;
7 result?: string;
8 expanded: boolean;
9}
10
11function ToolCall({ name, args, result, expanded }: ToolCallProps) {
12 return (
13 <Box flexDirection="column" borderStyle="round" borderColor="yellow" marginY={1}>
14 <Box paddingX={1}>
15 <Text color="yellow" bold>{name}</Text>
16 <Text dimColor> ({Object.keys(args).join(', ')})</Text>
17 </Box>
18
19 {expanded && (
20 <Box flexDirection="column" paddingX={1}>
21 {/* Arguments */}
22 <Box marginTop={1}>
23 <Text dimColor>Args: </Text>
24 <Text>{JSON.stringify(args, null, 2)}</Text>
25 </Box>
26
27 {/* Result/Diff */}
28 {result && (
29 <Box marginTop={1} flexDirection="column">
30 <Text dimColor>Result:</Text>
31 <Box borderStyle="single" borderColor="green" marginTop={1}>
32 <Text>{result}</Text>
33 </Box>
34 </Box>
35 )}
36 </Box>
37 )}
38 </Box>
39 );
40}
Pattern 4: Scrollable List with Keyboard Navigation #
Since Ink's native scrolling is limited, implement virtual scrolling manually:
1import React, { useState } from 'react';
2import { Box, Text, useInput, useStdout } from 'ink';
3
4interface ScrollableListProps<T> {
5 items: T[];
6 renderItem: (item: T, index: number, isSelected: boolean) => React.ReactNode;
7 maxVisible?: number;
8}
9
10function ScrollableList<T>({
11 items,
12 renderItem,
13 maxVisible
14}: ScrollableListProps<T>) {
15 const { stdout } = useStdout();
16 const visibleCount = maxVisible ?? Math.min(10, (stdout?.rows ?? 20) - 5);
17
18 const [selectedIndex, setSelectedIndex] = useState(0);
19 const [scrollOffset, setScrollOffset] = useState(0);
20
21 useInput((_, key) => {
22 if (key.upArrow) {
23 setSelectedIndex(i => {
24 const newIndex = Math.max(0, i - 1);
25 if (newIndex < scrollOffset) {
26 setScrollOffset(newIndex);
27 }
28 return newIndex;
29 });
30 }
31 if (key.downArrow) {
32 setSelectedIndex(i => {
33 const newIndex = Math.min(items.length - 1, i + 1);
34 if (newIndex >= scrollOffset + visibleCount) {
35 setScrollOffset(newIndex - visibleCount + 1);
36 }
37 return newIndex;
38 });
39 }
40 });
41
42 const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
43 const showScrollUp = scrollOffset > 0;
44 const showScrollDown = scrollOffset + visibleCount < items.length;
45
46 return (
47 <Box flexDirection="column">
48 {showScrollUp && <Text dimColor> ↑ {scrollOffset} more above</Text>}
49 {visibleItems.map((item, i) => (
50 <Box key={scrollOffset + i}>
51 {renderItem(item, scrollOffset + i, scrollOffset + i === selectedIndex)}
52 </Box>
53 ))}
54 {showScrollDown && (
55 <Text dimColor> ↓ {items.length - scrollOffset - visibleCount} more below</Text>
56 )}
57 </Box>
58 );
59}
Pattern 5: Complete TUI Layout for Your Use Case #
Putting it all together:
1import React, { useState, useEffect } from 'react';
2import { render, Box, Text, useInput, useStdout } from 'ink';
3
4// Types
5interface Message {
6 role: 'user' | 'assistant';
7 content: string;
8 toolCalls?: ToolCallData[];
9}
10
11interface ToolCallData {
12 id: string;
13 name: string;
14 args: Record<string, unknown>;
15 result?: string;
16}
17
18interface Stats {
19 inputTokens: number;
20 outputTokens: number;
21 cost: number;
22}
23
24// Fullscreen wrapper
25function FullScreen({ children }: { children: React.ReactNode }) {
26 useEffect(() => {
27 process.stdout.write('\x1b[?1049h');
28 return () => process.stdout.write('\x1b[?1049l');
29 }, []);
30 return <>{children}</>;
31}
32
33// Main App
34function ChatTUI() {
35 const { stdout } = useStdout();
36 const height = stdout?.rows ?? 24;
37
38 const [messages, setMessages] = useState<Message[]>([]);
39 const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
40 const [focusedIndex, setFocusedIndex] = useState(0);
41 const [stats, setStats] = useState<Stats>({ inputTokens: 0, outputTokens: 0, cost: 0 });
42
43 // Get all tool calls for navigation
44 const allToolCalls = messages.flatMap(m => m.toolCalls ?? []);
45
46 useInput((input, key) => {
47 if (key.upArrow) {
48 setFocusedIndex(i => Math.max(0, i - 1));
49 }
50 if (key.downArrow) {
51 setFocusedIndex(i => Math.min(allToolCalls.length - 1, i + 1));
52 }
53 if (key.return && allToolCalls[focusedIndex]) {
54 const id = allToolCalls[focusedIndex].id;
55 setExpandedTools(prev => {
56 const next = new Set(prev);
57 if (next.has(id)) next.delete(id);
58 else next.add(id);
59 return next;
60 });
61 }
62 if (input === 'q') {
63 process.exit(0);
64 }
65 });
66
67 return (
68 <FullScreen>
69 <Box flexDirection="column" height={height}>
70 {/* Header */}
71 <Box borderStyle="single" borderColor="blue" paddingX={1}>
72 <Text bold color="blue">Agent Chat</Text>
73 <Text> - Press ↑↓ to navigate, Enter to expand/collapse, q to quit</Text>
74 </Box>
75
76 {/* Main content area */}
77 <Box flexDirection="column" flexGrow={1} overflow="hidden" paddingX={1}>
78 {messages.map((msg, msgIdx) => (
79 <Box key={msgIdx} flexDirection="column" marginY={1}>
80 <Text color={msg.role === 'user' ? 'green' : 'cyan'} bold>
81 {msg.role === 'user' ? 'You' : 'Assistant'}:
82 </Text>
83 <Text wrap="wrap">{msg.content}</Text>
84
85 {msg.toolCalls?.map((tc, tcIdx) => {
86 const globalIdx = messages
87 .slice(0, msgIdx)
88 .reduce((acc, m) => acc + (m.toolCalls?.length ?? 0), 0) + tcIdx;
89 const isExpanded = expandedTools.has(tc.id);
90 const isFocused = globalIdx === focusedIndex;
91
92 return (
93 <Box
94 key={tc.id}
95 flexDirection="column"
96 borderStyle={isFocused ? 'double' : 'single'}
97 borderColor={isFocused ? 'cyan' : 'yellow'}
98 marginY={1}
99 >
100 <Box paddingX={1}>
101 <Text>{isExpanded ? '▼' : '▶'} </Text>
102 <Text color="yellow" bold>{tc.name}</Text>
103 </Box>
104 {isExpanded && (
105 <Box paddingX={2} flexDirection="column">
106 <Text dimColor>Args: {JSON.stringify(tc.args)}</Text>
107 {tc.result && (
108 <Box marginTop={1}>
109 <Text>{tc.result}</Text>
110 </Box>
111 )}
112 </Box>
113 )}
114 </Box>
115 );
116 })}
117 </Box>
118 ))}
119 </Box>
120
121 {/* Fixed footer with stats */}
122 <Box
123 borderStyle="single"
124 borderColor="gray"
125 paddingX={1}
126 justifyContent="space-between"
127 >
128 <Text>
129 <Text color="cyan">Input: {stats.inputTokens.toLocaleString()}</Text>
130 <Text> | </Text>
131 <Text color="magenta">Output: {stats.outputTokens.toLocaleString()}</Text>
132 </Text>
133 <Text color="green" bold>${stats.cost.toFixed(4)}</Text>
134 </Box>
135 </Box>
136 </FullScreen>
137 );
138}
139
140render(<ChatTUI />);
Anti-Patterns & Pitfalls #
Don't: Put raw text in Box #
1// BAD - will throw error
2<Box>Hello world</Box>
Why it's wrong: Ink requires all text to be wrapped in <Text> components.
Instead: Always wrap text #
1// GOOD
2<Box><Text>Hello world</Text></Box>
Don't: Nest Box inside Text #
1// BAD - will throw error
2<Text>
3 Hello <Box><Text>world</Text></Box>
4</Text>
Why it's wrong: Text components can only contain text nodes and other Text components, not layout components.
Instead: Keep layout and text separate #
1// GOOD
2<Box>
3 <Text>Hello </Text>
4 <Text bold>world</Text>
5</Box>
Don't: Expect native scrolling to work automatically #
1// BAD - content just gets cut off
2<Box height={10} overflow="hidden">
3 {/* 100 items here... */}
4</Box>
Why it's wrong: Ink's overflow="hidden" only clips content—it doesn't provide scrolling. You must implement virtual scrolling manually[5].
Instead: Implement virtual scrolling #
1// GOOD - slice items based on scroll position
2const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
Don't: Use percentage dimensions without parent constraints #
1// BAD - percentage of what?
2<Box width="50%">
3 <Text>Content</Text>
4</Box>
Why it's wrong: Percentages need a parent with explicit dimensions to calculate against.
Instead: Set explicit dimensions on parent or use flexGrow #
1// GOOD
2<Box height={process.stdout.rows} flexDirection="row">
3 <Box width="50%"><Text>Left</Text></Box>
4 <Box width="50%"><Text>Right</Text></Box>
5</Box>
Don't: Forget cleanup for alternate screen buffer #
1// BAD - terminal left in broken state on crash
2useEffect(() => {
3 process.stdout.write('\x1b[?1049h');
4}, []);
Why it's wrong: If the app crashes, the terminal stays in alternate buffer mode.
Instead: Always return cleanup function #
1// GOOD
2useEffect(() => {
3 process.stdout.write('\x1b[?1049h');
4 return () => process.stdout.write('\x1b[?1049l');
5}, []);
Caveats #
-
No built-in virtual scrolling: For large lists (hundreds of items), you must implement windowing yourself. Consider using a virtualized approach that only renders visible items[5].
-
Overflow clips but doesn't scroll:
overflow="hidden"hides content but doesn't provide scrolling—you need manual scroll state management[6]. -
Terminal dimensions: Use
useStdout()to get terminal size and listen for resize, but be aware thatstdout.rowscan beundefinedin non-TTY environments. -
Static component is for logs, not headers:
<Static>renders content that persists above dynamic content but is designed for completed/immutable content (like test results), not for fixed headers[1]. -
No diff rendering built-in: For syntax-highlighted diffs, you'll need a library like
difffor computing diffs and custom rendering logic with colored Text components. -
React 18+ required: Ink 4+ requires React 18 with the new root API.
References #
[1] Ink GitHub Repository - Official docs, component API, and examples
[2] LogRocket: Using Ink UI with React - Tutorial on Ink UI components and who uses Ink
[3] Ink UI GitHub - Companion component library (Select, Spinner, etc.)
[4] Ink fullscreen discussion #263 - Alternate screen buffer pattern for fullscreen apps
[5] Ink scrolling issue #222 - Discussion of scrolling limitations and workarounds
[6] Ink overflow/scrolling issue #432 - Technical details on overflow behavior
[7] DEV.to: Building Reactive CLIs with Ink - Tutorial with file explorer example
[8] developerlife.com Ink Reference - Advanced component patterns