Skip to content

feat(tui): show live retry progress in the activity pane#1861

Open
vinlee19 wants to merge 2 commits into
MoonshotAI:mainfrom
vinlee19:feat/tui-retry-progress
Open

feat(tui): show live retry progress in the activity pane#1861
vinlee19 wants to merge 2 commits into
MoonshotAI:mainfrom
vinlee19:feat/tui-retry-progress

Conversation

@vinlee19

Copy link
Copy Markdown

Related Issue

Closes #1860 — together with #1857, which fixes the engine-side fail-fast half.

Problem

See #1860: the engine emits turn.step.retrying (attempt, max, delay, error), but the interactive TUI drops it (case 'turn.step.retrying': break;), so during LLM retries the user sees only the generic moon spinner for up to ~3 minutes and reads it as a frozen app. Print mode and the v2 activity view already surface this event; the shell TUI was the only consumer that didn't.

What changed

  • New streamingPhase: 'retrying' plus AppState.retryStatus (mirrors agent-core-v2's TurnPhase 'retrying' design language).
  • The activity pane's moon spinner shows a warning-colored live label: Rate limited (429) · attempt 3/10 · retrying in 12s, degrading to retrying now… once the backoff elapses. The countdown piggybacks on MoonLoader's existing 120 ms tick via a new setLabelFn — no extra timer. The reason text derives from statusCode/errorName (429 / 408 / 5xx / connection / timeout).
  • handleStepRetrying discards the failed attempt's partial live output before the next attempt re-streams. This needed a new StreamingUIController.discardStreamingBlock(): the existing reset methods only clear internal buffers, while a partial assistant block flushed mid-stream is already rendered in the transcript and must also be removed — otherwise every mid-stream retry leaves duplicated partial text above the fresh stream. (Print mode gets this for free because it buffers and writes at step boundaries.)
  • A central invariant in setAppState clears retryStatus on any phase transition out of 'retrying', with defensive clears on the three paths that historically set no phase (empty thinking delta, tool-call start, step completed).
  • Esc during backoff already worked (the backoff sleep is abortable and editor-keyboard treats any non-idle phase as cancellable) — no changes there.
  • No transcript writes per retry (the final failure still renders exactly as before); zero behavior change when no retry occurs.

Tests: pure formatter unit tests (reason mapping, countdown ceil / retrying now… degradation), a MoonLoader fake-timer test proving the label re-evaluates on tick, and 7 end-to-end event→render cases in the existing message-flow harness (label content, attempt counter climbing across consecutive retry events, clearing on assistant/thinking/tool-call/step-completed, Esc interrupt chain, partial-draft discard).

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset. (changeset added: patch for @moonshot-ai/kimi-code)
  • Ran gen-docs skill, or this PR needs no doc update. (self-explanatory UI status; no config surface)

The engine emits turn.step.retrying with full attempt/backoff data, but
the interactive TUI dropped the event, so LLM retries (up to 10 attempts
over ~3 minutes for sustained 429s) showed only the generic moon spinner
and the session appeared frozen. Print mode and the v2 activity view
already surface this event; the shell TUI was the only consumer that
did not.

Add a 'retrying' streaming phase driven by the event: the activity pane
spinner shows a warning-colored live label such as
"Rate limited (429) · attempt 3/10 · retrying in 12s", counting down on
MoonLoader's existing 120ms tick via a new setLabelFn (no extra timer)
and degrading to "retrying now…" once the backoff elapses. The failed
attempt's partial live output is discarded before the next attempt
re-streams (new StreamingUIController.discardStreamingBlock removes the
already-rendered block; the existing reset methods only clear buffers).
retryStatus is cleared centrally in setAppState on any phase transition
out of 'retrying', with defensive clears on the paths that set no phase
(empty thinking delta, tool-call start, step completed). Esc during
backoff already aborts the sleep; no changes needed there. No transcript
writes per retry; behavior is unchanged when no retry occurs.
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7b1cd6c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a57f1b6730

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +379 to +381
this.host.streamingUI.discardStreamingBlock();
this.host.streamingUI.resetLiveText();
this.host.streamingUI.resetToolUi();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Discard all failed-attempt artifacts on retry

When a retry is triggered after the failed attempt has already rendered thinking or a tool-call preview, this path only removes the active assistant _streamingBlock; resetLiveText() just disposes the live ThinkingComponent, and resetToolUi() only disposes/clears pending tool components, leaving their mounted transcript children (and any assistant text finalized when the tool preview started) in place. Since the retry re-streams the step from the beginning, those stale thinking/tool/assistant fragments remain above the fresh attempt and duplicate or misrepresent the final transcript.

Useful? React with 👍 / 👎.

A retry re-streams the step from scratch, but the discard path only
removed the active assistant streaming block. Thinking bubbles and
tool-call previews are mounted into the transcript and merely disposed
(spinner stopped) on reset, and assistant text already finalized when a
tool preview started had dropped its ref entirely — so all of them
stayed rendered above the fresh attempt, duplicating content.

Track every transcript component the streaming UI mounts during the
current step (assistant blocks with their transcript entries, thinking,
tool previews and their group containers) and pull them all on
turn.step.retrying. The scope clears at step boundaries so committed
output from completed steps is never touched; group upgrades replace a
solo card in place, and Container.removeChild is a no-op for
non-children, so tracking both card and group stays safe.
@vinlee19

Copy link
Copy Markdown
Author

Good catch — confirmed and fixed in 7b1cd6c. Verified against the code: ThinkingComponent.dispose() only stops the spinner, tool previews stay mounted after resetToolUi(), and an assistant block finalized by a tool-call preview drops its ref entirely — so all three artifact kinds survived the old discard path.

The fix tracks every transcript component the streaming UI mounts during the current step (assistant blocks + their transcript entries, thinking bubbles, tool previews and their group containers) and pulls them all on turn.step.retrying. The tracking scope clears at step boundaries, so committed output from completed steps is never touched; group upgrades replace a solo card in place and Container.removeChild is a no-op for non-children, so tracking both the card and its group stays safe. Added four tests: rendered thinking discarded, tool preview discarded, tool-finalized assistant text discarded, and a completed step's output surviving a later step's retry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: quota-exhausted 429 is retried for ~3 minutes with no UI feedback (session looks frozen)

1 participant