|
| 1 | +# Terminal Alternate Screen Buffer Pattern |
| 2 | + |
| 3 | +When building CLI applications with full-screen UIs (like TUI apps), use the alternate screen buffer to prevent UI output from polluting the user's terminal scrollback when the app exits. |
| 4 | + |
| 5 | +## The Problem |
| 6 | + |
| 7 | +By default, terminal applications write to the main screen buffer. When a full-screen CLI app exits, all its UI output remains in the terminal scrollback, cluttering the user's terminal history. This is annoying for users who expect clean terminal behavior like vim, less, htop, and other well-behaved CLI tools. |
| 8 | + |
| 9 | +## The Solution: Alternate Screen Buffer |
| 10 | + |
| 11 | +Terminals support an alternate screen buffer that can be entered/exited using ANSI escape sequences: |
| 12 | + |
| 13 | +- **Enter alternate screen:** `\x1b[?1049h` (smcup) |
| 14 | +- **Exit alternate screen:** `\x1b[?1049l` (rmcup) |
| 15 | + |
| 16 | +When you enter the alternate screen buffer, the terminal saves the current screen content. When you exit, it restores the original content, leaving the scrollback clean. |
| 17 | + |
| 18 | +## Implementation Pattern |
| 19 | + |
| 20 | +### 1. Define the Escape Sequences |
| 21 | + |
| 22 | +```typescript |
| 23 | +// Terminal alternate screen buffer escape sequences |
| 24 | +export const ENTER_ALT_BUFFER = '\x1b[?1049h' |
| 25 | +export const EXIT_ALT_BUFFER = '\x1b[?1049l' |
| 26 | +``` |
| 27 | + |
| 28 | +### 2. Enter Before Rendering |
| 29 | + |
| 30 | +Enter the alternate screen buffer BEFORE initializing your UI renderer: |
| 31 | + |
| 32 | +```typescript |
| 33 | +export function enterAlternateScreen(): void { |
| 34 | + if (process.stdout.isTTY) { |
| 35 | + process.stdout.write(ENTER_ALT_BUFFER) |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +async function main(): Promise<void> { |
| 40 | + // Enter alternate screen buffer BEFORE rendering the app |
| 41 | + if (process.stdout.isTTY) { |
| 42 | + enterAlternateScreen() |
| 43 | + } |
| 44 | + |
| 45 | + // Initialize your UI renderer after entering alternate buffer |
| 46 | + const renderer = await createCliRenderer({ ... }) |
| 47 | + // ... rest of app initialization |
| 48 | +} |
| 49 | +``` |
| 50 | + |
| 51 | +### 3. Exit During Cleanup |
| 52 | + |
| 53 | +Ensure the alternate screen buffer is exited during all cleanup scenarios: |
| 54 | + |
| 55 | +```typescript |
| 56 | +const TERMINAL_RESET_SEQUENCES = |
| 57 | + EXIT_ALT_BUFFER + // Exit alternate screen buffer (restores main screen) |
| 58 | + '\x1b[?1000l' + // Disable X10 mouse mode |
| 59 | + '\x1b[?1002l' + // Disable button event mouse mode |
| 60 | + // ... other terminal reset sequences |
| 61 | + '\x1b[?25h' // Show cursor |
| 62 | + |
| 63 | +function resetTerminalState(): void { |
| 64 | + try { |
| 65 | + process.stdout.write(TERMINAL_RESET_SEQUENCES) |
| 66 | + } catch { |
| 67 | + // Ignore errors - stdout may already be closed |
| 68 | + } |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +### 4. Handle All Exit Scenarios |
| 73 | + |
| 74 | +Register cleanup handlers for all possible exit scenarios: |
| 75 | + |
| 76 | +```typescript |
| 77 | +process.on('SIGTERM', cleanup) |
| 78 | +process.on('SIGHUP', cleanup) |
| 79 | +process.on('SIGINT', cleanup) |
| 80 | +process.on('beforeExit', cleanup) |
| 81 | +process.on('exit', cleanup) |
| 82 | +process.on('uncaughtException', cleanup) |
| 83 | +process.on('unhandledRejection', cleanup) |
| 84 | +``` |
| 85 | + |
| 86 | +## Key Considerations |
| 87 | + |
| 88 | +### TTY Detection |
| 89 | + |
| 90 | +Only enter alternate screen buffer in interactive terminals: |
| 91 | + |
| 92 | +```typescript |
| 93 | +if (process.stdout.isTTY) { |
| 94 | + enterAlternateScreen() |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +This prevents issues when: |
| 99 | +- Output is piped to a file (`app > output.txt`) |
| 100 | +- Running in CI/automated environments |
| 101 | +- Output is redirected or captured |
| 102 | + |
| 103 | +### Timing is Critical |
| 104 | + |
| 105 | +1. **Enter alternate buffer FIRST** - before any UI initialization |
| 106 | +2. **Exit alternate buffer LAST** - as part of terminal reset sequences |
| 107 | +3. **Write exit sequence directly to stdout** - don't rely on UI renderer cleanup |
| 108 | + |
| 109 | +### Terminal Compatibility |
| 110 | + |
| 111 | +The `?1049` sequence is widely supported by modern terminals: |
| 112 | +- xterm, gnome-terminal, iTerm2, Terminal.app |
| 113 | +- tmux, screen (with proper configuration) |
| 114 | +- Windows Terminal, ConEmu |
| 115 | + |
| 116 | +Very old terminals may not support it, but the TTY check provides a reasonable fallback. |
| 117 | + |
| 118 | +## Integration with UI Frameworks |
| 119 | + |
| 120 | +### OpenTUI Example |
| 121 | + |
| 122 | +```typescript |
| 123 | +import { createCliRenderer } from '@opentui/core' |
| 124 | + |
| 125 | +async function main(): Promise<void> { |
| 126 | + // Enter alternate screen BEFORE creating renderer |
| 127 | + if (process.stdout.isTTY) { |
| 128 | + enterAlternateScreen() |
| 129 | + } |
| 130 | + |
| 131 | + const renderer = await createCliRenderer({ |
| 132 | + backgroundColor: 'transparent', |
| 133 | + exitOnCtrlC: false, |
| 134 | + }) |
| 135 | + |
| 136 | + // Install cleanup handlers that exit alternate screen |
| 137 | + installProcessCleanupHandlers(renderer) |
| 138 | + |
| 139 | + // Render your app |
| 140 | + createRoot(renderer).render(<App />) |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +### Ink.js Example |
| 145 | + |
| 146 | +```typescript |
| 147 | +import { render } from 'ink' |
| 148 | + |
| 149 | +function main() { |
| 150 | + if (process.stdout.isTTY) { |
| 151 | + enterAlternateScreen() |
| 152 | + } |
| 153 | + |
| 154 | + const { unmount } = render(<App />) |
| 155 | + |
| 156 | + // Ensure cleanup on exit |
| 157 | + process.on('exit', () => { |
| 158 | + unmount() |
| 159 | + resetTerminalState() |
| 160 | + }) |
| 161 | +} |
| 162 | +``` |
| 163 | + |
| 164 | +## Testing |
| 165 | + |
| 166 | +To verify alternate screen buffer works correctly: |
| 167 | + |
| 168 | +1. **Before running your CLI:** Note some text in your terminal scrollback |
| 169 | +2. **Run your CLI:** The UI should appear in a clean screen |
| 170 | +3. **Exit your CLI:** You should return to the exact terminal state from step 1 |
| 171 | +4. **Check scrollback:** The UI output should not appear in your scrollback history |
| 172 | + |
| 173 | +## Common Mistakes |
| 174 | + |
| 175 | +❌ **Entering alternate buffer too late** - after UI initialization |
| 176 | +❌ **Not checking TTY status** - breaks piped output |
| 177 | +❌ **Forgetting exit sequences** - leaves terminal in alternate buffer |
| 178 | +❌ **Not handling all exit scenarios** - cleanup only works for normal exit |
| 179 | +❌ **Relying on UI framework cleanup** - may not run if framework crashes |
| 180 | + |
| 181 | +## When to Use |
| 182 | + |
| 183 | +Use alternate screen buffer for: |
| 184 | +- Full-screen TUI applications |
| 185 | +- Interactive CLI tools with complex UIs |
| 186 | +- Any CLI that renders multiple lines of output that users don't need to reference later |
| 187 | + |
| 188 | +Don't use for: |
| 189 | +- Simple command-line tools with minimal output |
| 190 | +- Tools where users need to reference output after exit |
| 191 | +- Log viewers or tools that should integrate with terminal scrollback |
0 commit comments