446 lines
11 KiB
Markdown
446 lines
11 KiB
Markdown
# AGENTS.md - Working with Ink CLI Applications
|
|
|
|
## Overview
|
|
|
|
Ink is a React renderer for building command-line interfaces. Unlike web React, Ink renders to terminal output with strict constraints. This guide helps AI agents understand the unique considerations when writing React code for Ink applications.
|
|
|
|
## Key Differences from Web React
|
|
|
|
### 1. Terminal Rendering Environment
|
|
- **Fixed-width character grid**: Terminals use monospace fonts with fixed character cells
|
|
- **No pixel-based layouts**: Everything is measured in character columns and rows
|
|
- **Text-only output**: No images, videos, or rich media
|
|
- **Limited color support**: 16 colors, 256 colors, or RGB depending on terminal
|
|
- **No mouse interaction**: Primarily keyboard-driven (unless terminal supports mouse)
|
|
|
|
### 2. Layout System
|
|
- **Flexbox only**: All elements use `display: flex` by default
|
|
- **No CSS**: Styling is done through component props, not CSS classes
|
|
- **Character-based dimensions**: Width/height measured in characters, not pixels
|
|
- **No scrolling**: Content that exceeds terminal bounds is clipped or wrapped
|
|
|
|
## Text Handling and Overflow
|
|
|
|
### Text Wrapping
|
|
Text in Ink has specific wrapping behaviors controlled by the `wrap` prop:
|
|
|
|
```jsx
|
|
// Default wrapping - breaks at word boundaries
|
|
<Box width={10}>
|
|
<Text>Hello World</Text>
|
|
</Box>
|
|
// Output: "Hello\nWorld"
|
|
|
|
// Hard wrapping - breaks anywhere to fill width
|
|
<Box width={7}>
|
|
<Text wrap="hard">Hello World</Text>
|
|
</Box>
|
|
// Output: "Hello W\norld"
|
|
|
|
// Truncation options
|
|
<Box width={7}>
|
|
<Text wrap="truncate">Hello World</Text>
|
|
</Box>
|
|
// Output: "Hello…"
|
|
|
|
<Box width={7}>
|
|
<Text wrap="truncate-middle">Hello World</Text>
|
|
</Box>
|
|
// Output: "He…ld"
|
|
```
|
|
|
|
### Common Text Overflow Issues
|
|
❌ **Don't assume unlimited width:**
|
|
```jsx
|
|
// BAD - Text may overflow terminal width
|
|
<Text>This is a very long line that might exceed the terminal width and cause layout issues</Text>
|
|
```
|
|
|
|
✅ **Do constrain text appropriately:**
|
|
```jsx
|
|
// GOOD - Constrain width and handle wrapping
|
|
<Box width="80%">
|
|
<Text wrap="wrap">This is a very long line that will wrap properly within the container</Text>
|
|
</Box>
|
|
```
|
|
|
|
## Layout Constraints and Best Practices
|
|
|
|
### 1. Terminal Width Awareness
|
|
Always consider terminal width limitations:
|
|
|
|
```jsx
|
|
import {useWindowSize} from 'ink';
|
|
|
|
const ResponsiveComponent = () => {
|
|
const {columns} = useWindowSize();
|
|
|
|
return (
|
|
<Box width={Math.min(columns - 4, 80)}> {/* Leave margin, cap at 80 */}
|
|
<Text>Content that adapts to terminal size</Text>
|
|
</Box>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 2. Vertical Space Management
|
|
Terminal height is limited - avoid excessive vertical content:
|
|
|
|
❌ **Don't create unlimited vertical lists:**
|
|
```jsx
|
|
// BAD - Could exceed terminal height
|
|
{items.map(item => (
|
|
<Box key={item.id} height={3}>
|
|
<Text>{item.title}</Text>
|
|
</Box>
|
|
))}
|
|
```
|
|
|
|
✅ **Do implement pagination or scrolling:**
|
|
```jsx
|
|
// GOOD - Paginate or limit visible items
|
|
const visibleItems = items.slice(currentPage * pageSize, (currentPage + 1) * pageSize);
|
|
return (
|
|
<>
|
|
{visibleItems.map(item => (
|
|
<Box key={item.id}>
|
|
<Text>{item.title}</Text>
|
|
</Box>
|
|
))}
|
|
<Text dimColor>Page {currentPage + 1} of {Math.ceil(items.length / pageSize)}</Text>
|
|
</>
|
|
);
|
|
```
|
|
|
|
### 3. Flexbox Layout Patterns
|
|
|
|
**Horizontal layouts:**
|
|
```jsx
|
|
// Side-by-side content
|
|
<Box>
|
|
<Box width="50%">
|
|
<Text>Left panel</Text>
|
|
</Box>
|
|
<Box width="50%">
|
|
<Text>Right panel</Text>
|
|
</Box>
|
|
</Box>
|
|
|
|
// Label-value pairs
|
|
<Box>
|
|
<Text>Status: </Text>
|
|
<Box flexGrow={1}>
|
|
<Text color="green">Running</Text>
|
|
</Box>
|
|
</Box>
|
|
```
|
|
|
|
**Vertical layouts:**
|
|
```jsx
|
|
// Stacked content
|
|
<Box flexDirection="column">
|
|
<Text>Header</Text>
|
|
<Box flexGrow={1}>
|
|
<Text>Main content</Text>
|
|
</Box>
|
|
<Text>Footer</Text>
|
|
</Box>
|
|
```
|
|
|
|
## Ink-Specific Components
|
|
|
|
### 1. Text Component
|
|
- **All text must be wrapped in `<Text>`**
|
|
- Only text nodes and nested `<Text>` components allowed inside
|
|
- No `<Box>` or other components inside `<Text>`
|
|
|
|
```jsx
|
|
// ✅ Correct
|
|
<Text color="green">Success: <Text bold>Operation completed</Text></Text>
|
|
|
|
// ❌ Incorrect
|
|
<Text>Status: <Box><Text>Running</Text></Box></Text>
|
|
```
|
|
|
|
### 2. Box Component
|
|
- Primary layout component (like `<div>` but with `display: flex`)
|
|
- Supports Flexbox properties, padding, margin, borders
|
|
- Use for all layout and positioning
|
|
|
|
### 3. Static Component
|
|
- For content that doesn't change after rendering
|
|
- Useful for logs, completed tasks, permanent output
|
|
- Renders above dynamic content
|
|
|
|
```jsx
|
|
<Static items={completedTasks}>
|
|
{task => (
|
|
<Box key={task.id}>
|
|
<Text color="green">✓ {task.name}</Text>
|
|
</Box>
|
|
)}
|
|
</Static>
|
|
```
|
|
|
|
### 4. Spacer Component
|
|
- Flexible space that expands along the major axis
|
|
- Useful for pushing content to edges
|
|
|
|
```jsx
|
|
<Box>
|
|
<Text>Left</Text>
|
|
<Spacer />
|
|
<Text>Right</Text>
|
|
</Box>
|
|
```
|
|
|
|
## Input and Interaction
|
|
|
|
### Keyboard Input
|
|
```jsx
|
|
import {useInput} from 'ink';
|
|
|
|
const InteractiveComponent = () => {
|
|
useInput((input, key) => {
|
|
if (input === 'q') {
|
|
process.exit(0);
|
|
}
|
|
|
|
if (key.upArrow) {
|
|
// Handle up arrow
|
|
}
|
|
|
|
if (key.return) {
|
|
// Handle enter key
|
|
}
|
|
});
|
|
|
|
return <Text>Press 'q' to quit</Text>;
|
|
};
|
|
```
|
|
|
|
### Focus Management
|
|
```jsx
|
|
import {useFocus} from 'ink';
|
|
|
|
const FocusableComponent = () => {
|
|
const {isFocused} = useFocus();
|
|
|
|
return (
|
|
<Text color={isFocused ? 'blue' : 'white'}>
|
|
{isFocused ? '> ' : ' '}Focusable item
|
|
</Text>
|
|
);
|
|
};
|
|
```
|
|
|
|
## Performance Considerations
|
|
|
|
### 1. Minimize Re-renders
|
|
Terminal rendering is expensive - avoid unnecessary updates:
|
|
|
|
```jsx
|
|
// Use React.memo for stable components
|
|
const StatusLine = React.memo(({status}) => (
|
|
<Text color="blue">Status: {status}</Text>
|
|
));
|
|
|
|
// Debounce rapid updates
|
|
const [debouncedValue] = useDebounce(rapidlyChangingValue, 100);
|
|
```
|
|
|
|
### 2. Animation Considerations
|
|
```jsx
|
|
import {useAnimation} from 'ink';
|
|
|
|
const Spinner = () => {
|
|
const {frame} = useAnimation({interval: 80}); // Not too fast
|
|
const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
|
|
return <Text>{chars[frame % chars.length]}</Text>;
|
|
};
|
|
```
|
|
|
|
### 3. Control Frame Rate
|
|
```jsx
|
|
// Limit updates for better performance
|
|
render(<App />, {
|
|
maxFps: 30, // Default is 30, lower for less CPU usage
|
|
});
|
|
```
|
|
|
|
## Common Pitfalls and Solutions
|
|
|
|
### 1. Text Overflow
|
|
❌ **Problem:** Text exceeds terminal width
|
|
```jsx
|
|
<Text>Very long text that might overflow the terminal width causing display issues</Text>
|
|
```
|
|
|
|
✅ **Solution:** Use width constraints and wrapping
|
|
```jsx
|
|
<Box width="100%">
|
|
<Text wrap="wrap">Very long text that might overflow the terminal width causing display issues</Text>
|
|
</Box>
|
|
```
|
|
|
|
### 2. Nested Box Issues
|
|
❌ **Problem:** Unnecessary nesting causing layout issues
|
|
```jsx
|
|
<Box>
|
|
<Box>
|
|
<Box>
|
|
<Text>Over-nested content</Text>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
```
|
|
|
|
✅ **Solution:** Flatten structure when possible
|
|
```jsx
|
|
<Box padding={1}>
|
|
<Text>Properly structured content</Text>
|
|
</Box>
|
|
```
|
|
|
|
### 3. Color and Styling
|
|
❌ **Problem:** Assuming rich styling support
|
|
```jsx
|
|
<Text style={{fontSize: '16px', fontFamily: 'Arial'}}>Styled text</Text>
|
|
```
|
|
|
|
✅ **Solution:** Use Ink's supported styling props
|
|
```jsx
|
|
<Text color="blue" bold underline>Styled text</Text>
|
|
```
|
|
|
|
### 4. Dynamic Content Height
|
|
❌ **Problem:** Unlimited dynamic content
|
|
```jsx
|
|
{messages.map(msg => (
|
|
<Text key={msg.id}>{msg.content}</Text>
|
|
))}
|
|
```
|
|
|
|
✅ **Solution:** Implement scrolling or pagination
|
|
```jsx
|
|
const visibleMessages = messages.slice(-maxVisible);
|
|
return (
|
|
<Box flexDirection="column" height={maxVisible}>
|
|
{visibleMessages.map(msg => (
|
|
<Text key={msg.id}>{msg.content}</Text>
|
|
))}
|
|
</Box>
|
|
);
|
|
```
|
|
|
|
## Testing Terminal UIs
|
|
|
|
### 1. Use ink-testing-library
|
|
```jsx
|
|
import {render} from 'ink-testing-library';
|
|
|
|
const {lastFrame, stdin} = render(<MyComponent />);
|
|
|
|
// Test output
|
|
expect(lastFrame()).toMatch(/Expected text/);
|
|
|
|
// Test input
|
|
stdin.write('q');
|
|
expect(lastFrame()).toMatch(/Quit message/);
|
|
```
|
|
|
|
### 2. Test Different Terminal Sizes
|
|
```jsx
|
|
// Test with different widths
|
|
const {lastFrame} = render(<MyComponent />, {columns: 40});
|
|
expect(lastFrame()).toMatch(/Wrapped content/);
|
|
```
|
|
|
|
## Accessibility Considerations
|
|
|
|
### Screen Reader Support
|
|
```jsx
|
|
// Provide meaningful labels
|
|
<Box aria-role="checkbox" aria-state={{checked: true}}>
|
|
<Text>Accept terms</Text>
|
|
</Box>
|
|
|
|
// Use descriptive labels for progress indicators
|
|
<Box>
|
|
<Box width="50%" backgroundColor="green" />
|
|
<Text aria-label="Progress: 50%">50%</Text>
|
|
</Box>
|
|
```
|
|
|
|
## Best Practices Summary
|
|
|
|
1. **Always constrain content width** - Use `width` props or percentage widths
|
|
2. **Handle text wrapping explicitly** - Set appropriate `wrap` values
|
|
3. **Consider terminal size** - Use `useWindowSize()` for responsive layouts
|
|
4. **Minimize vertical content** - Implement pagination for long lists
|
|
5. **Use semantic structure** - Proper component hierarchy with `<Box>` and `<Text>`
|
|
6. **Test with different terminal sizes** - Ensure layouts work across screen sizes
|
|
7. **Optimize for performance** - Avoid unnecessary re-renders and high frame rates
|
|
8. **Provide keyboard navigation** - Implement proper focus management
|
|
9. **Consider accessibility** - Use ARIA labels where appropriate
|
|
10. **Handle edge cases** - Empty states, loading states, error conditions
|
|
|
|
## Example: Well-Structured Ink Component
|
|
|
|
```jsx
|
|
import React, {useState} from 'react';
|
|
import {Box, Text, useInput, useWindowSize, Spacer} from 'ink';
|
|
|
|
const TaskList = ({tasks}) => {
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const {columns} = useWindowSize();
|
|
|
|
useInput((input, key) => {
|
|
if (key.upArrow && selectedIndex > 0) {
|
|
setSelectedIndex(selectedIndex - 1);
|
|
}
|
|
if (key.downArrow && selectedIndex < tasks.length - 1) {
|
|
setSelectedIndex(selectedIndex + 1);
|
|
}
|
|
});
|
|
|
|
const maxWidth = Math.min(columns - 4, 80);
|
|
|
|
return (
|
|
<Box flexDirection="column" width={maxWidth}>
|
|
<Box borderStyle="round" padding={1}>
|
|
<Text bold>Task List ({tasks.length})</Text>
|
|
</Box>
|
|
|
|
<Box flexDirection="column" marginTop={1}>
|
|
{tasks.map((task, index) => (
|
|
<Box key={task.id} backgroundColor={index === selectedIndex ? 'blue' : undefined}>
|
|
<Text color={task.completed ? 'green' : 'white'}>
|
|
{task.completed ? '✓' : '○'}
|
|
</Text>
|
|
<Text> </Text>
|
|
<Box width="100%">
|
|
<Text wrap="truncate">{task.title}</Text>
|
|
</Box>
|
|
<Spacer />
|
|
<Text dimColor>{task.priority}</Text>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
<Box marginTop={1}>
|
|
<Text dimColor>Use ↑↓ to navigate</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|
|
```
|
|
|
|
This example demonstrates:
|
|
- Proper width constraints and responsive design
|
|
- Keyboard input handling
|
|
- Appropriate use of Ink components
|
|
- Text truncation for overflow handling
|
|
- Clear visual hierarchy and spacing
|
|
- Accessibility considerations with clear navigation hints |