How Extension Widgets Render Custom UI Components in π‑Web’s Terminal Style
Extension widgets render as terminal‑style UI components by flowing through a four‑stage pipeline: extensions emit extension_ui_request events, the server stores them in rpc‑manager.ts, React hooks propagate the state to the client, and ChatWindow.tsx paints them as bordered monospaced blocks using CSS variables.
The agegr/pi-web repository implements a chat‑centric interface where extensions can contribute custom UI elements that adopt a terminal aesthetic. These widgets appear as boxed panels inside the chat pane, preserving whitespace and using monospaced fonts to mimic a command‑line environment.
The Extension Widget Rendering Pipeline
Step 1: Extensions Emit UI Requests
Extensions initiate rendering by calling the Pi SDK’s extensionUiRequest method (or emitting the legacy extension_ui_request event). The payload must include three fields:
key– A short identifier displayed as the widget’s header.lines– An array of strings representing the terminal content.placement– Either"aboveEditor"or"belowEditor"to position the widget relative to the chat input.
The SDK forwards this request to the running AgentSession on the server.
Step 2: Server-Side Collection in RPC Manager
Inside lib/rpc-manager.ts, the server maintains a Map<string, ExtensionWidgetItem> named extensionWidgets. When a request arrives, the manager stores the widget via:
this.extensionWidgets.set(key, {key, lines, placement});
The map is exposed through getExtensionWidgets(), which returns Array.from(this.extensionWidgets.values()) according to lines 52‑55 of rpc‑manager.ts.
Step 3: State Propagation via React Hooks
The client receives updates through hooks/useAgentSession.ts. The hook listens for agent events containing extensionWidgets and updates React state:
const [extensionWidgets, setExtensionWidgets] = useState<ExtensionWidgetItem[]>([]);
// ...
if (liveState.extensionWidgets !== undefined) setExtensionWidgets(liveState.extensionWidgets ?? []);
This synchronization occurs around lines 68‑71 and 84‑85, ensuring the UI layer always holds the latest widget list.
Step 4: Terminal-Style Rendering in ChatWindow
components/ChatWindow.tsx consumes the widget list and splits it by placement:
const aboveEditorWidgets = extensionWidgets.filter(w => w.placement !== "belowEditor");
const belowEditorWidgets = extensionWidgets.filter(w => w.placement === "belowEditor");
As implemented on lines 79‑80, these arrays pass to the ExtensionWidgets helper component (lines 105‑106). The component renders each widget as a bordered terminal panel:
function ExtensionWidgets({ widgets }) {
if (widgets.length === 0) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 10 }}>
{widgets.map(widget => (
<div key={widget.key} style={{
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--bg-panel)",
overflow: "hidden",
}}>
<div style={{ padding: "5px 9px", borderBottom: "1px solid var(--border)" }}>
{widget.key}
</div>
<pre style={{
margin: 0,
padding: "8px 9px",
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{widget.lines.join("\n")}
</pre>
</div>
))}
</div>
);
}
This implementation (lines 53‑75) uses a <pre> element to preserve whitespace and applies var(--font-mono) for the terminal aesthetic.
Key Implementation Details
Placement and Positioning
The placement property controls vertical ordering. Widgets with "aboveEditor" appear between the chat history and the input field, while "belowEditor" renders underneath. The layout uses flexbox with an 8‑pixel gap and 10‑pixel bottom margin to separate multiple widgets.
Terminal Aesthetic via CSS Variables
The visual style relies on CSS custom properties defined in the π‑Web theme:
var(--bg-panel)– Dark background for the widget container.var(--border)– Subtle border color creating the boxed effect.var(--font-mono)– Monospaced typeface for the<pre>content.var(--text-muted)– Dimmed text color matching secondary UI elements.
The whiteSpace: "pre-wrap" declaration ensures that newline characters in the lines array render correctly, while wordBreak: "break-word" prevents horizontal overflow.
Practical Examples
Extension Side (TypeScript)
Push a terminal widget from an extension entry point:
import { AgentSession } from "pi-sdk";
async function showWidget(session: AgentSession) {
await session.send({
type: "extension_ui_request",
id: "system-monitor",
method: "custom",
lines: [
"┌─ System Monitor ───────────┐",
"│ CPU: 12% │",
"│ Memory: 4.2GB / 16GB │",
"└────────────────────────────┘",
],
placement: "aboveEditor",
});
}
React Consumption
Reuse the built‑in component in custom views:
import { ExtensionWidgets } from "@/components/ChatWindow";
const widgets = [
{ key: "logs", lines: ["[INFO] Service started", "[DEBUG] Connected"], placement: "belowEditor" as const },
];
<ExtensionWidgets widgets={widgets} />;
Summary
- Extensions trigger widgets via
extension_ui_requestpayloads containingkey,lines, andplacement. lib/rpc-manager.tsstores widgets in aMapand exposes them viagetExtensionWidgets().hooks/useAgentSession.tspropagates the widget array to React state on the client.components/ChatWindow.tsxfilters by placement and rendersExtensionWidgetsas bordered, monospaced terminal blocks using CSS variables like--font-monoand--bg-panel.
Frequently Asked Questions
What data structure defines an extension widget?
The ExtensionWidgetItem type (defined in lib/types.ts) requires a string key, an array of string lines, and a placement union type of "aboveEditor" | "belowEditor".
How do I position a widget above the chat input?
Set the placement field to "aboveEditor" in your extension request. The ChatWindow.tsx component filters the widget list and renders above‑editor items before the text input field.
Where is the terminal styling defined?
The terminal aesthetic is defined inline within the ExtensionWidgets function in components/ChatWindow.tsx (lines 53‑75). It uses CSS variables for theming and a <pre> tag to preserve whitespace and enforce monospaced fonts.
Can extension widgets contain interactive HTML?
No. The current implementation joins the lines array with newline characters and renders them inside a <pre> tag, which treats content as plain text. Interactive elements would require modifying the ExtensionWidgets component to accept JSX children rather than string arrays.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →