feat(coding-agent): add configurable working indicator closes #3413
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user