Skip to content
5 changes: 5 additions & 0 deletions .changeset/tui-retry-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show retry progress in the loading indicator when a model request fails and is retried, with the attempt count and a detail line for the provider error.
17 changes: 13 additions & 4 deletions apps/kimi-code/src/tui/components/panes/activity-pane.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { Container, Spacer } from '@moonshot-ai/pi-tui';
import { Container, Spacer, Text } from '@moonshot-ai/pi-tui';

import type { MoonLoader } from '#/tui/components/chrome/moon-loader';
import { ACTIVITY_DETAIL_INDENT } from '#/tui/constant/rendering';
import { currentTheme } from '#/tui/theme';

export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool';

export interface ActivityPaneOptions {
readonly mode: ActivityPaneMode;
readonly spinner?: MoonLoader;
readonly tip?: string;
/** Extra dim line rendered under the spinner (e.g. step retry error detail). */
readonly detail?: string;
}

export function formatActivitySpinnerTip(tip: string | undefined): string {
return tip === undefined || tip.length === 0 ? '' : ` · Tip: ${tip}`;
}

export class ActivityPaneComponent extends Container {
Expand All @@ -22,10 +30,11 @@ export class ActivityPaneComponent extends Container {
options.spinner !== undefined
) {
this.addChild(new Spacer(1));
if (options.tip) {
options.spinner.setTip(` · Tip: ${options.tip}`);
}
options.spinner.setTip(formatActivitySpinnerTip(options.tip));
this.addChild(options.spinner);
if (options.detail !== undefined && options.detail.length > 0) {
this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0));
}
}
}

Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-code/src/tui/constant/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export const RESULT_PREVIEW_LINES = 3;
export const THINKING_PREVIEW_LINES = 2;
export const COMMAND_PREVIEW_LINES = 10;

// Cap on the step-retry detail line under the waiting spinner, so huge
// provider error bodies (occasionally whole HTML error pages) can't flood
// the activity pane.
export const RETRY_DETAIL_MAX_CHARS = 160;
// Left indent (cells) for the detail line under the waiting spinner, aligning
// it with the label text: 1 (the spinner Text's own paddingX) + 2 (moon
// frame) + 1 (space between frame and label).
export const ACTIVITY_DETAIL_INDENT = 4;

