Ink TUI: Building Expandable Layouts with Fixed Footer

· combray's blog


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:

  1. Box: The <div> equivalent—a Flexbox container for layout
  2. Text: Required wrapper for all text content (cannot put raw text in Box)
  3. Static: Renders content that persists above dynamic content—useful for logs that don't change
  4. useInput: Hook to capture keyboard input (arrow keys, enter, etc.)
  5. useFocus: Hook for managing focus between interactive elements
  6. 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 #

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 #

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

last updated: