Skip to content

[Feature] Queue user messages while the agent is busy, with arrow-key navigation to edit them #597

Description

@will-lamerton

Summary
Allow the user to keep typing and submit additional messages while an agent turn (LLM streaming + tool execution) is in flight, instead of being locked out of the input. Submitted messages should be queued and dispatched in order when the current turn ends, and the user should be able to see the queue and navigate it with ↑/↓ to re-load a queued message back into the input for editing — modeled on the behaviour in Claude Code.

Context
Today the chat input is fully replaced by a "Press Esc to cancel" spinner the moment a turn starts:

  • source/app/sections/interactive-app.tsx:198 passes inputDisabled={chatHandler.isGenerating || appState.isToolExecuting} into ChatInput.
  • source/app/components/chat-input.tsx:158-163 only mounts UserInput when neither tool confirmation, tool execution, nor question prompts are pending AND the client is ready — i.e. never while busy.
  • source/components/user-input.tsx:618-630 is the "disabled" branch: it renders a spinner and an early-return, so even though TextInput is mounted, all keypresses (including typing) are blocked by useInput at source/components/user-input.tsx:431-433 (if (disabled) { return; }).

This is the "user input disappears until the agent loop finishes" behaviour the request describes. There is no existing user-input queue to extend:

  • source/utils/message-queue.tsx is only for system-level logInfo/logError/etc. messages, not user submissions. It pipes ErrorMessage/InfoMessage/SuccessMessage/WarningMessage into the static chat queue — nothing about preserving a pending user message across turns.
  • source/components/chat-queue.tsx only renders already-finalised message components via Ink's Static; it has no concept of "next user message to send".
  • The onConversationComplete callback fired from source/hooks/chat-handler/conversation/conversation-loop.tsx:367-369, 670-672, 929-931 (wired up at source/app/App.tsx:178-185 to reset isConversationComplete/tool counts/task list) is the natural drain point for a queue — once a turn ends, the next queued message can be dispatched.

Existing patterns to follow:

  • The two existing "global handler slot" queues (source/utils/question-queue.ts, source/utils/tool-approval-queue.ts, source/utils/tool-confirm-queue.ts) and their shared helper source/utils/global-handler-slot.ts show how the codebase bridges a tool's suspended Promise with the Ink UI via a module-level singleton + a set… wiring call from App.tsx. The new user-input queue can be a lighter-weight version of this: state lives in a hook/ref owned by App.tsx, exposed to UserInput via props.
  • source/components/user-input.tsx:330-389 already handles assemblePrompt + extractImageReferences + promptHistory.addPrompt inside handleSubmit. The submit path can branch on isBusy: instead of calling onSubmit directly, push onto the queue and reset the input.
  • Arrow-key history navigation already exists in source/components/user-input.tsx:391-465 (handleHistoryNavigation) and is wired through useInputState / promptHistory. The new queue-navigation mode would be a sibling state machine: ↑/↓ move a "selected queue index" cursor, Enter loads the selected message back into the input for editing, and another key (e.g. Ctrl+Delete) removes it from the queue.

Proposed approach

  • Introduce a useMessageQueue (or extend useAppState / useChatHandler) that owns an ordered list of pending user messages, each carrying the same triple the current submit path uses: {message, displayValue, images}. Expose queuedMessages, enqueueMessage, dequeueMessage, removeFromQueue, and updateQueueItem.
  • Lift the queue state into App.tsx (next to appState.chatComponents) so it can be read by both InteractiveApp (for rendering the queue list above/below the input) and useChatHandler (for draining on completion). Wire the drain by adding a drainMessageQueue call inside the onConversationComplete callback at source/app/App.tsx:178-185 and inside the error/finally path in source/hooks/chat-handler/useChatHandler.tsx:200-208.
  • Replace the hard inputDisabled swap in source/app/components/chat-input.tsx with a softer "input stays enabled while busy" path: render UserInput whenever the client/MCP are ready and no decision-state modal owns the slot (tool confirmation, question prompt, subagent approval), and pass isBusy={chatHandler.isGenerating || appState.isToolExecuting} instead of inputDisabled.
  • Modify source/components/user-input.tsx:
    • When isBusy is true, handleSubmit pushes onto the queue and clears the input instead of calling onSubmit. Re-show the queue strip below the input.
    • Add a queue-navigation state machine similar to handleHistoryNavigation: ↑/↓ move a selected index over the queue, Enter/Esc re-loads the selected message into the input buffer (re-assembling its InputState via setInputState), Ctrl+Delete removes the selected item, Escape-with-empty-input clears the selection.
    • Render the queue strip (e.g. ▸ 1. first line… 2. second line… with the active row highlighted) above or just below the input box, matching the position of the existing showCompletions / isFileAutocompleteMode lists in source/components/user-input.tsx:649-712.
    • Keep the existing "Press Esc to cancel" indicator but only as a small status line; the input itself stays interactive.
  • In source/app/sections/interactive-app.tsx, stop wiring inputDisabled. Pass isBusy and queuedMessages into ChatInputUserInput so the queue strip renders next to the spinner instead of replacing the input.
  • Update tests:
    • source/components/user-input.spec.tsx and source/app/components/chat-input.spec.tsx for the new submit-while-busy path, queue rendering, and arrow-key navigation.
    • Add a hook-level test that onConversationComplete drains the queue in FIFO order and that an error in the current turn still drains correctly.

Acceptance criteria

  • While chatHandler.isGenerating or appState.isToolExecuting is true, the text input remains mounted, accepts typed characters, and shows a visible queue strip of pending submissions.
  • Submitting (Enter) while busy adds the message to the end of the queue and clears the input; the message is not sent to the LLM until the in-flight turn ends.
  • When the in-flight turn ends (via onConversationComplete or the error/finally path in useChatHandler), the next queued message is dispatched automatically in FIFO order, and the queue strip updates.
  • ↑/↓ arrows navigate the queue strip when the input is empty (or via a dedicated modifier key) and load the selected message back into the input buffer for editing; a separate key (e.g. Ctrl+Delete) removes a queued message.
  • Image attachments and [@file] placeholders are preserved through enqueue/dequeue (i.e. the queue stores the same {message, displayValue, images} triple the submit path already produces).
  • An empty queue behaves identically to today: pressing Enter sends immediately; Escape cancels the running turn.
  • No regressions in the existing decision states — tool confirmation, question prompts, and subagent approval still own the slot when they are pending (the input is hidden during those, just like today).

Out of scope

  • Cross-session queue persistence (queue lives only in memory for the current run).
  • Reordering queued messages via drag/move (only delete + re-type is required for the first cut).
  • Showing the queue count in the status/title bar (can be a follow-up).
  • Changing the behaviour of /commands while busy (slash commands should still bypass the queue and execute immediately, mirroring today's behaviour).

Metadata

Metadata

Labels

enhancementNew feature or requesthelp wantedExtra attention is needed

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions