Skip to content

[Feature] Allow Escape to recall an in-flight message back into the input before the assistant starts streaming #598

Description

@will-lamerton

Summary
When the user submits a message and presses Escape immediately (before the model has produced any response), the message is currently lost: Escape cancels the request and clears the user bubble from the chat history, so the user has to retype from scratch. Make Escape restore the just-submitted message into the input box for quick editing when nothing has streamed back yet.

Context

  • Submission wipes the input synchronously. In source/components/user-input.tsx:341-345, handleSubmit calls promptHistory.addPrompt(currentState), then onSubmit(...), then resetInput() / resetUIState() / setAttachments([]). The original InputState (displayValue + placeholderContent) is no longer held in component state after that, and attachments is cleared too.
  • onSubmit ultimately calls chatHandler.handleChatMessage, which appends the user message to messages (source/hooks/chat-handler/useChatHandler.tsx:309) and then awaits the response. The user-visible "anything has processed" signal is chatHandler.streamingContent !== '' (or any token streamed via onToken at source/hooks/chat-handler/conversation/conversation-loop.tsx:284-287).
  • Escape handling while in flight is centralized at the section level. source/app/sections/interactive-app.tsx:107-115 installs a single useInput that calls appHandlers.handleCancel() whenever cancellable is true (isCancelling || isGenerating || isToolExecuting || abortController !== null). handleCancel aborts the controller and kills active bash (source/hooks/useAppHandlers.tsx:170-194) but does not touch the message history or the input.
  • UserInput itself swallows Escape when isBusy || disabled (source/components/user-input.tsx:443-445) so the section handler can run. The double-Escape clear-input flow at source/components/user-input.tsx:350-359 is unrelated and only runs when idle.
  • The InputState type (source/types/hooks.ts:42) and the setInputState setter (line 109 of user-input.tsx) are already used to populate the input for command completions and history navigation, so they're the right primitive to restore the recalled message — including file/paste placeholders.
  • The last user message is reachable via appState.messages[messages.length - 1] (set in handleChatMessage at source/hooks/chat-handler/useChatHandler.tsx:309), and the just-pushed InputState is at the tail of promptHistory.getHistory() (since promptHistory.addPrompt(currentState) runs before onSubmit).

Proposed approach

  • Introduce an "edit in-flight" recall path that only triggers when the user presses Escape while cancellable === true AND the assistant has not yet streamed anything (e.g., !chatHandler.isGenerating || chatHandler.streamingContent === ''). Once any token has streamed, fall back to the current behaviour (cancel and discard).
  • Capture the pre-submit InputState and attachments before the existing handleSubmit clears them. Options:
    • Stash currentState and attachments on a pendingRecallRef inside UserInput at the top of handleSubmit (around source/components/user-input.tsx:316-318), then have the recall path call setInputState(pendingRecallRef.current) and setAttachments(pendingRecallRef.currentAttachments); or
    • Plumb a onRecallSubmitted callback from App.tsx down through InteractiveAppChatInputUserInput, mirroring the existing handleUserSubmit plumbing (source/app/App.tsx:402-405). handleRecall would look up the tail of appState.messages (the just-added user turn), call props.updateMessages(messages.slice(0, -1)) to drop it from history, set setIsCancelling/clear the abort controller, and then re-populate the input via a pendingRestore state that UserInput consumes via setInputState / setAttachments on mount/change. The lifted approach keeps UserInput simple and centralises the "remove from history + abort + restore" sequence.
  • Wire the section-level Escape handler in source/app/sections/interactive-app.tsx:107-115 to call the new handleRecall instead of handleCancel when the "no tokens streamed yet" guard is satisfied. Keep handleCancel as the fallback for the case where streaming has begun (so we don't surface partial assistant output alongside the user's recalled draft).
  • Mirror the existing idle-Escape behaviour in UserInput (single press shows a "Press escape again to clear"-style hint, second press clears). For the in-flight recall path the first Escape should already restore the message without prompting, since cancelling a fresh submission is destructive. Consider an undo affordance if the existing setIsCancelling banner is repurposed.

Acceptance criteria

  • Pressing Enter on a message and then immediately pressing Escape (before any assistant token has streamed) restores the submitted text, placeholders, and image attachments into the input box, ready for editing.
  • The recalled message is removed from appState.messages so re-submitting it doesn't produce a duplicate turn in the chat history.
  • The abort controller is cleaned up and setIsCancelling is reset so the UI returns to the idle state.
  • If the assistant has already streamed at least one token (or the user is in an in-flight tool/question/confirmation state), Escape behaves exactly as today: cancel, no recall.
  • Recalling restores currentState.placeholderContent so file/paste placeholders re-render with their [@file] / [Paste #…] form, not raw contents.
  • A new spec in source/components/user-input.spec.tsx (and/or source/app/sections/interactive-app.spec.tsx) covers the recall path: submit + Escape before stream → input shows original message; submit + Escape after token → existing cancel behaviour, no recall.

Out of scope

  • Recalling after partial streaming (would require merging edited text with the in-flight assistant reply; deferred).
  • Recalling after tool execution has begun (isToolExecuting), question prompts, tool confirmations, or subagent approvals — these are intentionally excluded by the current cancellable predicate.
  • Changing the idle double-Escape clear-input flow.

Metadata

Metadata

Labels

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