feat(tui): add edit and discard actions for queued messages
Summary
Add the ability to edit and discard queued messages while the agent is working. This allows users to fix typos, rephrase, or completely remove messages from the queue before they are processed.
New keybinds:
-
<leader>i- Edit the last queued message -
<leader>d- Discard the last queued message
Motivation
When iterating quickly with the AI assistant, users often queue multiple messages. Currently, there's no way to modify or remove these queued messages once sent. This leads to:
- Wasted tokens on messages with typos
- Duplicate/redundant instructions when user and agent solve the problem simultaneously
- No way to "take back" a queued message
This feature addresses a common workflow need requested by multiple users.
Related Issues & PRs
Closes
- #4821 - Add ability to unqueue messages
Supersedes
- #5415 - feat(tui): cancel queued messages with history_previous
- Our implementation improves upon this with dedicated keybinds, file preservation, visual feedback, and bug fixes
Related
- #5333 - Graceful handling of queued messages after session interrupt (partial)
- #5770 - force push message keybind (provides discard capability)
- #3623 - Queued bugging
- #2609 - Compact + Queue interaction
Screenshots
Queued message with hints
Editing a queued message
Command palette with queue commands
Demo
https://streamable.com/urjeww
The video demonstrates the complete flow:
- Sending a message while agent is working (queued)
- Editing the queued message with
<leader>i - Discarding a queued message with
<leader>d - The toast notification when a message is processed while being edited
Implementation
Why this PR exists (context on #5415)
This implementation started from PR #5415 which was stale and had several issues:
Problems with the original PR #5415
-
Reused
history_previouskeybind - Hijacked the UP arrow when prompt was empty, conflicting with normal history navigation - Lost file attachments - When loading a queued message for editing, only text parts were preserved; files and images were silently discarded
- No visual feedback - No indication that a message was being edited vs just queued
- No dedicated discard action - Could only edit, not quickly discard
- Missing API endpoint - Only had cancel endpoint, no way to fetch message without removing it
- Bug in dialog-command.tsx - Disabled commands could still be triggered via keybinds
What we fixed/improved
| Issue | Original #5415 | This PR |
|---|---|---|
| Keybinds | Reuses UP arrow | Dedicated <leader>i / <leader>d |
| File preservation | Lost on edit | Preserved with extmarks |
| Visual feedback | None | QUEUED + EDITING badges |
| Discard action | None | Dedicated keybind |
| API | 1 endpoint (cancel) | 3 endpoints (list, get, cancel) |
| Disabled commands | Could trigger via keybind | Fixed to respect disabled state |
Architecture Decisions
Why dedicated keybinds instead of UP arrow?
The original PR #5415 proposed reusing history_previous (UP arrow) when the prompt
is empty. We chose dedicated keybinds (<leader>i and <leader>d) because:
- No conflict with history navigation - Users can still navigate history normally
- Explicit intent - Editing a queued message is a distinct action from browsing history
-
Consistency with OpenCode patterns - Other specialized actions use leader-prefix keybinds (e.g.,
<leader>hfor tips,<leader>upfor parent session) - Discoverability - Commands appear in the command palette with clear names
- Future-proof - PR #4268 proposes changing history keybinds, our approach avoids conflicts
Why separate Edit and Discard?
-
Edit (
<leader>i): Loads the message into the prompt for modification -
Discard (
<leader>d): Removes the message entirely without loading it
This separation allows:
- Quick discard without polluting the prompt
- Discard while already editing (removes message, clears prompt)
- Clear mental model: "i" for insert/edit, "d" for delete
Why only edit the last queued message?
Both OpenAI Codex and our implementation only allow editing the most recent queued message. Rationale:
- Simplifies UX - no need for message selection UI
- Covers 99% of use cases - users typically want to fix what they just typed
- If you need to edit something deeper in the queue, you probably made a larger mistake and should discard and re-queue
State Management
We track editingQueuedMessageID in the prompt store to:
- Show the
EDITINGbadge on the correct message - Know which message to cancel when submitting the edited version
- Clear state appropriately when the message is processed mid-edit
- Prevent editing multiple messages simultaneously
File/Image Preservation
When loading a queued message for editing, we preserve non-text parts by reconstructing them with virtual text representations (e.g., [File: filename] or [Image 1]) and creating extmarks for proper rendering. This ensures images and files aren't silently lost when editing.
Comparison with Other Tools
We studied how other AI coding assistants handle queued message editing:
OpenAI Codex (Rust TUI)
Source: codex-rs/tui/src/chatwidget.rs
Codex stores queued messages in a VecDeque<UserMessage> with text and image paths. It uses Alt+Up to pop the most recent queued message back into the composer, preserving image paths separately. A static hint "Alt+Up edit" is shown on queued messages.
What we learned from Codex:
- Preserve image paths separately (we preserve all file parts with extmarks)
- Show hint on queued messages (we show contextual hints that change based on state)
- Only edit last message (we follow this pattern)
How we differ from Codex:
| Aspect | Codex | OpenCode (this PR) |
|---|---|---|
| Keybind | Alt+Up |
<leader>i |
| Discard action | None | <leader>d |
| Visual state | No badge | EDITING badge |
| Hints | Static "Alt+Up edit" | Contextual (changes when editing) |
| On interrupt | Restores all to composer | N/A (different architecture) |
Claude Code
Claude Code allows editing queued messages via UP arrow when prompt is empty. This is similar to the original #5415 approach, which we improved upon with dedicated keybinds to avoid conflicts with history navigation.
Flow Diagram
QUEUED MESSAGE
┌──────────────────────────────────────────────────────────┐
│ │
│ You QUEUED ctrl+x i edit · ctrl+x d discard │
│ > Your queued message text here... │
│ │
└──────────────────────────────────────────────────────────┘
│ │
│ <leader>i │ <leader>d
▼ ▼
┌───────────────────────────┐ ┌─────────────────────────┐
│ │ │ │
│ QUEUED EDITING │ │ Message removed from │
│ │ │ queue and storage │
│ enter submit │ │ │
│ ctrl+c cancel │ └─────────────────────────┘
│ ctrl+x d discard │
│ │
│ ┌─────────────────────┐ │
│ │ Prompt: edited text │ │
│ └─────────────────────┘ │
└───────────────────────────┘
│
│ Enter
▼
┌───────────────────────────┐
│ │
│ 1. Original message │
│ cancelled │
│ │
│ 2. New message sent │
│ with edited content │
│ │
└───────────────────────────┘
API Endpoints Added
| Method | Endpoint | Description |
|---|---|---|
GET |
/session/:sessionID/queue |
List queued message IDs |
GET |
/session/:sessionID/queue/:messageID |
Get queued message (without removing) |
DELETE |
/session/:sessionID/queue/:messageID |
Cancel and remove queued message |
Changes
Backend (packages/opencode/src/)
| File | Changes |
|---|---|
session/prompt.ts |
Add queued(), getQueued(), cancelQueued() functions; track messageID in callbacks |
server/server.ts |
Add 3 REST endpoints for queue operations |
config/config.ts |
Add queue_edit and queue_discard keybind defaults |
Frontend (packages/opencode/src/cli/cmd/tui/)
| File | Changes |
|---|---|
component/prompt/index.tsx |
Add queue edit/discard commands; track editingQueuedMessageID state; preserve file parts with extmarks; handle edge cases (message processed mid-edit, cancel with ctrl+c, clear with ctrl+u) |
component/dialog-command.tsx |
Bug fix: prevent disabled commands from triggering via keybinds |
routes/session/index.tsx |
Show QUEUED/EDITING badges with contextual hints |
SDK (auto-generated)
| File | Changes |
|---|---|
packages/sdk/js/src/v2/gen/* |
Types for new endpoints |
packages/sdk/openapi.json |
API schema |
Bug fix: disabled commands triggering via keybinds
Found and fixed a bug in dialog-command.tsx where disabled commands could still be triggered via their keybinds:
- if (option.keybind && keybind.match(option.keybind, evt)) {
+ if (option.keybind && !option.disabled && keybind.match(option.keybind, evt)) {
This affected all commands with a disabled property, not just the queue commands.
Edge Cases
| Scenario | Behavior |
|---|---|
| Message processed while editing | Toast "Queued message was processed", prompt cleared, editing state reset |
| Edit message with file attachments | Files preserved as [File: filename] with extmarks |
Cancel edit (ctrl+c) |
Prompt cleared, returns to normal state, original message stays queued |
Clear prompt (ctrl+u) |
Editing state also cleared |
| Discard while editing | Message removed, prompt cleared |
| No queued messages | Commands disabled in palette, keybinds have no effect |
| Multiple queued messages | Only last message can be edited (consistent with Codex behavior) |
How to Test
git checkout feat/edit-queued-messages
bun install
cd packages/opencode && bun dev
- Start a conversation with the agent
- While the agent is working, send another message (it will show
QUEUEDbadge) - Press
ctrl+x ito edit the queued message - Modify the text and press
Enterto submit, orctrl+cto cancel - Alternatively, press
ctrl+x dto discard without editing
Testing
test/session/queue.test.ts (new file)
-
SessionPrompt.queued()- returns empty array for non-existent session -
SessionPrompt.getQueued()- returns undefined for non-existent session/message -
SessionPrompt.cancelQueued()- returns undefined for non-existent session/message
test/config/config.test.ts (additions)
-
queue_editdefaults to<leader>i -
queue_discarddefaults to<leader>d - Both keybinds can be customized via config
Breaking Changes
None. This is an additive feature with new keybinds that don't conflict with existing ones.