Skip to content

ui: the queue's "↑ edit" hint appears only when something is editable - #4

Merged
joyqi merged 18 commits into
mainfrom
queue-hint-fix
Oct 26, 2026
Merged

joyqi merged 18 commits into
mainfrom
queue-hint-fix

Conversation

@joyqi

@joyqi joyqi commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

The type-ahead queue holds two kinds of entries: Queued::Typed (what the user typed) and Queued::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 · ↑ edit to the block's bottom row — or to the … +N more overflow row — unconditionally. With only notices queued, the screen read:

» [background job b1 finished: exit 0 after 2s] make test · ↑ edit

and pressing ↑ did nothing. The hint was lying.

After this change:

» [background job b1 finished: exit 0 after 2s] make test

The fix

  • FrameInput gains queue_editable: bool (src/ui/render/frame.rs); the model fills it with self.newest_typed().is_some() (src/ui/runtime/event_loop.rs).
  • Both hint sites honour it: the bottom row (hinted = fi.queue_editable && …) and the overflow row (… +N more gets the hint only when queue_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 pops a → 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 pops a → 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, through UiMsg::Enqueue and real keys): notices only → no hint and ↑ is a no-op; four notices → … +1 more without 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.
  • Red/green: with the two conditions in frame.rs reverted, 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 test Case defaults queue_editable to true).
  • CHANGELOG: Unreleased → ### Fixed.

Evidence

./ci.sh exit code 0; across the run 2146 tests passed, 0 failed. Tail:

   Compiling reqwest v0.13.4
   Compiling iota v0.7.4 (/Users/joyqi/.herdr/worktrees/iota/queue-hint-fix)
    Finished `release` profile [optimized] target(s) in 1m 19s
exit=0

🤖 Generated with Claude Code

joyqi and others added 18 commits October 23, 2026 16:43
…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>
@joyqi
joyqi merged commit 7896775 into main Oct 26, 2026
10 of 11 checks passed
@joyqi

joyqi commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Merged into main as 7896775.

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