Skip to content

mecatui: queued follow-ups — merge into one prompt, allow edit-back, and auto-resume on transient errors #228

Description

@JAORMX

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)

  1. Merge always — match Claude Code. Multiple queued follow-ups combine into one prompt, not N sequential turns.
  2. 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.
  3. 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

  1. Merge separator unverified against Claude Code (undocumented). Isolated in one constant; proposal "\n\n".
  2. 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.
  3. 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.
  4. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    tuimecatui terminal UI (rendering, keybindings, footer, panes, scrollback)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions