@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof setInterval> | 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));
|
||||
});
|
||||
}
|
||||
@@ -193,6 +193,7 @@ const noOpUIContext: ExtensionUIContext = {
|
||||
onTerminalInput: () => () => {},
|
||||
setStatus: () => {},
|
||||
setWorkingMessage: () => {},
|
||||
setWorkingVisible: () => {},
|
||||
setWorkingIndicator: () => {},
|
||||
setHiddenThinkingLabel: () => {},
|
||||
setWidget: () => {},
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -173,6 +173,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
// Working message not supported in RPC mode - requires TUI loader access
|
||||
},
|
||||
|
||||
setWorkingVisible(_visible: boolean): void {
|
||||
// Working visibility not supported in RPC mode - requires TUI loader access
|
||||
},
|
||||
|
||||
setWorkingIndicator(_options?: WorkingIndicatorOptions): void {
|
||||
// Working indicator customization not supported in RPC mode - requires TUI loader access
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user