feat(coding-agent): Add widget placement option (#850)

* Add widget placement for extension widgets

* Remove changelog entry for widget placement

* Keep no-op widget signature

* Move widget render before attach
This commit is contained in:
Marc Krenn 2026-01-19 15:54:24 +01:00 committed by GitHub
parent 6327bfd3dc
commit abb1775ff7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 114 additions and 36 deletions

View file

@ -1184,8 +1184,9 @@ ctx.ui.notify("Done!", "success"); // success, info, warning, error
ctx.ui.setStatus("my-ext", "Processing..."); ctx.ui.setStatus("my-ext", "Processing...");
ctx.ui.setStatus("my-ext", null); // Clear ctx.ui.setStatus("my-ext", null); // Clear
// Widgets (above editor) // Widgets (above editor by default)
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]);
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"], { placement: "belowEditor" });
// Custom footer (replaces built-in footer) // Custom footer (replaces built-in footer)
ctx.ui.setFooter((tui, theme) => ({ ctx.ui.setFooter((tui, theme) => ({

View file

@ -184,7 +184,7 @@ export default function (pi: ExtensionAPI) {
const ok = await ctx.ui.confirm("Title", "Are you sure?"); const ok = await ctx.ui.confirm("Title", "Are you sure?");
ctx.ui.notify("Done!", "success"); ctx.ui.notify("Done!", "success");
ctx.ui.setStatus("my-ext", "Processing..."); // Footer status ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
}); });
// Register tools, commands, shortcuts, flags // Register tools, commands, shortcuts, flags
@ -1366,7 +1366,7 @@ Extensions can interact with users via `ctx.ui` methods and customize how messag
- Settings toggles (SettingsList) - Settings toggles (SettingsList)
- Status indicators (setStatus) - Status indicators (setStatus)
- Working message during streaming (setWorkingMessage) - Working message during streaming (setWorkingMessage)
- Widgets above editor (setWidget) - Widgets above/below editor (setWidget)
- Custom footers (setFooter) - Custom footers (setFooter)
### Dialogs ### Dialogs
@ -1456,8 +1456,10 @@ ctx.ui.setStatus("my-ext", undefined); // Clear
ctx.ui.setWorkingMessage("Thinking deeply..."); ctx.ui.setWorkingMessage("Thinking deeply...");
ctx.ui.setWorkingMessage(); // Restore default ctx.ui.setWorkingMessage(); // Restore default
// Widget above editor (string array or factory function) // Widget above editor (default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// Widget below editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0)); ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));
ctx.ui.setWidget("my-widget", undefined); // Clear ctx.ui.setWidget("my-widget", undefined); // Clear

View file

@ -704,14 +704,17 @@ ctx.ui.setStatus("my-ext", undefined);
**Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts) **Examples:** [status-line.ts](../examples/extensions/status-line.ts), [plan-mode.ts](../examples/extensions/plan-mode.ts), [preset.ts](../examples/extensions/preset.ts)
### Pattern 5: Widget Above Editor ### Pattern 5: Widgets Above/Below Editor
Show persistent content above the input editor. Good for todo lists, progress. Show persistent content above or below the input editor. Good for todo lists, progress.
```typescript ```typescript
// Simple string array // Simple string array (above editor by default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// Render below the editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });
// Or with theme // Or with theme
ctx.ui.setWidget("my-widget", (_tui, theme) => { ctx.ui.setWidget("my-widget", (_tui, theme) => {
const lines = items.map((item, i) => const lines = items.map((item, i) =>

View file

@ -47,6 +47,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` | | `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | | `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | | `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
| `widget-placement.ts` | Shows widgets above and below the editor via `ctx.ui.setWidget()` placement |
| `model-status.ts` | Shows model changes in status bar via `model_select` hook | | `model-status.ts` | Shows model changes in status bar via `model_select` hook |
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | | `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions | | `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |

View file

@ -0,0 +1,17 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
const applyWidgets = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return;
ctx.ui.setWidget("widget-above", ["Above editor widget"]);
ctx.ui.setWidget("widget-below", ["Below editor widget"], { placement: "belowEditor" });
};
export default function widgetPlacementExtension(pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
applyWidgets(ctx);
});
pi.on("session_switch", (_event, ctx) => {
applyWidgets(ctx);
});
}

View file

@ -58,6 +58,7 @@ export type {
ExtensionShortcut, ExtensionShortcut,
ExtensionUIContext, ExtensionUIContext,
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
ExtensionWidgetOptions,
FindToolResultEvent, FindToolResultEvent,
GetActiveToolsHandler, GetActiveToolsHandler,
GetAllToolsHandler, GetAllToolsHandler,
@ -116,6 +117,7 @@ export type {
// Events - User Bash // Events - User Bash
UserBashEvent, UserBashEvent,
UserBashEventResult, UserBashEventResult,
WidgetPlacement,
WriteToolResultEvent, WriteToolResultEvent,
} from "./types.js"; } from "./types.js";
// Type guards // Type guards

View file

@ -68,6 +68,15 @@ export interface ExtensionUIDialogOptions {
timeout?: number; timeout?: number;
} }
/** Placement for extension widgets. */
export type WidgetPlacement = "aboveEditor" | "belowEditor";
/** Options for extension widgets. */
export interface ExtensionWidgetOptions {
/** Where the widget is rendered. Defaults to "aboveEditor". */
placement?: WidgetPlacement;
}
/** /**
* UI context for extensions to request interactive UI. * UI context for extensions to request interactive UI.
* Each mode (interactive, RPC, print) provides its own implementation. * Each mode (interactive, RPC, print) provides its own implementation.
@ -91,9 +100,13 @@ export interface ExtensionUIContext {
/** Set the working/loading message shown during streaming. Call with no argument to restore default. */ /** Set the working/loading message shown during streaming. Call with no argument to restore default. */
setWorkingMessage(message?: string): void; setWorkingMessage(message?: string): void;
/** Set a widget to display above the editor. Accepts string array or component factory. */ /** Set a widget to display above or below the editor. Accepts string array or component factory. */
setWidget(key: string, content: string[] | undefined): void; setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void;
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; setWidget(
key: string,
content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined,
options?: ExtensionWidgetOptions,
): void;
/** Set a custom footer component, or undefined to restore the built-in footer. /** Set a custom footer component, or undefined to restore the built-in footer.
* *

View file

@ -66,6 +66,7 @@ export type {
ExtensionShortcut, ExtensionShortcut,
ExtensionUIContext, ExtensionUIContext,
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
ExtensionWidgetOptions,
InputEvent, InputEvent,
InputEventResult, InputEventResult,
InputSource, InputSource,
@ -94,6 +95,7 @@ export type {
TurnStartEvent, TurnStartEvent,
UserBashEvent, UserBashEvent,
UserBashEventResult, UserBashEventResult,
WidgetPlacement,
} from "./core/extensions/index.js"; } from "./core/extensions/index.js";
export { export {
ExtensionRunner, ExtensionRunner,

View file

@ -50,6 +50,7 @@ import type {
ExtensionRunner, ExtensionRunner,
ExtensionUIContext, ExtensionUIContext,
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../core/extensions/index.js"; } from "../../core/extensions/index.js";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
import { type AppAction, KeybindingsManager } from "../../core/keybindings.js"; import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
@ -206,9 +207,11 @@ export class InteractiveMode {
private extensionInput: ExtensionInputComponent | undefined = undefined; private extensionInput: ExtensionInputComponent | undefined = undefined;
private extensionEditor: ExtensionEditorComponent | undefined = undefined; private extensionEditor: ExtensionEditorComponent | undefined = undefined;
// Extension widgets (components rendered above the editor) // Extension widgets (components rendered above/below the editor)
private extensionWidgets = new Map<string, Component & { dispose?(): void }>(); private extensionWidgetsAbove = new Map<string, Component & { dispose?(): void }>();
private widgetContainer!: Container; private extensionWidgetsBelow = new Map<string, Component & { dispose?(): void }>();
private widgetContainerAbove!: Container;
private widgetContainerBelow!: Container;
// Custom footer from extension (undefined = use built-in footer) // Custom footer from extension (undefined = use built-in footer)
private customFooter: (Component & { dispose?(): void }) | undefined = undefined; private customFooter: (Component & { dispose?(): void }) | undefined = undefined;
@ -240,7 +243,8 @@ export class InteractiveMode {
this.chatContainer = new Container(); this.chatContainer = new Container();
this.pendingMessagesContainer = new Container(); this.pendingMessagesContainer = new Container();
this.statusContainer = new Container(); this.statusContainer = new Container();
this.widgetContainer = new Container(); this.widgetContainerAbove = new Container();
this.widgetContainerBelow = new Container();
this.keybindings = KeybindingsManager.create(); this.keybindings = KeybindingsManager.create();
const editorPaddingX = this.settingsManager.getEditorPaddingX(); const editorPaddingX = this.settingsManager.getEditorPaddingX();
this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { paddingX: editorPaddingX }); this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { paddingX: editorPaddingX });
@ -427,9 +431,10 @@ export class InteractiveMode {
this.ui.addChild(this.chatContainer); this.ui.addChild(this.chatContainer);
this.ui.addChild(this.pendingMessagesContainer); this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer); this.ui.addChild(this.statusContainer);
this.ui.addChild(this.widgetContainer);
this.renderWidgets(); // Initialize with default spacer this.renderWidgets(); // Initialize with default spacer
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer); this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footer); this.ui.addChild(this.footer);
this.ui.setFocus(this.editor); this.ui.setFocus(this.editor);
@ -877,14 +882,26 @@ export class InteractiveMode {
private setExtensionWidget( private setExtensionWidget(
key: string, key: string,
content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined,
options?: ExtensionWidgetOptions,
): void { ): void {
// Dispose and remove existing widget const placement = options?.placement ?? "aboveEditor";
const existing = this.extensionWidgets.get(key); const removeExisting = (map: Map<string, Component & { dispose?(): void }>) => {
if (existing?.dispose) existing.dispose(); const existing = map.get(key);
if (existing?.dispose) existing.dispose();
map.delete(key);
};
removeExisting(this.extensionWidgetsAbove);
removeExisting(this.extensionWidgetsBelow);
if (content === undefined) { if (content === undefined) {
this.extensionWidgets.delete(key); this.renderWidgets();
} else if (Array.isArray(content)) { return;
}
let component: Component & { dispose?(): void };
if (Array.isArray(content)) {
// Wrap string array in a Container with Text components // Wrap string array in a Container with Text components
const container = new Container(); const container = new Container();
for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) { for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) {
@ -893,12 +910,14 @@ export class InteractiveMode {
if (content.length > InteractiveMode.MAX_WIDGET_LINES) { if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
} }
this.extensionWidgets.set(key, container); component = container;
} else { } else {
// Factory function - create component // Factory function - create component
const component = content(this.ui, theme); component = content(this.ui, theme);
this.extensionWidgets.set(key, component);
} }
const targetMap = placement === "belowEditor" ? this.extensionWidgetsBelow : this.extensionWidgetsAbove;
targetMap.set(key, component);
this.renderWidgets(); this.renderWidgets();
} }
@ -909,21 +928,33 @@ export class InteractiveMode {
* Render all extension widgets to the widget container. * Render all extension widgets to the widget container.
*/ */
private renderWidgets(): void { private renderWidgets(): void {
if (!this.widgetContainer) return; if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;
this.widgetContainer.clear(); this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true);
this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false);
this.ui.requestRender();
}
if (this.extensionWidgets.size === 0) { private renderWidgetContainer(
this.widgetContainer.addChild(new Spacer(1)); container: Container,
this.ui.requestRender(); widgets: Map<string, Component & { dispose?(): void }>,
spacerWhenEmpty: boolean,
leadingSpacer: boolean,
): void {
container.clear();
if (widgets.size === 0) {
if (spacerWhenEmpty) {
container.addChild(new Spacer(1));
}
return; return;
} }
this.widgetContainer.addChild(new Spacer(1)); if (leadingSpacer) {
for (const [_key, component] of this.extensionWidgets) { container.addChild(new Spacer(1));
this.widgetContainer.addChild(component); }
for (const component of widgets.values()) {
container.addChild(component);
} }
this.ui.requestRender();
} }
/** /**
@ -1014,7 +1045,7 @@ export class InteractiveMode {
} }
} }
}, },
setWidget: (key, content) => this.setExtensionWidget(key, content), setWidget: (key, content, options) => this.setExtensionWidget(key, content, options),
setFooter: (factory) => this.setExtensionFooter(factory), setFooter: (factory) => this.setExtensionFooter(factory),
setHeader: (factory) => this.setExtensionHeader(factory), setHeader: (factory) => this.setExtensionHeader(factory),
setTitle: (title) => this.ui.terminal.setTitle(title), setTitle: (title) => this.ui.terminal.setTitle(title),

View file

@ -14,7 +14,11 @@
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import * as readline from "readline"; import * as readline from "readline";
import type { AgentSession } from "../../core/agent-session.js"; import type { AgentSession } from "../../core/agent-session.js";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index.js"; import type {
ExtensionUIContext,
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
} from "../../core/extensions/index.js";
import { type Theme, theme } from "../interactive/theme/theme.js"; import { type Theme, theme } from "../interactive/theme/theme.js";
import type { import type {
RpcCommand, RpcCommand,
@ -154,7 +158,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
// Working message not supported in RPC mode - requires TUI loader access // Working message not supported in RPC mode - requires TUI loader access
}, },
setWidget(key: string, content: unknown): void { setWidget(key: string, content: unknown, options?: ExtensionWidgetOptions): void {
// Only support string arrays in RPC mode - factory functions are ignored // Only support string arrays in RPC mode - factory functions are ignored
if (content === undefined || Array.isArray(content)) { if (content === undefined || Array.isArray(content)) {
output({ output({
@ -163,6 +167,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
method: "setWidget", method: "setWidget",
widgetKey: key, widgetKey: key,
widgetLines: content as string[] | undefined, widgetLines: content as string[] | undefined,
widgetPlacement: options?.placement,
} as RpcExtensionUIRequest); } as RpcExtensionUIRequest);
} }
// Component factories are not supported in RPC mode - would need TUI access // Component factories are not supported in RPC mode - would need TUI access

View file

@ -208,6 +208,7 @@ export type RpcExtensionUIRequest =
method: "setWidget"; method: "setWidget";
widgetKey: string; widgetKey: string;
widgetLines: string[] | undefined; widgetLines: string[] | undefined;
widgetPlacement?: "aboveEditor" | "belowEditor";
} }
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };