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 InteractiveApp → ChatInput → UserInput, 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.
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
source/components/user-input.tsx:341-345,handleSubmitcallspromptHistory.addPrompt(currentState), thenonSubmit(...), thenresetInput()/resetUIState()/setAttachments([]). The originalInputState(displayValue + placeholderContent) is no longer held in component state after that, andattachmentsis cleared too.onSubmitultimately callschatHandler.handleChatMessage, which appends the user message tomessages(source/hooks/chat-handler/useChatHandler.tsx:309) and then awaits the response. The user-visible "anything has processed" signal ischatHandler.streamingContent !== ''(or any token streamed viaonTokenatsource/hooks/chat-handler/conversation/conversation-loop.tsx:284-287).source/app/sections/interactive-app.tsx:107-115installs a singleuseInputthat callsappHandlers.handleCancel()whenevercancellableis true (isCancelling || isGenerating || isToolExecuting || abortController !== null).handleCancelaborts the controller and kills active bash (source/hooks/useAppHandlers.tsx:170-194) but does not touch the message history or the input.UserInputitself swallows Escape whenisBusy || disabled(source/components/user-input.tsx:443-445) so the section handler can run. The double-Escape clear-input flow atsource/components/user-input.tsx:350-359is unrelated and only runs when idle.InputStatetype (source/types/hooks.ts:42) and thesetInputStatesetter (line 109 ofuser-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.appState.messages[messages.length - 1](set inhandleChatMessageatsource/hooks/chat-handler/useChatHandler.tsx:309), and the just-pushedInputStateis at the tail ofpromptHistory.getHistory()(sincepromptHistory.addPrompt(currentState)runs beforeonSubmit).Proposed approach
cancellable === trueAND 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).InputStateandattachmentsbefore the existinghandleSubmitclears them. Options:currentStateandattachmentson apendingRecallRefinsideUserInputat the top ofhandleSubmit(aroundsource/components/user-input.tsx:316-318), then have the recall path callsetInputState(pendingRecallRef.current)andsetAttachments(pendingRecallRef.currentAttachments); oronRecallSubmittedcallback fromApp.tsxdown throughInteractiveApp→ChatInput→UserInput, mirroring the existinghandleUserSubmitplumbing (source/app/App.tsx:402-405).handleRecallwould look up the tail ofappState.messages(the just-added user turn), callprops.updateMessages(messages.slice(0, -1))to drop it from history, setsetIsCancelling/clear the abort controller, and then re-populate the input via apendingRestorestate thatUserInputconsumes viasetInputState/setAttachmentson mount/change. The lifted approach keepsUserInputsimple and centralises the "remove from history + abort + restore" sequence.source/app/sections/interactive-app.tsx:107-115to call the newhandleRecallinstead ofhandleCancelwhen the "no tokens streamed yet" guard is satisfied. KeephandleCancelas the fallback for the case where streaming has begun (so we don't surface partial assistant output alongside the user's recalled draft).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 existingsetIsCancellingbanner is repurposed.Acceptance criteria
appState.messagesso re-submitting it doesn't produce a duplicate turn in the chat history.setIsCancellingis reset so the UI returns to the idle state.currentState.placeholderContentso file/paste placeholders re-render with their [@file] / [Paste #…] form, not raw contents.source/components/user-input.spec.tsx(and/orsource/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
isToolExecuting), question prompts, tool confirmations, or subagent approvals — these are intentionally excluded by the currentcancellablepredicate.