PXI Terminal UI#
The PXI terminal UI is an Ink-based React interface for the pxi executable in js/packages/phoenix-cli. It provides an interactive chat session against the PXI server-agent endpoint — rendering a streaming conversation transcript, inline tool call progress, and a live "thinking" indicator — entirely in the terminal.
Key source files (all under js/packages/phoenix-cli/src/pxi/):
| File | Purpose |
|---|---|
App.tsx | Root Ink component — banner, transcript, input prompt, keyboard handling |
toolPresentation.ts | Pure presentation registry: icons, preview text, detail/error lines per tool |
toolProgress.ts | Derives ToolProgress from AI SDK UIMessageParts; merges ToolPresentation fields |
client.ts | Chat client wired to the PXI server-agent stream endpoint |
inkMarkdown.tsx | Markdown rendering inside Ink |
options.ts | Runtime options parsing and model selection |
preflight.ts | Pre-flight environment checks before launching the TUI |
App Structure and State#
PxiApp is the root component. It holds four pieces of state — messages, draft, status ("idle" | "streaming"), and error — and drives PxiChatClient to stream assistant replies.
Top-level layout:
PxiApp
├── PxiBanner — ASCII PXI wordmark (blueBright raised faces, gray shading)
├── status line — endpoint / model / session
├── Transcript — all conversation turns (color-coded "You" vs "PXI")
│ └── MessageParts — per-message: Text → Markdown, tool parts → InlineToolProgress
├── ThinkingIndicator — animated "PXI is thinking…" (250 ms frames, shown while streaming)
└── InputPrompt — single-line draft with block cursor; hints below the border
Keyboard bindings :
| Key | Action |
|---|---|
| Enter | Submit draft |
| Shift+Enter | Insert newline |
| Esc | Interrupt in-flight stream |
| Ctrl+C / Ctrl+D | Exit |
| Backspace / Delete | Remove last character |
When the user presses Esc mid-stream, the current assistant message is frozen with any completed parts kept intact and an [Interrupted by user before completion.] text suffix appended.
Tool Call Rendering#
Each tool part in a message renders as InlineToolProgress. The original implementation showed a single opaque [tool] bash Complete line; PR #14093 replaced it with a structured layout.
Layout of a tool row#
<state> <icon> <toolName> · <previewText> (<statusSuffix>)
<detailLines …> (dimmed)
<errorLines …> (red)
State glyph — rendered by ToolStateIndicator:
| State | Glyph | Color |
|---|---|---|
input-streaming, input-available, approval-responded | ⠋⠙⠹… (braille spinner) | yellow |
output-available (no error) | ✓ | green |
output-available + non-zero statusSuffix | ✗ | red |
output-error | ✗ | red |
approval-requested | ? | yellow |
output-denied | ⊘ | dimmed |
The spinner is ToolSpinner: a setInterval at 250 ms cycles through ten braille frames (⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏).
Spacing#
Consecutive tool calls stack compactly — marginTop/marginBottom are set to 0 when the adjacent part is also a tool call, and 1 at the boundary with text content.
Quiet tools#
load_skill and read_skill_resource collapse on completion to a single dim line, e.g. ✓ Loaded skill datasets, instead of a full tool row.
Tool Presentation Registry (toolPresentation.ts)#
toolPresentation.ts is a pure derivation layer — no side effects, no imports beyond ToolProgressState. It mirrors the web UI's per-tool registry in app/src/components/agent/ToolPart.tsx (which cannot be imported from this package).
ToolPresentation type#
type ToolPresentation = {
icon: string; // terminal-safe single-width BMP unicode
previewText: string; // one-line summary; empty until derivable
detailLines: string[]; // dimmed lines (e.g. bash command excerpt)
errorLines: string[]; // red lines (e.g. stderr)
statusSuffix?: string; // appended to header, e.g. "exit 1"
isQuiet: boolean; // collapse to single dim line when complete
quietLabel?: string; // text for the collapsed line
};
Per-tool icons and bespoke presenters#
| Tool | Icon | Preview source | Detail | Error |
|---|---|---|---|---|
bash | $ | input.summary (streams first), fallback first line of command | First 3 lines of command | exit N + 2-line stderr excerpt |
web_search | ⌕ | query / q / search_query field | — | — |
web_fetch | ↓ | url / uri / href field | — | — |
call_subagent | ◇ | name field | — | — |
load_skill | ✦ | skill_name | — | collapses quietly |
read_skill_resource | ✦ | skill_name/resource_name | — | collapses quietly |
| (all others) | ◆ | First non-empty string field among summary, description, query, name, path, url, prompt, text, command | — | error text clamped to 3 lines |
Clamping strategy#
All raw payload text is inspected after being clamped to MAX_SOURCE_LENGTH = 1000 characters, keeping the per-snapshot re-render O(1) even for large payloads. previewText is collapsed to a single line and truncated at 120 chars with …; detail lines are each capped at 200 chars.
getToolPresentation({ toolName, state, input, output, errorText }) is the single public entry point. toolProgress.ts calls it and spreads the result directly onto the ToolProgress object.
Tests and Extension Points#
Test files (under js/packages/phoenix-cli/test/):
pxiApp.test.tsx— Ink component tests; covers spinner rendering, bash-failure display, quiet-skill collapse, and the old black-box behavior regression.pxiToolPresentation.test.ts— 28 unit cases: streaming-partial inputs, per-tool previews, truncation/clamping, exit-code handling, and malformed inputs that must never throw.pxiClient.test.ts,pxiMarkdown.test.ts,pxiOptions.test.ts,pxiPreflight.test.ts— coverage for the remaining modules.
Adding a new tool presenter: Register a ToolPresenter function in the TOOL_PRESENTERS map in toolPresentation.ts. The function receives { state, input, output, errorText } and returns a Partial<ToolPresentation>. Raw JSON is never printed — always derive display text from named input fields.