// Retention caps for the subagent activity store (background-agent detail
// view): only the most recent steps are kept, older steps are discarded
// whole, and per-step text / per-call output keep bounded tails.
Expand Down
51 changes: 50 additions & 1 deletion apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
TurnStartedEvent,
TurnStepCompletedEvent,
TurnStepInterruptedEvent,
TurnStepRetryingEvent,
TurnStepStartedEvent,
TokenUsage,
WarningEvent,
Expand Down Expand Up @@ -166,6 +167,7 @@ export class SessionEventHandler {
private queuedGoalPromotionPending = false;
private queuedGoalPromotionInFlight = false;
private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined;
private stepRetryAttemptTimer: ReturnType<typeof setTimeout> | undefined;

resetRuntimeState(): void {
this.backgroundTasks.clear();
Expand All @@ -184,6 +186,7 @@ export class SessionEventHandler {
this.queuedGoalPromotionPending = false;
this.queuedGoalPromotionInFlight = false;
this.clearQueuedGoalPromotionTimer();
this.clearStepRetryAttemptTimer();
this.stopAllMcpServerStatusSpinners();
}

Expand Down Expand Up @@ -267,7 +270,7 @@ export class SessionEventHandler {
case 'turn.step.started': this.handleStepBegin(event); break;
case 'turn.step.interrupted': this.handleStepInterrupted(event); break;
case 'turn.step.completed': this.handleStepCompleted(event); break;
case 'turn.step.retrying': break;
case 'turn.step.retrying': this.handleStepRetrying(event); break;
case 'tool.progress': this.handleToolProgress(event); break;
case 'shell.output': this.host.handleShellOutput(event); break;
case 'shell.started': this.host.handleShellStarted(event); break;
Expand Down Expand Up @@ -354,6 +357,7 @@ export class SessionEventHandler {

private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void {
this.host.streamingUI.flushNow();
this.clearStepRetry();
if (event.reason === 'cancelled') {
this.markActiveAgentSwarmsCancelled();
}
Expand Down Expand Up @@ -414,6 +418,7 @@ export class SessionEventHandler {

private handleStepCompleted(event: TurnStepCompletedEvent): void {
this.host.streamingUI.flushNow();
this.clearStepRetry();
this.host.noteStepUsage(event.usage);
this.maybeShowDebugTiming(event);

Expand Down Expand Up @@ -442,6 +447,48 @@ export class SessionEventHandler {
this.host.showNotice(title, detail);
}

private handleStepRetrying(event: TurnStepRetryingEvent): void {
// The failure may arrive mid-stream, after thinking/assistant deltas have
// parked the pane in `thinking`/`composing` — drive it back to waiting so
// the retry label and detail actually render during the backoff.
this.host.patchLivePane({ mode: 'waiting' });
this.host.setAppState({
Comment thread
liruifengv marked this conversation as resolved.
streamingPhase: 'waiting',
stepRetry: {
nextAttempt: event.nextAttempt,
maxAttempts: event.maxAttempts,
delayMs: event.delayMs,
errorName: event.errorName,
errorMessage: event.errorMessage,
statusCode: event.statusCode,
phase: 'backoff',
},
});
// Both engines sleep for `delayMs` before the next attempt runs, but only
// v2 re-emits `turn.step.started` for it — flip the phase on a timer so the
// stale countdown drops on the legacy engine too.
this.clearStepRetryAttemptTimer();
this.stepRetryAttemptTimer = setTimeout(() => {
this.stepRetryAttemptTimer = undefined;
const retry = this.host.state.appState.stepRetry;
if (retry === null) return;
this.host.setAppState({ stepRetry: { ...retry, phase: 'attempt' } });
}, event.delayMs);
Comment thread
liruifengv marked this conversation as resolved.
}

private clearStepRetry(): void {
this.clearStepRetryAttemptTimer();
if (this.host.state.appState.stepRetry === null) return;
this.host.setAppState({ stepRetry: null });
}

clearStepRetryAttemptTimer(): void {
if (this.stepRetryAttemptTimer !== undefined) {
clearTimeout(this.stepRetryAttemptTimer);
this.stepRetryAttemptTimer = undefined;
}
}

private maybeShowDebugTiming(event: TurnStepCompletedEvent): void {
if (process.env['KIMI_CODE_DEBUG'] !== '1') return;
const text = formatStepDebugTiming(event);
Expand Down Expand Up @@ -469,6 +516,7 @@ export class SessionEventHandler {

private handleStepInterrupted(event: TurnStepInterruptedEvent): void {
this.host.streamingUI.flushNow();
this.clearStepRetry();
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeLiveTextBuffers('idle');
const reason = event.reason;
Expand Down Expand Up @@ -621,6 +669,7 @@ export class SessionEventHandler {
private handleToolResult(event: ToolResultEvent): void {
const { streamingUI } = this.host;
streamingUI.flushNow();
this.clearStepRetry();
const resultData: ToolResultBlockData = {
tool_call_id: event.toolCallId,
output: serializeToolResultOutput(event.output),
Expand Down
22 changes: 19 additions & 3 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ import {
type LoginProgressSpinnerHandle,
type QueuedMessage,
type SteerInputItem,
type StepRetryState,
type TranscriptEntry,
type TUIStartupOptions,
type TUIStartupState,
Expand All @@ -152,6 +153,7 @@ import { startupTrace } from '#/utils/startup-trace';
import { REPLAY_TURN_LIMIT } from './utils/message-replay';
import { hasPatchChanges } from './utils/object-patch';
import { sessionRowsForPicker } from './utils/session-picker-rows';
import { formatStepRetryDetail, formatStepRetryLabel } from './utils/step-retry';
import { formatBashOutputForDisplay } from './utils/shell-output';
import { thinkingEffortFromConfig } from './utils/thinking-config';
import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup';
Expand Down Expand Up @@ -210,6 +212,10 @@ function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undef
return undefined;
}

function waitingSpinnerLabel(retry: StepRetryState | null): string {
return retry === null ? '' : formatStepRetryLabel(retry);
}

function sameStringArrays(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
Expand Down Expand Up @@ -241,6 +247,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
isReplaying: false,
streamingPhase: 'idle',
streamingStartTime: 0,
stepRetry: null,
theme: input.tuiConfig.theme,
version: input.version,
editorCommand: input.tuiConfig.editorCommand,
Expand Down Expand Up @@ -977,6 +984,7 @@ export class KimiTUI {
await this.harness.close();
} finally {
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
this.sessionEventHandler.clearStepRetryAttemptTimer();
this.uninstallRainbowDance();
try {
await this.state.terminal.drainInput();
Expand Down Expand Up @@ -2712,7 +2720,13 @@ export class KimiTUI {
}
this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode));
const placeSpinnerInAgentSwarm = this.shouldPlaceActivitySpinnerInAgentSwarm(effectiveMode);
const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}`;
// Carry the retry state in the mode key so an incoming/cleared
// `turn.step.retrying` rebuilds the waiting pane with fresh label and
// detail instead of hitting the cached-pane early return below.
const retry = effectiveMode === 'waiting' ? this.state.appState.stepRetry : null;
const retryKey =
retry === null ? '' : `${formatStepRetryLabel(retry)}|${formatStepRetryDetail(retry)}`;
const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentSwarm ? 'swarm' : 'pane'}:${retryKey}`;

if (
activityModeKey === this.lastActivityMode &&
Expand All @@ -2734,14 +2748,16 @@ export class KimiTUI {
this.state.ui.requestRender();
return;
case 'waiting': {
const spinner = this.ensureActivitySpinner('moon');
const stepRetry = this.state.appState.stepRetry;
const spinner = this.ensureActivitySpinner('moon', waitingSpinnerLabel(stepRetry));
this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined);
if (placeSpinnerInAgentSwarm) break;
this.state.activityContainer.addChild(
new ActivityPaneComponent({
mode: 'waiting',
spinner,
tip: this.currentLoadingTip?.tip,
tip: stepRetry === null ? this.currentLoadingTip?.tip : undefined,
detail: stepRetry === null ? undefined : formatStepRetryDetail(stepRetry),
}),
);
break;
Expand Down
20 changes: 20 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface AppState {
isReplaying: boolean;
streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell';
streamingStartTime: number;
/** Pending step retry backoff (fed by `turn.step.retrying`); null when no retry is in flight. */
stepRetry: StepRetryState | null;
theme: ThemeName;
version: string;
editorCommand: string | null;
Expand All @@ -85,6 +87,24 @@ export interface AppState {
banner?: BannerState | null;
}

export interface StepRetryState {
/** Upcoming attempt number (1-based). */
nextAttempt: number;
maxAttempts: number;
/** Backoff wait before the next attempt, in milliseconds. */
delayMs: number;
errorName: string;
errorMessage: string;
/** HTTP status code for `APIStatusError`; undefined for network/timeout failures. */
statusCode?: number;
/**
* `backoff` while sleeping before the next attempt (label shows the
* countdown); `attempt` once the `delayMs` backoff has elapsed and the next
* attempt is running — the countdown has expired by then and is dropped.
*/
phase: 'backoff' | 'attempt';
}

export interface ToolCallBlockData {
id: string;
name: string;
Expand Down
19 changes: 19 additions & 0 deletions apps/kimi-code/src/tui/utils/step-retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { RETRY_DETAIL_MAX_CHARS } from '../constant/rendering';
import type { StepRetryState } from '../types';

export function formatStepRetryLabel(retry: StepRetryState): string {
const base = `Retrying (${retry.nextAttempt}/${retry.maxAttempts}) · ${retry.errorName}`;
if (retry.phase === 'attempt') return base;
const delaySeconds = Math.max(1, Math.ceil(retry.delayMs / 1000));
return `${base} · in ${delaySeconds}s`;
}

/** Detail line under the spinner: status code + provider message, single-line, capped. */
export function formatStepRetryDetail(retry: StepRetryState): string {
const message = retry.errorMessage.replaceAll(/\s+/g, ' ').trim();
const code = retry.statusCode === undefined ? '' : String(retry.statusCode);
const detail = [code, message].filter((part) => part.length > 0).join(' · ');
return detail.length > RETRY_DETAIL_MAX_CHARS
? `${detail.slice(0, RETRY_DETAIL_MAX_CHARS - 1)}…`
: detail;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const baseState: AppState = {
isReplaying: false,
streamingPhase: 'idle',
streamingStartTime: 0,
stepRetry: null,
planMode: false,
inputMode: 'prompt',
swarmMode: false,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/components/chrome/footer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const appState: AppState = {
isReplaying: false,
streamingPhase: 'idle',
streamingStartTime: 0,
stepRetry: null,
planMode: false,
inputMode: 'prompt',
swarmMode: false,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/components/chrome/welcome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const appState: AppState = {
isReplaying: false,
streamingPhase: 'idle',
streamingStartTime: 0,
stepRetry: null,
planMode: false,
inputMode: 'prompt',
swarmMode: false,
Expand Down
20 changes: 18 additions & 2 deletions apps/kimi-code/test/tui/components/panes/activity-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,39 @@ function createMockSpinner(initialText = 'working') {

describe('ActivityPaneComponent', () => {
it('renders waiting loader after a spacer', () => {
const { spinner } = createMockSpinner('loading');
const component = new ActivityPaneComponent({
mode: 'waiting',
spinner: new Text('loading', 0, 0) as never,
spinner,
});

expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'loading']);
});

it('renders composing spinner after a spacer', () => {
const { spinner } = createMockSpinner('working');
const component = new ActivityPaneComponent({
mode: 'composing',
spinner: new Text('working', 0, 0) as never,
spinner,
});

expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']);
});

it('renders the detail line under the waiting spinner', () => {
const { spinner } = createMockSpinner('working');
const component = new ActivityPaneComponent({
mode: 'waiting',
spinner,
detail: '429 · rate limited',
});

const lines = component
.render(80)
.map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '').trimEnd());
expect(lines).toEqual(['', 'working', ' 429 · rate limited']);
});

it.each(['waiting', 'tool', 'composing'] as const)(
'renders %s spinner with tip after a spacer',
(mode) => {
Expand Down
Loading
Loading