Skip to content

feat(tui): spinner says what the run is doing - #1418

Merged
dennisonbertram merged 1 commit into
mainfrom
feat/1415-truthful-spinner
Sep 8, 2026
Merged

feat(tui): spinner says what the run is doing#1418
dennisonbertram merged 1 commit into
mainfrom
feat/1415-truthful-spinner

Conversation

@dennisonbertram

Copy link
Copy Markdown
Owner

Closes #1415

The problem

The spinner rotated fifteen near-synonyms for "thinking":

"Thinking", "Reasoning", "Pondering", "Analyzing", "Processing",
"Computing", "Synthesizing", "Evaluating", "Reflecting", "Deliberating",
"Considering", "Examining", "Contemplating", "Strategizing", "Planning",

A random one was re-picked every 8 ticks at 120ms — about once a second. The word changed and the meaning did not, which reads as a stuck animation rather than a live one.

A comment above that list claimed these were "the same verbs Claude Code uses in its thinking indicator." That was false, and the research to check it is what settled the design.

Why not just get better words

Extraction across 139 published @anthropic-ai/claude-code npm versions (levindixon/tengu_spinner_words) reports ~90 varied words — Flibbertigibbeting, Reticulating, Schlepping, Spelunking — merged at runtime with a set fetched from a Statsig dynamic config. Their rotation is also roughly 1s.

So our rotation was never faster than theirs (~960ms vs ~1000ms). Speed was not the differentiator; vocabulary was. Ninety varied words make a 1s rotation feel playful. Fifteen synonyms make the same rotation feel broken.

Matching that would mean maintaining a list at a scale we cannot verify, from a secondary source, to reproduce an effect that partly depends on a server-side set we do not have. opencode — which we can read directly, so a primary source — uses no randomized words at all.

Both the ~90 count and the ~1000ms interval are third-party static analysis of a minified bundle. Nobody has pulled the literal setInterval out of it. The engineering log records them as hypotheses rather than facts, so the limitation does not quietly become received wisdom in our own docs.

What it does now

Most specific true thing wins:

Condition Label
A tool is running Running bash
Assistant text streaming Writing response
Reasoning arrived, no text yet Thinking
Otherwise Waiting for gpt-4.1-mini

currentSpinnerAction() never returns "", which is what makes a decorative pool unnecessary rather than merely unfashionable. The glyph still animates at 120ms so the line reads as live; only the word holds still until state changes. The trailing ... is gone — "Running bash" is a fact, not a vague one.

TUIConfig.SpinnerSeed and spinnerSeed() are removed. They existed solely to make random verb selection deterministic for snapshots; rendering is now deterministic by construction, so keeping the field would have left a setting that no longer did anything.

A regression I introduced, and caught by looking

Truthful labels are longer than Computing.... At 40 columns, right-truncation ate the cancel hint:

✶ Waiting for gpt-4.1-mini (esc to inter

The hint is the only actionable part of that line. The label yields instead:

✶ Waiting for gpt-4.… (esc to interrupt)

Two tests pin it, including the degenerate case where not even a stub of a label fits and the hint alone survives.

Verification

Red first: TestCurrentSpinnerActionLadder failed on four of its five states (currentSpinnerAction() = "", want "Waiting for gpt-4.1-mini"), and TestSpinnerLabelDoesNotRotateOnTicks failed against the rotating model.

TestSpinnerGlyphStillAnimates is the deliberate control: freezing the entire line would satisfy "the label does not change" while making the spinner look hung.

All TUI packages green under -race. Snapshots regenerated from ✽ Computing... (esc to interrupt) to ✽ Working (esc to interrupt).

Live TUI, and what it could not show. I drove the real TUI through a pty against the fake provider on this branch's binaries. Observed verbatim:

✶ Working (esc to interrupt)                       first frame, before any state known
· Running bash (esc to interrupt)                  frame cycle ✻ ✽ ✳ ✢ while the tool ran
✢ Running bash (2.1s) (esc to interrupt)           duration appended past the 2s threshold
· Waiting for model (3.0s) (esc to interrupt)      between tool finish and next completion

Thinking and Writing response never appeared as distinct frames, and the reason is worth stating rather than glossing: the fake provider's turns-file format (fakeProviderTurnJSON in cmd/harnessd/main.go) exposes no delay field, though internal/fakeprovider.Turn has Delay and InterDeltaDelay. With zero delay, a scripted turn's reasoning delta, content delta, and tool call all land inside one sub-millisecond Complete() call, while the label refreshes on the 120ms tick. Those two states exist for microseconds.

With a real model, streaming takes seconds and both states will be visible — but I have not observed them, and the unit tests are the only evidence for them. Filed as a follow-up: the fake provider should be able to inject a delay, so TUI states like these are observable in tests at all.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

The spinner rotated fifteen near-synonyms for "thinking" every ~960ms.
The word changed and the meaning did not, which reads as a stuck
animation rather than a live one.

Research across 139 published claude-code npm versions puts their list at
~90 varied words plus a runtime-fetched set, rotating at roughly the same
~1s. So speed was never what made ours feel canned — vocabulary was.
Matching that would mean maintaining a list at a scale we cannot verify,
from a secondary source, to reproduce an effect that depends on a dynamic
set we do not have. opencode, which we can read directly, uses no
randomized words at all.

So the label now states what is actually happening, most specific first:
a running tool, then streaming text, then reasoning, then the model being
waited on. It never returns empty, which is what makes a decorative pool
unnecessary. The glyph still animates at 120ms so the line reads as live;
only the word holds still until the state changes.

Truthful labels are longer than "Computing...", and at 40 columns
right-truncation ate the cancel hint. The label now yields instead: the
hint is the only actionable part of that line.

TUIConfig.SpinnerSeed is removed. It existed only to make random verbs
deterministic for snapshots, and would otherwise have become a setting
that no longer did anything.

Closes #1415

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJGxhoFhA8JjkwZFcLGdS5
@dennisonbertram
dennisonbertram force-pushed the feat/1415-truthful-spinner branch from 898ef56 to cb14d69 Compare September 8, 2026 11:49
@dennisonbertram
dennisonbertram merged commit a5dc42f into main Sep 8, 2026
2 checks passed
@dennisonbertram
dennisonbertram deleted the feat/1415-truthful-spinner branch September 8, 2026 11:54
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.

feat(tui): spinner should say something true instead of rotating fifteen synonyms for thinking

1 participant