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.