From 6580dae40081157a18ba5b596231e437ea2ae947 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 24 Apr 2026 23:16:39 +0200 Subject: [PATCH] feat(coding-agent): allow hiding working loader closes #3674 --- packages/coding-agent/CHANGELOG.md | 5 + packages/coding-agent/docs/extensions.md | 6 +- .../extensions/border-status-editor.ts | 148 ++++++++++++++++++ .../src/core/extensions/runner.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 3 + .../src/modes/interactive/interactive-mode.ts | 65 +++++--- .../coding-agent/src/modes/rpc/rpc-mode.ts | 4 + 7 files changed, 211 insertions(+), 21 deletions(-) create mode 100644 packages/coding-agent/examples/extensions/border-status-editor.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f2b8acc..ee6dd6a0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Added + +- Added `ctx.ui.setWorkingVisible()` so extensions can hide the built-in interactive working loader row without reserving layout space, plus a border-status editor example that moves working state into a custom editor border ([#3674](https://github.com/badlogic/pi-mono/issues/3674)) + ### Fixed +- Fixed Escape interrupt handling when extensions hide the built-in working loader row ([#3674](https://github.com/badlogic/pi-mono/issues/3674)) - Fixed coding-agent test expectations for current default models and missing-auth guidance. ## [0.70.2] - 2026-04-24 diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index d450935e..0bef2cfc 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2070,7 +2070,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 and indicator during streaming (`setWorkingMessage`, `setWorkingIndicator`) +- Working message, visibility, and indicator during streaming (`setWorkingMessage`, `setWorkingVisible`, `setWorkingIndicator`) - Widgets above/below editor (setWidget) - Autocomplete providers layered on top of built-in slash/path completion (addAutocompleteProvider) - Custom footers (setFooter) @@ -2152,9 +2152,11 @@ See [examples/extensions/timed-confirm.ts](../examples/extensions/timed-confirm. ctx.ui.setStatus("my-ext", "Processing..."); ctx.ui.setStatus("my-ext", undefined); // Clear -// Working message (shown during streaming) +// Working loader (shown during streaming) ctx.ui.setWorkingMessage("Thinking deeply..."); ctx.ui.setWorkingMessage(); // Restore default +ctx.ui.setWorkingVisible(false); // Hide the built-in working loader row entirely +ctx.ui.setWorkingVisible(true); // Show the built-in working loader row // Working indicator (shown during streaming) ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] }); // Static dot diff --git a/packages/coding-agent/examples/extensions/border-status-editor.ts b/packages/coding-agent/examples/extensions/border-status-editor.ts new file mode 100644 index 00000000..6cf03b08 --- /dev/null +++ b/packages/coding-agent/examples/extensions/border-status-editor.ts @@ -0,0 +1,148 @@ +import { + CustomEditor, + type ExtensionAPI, + type ExtensionContext, + type KeybindingsManager, +} from "@mariozechner/pi-coding-agent"; +import type { Component, EditorTheme, TUI } from "@mariozechner/pi-tui"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +function fitBorder(left: string, right: string, width: number, fill: (text: string) => string): string { + if (width <= 0) return ""; + if (width === 1) return fill("─"); + + let leftText = left; + let rightText = right; + const edgeWidth = 1; + const minimumGap = 1; + + while ( + edgeWidth * 2 + visibleWidth(leftText) + visibleWidth(rightText) + minimumGap > width && + visibleWidth(rightText) > 0 + ) { + rightText = truncateToWidth(rightText, Math.max(0, visibleWidth(rightText) - 1), ""); + } + while ( + edgeWidth * 2 + visibleWidth(leftText) + visibleWidth(rightText) + minimumGap > width && + visibleWidth(leftText) > 0 + ) { + leftText = truncateToWidth(leftText, Math.max(0, visibleWidth(leftText) - 1), ""); + } + + const gapWidth = Math.max(0, width - edgeWidth * 2 - visibleWidth(leftText) - visibleWidth(rightText)); + return `${fill("─")}${leftText}${fill("─".repeat(gapWidth))}${rightText}${fill("─")}`; +} + +function formatCwd(cwd: string): string { + const home = process.env.HOME; + if (home && cwd.startsWith(home)) { + return `~${cwd.slice(home.length)}`; + } + return cwd; +} + +function formatContext(ctx: ExtensionContext): string { + const usage = ctx.getContextUsage(); + if (!usage || usage.tokens === null || usage.percent === null) { + return "context unknown"; + } + return `${Math.round(usage.percent)}% of ${(usage.contextWindow / 1000).toFixed(1)}k`; +} + +function formatSessionCost(ctx: ExtensionContext): string { + let totalCost = 0; + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + totalCost += entry.message.usage.cost.total; + } + } + return `$${totalCost.toFixed(3)}`; +} + +class EmptyFooter implements Component { + render(): string[] { + return []; + } + + invalidate(): void {} +} + +export default function (pi: ExtensionAPI) { + let isWorking = false; + let spinnerIndex = 0; + let spinnerTimer: ReturnType | undefined; + let activeTui: TUI | undefined; + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + const stopSpinner = () => { + if (spinnerTimer) { + clearInterval(spinnerTimer); + spinnerTimer = undefined; + } + }; + + pi.on("agent_start", () => { + isWorking = true; + stopSpinner(); + spinnerTimer = setInterval(() => { + spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length; + activeTui?.requestRender(); + }, 80); + activeTui?.requestRender(); + }); + + pi.on("agent_end", () => { + isWorking = false; + stopSpinner(); + activeTui?.requestRender(); + }); + + pi.on("session_shutdown", () => { + stopSpinner(); + activeTui = undefined; + }); + + pi.on("session_start", (_event, ctx) => { + ctx.ui.setWorkingVisible(false); + ctx.ui.setFooter(() => new EmptyFooter()); + + let branch: string | undefined; + + const refreshBranch = async () => { + const result = await pi.exec("git", ["branch", "--show-current"], { cwd: ctx.cwd }).catch(() => undefined); + const stdout = result?.stdout.trim(); + branch = stdout && stdout.length > 0 ? stdout : undefined; + activeTui?.requestRender(); + }; + void refreshBranch(); + + class BorderStatusEditor extends CustomEditor { + constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) { + super(tui, theme, keybindings, { paddingX: 0 }); + activeTui = tui; + } + + render(width: number): string[] { + const lines = super.render(width); + if (lines.length < 2) return lines; + + const thm = ctx.ui.theme; + const model = ctx.model ? `(${ctx.model.provider}) ${ctx.model.id}` : "no model"; + const thinking = pi.getThinkingLevel(); + const workingText = isWorking ? `${spinnerFrames[spinnerIndex]} working` : "idle"; + const topLeft = thm.fg("muted", ` ${formatContext(ctx)} · ${formatSessionCost(ctx)} `); + const topRight = thm.fg("muted", ` ${model} · ${thinking} `); + const bottomLeft = isWorking ? thm.fg("accent", ` ${workingText} `) : thm.fg("muted", ` ${workingText} `); + const bottomRight = thm.fg("muted", ` ${formatCwd(ctx.cwd)}${branch ? ` (${branch})` : ""} `); + + lines[0] = fitBorder(topLeft, topRight, width, (text) => this.borderColor(text.replace(/ /g, "─"))); + lines[lines.length - 1] = fitBorder(bottomLeft, bottomRight, width, (text) => + this.borderColor(text.replace(/ /g, "─")), + ); + return lines; + } + } + + ctx.ui.setEditorComponent((tui, theme, keybindings) => new BorderStatusEditor(tui, theme, keybindings)); + }); +} diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 27715439..0fd52189 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -193,6 +193,7 @@ const noOpUIContext: ExtensionUIContext = { onTerminalInput: () => () => {}, setStatus: () => {}, setWorkingMessage: () => {}, + setWorkingVisible: () => {}, setWorkingIndicator: () => {}, setHiddenThinkingLabel: () => {}, setWidget: () => {}, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 86ec071b..99bc3625 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -142,6 +142,9 @@ export interface ExtensionUIContext { /** Set the working/loading message shown during streaming. Call with no argument to restore default. */ setWorkingMessage(message?: string): void; + /** Show or hide the built-in interactive working loader row during streaming. */ + setWorkingVisible(visible: boolean): void; + /** * Configure the interactive working indicator shown during streaming. * diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 951be6df..1abe8fff 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -262,6 +262,7 @@ export class InteractiveMode { private onInputCallback?: (text: string) => void; private loadingAnimation: Loader | undefined = undefined; private workingMessage: string | undefined = undefined; + private workingVisible = true; private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined; private readonly defaultWorkingMessage = "Working..."; private readonly defaultHiddenThinkingLabel = "Thinking..."; @@ -1695,6 +1696,43 @@ export class InteractiveMode { this.ui.requestRender(); } + private getWorkingLoaderMessage(): string { + return this.workingMessage ?? this.defaultWorkingMessage; + } + + private createWorkingLoader(): Loader { + return new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + this.getWorkingLoaderMessage(), + this.workingIndicatorOptions, + ); + } + + private stopWorkingLoader(): void { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + } + + private setWorkingVisible(visible: boolean): void { + this.workingVisible = visible; + if (!visible) { + this.stopWorkingLoader(); + this.ui.requestRender(); + return; + } + if (this.session.isStreaming && !this.loadingAnimation) { + this.statusContainer.clear(); + this.loadingAnimation = this.createWorkingLoader(); + this.statusContainer.addChild(this.loadingAnimation); + } + this.ui.requestRender(); + } + private setWorkingIndicator(options?: LoaderIndicatorOptions): void { this.workingIndicatorOptions = options; this.loadingAnimation?.setIndicator(options); @@ -1794,6 +1832,7 @@ export class InteractiveMode { this.defaultEditor.onExtensionShortcut = undefined; this.updateTerminalTitle(); this.workingMessage = undefined; + this.workingVisible = true; this.setWorkingIndicator(); if (this.loadingAnimation) { this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`); @@ -1946,15 +1985,10 @@ export class InteractiveMode { setWorkingMessage: (message) => { this.workingMessage = message; if (this.loadingAnimation) { - if (message) { - this.loadingAnimation.setMessage(message); - } else { - this.loadingAnimation.setMessage( - `${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`, - ); - } + this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage); } }, + setWorkingVisible: (visible) => this.setWorkingVisible(visible), setWorkingIndicator: (options) => this.setWorkingIndicator(options), setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label), setWidget: (key, content, options) => this.setExtensionWidget(key, content, options), @@ -2355,7 +2389,7 @@ export class InteractiveMode { // Set up handlers on defaultEditor - they use this.editor for text access // so they work correctly regardless of which editor is active this.defaultEditor.onEscape = () => { - if (this.loadingAnimation) { + if (this.session.isStreaming) { this.restoreQueuedMessagesToEditor({ abort: true }); } else if (this.session.isBashRunning) { this.session.abortBash(); @@ -2652,18 +2686,11 @@ export class InteractiveMode { this.retryLoader.stop(); this.retryLoader = undefined; } - if (this.loadingAnimation) { - this.loadingAnimation.stop(); + this.stopWorkingLoader(); + if (this.workingVisible) { + this.loadingAnimation = this.createWorkingLoader(); + this.statusContainer.addChild(this.loadingAnimation); } - this.statusContainer.clear(); - this.loadingAnimation = new Loader( - this.ui, - (spinner) => theme.fg("accent", spinner), - (text) => theme.fg("muted", text), - this.workingMessage || this.defaultWorkingMessage, - this.workingIndicatorOptions, - ); - this.statusContainer.addChild(this.loadingAnimation); this.ui.requestRender(); break; diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 5c1d24d1..46161e2e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -173,6 +173,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise