feat(tui): stop the interface and the step budget fighting the user - #48
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Two separate complaints, both reproduced and measured against the real
Appbefore anything was changed — rendered into a capture stream with a stub controller, the waytui/src/prompt.shape.test.tsxdoes.The interface fought the transcript.
startTool,startAssistantMessageandsetTodoseach resetscrollOffset: 0— scrolling up to read something survived until the next streamed tokenAnd the diff under review could be squeezed out of existence. The transcript and the diff panel were both
flexShrink={1}with no guaranteed share:At 1000 the frame ended at the diff's header — no body, no
Esc reject · Enter applyfooter — 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 · failedhaving already edited a controller, its routes and its tests.DEFAULT_MAX_ITERATIONS = 20was 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 aRetryInfo,providerRetryDelayMshonours it, andproviders/client.tsturns it into a message naming the quota page. A constant inloop.tscannot 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 asfailed, 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 finishedTurnFooterdeliberately does not subscribe — context ignoresmemo, 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.onBudgetExhaustedreturnscontinueorstop;continuegrants another 40 and carries the same turn on, so history and reasoning context are kept and nothing is re-established.stopreports throughonCancel, whichAgentControlleralready wraps to setwasCancelled— so the footer sayscancelled, notfailed, with no new outcome type. An absent handler is notstop: it means nobody is there to ask, and the loop throwsIterationBudgetExhaustedErrorexactly 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 nownotice 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. TheN iterations remainingmessage 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 theonUsagethe 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
Re-measured after the change, same harness as the numbers above:
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,continueruns past the original ceiling and warns again for the extension,stopcallsonCanceland throws nothing.WOOPCODE_MAX_ITERATIONS=2 bun cli.ts -p …still exits2with 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,Enterresolves continue,Escresolves 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 startreaches 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 themock.modulestub inagentController.test.tsreplaces 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