Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 live retry progress in the TUI activity pane. When an LLM request fails and is retried, the moon spinner now shows a warning-colored status like `Rate limited (429) · attempt 3/10 · retrying in 12s` with a live countdown, instead of an unchanged generic spinner for the whole backoff window. The failed attempt's partial output is discarded before the next attempt re-streams.
8 changes: 8 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/moon-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class MoonLoader extends Text {
private interval: number;
private colorFn?: (s: string) => string;
private label: string;
private labelFn?: () => string;
private displayText = '';
// Inline text used when the spinner is embedded into another line (e.g. the
// agent-swarm progress status line). It intentionally excludes the tip: the
Expand Down Expand Up @@ -64,10 +65,16 @@ export class MoonLoader extends Text {
}

setLabel(label: string): void {
this.labelFn = undefined;
this.label = label;
this.updateDisplay();
}

setLabelFn(fn: (() => string) | undefined): void {
this.labelFn = fn;
this.updateDisplay();
}

setColorFn(colorFn: (s: string) => string): void {
this.colorFn = colorFn;
this.updateDisplay();
Expand All @@ -89,6 +96,7 @@ export class MoonLoader extends Text {
}

private updateDisplay(): void {
if (this.labelFn !== undefined) this.label = this.labelFn();
const frame = this.frames[this.currentFrame]!;
const coloredFrame = this.colorFn ? this.colorFn(frame) : frame;
const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame;
Expand Down
7 changes: 5 additions & 2 deletions apps/kimi-code/src/tui/components/panes/activity-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Container, Spacer } from '@moonshot-ai/pi-tui';

import type { MoonLoader } from '#/tui/components/chrome/moon-loader';

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

export interface ActivityPaneOptions {
readonly mode: ActivityPaneMode;
Expand All @@ -18,7 +18,10 @@ export class ActivityPaneComponent extends Container {
this.spinnerRef = options.spinner;

if (
(options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') &&
(options.mode === 'waiting' ||
options.mode === 'tool' ||
options.mode === 'composing' ||
options.mode === 'retrying') &&
options.spinner !== undefined
) {
this.addChild(new Spacer(1));
Expand Down
38 changes: 36 additions & 2 deletions 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,
WarningEvent,
} from '@moonshot-ai/kimi-code-sdk';
Expand All @@ -51,6 +52,7 @@ import {
serializeToolResultOutput,
stringValue,
} from '../utils/event-payload';
import { buildRetryStatus } from '../utils/retry-status';
import {
readGoalQueue,
removeGoalQueueItem,
Expand Down Expand Up @@ -245,7 +247,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 @@ -356,6 +358,7 @@ export class SessionEventHandler {
this.host.streamingUI.setStep(event.step);
this.host.streamingUI.resetToolUi();
this.host.streamingUI.finalizeLiveTextBuffers('waiting');
this.host.streamingUI.clearStepArtifactScope();
this.host.patchLivePane({
mode: 'waiting',
pendingApproval: null,
Expand All @@ -367,7 +370,26 @@ export class SessionEventHandler {
});
}

private handleStepRetrying(event: TurnStepRetryingEvent): void {
// The failed attempt's output is invalid — the next attempt re-streams the
// step from scratch. Mirror print mode's discardAssistant()
// (cli/run-prompt.ts:578) instead of committing it: the TUI renders
// live, so discarding means pulling every already-mounted artifact of the
// attempt (assistant blocks, thinking, tool previews), not just clearing
// buffers.
this.host.streamingUI.discardPending();
this.host.streamingUI.discardStepArtifacts();
this.host.streamingUI.resetLiveText();
this.host.streamingUI.resetToolUi();
this.host.patchLivePane({ mode: 'waiting', pendingApproval: null, pendingQuestion: null });
this.host.setAppState({ streamingPhase: 'retrying', retryStatus: buildRetryStatus(event) });
}

private handleStepCompleted(event: TurnStepCompletedEvent): void {
if (this.host.state.appState.streamingPhase === 'retrying') {
this.host.setAppState({ streamingPhase: 'waiting' });
}
this.host.streamingUI.clearStepArtifactScope();
this.host.streamingUI.flushNow();
this.maybeShowDebugTiming(event);

Expand Down Expand Up @@ -453,7 +475,14 @@ export class SessionEventHandler {
// moon spinner while no ThinkingComponent is ever created (it needs visible
// text), leaving a blank, spinner-less gap until the first real text/tool
// token arrives. Keep the moon up until actual thinking text shows up.
if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return;
if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) {
// Encrypted-only thinking never enters `thinking` mode, so clear a lingering
// retry phase here — otherwise the retry label would outlive the retry.
if (state.appState.streamingPhase === 'retrying') {
this.host.setAppState({ streamingPhase: 'waiting' });
}
return;
}
streamingUI.appendThinkingDelta(event.delta);
this.host.patchLivePane({ mode: 'idle' });
if (state.appState.streamingPhase !== 'thinking') {
Expand Down Expand Up @@ -531,6 +560,11 @@ export class SessionEventHandler {
pendingApproval: null,
pendingQuestion: null,
});
// A tool call can begin without an intervening delta phase; clear a lingering
// retry phase so the retry label does not persist under the tool spinner.
if (this.host.state.appState.streamingPhase === 'retrying') {
this.host.setAppState({ streamingPhase: 'waiting' });
}
}

private handleToolCallDelta(event: ToolCallDeltaEvent): void {
Expand Down
52 changes: 52 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Session } from '@moonshot-ai/kimi-code-sdk';
import type { Component } from '@moonshot-ai/pi-tui';

import { AgentGroupComponent } from '../components/messages/agent-group';
import { AssistantMessageComponent } from '../components/messages/assistant-message';
Expand Down Expand Up @@ -54,6 +55,15 @@ export class StreamingUIController {
private _assistantDraft = '';
private _thinkingDraft = '';
private _streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null;
// Every transcript component mounted while streaming the current step's LLM
// attempt (assistant block, thinking bubble, tool-call previews and their
// group containers). A retry re-streams the step from scratch, so these are
// exactly what must be pulled from the transcript when the attempt fails —
// dispose()/reset methods only stop timers and drop refs, they do not
// unmount. Cleared at step boundaries; group upgrades replace a solo card
// in place, so removing both the card and its group is a safe no-op for
// whichever is no longer a direct child.
private _stepArtifacts: Array<{ component: Component; entry?: TranscriptEntry }> = [];
private _activeThinkingComponent: ThinkingComponent | undefined = undefined;
private _activeCompactionBlock: CompactionComponent | undefined = undefined;
private _activeToolCalls = new Map<string, ToolCallBlockData>();
Expand Down Expand Up @@ -526,9 +536,42 @@ export class StreamingUIController {
this._assistantDraft = '';
this._streamingBlock = null;
this._thinkingDraft = '';
this._stepArtifacts = [];
this.disposeActiveThinkingComponent();
}

// Pull everything the failed attempt rendered — assistant blocks (including
// ones already finalized when a tool preview started), the thinking bubble,
// tool-call previews and their group containers — plus their transcript
// entries, so the retried attempt re-streams into a clean transcript.
discardStepArtifacts(): void {
if (this._stepArtifacts.length === 0 && this._streamingBlock === null) return;
const { state } = this.host;
for (const artifact of this._stepArtifacts) {
state.transcriptContainer.removeChild(artifact.component);
}
const droppedEntries = new Set(
this._stepArtifacts
.map((artifact) => artifact.entry)
.filter((entry) => entry !== undefined),
);
if (droppedEntries.size > 0) {
state.transcriptEntries = state.transcriptEntries.filter(
(entry) => !droppedEntries.has(entry),
);
}
this._stepArtifacts = [];
this._streamingBlock = null;
this._pendingAgentGroup = null;
this._pendingReadGroup = null;
state.ui.requestRender();
}

// Artifacts become permanent once the step outlives its retry window.
clearStepArtifactScope(): void {
this._stepArtifacts = [];
}

resetToolUi(): void {
this.pendingToolCallFlushIds.clear();
this.clearFlushTimerIfIdle();
Expand Down Expand Up @@ -602,6 +645,7 @@ export class StreamingUIController {
this._streamingBlock = { component, entry };
this.host.pushTranscriptEntry(entry);
state.transcriptContainer.addChild(component);
this._stepArtifacts.push({ component, entry });
state.ui.requestRender();
}

Expand Down Expand Up @@ -640,6 +684,7 @@ export class StreamingUIController {
);
if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true);
state.transcriptContainer.addChild(this._activeThinkingComponent);
this._stepArtifacts.push({ component: this._activeThinkingComponent });
} else {
this._activeThinkingComponent.setText(fullText);
}
Expand Down Expand Up @@ -674,6 +719,7 @@ export class StreamingUIController {
if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc);
if (!handled) {
state.transcriptContainer.addChild(tc);
this._stepArtifacts.push({ component: tc });
state.ui.requestRender();
}

Expand Down Expand Up @@ -806,6 +852,7 @@ export class StreamingUIController {
if (cur === null) {
this._pendingAgentGroup = { step, turnId, solo: tc };
state.transcriptContainer.addChild(tc);
this._stepArtifacts.push({ component: tc });
state.ui.requestRender();
return true;
}
Expand All @@ -819,6 +866,7 @@ export class StreamingUIController {
if (solo === undefined) {
this._pendingAgentGroup = { step, turnId, solo: tc };
state.transcriptContainer.addChild(tc);
this._stepArtifacts.push({ component: tc });
state.ui.requestRender();
return true;
}
Expand All @@ -841,6 +889,7 @@ export class StreamingUIController {
state.transcriptContainer.addChild(group);
}
group.attach(solo.toolCallView.id, solo);
this._stepArtifacts.push({ component: group });
return group;
}

Expand All @@ -863,6 +912,7 @@ export class StreamingUIController {
if (cur === null) {
this._pendingReadGroup = { step, turnId, solo: tc };
state.transcriptContainer.addChild(tc);
this._stepArtifacts.push({ component: tc });
state.ui.requestRender();
return true;
}
Expand All @@ -876,6 +926,7 @@ export class StreamingUIController {
if (solo === undefined) {
this._pendingReadGroup = { step, turnId, solo: tc };
state.transcriptContainer.addChild(tc);
this._stepArtifacts.push({ component: tc });
state.ui.requestRender();
return true;
}
Expand All @@ -898,6 +949,7 @@ export class StreamingUIController {
state.transcriptContainer.addChild(group);
}
group.attach(solo.toolCallView.id, solo);
this._stepArtifacts.push({ component: group });
return group;
}
}
50 changes: 48 additions & 2 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ import {
import { hasDispose, isExpandable } from './utils/component-capabilities';
import { isDeadTerminalError } from './utils/dead-terminal';
import { formatErrorMessage } from './utils/event-payload';
import { formatRetryLabel } from './utils/retry-status';
import { pickForegroundTasks } from './utils/foreground-task';
import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store';
import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder';
Expand Down Expand Up @@ -228,6 +229,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState {
availableProviders: {},
sessionTitle: null,
goal: null,
retryStatus: null,
mcpServersSummary: null,
banner: undefined,
};
Expand Down Expand Up @@ -1446,6 +1448,17 @@ export class KimiTUI {
}

setAppState(patch: Partial<AppState>): void {
// A phase transition away from `retrying` (without an explicit retryStatus in
// the same patch) clears the stale backoff status centrally, so every exit
// path — delta, tool call, step/turn end — need not remember to null it.
if (
patch.streamingPhase !== undefined &&
patch.streamingPhase !== 'retrying' &&
patch.retryStatus === undefined &&
(this.state.appState.retryStatus ?? null) !== null
) {
patch = { ...patch, retryStatus: null };
}
if (!hasPatchChanges(this.state.appState, patch)) return;
const additionalDirsChanged =
'additionalDirs' in patch &&
Expand Down Expand Up @@ -2304,8 +2317,16 @@ export class KimiTUI {

if (
activityModeKey === this.lastActivityMode &&
(effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool')
(effectiveMode === 'waiting' ||
effectiveMode === 'thinking' ||
effectiveMode === 'tool' ||
effectiveMode === 'retrying')
) {
// Re-sync so a fresh retry event (attempt N -> N+1) refreshes the label
// immediately instead of waiting for the next spinner tick.
if (effectiveMode === 'retrying') {
this.applyRetrySpinnerState();
}
if (placeSpinnerInAgentSwarm) {
this.syncAgentSwarmActivitySpinner(this.state.activitySpinner?.instance);
}
Expand Down Expand Up @@ -2366,6 +2387,15 @@ export class KimiTUI {
);
break;
}
case 'retrying': {
const spinner = this.ensureActivitySpinner('moon');
this.applyRetrySpinnerState();
this.syncAgentSwarmActivitySpinner(undefined);
this.state.activityContainer.addChild(
new ActivityPaneComponent({ mode: 'retrying', spinner }),
);
break;
}
case 'idle':
case 'session': {
this.stopActivitySpinner();
Expand All @@ -2392,6 +2422,8 @@ export class KimiTUI {
// until it finishes, signalling that input is busy / queued.
if (streamingPhase === 'shell') return 'waiting';

if (streamingPhase === 'retrying') return 'retrying';

if (this.state.livePane.mode === 'idle') {
if (streamingPhase === 'thinking' || streamingPhase === 'composing') {
return streamingPhase;
Expand Down Expand Up @@ -2615,7 +2647,8 @@ export class KimiTUI {
effectiveMode === 'waiting' ||
effectiveMode === 'thinking' ||
effectiveMode === 'composing' ||
effectiveMode === 'tool'
effectiveMode === 'tool' ||
effectiveMode === 'retrying'
);
}

Expand Down Expand Up @@ -2668,6 +2701,19 @@ export class KimiTUI {
}
}

// The label is a live function (not a static string) so the moon spinner's
// 120ms tick re-renders the backoff countdown without a dedicated timer.
private applyRetrySpinnerState(): void {
const spinner = this.state.activitySpinner?.instance;
if (spinner === undefined) return;
spinner.setTip('');
spinner.setLabelFn(() => {
const status = this.state.appState.retryStatus;
if (status === null || status === undefined) return '';
return currentTheme.fg('warning', formatRetryLabel(status));
});
}

// =========================================================================
// Dialogs / Selectors
// =========================================================================
Expand Down
Loading