From 5d499272a879398985ad9b1695866ab3d8685053 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 29 Jun 2026 16:53:39 +0200 Subject: [PATCH] fix(coding-agent): stabilize interactive status indicators Closes #6026 --- packages/coding-agent/CHANGELOG.md | 1 + .../components/status-indicator.ts | 114 +++++++++ .../src/modes/interactive/interactive-mode.ts | 234 +++++++----------- .../interactive-mode-import-command.test.ts | 9 +- .../test/status-indicator.test.ts | 32 +++ 5 files changed, 239 insertions(+), 151 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/status-indicator.ts create mode 100644 packages/coding-agent/test/status-indicator.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4a4de8b6..e3b5f016 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/src/modes/interactive/components/status-indicator.ts b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts new file mode 100644 index 00000000..cb7f2ef8 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/status-indicator.ts @@ -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]; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 4d7ba928..3945f304 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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[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 { - 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 { - 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(); diff --git a/packages/coding-agent/test/interactive-mode-import-command.test.ts b/packages/coding-agent/test/interactive-mode-import-command.test.ts index c2e5ae1f..cec7a5de 100644 --- a/packages/coding-agent/test/interactive-mode-import-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-import-command.test.ts @@ -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, diff --git a/packages/coding-agent/test/status-indicator.test.ts b/packages/coding-agent/test/status-indicator.test.ts new file mode 100644 index 00000000..72267fb3 --- /dev/null +++ b/packages/coding-agent/test/status-indicator.test.ts @@ -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); + }); +});