Kaede AkinoandClaude Opus 5 61fc841627 Send what the user typed while the agent was working
Typing at a running agent put a bubble on screen and, more often than
not, did nothing else. The loop returned `complete` the moment an
assistant turn came back with no tool calls, and the steering queue was
only drained at the top of the *next* iteration — so anything typed while
the final reply was being written was dropped on the floor, sitting in the
transcript as a turn the model was never shown. Pi does not have this bug
because its inner loop runs `while (hasMoreToolCalls || pendingMessages.length > 0)`:
a queued message keeps the loop alive even when the model asked for
nothing. That is the rule adopted here, and it is the whole fix — the
guard now completes only when the queue is empty and otherwise continues,
which buys the message a turn of its own.

The second defect was quieter and worse. The bubble was appended to both
`messages` and `transcript` at the keystroke, so the conversation ordered
it *before* the assistant text that actually preceded it while the run's
snapshot ordered it after. `transcript` is what `pendingPriorMessages`
replays as the next run's history, so a steered conversation would later
show the model a conversation that never happened. Nothing is written at
the keystroke now. The queued message enters both lists on
`steeringDelivered`, which the runtime emits at the moment it injects it,
and its `createdAt` is restamped there — a message stamped while the
previous turn was still streaming sorts above the reply the user was
reading when they typed it, which is not where the model saw it.

A queue is a promise, so it can be broken and the words have to survive
that. `steer` returns a Bool rather than swallowing the message: `false`
means there is no run left to deliver it and the caller still owns the
text. The gate is `acceptsSteering` rather than `runTask != nil`, because
`complete` awaits its final persist before the run ends and an actor is
reentrant at every await — a message accepted in that window would land
in a queue nobody drains again. It is cleared as the first statement of
every terminating path, which makes the window empty rather than narrow,
and set in `start` rather than inside the run task, because the first
drain happens ahead of the first provider request and something typed
that early should still be delivered. Whatever is undelivered when a run
ends — stopped, failed, or refused in that race — goes back into the
composer with its attachments, joined above whatever has been typed
since. Returning the whole queue at once rather than one entry per
refused hop is also what keeps the words in the order they were written.

What is queued is drawn as a queue, not as a turn. A strip above the
composer lists each pending message with an ✕ that hands it back, and it
empties itself as each row enters the transcript proper. The caption
changes to "held until you answer above" while an approval or a question
card is holding the run, because in that state nothing is working towards
the next step and promising one would be a lie. Rows show what was typed,
not the composed payload, which carries the attachment blocks folded in
for the model. Its height is measured from its rows and bounded, because
a bare `ScrollView` is greedy and would stand at its 88pt ceiling with
one line in it — the same problem, and the same answer, as the draft
editor's height.

Stop and Send no longer stand side by side. Drawn together they read as a
pair of equals and the primary action moved under the pointer the moment
the draft gained a character; they share one slot now, Stop when there is
nothing to send and Send otherwise. That leaves Stop without a target
while typing, so `⌘.` reaches it from the draft field as well as from the
button. And each region above the composer now rules itself off from what
is above it instead of below it: ruling below meant any visible card was
followed by two adjacent hairlines, its own and the composer's.

Two things from Pi are deliberately left out. There is no follow-up queue
— the second queue, delivered only once the agent would otherwise stop —
because Enter while running means steering, as it does in Pi's own TUI.
And there is no `QueueMode`: Pi drains one message per boundary by
default, where draining the whole queue costs one round trip instead of
several for the common case of two hurried messages. The delivered bubble
is also left unmarked, since once injected it is an ordinary user turn,
and `ChatMessage` is rebuilt from stored transcript rows on relaunch, so
any flag would quietly disappear on restart.

The regression test was checked against the unfixed loop rather than
merely observed to pass: reverting the guard makes it fail with one
provider request instead of two, and with the queued message absent from
both the second request and the saved snapshot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 10:24:32 +08:00
2026-08-24 18:20:20 +08:00
2026-08-24 18:20:20 +08:00
2026-08-24 18:20:20 +08:00
S
Description
AI-driven remote workspace for Mac, iPhone, and iPad.
https://termind.akinokaede.com
GPL-3.0
23 MiB
0 Stars 1 Watchers 0 Forks
Languages
Swift 98.9%
Python 0.8%
Ruby 0.3%