Skip to content

Object model

What belongs to the Pi TUI alone: the view state it draws, the actions a user takes, the screen regions that show them, the components that draw them, and the visual rules. This page owns the app’s design vocabulary. The shared nouns, such as session, input, queue, run, turn, tool call, user turn, async task, and the transcript and its view, are defined in the runtime’s object model and named in the glossary; this page uses them without redefining them. Pi core uses this page’s names in its view-model types (apps/pi-core/src/view-model.ts); the HTML design reference and the app’s pi-tui components use the same component names. Pi core is the frontend’s backend; the pi --mode rpc process it runs is the Pi process.

The MVP draws every assistant message. Drawing only each user turn’s final answer waits on the user-turns package (decision). It exists, but it still runs under tui-display’s gates; running it in every mode, and drawing from it here, are later changes.

Each view-model object lists what the TUI holds, its states, and where its data comes from. An object named after a shared noun holds the TUI’s copy of it. RPC names refer to the commands and events in Pi’s docs/rpc-commands.md, docs/json.md, and docs/rpc-extension-ui.md (Pi 1.3.0).

ObjectWhat it isStatesSource
Pi processThe pi --mode rpc process Pi core runs for the TUI (PiProcessView).starting, ready, exited (code, signal, stderr tail)Pi core’s transport
SessionThe session the Pi process holds: id, file, name, cwd, repository, branch.One at a time; a replacement swaps itget_state; the session file’s header for the cwd; session_info_changed
ModelThe selected provider and model, with its reasoning flag and context window.Selected, or noneget_state, set_model, get_available_models
Thinking levelThe model’s effort: off to max.One value; off until get_state reports it, because initialViewState starts thereget_state, thinking_level_changed
Session stateWhat the session is doing: idle, or busy and what it is busy with (SessionStateView). It describes the session, not one run.idle, streaming, compacting, retrying; a retry’s message is constant text with at most an HTTP status, never the provider’sagent_start, agent_settled, compaction_*, auto_retry_*
BlockOne drawn transcript entry: a user prompt, an assistant message, an extension message, a compaction, a notice, or a session break.An assistant block is streaming until message_end, then done, failed, or aborted. A failed or aborted one ends with a constant tail (errorText) that never carries the provider’s textEvents live; get_entries for history
Tool callOne tool call inside an assistant block. Pi runs a message’s calls only after the message ends, then streams the next message: a call runs inside a settled block, and the answer is a block of its own.pending (arguments streaming, or waiting to run), running, done, failed (also when the session settles before the call finishes)toolcall_* deltas, tool_execution_*
QueueThe session’s queue: steers and follow-ups waiting for their boundary.Empty or holding textqueue_update, clear_queue
DraftThe editor’s text before it is sent.Empty or holding textLocal; set_editor_text replaces it
DialogAn extension’s question: select, confirm, input, or editor.open, then answered, cancelled, or expiredextension_ui_request; Pi core’s own timer for timeout
NoticeA short message: an extension notify, an extension_error, a protocol error, or the TUI’s own hint.Live for a few seconds; errors also stay in the transcript view as notice blocksextension_ui_request, events, Pi core, the TUI
Status entryA keyed text an extension sets for the footer.Set or clearedsetStatus
WidgetKeyed lines an extension places above or below the editor.Set or clearedsetWidget (string arrays only); keys starting pi-frontend:v1: are the frontend bridge’s data, never widgets
Work statusWork-status rows by owner (workStatus): shell tasks, subagents, and persistent jobs; not drawn yet.A row is unfinished (pending, active, waiting, serving) or finished; a finished row the owner drops stays until the session settlesThe frontend bridge’s widgets
CommandA slash command: an extension command, a prompt template, a skill, or a TUI built-in.Listedget_commands, plus the built-ins below
UsageSession cost and context use.Known or unknownget_session_stats, refreshed after each settle and compaction
ViewportThe transcript view’s scroll position.following the end, or scrolled away with a count of rows below the viewLocal
SelectionTranscript-view text the mouse selected.None, or a range copied with OSC 52Local

The session cost excludes subagent children, unlike the native footer. A child’s cost is not on the RPC stream. Async tasks, both shell tasks and subagents, are not MVP objects; they arrive Later. Pi core already keeps their work-status rows; drawing them waits for accepted design frames.

