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.
16 changes: 12 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,20 @@
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 { 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 +29,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), 1, 0));
}
}
}

Expand Down
5 changes: 5 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,11 @@ 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;

// 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
34 changes: 33 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 @@ -267,7 +268,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 +355,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 @@ -398,6 +400,10 @@ export class SessionEventHandler {

private handleStepBegin(event: TurnStepStartedEvent): void {
this.host.streamingUI.flushNow();
// NOTE: no clearStepRetry() here — the v2 engine re-emits `turn.step.started`
// for every retried attempt of the same step (stepRetryService re-queues the
// failed driver), so clearing here would hide the retry label mid-retry. The
// state is cleared by the step's terminal events instead.
Comment thread
liruifengv marked this conversation as resolved.
Resolved
this.host.streamingUI.setStep(event.step);
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeLiveTextBuffers('waiting');
Expand All @@ -414,6 +420,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 +449,29 @@ 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.
Resolved
streamingPhase: 'waiting',
stepRetry: {
nextAttempt: event.nextAttempt,
maxAttempts: event.maxAttempts,
delayMs: event.delayMs,
errorName: event.errorName,
errorMessage: event.errorMessage,
statusCode: event.statusCode,
},
});
}

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

private maybeShowDebugTiming(event: TurnStepCompletedEvent): void {
if (process.env['KIMI_CODE_DEBUG'] !== '1') return;
const text = formatStepDebugTiming(event);
Expand Down Expand Up @@ -469,6 +499,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 +652,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
21 changes: 18 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 @@ -2712,7 +2719,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 +2747,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
14 changes: 14 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,18 @@ 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;
}

export interface ToolCallBlockData {
id: string;
name: string;
Expand Down
17 changes: 17 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,17 @@
import { RETRY_DETAIL_MAX_CHARS } from '../constant/rendering';
import type { StepRetryState } from '../types';

export function formatStepRetryLabel(retry: StepRetryState): string {
const delaySeconds = Math.max(1, Math.ceil(retry.delayMs / 1000));
return `retrying (${retry.nextAttempt}/${retry.maxAttempts}) · ${retry.errorName} · 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