feat: add ctx.ui.setWorkingMessage() extension API

Allows extensions to customize the streaming loader message.
Pass undefined to restore default.
This commit is contained in:
Nico Bailon 2026-01-10 21:02:41 -08:00
parent 016a24e9a1
commit 271b49da3c
6 changed files with 24 additions and 1 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Added
- `ctx.ui.setWorkingMessage()` extension API to customize the "Working..." message during streaming
## [0.42.5] - 2026-01-11 ## [0.42.5] - 2026-01-11
### Fixed ### Fixed

View file

@ -1170,6 +1170,7 @@ Extensions can interact with users via `ctx.ui` methods and customize how messag
- Async operations with cancel (BorderedLoader) - Async operations with cancel (BorderedLoader)
- Settings toggles (SettingsList) - Settings toggles (SettingsList)
- Status indicators (setStatus) - Status indicators (setStatus)
- Working message during streaming (setWorkingMessage)
- Widgets above editor (setWidget) - Widgets above editor (setWidget)
- Custom footers (setFooter) - Custom footers (setFooter)
@ -1256,6 +1257,10 @@ See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm.
ctx.ui.setStatus("my-ext", "Processing..."); ctx.ui.setStatus("my-ext", "Processing...");
ctx.ui.setStatus("my-ext", undefined); // Clear ctx.ui.setStatus("my-ext", undefined); // Clear
// Working message (shown during streaming)
ctx.ui.setWorkingMessage("Thinking deeply...");
ctx.ui.setWorkingMessage(); // Restore default
// Widget above editor (string array or factory function) // Widget above editor (string array or factory function)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]); ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
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));

View file

@ -79,6 +79,7 @@ const noOpUIContext: ExtensionUIContext = {
input: async () => undefined, input: async () => undefined,
notify: () => {}, notify: () => {},
setStatus: () => {}, setStatus: () => {},
setWorkingMessage: () => {},
setWidget: () => {}, setWidget: () => {},
setFooter: () => {}, setFooter: () => {},
setHeader: () => {}, setHeader: () => {},

View file

@ -79,6 +79,9 @@ export interface ExtensionUIContext {
/** Set status text in the footer/status bar. Pass undefined to clear. */ /** Set status text in the footer/status bar. Pass undefined to clear. */
setStatus(key: string, text: string | undefined): void; setStatus(key: string, text: string | undefined): void;
/** 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. */ /** 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: string[] | undefined): 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): void;

View file

@ -135,6 +135,7 @@ export class InteractiveMode {
private isInitialized = false; private isInitialized = false;
private onInputCallback?: (text: string) => void; private onInputCallback?: (text: string) => void;
private loadingAnimation: Loader | undefined = undefined; private loadingAnimation: Loader | undefined = undefined;
private readonly defaultWorkingMessage = "Working... (esc to interrupt)";
private lastSigintTime = 0; private lastSigintTime = 0;
private lastEscapeTime = 0; private lastEscapeTime = 0;
@ -948,6 +949,11 @@ export class InteractiveMode {
input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts),
notify: (message, type) => this.showExtensionNotify(message, type), notify: (message, type) => this.showExtensionNotify(message, type),
setStatus: (key, text) => this.setExtensionStatus(key, text), setStatus: (key, text) => this.setExtensionStatus(key, text),
setWorkingMessage: (message) => {
if (this.loadingAnimation) {
this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage);
}
},
setWidget: (key, content) => this.setExtensionWidget(key, content), setWidget: (key, content) => this.setExtensionWidget(key, content),
setFooter: (factory) => this.setExtensionFooter(factory), setFooter: (factory) => this.setExtensionFooter(factory),
setHeader: (factory) => this.setExtensionHeader(factory), setHeader: (factory) => this.setExtensionHeader(factory),
@ -1559,7 +1565,7 @@ export class InteractiveMode {
this.ui, this.ui,
(spinner) => theme.fg("accent", spinner), (spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text), (text) => theme.fg("muted", text),
"Working... (esc to interrupt)", this.defaultWorkingMessage,
); );
this.statusContainer.addChild(this.loadingAnimation); this.statusContainer.addChild(this.loadingAnimation);
this.ui.requestRender(); this.ui.requestRender();

View file

@ -150,6 +150,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
} as RpcExtensionUIRequest); } as RpcExtensionUIRequest);
}, },
setWorkingMessage(_message?: string): void {
// Working message not supported in RPC mode - requires TUI loader access
},
setWidget(key: string, content: unknown): void { setWidget(key: string, content: unknown): 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)) {