Pi Daemon can present Pi's terminal UI in a browser without starting
a second pi process. The reusable foundation is
VirtualTerminal, exported from
@harryaskham/pi-daemon/virtual-terminal.
This document records the Pi 0.80.6 shadow-TUI spike and the
implemented canonical host/channel substrate. ShadowTuiHost
and ShadowTuiAttachmentManager are exported, bounded, and
conformance tested, but production capability remains unavailable until
Pi exposes the host-safe InteractiveSessionView factory
below. No private/process-owning fallback is used.
VirtualTerminal implements the public
Terminal interface from the exact pinned
@earendil-works/pi-tui package. A normal in-process
TUI writes its ANSI differential stream to this terminal.
The terminal:
The acceptance fixture renders Pi's exported
UserMessageComponent,
AssistantMessageComponent,
ToolExecutionComponent and
CustomMessageComponent, plus an overlay and focused
extension-style editor, through one TUI and one
VirtualTerminal. Input travels back through the same TUI
focus path. Rapid representative deltas and resizes are measured against
Dashboard's frameWorkP95Ms < 16 and
tuiDeltaP95Ms < 50 contracts.
Defaults are hard ceilings, not suggestions:
| Resource | Default maximum |
|---|---|
| columns | 320 |
| rows | 200 |
| one terminal write | 1 MiB |
| one escape/control sequence | 64 KiB |
| one input event | 16 KiB |
| title | 512 UTF-8 bytes |
| serialized frame | 512 KiB |
A caller may lower these limits but cannot raise them above the compiled hard ceilings. Frames contain only the final bounded grid and cumulative numeric counters; raw ANSI and stripped payloads are not retained.
The browser is not a terminal and does not receive terminal escape
bytes. VirtualTerminal interprets the small CSI/SGR subset
needed for rendering and strips side channels before projection:
Each category has a counter in
VirtualTerminalFrame.stripped; the payload is never copied
into a frame or log. Incomplete or oversized escape sequences, writes,
input, dimensions and frames fail closed with bounded errors.
A shadow TUI is a presentation of the daemon's existing
AgentSessionRuntime. It is not another agent runtime. For
an activated session, the target topology is:
AgentSessionRuntime (sole session state machine and JSONL writer)
└─ extension runtime (one instance)
└─ extension UI broker (one controlling presentation)
└─ InteractiveSessionView
└─ pi-tui TUI
└─ VirtualTerminal
└─ bounded frame subscribers (embedded or dedicated Dash)
Multiple browser panes may subscribe to the authoritative frame
stream, but must not each call bindExtensions() or create
another extension runtime. A controlling pane owns input and extension
dialogs; observer panes receive the same frames. Rich transcript peers
remain independent read-only projections.
This follows Pi's existing replacement semantics: when
AgentSessionRuntime replaces a session, the old view/broker
generation is invalidated, the new session is bound once, and peers must
attach to the new generation.
Starting pi under a PTY would create a second session
state machine and a second extension instance. If pointed at the same
JSONL it also creates a concurrent writer; if pointed at a copy it
immediately diverges from the session that Dashboard controls. Either
choice breaks prompt idempotency, settlement cursors, tool/UI
correlation, runtime replacement and conflict detection. It also
duplicates model/auth/tool setup and introduces an unbounded
terminal-control boundary.
A PTY therefore cannot be the normal compatibility path. Exporting a session as a new independent session is an explicit ownership operation, not a rendering technique.
The pinned SDK already exports the useful pieces:
InteractiveMode, AgentSessionRuntime, Pi
message/tool components and themes from
@earendil-works/pi-coding-agent;Terminal, TUI, components, input and ANSI
width utilities from @earendil-works/pi-tui.The remaining blocker is construction and lifecycle ownership inside
InteractiveMode:
new TUI(new ProcessTerminal(), ...).init() installs process signal and uncaught-exception
handlers and invokes
ensureTool("fd")/ensureTool("rg"); the latter
may create child processes. Neither is acceptable on a daemon's initial
no-tools path.process.exit, process signals and process suspension.TUI, extension UI context and render trigger are
private.bindCurrentSessionExtensions() calls
session.bindExtensions(...).
AgentSession.bindExtensions() updates bindings and
emits session_start again. Attaching a second
InteractiveMode to a session already bound for RPC would
replace the extension UI and duplicate lifecycle delivery.The component-level fixture is therefore supported today, while full interactive extension compatibility needs the small supported seam below. Pi Daemon does not patch package internals or call private methods in product code. A test invokes the pinned private differential renderer only to measure its work until the public view seam exists.
ShadowTuiHost accepts only an injected factory with the
proposed public view shape plus an external extension-UI broker and
runtime resolver. With that seam present it owns exactly one
(hostInstanceId, sessionId, generation) view,
VirtualTerminal, extension broker binding, replay buffer,
and channel set. Concurrent embedded panes are wrappers over that
canonical state; controller role is orthogonal and observers cannot
resize or send input.
Terminal write/resize/title/progress notifications coalesce through one immediate publication boundary. The first forced terminal frame becomes the channel snapshot; later publications use a separate contiguous dashboard sequence and opaque replay cursor. Generation invalidation stops the view, unbinds extension UI, closes peers, and discards retained frames before a new view can be created.
The mapper drops terminal-only columns and hyperlink metadata, converts indexed and RGB ANSI colors to deterministic lowercase browser hex, and preserves only the bounded style vocabulary. Semantic key input rejects unsupported/meta combinations; text is limited to one terminal input event and paste is chunked on UTF-8 boundaries under both browser and terminal ceilings.
ShadowTuiAttachmentManager exposes the same canonical
DashboardTuiChannel through the authenticated service API's
pi-daemon-tui.v1 WebSocket seam. It validates
role/generation/dimensions/input, sends snapshot/delta/gap/control
frames, bounds both directions, and closes the underlying channel on
every socket/error path. Embedded InProcessDashboardBackend
delegates through the same host manager and owns renewable residency
leases. Without an injected public view factory, callers continue to use
UnavailableDashboardTuiAttachments and advertise TUI
unavailable.
Add a host-safe InteractiveSessionView facade while
preserving the current CLI defaults:
export interface InteractiveSessionViewHost {
terminal: Terminal;
// No default process action is taken when supplied by an embedder.
requestExit(request: { code: number; reason: string }): void;
resolveAutocompleteTool?(name: "fd" | "rg"): Promise<string | undefined>;
suspend?(resume: () => void): void;
openExternalEditor?(request: ExternalEditorRequest): Promise<string | undefined>;
}
export interface InteractiveSessionViewOptions extends InteractiveModeOptions {
host: InteractiveSessionViewHost;
// The embedding host bound the extension runtime once through a stable UI
// broker. View initialization must not emit session_start again.
extensionBinding?: "managed" | "external";
}
export interface InteractiveSessionView {
readonly extensionUI: ExtensionUIContext;
init(): Promise<void>; // build/render UI, no model turn
requestRender(force?: boolean): void;
stop(): void; // no process exit
}
export function createInteractiveSessionView(
runtime: AgentSessionRuntime,
options: InteractiveSessionViewOptions,
): InteractiveSessionView;Implementation can reuse almost all current
InteractiveMode code:
TUI with options.host.terminal
instead of a hardcoded ProcessTerminal;fd/rg only through the host in
embedded mode (absence disables the corresponding autocomplete
source);extensionBinding is external, skip
bindExtensions() and let the embedding host's stable broker
delegate to view.extensionUI;InteractiveMode.run() as the process-owning CLI
wrapper, preserving current behavior by supplying
ProcessTerminal and the process lifecycle adapter.The extension UI broker must fail closed if a second controlling presentation tries to attach. Detaching an observer does not change extension bindings. Session replacement invalidates the broker generation before a replacement view can receive input.
This is deliberately smaller than a new terminal protocol or a second
session implementation: it only makes the terminal and process/UI
ownership that already exists in InteractiveMode injectable
and explicit.