diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b7c02c45..0ce65e6c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 3c3eac00..d64ebf16 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -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` | diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 5736fddc..d348cad8 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -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) diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index ccd0dd7c..59f7c11d 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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 diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 933f7621..664716e7 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -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 | diff --git a/packages/coding-agent/examples/extensions/working-indicator.ts b/packages/coding-agent/examples/extensions/working-indicator.ts new file mode 100644 index 00000000..e7fd29c8 --- /dev/null +++ b/packages/coding-agent/examples/extensions/working-indicator.ts @@ -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"); + }, + }); +} diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 66e622f5..5a90c964 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -149,6 +149,7 @@ export type { UserBashEvent, UserBashEventResult, WidgetPlacement, + WorkingIndicatorOptions, WriteToolCallEvent, WriteToolResultEvent, } from "./types.js"; diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 2360848d..292d569b 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -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 { - if (extensionRunner?.hasHandlers("session_shutdown")) { +export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner): Promise { + 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: () => {}, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index d4e540f4..87c24e24 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 17533f33..3fa96f95 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -70,5 +70,6 @@ export { type ToolResultEvent, type TurnEndEvent, type TurnStartEvent, + type WorkingIndicatorOptions, } from "./extensions/index.js"; export { createSyntheticSourceInfo } from "./source-info.js"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 378cd27b..65f05f5d 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -122,6 +122,7 @@ export type { UserBashEvent, UserBashEventResult, WidgetPlacement, + WorkingIndicatorOptions, WriteToolCallEvent, } from "./core/extensions/index.js"; export { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index e6989999..8e678d7e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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) { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 02668927..7b5d5c90 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -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 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(); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index aa3dd68b..3c8ae0aa 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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,