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

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

View File

@@ -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();
}