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

@@ -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

View File

@@ -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` |

View File

@@ -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)

View File

@@ -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

View File

@@ -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 |

View 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");
},
});
}

View File

@@ -149,6 +149,7 @@ export type {
UserBashEvent,
UserBashEventResult,
WidgetPlacement,
WorkingIndicatorOptions,
WriteToolCallEvent,
WriteToolResultEvent,
} from "./types.js";

View File

@@ -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: () => {},

View File

@@ -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;

View File

@@ -70,5 +70,6 @@ export {
type ToolResultEvent,
type TurnEndEvent,
type TurnStartEvent,
type WorkingIndicatorOptions,
} from "./extensions/index.js";
export { createSyntheticSourceInfo } from "./source-info.js";

View File

@@ -122,6 +122,7 @@ export type {
UserBashEvent,
UserBashEventResult,
WidgetPlacement,
WorkingIndicatorOptions,
WriteToolCallEvent,
} from "./core/extensions/index.js";
export {

View File

@@ -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) {

View File

@@ -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,

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

View File

@@ -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,