Skip to content

feat(tui): stop the interface and the step budget fighting the user - #48

Merged
mangit955 merged 1 commit into
mainfrom
feat/tui-and-step-budget
Aug 4, 2026
Merged

feat(tui): stop the interface and the step budget fighting the user#48
mangit955 merged 1 commit into
mainfrom
feat/tui-and-step-budget

Conversation

@mangit955

Copy link
Copy Markdown
Owner

Why

Two separate complaints, both reproduced and measured against the real App before anything was changed — rendered into a capture stream with a stub controller, the way tui/src/prompt.shape.test.tsx does.

The interface fought the transcript.

measured
scroll position, mid-turn startTool, startAssistantMessage and setTodos each reset scrollOffset: 0 — scrolling up to read something survived until the next streamed token
repaints while the turn merely waits 45/s at 0 running tools, 60/s at 8, 32 KB of terminal output per 3s, from four uncoordinated timers (60ms spinner, 100ms clock, 240ms pulse, 80ms per tool)
200 streamed tokens 364ms at 50 timeline items, 526ms at 500, 2018ms at 2000 — the timeline is unwindowed, so every commit re-lays out every item
the screen it paints on a full-height frame in the normal buffer, so quitting stranded the last frame and took the shell's scrollback with it

And the diff under review could be squeezed out of existence. The transcript and the diff panel were both flexShrink={1} with no guaranteed share:

transcript items diff rows visible
5 16
200 2
1000 0

At 1000 the frame ended at the diff's header — no body, no Esc reject · Enter apply footer — while the keys still worked. The failure mode was approving a write with nothing on screen to judge.

The step budget failed work it should have paused. A real turn ended Build · 1m 13s · failed having already edited a controller, its routes and its tests. DEFAULT_MAX_ITERATIONS = 20 was documented as low "because a human is waiting and a runaway loop spends their quota" — but the provider guards quota itself, and exactly: a 429 carries a RetryInfo, providerRetryDelayMs honours it, and providers/client.ts turns it into a message naming the quota page. A constant in loop.ts cannot know what is left of anyone's free tier, so as a spending limit it is always wrong in one direction. At 20 it sat inside the range of ordinary tasks and fired almost exclusively on legitimate work — which then read as failed, the word a crash gets, with its explanation in a status that cleared after three seconds.

What a ceiling honestly catches is a stuck loop with nobody watching. That wants a number reached rarely, and a question rather than an exception when it is.

What

Scrolling follows only when you are at the bottom. The offset is measured from the last line, so holding a reader's place means moving the number as content grows — done in setScrollLimit, the one place that learns how much it grew, with no per-item height bookkeeping. A one-column gutter shows position, painted only while scrolled away: always painting it tripled terminal output (16 → 50 KB per 3s), and pinned to the latest line it only says "at the bottom", which the transcript already shows. The column stays reserved either way, so it costs no reflow.

One 100ms clock (tui/src/hooks/useClock.tsx) drives the status spinner, the turn footer and the tool spinners. A finished TurnFooter deliberately does not subscribe — context ignores memo, and a long session holds hundreds of them.

The diff owns the screen while it is being judged, rather than negotiating for a share it can lose.

The ceiling is 40, and reaching it asks. AgentCallbacks.onBudgetExhausted returns continue or stop; continue grants another 40 and carries the same turn on, so history and reasoning context are kept and nothing is re-established. stop reports through onCancel, which AgentController already wraps to set wasCancelled — so the footer says cancelled, not failed, with no new outcome type. An absent handler is not stop: it means nobody is there to ask, and the loop throws IterationBudgetExhaustedError exactly as before. Headless never implements it, which is what preserves the exit-code-2 contract.

Two notices removed from the transcript. The N tools used - start implementing now notice is deleted outright: unlike the iteration warning beside it, it pushed nothing to the model, so it was advice for the model that only ever reached the user. The N iterations remaining message still goes to the model — it stops it starting new work — but no longer renders, because the ceiling now asks the user directly.

Also: / recall prompts (falling back to scrolling when there is no history yet, so a fresh session is unchanged), the status bar shows prompt tokens against the model's window from the onUsage the TUI was discarding, and the app takes the alternate screen.

The rejected alternative for the budget was simply raising the number. That moves the cliff rather than removing it.

Verified

$ bun run verify --staged
verify: 38 changed file(s)
  ok  docs lint · docs surface · type check · tests    4 gate(s) passed.

$ bun test $(git ls-files '*.test.ts' '*.test.tsx' | sort -r)
1409 pass, 0 fail

Re-measured after the change, same harness as the numbers above:

before after
writes/s, turn waiting 45 → 60 with tool count 30, flat
output per 3s 32 KB 17 KB
200 tokens @ 2000 items 2018 ms 386 ms
diff rows @ 1000-item transcript 0 full screen

The regression test for the squeezed diff was checked against the old layout — git checkout HEAD -- tui/src/app.tsx, and it fails with the header and footer present and zero body rows, rather than passing vacuously.

The alternate screen was confirmed under a real PTY, not by inspection: entered at byte 0 of the stream, and it and mouse reporting both restored at the end on Ctrl+C exit.

The budget checkpoint: the loop's three cases are unit tested (packages/tests/runtime/iterationBudget.test.ts) — no handler still throws, continue runs past the original ceiling and warns again for the extension, stop calls onCancel and throws nothing. WOOPCODE_MAX_ITERATIONS=2 bun cli.ts -p … still exits 2 with no dialog and no hang. The dialog was driven end to end through a fake stdin with no API calls: it renders with the step count, Enter resolves continue, Esc resolves stop.

Not verified: the checkpoint has not been driven by a human in a live session against a real provider. A PTY attempt typed the prompt but never submitted it, so it proved nothing and was replaced by the fake-stdin check above. WOOPCODE_MAX_ITERATIONS=3 bun run start reaches it in seconds if you want to see it.

One incidental fix: adding a store call to AgentController.cancel() broke 12 tests across three files, because the mock.module stub in agentController.test.ts replaces the store with an enumerated list of methods and a module mock lasts the whole run — so the e2e cancellation tests failed against a stub they never declared. The missing entry is added and the reason noted in the stub's comment.

🤖 Generated with Claude Code

Two things made the agent unpleasant to sit in front of, and both were
measured before being changed rather than argued about.

The interface fought the transcript. Appending reset the scroll offset, so
reading a tool result mid-turn lasted until the next streamed token. Four
uncoordinated animation timers repainted the whole frame 45-60 times a
second while the turn merely waited on the provider. The timeline was
unwindowed, so 200 streamed tokens took 364ms at 50 items and 2018ms at
2000. The diff panel and the transcript both shrank freely, so at a
1000-item transcript the edit under review rendered zero body rows and no
footer, while Enter still applied it.

The budget failed work it should have paused. Twenty steps was documented as
a quota guard, but the provider guards quota itself and exactly - a 429
carries a RetryInfo, and the client turns it into a message naming the quota
page. A constant here cannot know what is left of anyone's budget, so as a
spending limit it is always wrong in one direction; at twenty it sat inside
the range of ordinary tasks and fired almost only on legitimate work, which
then read as `failed`. What a ceiling honestly catches is a stuck loop with
nobody watching, and that wants a number high enough to be reached rarely
and a checkpoint rather than an exception when it is.

The alternative for the budget was simply raising the number. That moves the
cliff instead of removing it: a long task still ends in `failed` with its
explanation in a status that clears after three seconds. Asking puts the
decision where the information is, and is what makes a generous ceiling safe
- a runaway still stops and waits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
woop-code Ready Ready Preview Aug 4, 2026 12:25pm

@mangit955
mangit955 marked this pull request as ready for review August 4, 2026 12:25
@mangit955
mangit955 merged commit 1a6e523 into main Aug 4, 2026
6 checks passed
@mangit955
mangit955 deleted the feat/tui-and-step-budget branch August 4, 2026 12:29
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.

1 participant