feat(coding-agent): add configurable working indicator closes #3413
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
### 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))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1922,7 +1922,7 @@ Extensions can interact with users via `ctx.ui` methods and customize how messag
|
||||
- Async operations with cancel (BorderedLoader)
|
||||
- Settings toggles (SettingsList)
|
||||
- Status indicators (setStatus)
|
||||
- Working message during streaming (setWorkingMessage)
|
||||
- Working message and indicator during streaming (`setWorkingMessage`, `setWorkingIndicator`)
|
||||
- Widgets above/below editor (setWidget)
|
||||
- Custom footers (setFooter)
|
||||
|
||||
@@ -2007,6 +2007,12 @@ ctx.ui.setStatus("my-ext", undefined); // Clear
|
||||
ctx.ui.setWorkingMessage("Thinking deeply...");
|
||||
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)
|
||||
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
|
||||
// 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` |
|
||||
| **UI Components** |||
|
||||
| `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-header.ts` | Replace startup header | `on("session_start")`, `setHeader` |
|
||||
| `modal-editor.ts` | Vim-style modal editor | `setEditorComponent`, `CustomEditor` |
|
||||
|
||||
@@ -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:
|
||||
- `custom()` returns `undefined`
|
||||
- `setWorkingMessage()`, `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops
|
||||
- `setWorkingMessage()`, `setWorkingIndicator()`, `setFooter()`, `setHeader()`, `setEditorComponent()`, `setToolsExpanded()` are no-ops
|
||||
- `getEditorText()` returns `""`
|
||||
- `getToolsExpanded()` returns `false`
|
||||
- `pasteToEditor()` delegates to `setEditorText()` (no paste/collapse handling)
|
||||
|
||||
@@ -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)
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
- **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
|
||||
- **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 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
|
||||
|
||||
@@ -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 |
|
||||
| `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()` |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
105
packages/coding-agent/examples/extensions/working-indicator.ts
Normal file
105
packages/coding-agent/examples/extensions/working-indicator.ts
Normal 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");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -149,6 +149,7 @@ export type {
|
||||
UserBashEvent,
|
||||
UserBashEventResult,
|
||||
WidgetPlacement,
|
||||
WorkingIndicatorOptions,
|
||||
WriteToolCallEvent,
|
||||
WriteToolResultEvent,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -167,8 +167,8 @@ export type ShutdownHandler = () => void;
|
||||
* Helper function to emit session_shutdown event to extensions.
|
||||
* Returns true if the event was emitted, false if there were no handlers.
|
||||
*/
|
||||
export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise<boolean> {
|
||||
if (extensionRunner?.hasHandlers("session_shutdown")) {
|
||||
export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner): Promise<boolean> {
|
||||
if (extensionRunner.hasHandlers("session_shutdown")) {
|
||||
await extensionRunner.emit({
|
||||
type: "session_shutdown",
|
||||
});
|
||||
@@ -185,6 +185,7 @@ const noOpUIContext: ExtensionUIContext = {
|
||||
onTerminalInput: () => () => {},
|
||||
setStatus: () => {},
|
||||
setWorkingMessage: () => {},
|
||||
setWorkingIndicator: () => {},
|
||||
setHiddenThinkingLabel: () => {},
|
||||
setWidget: () => {},
|
||||
setFooter: () => {},
|
||||
|
||||
@@ -102,6 +102,14 @@ export interface ExtensionWidgetOptions {
|
||||
/** Raw terminal input listener for extensions. */
|
||||
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.
|
||||
* 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. */
|
||||
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. */
|
||||
setHiddenThinkingLabel(label?: string): void;
|
||||
|
||||
|
||||
@@ -70,5 +70,6 @@ export {
|
||||
type ToolResultEvent,
|
||||
type TurnEndEvent,
|
||||
type TurnStartEvent,
|
||||
type WorkingIndicatorOptions,
|
||||
} from "./extensions/index.js";
|
||||
export { createSyntheticSourceInfo } from "./source-info.js";
|
||||
|
||||
@@ -122,6 +122,7 @@ export type {
|
||||
UserBashEvent,
|
||||
UserBashEventResult,
|
||||
WidgetPlacement,
|
||||
WorkingIndicatorOptions,
|
||||
WriteToolCallEvent,
|
||||
} from "./core/extensions/index.js";
|
||||
export {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Container,
|
||||
fuzzyFilter,
|
||||
Loader,
|
||||
type LoaderIndicatorOptions,
|
||||
Markdown,
|
||||
matchesKey,
|
||||
ProcessTerminal,
|
||||
@@ -201,6 +202,7 @@ export class InteractiveMode {
|
||||
private onInputCallback?: (text: string) => void;
|
||||
private loadingAnimation: Loader | undefined = undefined;
|
||||
private pendingWorkingMessage: string | undefined = undefined;
|
||||
private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined;
|
||||
private readonly defaultWorkingMessage = "Working...";
|
||||
private readonly defaultHiddenThinkingLabel = "Thinking...";
|
||||
private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
|
||||
@@ -370,11 +372,7 @@ export class InteractiveMode {
|
||||
return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`;
|
||||
}
|
||||
|
||||
private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner | undefined): ResourceDiagnostic[] {
|
||||
if (!extensionRunner) {
|
||||
return [];
|
||||
}
|
||||
|
||||
private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] {
|
||||
const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
|
||||
return extensionRunner
|
||||
.getRegisteredCommands()
|
||||
@@ -436,13 +434,14 @@ export class InteractiveMode {
|
||||
|
||||
// Convert extension commands to SlashCommand format
|
||||
const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
|
||||
const extensionCommands: SlashCommand[] = (
|
||||
this.session.extensionRunner?.getRegisteredCommands().filter((cmd) => !builtinCommandNames.has(cmd.name)) ?? []
|
||||
).map((cmd) => ({
|
||||
name: cmd.invocationName,
|
||||
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
|
||||
getArgumentCompletions: cmd.getArgumentCompletions,
|
||||
}));
|
||||
const extensionCommands: SlashCommand[] = this.session.extensionRunner
|
||||
.getRegisteredCommands()
|
||||
.filter((cmd) => !builtinCommandNames.has(cmd.name))
|
||||
.map((cmd) => ({
|
||||
name: cmd.invocationName,
|
||||
description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo),
|
||||
getArgumentCompletions: cmd.getArgumentCompletions,
|
||||
}));
|
||||
|
||||
// Build skill commands from session.skills (if enabled)
|
||||
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(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner));
|
||||
|
||||
const shortcutDiagnostics = this.session.extensionRunner?.getShortcutDiagnostics() ?? [];
|
||||
const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics();
|
||||
extensionDiagnostics.push(...shortcutDiagnostics);
|
||||
|
||||
if (extensionDiagnostics.length > 0) {
|
||||
@@ -1501,12 +1500,6 @@ export class InteractiveMode {
|
||||
this.setupAutocomplete(this.fdPath);
|
||||
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (!extensionRunner) {
|
||||
this.showLoadedResources({ extensions: [], force: false });
|
||||
this.showStartupNoticesIfNeeded();
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupExtensionShortcuts(extensionRunner);
|
||||
this.showLoadedResources({ force: false });
|
||||
this.showStartupNoticesIfNeeded();
|
||||
@@ -1627,6 +1620,12 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private setWorkingIndicator(options?: LoaderIndicatorOptions): void {
|
||||
this.workingIndicatorOptions = options;
|
||||
this.loadingAnimation?.setIndicator(options);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private setHiddenThinkingLabel(label?: string): void {
|
||||
this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel;
|
||||
for (const child of this.chatContainer.children) {
|
||||
@@ -1717,6 +1716,8 @@ export class InteractiveMode {
|
||||
this.setCustomEditorComponent(undefined);
|
||||
this.defaultEditor.onExtensionShortcut = undefined;
|
||||
this.updateTerminalTitle();
|
||||
this.pendingWorkingMessage = undefined;
|
||||
this.setWorkingIndicator();
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`);
|
||||
}
|
||||
@@ -1879,9 +1880,7 @@ export class InteractiveMode {
|
||||
this.pendingWorkingMessage = message;
|
||||
}
|
||||
},
|
||||
setWorkingIndicator: () => {
|
||||
// Working indicator customization not implemented in interactive mode yet.
|
||||
},
|
||||
setWorkingIndicator: (options) => this.setWorkingIndicator(options),
|
||||
setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label),
|
||||
setWidget: (key, content, options) => this.setExtensionWidget(key, content, options),
|
||||
setFooter: (factory) => this.setExtensionFooter(factory),
|
||||
@@ -2580,6 +2579,7 @@ export class InteractiveMode {
|
||||
(spinner) => theme.fg("accent", spinner),
|
||||
(text) => theme.fg("muted", text),
|
||||
this.defaultWorkingMessage,
|
||||
this.workingIndicatorOptions,
|
||||
);
|
||||
this.statusContainer.addChild(this.loadingAnimation);
|
||||
// Apply any pending working message queued before loader existed
|
||||
@@ -2927,7 +2927,7 @@ export class InteractiveMode {
|
||||
}
|
||||
case "custom": {
|
||||
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());
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
this.chatContainer.addChild(component);
|
||||
@@ -3531,7 +3531,6 @@ export class InteractiveMode {
|
||||
if (!text.startsWith("/")) return false;
|
||||
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (!extensionRunner) return false;
|
||||
|
||||
const spaceIndex = text.indexOf(" ");
|
||||
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.setupAutocomplete(this.fdPath);
|
||||
const runner = this.session.extensionRunner;
|
||||
if (runner) {
|
||||
this.setupExtensionShortcuts(runner);
|
||||
}
|
||||
this.setupExtensionShortcuts(runner);
|
||||
this.rebuildChatFromMessages();
|
||||
dismissReloadBox(this.editor as Component);
|
||||
this.showLoadedResources({
|
||||
@@ -4904,19 +4901,17 @@ export class InteractiveMode {
|
||||
|
||||
// Add extension-registered shortcuts
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (extensionRunner) {
|
||||
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
|
||||
if (shortcuts.size > 0) {
|
||||
hotkeys += `
|
||||
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
|
||||
if (shortcuts.size > 0) {
|
||||
hotkeys += `
|
||||
**Extensions**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
`;
|
||||
for (const [key, shortcut] of shortcuts) {
|
||||
const description = shortcut.description ?? shortcut.extensionPath;
|
||||
const keyDisplay = key.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
hotkeys += `| \`${keyDisplay}\` | ${description} |\n`;
|
||||
}
|
||||
for (const [key, shortcut] of shortcuts) {
|
||||
const description = shortcut.description ?? shortcut.extensionPath;
|
||||
const keyDisplay = key.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
hotkeys += `| \`${keyDisplay}\` | ${description} |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5011,14 +5006,12 @@ export class InteractiveMode {
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
|
||||
// Emit user_bash event to let extensions intercept
|
||||
const eventResult = extensionRunner
|
||||
? await extensionRunner.emitUserBash({
|
||||
type: "user_bash",
|
||||
command,
|
||||
excludeFromContext,
|
||||
cwd: this.sessionManager.getCwd(),
|
||||
})
|
||||
: undefined;
|
||||
const eventResult = await extensionRunner.emitUserBash({
|
||||
type: "user_bash",
|
||||
command,
|
||||
excludeFromContext,
|
||||
cwd: this.sessionManager.getCwd(),
|
||||
});
|
||||
|
||||
// If extension returned a full result, use it directly
|
||||
if (eventResult?.result) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionWidgetOptions,
|
||||
WorkingIndicatorOptions,
|
||||
} from "../../core/extensions/index.js";
|
||||
import { takeOverStdout, writeRawStdout } from "../../core/output-guard.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
|
||||
},
|
||||
|
||||
setWorkingIndicator(): void {
|
||||
setWorkingIndicator(_options?: WorkingIndicatorOptions): void {
|
||||
// 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": {
|
||||
const commands: RpcSlashCommand[] = [];
|
||||
|
||||
for (const command of session.extensionRunner?.getRegisteredCommands() ?? []) {
|
||||
for (const command of session.extensionRunner.getRegisteredCommands()) {
|
||||
commands.push({
|
||||
name: command.invocationName,
|
||||
description: command.description,
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { TUI } from "../tui.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 {
|
||||
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
private frames = [...DEFAULT_FRAMES];
|
||||
private intervalMs = DEFAULT_INTERVAL_MS;
|
||||
private currentFrame = 0;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private ui: TUI | null = null;
|
||||
@@ -15,39 +26,56 @@ export class Loader extends Text {
|
||||
private spinnerColorFn: (str: string) => string,
|
||||
private messageColorFn: (str: string) => string,
|
||||
private message: string = "Loading...",
|
||||
indicator?: LoaderIndicatorOptions,
|
||||
) {
|
||||
super("", 1, 0);
|
||||
this.ui = ui;
|
||||
this.start();
|
||||
this.setIndicator(indicator);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return ["", ...super.render(width)];
|
||||
}
|
||||
|
||||
start() {
|
||||
start(): void {
|
||||
this.updateDisplay();
|
||||
this.intervalId = setInterval(() => {
|
||||
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
|
||||
this.updateDisplay();
|
||||
}, 80);
|
||||
this.restartAnimation();
|
||||
}
|
||||
|
||||
stop() {
|
||||
stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(message: string) {
|
||||
setMessage(message: string): void {
|
||||
this.message = message;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay() {
|
||||
const frame = this.frames[this.currentFrame];
|
||||
this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
|
||||
setIndicator(indicator?: LoaderIndicatorOptions): void {
|
||||
this.frames = indicator?.frames ? [...indicator.frames] : [...DEFAULT_FRAMES];
|
||||
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) {
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export { CancellableLoader } from "./components/cancellable-loader.js";
|
||||
export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js";
|
||||
export { Image, type ImageOptions, type ImageTheme } from "./components/image.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 SelectItem,
|
||||
|
||||
Reference in New Issue
Block a user