← Pi Agents

Appendix B — The Terminal Interface, in Detail

Reference for the terminal UI as a layered system: reporting protocols, the pi-tui component model, keybindings, themes, and per-terminal fixes. Moved out of the main line because it describes the interface, not the agent.

This is reference material for the pinned Pi 1.0.4 release. Chapter 31 explains why the terminal is an interface to the agent and not part of it. This appendix is what you need if you are building a terminal component, tuning a keybinding or debugging a terminal that will not report a key. Terminal and multiplexer versions matter as well as Pi’s version; verify a reported workaround against the actual terminal path. If you are not doing one of those tasks, you can skip this appendix without losing anything in the main line.

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. Each symptom can originate at a different layer. If a terminal sends the same bytes for Shift+Enter and Enter, Pi cannot infer which key was pressed; but a terminal may also send a distinguishable sequence that Pi or an extension handles incorrectly. Reproduce the failure with the pinned Pi release, a known terminal configuration and, where possible, extensions disabled before attributing it to any layer. The advice below is to locate the boundary where information is lost, not to close a bug report on suspicion.

The layers

From the bottom up:

  1. The terminal emulator. Receives physical key and pointer input, encodes terminal input sequences, and answers supported capability queries.
  2. The reporting protocols. Extended-key and Kitty keyboard sequences carry modifier information; OSC 8 supplies hyperlink metadata; inline-image protocols and truecolor control output rendering.
  3. Pi’s terminal component system — @earendil-works/pi-tui. Interprets input, renders terminal-column-width lines, and manages component focus.
  4. Pi’s interactive mode. The transcript, editor, footer, and session picker built on that system.
  5. Your configuration — keybindings.json, settings.json, and theme files, which alter how interactive mode behaves.
  6. Your extensions, which may register UI components and handlers through ctx.ui.

These are troubleshooting concerns, not six serial middleware stages: configuration and extensions feed into interactive mode, and input travels from the terminal to Pi while rendered output travels back to the terminal.

Each layer has a documented failure mode, and the useful question when something breaks is always “which layer is it?”

    flowchart TD
    A["Terminal emulator: reports input"] --> B["Input sequences and capabilities"]
  B --> C["pi-tui: parses input and renders output"]
  C --> D["Pi interactive mode"]
  E["Configuration"] --> D
  F["Extensions"] --> D
  D --> C
  C -->|"ANSI output"| A
  B -.->|"modifier missing"| G["Pi cannot distinguish Shift+Enter from Enter"]
  

The solid arrows show input, output and configuration paths rather than a single upward dependency chain. The dotted edge is the failure this appendix helps diagnose: if a terminal or intermediary removes the modifier distinction, a keybinding inside Pi cannot recover it. If the distinction survives to Pi, the parser and keybinding configuration are still worth checking.

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. A theme change clears Pi’s rendering caches, but it cannot recolour ANSI-styled strings that your extension has already stored as application state. Recompute theme-dependent strings during rendering or when invalidate() rebuilds them; do not cache them indefinitely.

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.”

Mouse interaction is mode-dependent. In fullscreen mode Pi routes normalized mouse events to components, unhandled wheel events can scroll a ScrollView, and OSC 8 links take precedence over surrounding click regions. In regular mode the terminal owns mouse input and scrollback. A component that works only when clicked therefore needs a keyboard path; a MouseRegion alone does not promise working mouse input in every mode.

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

These ctx.ui APIs receive 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.” This is an interactive-terminal integration ladder, not a promise that every method works in headless or RPC mode. RPC supports a separate dialog subprotocol but degrades or omits several custom terminal APIs (chapter 35); ctx.hasUI alone does not establish that ctx.ui.custom() can display a terminal component.

Focus is not decoration

tui.md documents two rules that are especially visible with input method editors (IMEs), including Chinese, Japanese and Korean input, but can also expose ordinary keyboard-focus bugs:

  • A component displaying a text cursor should implement Focusable and place CURSOR_MARKER immediately before its visual cursor. The TUI uses that marker to position the hardware cursor for input method editors.
  • Containers wrapping an Input or Editor must propagate their focused state to that child, or IME candidate windows appear in the wrong place.

For terminals where IME candidates do not track the text cursor, PI_HARDWARE_CURSOR=1 or showHardwareCursor: true can help with cursor positioning, as documented in terminal-setup.md. This does not substitute for propagating focused to a wrapped Input or Editor: the setting affects cursor display, whereas focus propagation tells the TUI which component owns the insertion point.

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. End the interaction through the completion callback supplied to ctx.ui.custom(), which resolves its promise and disposes the component. For an overlay created by ctx.ui.custom(), use the documented handle’s setHidden() for temporary visibility changes; do not call OverlayHandle.hide() as a substitute for completing it.

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 bindings for that action; it does not disable the physical key in the terminal or prevent another action from using it in a different context. Here, tui.altScreen.pageUp: [] removes the fullscreen paging binding, not the editor’s independent Page Up behavior. Terminal interception can still prevent Pi from receiving a configured shortcut.

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 Pi’s active action-to-key mappings; it does not prove that the terminal actually forwards a particular modifier sequence. If /hotkeys lists the intended binding but pressing it still does the wrong thing, check the terminal, SSH or multiplexer, and any IDE-level shortcut before editing the same Pi binding again.

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 (the default agent directory is ~/.pi/agent) whose name is unique, is not system, and contains no /. Define its colours by semantic roles rather than individual components. Values 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 rejects a theme with a missing or circular reference. A terminal-default colour may need a reported terminal colour — or a light/dark fallback guess — when used for export or colour calculations.

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. On macOS, WezTerm and Alacritty may intercept Option+Enter / Alt+Enter; the following is a complete WezTerm Lua configuration example for forwarding it, not an Alacritty configuration. For Alacritty use its TOML keyboard.bindings syntax from terminal-setup.md:

local wezterm = require("wezterm")
local config = wezterm.config_builder()
config.enable_kitty_keyboard = true
config.keys = {
  { key = "Enter", mods = "ALT", action = wezterm.action.SendString("\x1b[13;3u") },
}
return config

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.

For the terminal versions and configurations covered by the pinned terminal-setup.md, xfce4-terminal, Terminator, and IntelliJ IDEA’s built-in terminal do not reliably distinguish modified Enter from plain Enter. Use Ctrl+J for a newline or a terminal with suitable extended-key support. Do not treat that list as a permanent verdict on every future release of those terminals.

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.

A theme can look different across terminals because of their reported palettes, light/dark appearance and truecolor support. That is separate from HTML export: Pi converts OKHSL colours to hexadecimal for CSS, and export rendering is not evidence that the terminal negotiated the same colours. If on-screen colours differ from the theme source, check terminal detection and contrast settings; if only the exported page differs, inspect export-specific colours and browser rendering instead.

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 — in interactive terminal mode, not merely whenever ctx.hasUI is true.

For an actual terminal bug, reproduce in this order: identify the terminal and any SSH, tmux or IDE intermediary; check whether the terminal sends a distinct input sequence; compare that with /hotkeys and keybindings.json; disable custom extensions and themes to isolate Pi from integration code; then rerun on the pinned Pi version in regular and fullscreen mode as appropriate. For rendering defects, test narrow widths, wide or combining characters, theme changes and resize events; PI_TUI_WRITE_LOG can capture the raw ANSI output for diagnosis. These checks separate “Pi did not receive the input” from “Pi received it but handled it incorrectly,” which is the distinction a useful bug report needs.