fix(coding-agent): stabilize interactive status indicators

Closes #6026
This commit is contained in:
Mario Zechner
2026-06-29 16:53:39 +02:00
parent 541d11f725
commit 5d499272a8
5 changed files with 239 additions and 151 deletions
+1
View File
@@ -8,6 +8,7 @@
### Fixed
- Fixed interactive status indicators so ending work, retry, compaction, or branch-summary indicators no longer shrink the TUI when clear-on-shrink is enabled ([#6026](https://github.com/earendil-works/pi/pull/6026)).
- Fixed `--session` and `SessionManager.open()` to reject non-empty invalid session files without overwriting them ([#6002](https://github.com/earendil-works/pi/issues/6002)).
- Fixed user-message transcript rendering to keep visible backslashes in Markdown escape sequences such as `\"` ([#6105](https://github.com/earendil-works/pi/issues/6105)).
- Fixed assistant messages stopped by output length to show a visible incomplete-response error ([#4290](https://github.com/earendil-works/pi/issues/4290)).
@@ -0,0 +1,114 @@
import { type Component, Loader, type TUI } from "@earendil-works/pi-tui";
import type { WorkingIndicatorOptions } from "../../../core/extensions/index.ts";
import { theme } from "../theme/theme.ts";
import { CountdownTimer } from "./countdown-timer.ts";
import { keyText } from "./keybinding-hints.ts";
export type StatusIndicatorKind = "working" | "retry" | "compaction" | "branchSummary";
export class StatusIndicator extends Loader {
readonly kind: StatusIndicatorKind;
constructor(
kind: StatusIndicatorKind,
ui: TUI,
spinnerColorFn: (str: string) => string,
messageColorFn: (str: string) => string,
message: string,
indicator?: WorkingIndicatorOptions,
) {
super(ui, spinnerColorFn, messageColorFn, message, indicator);
this.kind = kind;
}
dispose(): void {
this.stop();
}
}
export class WorkingStatusIndicator extends StatusIndicator {
constructor(ui: TUI, message: string, indicator?: WorkingIndicatorOptions) {
super(
"working",
ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
message,
indicator,
);
}
}
export class RetryStatusIndicator extends StatusIndicator {
private countdown: CountdownTimer | undefined;
constructor(ui: TUI, attempt: number, maxAttempts: number, delayMs: number) {
const retryMessage = (seconds: number) =>
`Retrying (${attempt}/${maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`;
super(
"retry",
ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
retryMessage(Math.ceil(delayMs / 1000)),
);
this.countdown = new CountdownTimer(
delayMs,
ui,
(seconds) => {
this.setMessage(retryMessage(seconds));
},
() => {
this.countdown = undefined;
},
);
}
override dispose(): void {
this.countdown?.dispose();
this.countdown = undefined;
super.dispose();
}
}
export type CompactionStatusReason = "manual" | "threshold" | "overflow";
export class CompactionStatusIndicator extends StatusIndicator {
constructor(ui: TUI, reason: CompactionStatusReason) {
const cancelHint = `(${keyText("app.interrupt")} to cancel)`;
const label =
reason === "manual"
? `Compacting context... ${cancelHint}`
: `${reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`;
super(
"compaction",
ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
label,
);
}
}
export class BranchSummaryStatusIndicator extends StatusIndicator {
constructor(ui: TUI) {
super(
"branchSummary",
ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`Summarizing branch... (${keyText("app.interrupt")} to cancel)`,
);
}
}
export class IdleStatus implements Component {
invalidate(): void {
// No cached state to invalidate.
}
render(width: number): string[] {
const emptyLine = " ".repeat(width);
return [emptyLine, emptyLine];
}
}
@@ -35,8 +35,6 @@ import {
fuzzyFilter,
getCapabilities,
hyperlink,
Loader,
type LoaderIndicatorOptions,
Markdown,
matchesKey,
ProcessTerminal,
@@ -72,6 +70,7 @@ import type {
ExtensionUIDialogOptions,
ExtensionWidgetOptions,
ProjectTrustContext,
WorkingIndicatorOptions,
} from "../../core/extensions/index.ts";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
@@ -103,7 +102,6 @@ import { BashExecutionComponent } from "./components/bash-execution.ts";
import { BorderedLoader } from "./components/bordered-loader.ts";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts";
import { CountdownTimer } from "./components/countdown-timer.ts";
import { CustomEditor } from "./components/custom-editor.ts";
import { CustomMessageComponent } from "./components/custom-message.ts";
import { DaxnutsComponent } from "./components/daxnuts.ts";
@@ -121,6 +119,14 @@ import { ScopedModelsSelectorComponent } from "./components/scoped-models-select
import { SessionSelectorComponent } from "./components/session-selector.ts";
import { SettingsSelectorComponent } from "./components/settings-selector.ts";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts";
import {
BranchSummaryStatusIndicator,
CompactionStatusIndicator,
IdleStatus,
RetryStatusIndicator,
type StatusIndicator,
WorkingStatusIndicator,
} from "./components/status-indicator.ts";
import { ToolExecutionComponent } from "./components/tool-execution.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts";
@@ -284,10 +290,11 @@ export class InteractiveMode {
private isInitialized = false;
private onInputCallback?: (text: string) => void;
private pendingUserInputs: string[] = [];
private loadingAnimation: Loader | undefined = undefined;
private activeStatusIndicator: StatusIndicator | undefined = undefined;
private readonly idleStatus = new IdleStatus();
private workingMessage: string | undefined = undefined;
private workingVisible = true;
private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined;
private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined;
private readonly defaultWorkingMessage = "Working...";
private readonly defaultHiddenThinkingLabel = "Thinking...";
private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
@@ -332,12 +339,9 @@ export class InteractiveMode {
private pendingBashComponents: BashExecutionComponent[] = [];
// Auto-compaction state
private autoCompactionLoader: Loader | undefined = undefined;
private autoCompactionEscapeHandler?: () => void;
// Auto-retry state
private retryLoader: Loader | undefined = undefined;
private retryCountdown: CountdownTimer | undefined = undefined;
private retryEscapeHandler?: () => void;
// Messages queued while compaction is running
@@ -1548,11 +1552,7 @@ export class InteractiveMode {
commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
this.clearStatusIndicator();
try {
return await this.runtimeHost.newSession(options);
} catch (error: unknown) {
@@ -1625,7 +1625,11 @@ export class InteractiveMode {
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
const clearOnShrink = this.settingsManager.getClearOnShrink();
this.ui.setClearOnShrink(clearOnShrink);
if (!clearOnShrink && !this.activeStatusIndicator) {
this.statusContainer.clear();
}
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor.setPaddingX(editorPaddingX);
@@ -1744,46 +1748,50 @@ 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;
}
private showStatusIndicator(indicator: StatusIndicator): void {
this.activeStatusIndicator?.dispose();
this.activeStatusIndicator = indicator;
this.statusContainer.clear();
this.statusContainer.addChild(indicator);
}
private clearStatusIndicator(kind?: StatusIndicator["kind"]): void {
if (kind && this.activeStatusIndicator?.kind !== kind) {
return;
}
const hadActiveStatusIndicator = this.activeStatusIndicator !== undefined;
this.activeStatusIndicator?.dispose();
this.activeStatusIndicator = undefined;
this.statusContainer.clear();
if (hadActiveStatusIndicator && this.ui.getClearOnShrink()) {
this.statusContainer.addChild(this.idleStatus);
}
}
private setWorkingVisible(visible: boolean): void {
this.workingVisible = visible;
if (!visible) {
this.stopWorkingLoader();
this.clearStatusIndicator("working");
this.ui.requestRender();
return;
}
if (this.session.isStreaming && !this.loadingAnimation) {
this.statusContainer.clear();
this.loadingAnimation = this.createWorkingLoader();
this.statusContainer.addChild(this.loadingAnimation);
if (this.session.isStreaming && this.activeStatusIndicator?.kind !== "working") {
this.showStatusIndicator(
new WorkingStatusIndicator(
this.ui,
this.workingMessage ?? this.defaultWorkingMessage,
this.workingIndicatorOptions,
),
);
}
this.ui.requestRender();
}
private setWorkingIndicator(options?: LoaderIndicatorOptions): void {
private setWorkingIndicator(options?: WorkingIndicatorOptions): void {
this.workingIndicatorOptions = options;
this.loadingAnimation?.setIndicator(options);
if (this.activeStatusIndicator?.kind === "working") {
this.activeStatusIndicator.setIndicator(options);
}
this.ui.requestRender();
}
@@ -1882,8 +1890,10 @@ export class InteractiveMode {
this.workingMessage = undefined;
this.workingVisible = true;
this.setWorkingIndicator();
if (this.loadingAnimation) {
this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`);
if (this.activeStatusIndicator?.kind === "working") {
this.activeStatusIndicator.setMessage(
`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`,
);
}
this.setHiddenThinkingLabel();
}
@@ -2047,8 +2057,8 @@ export class InteractiveMode {
setStatus: (key, text) => this.setExtensionStatus(key, text),
setWorkingMessage: (message) => {
this.workingMessage = message;
if (this.loadingAnimation) {
this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage);
if (this.activeStatusIndicator?.kind === "working") {
this.activeStatusIndicator.setMessage(message ?? this.defaultWorkingMessage);
}
},
setWorkingVisible: (visible) => this.setWorkingVisible(visible),
@@ -2749,18 +2759,16 @@ export class InteractiveMode {
this.defaultEditor.onEscape = this.retryEscapeHandler;
this.retryEscapeHandler = undefined;
}
if (this.retryCountdown) {
this.retryCountdown.dispose();
this.retryCountdown = undefined;
}
if (this.retryLoader) {
this.retryLoader.stop();
this.retryLoader = undefined;
}
this.stopWorkingLoader();
if (this.workingVisible) {
this.loadingAnimation = this.createWorkingLoader();
this.statusContainer.addChild(this.loadingAnimation);
this.showStatusIndicator(
new WorkingStatusIndicator(
this.ui,
this.workingMessage ?? this.defaultWorkingMessage,
this.workingIndicatorOptions,
),
);
} else {
this.clearStatusIndicator();
}
this.ui.requestRender();
break;
@@ -2924,11 +2932,7 @@ export class InteractiveMode {
if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(false);
}
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
this.statusContainer.clear();
}
this.clearStatusIndicator("working");
if (this.streamingComponent) {
this.chatContainer.removeChild(this.streamingComponent);
this.streamingComponent = undefined;
@@ -2950,19 +2954,7 @@ export class InteractiveMode {
this.defaultEditor.onEscape = () => {
this.session.abortCompaction();
};
this.statusContainer.clear();
const cancelHint = `(${keyText("app.interrupt")} to cancel)`;
const label =
event.reason === "manual"
? `Compacting context... ${cancelHint}`
: `${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`;
this.autoCompactionLoader = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
label,
);
this.statusContainer.addChild(this.autoCompactionLoader);
this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason));
this.ui.requestRender();
break;
}
@@ -2975,11 +2967,7 @@ export class InteractiveMode {
this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
this.autoCompactionEscapeHandler = undefined;
}
if (this.autoCompactionLoader) {
this.autoCompactionLoader.stop();
this.autoCompactionLoader = undefined;
this.statusContainer.clear();
}
this.clearStatusIndicator("compaction");
if (event.aborted) {
if (event.reason === "manual") {
this.showError("Compaction cancelled");
@@ -3016,28 +3004,9 @@ export class InteractiveMode {
this.defaultEditor.onEscape = () => {
this.session.abortRetry();
};
// Show retry indicator
this.statusContainer.clear();
this.retryCountdown?.dispose();
const retryMessage = (seconds: number) =>
`Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`;
this.retryLoader = new Loader(
this.ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
retryMessage(Math.ceil(event.delayMs / 1000)),
this.showStatusIndicator(
new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs),
);
this.retryCountdown = new CountdownTimer(
event.delayMs,
this.ui,
(seconds) => {
this.retryLoader?.setMessage(retryMessage(seconds));
},
() => {
this.retryCountdown = undefined;
},
);
this.statusContainer.addChild(this.retryLoader);
this.ui.requestRender();
break;
}
@@ -3048,16 +3017,7 @@ export class InteractiveMode {
this.defaultEditor.onEscape = this.retryEscapeHandler;
this.retryEscapeHandler = undefined;
}
if (this.retryCountdown) {
this.retryCountdown.dispose();
this.retryCountdown = undefined;
}
// Stop loader
if (this.retryLoader) {
this.retryLoader.stop();
this.retryLoader = undefined;
this.statusContainer.clear();
}
this.clearStatusIndicator("retry");
// Show error only on final failure (success shows normal response)
if (!event.success) {
this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
@@ -4111,6 +4071,9 @@ export class InteractiveMode {
onClearOnShrinkChange: (enabled) => {
this.settingsManager.setClearOnShrink(enabled);
this.ui.setClearOnShrink(enabled);
if (!enabled && !this.activeStatusIndicator) {
this.statusContainer.clear();
}
},
onShowTerminalProgressChange: (enabled) => {
this.settingsManager.setShowTerminalProgress(enabled);
@@ -4490,8 +4453,8 @@ export class InteractiveMode {
}
}
// Set up escape handler and loader if summarizing
let summaryLoader: Loader | undefined;
// Set up escape handler and status indicator if summarizing
let showingSummaryIndicator = false;
const originalOnEscape = this.defaultEditor.onEscape;
if (wantsSummary) {
@@ -4499,13 +4462,8 @@ export class InteractiveMode {
this.session.abortBranchSummary();
};
this.chatContainer.addChild(new Spacer(1));
summaryLoader = new Loader(
this.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`Summarizing branch... (${keyText("app.interrupt")} to cancel)`,
);
this.statusContainer.addChild(summaryLoader);
this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui));
showingSummaryIndicator = true;
this.ui.requestRender();
}
@@ -4537,9 +4495,8 @@ export class InteractiveMode {
} catch (error) {
this.showError(error instanceof Error ? error.message : String(error));
} finally {
if (summaryLoader) {
summaryLoader.stop();
this.statusContainer.clear();
if (showingSummaryIndicator) {
this.clearStatusIndicator("branchSummary");
}
this.defaultEditor.onEscape = originalOnEscape;
}
@@ -4601,11 +4558,7 @@ export class InteractiveMode {
sessionPath: string,
options?: Parameters<ExtensionCommandContext["switchSession"]>[1],
): Promise<{ cancelled: boolean }> {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
this.clearStatusIndicator();
try {
const result = await this.runtimeHost.switchSession(sessionPath, {
withSession: options?.withSession,
@@ -5114,7 +5067,11 @@ export class InteractiveMode {
this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
}
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
const clearOnShrink = this.settingsManager.getClearOnShrink();
this.ui.setClearOnShrink(clearOnShrink);
if (!clearOnShrink && !this.activeStatusIndicator) {
this.statusContainer.clear();
}
this.setupAutocompleteProvider();
const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner);
@@ -5201,11 +5158,7 @@ export class InteractiveMode {
}
try {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
this.clearStatusIndicator();
const result = await this.runtimeHost.importFromJsonl(inputPath);
if (result.cancelled) {
this.showStatus("Import cancelled");
@@ -5556,11 +5509,7 @@ export class InteractiveMode {
}
private async handleClearCommand(): Promise<void> {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
this.clearStatusIndicator();
try {
const result = await this.runtimeHost.newSession();
if (result.cancelled) {
@@ -5719,11 +5668,7 @@ export class InteractiveMode {
}
private async handleCompactCommand(customInstructions?: string): Promise<void> {
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
this.clearStatusIndicator();
try {
await this.session.compact(customInstructions);
@@ -5736,10 +5681,7 @@ export class InteractiveMode {
if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(false);
}
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.clearStatusIndicator();
this.themeController.disableAutoSync();
this.clearExtensionTerminalInputListeners();
this.footer.dispose();
@@ -10,8 +10,7 @@ type InteractiveModePrototype = {
};
type ImportCommandContext = {
loadingAnimation?: { stop: () => void };
statusContainer: { clear: () => void };
clearStatusIndicator: () => void;
runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> };
showError: (message: string) => void;
showStatus: (message: string) => void;
@@ -58,7 +57,7 @@ describe("InteractiveMode /import parsing", () => {
const showError = vi.fn();
const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() },
clearStatusIndicator: vi.fn(),
runtimeHost: { importFromJsonl },
showError,
showStatus,
@@ -90,7 +89,7 @@ describe("InteractiveMode /import parsing", () => {
const showError = vi.fn();
const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() },
clearStatusIndicator: vi.fn(),
runtimeHost: { importFromJsonl },
showError,
showStatus,
@@ -123,7 +122,7 @@ describe("InteractiveMode /import parsing", () => {
});
const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() },
clearStatusIndicator: vi.fn(),
runtimeHost: { importFromJsonl },
showError,
showStatus,
@@ -0,0 +1,32 @@
import type { TUI } from "@earendil-works/pi-tui";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IdleStatus, RetryStatusIndicator } from "../src/modes/interactive/components/status-indicator.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
describe("status indicators", () => {
afterEach(() => {
vi.useRealTimers();
});
it("keeps idle status at the same height as status indicators", () => {
const idleStatus = new IdleStatus();
const lines = idleStatus.render(20);
expect(lines).toHaveLength(2);
expect(lines).toEqual([" ".repeat(20), " ".repeat(20)]);
});
it("disposes retry countdown updates", () => {
initTheme("dark");
vi.useFakeTimers();
const requestRender = vi.fn();
const tui = { requestRender } as unknown as TUI;
const indicator = new RetryStatusIndicator(tui, 1, 3, 1000);
const callsBeforeDispose = requestRender.mock.calls.length;
indicator.dispose();
vi.advanceTimersByTime(2000);
expect(requestRender).toHaveBeenCalledTimes(callsBeforeDispose);
});
});