Summary
The mecatui follow-up-prompt queue has three UX problems that surfaced together in a live session. When a run died on server_error: Upstream idle timeout exceeded, the TUI showed ⏸ 1 queued · paused: error and the only two exits were enter (send the message verbatim) or esc (drop the entire queue). There is no way to edit a queued message, transient errors are treated as fatal for the queue, and multiple queued messages are sent as separate sequential turns rather than merged.
![paused queue on error]()
All three are in cmd/mecatui/ui (the Bubble Tea client). Decisions on all three have been made (see Decisions); this issue captures the analysis and a file-level plan so it can be picked up.
Background: how the queue works today
While a run streams, pressing enter stages the input text into m.queued (enqueuePrompt, cmd/mecatui/ui/update.go:1480) instead of sending. When the run ends, drainQueue (update.go:2592) decides:
- healthy stop (
shouldDrain: "", end_turn, max_turns, max_tool_calls, budget, update.go:2662) → pop the oldest and submit it; its ResultMsg re-enters drainQueue, giving a one-at-a-time FIFO drain.
- non-clean stop (error / user-cancel /
max_consecutive_failures / stream close) → record m.queuePaused and keep the queue; the user resumes with enter on an empty line (resumeQueue, update.go:2636) or clears with esc (update.go:1565, m.queued = nil).
renderQueue (cmd/mecatui/ui/view.go:743) draws the card and the enter sends next · esc clears hint.
The three problems
1. Transient errors are treated as fatal for the queue
shouldDrain returns false for every non-healthy stop, so a transient failure (upstream idle timeout, gRPC Unavailable/DeadlineExceeded, 429, 5xx, the engine's stream-idle error) pauses the queue identically to a hard failure (auth error, max_consecutive_failures). An idle-timeout is retryable — resuming would almost certainly succeed — but the user has to notice the pause and manually resume.
2. No way to recover/edit a queued message (the sharpest gap)
Once text is in m.queued there are exactly two exits: enter sends it verbatim, esc drops the whole queue (unrecoverable). There is no path to pull a queued item back into the textarea to edit it. The user is stuck between "send exactly this" and "lose it."
3. Multiple queued messages are sent as separate turns, not merged
Each staged prompt is drained as its own follow-up run (FIFO, one at a time). The user expects them merged into a single prompt (Claude Code behaviour).
Root-cause detail (confirmed in code)
- The observed error
agent: stream: response failed: server_error: Upstream idle timeout exceeded is not the engine's own llmresilience.StreamIdleError (whose text is llmresilience: llm stream stalled: no chunk for <d>, internal/adapter/llmresilience/llmresilience.go:177-188). It is the upstream provider's (Z.ai/GLM) server_error, which the openai adapter maps to 503 (internal/adapter/openai/stream.go:351) carrying the gateway's "Upstream idle timeout" message text.
- Implication for the fix: a gRPC-code-only transient predicate can miss this case (a provider 503 may map to
Unavailable or Internal). The classifier must also match the error-message text, and the same vocabulary must apply on both delivery paths:
StreamErrMsg (cmd/mecatui/client/stream.go:73) — transport/RPC error; a gRPC status.Code is available.
ResultMsg (cmd/mecatui/client/msgs.go:622-629) — in-loop terminal error relayed as a normal EvResult with Stop:"error"; here only the error string survives.
Decisions (already made)
- Merge always — match Claude Code. Multiple queued follow-ups combine into one prompt, not N sequential turns.
- Edit-back into input — a non-destructive affordance pulls the (merged) queued text back into the textarea and removes it from the queue, so it can be edited then re-sent/re-queued. Esc stays clear-all.
- Auto-resume transient errors — split the error handling: transient/retryable failures auto-fire the merged queue (reopens the session server-side and retries); hard failures and explicit user-cancel still pause.
Note: Claude Code's exact merge separator is undocumented (verified via the docs — not published). Proposal: a single named constant queueMergeSep = "\n\n" (blank line between staged prompts so they read as distinct paragraphs), trivially changeable.
Proposed implementation (file-level)
Merge-always
popAndSubmit (update.go:2617) → join all m.queued entries with queueMergeSep, clear the slice, set the textarea, submit once via submitPrompt. Keep m.queued a []string for the preview card. Preserve the pendingMode pause (the merged text lands in the textarea, queuePaused="mode", no submit).
drainQueue/resumeQueue collapse from one-at-a-time FIFO to all-at-once (the ResultMsg → drainQueue re-entry now no-ops on an empty queue).
Edit-back
- New
editBackQueue method: m.ta.SetValue(strings.Join(m.queued, queueMergeSep)), m.queued = nil, m.queuePaused = "", muted status, afterInputEdit(nil). Entries were expanded at enqueue, so no paste re-expansion.
- Keybinding:
up on an empty input line when len(m.queued) > 0 (Claude Code recall idiom). Collision-safe: up/down are not globally bound (ScrollU/D are pgup/pgdown; overlay Up/Down only fire inside overlays); on an empty single-line input up is a textarea no-op. Non-empty input → binding does not fire (up keeps navigating textarea lines); recall while composing = esc then up. Add EditBack to keyMap (keys.go) so it's override-able. Wire into both onRunningKey and onIdleKey.
- Update
renderQueue hint: draining → add ↑ edit; paused → enter sends · ↑ edit · esc clears.
Auto-resume transient errors
- Add
Transient bool to StreamErrMsg and ResultMsg, set at construction in the client package (the only place the raw error/text lives — keeps ui proto/engine-free).
- Predicates in the
client package (already imports google.golang.org/grpc/status+codes):
TransientStreamErr(err) — true if gRPC code ∈ {Unavailable, DeadlineExceeded, ResourceExhausted} OR errors.Is(err, context.DeadlineExceeded) OR the shared message-vocabulary matches status.Convert(err).Message()/err.Error().
TransientResultError(text) — the shared message-vocabulary over the flattened EvResult error string.
- Shared vocabulary (case-insensitive substrings):
idle timeout, stream stalled, stream idle, unavailable, overloaded, deadline exceeded, too many requests, 429, 502, 503, 504, temporarily, engine_overloaded, service_unavailable. (Deliberately not bare server_error — OpenRouter reuses it for non-transient faults too, per internal/adapter/openai/stream.go:299.)
drainQueue(stop, transient) fires when shouldDrain(stop) || (stop == stopError && transient). Hard errors and cancelled still pause. Auto-resume fires exactly once (the merged queue empties in one shot → no loop). StreamClosedMsg (clean EOF) stays pause.
Stale docs
docs/tui.md — the "Type-while-running and queued follow-ups" section (:884-909) and the keys table (:519-525): merge-on-drain, transient auto-resume, and the ↑ edit-back affordance.
- Regenerate
llms.txt via task generate.
Test plan
- Merge: merge-on-drain, merge-on-resume (assert one run with joined text, queue empty).
- Edit-back: pulls-and-clears while running; re-queues after editing while running; sends when idle; refuses on non-empty input; available in both paused and draining states; esc still clears-all.
- Transient:
ResultMsg{Stop:error, Error:"...idle timeout...", Transient:true} auto-resumes; StreamErrMsg{codes.Unavailable} auto-resumes; hard error (invalid api key) still pauses; cancelled still pauses; max_consecutive_failures still pauses; auto-resume bounded (no loop).
- Classifier tables (
client/msgs_test.go): transient vocabulary + gRPC codes true/false; EventToMsg sets Transient correctly.
- Non-regression:
pendingMode pause, staged large-paste expansion (no double-expansion on merge/edit-back), /clear wipes queued+queuePaused, maxQueued cap.
- Goldens shift from the hint-line change →
task test:golden; go run ./cmd/mecademo still prints a full offline session.
Risks / open questions
- Merge separator unverified against Claude Code (undocumented). Isolated in one constant; proposal
"\n\n".
ResultMsg transient classification is string-based (the EvResult path flattens the typed error). A cleaner long-term fix threads a real retryable flag from the engine (llmresilience classifier) onto the proto EvResult — a larger cross-boundary change; possible follow-up.
- Edit-back of an expanded large paste drops the full payload back into the textarea (reintroduces the per-frame re-wrap cost the
[Pasted text #N] placeholder avoids). Proposal: accept for now (user chose to edit); re-staging as a placeholder is an option.
- Clean-EOF (
StreamClosedMsg) stays pause — a transient drop normally arrives as StreamErrMsg (with a gRPC status), so this is conservative and consistent.
Scope
cmd/mecatui/ui/{update.go,view.go,keys.go}, cmd/mecatui/client/{msgs.go,stream.go,client.go}, tests under cmd/mecatui/ui + cmd/mecatui/client, docs/tui.md, llms.txt. No engine/internal API changes (the client-side transient flag stays in the mecatui client package).
Summary
The mecatui follow-up-prompt queue has three UX problems that surfaced together in a live session. When a run died on
server_error: Upstream idle timeout exceeded, the TUI showed⏸ 1 queued · paused: errorand the only two exits were enter (send the message verbatim) or esc (drop the entire queue). There is no way to edit a queued message, transient errors are treated as fatal for the queue, and multiple queued messages are sent as separate sequential turns rather than merged.All three are in
cmd/mecatui/ui(the Bubble Tea client). Decisions on all three have been made (see Decisions); this issue captures the analysis and a file-level plan so it can be picked up.Background: how the queue works today
While a run streams, pressing enter stages the input text into
m.queued(enqueuePrompt,cmd/mecatui/ui/update.go:1480) instead of sending. When the run ends,drainQueue(update.go:2592) decides:shouldDrain:"",end_turn,max_turns,max_tool_calls,budget,update.go:2662) → pop the oldest and submit it; itsResultMsgre-entersdrainQueue, giving a one-at-a-time FIFO drain.max_consecutive_failures/ stream close) → recordm.queuePausedand keep the queue; the user resumes with enter on an empty line (resumeQueue,update.go:2636) or clears with esc (update.go:1565,m.queued = nil).renderQueue(cmd/mecatui/ui/view.go:743) draws the card and theenter sends next · esc clearshint.The three problems
1. Transient errors are treated as fatal for the queue
shouldDrainreturnsfalsefor every non-healthy stop, so a transient failure (upstream idle timeout, gRPCUnavailable/DeadlineExceeded, 429, 5xx, the engine's stream-idle error) pauses the queue identically to a hard failure (auth error,max_consecutive_failures). An idle-timeout is retryable — resuming would almost certainly succeed — but the user has to notice the pause and manually resume.2. No way to recover/edit a queued message (the sharpest gap)
Once text is in
m.queuedthere are exactly two exits: enter sends it verbatim, esc drops the whole queue (unrecoverable). There is no path to pull a queued item back into the textarea to edit it. The user is stuck between "send exactly this" and "lose it."3. Multiple queued messages are sent as separate turns, not merged
Each staged prompt is drained as its own follow-up run (FIFO, one at a time). The user expects them merged into a single prompt (Claude Code behaviour).
Root-cause detail (confirmed in code)
agent: stream: response failed: server_error: Upstream idle timeout exceededis not the engine's ownllmresilience.StreamIdleError(whose text isllmresilience: llm stream stalled: no chunk for <d>,internal/adapter/llmresilience/llmresilience.go:177-188). It is the upstream provider's (Z.ai/GLM)server_error, which the openai adapter maps to 503 (internal/adapter/openai/stream.go:351) carrying the gateway's "Upstream idle timeout" message text.UnavailableorInternal). The classifier must also match the error-message text, and the same vocabulary must apply on both delivery paths:StreamErrMsg(cmd/mecatui/client/stream.go:73) — transport/RPC error; a gRPCstatus.Codeis available.ResultMsg(cmd/mecatui/client/msgs.go:622-629) — in-loop terminal error relayed as a normalEvResultwithStop:"error"; here only the error string survives.Decisions (already made)
Proposed implementation (file-level)
Merge-always
popAndSubmit(update.go:2617) → join allm.queuedentries withqueueMergeSep, clear the slice, set the textarea, submit once viasubmitPrompt. Keepm.queueda[]stringfor the preview card. Preserve thependingModepause (the merged text lands in the textarea,queuePaused="mode", no submit).drainQueue/resumeQueuecollapse from one-at-a-time FIFO to all-at-once (theResultMsg → drainQueuere-entry now no-ops on an empty queue).Edit-back
editBackQueuemethod:m.ta.SetValue(strings.Join(m.queued, queueMergeSep)),m.queued = nil,m.queuePaused = "", muted status,afterInputEdit(nil). Entries were expanded at enqueue, so no paste re-expansion.upon an empty input line whenlen(m.queued) > 0(Claude Code recall idiom). Collision-safe:up/downare not globally bound (ScrollU/Darepgup/pgdown; overlayUp/Downonly fire inside overlays); on an empty single-line inputupis a textarea no-op. Non-empty input → binding does not fire (upkeeps navigating textarea lines); recall while composing =escthenup. AddEditBacktokeyMap(keys.go) so it's override-able. Wire into bothonRunningKeyandonIdleKey.renderQueuehint: draining → add↑ edit; paused →enter sends · ↑ edit · esc clears.Auto-resume transient errors
Transient booltoStreamErrMsgandResultMsg, set at construction in theclientpackage (the only place the raw error/text lives — keepsuiproto/engine-free).clientpackage (already importsgoogle.golang.org/grpc/status+codes):TransientStreamErr(err)— true if gRPC code ∈ {Unavailable,DeadlineExceeded,ResourceExhausted} ORerrors.Is(err, context.DeadlineExceeded)OR the shared message-vocabulary matchesstatus.Convert(err).Message()/err.Error().TransientResultError(text)— the shared message-vocabulary over the flattenedEvResulterror string.idle timeout,stream stalled,stream idle,unavailable,overloaded,deadline exceeded,too many requests,429,502,503,504,temporarily,engine_overloaded,service_unavailable. (Deliberately not bareserver_error— OpenRouter reuses it for non-transient faults too, perinternal/adapter/openai/stream.go:299.)drainQueue(stop, transient)fires whenshouldDrain(stop) || (stop == stopError && transient). Hard errors andcancelledstill pause. Auto-resume fires exactly once (the merged queue empties in one shot → no loop).StreamClosedMsg(clean EOF) stays pause.Stale docs
docs/tui.md— the "Type-while-running and queued follow-ups" section (:884-909) and the keys table (:519-525): merge-on-drain, transient auto-resume, and the↑edit-back affordance.llms.txtviatask generate.Test plan
ResultMsg{Stop:error, Error:"...idle timeout...", Transient:true}auto-resumes;StreamErrMsg{codes.Unavailable}auto-resumes; hard error (invalid api key) still pauses;cancelledstill pauses;max_consecutive_failuresstill pauses; auto-resume bounded (no loop).client/msgs_test.go): transient vocabulary + gRPC codes true/false;EventToMsgsetsTransientcorrectly.pendingModepause, staged large-paste expansion (no double-expansion on merge/edit-back),/clearwipesqueued+queuePaused,maxQueuedcap.task test:golden;go run ./cmd/mecademostill prints a full offline session.Risks / open questions
"\n\n".ResultMsgtransient classification is string-based (theEvResultpath flattens the typed error). A cleaner long-term fix threads a realretryableflag from the engine (llmresilienceclassifier) onto the protoEvResult— a larger cross-boundary change; possible follow-up.[Pasted text #N]placeholder avoids). Proposal: accept for now (user chose to edit); re-staging as a placeholder is an option.StreamClosedMsg) stays pause — a transient drop normally arrives asStreamErrMsg(with a gRPC status), so this is conservative and consistent.Scope
cmd/mecatui/ui/{update.go,view.go,keys.go},cmd/mecatui/client/{msgs.go,stream.go,client.go}, tests undercmd/mecatui/ui+cmd/mecatui/client,docs/tui.md,llms.txt. No engine/internal API changes (the client-side transient flag stays in the mecatui client package).