Keys follow Pi’s keybinding ids and defaults (Pi docs/keybindings.md). “Later” means Milestone B; “not planned” means no current intent.

ActionActs onKeyPi idRPCMilestone
SendDraftEntertui.input.submitprompt; while the session is busy, prompt with streamingBehavior: "steer"MVP
New lineDraftShift+Enter, Ctrl+Jtui.input.newLine—MVP
Queue a follow-upDraftAlt+Enterapp.message.followUpprompt with streamingBehavior: "followUp"; sends normally when idleMVP
Restore the queueQueueAlt+Upapp.message.dequeueclear_queue, text returns to the draftMVP
InterruptRunEsc, after the dialog, the command list, and the selection (see Esc precedence)app.interruptclear_queue, then abort; queued text returns to the draft at once, without waiting for Pi to answer the abortMVP
Clear, then exitDraftCtrl+C outside a dialog: clears the draft; a second press within 500 ms exits, as in Piapp.clear—MVP
ExitPi processCtrl+D on an empty draftapp.exitstdin closedMVP
Complete a commandCommandTab, or Enter in the listtui.input.tab—MVP
Scroll a pageViewportPgUp, PgDntui.altScreen.pageUp, pageDown—MVP
ScrollViewportMouse wheel——MVP
Jump to the topViewportCtrl+Hometui.altScreen.top—MVP
Jump to the end and followViewportCtrl+Endtui.altScreen.bottom—MVP
Select and copySelectionMouse drag (copy on release)——MVP
Answer a dialogDialogEnter; Up/Down to choose; Esc or Ctrl+C cancelstui.select.* (tui.select.cancel is Esc and Ctrl+C)extension_ui_responseMVP
Pick a modelModelCtrl+L, or /modelapp.model.selectget_available_models, set_modelMVP
Cycle the modelModelCtrl+Papp.model.cycleForwardcycle_modelMVP
Cycle the model backwardModelShift+Ctrl+Papp.model.cycleBackwardnone in RPC: cycle_model only goes forwardLater
Cycle the thinking levelThinking levelShift+Tabapp.thinking.cyclecycle_thinking_levelMVP
Set the thinking levelThinking level/thinking—set_thinking_levelMVP
New sessionSession/newapp.session.newnew_sessionMVP
CompactSession/compact [instructions]—compactMVP
Name the sessionSession/name <name>—set_session_nameMVP
QuitPi process/quit—stdin closedMVP
PasteDraftTerminal paste; more than ten lines become a [paste #1 +12 lines] marker, sent in full——MVP
EditingDraftPi’s tui.editor.* defaults: Ctrl+A/E, Ctrl+B/F, Alt+B/F, Ctrl+W, Alt+D, Ctrl+U, Ctrl+K, Ctrl+- undotui.editor.*—MVP
Yank and yank-popDraftCtrl+Y, Alt+Ytui.editor.yank*—MVP
Prompt historyDraftUp/Down at the draft’s edgetui.editor.cursorUp/Down—MVP
External editorDraftCtrl+Gapp.editor.external—Later
Paste an imageDraftCtrl+Vapp.clipboard.pasteImageprompt with imagesLater
Copy the last answerBlockCtrl+Xapp.message.copyget_last_assistant_textLater
Tool and thinking peekBlockCtrl+O, Ctrl+Tapp.tools.expand, app.thinking.toggle—Later
SuspendTUICtrl+Zapp.suspend—Later
Jump to the previous or next promptViewportCtrl+Shift+Up, Ctrl+Shift+Downtui.altScreen.previousPrompt, nextPrompt—Later
Search the transcript viewViewportCtrl+Shift+Ftui.altScreen.search—MVP: pi-tui’s search, over the mounted window only
Resume, tree, fork, cloneSession/resume, /tree, /fork, /cloneapp.session.*switch_session, get_tree, fork, cloneLater
ReloadPi process/reload—none in RPCLater

Esc does the first of these that applies, and only that:

  1. Cancel the open dialog: it owns the keyboard.
  2. Close the command list.
  3. Clear the selection.
  4. Interrupt the run, while the session is busy.

Ctrl+C cancels an open dialog too, as Pi’s tui.select.cancel does. Outside a dialog it clears the draft, and a second Ctrl+C within 500 ms exits.

  • Tab completes a command; it does not move focus. Focus stays in the draft, and the transcript view scrolls by wheel and keys without focus.
  • Ctrl+Z is not undo; undo is Ctrl+- as in Pi. Ctrl+Z is reserved for suspend.
  • Ctrl+Y is not redo; it yanks, as in Pi.
  • Ctrl+C never aborts; Esc interrupts.
  • Alt+Enter is never a new line.

Top to bottom, full terminal width:

RegionShowsDefaultDegrades at 70 columns
Transcript viewBlocks, with tool calls as one line eachFills the space above the bottom regions; follows the endText wraps; tool lines truncate their argument summary first
DialogThe head of the dialog slot: a bordered box, one at a time; the rest wait in orderHidden when no dialog is open; the transcript view gives up its rows to itThe box narrows to the width
Notice rowThe newest live noticeHidden when no notice is liveTruncates with an ellipsis
Status rowBusy indicator and elapsed time, the last tool, retry state; right side: rows below the view while scrolled awayShown while the session is busy or the viewport is scrolled away; hidden while idle and followingDrops the last tool before the indicator
QueueOne dim line per queued message: steer or follow-up, then its first lineHidden when the queue is emptyTruncates each line
Widgets aboveExtension widget lines with aboveEditor placementHidden when noneTruncates each line
EditorPi’s editor: the draft between two rules, scrolling past 30 % of the terminal’s rows (at least five); the command list below the bottom ruleAlways shown; holds focusWraps
Widgets belowExtension widget lines with belowEditor placementHidden when noneTruncates each line
FooterRepository and branch, model and effort, context, cost; status entries on their own line belowAlways shownBreaks the fields into two lines at a field boundary; only when two lines cannot hold them, drops effort, then the model. Repository, branch, context, and a shown cost stay; status entries that do not fit drop

Two parts show only at times:

  • Command list: Pi’s autocomplete list, below the editor’s bottom rule, while the draft is / and a name with no space. Up to five rows (Pi’s autocompleteMaxVisible default): name and description, built-ins first.
  • Pi exit panel (PiExit): replaces the transcript view when the Pi process exits unexpectedly: exit code or signal, and the last stderr lines.

Focus is never in doubt: a dialog owns the keyboard while open; otherwise the draft does. The command list borrows Up, Down, Tab, Enter, and Esc while visible.

One component per region and per block kind. The same names appear in the design reference (apps/my-tui/design/) and in apps/my-tui/src/. Each takes a slice of the view model.

ComponentInputpi-tui part
AppViewState, the FrontendClient actionsTuiAltScreen with a VStack of the regions; owns the keys and focus
TranscriptViewblocks, viewportwindowed ScrollView that follows the end
UserBlockUserBlocklines on the user background band
AssistantBlockAssistantBlockMarkdown with Pi’s markdown tones and highlight.js, then ToolLine rows
ToolLineToolCallViewone line: state glyph, tool name, argument summary, duration
CustomBlockCustomBlocklines with the custom-message tones
CompactionBlockCompactionBlockone dim line with the token count before compaction (tokensBefore), when known
SessionBreakSessionBreakBlockone rule line with the new cwd
NoticeBlockNoticeBlocklines in the level’s tone
NoticeRownewest live Noticeone line
StatusRowsessionState, viewportone line with a spinner
QueueListqueueone line per message
WidgetAreawidgets for one placementone line per widget line
EditordraftPi’s Editor with Pi’s keybindings
Footersession, model, thinkingLevel, usage, statusesone or two lines, then the status entries
CommandListfiltered commandsthe Editor’s autocomplete SelectList
Dialoghead of dialogsa drawn border around option rows, an Input, or an Editor per kind
PiExitpiProcesslines

Colors are the tokens of the theme Pi’s theme setting names (Catppuccin Mocha from pi/agent/themes in this repository’s settings), named as Pi names them: text, muted, dim, accent, success, warning, error, toolOutput, thinkingText, userMessageBg, userMessageText, customMessageBg, customMessageText, border, borderMuted, the md* and syntax* tokens, and the thinking* tokens for the editor rules. The theme file is read at startup, because getTheme() is undefined in RPC mode.

<glyph> <name> <summary> <duration>:

  • The glyph carries the state: ○ pending in muted, a spinner in accent while running, ✓ in success, ✕ in error.
  • The name is in toolOutput; the summary is dim and is the first argument that names a target: command, path, pattern, query, or url, else the first string argument.
  • The duration shows once the call ends, as 1.2s or 3m04s.
  • A call keeps its own state in a settled block: Pi runs calls after their message ends, so a running call spins in a block that has stopped streaming. Pi core marks a call that can no longer finish as failed.

The design reference draws every MVP scene as a full terminal frame from the shared scene fixtures. The frames are the visual spec; these rules state what they follow. Tones are theme token names. Widths count terminal cells.

  • Every gap is whole rows and cells. Each row has a one-cell inset at both edges, like Pi’s outputPad 1.
  • The lane is min(96, width − 2) columns, left-aligned at the inset. Message text, tool lines, and transcript-view notices wrap inside it. User and custom bands, rules, the draft (inside the insets, as Pi’s editor wraps it), the notice row, status row, queue, widgets, and footer span the full width.
  • Text wraps at spaces and breaks a word only when it has to. One-row elements never wrap. Tool lines, the notice row, status row, queue lines, widget lines, and footer lines end in … when cut; command rows are clipped, as pi-tui’s list clips them. A tool line cuts its summary first.
  • The transcript view holds one blank row above its first block and one below its last. It stays at the top while it fits and follows the end once it overflows.
  • One blank row separates blocks. Inside an assistant block, one blank row separates the markdown blocks, the tool lines, and the error tail. Tool lines stack with no blank row between them. A block with nothing to draw, such as a thinking-only message, takes no rows and adds no blank row.
  • The bottom regions stack with no blank rows between them.
  • User: a userMessageBg band from edge to edge, with one padded row above and below the text. The text is userMessageText, plain, with no markdown. Attached images add a muted 2 images row.
  • Assistant: markdown as Pi draws it (pi-tui’s Markdown with Pi’s markdown tones), in toolOutput while it streams and in text once settled.
    • A soft line break stays a line break.
    • Headings are mdHeading bold; levels 1 and 2 draw without # markers and a level-1 heading is also underlined; from level 3 the markers stay.
    • Bold and italic keep the tone around them: the block’s tone in paragraphs and lists, mdHeading in headings, mdQuote in quotes. Inline code is mdCode without backticks.
    • Links are mdLink, underlined, and open their URL through an OSC 8 hyperlink.
    • List markers are - or the item’s number in mdListBullet, with a hanging indent. Quotes are mdQuote italic behind an mdQuoteBorder bar; rules are mdHr; tables are boxed.
    • Fenced code keeps its fence rows, ```lang and ```, in mdCodeBlockBorder. Its lines are indented two cells, highlighted with syntax* tones as Pi highlights them, or mdCodeBlock for a language it does not know; a wrapped code line continues at the lane’s edge.
  • Tool line: the format in Tool line, with single spaces between the parts. A duration under a second reads 48ms. Each call draws the state Pi core gives it, so a running call spins in a settled block.
  • Error tail: a failed or aborted message’s constant text on its own row, muted when the message was aborted and error when it failed.
  • Custom: a customMessageBg band like the user’s. A customType row in customMessageLabel bold comes first, then the text in customMessageText.
  • Compaction: one dim row, [compaction] Compacted from 12,345 tokens from tokensBefore, or [compaction] Compacted when the count is unknown.
  • Session break: one row, ── ⟳ new session <cwd> ──…, filled to the right inset. The rule is dim, ⟳ new session is accent bold, and the cwd is toolOutput; the cwd truncates first.
  • Empty session: one dim row of key hints in the transcript view’s first row, below its blank top row: enter send · shift+enter new line · / commands · ctrl+l model · ctrl+d quit. It never wraps and ends in … when cut.
  • Notice: <glyph> <text> with a hanging indent. Info is • in accent with muted text. Warning is ! with text, both in warning. Error is ✕ with text, both in error.
  • Notice row: the newest live notice, one row, with the notice block’s glyph and tones.
  • Status row: <spinner> <elapsed> · <last tool> · <activity>, shown while the session is busy or the viewport is scrolled away. While idle only its right side draws. The separators are dim.
    • The spinner is Pi’s Braille loader (80 ms frames) in accent. The elapsed time is dim: 14s, 2m05s, 1h02m.
    • The last tool reads › bash 2s while it runs, with › in accent. After it ends, it reads ✓ bash 3s ago or ✕ bash 3s ago. The name is toolOutput and the time dim.
    • The activity is thinking in thinkingText, compacting in muted, or retry 2/3 in warning followed by the retry message in dim.
    • On the right, ↓ 12 more rows in muted shows while the viewport is scrolled away. It reads ↓ 12+ more rows when blocks below the view are not mounted (see the windowed transcript view in the TUI README), so the count is a lower bound.
    • When the row does not fit, the last tool drops first. Then the left side truncates.
  • Queue: one row per message, steering first. The label, steer or follow-up, is dim and padded to nine cells. The message’s first line follows in muted, with … when more lines follow.
  • Widgets: one muted row per line.
  • Editor: Pi’s editor. The draft sits between two full-width ─ rules in the current level’s thinking* token, with a reverse-video block cursor. It scrolls past 30 % of the terminal’s rows, at least five. No cursor draws while a dialog is open or after the Pi process exits.
  • Command list: Pi’s autocomplete list below the bottom rule. Up to five rows, built-ins first; with more matches, a muted (1/10) row follows.
    • The selected row starts with → and is accent throughout. The other rows start with two spaces; their names take the terminal’s default color and their descriptions muted.
    • Names are padded to a column of the widest match plus two cells, at least 12 and at most 32.
  • Footer: the fields repo/branch · provider/model (effort) · context · cost, with · in dim, laid out as tui-footer lays them out.
    • The repository (session.repo) is muted and the branch accent, joined by a dim /. Without a repository the field is the branch alone; without either, it is absent. The provider is muted and the model text, also joined by a dim /.
    • The effort sits in dim parentheses and keeps tui-footer’s quiet-to-loud ramp: dim for off, muted for minimal, thinkingText for low, success for medium, warning for high, and error for xhigh and max. The thinking* tokens color only the editor rules. The effort is omitted when the model cannot reason.
    • The context reads 9.2%/200k in dim, warning above 70 %, and error above 90 %. When usage is unknown it reads ?/200k.
    • The cost reads $0.04 in muted and is omitted until it is positive.
    • The fields take one line while they fit inside the insets. Otherwise they break at a field boundary into two lines that keep every field. A break that keeps the model with its effort wins, then the one whose wider line is narrower. At 70 columns the frames break after the effort: my-pi/tui-mvp · personal-sub/claude-haiku-5-5 (low) over 9.2%/200k · $0.04.
    • Only when two lines cannot hold the fields does the footer drop the effort, then the model. The repository, branch, context, and a shown cost never drop for width. When the rest still does not fit two lines, each field takes its own line and is cut with … when it overflows.
    • Extension status entries take their own line below the fields, in muted, joined by ·. Whole entries from the left stay while they fit; the rest drop.
  • Dialog: a region between the transcript view and the notice row, beginning with one blank row; the transcript view gives up its rows to it and keeps following its end above it. It never moves the status row, queue, or editor. The box is a rounded borderAccent box at the inset, min(width − 2, 76) cells wide, border included. Its border runs through the box’s edge cells, as box-drawing glyphs do.
    • The top border holds the title, text bold, from the box’s fourth cell. The countdown, dim, sits at the border’s right end.
    • The body has one cell of padding. Options start with › in accent on the selected option, which is also accent; the other options are text. Up to eight options show; the list scrolls with the selection.
    • A confirm dialog shows its message, a blank row, then Yes and No. An input dialog shows a › row with pi-tui’s Input, its cursor, and a dim placeholder. An editor dialog shows its prefill in Pi’s editor, without its rules.
    • The bottom border holds the key hints in dim, such as ↑↓ choose · enter select · esc cancel.
  • Pi exit panel (PiExit): replaces the transcript view, starting one row down.
    • The first line is ✕ Pi exited with code 1 in error bold, or ✕ Pi was stopped by SIGTERM.
    • After a blank row, the stderr tail follows in toolOutput behind a borderMuted bar, indented two cells.
    • After another blank row, ctrl+d quit follows in dim.