feat(coding-agent): add configurable working indicator closes #3413
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
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,
|
UserBashEvent,
|
||||||
UserBashEventResult,
|
UserBashEventResult,
|
||||||
WidgetPlacement,
|
WidgetPlacement,
|
||||||
|
WorkingIndicatorOptions,
|
||||||
WriteToolCallEvent,
|
WriteToolCallEvent,
|
||||||
WriteToolResultEvent,
|
WriteToolResultEvent,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|||||||
@@ -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: () => {},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user