Pi stock-UI protocol trial¶
Question and status¶
Can Pi 0.87.1’s stock InteractiveMode keep its editor, footer, and status
while the transcript (user prompts, assistant text, one tool type) is owned by
protocol Blocks through the experimental xterm host?
This is the Path B trial from the
design draft. It composes the real
InteractiveMode, AgentSessionRuntime, and transcript component classes from
the published Pi 0.87.1 packages (pin 2b0a123); no Pi files are modified.
Status, 2026-10-03: Path B works end to end. With the experimental region-aware history mode wired on the protocol path, all four browser checks pass through the real ConPTY/xterm host: one turn with the editor accepting input during streaming, cancel mid-stream plus a further turn, a 60/36/60 resize round trip, and the stock fallback. Node-level integration adds negotiation-negative, lifecycle, and input-routing edge cases. On 2026-10-19 the same checks passed with styled assistant Blocks, plus a fifth that verifies Pi’s styling in the painted cells. The 2026-09-28 boundary failure that motivated host region ownership is kept below for the record.
Status, 2026-10-16: assistant Blocks carry Pi’s real styling when the
terminal confirms text/styled. A follow-up experiment on the same
composition: the event adapter renders each assistant message through Pi’s
own AssistantMessageComponent and sends the visible result as span lists,
so markdown emphasis, thinking, and error styling reach the terminal as
text/styled content instead of being flattened to the trial’s plain text.
User and tool Blocks stay text/plain; Block content types mix freely
inside one Context. See Styled transcript content.
Status, 2026-10-24: assistant Blocks carry Pi’s render as text/lines
when the terminal confirms it, so the terminal lays them out at every width
instead of keeping a render width. See
Line-layout transcript content.
Layout¶
File |
Responsibility |
|---|---|
|
Child entry: fixture model, trial runtime, fail-stop on latched failure. |
|
Composition root: negotiation before UI start; stock fallback or gate+bridge activation. |
|
Trial flag and the |
|
Trial-owned pi-tui |
|
Second |
|
Pi event → Block Operation mapping (matches the pi-session trial, plus multi-user runs); span-level diffing for styled Blocks. |
|
Renders assistant messages through Pi’s real |
|
Isolated real Pi 0.87.1 runtime through the public composition; one read-only tool; in-memory session/settings. |
|
Deterministic local provider and the fixed sample text; optional fragment overrides for styled checks. |
|
Wrap-baking experiment: Pi’s layout at one width versus terminal reflow at others. |
|
RFC 0003 check: Pi’s wide render as |
|
RFC 0003 end to end: Pi’s |
|
Pi’s |
|
The |
|
Records one stock-mode turn’s raw ConPTY output as the SDK’s real-output ANSI fixture. |
|
In-process terminal host for Node tests (chrome recorder + real endpoint + reply path), with an xterm-backed endpoint variant. |
|
Focused Node checks; the stock-ui suite is also the plain-text fallback path. |
|
Styled-mode Node checks; the env module forces chalk color emission for deterministic style assertions. |
|
PTY bridge and the experimental xterm host page on port 4181. |
Patch report¶
No Pi source files are changed. The trial wraps render(width) on exactly
eight transcript component classes from @earendil-works/pi-coding-agent@0.87.1
(pin 2b0a123), returning zero lines while the trial flag is active and
delegating otherwise:
AssistantMessageComponent, UserMessageComponent, ToolExecutionComponent,
SkillInvocationMessageComponent, BashExecutionComponent,
CompactionSummaryMessageComponent, BranchSummaryMessageComponent,
CustomMessageComponent.
Chrome classes (editor, footer, status, widgets, dialogs) are never wrapped;
the structured report prints at startup. Pi itself ships a precedent for a
zero-line render switch (ToolExecutionComponent.hideComponent).
Node evidence, 2026-09-28¶
pnpm typecheck, 21 focused tests, all 317 repository Node tests, and the
browser build pass. The focused tests cover:
the gate wraps exactly the eight render methods, once, defaults to stock, and flips behavior only with the flag;
trial flag off: no protocol bytes cross and the transcript renders in the chrome stream;
negotiation negative: only the capability query crosses, no Context opens, the transcript stays in the chrome stream;
negotiation positive: the transcript seals into Blocks and the chrome stream carries no transcript text;
cancel mid-stream seals the partial assistant Block with an abort label, and a second turn completes;
typing into the editor during streaming reaches Pi and echoes in the chrome stream; resize re-renders chrome without disturbing sealed Block state;
input splitting: keystrokes pass untouched and in order around reply frames, a reply frame split across chunks arrives exactly once, a lone Escape is released after the escape timeout, terminal color-query replies stay ordinary input, an OSC prefix lookalike stays ordinary, a BEL-terminated protocol frame fails as protocol input rather than editor input, and input typed before the UI starts replays in order;
a compaction event is outside this trial and fails instead of being hidden.
Browser evidence¶
2026-10-03, automated checks.html through Windows bundled ConPTY and the
experimental xterm host, with the region-aware history mode on the protocol
path — all four scenarios passed:
One turn completes with the transcript in four sealed Blocks; the editor accepted input during streaming; transcript text appears exactly once in native rows.
Cancel mid-stream keeps a sealed partial Block with an abort label; the tool never runs; the editor stays responsive; a further prompt completes.
A 60/36/60 column round trip keeps sealed Blocks, exactly one copy of each transcript line, a live editor, and non-blank chrome. Pi’s resize emits a full screen+scrollback clear; the host re-materializes the Blocks afterwards instead of invalidating the Context.
With the trial flag off, Pi renders its own transcript through the same host and no protocol state appears.
The scenarios synchronize on stable states (an open Context, a sealed
Block) rather than transient ones, and each scenario can set its own fixture
pacing (?pace=<ms>; the cancel scenario uses 2400 ms so an automated
Escape lands mid-stream).
2026-10-19, the same checks.html with browser.ts advertising
text/styled, so assistant Blocks travel as span lists — all five
scenarios passed: the four above, now on the styled path, plus
With the markdown fixture text (
?demo=1), Pi’s own styling reaches the painted xterm cells: the DOM renderer paints the**…**phrase withxterm-bold, the surrounding prose without it, and the inline code in its own color (Pi’s dark-thememdCode,rgb(138, 190, 183)); the Blocks’ logical text carries no markdown markers. The paint holds at 36 columns and after returning to 60.
Scenario 2 on the styled path exposed a stale assumption: a real Escape ends
the assistant Block with Pi’s default Operation aborted label, not the
Cancelled by user text the Node fixture provider sets. The check now
accepts [aborted] (plain) or Operation aborted (styled).
The DOM renderer paints only visible rows, and Chrome pauses painting in a hidden page or an off-screen iframe; scenario 5 scrolls the terminal into view, and the page must be visible while it runs.
Earlier record, 2026-09-28 (manual, before region ownership)¶
Stock fallback passes through the real ConPTY/browser path.
Protocol path fail-stops at the boundary: after the user Block sealed and the assistant Block opened, Pi’s chrome redraw erased rows inside adapter-owned Block territory; the mixed-ingress watchdog invalidated the Context as designed and the child exited 1. This was the first observed two-writer conflict and motivated region ownership.
Correction: the automated checks at that time timed out for a different reason — they submitted before Pi’s startup finished and never retried. The conflict above was observed on the manual path, and the checks now wait for the turn to actually start.
Region ownership: the resolved host feature¶
The 2026-09-28 conflict was structural: Blocks and chrome share one
coordinate space and Pi’s absolute cursor math cannot see adapter-inserted
rows. The fix is the experimental
region-aware history mode
from the region-ownership design:
a passive app-region estimate confines the application’s ANSI drawing to its
own rows, cursor compensation after every Block materialization keeps the
application’s frame math valid, the watchdog stays armed for genuine
conflicts, and application resize clears re-materialize the Blocks instead
of invalidating Contexts. This trial’s browser.ts wires the mode on the
protocol path only; the stock fallback registers nothing.
Styled transcript content¶
When the terminal confirms text/styled during negotiation
(client.supportsStyledContent), the bridge hands the event adapter an
assistant span renderer and assistant Blocks go out as span lists; user and
tool Blocks stay text/plain. Without the confirmation the trial is
byte-for-byte the plain mapping, which is what the stock-ui Node suite
exercises.
The renderer is Pi’s own, not a copy: styled-renderer.ts keeps one
AssistantMessageComponent (exported from the published
@earendil-works/pi-coding-agent package root, same source as the render
gate’s report), feeds it each message snapshot via updateContent, and
calls render(width) offscreen. The render gate suppresses Pi’s transcript
UI through a prototype wrap that would also catch this instance, so the
renderer lifts the gate for its own synchronous render call; the gate file
itself is unchanged. The component is constructed with outputPad 0 so no
chrome gutter becomes logical text; each rendered line then goes through the
SDK’s spansFromAnsi (SGR → spans, OSC 133 zone markers and other non-SGR
sequences discarded), trailing width padding is trimmed per line, the outer
spacer lines are dropped, and lines join through "\n" spans behind an
unstyled Assistant:\n label span. Pi’s theme emits emphasis through chalk,
whose color level follows the process TTY: on the real PTY child the SGR
attributes are present; in Node tests styled-test-env.ts forces
FORCE_COLOR=3 and truecolor capabilities before chalk loads, or bold and
italic would silently vanish from the rendered lines.
Updates diff at span granularity: when the previous spans form an exact
content prefix of the next render (a boundary span may grow with unchanged
style) the tail goes out as one Extend — the common streaming case. A
re-render that restyles earlier text (for example a completing **bold**
marker) shortens the span-exact common prefix and the update degrades to a
ReplaceSuffix whose retain counts the shared prefix’s logical scalars, using
spanPrefix/spanDrop from the terminal module. The render width comes
from a () => number provider wired from TrialTerminal.columns through
the bridge into the adapter, so width is read at each render.
Node evidence, 2026-10-16: styled-content.test.ts adds 8 checks — pure
streaming growth emits Extend only and its logical text equals the
plain-mode transcript; thinking content keeps Pi’s italic thinkingText
style; an error end carries Pi’s own error label in the error color;
markdown **bold**, `code`, and *italic* become the corresponding
spans; a restyling re-render degrades to ReplaceSuffix at the span-exact
prefix; user and tool Blocks stay text/plain inside a styled Context. Two
checks run the full trial against the fake host with the xterm endpoint and
a real @xterm/headless buffer: the fixture turn keeps the plain text
projection row for row with text/styled assistant Blocks, and a
markdown-marked fixture variant lands bold cells, italic cells, and the dark
theme’s code color at the expected buffer positions while its logical text
still equals the marker-free plain transcript.
Known divergences from the plain mapping, all consequences of adopting Pi’s rendered projection as the logical text:
Thinking content renders with Pi’s italic thinkingText style and no
[Thinking]label; the plain mapping’s label exists only in plain mode.Abort, error, and truncation ends show Pi’s own labels (
Operation abortedfor a real Escape, the provider’s message such as the Node fixture’sCancelled by userotherwise,Error: …) in the error color instead of the plain[aborted] …suffix.Markdown constructs appear in their displayed form:
**bold**arrives as a boldboldspan, so the logical text of markdown-heavy messages differs from the plain mapping’s raw text. For the prose-only fixture the two projections are identical.Line wrapping is baked at the render width: the logical text carries the newlines of the width-60/80 render, and a later narrower width re-flows those already-split lines. This is the tension between renderer-owned layout and terminal-owned reflow that the experiment exists to expose; the browser resize scenario is where to watch it.
Line-layout transcript content¶
When the terminal confirms text/lines
(RFC 0003), the bridge
prefers it over text/styled. lines-renderer.ts renders each assistant
message through the same AssistantMessageComponent at 1000 columns, so Pi
inserts no width breaks, and converts the result with the experimental
reader in lines-from-render.ts (hanging indents for nested bullet and
numbered items, repeated quote markers, fill rules). The Block starts with an
Assistant: label line; user and tool Blocks stay text/plain.
Each message update goes out as the smallest edit lines-edit.ts finds:
Extend when only the last line grew or lines were added; ReplaceSuffix from
inside the first changed line when its attributes are unchanged (for example a
completed **bold** restyling earlier text); ReplaceSuffix from the end of
the previous line when a line’s attributes changed (a line that only later
turns out to be a list item); Update only when the first line’s attributes
change. Eight Node checks apply each edit with the terminal module’s own
content rules and compare the result.
Node evidence, 2026-10-24 (lines-trial.test.ts, xterm-backed fake host
without an application region): with text/lines confirmed the assistant
Blocks are text/lines with hanging indents and quote markers, a paragraph
is one logical line, and streaming used Extend; the streamed answer’s rows
equal the host layout at 60 columns and again at 36 and 90 after resizes;
with only text/styled confirmed the styled path is unchanged.
Browser evidence, 2026-10-24: with browser.ts advertising text/lines,
all six checks.html scenarios pass through the real ConPTY host with the
application region: the five earlier scenarios now run on the lines path,
and a sixth checks line layout across Pi’s resize redraw. At 36 columns the
demo answer reads The sample reports: mutable history /
keeps old output readable. and - Styling comes from Pi's real /
renderer — word breaks and a hanging indent, with Pi’s chrome intact
below — and at 60 columns the bullet is one row again with no stale
continuation. In region mode the host does not relayout on resize itself:
Pi’s redraw clears the screen, and clear recovery re-materializes the Blocks
at the new width with the line layout.
Wrap-baking experiment¶
wrap-baking.test.ts renders a fixed markdown sample (wrap-sample.ts)
through the same AssistantMessageComponent at 60 columns and at a wide
1000 columns, appends each as a styled Block through the xterm endpoint, and
compares the native rows after resizing with Pi’s own layout at the display
width (wrap-experiment.ts; wrap-probe.ts prints the table). Seven checks
pass, 2026-10-19:
a render at the display width reproduces Pi’s layout row for row;
shown at 36 columns, the 60-column render keeps 17 of Pi’s 29 rows, with 6 mid-word splits and 3 list or quote continuations missing their prefix; shown at 100 it keeps its 60-column breaks, 5 rows more than Pi;
the wide render carries only content breaks (the 60-column render adds 6), so a paragraph copies as one line, but the terminal’s cell-based reflow splits words and drops continuation prefixes at every narrower width;
Pi’s rule, drawn at a fixed length, spills onto extra rows when narrower.
Neither width choice reproduces Pi’s layout once the display width changes. The analysis and the layout capabilities this points to are in Renderer-owned layout and reflow.
Paired stock and protocol demonstration¶
The side-by-side walkthrough uses one runtime
to drive a stock view and a protocol view in real Tabby. paired.ts scopes the
render gate to the synchronous doRender path, while pair-main.ts and
channel-client.ts carry the two frontends’ bytes and real PTY resizes. One
prompt executes one tool; stock renders Pi’s original transcript while protocol
seals four Blocks. paired.test.ts proves the shared runtime and single tool
call; pair-transport.test.ts checks role handshakes, directions, and resize.
Run¶
From the repository root (Windows, Node 24+, pnpm 11.9.0):
pnpm prototype:pi-stock-ui
# http://127.0.0.1:4181/ protocol path (completes a fixture turn)
# http://127.0.0.1:4181/?stock=1 stock fallback
# http://127.0.0.1:4181/?pace=2400 slower fixture stream (long cancel window)
# http://127.0.0.1:4181/?demo=1 markdown fixture text (visible styling)
# http://127.0.0.1:4181/checks.html the six automated scenarios (keep the page visible)
pnpm typecheck
pnpm test
pnpm build:pi-stock-ui
For bounded long-session runs, main.ts also accepts PI_TRIAL_PACE (default
600 ms) and PI_TRIAL_MAX_REQUESTS (default 4). The
Tabby long-session check
sets both for one continuous local-fixture conversation. It uses no model
network or credentials.
record-ansi.ts re-records the SDK’s real-output ANSI fixture: one stock-mode
turn with the markdown text through bundled ConPTY at 60x24, with the temp
working directory replaced by <trial-cwd>.
node prototypes/integration/pi-stock-ui/record-ansi.ts sdk/test/fixtures/pi-0.87.1-stock-turn.ansi
Limits¶
Kitty keyboard negotiation is not performed (
kittyProtocolActivestays false); editor keys beyond typing, Enter, Escape, and Backspace are unverified.Local deterministic provider only; no live model, credentials, or network.
Windows bundled ConPTY only; 60x24 viewport in the browser fixture.
The render gate keeps transcript components updating offscreen (wasted per-token work), accepted for the smallest behavioral delta. The styled path adds one offscreen render per message event on top.
User and tool Blocks stay
text/plaineven when the terminal confirmstext/styled; only assistant messages carry styling.The styled Block’s logical text is the rendered projection: thinking has no
[Thinking]label, abort/error ends use Pi’s own labels, and markdown constructs appear in displayed form. See the divergences list in Styled transcript content.On the
text/styledpath, rendered wrapping is baked into the logical text at the current width; narrower reflow afterwards splits those lines again. Thetext/linespath avoids this; its layout intent is read back from Pi’s rendered shapes for the constructs listed above, so tables and other constructs keep only word wrapping. The wrap-baking experiment measures the cost.The styled browser check samples three paint attributes (bold, its absence on prose, and the code color) on one fixture message; thinking, error styling, and other themes are covered by Node buffer checks only.
A resize clear re-materializes Blocks, but a reading anchor, an active selection, or search-match positions across the clear are best-effort or lost; the active search term survives via save/restore.
Session replacement (
/new, resume, fork) and compaction are out of scope and fail the trial instead of being hidden.
Related: design draft, styled text content draft, Pi rendering architecture, pi-session trial, terminal host example, xterm protocol endpoint.