From 1b941ff5cb29265ad583fbbe407c547dbb769dd5 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 22:30:59 +0800 Subject: [PATCH 1/7] feat(kimi-code): show step retry progress in the activity indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the engine's turn.step.retrying event into the TUI: while a failed model request is backing off for another attempt, the waiting spinner shows 'retrying (N/M) · errorName · in Xs' with a dim detail line for the status code and provider error message, and the loading tip is suppressed. The retry state clears on the step's terminal events (completed / interrupted), turn.ended, and tool.result. It intentionally survives turn.step.started because the v2 engine re-emits that event for every retried attempt of the same step. --- .changeset/tui-retry-progress.md | 5 + .../src/tui/components/panes/activity-pane.ts | 16 ++- apps/kimi-code/src/tui/constant/rendering.ts | 5 + .../tui/controllers/session-event-handler.ts | 29 +++- apps/kimi-code/src/tui/kimi-tui.ts | 21 ++- apps/kimi-code/src/tui/types.ts | 14 ++ apps/kimi-code/src/tui/utils/step-retry.ts | 17 +++ .../chrome/footer-status-line.test.ts | 1 + .../test/tui/components/chrome/footer.test.ts | 1 + .../tui/components/chrome/welcome.test.ts | 1 + .../components/panes/activity-pane.test.ts | 20 ++- .../session-event-handler-step-retry.test.ts | 126 ++++++++++++++++++ .../test/tui/create-tui-state.test.ts | 1 + .../test/tui/utils/step-retry.test.ts | 57 ++++++++ 14 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 .changeset/tui-retry-progress.md create mode 100644 apps/kimi-code/src/tui/utils/step-retry.ts create mode 100644 apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts create mode 100644 apps/kimi-code/test/tui/utils/step-retry.test.ts diff --git a/.changeset/tui-retry-progress.md b/.changeset/tui-retry-progress.md new file mode 100644 index 00000000000..76143cb0bbe --- /dev/null +++ b/.changeset/tui-retry-progress.md @@ -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. diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index 22e6f3bc5c8..f7b32591b54 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,6 +1,7 @@ -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'; @@ -8,6 +9,12 @@ 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 { @@ -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)); + } } } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index a6a1c6b7d4b..7618a118378 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -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. diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 3735465089a..7cc75c352cf 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -27,6 +27,7 @@ import type { TurnStartedEvent, TurnStepCompletedEvent, TurnStepInterruptedEvent, + TurnStepRetryingEvent, TurnStepStartedEvent, TokenUsage, WarningEvent, @@ -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; @@ -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(); } @@ -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. this.host.streamingUI.setStep(event.step); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('waiting'); @@ -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); @@ -442,6 +449,24 @@ export class SessionEventHandler { this.host.showNotice(title, detail); } + private handleStepRetrying(event: TurnStepRetryingEvent): void { + this.host.setAppState({ + 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); @@ -469,6 +494,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; @@ -621,6 +647,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), diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index c1254bce89c..12d6b994de7 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -136,6 +136,7 @@ import { type LoginProgressSpinnerHandle, type QueuedMessage, type SteerInputItem, + type StepRetryState, type TranscriptEntry, type TUIStartupOptions, type TUIStartupState, @@ -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'; @@ -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]); } @@ -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, @@ -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 && @@ -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; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d423aec7053..83a016e7570 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -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; @@ -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; diff --git a/apps/kimi-code/src/tui/utils/step-retry.ts b/apps/kimi-code/src/tui/utils/step-retry.ts new file mode 100644 index 00000000000..34013c788a7 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/step-retry.ts @@ -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; +} diff --git a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts index a6be39adfb6..36bd1fcf51d 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer-status-line.test.ts @@ -29,6 +29,7 @@ const baseState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 2fe6f3e52e6..79abf826e8e 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -48,6 +48,7 @@ const appState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index bc1b754fb67..18eef144018 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -25,6 +25,7 @@ const appState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, inputMode: 'prompt', swarmMode: false, diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index 76acd438ce5..c02bc0aa332 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -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) => { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts new file mode 100644 index 00000000000..fa44a3160c7 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost() { + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + isCompacting: false, + model: 'kimi-model', + permissionMode: 'auto', + stepRetry: null, + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + setStep: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + finalizeLiveTextBuffers: vi.fn(), + completeToolResult: vi.fn(), + }, + requireSession: vi.fn(), + setAppState: vi.fn((patch: Record) => + Object.assign(host.state.appState, patch), + ), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any }; +} + +const retryingEvent = { + type: 'turn.step.retrying', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, +} as const; + +describe('SessionEventHandler step retry state', () => { + it('stores the retry snapshot when a step starts retrying', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toEqual({ + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + }); + }); + + it.each([ + [{ type: 'turn.step.completed', turnId: 1, step: 1 }, 'turn.step.completed'], + [ + { type: 'turn.step.interrupted', turnId: 1, step: 1, reason: 'error' }, + 'turn.step.interrupted', + ], + [{ type: 'turn.ended', turnId: 1, reason: 'completed' }, 'turn.ended'], + [ + { type: 'tool.result', turnId: 1, toolCallId: 'tc1', output: 'ok', isError: false }, + 'tool.result', + ], + ])('clears the retry snapshot on %s', (event, _label) => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).not.toBeNull(); + handler.handleEvent( + { sessionId: 's1', agentId: 'main', ...event } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('keeps the retry snapshot on turn.step.started (v2 re-emits it per retried attempt)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { type: 'turn.step.started', sessionId: 's1', agentId: 'main', turnId: 1, step: 1 } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).not.toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf07023..8e17cc8f6b3 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -22,6 +22,7 @@ function fakeInitialAppState(): AppState { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: 'dark', version: '0.0.0-test', editorCommand: null, diff --git a/apps/kimi-code/test/tui/utils/step-retry.test.ts b/apps/kimi-code/test/tui/utils/step-retry.test.ts new file mode 100644 index 00000000000..bf848a4446c --- /dev/null +++ b/apps/kimi-code/test/tui/utils/step-retry.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { RETRY_DETAIL_MAX_CHARS } from '#/tui/constant/rendering'; +import { formatStepRetryDetail, formatStepRetryLabel } from '#/tui/utils/step-retry'; +import type { StepRetryState } from '#/tui/types'; + +function retry(partial: Partial = {}): StepRetryState { + return { + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + ...partial, + }; +} + +describe('formatStepRetryLabel', () => { + it('shows attempts, raw error name, and backoff delay', () => { + expect(formatStepRetryLabel(retry())).toBe('retrying (2/10) · APIStatusError · in 4s'); + }); + + it('rounds sub-second delays up to 1s', () => { + expect(formatStepRetryLabel(retry({ delayMs: 500 }))).toContain('in 1s'); + }); +}); + +describe('formatStepRetryDetail', () => { + it('prefixes the message with the status code', () => { + expect(formatStepRetryDetail(retry())).toBe('429 · rate limited'); + }); + + it('omits the status code for network/timeout failures', () => { + expect( + formatStepRetryDetail( + retry({ errorName: 'APIConnectionError', errorMessage: 'fetch failed', statusCode: undefined }), + ), + ).toBe('fetch failed'); + }); + + it('collapses multi-line error bodies into one line', () => { + expect(formatStepRetryDetail(retry({ errorMessage: 'line one\n\n line two' }))).toBe( + '429 · line one line two', + ); + }); + + it('caps huge error bodies', () => { + const detail = formatStepRetryDetail(retry({ errorMessage: 'x'.repeat(1000) })); + expect(detail.length).toBe(RETRY_DETAIL_MAX_CHARS); + expect(detail.endsWith('…')).toBe(true); + }); + + it('returns the status code alone when the message is empty', () => { + expect(formatStepRetryDetail(retry({ errorMessage: '' }))).toBe('429'); + }); +}); From 109a2f9c0ede8660637fabd98f6f870f26277cf4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 22:38:07 +0800 Subject: [PATCH 2/7] fix(kimi-code): show the retry indicator for mid-stream failures A retryable failure raised after thinking/assistant deltas had already streamed left the pane in thinking/composing mode, so the retry label and detail never rendered during the backoff. Drive the pane and the streaming phase back to waiting when a retry begins. --- .../src/tui/controllers/session-event-handler.ts | 5 +++++ .../controllers/session-event-handler-step-retry.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 7cc75c352cf..ce7500503d7 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -450,7 +450,12 @@ export class SessionEventHandler { } 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({ + streamingPhase: 'waiting', stepRetry: { nextAttempt: event.nextAttempt, maxAttempts: event.maxAttempts, diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts index fa44a3160c7..73ec850c6ab 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -90,6 +90,15 @@ describe('SessionEventHandler step retry state', () => { }); }); + it('drives the pane back to waiting so mid-stream retries render', () => { + const { host } = makeHost(); + host.state.appState.streamingPhase = 'composing'; + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.patchLivePane).toHaveBeenCalledWith({ mode: 'waiting' }); + expect(host.state.appState.streamingPhase).toBe('waiting'); + }); + it.each([ [{ type: 'turn.step.completed', turnId: 1, step: 1 }, 'turn.step.completed'], [ From 79830f9f6106054807c7dcef337e8deb632ee73a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 23:00:52 +0800 Subject: [PATCH 3/7] fix(kimi-code): drop the stale retry countdown once the attempt starts The v2 engine re-emits turn.step.started when the retried attempt begins running after the backoff sleep. Track a backoff/attempt phase so the label keeps showing the retry attempt and error but drops the already-elapsed 'in Xs' countdown, instead of either clearing the state or showing stale timing through a slow attempt. --- .../src/tui/controllers/session-event-handler.ts | 15 +++++++++++---- apps/kimi-code/src/tui/types.ts | 6 ++++++ apps/kimi-code/src/tui/utils/step-retry.ts | 4 +++- .../session-event-handler-step-retry.test.ts | 5 +++-- apps/kimi-code/test/tui/utils/step-retry.test.ts | 7 +++++++ 5 files changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index ce7500503d7..59a04430170 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -400,10 +400,16 @@ 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. + // The v2 engine re-emits `turn.step.started` for every retried attempt of + // the same step (stepRetryService re-queues the failed driver after the + // backoff sleep). Reaching here with a retry still set means the backoff + // has elapsed and the attempt is now running — flip the phase so the + // label drops the stale countdown instead of clearing the state (the + // attempt itself may take a while, e.g. a connection timeout). + const retry = this.host.state.appState.stepRetry; + if (retry !== null && retry.phase === 'backoff') { + this.host.setAppState({ stepRetry: { ...retry, phase: 'attempt' } }); + } this.host.streamingUI.setStep(event.step); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('waiting'); @@ -463,6 +469,7 @@ export class SessionEventHandler { errorName: event.errorName, errorMessage: event.errorMessage, statusCode: event.statusCode, + phase: 'backoff', }, }); } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 83a016e7570..ef2f8e31dd1 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -97,6 +97,12 @@ export interface StepRetryState { 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 v2 engine re-emits `turn.step.started` for + * the retried attempt — the countdown has elapsed by then and is dropped. + */ + phase: 'backoff' | 'attempt'; } export interface ToolCallBlockData { diff --git a/apps/kimi-code/src/tui/utils/step-retry.ts b/apps/kimi-code/src/tui/utils/step-retry.ts index 34013c788a7..7266d0690d2 100644 --- a/apps/kimi-code/src/tui/utils/step-retry.ts +++ b/apps/kimi-code/src/tui/utils/step-retry.ts @@ -2,8 +2,10 @@ 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 `retrying (${retry.nextAttempt}/${retry.maxAttempts}) · ${retry.errorName} · in ${delaySeconds}s`; + return `${base} · in ${delaySeconds}s`; } /** Detail line under the spinner: status code + provider message, single-line, capped. */ diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts index 73ec850c6ab..73bc10d71b9 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -87,6 +87,7 @@ describe('SessionEventHandler step retry state', () => { errorName: 'APIStatusError', errorMessage: 'rate limited', statusCode: 429, + phase: 'backoff', }); }); @@ -122,7 +123,7 @@ describe('SessionEventHandler step retry state', () => { expect(host.state.appState.stepRetry).toBeNull(); }); - it('keeps the retry snapshot on turn.step.started (v2 re-emits it per retried attempt)', () => { + it('flips the retry to attempt phase on turn.step.started (v2 re-emits it per attempt)', () => { const { host } = makeHost(); const handler = new SessionEventHandler(host); handler.handleEvent(retryingEvent as any, vi.fn()); @@ -130,6 +131,6 @@ describe('SessionEventHandler step retry state', () => { { type: 'turn.step.started', sessionId: 's1', agentId: 'main', turnId: 1, step: 1 } as any, vi.fn(), ); - expect(host.state.appState.stepRetry).not.toBeNull(); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'attempt' }); }); }); diff --git a/apps/kimi-code/test/tui/utils/step-retry.test.ts b/apps/kimi-code/test/tui/utils/step-retry.test.ts index bf848a4446c..4fd09d2141a 100644 --- a/apps/kimi-code/test/tui/utils/step-retry.test.ts +++ b/apps/kimi-code/test/tui/utils/step-retry.test.ts @@ -12,6 +12,7 @@ function retry(partial: Partial = {}): StepRetryState { errorName: 'APIStatusError', errorMessage: 'rate limited', statusCode: 429, + phase: 'backoff', ...partial, }; } @@ -21,6 +22,12 @@ describe('formatStepRetryLabel', () => { expect(formatStepRetryLabel(retry())).toBe('retrying (2/10) · APIStatusError · in 4s'); }); + it('drops the stale countdown once the attempt is running', () => { + expect(formatStepRetryLabel(retry({ phase: 'attempt' }))).toBe( + 'retrying (2/10) · APIStatusError', + ); + }); + it('rounds sub-second delays up to 1s', () => { expect(formatStepRetryLabel(retry({ delayMs: 500 }))).toContain('in 1s'); }); From 257c988f3d36af7a38e6aa00315965220575c07a Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 23:21:04 +0800 Subject: [PATCH 4/7] fix(kimi-code): advance the retry phase on a timer instead of step starts The legacy engine retries inside the same step and never re-emits turn.step.started, so the backoff-to-attempt transition keyed on that event never fired there and the stale countdown stayed up through the attempt. Schedule the flip from delayMs instead, which matches when both engines actually start the next attempt, and drop the step-start hook. --- .../tui/controllers/session-event-handler.ts | 30 +++++++++----- apps/kimi-code/src/tui/types.ts | 4 +- .../session-event-handler-step-retry.test.ts | 41 +++++++++++++++++-- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 59a04430170..10dae21ed25 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -167,6 +167,7 @@ export class SessionEventHandler { private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; + private stepRetryAttemptTimer: ReturnType | undefined; resetRuntimeState(): void { this.backgroundTasks.clear(); @@ -185,6 +186,7 @@ export class SessionEventHandler { this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); + this.clearStepRetryAttemptTimer(); this.stopAllMcpServerStatusSpinners(); } @@ -400,16 +402,6 @@ export class SessionEventHandler { private handleStepBegin(event: TurnStepStartedEvent): void { this.host.streamingUI.flushNow(); - // The v2 engine re-emits `turn.step.started` for every retried attempt of - // the same step (stepRetryService re-queues the failed driver after the - // backoff sleep). Reaching here with a retry still set means the backoff - // has elapsed and the attempt is now running — flip the phase so the - // label drops the stale countdown instead of clearing the state (the - // attempt itself may take a while, e.g. a connection timeout). - const retry = this.host.state.appState.stepRetry; - if (retry !== null && retry.phase === 'backoff') { - this.host.setAppState({ stepRetry: { ...retry, phase: 'attempt' } }); - } this.host.streamingUI.setStep(event.step); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('waiting'); @@ -472,13 +464,31 @@ export class SessionEventHandler { 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); } private clearStepRetry(): void { + this.clearStepRetryAttemptTimer(); if (this.host.state.appState.stepRetry === null) return; this.host.setAppState({ stepRetry: null }); } + private 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); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index ef2f8e31dd1..d1e3341d87d 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -99,8 +99,8 @@ export interface StepRetryState { statusCode?: number; /** * `backoff` while sleeping before the next attempt (label shows the - * countdown); `attempt` once the v2 engine re-emits `turn.step.started` for - * the retried attempt — the countdown has elapsed by then and is dropped. + * 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'; } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts index 73bc10d71b9..82073f94c20 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { getBuiltInPalette } from '#/tui/theme'; @@ -76,6 +76,13 @@ const retryingEvent = { } as const; describe('SessionEventHandler step retry state', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + it('stores the retry snapshot when a step starts retrying', () => { const { host } = makeHost(); const handler = new SessionEventHandler(host); @@ -123,7 +130,35 @@ describe('SessionEventHandler step retry state', () => { expect(host.state.appState.stepRetry).toBeNull(); }); - it('flips the retry to attempt phase on turn.step.started (v2 re-emits it per attempt)', () => { + it('flips to attempt phase once the backoff delay elapses', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + vi.advanceTimersByTime(4000); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'attempt' }); + }); + + it('cancels the phase flip when the retry is cleared during the backoff', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { + type: 'turn.step.interrupted', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + reason: 'error', + } as any, + vi.fn(), + ); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('keeps the retry snapshot on turn.step.started (v2 re-emits it per attempt)', () => { const { host } = makeHost(); const handler = new SessionEventHandler(host); handler.handleEvent(retryingEvent as any, vi.fn()); @@ -131,6 +166,6 @@ describe('SessionEventHandler step retry state', () => { { type: 'turn.step.started', sessionId: 's1', agentId: 'main', turnId: 1, step: 1 } as any, vi.fn(), ); - expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'attempt' }); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'backoff' }); }); }); From ba61ccd6696bdb10f9ba3c0be1a2f0af0754842c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 23:46:24 +0800 Subject: [PATCH 5/7] fix(kimi-code): cancel the retry phase timer on TUI shutdown A pending backoff timer survived KimiTUI.stop(), keeping the event loop alive and firing setAppState against a disposed UI when stop() runs without an immediate process exit. Expose the timer cleanup and invoke it from the shutdown path. --- .../src/tui/controllers/session-event-handler.ts | 2 +- apps/kimi-code/src/tui/kimi-tui.ts | 1 + .../controllers/session-event-handler-step-retry.test.ts | 9 +++++++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 10dae21ed25..9cb1029bdc5 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -482,7 +482,7 @@ export class SessionEventHandler { this.host.setAppState({ stepRetry: null }); } - private clearStepRetryAttemptTimer(): void { + clearStepRetryAttemptTimer(): void { if (this.stepRetryAttemptTimer !== undefined) { clearTimeout(this.stepRetryAttemptTimer); this.stepRetryAttemptTimer = undefined; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 12d6b994de7..2eceb579f6a 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -984,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(); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts index 82073f94c20..a60aa55c636 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -168,4 +168,13 @@ describe('SessionEventHandler step retry state', () => { ); expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'backoff' }); }); + + it('cancels the pending phase flip via clearStepRetryAttemptTimer (TUI shutdown path)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.clearStepRetryAttemptTimer(); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + }); }); From 964f89df8735b6f2453d3f4bfd6213944e8ae1b8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 11:28:37 +0800 Subject: [PATCH 6/7] fix(kimi-code): align the retry detail line with the spinner label --- apps/kimi-code/src/tui/components/panes/activity-pane.ts | 3 ++- apps/kimi-code/src/tui/constant/rendering.ts | 4 ++++ .../kimi-code/test/tui/components/panes/activity-pane.test.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/components/panes/activity-pane.ts b/apps/kimi-code/src/tui/components/panes/activity-pane.ts index f7b32591b54..43f9ece4126 100644 --- a/apps/kimi-code/src/tui/components/panes/activity-pane.ts +++ b/apps/kimi-code/src/tui/components/panes/activity-pane.ts @@ -1,6 +1,7 @@ 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'; @@ -32,7 +33,7 @@ export class ActivityPaneComponent extends Container { 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)); + this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0)); } } } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index 7618a118378..d3de252f3dd 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -16,6 +16,10 @@ export const COMMAND_PREVIEW_LINES = 10; // 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 diff --git a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts index c02bc0aa332..314c7a18a86 100644 --- a/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/components/panes/activity-pane.test.ts @@ -58,7 +58,7 @@ describe('ActivityPaneComponent', () => { const lines = component .render(80) .map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '').trimEnd()); - expect(lines).toEqual(['', 'working', ' 429 · rate limited']); + expect(lines).toEqual(['', 'working', ' 429 · rate limited']); }); it.each(['waiting', 'tool', 'composing'] as const)( From 5fd019c349404b518940c60b2e24462b0ea6b6c9 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 11:31:55 +0800 Subject: [PATCH 7/7] fix(kimi-code): capitalize the retry spinner label --- apps/kimi-code/src/tui/utils/step-retry.ts | 2 +- apps/kimi-code/test/tui/utils/step-retry.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/utils/step-retry.ts b/apps/kimi-code/src/tui/utils/step-retry.ts index 7266d0690d2..34a79887863 100644 --- a/apps/kimi-code/src/tui/utils/step-retry.ts +++ b/apps/kimi-code/src/tui/utils/step-retry.ts @@ -2,7 +2,7 @@ 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}`; + 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`; diff --git a/apps/kimi-code/test/tui/utils/step-retry.test.ts b/apps/kimi-code/test/tui/utils/step-retry.test.ts index 4fd09d2141a..9111471c888 100644 --- a/apps/kimi-code/test/tui/utils/step-retry.test.ts +++ b/apps/kimi-code/test/tui/utils/step-retry.test.ts @@ -19,12 +19,12 @@ function retry(partial: Partial = {}): StepRetryState { describe('formatStepRetryLabel', () => { it('shows attempts, raw error name, and backoff delay', () => { - expect(formatStepRetryLabel(retry())).toBe('retrying (2/10) · APIStatusError · in 4s'); + expect(formatStepRetryLabel(retry())).toBe('Retrying (2/10) · APIStatusError · in 4s'); }); it('drops the stale countdown once the attempt is running', () => { expect(formatStepRetryLabel(retry({ phase: 'attempt' }))).toBe( - 'retrying (2/10) · APIStatusError', + 'Retrying (2/10) · APIStatusError', ); });