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
- 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 `--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 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)). - 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, fuzzyFilter,
getCapabilities, getCapabilities,
hyperlink, hyperlink,
Loader,
type LoaderIndicatorOptions,
Markdown, Markdown,
matchesKey, matchesKey,
ProcessTerminal, ProcessTerminal,
@@ -72,6 +70,7 @@ import type {
ExtensionUIDialogOptions, ExtensionUIDialogOptions,
ExtensionWidgetOptions, ExtensionWidgetOptions,
ProjectTrustContext, ProjectTrustContext,
WorkingIndicatorOptions,
} from "../../core/extensions/index.ts"; } from "../../core/extensions/index.ts";
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts";
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.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 { BorderedLoader } from "./components/bordered-loader.ts";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts";
import { CompactionSummaryMessageComponent } from "./components/compaction-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 { CustomEditor } from "./components/custom-editor.ts";
import { CustomMessageComponent } from "./components/custom-message.ts"; import { CustomMessageComponent } from "./components/custom-message.ts";
import { DaxnutsComponent } from "./components/daxnuts.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 { SessionSelectorComponent } from "./components/session-selector.ts";
import { SettingsSelectorComponent } from "./components/settings-selector.ts"; import { SettingsSelectorComponent } from "./components/settings-selector.ts";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.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 { ToolExecutionComponent } from "./components/tool-execution.ts";
import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TreeSelectorComponent } from "./components/tree-selector.ts";
import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts";
@@ -284,10 +290,11 @@ export class InteractiveMode {
private isInitialized = false; private isInitialized = false;
private onInputCallback?: (text: string) => void; private onInputCallback?: (text: string) => void;
private pendingUserInputs: string[] = []; private pendingUserInputs: string[] = [];
private loadingAnimation: Loader | undefined = undefined; private activeStatusIndicator: StatusIndicator | undefined = undefined;
private readonly idleStatus = new IdleStatus();
private workingMessage: string | undefined = undefined; private workingMessage: string | undefined = undefined;
private workingVisible = true; private workingVisible = true;
private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined; private workingIndicatorOptions: WorkingIndicatorOptions | undefined = undefined;
private readonly defaultWorkingMessage = "Working..."; private readonly defaultWorkingMessage = "Working...";
private readonly defaultHiddenThinkingLabel = "Thinking..."; private readonly defaultHiddenThinkingLabel = "Thinking...";
private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; private hiddenThinkingLabel = this.defaultHiddenThinkingLabel;
@@ -332,12 +339,9 @@ export class InteractiveMode {
private pendingBashComponents: BashExecutionComponent[] = []; private pendingBashComponents: BashExecutionComponent[] = [];
// Auto-compaction state // Auto-compaction state
private autoCompactionLoader: Loader | undefined = undefined;
private autoCompactionEscapeHandler?: () => void; private autoCompactionEscapeHandler?: () => void;
// Auto-retry state // Auto-retry state
private retryLoader: Loader | undefined = undefined;
private retryCountdown: CountdownTimer | undefined = undefined;
private retryEscapeHandler?: () => void; private retryEscapeHandler?: () => void;
// Messages queued while compaction is running // Messages queued while compaction is running
@@ -1548,11 +1552,7 @@ export class InteractiveMode {
commandContextActions: { commandContextActions: {
waitForIdle: () => this.session.agent.waitForIdle(), waitForIdle: () => this.session.agent.waitForIdle(),
newSession: async (options) => { newSession: async (options) => {
if (this.loadingAnimation) { this.clearStatusIndicator();
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
try { try {
return await this.runtimeHost.newSession(options); return await this.runtimeHost.newSession(options);
} catch (error: unknown) { } catch (error: unknown) {
@@ -1625,7 +1625,11 @@ export class InteractiveMode {
this.footerDataProvider.setCwd(this.sessionManager.getCwd()); this.footerDataProvider.setCwd(this.sessionManager.getCwd());
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); 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 editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor.setPaddingX(editorPaddingX); this.defaultEditor.setPaddingX(editorPaddingX);
@@ -1744,46 +1748,50 @@ export class InteractiveMode {
this.ui.requestRender(); this.ui.requestRender();
} }
private getWorkingLoaderMessage(): string { private showStatusIndicator(indicator: StatusIndicator): void {
return this.workingMessage ?? this.defaultWorkingMessage; this.activeStatusIndicator?.dispose();
} this.activeStatusIndicator = indicator;
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(); 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 { private setWorkingVisible(visible: boolean): void {
this.workingVisible = visible; this.workingVisible = visible;
if (!visible) { if (!visible) {
this.stopWorkingLoader(); this.clearStatusIndicator("working");
this.ui.requestRender(); this.ui.requestRender();
return; return;
} }
if (this.session.isStreaming && !this.loadingAnimation) { if (this.session.isStreaming && this.activeStatusIndicator?.kind !== "working") {
this.statusContainer.clear(); this.showStatusIndicator(
this.loadingAnimation = this.createWorkingLoader(); new WorkingStatusIndicator(
this.statusContainer.addChild(this.loadingAnimation); this.ui,
this.workingMessage ?? this.defaultWorkingMessage,
this.workingIndicatorOptions,
),
);
} }
this.ui.requestRender(); this.ui.requestRender();
} }
private setWorkingIndicator(options?: LoaderIndicatorOptions): void { private setWorkingIndicator(options?: WorkingIndicatorOptions): void {
this.workingIndicatorOptions = options; this.workingIndicatorOptions = options;
this.loadingAnimation?.setIndicator(options); if (this.activeStatusIndicator?.kind === "working") {
this.activeStatusIndicator.setIndicator(options);
}
this.ui.requestRender(); this.ui.requestRender();
} }
@@ -1882,8 +1890,10 @@ export class InteractiveMode {
this.workingMessage = undefined; this.workingMessage = undefined;
this.workingVisible = true; this.workingVisible = true;
this.setWorkingIndicator(); this.setWorkingIndicator();
if (this.loadingAnimation) { if (this.activeStatusIndicator?.kind === "working") {
this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`); this.activeStatusIndicator.setMessage(
`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`,
);
} }
this.setHiddenThinkingLabel(); this.setHiddenThinkingLabel();
} }
@@ -2047,8 +2057,8 @@ export class InteractiveMode {
setStatus: (key, text) => this.setExtensionStatus(key, text), setStatus: (key, text) => this.setExtensionStatus(key, text),
setWorkingMessage: (message) => { setWorkingMessage: (message) => {
this.workingMessage = message; this.workingMessage = message;
if (this.loadingAnimation) { if (this.activeStatusIndicator?.kind === "working") {
this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage); this.activeStatusIndicator.setMessage(message ?? this.defaultWorkingMessage);
} }
}, },
setWorkingVisible: (visible) => this.setWorkingVisible(visible), setWorkingVisible: (visible) => this.setWorkingVisible(visible),
@@ -2749,18 +2759,16 @@ export class InteractiveMode {
this.defaultEditor.onEscape = this.retryEscapeHandler; this.defaultEditor.onEscape = this.retryEscapeHandler;
this.retryEscapeHandler = undefined; 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) { if (this.workingVisible) {
this.loadingAnimation = this.createWorkingLoader(); this.showStatusIndicator(
this.statusContainer.addChild(this.loadingAnimation); new WorkingStatusIndicator(
this.ui,
this.workingMessage ?? this.defaultWorkingMessage,
this.workingIndicatorOptions,
),
);
} else {
this.clearStatusIndicator();
} }
this.ui.requestRender(); this.ui.requestRender();
break; break;
@@ -2924,11 +2932,7 @@ export class InteractiveMode {
if (this.settingsManager.getShowTerminalProgress()) { if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(false); this.ui.terminal.setProgress(false);
} }
if (this.loadingAnimation) { this.clearStatusIndicator("working");
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
this.statusContainer.clear();
}
if (this.streamingComponent) { if (this.streamingComponent) {
this.chatContainer.removeChild(this.streamingComponent); this.chatContainer.removeChild(this.streamingComponent);
this.streamingComponent = undefined; this.streamingComponent = undefined;
@@ -2950,19 +2954,7 @@ export class InteractiveMode {
this.defaultEditor.onEscape = () => { this.defaultEditor.onEscape = () => {
this.session.abortCompaction(); this.session.abortCompaction();
}; };
this.statusContainer.clear(); this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason));
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.ui.requestRender(); this.ui.requestRender();
break; break;
} }
@@ -2975,11 +2967,7 @@ export class InteractiveMode {
this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
this.autoCompactionEscapeHandler = undefined; this.autoCompactionEscapeHandler = undefined;
} }
if (this.autoCompactionLoader) { this.clearStatusIndicator("compaction");
this.autoCompactionLoader.stop();
this.autoCompactionLoader = undefined;
this.statusContainer.clear();
}
if (event.aborted) { if (event.aborted) {
if (event.reason === "manual") { if (event.reason === "manual") {
this.showError("Compaction cancelled"); this.showError("Compaction cancelled");
@@ -3016,28 +3004,9 @@ export class InteractiveMode {
this.defaultEditor.onEscape = () => { this.defaultEditor.onEscape = () => {
this.session.abortRetry(); this.session.abortRetry();
}; };
// Show retry indicator this.showStatusIndicator(
this.statusContainer.clear(); new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs),
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.retryCountdown = new CountdownTimer(
event.delayMs,
this.ui,
(seconds) => {
this.retryLoader?.setMessage(retryMessage(seconds));
},
() => {
this.retryCountdown = undefined;
},
);
this.statusContainer.addChild(this.retryLoader);
this.ui.requestRender(); this.ui.requestRender();
break; break;
} }
@@ -3048,16 +3017,7 @@ export class InteractiveMode {
this.defaultEditor.onEscape = this.retryEscapeHandler; this.defaultEditor.onEscape = this.retryEscapeHandler;
this.retryEscapeHandler = undefined; this.retryEscapeHandler = undefined;
} }
if (this.retryCountdown) { this.clearStatusIndicator("retry");
this.retryCountdown.dispose();
this.retryCountdown = undefined;
}
// Stop loader
if (this.retryLoader) {
this.retryLoader.stop();
this.retryLoader = undefined;
this.statusContainer.clear();
}
// Show error only on final failure (success shows normal response) // Show error only on final failure (success shows normal response)
if (!event.success) { if (!event.success) {
this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
@@ -4111,6 +4071,9 @@ export class InteractiveMode {
onClearOnShrinkChange: (enabled) => { onClearOnShrinkChange: (enabled) => {
this.settingsManager.setClearOnShrink(enabled); this.settingsManager.setClearOnShrink(enabled);
this.ui.setClearOnShrink(enabled); this.ui.setClearOnShrink(enabled);
if (!enabled && !this.activeStatusIndicator) {
this.statusContainer.clear();
}
}, },
onShowTerminalProgressChange: (enabled) => { onShowTerminalProgressChange: (enabled) => {
this.settingsManager.setShowTerminalProgress(enabled); this.settingsManager.setShowTerminalProgress(enabled);
@@ -4490,8 +4453,8 @@ export class InteractiveMode {
} }
} }
// Set up escape handler and loader if summarizing // Set up escape handler and status indicator if summarizing
let summaryLoader: Loader | undefined; let showingSummaryIndicator = false;
const originalOnEscape = this.defaultEditor.onEscape; const originalOnEscape = this.defaultEditor.onEscape;
if (wantsSummary) { if (wantsSummary) {
@@ -4499,13 +4462,8 @@ export class InteractiveMode {
this.session.abortBranchSummary(); this.session.abortBranchSummary();
}; };
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
summaryLoader = new Loader( this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui));
this.ui, showingSummaryIndicator = true;
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`Summarizing branch... (${keyText("app.interrupt")} to cancel)`,
);
this.statusContainer.addChild(summaryLoader);
this.ui.requestRender(); this.ui.requestRender();
} }
@@ -4537,9 +4495,8 @@ export class InteractiveMode {
} catch (error) { } catch (error) {
this.showError(error instanceof Error ? error.message : String(error)); this.showError(error instanceof Error ? error.message : String(error));
} finally { } finally {
if (summaryLoader) { if (showingSummaryIndicator) {
summaryLoader.stop(); this.clearStatusIndicator("branchSummary");
this.statusContainer.clear();
} }
this.defaultEditor.onEscape = originalOnEscape; this.defaultEditor.onEscape = originalOnEscape;
} }
@@ -4601,11 +4558,7 @@ export class InteractiveMode {
sessionPath: string, sessionPath: string,
options?: Parameters<ExtensionCommandContext["switchSession"]>[1], options?: Parameters<ExtensionCommandContext["switchSession"]>[1],
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
if (this.loadingAnimation) { this.clearStatusIndicator();
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
try { try {
const result = await this.runtimeHost.switchSession(sessionPath, { const result = await this.runtimeHost.switchSession(sessionPath, {
withSession: options?.withSession, withSession: options?.withSession,
@@ -5114,7 +5067,11 @@ export class InteractiveMode {
this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible); this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
} }
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); 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(); this.setupAutocompleteProvider();
const runner = this.session.extensionRunner; const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner); this.setupExtensionShortcuts(runner);
@@ -5201,11 +5158,7 @@ export class InteractiveMode {
} }
try { try {
if (this.loadingAnimation) { this.clearStatusIndicator();
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
const result = await this.runtimeHost.importFromJsonl(inputPath); const result = await this.runtimeHost.importFromJsonl(inputPath);
if (result.cancelled) { if (result.cancelled) {
this.showStatus("Import cancelled"); this.showStatus("Import cancelled");
@@ -5556,11 +5509,7 @@ export class InteractiveMode {
} }
private async handleClearCommand(): Promise<void> { private async handleClearCommand(): Promise<void> {
if (this.loadingAnimation) { this.clearStatusIndicator();
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
try { try {
const result = await this.runtimeHost.newSession(); const result = await this.runtimeHost.newSession();
if (result.cancelled) { if (result.cancelled) {
@@ -5719,11 +5668,7 @@ export class InteractiveMode {
} }
private async handleCompactCommand(customInstructions?: string): Promise<void> { private async handleCompactCommand(customInstructions?: string): Promise<void> {
if (this.loadingAnimation) { this.clearStatusIndicator();
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
try { try {
await this.session.compact(customInstructions); await this.session.compact(customInstructions);
@@ -5736,10 +5681,7 @@ export class InteractiveMode {
if (this.settingsManager.getShowTerminalProgress()) { if (this.settingsManager.getShowTerminalProgress()) {
this.ui.terminal.setProgress(false); this.ui.terminal.setProgress(false);
} }
if (this.loadingAnimation) { this.clearStatusIndicator();
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.themeController.disableAutoSync(); this.themeController.disableAutoSync();
this.clearExtensionTerminalInputListeners(); this.clearExtensionTerminalInputListeners();
this.footer.dispose(); this.footer.dispose();
@@ -10,8 +10,7 @@ type InteractiveModePrototype = {
}; };
type ImportCommandContext = { type ImportCommandContext = {
loadingAnimation?: { stop: () => void }; clearStatusIndicator: () => void;
statusContainer: { clear: () => void };
runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> }; runtimeHost: { importFromJsonl: (inputPath: string, cwdOverride?: string) => Promise<{ cancelled: boolean }> };
showError: (message: string) => void; showError: (message: string) => void;
showStatus: (message: string) => void; showStatus: (message: string) => void;
@@ -58,7 +57,7 @@ describe("InteractiveMode /import parsing", () => {
const showError = vi.fn(); const showError = vi.fn();
const context: ImportCommandContext = { const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() }, clearStatusIndicator: vi.fn(),
runtimeHost: { importFromJsonl }, runtimeHost: { importFromJsonl },
showError, showError,
showStatus, showStatus,
@@ -90,7 +89,7 @@ describe("InteractiveMode /import parsing", () => {
const showError = vi.fn(); const showError = vi.fn();
const context: ImportCommandContext = { const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() }, clearStatusIndicator: vi.fn(),
runtimeHost: { importFromJsonl }, runtimeHost: { importFromJsonl },
showError, showError,
showStatus, showStatus,
@@ -123,7 +122,7 @@ describe("InteractiveMode /import parsing", () => {
}); });
const context: ImportCommandContext = { const context: ImportCommandContext = {
statusContainer: { clear: vi.fn() }, clearStatusIndicator: vi.fn(),
runtimeHost: { importFromJsonl }, runtimeHost: { importFromJsonl },
showError, showError,
showStatus, 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);
});
});