The Terminal Interface
The terminal UI as a layered system — keybindings, themes, the pi-tui component model, and the protocol layers your terminal must actually report.
Shift+Enter submits instead of inserting a line. Your custom theme is unreadable in someone else’s terminal. A click handler works for you and not for the person who filed the bug.
None of these is a Pi bug, and each has a documented cause. The terminal interface is not one thing — it is a stack, and at every layer there is a contract that can be satisfied or not. Knowing which layer failed is most of the work.
The layers
From the bottom up:
- The terminal emulator. Decodes escape sequences, reports capabilities, forwards key events.
- The reporting protocols. Extended-key protocols, the Kitty keyboard protocol, OSC 8 hyperlinks, inline image protocols, truecolor.
- Pi’s terminal component system —
@earendil-works/pi-tui. Renders arrays of lines for a width, handles input, manages focus. - Pi’s interactive mode. The transcript, editor, footer, and session picker built on that system.
- Your configuration —
keybindings.json,settings.json, and theme files. - Your extensions, which reach the same system through
ctx.uiand custom components.
Each layer has a documented failure mode, and the useful question when something breaks is always “which layer is it?”
flowchart TD
A["1 Terminal emulator"] --> B["2 Reporting protocols"]
B --> C["3 pi-tui components"]
C --> D["4 Interactive mode"]
D --> E["5 Your configuration"]
E --> F["6 Your extensions"]
B -. "cannot report Shift+Enter" .-> G["Every layer above inherits plain Enter"]
Arrows mean up the stack, and the dotted edge is the one that earns this chapter. When a terminal cannot distinguish a modified Enter from a plain one, nothing above layer 2 can repair it — layers 3 to 6 all receive the same key and disagree with you about what it meant.
Layer 3: what a component actually is
tui.md is unusually precise here, and the precision is the whole design:
A component renders an array of terminal lines for an available width. It can optionally handle keyboard and mouse input, and it must invalidate cached output when its state or theme-dependent content changes.
Three consequences follow.
Width is measured in columns, not characters. tui.md requires every rendered line to fit within the supplied width and says to measure visible terminal columns rather than string length, because ANSI escapes, wide characters, emoji, and combining characters change display width. That is why visibleWidth(), truncateToWidth(), sliceByColumn(), and wrapTextWithAnsi() are exported rather than left to you.
Styling is per line. Pi resets styling and hyperlinks after every line, so styles must be reapplied on each rendered line. A component that styles once and reuses the string will lose its colours halfway down a long output.
Invalidation is explicit. After changing state you invalidate the affected component and call the injected tui.requestRender(). The TUI coalesces render requests.
The package ships the components you would otherwise rebuild: Text, Markdown, Image, TruncatedText; Container, VStack, HStack, Box, Spacer; Input and Editor; SelectList and SettingsList; ScrollView; Loader and CancellableLoader; MouseRegion. tui.md is direct about this — prefer these over rebuilding selection, scrolling, text editing, or width handling — and adds a rule that is worth internalising: “Do not create a second terminal renderer inside an extension.”
The UI ladder, and where its rungs stop
tui.md gives an explicit integration table, and it is the same ladder the custom-UI chapter built, now with the reason each rung exists:
| Need | Use |
|---|---|
| Select, confirm, input, multi-line editor | ctx.ui.select(), confirm(), input(), editor() |
| Non-blocking feedback | ctx.ui.notify() or setStatus() |
| Persistent content near the editor | ctx.ui.setWidget() |
| Replace header, footer, or editor | The corresponding ctx.ui component factory |
| Temporary interactive screen or overlay | ctx.ui.custom() |
| Custom rendering for a tool or session entry | An extension renderer |
Each of these ctx.ui APIs receives Pi’s active theme and keybindings where needed. Build a custom component “only when the UI needs its own rendering, keyboard or mouse input, focus, layout, or lifecycle.”
Focus is not decoration
tui.md documents two rules that only bite on non-English input:
- A component displaying a text cursor should implement
Focusableand placeCURSOR_MARKERimmediately before its visual cursor. The TUI uses that marker to position the hardware cursor for input method editors. - Containers wrapping an
InputorEditormust propagate theirfocusedstate to that child, or IME candidate windows appear in the wrong place.
That second failure has a documented escape hatch for WSL and IntelliJ’s terminal: PI_HARDWARE_CURSOR=1, or showHardwareCursor: true in settings. The proper fix remains propagating focused.
If you replace the main editor, extend Pi’s CustomEditor — tui.md says this preserves application shortcuts and agent controls. Forward keys your editor does not own to the base implementation, and restore the default by clearing the custom editor factory.
Overlays own input
ctx.ui.custom() gives one component control of the interactive area and resolves when that component calls the supplied completion callback. With overlay: true it draws above existing content, and the handle can change focus or hide and show the overlay with setHidden().
Two rules prevent the common bug. Focused overlays retain input ownership across ordinary renders, so if another component should receive input you must explicitly release or redirect focus. And each custom component instance belongs to one interaction — create a new instance when starting that interaction again.
Layer 5: keybindings are named actions
keybindings.md describes an indirection that is worth internalising: Pi exposes named actions such as app.session.new, and keybindings map to those names.
{
"app.session.new": "ctrl+shift+n",
"app.session.tree": ["ctrl+shift+t", "alt+shift+t"],
"tui.altScreen.pageUp": []
}
A configured value replaces the default for that action. An empty list disables the action’s keybindings entirely, which is how you take a key away from the fullscreen transcript without losing the editor binding.
Three details are practical rather than obvious. super bindings need a terminal that reports the modifier separately, typically through the Kitty keyboard protocol, so they may not work everywhere. The dedicated history actions tui.editor.historyPrevious and historyNext “browse prompt history regardless of cursor position and take precedence over application actions using the same key.” And several defaults are platform-dependent — app.suspend is ctrl+z with no default on native Windows, tui.editor.undo is ctrl+- except ctrl+z on Windows and alt+z on WSL, and app.message.followUp is alt+enter except ctrl+q on Windows and WSL.
After editing keybindings.json, run /reload. /hotkeys shows what is actually active — and it is the authority when the documentation and your terminal disagree.
Themes are roles, not components
themes.md is careful about the word “theme”. Pi ships system, dark, and light. system is the default, and it does not bring a palette at all: Pi queries the terminal’s default foreground and background plus its 16 ANSI colours, takes each Pi colour’s hue from one ANSI colour, and sets lightness so it clears a minimum contrast — body text keeps at least a 4.5:1 WCAG contrast ratio on the background and every panel. When the terminal switches between light and dark, Pi queries again and rebuilds.
That is why system is a reserved name and a custom theme with that name is ignored. It is not a palette; it is a derivation.
There is a documented degradation ladder. If the terminal reports background and ANSI colours, Pi uses its palette. If it reports background only, Pi uses its own hues placed for the actual background. If it reports nothing, Pi falls back to ANSI colour indices and terminal defaults, secondary text is faint, and panels have no background colour. Pi waits at most 100 ms for the terminal’s answer before showing the startup header, and applies the colours if they arrive later.
A custom theme is a JSON file in <agent-dir>/themes/my-theme.json with a unique name that is not system and contains no /. Colours accept six forms — RGB hex, OKLCH, OKHSL, a 256-colour index, a vars reference, or "" for the terminal default. Pi resolves chained variable references and makes a missing variable or circular reference invalid.
Hot-reload applies only to <agent-dir>/themes/<name>.json; run /reload after adding a theme from any other source. Project themes live in .pi/themes/ and load only after project trust is granted.
Layer 2: what your terminal has to report
terminal-setup.md opens with the sentence that explains most of the odd behaviour people report: “Pi uses extended-key protocols so terminals can distinguish combinations such as Shift+Enter and Alt+Enter from plain Enter.” Terminal proxies, multiplexers, and built-in IDE terminals can change or discard that information.
The documented fixes are per-terminal and specific. Kitty needs nothing. iTerm2 needs “Trackpad scrolls fast?” set to No, because in fullscreen mode Pi owns the viewport so iTerm2 sends mouse-wheel reports instead of scrolling native history. Ghostty needs keybind = alt+backspace=text:\x1b\x7f if Alt+Backspace fails, and needs Shift+Command held for link hover previews while Pi captures the mouse. WezTerm and Alacritty need Option+Enter / Alt+Enter forwarded explicitly, because both bind it to fullscreen on macOS:
config.enable_kitty_keyboard = true
config.keys = {
{ key = 'Enter', mods = 'ALT', action = wezterm.action.SendString('\x1b[13;3u') },
}
Windows Terminal binds Alt+Enter to fullscreen by default, which is exactly why Pi’s documented default for app.message.followUp is ctrl+q there rather than alt+enter.
Two documented traps deserve repeating. Apple Terminal’s modifier fallback “works only when Pi runs on the same Mac as Terminal.app. It cannot inspect the local modifier state when Pi runs on another machine over SSH.” And a Ghostty mapping of shift+enter=text:\n sends a raw linefeed that Pi cannot distinguish from Ctrl+J — remove it, because Pi already binds Ctrl+J as a newline alternative and the mapping appears to work while still breaking Shift+Enter.
xfce4-terminal, Terminator, and IntelliJ IDEA’s built-in terminal cannot reliably distinguish modified Enter from plain Enter at all. The documentation’s answer is to use a different terminal or Ctrl+J, not to work around it.
Layer 2b: overriding detection
Three capabilities are auto-detected and can be overridden, with settings taking precedence over environment variables:
| Capability | Environment variable | Setting |
|---|---|---|
| Hyperlinks | PI_HYPERLINKS=1|0|auto |
terminal.hyperlinks |
| Inline images | PI_IMAGE_PROTOCOL=kitty|iterm2|none|auto |
terminal.images |
| Truecolor | PI_TRUE_COLOR=1|0|auto |
terminal.trueColor |
The warning that goes with the table matters more than the table: only force a capability supported by the complete terminal path, because “Unsupported escape sequences can corrupt rendering.” Forcing truecolor through a proxy that drops it produces garbage rather than a fallback.
This is also why a theme can look right on your machine and wrong on a colleague’s: HTML exports convert OKHSL values to hexadecimal because CSS does not support them, and if colours differ from their source values themes.md tells you to check truecolor detection and contrast settings.
Choosing a rung honestly
The reusable decision is: start at the lowest rung whose capability matches the requirement. Dialogs cover selection, confirmation, text input, and multi-line editing. Notifications and status cover feedback. Widgets cover persistent content near the editor. ctx.ui.custom() is for a screen or overlay that genuinely needs its own rendering, input, focus, layout, or lifecycle — and it exists only when a real terminal is attached.
Everything on that ladder is also the reason chapter 30’s mode-only failures are predictable. And the layer you have just read is the one a person uses. The next chapter is the one a script uses.
Next: Headless Pi — the same agent with no person attached, invoked once and exited.