TUI Development: Ink + React

· combray's blog


Summary #

For building a complex 3-pane TUI (Text User Interface) with focus management and rich interactivity, Ink is the clear industry standard in the Node.js ecosystem. It allows you to build terminal UIs using React components, leveraging the same declarative mental model used in web development[1].

We recommend a combination of standard Ink libraries to achieve your specific layout:

Philosophy & Mental Model #

Ink applies the React component model to stdout.

  1. Everything is a Component: Your TUI is a tree of <Box> and <Text> elements.
  2. Flexbox Layout: Layouts are controlled via flexbox properties (flexDirection, justifyContent) on <Box> components. There is no CSS grid; everything is nested boxes.
  3. Hooks for Interactive Logic:
    • useInput: Listens for raw keystrokes (stdin).
    • useFocus: Manages which component receives input.
    • useApp: Accesses app lifecycle methods (exit).

Setup #

Install the core library and recommended components:

1pnpm add ink react ink-markdown ink-text-input ink-select-input
2pnpm add -D @types/react

Note: You might need to install ink-text-area from a specific maintainer or copy a small implementation if the main package is outdated, but ink-text-input is the official standard for single lines.

Core Usage Patterns #

Pattern 1: The 3-Pane Layout (Flexbox) #

Ink uses Yoga Layout (Flexbox) under the hood. To create a bottom input, top chat, and right sidebar:

 1import React, { useState } from 'react';
 2import { Box, Text, useInput, useFocusManager } from 'ink';
 3
 4export const App = () => {
 5  const { focusNext } = useFocusManager();
 6
 7  // Global global keyboard shortcut to cycle focus
 8  useInput((input, key) => {
 9    if (key.return && key.meta) { // Example: Ctrl/Meta+Enter to submit
10       // submit logic
11    }
12    if (key.tab) {
13      focusNext();
14    }
15  });
16
17  return (
18    <Box flexDirection="row" height="100%">
19      {/* Left Column (Chat + Input) */}
20      <Box flexDirection="column" width="70%">
21        
22        {/* Top: Chat Window (Grow to fill space) */}
23        <Box flexGrow={1} borderStyle="single" borderColor="green">
24          <Text>Chat Messages Scroll Area</Text>
25        </Box>
26
27        {/* Bottom: Input Area (Fixed height) */}
28        <Box height={5} borderStyle="single" borderColor="blue">
29           <Text>Input Area</Text>
30        </Box>
31      
32      </Box>
33
34      {/* Right Column: Options/Files */}
35      <Box width="30%" borderStyle="single" borderColor="yellow">
36        <Text>File List</Text>
37      </Box>
38    </Box>
39  );
40};

Pattern 2: Focus Management #

To allow the user to "tab" between panels, wrap each interactive section in a component that uses useFocus.

 1import { useFocus } from 'ink';
 2
 3const FocusablePane = ({ label, children }) => {
 4  const { isFocused } = useFocus();
 5  
 6  return (
 7    <Box 
 8      borderStyle={isFocused ? "double" : "single"} 
 9      borderColor={isFocused ? "blue" : "gray"}
10      flexDirection="column"
11    >
12      <Text bold={isFocused}>{label}</Text>
13      {children}
14    </Box>
15  );
16};

Pattern 3: Manual Scrolling (Virtualization) #

Ink does not have a native <ScrollView> that handles overflow automatically like a browser. You must implement "windowing" logic: only render the slice of messages that fit in the viewport.

 1const ScrollableList = ({ items, height = 10 }) => {
 2  const [scrollTop, setScrollTop] = useState(0);
 3  const { isFocused } = useFocus();
 4
 5  useInput((input, key) => {
 6    if (!isFocused) return;
 7    
 8    if (key.upArrow) {
 9      setScrollTop(Math.max(0, scrollTop - 1));
10    }
11    if (key.downArrow) {
12      setScrollTop(Math.min(items.length - height, scrollTop + 1));
13    }
14  });
15
16  // Only render visible slice
17  const visibleItems = items.slice(scrollTop, scrollTop + height);
18
19  return (
20    <Box flexDirection="column">
21      {visibleItems.map(item => <Text key={item.id}>{item.text}</Text>)}
22    </Box>
23  );
24};

Pattern 4: Expandable Tool Calls #

For the "expandable tool call" requirement, use simple React state.

 1const ToolCall = ({ toolName, args, result }) => {
 2  const [expanded, setExpanded] = useState(false);
 3
 4  return (
 5    <Box flexDirection="column">
 6      <Text color="yellow" onClick={() => setExpanded(!expanded)}>
 7        {expanded ? "▼" : "▶"} Called {toolName}
 8      </Text>
 9      
10      {expanded && (
11        <Box marginLeft={2} flexDirection="column">
12          <Text color="gray">Args: {JSON.stringify(args)}</Text>
13          <Text color="green">Result: {result}</Text>
14        </Box>
15      )}
16    </Box>
17  );
18};

Anti-Patterns & Pitfalls #

❌ Don't: Rely on console.log #

Logging to console.log while Ink is running will break the layout. Instead: Use a specialized logger component that renders log lines into a specific part of your UI, or write logs to a file (tail -f in another window).

❌ Don't: Render 10,000 components at once #

Ink renders to a string. Rendering a massive list without windowing/slicing will cause performance issues and flickering. Instead: Always limit the number of rendered <Text> nodes to what fits on the screen (e.g., last 50 messages).

❌ Don't: Assume terminal size is static #

Terminals can be resized. Instead: Use the useStdoutDimensions() hook (from ink) to dynamically adjust the number of visible rows in your scrolling logic.

References #

[1] Ink Repository - Official documentation and examples. [2] Ink UI - Collection of reusable UI components. [3] Focus Management - Official focus hook documentation.

last updated: