Repository navigation
ui: the queue's "↑ edit" hint appears only when something is editable - #4
Merged
Merged
Conversation
…n interrupt (X-84 draft) A turn that ends in 429 or is interrupted leaves a Gate on Repl; while it holds, a host notice read_input delivers is put aside instead of opening a turn. A typed message carries the held notices; RateLimited also releases them at retry-after or on a doubling 1-30 min probe, and /model probes at once. 429 gets its own 3-rung ladder; retry-after over 60 s is not slept. No provider body parsing. Design only; no code changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…slept (X-84) StatusError gains retry_after, read once from the refused response by the new llm::client::retry_after (retry-after-ms, retry-after seconds or HTTP-date — the parse retry_delay already had, which now calls it). Display is unchanged. should_retry hands a status whose retry-after exceeds RETRY_AFTER_CAP (60 s) up at once instead of sleeping it — a five-hour wait used to hold the turn silently for five hours; x-should-retry still wins. tests/provider/wire.rs pins the parse, the cap and the one-request end to end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or an interrupt (X-84)
retry_round takes a Retries (All / NoRateLimit / None, replacing TurnCtx's
can_retry): a 429 climbs at most RATE_RETRIES = 3 rungs, waiting its
retry-after when that is at most a minute and 2/4/8 s otherwise, and stops at
once when it asks for longer; 5xx and transport faults keep the 10-rung linear
ladder.
The new repl::noticegate::NoticeGate on Repl holds automatic work: a turn that
ends in 429 leaves RateLimited { next, step } (next = now + retry-after, else a
minute, doubling to 30 min per refused probe), an interrupted one leaves
Interrupted, an answer or any other failure clears it. While it holds, a job's
notice read_input delivers is put aside with one dim line and the host settled
(Presenter::notice_held) instead of opening a turn. A typed message carries
every held notice ahead of it; a rate-limit hold also lets them out at next as
one probe turn with no 429 ladder — the prompt races the deadline through a
child token the timer cancels, never a dropped future — and /model onto another
model probes at once. A refused probe leaves one dim line, no block and no ping.
A notice whose turn a 429 rolled back, or an interrupt discarded whole, goes back
to the gate. The 429 block gains a hint naming the next try. The flush notice
never reads the gate. Running turns still take notices at round boundaries.
Test devices: Round::rate_limited (a 429 with its body and retry-after) and
Reply::Idle (a parked prompt that answers only its token's cancel).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A: a job's notice queued behind a held stream is put aside when ESC stops it (one dim ⏸ line), opens no turn, and its headline goes with the next typed message. B: a notice refused with 429 shows the `attempt n/3` row and ONE `✗ Rate limited (429)` block whose hint names the next try; the next job's notice, finishing during the ladder, is put aside `held until` with no second block. The 429 rides the job's OUTPUT through the mock's existing fail: directive, so the mock is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DIVERGENCES X-84 as built (with the control: scenario 35 is red 12/15 on the wire half alone, 21/21 here); CHANGELOG Unreleased Fixed; ARCHITECTURE lists repl/noticegate.rs and calls the turn's retry what it is, a per-call ladder; the design doc is marked implemented, with §11 recording every place the code departs from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…X-84) The doc leg of ci.sh refused the intra-doc link from the public should_retry to a pub(crate) constant. The cap is part of what should_retry and retry_after promise, like BACKOFF_BASE and BACKOFF_CAP beside it, so it is pub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…never overtakes input (X-84 B1, M2) B1. ReadCancel carries an ack. The loop sends any reply it already owed the waiter — a submit, a notice, an idle interrupt — before it acks, and the mailbox is FIFO, so TuiHandle::read_input keeps the reply channel open until the ack and returns whatever is on it; only the ack alone resolves the new UiError::Cancelled. A submit or notice after the revoke finds no waiter and queues. No grace period anywhere. M2. next_input has no overdue fast path: the prompt is always read, so input already queued is served before the revoke behind it, and only Cancelled with root and the facade alive admits a probe — an idle Ctrl+C or a close that coincides with the deadline is the user's exit. Tests: queue_tests a_revoke_is_acknowledged_after_anything_it_lost_the_race_to; handle a_submit_racing_a_revoke_is_never_lost (20 rounds, no sleep), a_notice_ahead_of_a_revoke_is_returned_not_dropped; rate_limit queued_input_behind_a_model_switch_goes_before_the_probe, a_deadline_crossed_during_a_command_does_not_overtake_input, an_exit_at_the_deadline_is_not_a_probe. ScriptedUi gains Reply::Elapse; Reply::Idle now answers Cancelled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…9 rolls it back (X-84 B2) Steerer records the jobs' notices it injects (flush notices keep their held path); TurnEngine::take_injected hands them to the loop, which appends them to the turn's carried notices — after the carried ones, in arrival order. A 429 rollback (or a wholly discarded interrupt) puts them back on the gate without settling host debt again; a kept turn keeps them in its history. Tests: rate_limit a_notice_steered_in_before_a_429_is_not_lost (tool round, steered notice, the 429 ladder, the probe: delivered and persisted once — red without the hand-back), a_kept_turn_keeps_its_steered_notice_once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er (X-84 M1) The wire already hands up a status whose retry-after exceeds RETRY_AFTER_CAP; rate_limit now classifies it too — a 429 whatever it says, or any status with such a wait — so retry_round stops at once and the notice gate holds until the named time, with the hint saying when. A 5xx asking for a minute or less keeps the linear ladder. Tests: retry a_long_retry_after_on_any_status_is_a_limit; wire a_503_with_a_long_retry_after_is_one_request; rate_limit a_503_with_a_long_retry_after_holds_like_a_rate_limit (one call, one block, the notices behind it held, nothing slept). FakeProvider's 429 failure is now Failure::Status with Round::refused(status, body, retry_after). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rries (X-84 M3) offer_before_send takes the whole batch the turn sends — the held notices it carries and its input — and TurnEngine::run notes the `fresh` messages at the history's end into the meter, not just the last. A probe or a typed release whose batch crosses the threshold is offered the compaction instead of being sent over the window and rolled back with its notices. Test: rate_limit the_compaction_offer_prices_every_held_notice (100k of a 128k window, four ~5k notices; probe and typed release each get the offer — red when only the input is priced). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the gate (X-84 M4) The loop compares the configured provider (catalog.session_provider()) beside the dialect and the model id before and after /model: two accounts serving the same model over the same API are different limits, so moving to the other one probes at once. Test: rate_limit a_switch_to_another_account_with_the_same_model_probes_at_once (glm_a and glm_b, both anthropic, both glm-5.3: the held notices go to glm_b immediately — red when only kind and model are compared). The fixture gains an endpoint factory and a real catalog for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ick keeps its notice (X-84 m1) rate_limit_hint takes whether any notice will be waiting (already held, or handed back by this rollback): with none there is no probe, so the hint names the time and the two manual ways and promises nothing. The ensure_model failure path now hands back the turn's own notice as well as the ones it carried. Tests: errors a_quota_429_keeps_the_providers_own_sentence (both hint forms); rate_limit a_typed_refusal_with_nothing_held_promises_no_retry, a_notice_whose_model_pick_is_cancelled_is_kept (red without the restore). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
X-84 now says what the code does: a rate limit is a 429 or any status asking for more than a minute; the outer 429 ladder is three retries, each a wire call; x-should-retry: true still wins over the 60 s cap; admission is one decision through the acknowledged revoke; steered notices come back on a rollback; the batch is priced for compaction; /model counts the provider; the hint promises an automatic try only when notices wait; chat-completions sends consecutive user messages rather than coalescing; held notices do not survive an exit. RETRY_AFTER_CAP's doc names the x-should-retry exception. The design's §11.1 records every deviation the fixes made, and what the review listed that is still not tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ob notices behind an interrupted turn, no longer loop: repl::noticegate::NoticeGate holds automatic work until the user speaks or a paced probe, a 429 climbs three rungs of its own and honours retry-after up to a minute, the wire no longer sleeps a longer one, the prompt revoke is acknowledged so a racing Enter is returned or queued, a notice steered into a turn comes back on a 429 rollback, the carried batch is priced for compaction, and /model counts the provider identity; tests/repl/rate_limit.rs, the gate/retry/wire unit tests and tmux scenario 35 pin it, DIVERGENCES X-84, CHANGELOG and docs/design/rate-limit-and-interrupt.md say so; X-84)
A new advisory against tokio <1.53.3 turned cargo deny red with no code
change:
- RUSTSEC-2026-0386 `AsyncFd::new` and related constructors violate I/O safety
Fixed in 1.53.3 and 1.54.0; the newest Cargo.toml ("1.53") admits is 1.54.1.
Lockfile-only; no other crate moves. `cargo update -p tokio` alone would also
have re-pointed six crates (errno, quinn-udp, rustix, rustls-platform-verifier,
tempfile, winapi-util) from windows-sys 0.61.2 to 0.52.0 — a second copy the
duplicate check refuses — so those edges are kept as they were and only tokio's
version and checksum move (`cargo metadata --locked` and `cargo deny --locked
check` both pass on the result).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…own rungs, and the CHANGELOG names the rate limit (X-84, the review after the merge: m1, m3, m4) m1 — `llm::client::RETRY_AFTER_HORIZON` (24 h): `retry_after` clamps a longer header at the parse, so the wire's sleep, the ladder, the gate's `next` and its `held until HH:MM` line all see one bounded value. `Duration::try_from_secs_f64` accepts up to u64::MAX seconds while `Instant + Duration` panics past i64 seconds, so `retry-after: 10000000000000000000` took the loop down in `NoticeGate::settle` (before the branch that value only reached `tokio::time::sleep`, which clamps). A day is past any quota window a header names today, as long as Claude Code waits for a reset, and as far as a dateless HH:MM reads; the constant's doc says so. Pinned in `tests/provider/wire.rs::retry_after_reads_the_three_header_shapes` (all three shapes clamped; a day and five hours kept as named). m4 — `retry_round` kept one `attempt` across error kinds, so three 503s then a 429 left `attempt 4 > RATE_RETRIES 3` and the 429 got no rungs. The linear ladder and the 429 ladder now count their own; the busy row's `attempt n/max` is that ladder's n, single-kind sequences keep every call time and label byte for byte, and the recovery notice counts every retry made. Pinned by `retry::tests::each_ladder_counts_its_own_rungs` (3×503 then 429: calls at 0, 1, 3, 6, 8, 12, 20 s; 2×429 then 503: the 503 climbs all ten). m3 — CHANGELOG: a notice is no longer lost when its turn is refused BY A RATE LIMIT; any other failure still drops it (the owner's Q2), and the day's horizon is named. m2 (the real TuiHandle under the gate's timer, untested) is on the design's gap list (§11.1 #24); §11.2 records this round; DIVERGENCES X-84 says the horizon and the per-ladder counts. Gate per commit: cargo fmt, clippy -D warnings, the targeted suites (retry, noticegate, provider wire, repl rate_limit, repl stall); the full ./ci.sh runs once over this and the tokio bump before it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… taken to name at most a day, so an absurd header cannot panic the gate, each retry ladder counts its own rungs so a 429 still gets three after three 503s, and the CHANGELOG names the rate limit as the case that keeps a notice; tokio 1.54.1 for RUSTSEC-2026-0386; X-84) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
↑ pops the newest TYPED queue entry and Ctrl+C folds only typed ones, but the frame appended " · ↑ edit" to the block's bottom row (or the overflow row) unconditionally, so a queue holding only host notices (finished background jobs) advertised an edit that ↑ could not perform. FrameInput gains queue_editable, filled from newest_typed().is_some(): any typed entry anywhere in the queue, not "the last row is typed", because that is exactly the entry ↑ would pop. Both hint sites (bottom row and "… +N more") honour it. Go has no queued notices (X-08), so its hint was always truthful; this restores parity, no DIVERGENCES entry. Tests: queue_tests::the_queue_hint_needs_a_typed_entry (model level: notices only, notices behind overflow, typed ahead of a notice, typed hidden behind overflow) and frame::tests:: queue_rows_without_an_editable_entry_carry_no_hint; both red without the fix. CHANGELOG Unreleased/Fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
Author
|
Merged into |
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.
What was wrong
The type-ahead queue holds two kinds of entries:
Queued::Typed(what the user typed) andQueued::Notice(host-injected, e.g. a finished background job).↑only pops the newest Typed entry (newest_typed()→rposition(|q| q.typed().is_some())), and Ctrl+C only folds Typed entries back. But the frame appended· ↑ editto the block's bottom row — or to the… +N moreoverflow row — unconditionally. With only notices queued, the screen read:and pressing
↑did nothing. The hint was lying.After this change:
The fix
FrameInputgainsqueue_editable: bool(src/ui/render/frame.rs); the model fills it withself.newest_typed().is_some()(src/ui/runtime/event_loop.rs).hinted = fi.queue_editable && …) and the overflow row (… +N moregets the hint only whenqueue_editable). Row placement and the width reserve are unchanged.Why "any Typed" rather than "the last row is Typed"
The hint advertises what
↑will do, and↑pops the newest Typed entry wherever it sits, not the last row. So:[Typed("a"), Notice]→↑really popsa→ the hint must show (on the bottom row, as before, even though that row is the notice);[Notice]→↑is a no-op → no hint;[Typed("a"), Notice, Notice, Notice]→ the Typed entry is visible but the overflow row carries the hint, and↑still popsa→ hint stays.A "last row is Typed" test would wrongly hide the hint in the first and third cases.
Go has no queued notices (DIVERGENCES X-08: job completion notices are iota-only), so its queue only ever held typed entries and its hint (
model.go:998) was always truthful. This restores that property; no DIVERGENCES entry is needed.Tests
ui::runtime::event_loop::queue_tests::the_queue_hint_needs_a_typed_entry(model level, throughUiMsg::Enqueueand real keys): notices only → no hint and↑is a no-op; four notices →… +1 morewithout hint;[Typed, Notice]→ hint on the notice row,↑pops the typed entry, after which the hint disappears; typed entry plus three notices →… +1 more · ↑ edit.ui::render::frame::tests::queue_rows_without_an_editable_entry_carry_no_hint(frame level):queue_editable: false→ neither the bottom row nor the overflow row hints.frame.rsreverted, both new tests fail and the 17 existing queue tests pass; with the fix all 19 pass. No existing assertion was changed or removed (the frame testCasedefaultsqueue_editabletotrue).Unreleased→### Fixed.Evidence
./ci.shexit code 0; across the run 2146 tests passed, 0 failed. Tail:🤖 Generated with Claude Code