From abb1775ff7c98ad8b3b1b7a5b79f1fb1411dfdf7 Mon Sep 17 00:00:00 2001 From: Marc Krenn Date: Mon, 19 Jan 2026 15:54:24 +0100 Subject: [PATCH] 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 --- packages/coding-agent/README.md | 3 +- packages/coding-agent/docs/extensions.md | 8 +- packages/coding-agent/docs/tui.md | 9 ++- .../examples/extensions/README.md | 1 + .../examples/extensions/widget-placement.ts | 17 ++++ .../coding-agent/src/core/extensions/index.ts | 2 + .../coding-agent/src/core/extensions/types.ts | 19 ++++- packages/coding-agent/src/index.ts | 2 + .../src/modes/interactive/interactive-mode.ts | 79 +++++++++++++------ .../coding-agent/src/modes/rpc/rpc-mode.ts | 9 ++- .../coding-agent/src/modes/rpc/rpc-types.ts | 1 + 11 files changed, 114 insertions(+), 36 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/widget-placement.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1e1fbf79..70fe6f0e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -1184,8 +1184,9 @@ ctx.ui.notify("Done!", "success"); // success, info, warning, error ctx.ui.setStatus("my-ext", "Processing..."); 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"], { placement: "belowEditor" }); // Custom footer (replaces built-in footer) ctx.ui.setFooter((tui, theme) => ({ diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index cf62e8ef..44d6780d 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -184,7 +184,7 @@ export default function (pi: ExtensionAPI) { const ok = await ctx.ui.confirm("Title", "Are you sure?"); ctx.ui.notify("Done!", "success"); 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 @@ -1366,7 +1366,7 @@ Extensions can interact with users via `ctx.ui` methods and customize how messag - Settings toggles (SettingsList) - Status indicators (setStatus) - Working message during streaming (setWorkingMessage) -- Widgets above editor (setWidget) +- Widgets above/below editor (setWidget) - Custom footers (setFooter) ### Dialogs @@ -1456,8 +1456,10 @@ ctx.ui.setStatus("my-ext", undefined); // Clear ctx.ui.setWorkingMessage("Thinking deeply..."); 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"]); +// 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", undefined); // Clear diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 0c284a34..c8ca032e 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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) -### 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 -// Simple string array +// Simple string array (above editor by default) 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 ctx.ui.setWidget("my-widget", (_tui, theme) => { const lines = items.map((item, i) => diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 5ccbc0ae..48b75dd6 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -47,6 +47,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `handoff.ts` | Transfer context to a new focused session via `/handoff ` | | `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 | +| `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 | | `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 | diff --git a/packages/coding-agent/examples/extensions/widget-placement.ts b/packages/coding-agent/examples/extensions/widget-placement.ts new file mode 100644 index 00000000..349c4a54 --- /dev/null +++ b/packages/coding-agent/examples/extensions/widget-placement.ts @@ -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); + }); +} diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index ff81d28e..d10d0a1a 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -58,6 +58,7 @@ export type { ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, + ExtensionWidgetOptions, FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, @@ -116,6 +117,7 @@ export type { // Events - User Bash UserBashEvent, UserBashEventResult, + WidgetPlacement, WriteToolResultEvent, } from "./types.js"; // Type guards diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 8a2c3579..2a058160 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -68,6 +68,15 @@ export interface ExtensionUIDialogOptions { 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. * 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. */ setWorkingMessage(message?: string): void; - /** Set a widget to display above the editor. Accepts string array or component factory. */ - setWidget(key: string, content: string[] | undefined): void; - setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; + /** Set a widget to display above or below the editor. Accepts string array or component factory. */ + setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): 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. * diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index c79f4f8b..7dbd741d 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -66,6 +66,7 @@ export type { ExtensionShortcut, ExtensionUIContext, ExtensionUIDialogOptions, + ExtensionWidgetOptions, InputEvent, InputEventResult, InputSource, @@ -94,6 +95,7 @@ export type { TurnStartEvent, UserBashEvent, UserBashEventResult, + WidgetPlacement, } from "./core/extensions/index.js"; export { ExtensionRunner, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5297d077..61173f3c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -50,6 +50,7 @@ import type { ExtensionRunner, ExtensionUIContext, ExtensionUIDialogOptions, + ExtensionWidgetOptions, } from "../../core/extensions/index.js"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js"; import { type AppAction, KeybindingsManager } from "../../core/keybindings.js"; @@ -206,9 +207,11 @@ export class InteractiveMode { private extensionInput: ExtensionInputComponent | undefined = undefined; private extensionEditor: ExtensionEditorComponent | undefined = undefined; - // Extension widgets (components rendered above the editor) - private extensionWidgets = new Map(); - private widgetContainer!: Container; + // Extension widgets (components rendered above/below the editor) + private extensionWidgetsAbove = new Map(); + private extensionWidgetsBelow = new Map(); + private widgetContainerAbove!: Container; + private widgetContainerBelow!: Container; // Custom footer from extension (undefined = use built-in footer) private customFooter: (Component & { dispose?(): void }) | undefined = undefined; @@ -240,7 +243,8 @@ export class InteractiveMode { this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); this.statusContainer = new Container(); - this.widgetContainer = new Container(); + this.widgetContainerAbove = new Container(); + this.widgetContainerBelow = new Container(); this.keybindings = KeybindingsManager.create(); const editorPaddingX = this.settingsManager.getEditorPaddingX(); 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.pendingMessagesContainer); this.ui.addChild(this.statusContainer); - this.ui.addChild(this.widgetContainer); this.renderWidgets(); // Initialize with default spacer + this.ui.addChild(this.widgetContainerAbove); this.ui.addChild(this.editorContainer); + this.ui.addChild(this.widgetContainerBelow); this.ui.addChild(this.footer); this.ui.setFocus(this.editor); @@ -877,14 +882,26 @@ export class InteractiveMode { private setExtensionWidget( key: string, content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, + options?: ExtensionWidgetOptions, ): void { - // Dispose and remove existing widget - const existing = this.extensionWidgets.get(key); - if (existing?.dispose) existing.dispose(); + const placement = options?.placement ?? "aboveEditor"; + const removeExisting = (map: Map) => { + const existing = map.get(key); + if (existing?.dispose) existing.dispose(); + map.delete(key); + }; + + removeExisting(this.extensionWidgetsAbove); + removeExisting(this.extensionWidgetsBelow); if (content === undefined) { - this.extensionWidgets.delete(key); - } else if (Array.isArray(content)) { + this.renderWidgets(); + return; + } + + let component: Component & { dispose?(): void }; + + if (Array.isArray(content)) { // Wrap string array in a Container with Text components const container = new Container(); 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) { container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); } - this.extensionWidgets.set(key, container); + component = container; } else { // Factory function - create component - const component = content(this.ui, theme); - this.extensionWidgets.set(key, component); + component = content(this.ui, theme); } + + const targetMap = placement === "belowEditor" ? this.extensionWidgetsBelow : this.extensionWidgetsAbove; + targetMap.set(key, component); this.renderWidgets(); } @@ -909,21 +928,33 @@ export class InteractiveMode { * Render all extension widgets to the widget container. */ private renderWidgets(): void { - if (!this.widgetContainer) return; - this.widgetContainer.clear(); + if (!this.widgetContainerAbove || !this.widgetContainerBelow) return; + this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true); + this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false); + this.ui.requestRender(); + } - if (this.extensionWidgets.size === 0) { - this.widgetContainer.addChild(new Spacer(1)); - this.ui.requestRender(); + private renderWidgetContainer( + container: Container, + widgets: Map, + spacerWhenEmpty: boolean, + leadingSpacer: boolean, + ): void { + container.clear(); + + if (widgets.size === 0) { + if (spacerWhenEmpty) { + container.addChild(new Spacer(1)); + } return; } - this.widgetContainer.addChild(new Spacer(1)); - for (const [_key, component] of this.extensionWidgets) { - this.widgetContainer.addChild(component); + if (leadingSpacer) { + container.addChild(new Spacer(1)); + } + 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), setHeader: (factory) => this.setExtensionHeader(factory), setTitle: (title) => this.ui.terminal.setTitle(title), diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 95575bec..39f0ef17 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -14,7 +14,11 @@ import * as crypto from "node:crypto"; import * as readline from "readline"; 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 { RpcCommand, @@ -154,7 +158,7 @@ export async function runRpcMode(session: AgentSession): Promise { // 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 if (content === undefined || Array.isArray(content)) { output({ @@ -163,6 +167,7 @@ export async function runRpcMode(session: AgentSession): Promise { method: "setWidget", widgetKey: key, widgetLines: content as string[] | undefined, + widgetPlacement: options?.placement, } as RpcExtensionUIRequest); } // Component factories are not supported in RPC mode - would need TUI access diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index ba173e12..ba8abf15 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -208,6 +208,7 @@ export type RpcExtensionUIRequest = method: "setWidget"; widgetKey: string; widgetLines: string[] | undefined; + widgetPlacement?: "aboveEditor" | "belowEditor"; } | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };