61fc8416270e0c19b74e95d7b1da235463485c8a
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>
Description
AI-driven remote workspace for Mac, iPhone, and iPad.
https://termind.akinokaede.com
23 MiB
0 Stars
1 Watchers
0 Forks
Languages
Swift
98.9%
Python
0.8%
Ruby
0.3%