feat(coding-agent): add configurable working indicator closes #3413

This commit is contained in:
Mario Zechner
2026-04-20 16:28:39 +02:00
parent 61579214c8
commit 74139c3f66
15 changed files with 248 additions and 65 deletions

View File

@@ -4,6 +4,7 @@
### Added ### Added
- Added extension support for customizing the interactive streaming working indicator via `ctx.ui.setWorkingIndicator()`, including custom animated frames, static indicators, hidden indicators, a new `working-indicator.ts` example extension, and updated extension/TUI/RPC docs ([#3413](https://github.com/badlogic/pi-mono/issues/3413))
- Added `/clone` to duplicate the current active branch into a new session, while keeping `/fork` focused on forking from a previous user message ([#2962](https://github.com/badlogic/pi-mono/issues/2962)) - Added `/clone` to duplicate the current active branch into a new session, while keeping `/fork` focused on forking from a previous user message ([#2962](https://github.com/badlogic/pi-mono/issues/2962))
### Fixed ### Fixed

View File

@@ -1922,7 +1922,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) - Working message and indicator during streaming (`setWorkingMessage`, `setWorkingIndicator`)
- Widgets above/below editor (setWidget) - Widgets above/below editor (setWidget)
- Custom footers (setFooter) - Custom footers (setFooter)
@@ -2007,6 +2007,12 @@ 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
// Working indicator (shown during streaming)
ctx.ui.setWorkingIndicator({ frames: ["●"] }); // Static dot
ctx.ui.setWorkingIndicator({ frames: ["·", "•", "●", "•"], intervalMs: 120 });
ctx.ui.setWorkingIndicator({ frames: [] }); // Hide indicator
ctx.ui.setWorkingIndicator(); // Restore default spinner
// Widget above editor (default) // 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 // Widget below editor
@@ -2271,6 +2277,7 @@ All examples in [examples/extensions/](../examples/extensions/).
| `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` | | `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` |
| **UI Components** ||| | **UI Components** |||
| `status-line.ts` | Footer status indicator | `setStatus`, session events | | `status-line.ts` | Footer status indicator | `setStatus`, session events |
| `working-indicator.ts` | Customize the streaming working indicator | `setWorkingIndicator`, `registerCommand` |
| `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` | | `custom-footer.ts` | Replace footer entirely | `registerCommand`, `setFooter` |
| `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` | | `custom-header.ts` | Replace startup header | `on("session_start")`, `setHeader` |
| `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` | | `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` |

View File

@@ -994,7 +994,7 @@ If a dialog method includes a `timeout` field, the agent-side will auto-resolve
Some `ExtensionUIContext` methods are not supported or degraded in RPC mode because they require direct TUI access: Some `ExtensionUIContext` methods are not supported or degraded in RPC mode because they require direct TUI access:
- `custom()` returns `undefined` - `custom()` returns `undefined`
- `setWorkingMessage()`, `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops - `setWorkingMessage()`, `setWorkingIndicator()`, `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops
- `getEditorText()` returns `""` - `getEditorText()` returns `""`
- `getToolsExpanded()` returns `false` - `getToolsExpanded()` returns `false`
- `pasteToEditor()` delegates to `setEditorText()` (no paste/collapse handling) - `pasteToEditor()` delegates to `setEditorText()` (no paste/collapse handling)

View File

@@ -735,6 +735,31 @@ 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 4b: Working Indicator Customization
Customize the inline working indicator shown while pi is streaming a response.
```typescript
// Static indicator
ctx.ui.setWorkingIndicator({ frames: ["●"] });
// Custom animated indicator
ctx.ui.setWorkingIndicator({
frames: ["·", "•", "●", "•"],
intervalMs: 120,
});
// Hide the indicator entirely
ctx.ui.setWorkingIndicator({ frames: [] });
// Restore pi's default spinner
ctx.ui.setWorkingIndicator();
```
This only affects the normal streaming working indicator. Compaction and retry loaders keep their built-in styling.
**Examples:** [working-indicator.ts](../examples/extensions/working-indicator.ts)
### Pattern 5: Widgets Above/Below Editor ### Pattern 5: Widgets Above/Below Editor
Show persistent content above or below the input editor. Good for todo lists, progress. Show persistent content above or below the input editor. Good for todo lists, progress.
@@ -881,6 +906,7 @@ export default function (pi: ExtensionAPI) {
- **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls - **Async with cancel**: [examples/extensions/qna.ts](../examples/extensions/qna.ts) - BorderedLoader for LLM calls
- **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable - **Settings toggles**: [examples/extensions/tools.ts](../examples/extensions/tools.ts) - SettingsList for tool enable/disable
- **Status indicators**: [examples/extensions/plan-mode.ts](../examples/extensions/plan-mode.ts) - setStatus and setWidget - **Status indicators**: [examples/extensions/plan-mode.ts](../examples/extensions/plan-mode.ts) - setStatus and setWidget
- **Working indicator**: [examples/extensions/working-indicator.ts](../examples/extensions/working-indicator.ts) - setWorkingIndicator
- **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats - **Custom footer**: [examples/extensions/custom-footer.ts](../examples/extensions/custom-footer.ts) - setFooter with stats
- **Custom editor**: [examples/extensions/modal-editor.ts](../examples/extensions/modal-editor.ts) - Vim-like modal editing - **Custom editor**: [examples/extensions/modal-editor.ts](../examples/extensions/modal-editor.ts) - Vim-like modal editing
- **Snake game**: [examples/extensions/snake.ts](../examples/extensions/snake.ts) - Full game with keyboard input, game loop - **Snake game**: [examples/extensions/snake.ts](../examples/extensions/snake.ts) - Full game with keyboard input, game loop

View File

@@ -53,6 +53,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
| `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 | | `widget-placement.ts` | Shows widgets above and below the editor via `ctx.ui.setWidget()` placement |
| `hidden-thinking-label.ts` | Customizes the collapsed thinking label via `ctx.ui.setHiddenThinkingLabel()` | | `hidden-thinking-label.ts` | Customizes the collapsed thinking label via `ctx.ui.setHiddenThinkingLabel()` |
| `working-indicator.ts` | Customizes the streaming working indicator via `ctx.ui.setWorkingIndicator()` |
| `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 |
| `tic-tac-toe.ts` | Tic-tac-toe vs the agent with `executionMode: "sequential"` tools to prevent race conditions on shared cursor state | | `tic-tac-toe.ts` | Tic-tac-toe vs the agent with `executionMode: "sequential"` tools to prevent race conditions on shared cursor state |

View File

@@ -0,0 +1,105 @@
/**
* Working Indicator Extension
*
* Demonstrates `ctx.ui.setWorkingIndicator()` for customizing the inline
* working indicator shown while pi is streaming a response.
*
* Usage:
* pi --extension examples/extensions/working-indicator.ts
*
* Commands:
* /working-indicator Show current mode
* /working-indicator dot Use a static dot indicator
* /working-indicator pulse Use a custom animated indicator
* /working-indicator none Hide the indicator entirely
* /working-indicator spinner Restore an animated spinner
* /working-indicator reset Restore pi's default spinner
*/
import type { ExtensionAPI, ExtensionContext, WorkingIndicatorOptions } from "@mariozechner/pi-coding-agent";
type WorkingIndicatorMode = "dot" | "none" | "pulse" | "spinner" | "default";
const SPINNER_INDICATOR: WorkingIndicatorOptions = {
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
intervalMs: 80,
};
const DOT_INDICATOR: WorkingIndicatorOptions = {
frames: ["●"],
};
const PULSE_INDICATOR: WorkingIndicatorOptions = {
frames: ["·", "•", "●", "•"],
intervalMs: 120,
};
const HIDDEN_INDICATOR: WorkingIndicatorOptions = {
frames: [],
};
function getIndicator(mode: WorkingIndicatorMode): WorkingIndicatorOptions | undefined {
switch (mode) {
case "dot":
return DOT_INDICATOR;
case "none":
return HIDDEN_INDICATOR;
case "pulse":
return PULSE_INDICATOR;
case "spinner":
return SPINNER_INDICATOR;
case "default":
return undefined;
}
}
function describeMode(mode: WorkingIndicatorMode): string {
switch (mode) {
case "dot":
return "static dot";
case "none":
return "hidden";
case "pulse":
return "custom pulse";
case "spinner":
return "custom spinner";
case "default":
return "pi default spinner";
}
}
export default function (pi: ExtensionAPI) {
let mode: WorkingIndicatorMode = "dot";
const applyIndicator = (ctx: ExtensionContext) => {
ctx.ui.setWorkingIndicator(getIndicator(mode));
ctx.ui.setStatus("working-indicator", ctx.ui.theme.fg("dim", `Indicator: ${describeMode(mode)}`));
};
pi.on("session_start", async (_event, ctx) => {
applyIndicator(ctx);
});
pi.registerCommand("working-indicator", {
description: "Set the streaming working indicator: dot, pulse, none, spinner, or reset.",
handler: async (args, ctx) => {
const nextMode = args.trim().toLowerCase();
if (!nextMode) {
ctx.ui.notify(`Working indicator: ${describeMode(mode)}`, "info");
return;
}
if (
nextMode !== "dot" &&
nextMode !== "none" &&
nextMode !== "pulse" &&
nextMode !== "spinner" &&
nextMode !== "reset"
) {
ctx.ui.notify("Usage: /working-indicator [dot|pulse|none|spinner|reset]", "error");
return;
}
mode = nextMode === "reset" ? "default" : nextMode;
applyIndicator(ctx);
ctx.ui.notify(`Working indicator set to: ${describeMode(mode)}`, "info");
},
});
}

View File

@@ -149,6 +149,7 @@ export type {
UserBashEvent, UserBashEvent,
UserBashEventResult, UserBashEventResult,
WidgetPlacement, WidgetPlacement,
WorkingIndicatorOptions,
WriteToolCallEvent, WriteToolCallEvent,
WriteToolResultEvent, WriteToolResultEvent,
} from "./types.js"; } from "./types.js";

View File

@@ -167,8 +167,8 @@ export type ShutdownHandler = () => void;
* Helper function to emit session_shutdown event to extensions. * Helper function to emit session_shutdown event to extensions.
* Returns true if the event was emitted, false if there were no handlers. * Returns true if the event was emitted, false if there were no handlers.
*/ */
export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise<boolean> { export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner): Promise<boolean> {
if (extensionRunner?.hasHandlers("session_shutdown")) { if (extensionRunner.hasHandlers("session_shutdown")) {
await extensionRunner.emit({ await extensionRunner.emit({
type: "session_shutdown", type: "session_shutdown",
}); });
@@ -185,6 +185,7 @@ const noOpUIContext: ExtensionUIContext = {
onTerminalInput: () => () => {}, onTerminalInput: () => () => {},
setStatus: () => {}, setStatus: () => {},
setWorkingMessage: () => {}, setWorkingMessage: () => {},
setWorkingIndicator: () => {},
setHiddenThinkingLabel: () => {}, setHiddenThinkingLabel: () => {},
setWidget: () => {}, setWidget: () => {},
setFooter: () => {}, setFooter: () => {},

View File

@@ -102,6 +102,14 @@ export interface ExtensionWidgetOptions {
/** Raw terminal input listener for extensions. */ /** Raw terminal input listener for extensions. */
export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined; export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined;
/** Working indicator configuration for the interactive streaming loader. */
export interface WorkingIndicatorOptions {
/** Animation frames. Use an empty array to hide the indicator entirely. */
frames?: string[];
/** Frame interval in milliseconds for animated indicators. */
intervalMs?: number;
}
/** /**
* 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.
@@ -128,6 +136,15 @@ 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;
/**
* Configure the interactive working indicator shown during streaming.
*
* - Omit the argument to restore the default animated spinner.
* - Use `frames: ["●"]` for a static indicator.
* - Use `frames: []` to hide the indicator entirely.
*/
setWorkingIndicator(options?: WorkingIndicatorOptions): void;
/** Set the label shown for hidden thinking blocks. Call with no argument to restore default. */ /** Set the label shown for hidden thinking blocks. Call with no argument to restore default. */
setHiddenThinkingLabel(label?: string): void; setHiddenThinkingLabel(label?: string): void;

View File

@@ -70,5 +70,6 @@ export {
type ToolResultEvent, type ToolResultEvent,
type TurnEndEvent, type TurnEndEvent,
type TurnStartEvent, type TurnStartEvent,
type WorkingIndicatorOptions,
} from "./extensions/index.js"; } from "./extensions/index.js";
export { createSyntheticSourceInfo } from "./source-info.js"; export { createSyntheticSourceInfo } from "./source-info.js";

View File

@@ -122,6 +122,7 @@ export type {
UserBashEvent, UserBashEvent,
UserBashEventResult, UserBashEventResult,
WidgetPlacement, WidgetPlacement,
WorkingIndicatorOptions,
WriteToolCallEvent, WriteToolCallEvent,
} from "./core/extensions/index.js"; } from "./core/extensions/index.js";
export { export {

View File

@@ -26,6 +26,7 @@ import {
Container, Container,
fuzzyFilter, fuzzyFilter,
Loader, Loader,
type LoaderIndicatorOptions,
Markdown, Markdown,
matchesKey, matchesKey,
ProcessTerminal, ProcessTerminal,
@@ -201,6 +202,7 @@ export class InteractiveMode {
private onInputCallback?: (text: string) => void; private onInputCallback?: (text: string) => void;
private loadingAnimation: Loader | undefined = undefined; private loadingAnimation: Loader | undefined = undefined;
private pendingWorkingMessage: string | undefined = undefined; private pendingWorkingMessage: string | undefined = undefined;
private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined;
private readonly defaultWorkingMessage = "Working..."; private readonly defaultWorkingMessage = "Working...";
private readonly defaultHiddenThinkingLabel = "Thinking..."; private readonly defaultHiddenThinkingLabel = "Thinking...";
private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
@@ -370,11 +372,7 @@ export class InteractiveMode {
return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`; return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;
} }
private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner | undefined): ResourceDiagnostic[] { private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] {
if (!extensionRunner) {
return [];
}
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
return extensionRunner return extensionRunner
.getRegisteredCommands() .getRegisteredCommands()
@@ -436,13 +434,14 @@ export class InteractiveMode {
// Convert extension commands to SlashCommand format // Convert extension commands to SlashCommand format
const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
const extensionCommands: SlashCommand[] = ( const extensionCommands: SlashCommand[] = this.session.extensionRunner
this.session.extensionRunner?.getRegisteredCommands().filter((cmd) => !builtinCommandNames.has(cmd.name)) ?? [] .getRegisteredCommands()
).map((cmd) => ({ .filter((cmd) => !builtinCommandNames.has(cmd.name))
name: cmd.invocationName, .map((cmd) => ({
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), name: cmd.invocationName,
getArgumentCompletions: cmd.getArgumentCompletions, description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
})); getArgumentCompletions: cmd.getArgumentCompletions,
}));
// Build skill commands from session.skills (if enabled) // Build skill commands from session.skills (if enabled)
this.skillCommands.clear(); this.skillCommands.clear();
@@ -1393,11 +1392,11 @@ export class InteractiveMode {
} }
} }
const commandDiagnostics = this.session.extensionRunner?.getCommandDiagnostics() ?? []; const commandDiagnostics = this.session.extensionRunner.getCommandDiagnostics();
extensionDiagnostics.push(...commandDiagnostics); extensionDiagnostics.push(...commandDiagnostics);
extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner)); extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner));
const shortcutDiagnostics = this.session.extensionRunner?.getShortcutDiagnostics() ?? []; const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics();
extensionDiagnostics.push(...shortcutDiagnostics); extensionDiagnostics.push(...shortcutDiagnostics);
if (extensionDiagnostics.length > 0) { if (extensionDiagnostics.length > 0) {
@@ -1501,12 +1500,6 @@ export class InteractiveMode {
this.setupAutocomplete(this.fdPath); this.setupAutocomplete(this.fdPath);
const extensionRunner = this.session.extensionRunner; const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) {
this.showLoadedResources({ extensions: [], force: false });
this.showStartupNoticesIfNeeded();
return;
}
this.setupExtensionShortcuts(extensionRunner); this.setupExtensionShortcuts(extensionRunner);
this.showLoadedResources({ force: false }); this.showLoadedResources({ force: false });
this.showStartupNoticesIfNeeded(); this.showStartupNoticesIfNeeded();
@@ -1627,6 +1620,12 @@ export class InteractiveMode {
this.ui.requestRender(); this.ui.requestRender();
} }
private setWorkingIndicator(options?: LoaderIndicatorOptions): void {
this.workingIndicatorOptions = options;
this.loadingAnimation?.setIndicator(options);
this.ui.requestRender();
}
private setHiddenThinkingLabel(label?: string): void { private setHiddenThinkingLabel(label?: string): void {
this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel; this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel;
for (const child of this.chatContainer.children) { for (const child of this.chatContainer.children) {
@@ -1717,6 +1716,8 @@ export class InteractiveMode {
this.setCustomEditorComponent(undefined); this.setCustomEditorComponent(undefined);
this.defaultEditor.onExtensionShortcut = undefined; this.defaultEditor.onExtensionShortcut = undefined;
this.updateTerminalTitle(); this.updateTerminalTitle();
this.pendingWorkingMessage = undefined;
this.setWorkingIndicator();
if (this.loadingAnimation) { if (this.loadingAnimation) {
this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`); this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`);
} }
@@ -1879,9 +1880,7 @@ export class InteractiveMode {
this.pendingWorkingMessage = message; this.pendingWorkingMessage = message;
} }
}, },
setWorkingIndicator: () => { setWorkingIndicator: (options) => this.setWorkingIndicator(options),
// Working indicator customization not implemented in interactive mode yet.
},
setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label), setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label),
setWidget: (key, content, options) => this.setExtensionWidget(key, content, options), setWidget: (key, content, options) => this.setExtensionWidget(key, content, options),
setFooter: (factory) => this.setExtensionFooter(factory), setFooter: (factory) => this.setExtensionFooter(factory),
@@ -2580,6 +2579,7 @@ export class InteractiveMode {
(spinner) => theme.fg("accent", spinner), (spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text), (text) => theme.fg("muted", text),
this.defaultWorkingMessage, this.defaultWorkingMessage,
this.workingIndicatorOptions,
); );
this.statusContainer.addChild(this.loadingAnimation); this.statusContainer.addChild(this.loadingAnimation);
// Apply any pending working message queued before loader existed // Apply any pending working message queued before loader existed
@@ -2927,7 +2927,7 @@ export class InteractiveMode {
} }
case "custom": { case "custom": {
if (message.display) { if (message.display) {
const renderer = this.session.extensionRunner?.getMessageRenderer(message.customType); const renderer = this.session.extensionRunner.getMessageRenderer(message.customType);
const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings()); const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings());
component.setExpanded(this.toolOutputExpanded); component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component); this.chatContainer.addChild(component);
@@ -3531,7 +3531,6 @@ export class InteractiveMode {
if (!text.startsWith("/")) return false; if (!text.startsWith("/")) return false;
const extensionRunner = this.session.extensionRunner; const extensionRunner = this.session.extensionRunner;
if (!extensionRunner) return false;
const spaceIndex = text.indexOf(" "); const spaceIndex = text.indexOf(" ");
const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
@@ -4478,9 +4477,7 @@ export class InteractiveMode {
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.setupAutocomplete(this.fdPath); this.setupAutocomplete(this.fdPath);
const runner = this.session.extensionRunner; const runner = this.session.extensionRunner;
if (runner) { this.setupExtensionShortcuts(runner);
this.setupExtensionShortcuts(runner);
}
this.rebuildChatFromMessages(); this.rebuildChatFromMessages();
dismissReloadBox(this.editor as Component); dismissReloadBox(this.editor as Component);
this.showLoadedResources({ this.showLoadedResources({
@@ -4904,19 +4901,17 @@ export class InteractiveMode {
// Add extension-registered shortcuts // Add extension-registered shortcuts
const extensionRunner = this.session.extensionRunner; const extensionRunner = this.session.extensionRunner;
if (extensionRunner) { const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); if (shortcuts.size > 0) {
if (shortcuts.size > 0) { hotkeys += `
hotkeys += `
**Extensions** **Extensions**
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
`; `;
for (const [key, shortcut] of shortcuts) { for (const [key, shortcut] of shortcuts) {
const description = shortcut.description ?? shortcut.extensionPath; const description = shortcut.description ?? shortcut.extensionPath;
const keyDisplay = key.replace(/\b\w/g, (c) => c.toUpperCase()); const keyDisplay = key.replace(/\b\w/g, (c) => c.toUpperCase());
hotkeys += `| \`${keyDisplay}\` | ${description} |\n`; hotkeys += `| \`${keyDisplay}\` | ${description} |\n`;
}
} }
} }
@@ -5011,14 +5006,12 @@ export class InteractiveMode {
const extensionRunner = this.session.extensionRunner; const extensionRunner = this.session.extensionRunner;
// Emit user_bash event to let extensions intercept // Emit user_bash event to let extensions intercept
const eventResult = extensionRunner const eventResult = await extensionRunner.emitUserBash({
? await extensionRunner.emitUserBash({ type: "user_bash",
type: "user_bash", command,
command, excludeFromContext,
excludeFromContext, cwd: this.sessionManager.getCwd(),
cwd: this.sessionManager.getCwd(), });
})
: undefined;
// If extension returned a full result, use it directly // If extension returned a full result, use it directly
if (eventResult?.result) { if (eventResult?.result) {

View File

@@ -17,6 +17,7 @@ import type {
ExtensionUIContext, ExtensionUIContext,
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
ExtensionWidgetOptions, ExtensionWidgetOptions,
WorkingIndicatorOptions,
} from "../../core/extensions/index.js"; } from "../../core/extensions/index.js";
import { takeOverStdout, writeRawStdout } from "../../core/output-guard.js"; import { takeOverStdout, writeRawStdout } from "../../core/output-guard.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js"; import { killTrackedDetachedChildren } from "../../utils/shell.js";
@@ -172,7 +173,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
// Working message not supported in RPC mode - requires TUI loader access // Working message not supported in RPC mode - requires TUI loader access
}, },
setWorkingIndicator(): void { setWorkingIndicator(_options?: WorkingIndicatorOptions): void {
// Working indicator customization not supported in RPC mode - requires TUI loader access // Working indicator customization not supported in RPC mode - requires TUI loader access
}, },
@@ -617,7 +618,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
case "get_commands": { case "get_commands": {
const commands: RpcSlashCommand[] = []; const commands: RpcSlashCommand[] = [];
for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) { for (const command of session.extensionRunner.getRegisteredCommands()) {
commands.push({ commands.push({
name: command.invocationName, name: command.invocationName,
description: command.description, description: command.description,

View File

@@ -1,11 +1,22 @@
import type { TUI } from "../tui.js"; import type { TUI } from "../tui.js";
import { Text } from "./text.js"; import { Text } from "./text.js";
export interface LoaderIndicatorOptions {
/** Animation frames. Use an empty array to hide the indicator. */
frames?: string[];
/** Frame interval in milliseconds for animated indicators. */
intervalMs?: number;
}
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const DEFAULT_INTERVAL_MS = 80;
/** /**
* Loader component that updates every 80ms with spinning animation * Loader component that updates with an optional spinning animation.
*/ */
export class Loader extends Text { export class Loader extends Text {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; private frames = [...DEFAULT_FRAMES];
private intervalMs = DEFAULT_INTERVAL_MS;
private currentFrame = 0; private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null; private ui: TUI | null = null;
@@ -15,39 +26,56 @@ export class Loader extends Text {
private spinnerColorFn: (str: string) => string, private spinnerColorFn: (str: string) => string,
private messageColorFn: (str: string) => string, private messageColorFn: (str: string) => string,
private message: string = "Loading...", private message: string = "Loading...",
indicator?: LoaderIndicatorOptions,
) { ) {
super("", 1, 0); super("", 1, 0);
this.ui = ui; this.ui = ui;
this.start(); this.setIndicator(indicator);
} }
render(width: number): string[] { render(width: number): string[] {
return ["", ...super.render(width)]; return ["", ...super.render(width)];
} }
start() { start(): void {
this.updateDisplay(); this.updateDisplay();
this.intervalId = setInterval(() => { this.restartAnimation();
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, 80);
} }
stop() { stop(): void {
if (this.intervalId) { if (this.intervalId) {
clearInterval(this.intervalId); clearInterval(this.intervalId);
this.intervalId = null; this.intervalId = null;
} }
} }
setMessage(message: string) { setMessage(message: string): void {
this.message = message; this.message = message;
this.updateDisplay(); this.updateDisplay();
} }
private updateDisplay() { setIndicator(indicator?: LoaderIndicatorOptions): void {
const frame = this.frames[this.currentFrame]; this.frames = indicator?.frames ? [...indicator.frames] : [...DEFAULT_FRAMES];
this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`); this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;
this.currentFrame = 0;
this.start();
}
private restartAnimation(): void {
this.stop();
if (this.frames.length <= 1) {
return;
}
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, this.intervalMs);
}
private updateDisplay(): void {
const frame = this.frames[this.currentFrame] ?? "";
const indicator = frame.length > 0 ? `${this.spinnerColorFn(frame)} ` : "";
this.setText(`${indicator}${this.messageColorFn(this.message)}`);
if (this.ui) { if (this.ui) {
this.ui.requestRender(); this.ui.requestRender();
} }

View File

@@ -14,7 +14,7 @@ export { CancellableLoader } from "./components/cancellable-loader.js";
export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js"; export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js";
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
export { Input } from "./components/input.js"; export { Input } from "./components/input.js";
export { Loader } from "./components/loader.js"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.js";
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js"; export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
export { export {
type SelectItem, type SelectItem,