fix(coding-agent): stabilize interactive status indicators
Closes #6026
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user