Codex-style queued follow-up inputs: the queue key while a turn runs holds the prompt until the turn ends, one per turn; alt+↑ edits the last one; an interrupt…

Codex-style queued follow-up inputs for Claude Code.
While Claude is working, press Tab to queue your next prompt instead of steering the current turn. Queued prompts wait above the input box and go out one per turn, oldest first, after each turn ends.
• Queued follow-up inputs
↳ run the tests you just wrote
↳ then update the README
alt+↑ edit last queued message
claude plugin marketplace add Demo-0416/claude-code-mods
claude plugin install prompt-queue@demo-0416-mods
Then bind the keys (next section). A mod can't change your keybindings itself.
Add these to ~/.claude/keybindings.json, or run /keybindings to open it:
{
"bindings": [
{
"context": "Chat",
"bindings": {
"tab": "chat:queueSubmit"
}
},
{
"context": "Autocomplete",
"bindings": {
"tab": "autocomplete:accept"
}
}
]
}
tab → chat:queueSubmit makes Tab the queue key. Without it the queue key is Claude Code's default, ctrl+x enter.Autocomplete block keeps Tab for accepting a completion while the menu is open.Without this plugin loaded, a Tab bound to chat:queueSubmit behaves like Enter: Claude Code delivers the message into the running turn at the next tool call.
| Key | While Claude works | While idle |
|---|---|---|
| Tab | Queue the prompt for after the turn | Send it now, like Enter |
| Enter | Steer the running turn (Claude Code's default) | Send |
| alt+↑ (Option+↑ on macOS) | Take the last queued prompt back into the input box, replacing the draft | Same, while anything is queued |
| Esc | Interrupt; every queued prompt returns to the input box, oldest first, ahead of the draft |
· tab to queue message while Claude works and the input box has text.@file mentions in a queued prompt aren't expanded. Claude sees the path as text.Queued follow-up input line in the transcript. Claude Code requires a reason when a mod holds back a prompt.alt+↑, not Codex's shift+←: Claude Code doesn't hand a mod the input box's cursor keys.app:diffFileListUp, the one that scrolls the /diff panel's file list. While that panel is open, the key scrolls the panel instead.claude --plugin-dir ./plugins/prompt-queue # load from source; saves reload the mod
claude plugin validate ./plugins/prompt-queue
claude plugin test ./plugins/prompt-queue
tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes the first time it loads the mod. After that, tsc -p plugins/prompt-queue type-checks the mod.
hooks/register.tsx 321 lines1// Queued follow-up inputs, as Codex's TUI has them (codex-rs/tui,
2// bottom_pane/pending_input_preview.rs and chatwidget/input_restore.rs):
3// the queue key while a turn runs holds the prompt until the turn ends, one
4// prompt per turn, oldest first; the edit key takes the last one back into
5// the composer; an interrupt puts every queued prompt back into the composer.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { QueuedPrompt } from '../types'
10
11// Prompts waiting for the running turn to end, oldest first.
12const items = atom({ plugin: 'prompt-queue', key: 'items' } as const, [] as QueuedPrompt[])
13// A main-loop turn is running.
14const isBusy = atom({ plugin: 'prompt-queue', key: 'isBusy' } as const, false)
15// The id of a released prompt whose turn has not started yet, so a turn that
16// ends meanwhile (a steered prompt, a notification) releases nothing more.
17// Cleared when its prompt enters, when any main-loop turn starts, and on a
18// reload, so a release the plugin never saw enter cannot stall the queue.
19const releasing = atom({ plugin: 'prompt-queue', key: 'releasing' } as const, null as string | null)
20
21// Codex's PREVIEW_LINE_LIMIT: wrapped rows shown per queued prompt.
22const PREVIEW_LINE_LIMIT = 3
23const DEFAULT_QUEUE_KEY = 'ctrl+x enter'
24// The prompt box hands a mod no cursor keys, so the edit key is an engine
25// action the band's Button names: its chord presses the Button while the queue
26// shows. This one scrolls /diff's file list and is bound to alt+↑ (Codex's
27// second edit key) by default, so it needs no keybinding of the person's.
28const EDIT_ACTION = 'app:diffFileListUp'
29const EDIT_KEY_LABEL = 'alt+↑'
30
31const newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
32
33const ARROWS: Record<string, string> = { left: '←', right: '→', up: '↑', down: '↓' }
34
35// A keybinding as Codex labels it off macOS: `shift+←`, `alt+↑`.
36const keyLabel = (chord: string) =>
37 chord
38 .split(' ')
39 .map(stroke =>
40 stroke
41 .split('+')
42 .map(part => {
43 const name = part.toLowerCase()
44
45 return ['meta', 'opt', 'option'].includes(name) ? 'alt' : (ARROWS[name] ?? name)
46 })
47 .join('+'),
48 )
49 .join(' ')
50
51const isWide = (code: number) =>
52 (code >= 0x1100 && code <= 0x115f) ||
53 (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
54 (code >= 0xac00 && code <= 0xd7a3) ||
55 (code >= 0xf900 && code <= 0xfaff) ||
56 (code >= 0xfe30 && code <= 0xfe4f) ||
57 (code >= 0xff00 && code <= 0xff60) ||
58 (code >= 0xffe0 && code <= 0xffe6) ||
59 (code >= 0x1f300 && code <= 0x1f64f) ||
60 (code >= 0x1f900 && code <= 0x1f9ff) ||
61 (code >= 0x20000 && code <= 0x3fffd)
62
63const cellWidth = (char: string) => (isWide(char.codePointAt(0) ?? 0) ? 2 : 1)
64
65// Wraps one prompt into rows of at most `width` cells, breaking after a space
66// where the row has one, so English words stay whole and CJK breaks anywhere.
67function wrapRows(text: string, width: number): string[] {
68 const rows: string[] = []
69 for (const line of text.split('\n')) {
70 let row: string[] = []
71 let used = 0
72 let isContinued = false
73 for (const char of line) {
74 const w = cellWidth(char)
75 if (used + w > width && row.length > 0) {
76 const space = row.lastIndexOf(' ')
77 const carry = space > 0 ? row.splice(space + 1) : []
78 rows.push(row.join('').trimEnd())
79 row = carry
80 used = carry.reduce((sum, c) => sum + cellWidth(c), 0)
81 isContinued = true
82 }
83 if (isContinued && row.length === 0 && char === ' ') {
84 continue
85 }
86 row.push(char)
87 used += w
88 }
89 rows.push(row.join(''))
90 }
91
92 return rows
93}
94
95// The key bound to chat:queueSubmit in the Chat context: the person's last
96// binding of it in keybindings.json, else the default unless they unbound it.
97let queueKey: string | undefined = DEFAULT_QUEUE_KEY
98
99async function readQueueKey($: EngineInterface) {
100 const dir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
101 let file: unknown
102 try {
103 file = JSON.parse(await $.fs.read(`${dir}/keybindings.json`))
104 } catch {
105 return DEFAULT_QUEUE_KEY
106 }
107 let key: string | undefined = DEFAULT_QUEUE_KEY
108 const blocks = (file as { bindings?: unknown }).bindings
109 for (const block of Array.isArray(blocks) ? blocks : []) {
110 const { context, bindings } = block as { context?: unknown; bindings?: unknown }
111 if (context !== 'Chat' || typeof bindings !== 'object' || bindings === null) {
112 continue
113 }
114 for (const [chord, action] of Object.entries(bindings)) {
115 if (action === 'chat:queueSubmit') {
116 key = chord
117 } else if (chord === key && action === null) {
118 key = undefined
119 }
120 }
121 }
122
123 return key
124}
125
126// Sends the oldest queued prompt as a turn of its own once the session is idle.
127async function release($: EngineInterface) {
128 let head: QueuedPrompt | undefined
129 await update($, items, list => {
130 head = list[0]
131
132 return list.slice(1)
133 })
134 if (head === undefined) {
135 return
136 }
137 const sent = head
138 await update($, releasing, () => sent.id)
139 // Not awaited: it resolves when the prompt's turn starts, after this hook.
140 void $.prompt.submit({ text: sent.text, asUser: true })
141}
142
143// Releases the next prompt when nothing runs or is about to. Deferred to a
144// timer, so the submit never comes from within the hook that holds the turn,
145// and this plugin's prompt.submit hook sees it.
146function kick($: EngineInterface) {
147 $.clock.after(0, () => void kickNow($))
148}
149
150async function kickNow($: EngineInterface) {
151 const isIdle =
152 !(await read($, isBusy)) &&
153 (await read($, releasing)) === null &&
154 (await read($, items)).length > 0
155 if (isIdle) {
156 await release($)
157 }
158}
159
160// Takes the newest queued prompt back into the composer, over the draft, as
161// Codex's edit key does.
162async function takeLast($: EngineInterface) {
163 const last = (await read($, items)).at(-1)
164 if (last === undefined) {
165 return
166 }
167 const filled = await $.prompt.fill({ text: last.text, mode: 'replace' })
168 if (filled.isFilled) {
169 await update($, items, list => list.filter(one => one.id !== last.id))
170 }
171}
172
173// Puts every queued prompt back into the composer, oldest first, ahead of the
174// draft, as Codex does on an interrupt.
175async function restore($: EngineInterface) {
176 const list = await read($, items)
177 if (list.length === 0) {
178 return
179 }
180 const draft = (await $.prompt.read()).text
181 const text = [...list.map(one => one.text), ...(draft ? [draft] : [])].join('\n')
182 const filled = await $.prompt.fill({ text, mode: 'replace' })
183 if (filled.isFilled) {
184 const restored = new Set(list.map(one => one.id))
185 await update($, items, all => all.filter(one => !restored.has(one.id)))
186 }
187}
188
189export const register: Register = on => {
190 on('session.start', async ($, e, next) => {
191 queueKey = await readQueueKey($)
192 // A reload: a release in flight may have entered unseen.
193 await update($, releasing, () => null)
194 kick($)
195
196 return next(e)
197 })
198
199 on('prompt.submit', async ($, e, next) => {
200 if (e.origin.kind === 'plugin' && e.origin.name === 'prompt-queue') {
201 // One of ours: its turn has started once `next` resolves.
202 const entered = await next(e)
203 await update($, releasing, () => null)
204 if (entered.drop !== undefined) {
205 kick($)
206 }
207
208 return entered
209 }
210
211 if (!e.wait || e.origin.kind !== 'composer' || e.text.trim() === '') {
212 return next(e)
213 }
214 const hasPending = (await read($, items)).length > 0
215 if (e.turnId === undefined && !hasPending) {
216 // Idle with nothing queued: the queue key sends at once, like Enter.
217 return next(e)
218 }
219 if (e.attachments?.length) {
220 $.ui.toast('Images can’t be queued yet; sent now instead')
221
222 return next(e)
223 }
224 await update($, items, list => [...list, { id: newId(), text: e.text }])
225 if (e.turnId === undefined) {
226 kick($)
227 }
228
229 return { drop: 'Queued follow-up input' }
230 })
231
232 on('turn.start', async ($, e, next) => {
233 await update($, isBusy, () => true)
234 await update($, releasing, () => null)
235
236 return next(e)
237 })
238
239 on('turn.complete', async ($, e, next) => {
240 const result = await next(e)
241 if (e.agentId !== undefined) {
242 return result
243 }
244 await update($, isBusy, () => false)
245 if (e.reason === 'aborted') {
246 await restore($)
247
248 return result
249 }
250 // An answer, and an error or refusal too, sends the next one.
251 if ((await read($, releasing)) === null) {
252 await release($)
253 }
254
255 return result
256 })
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 const list = await read($, items)
260 if (list.length === 0 || e.props.hasSurvey) {
261 return next(e)
262 }
263 const { Box, Button, Text } = $.ui.resolve(e)
264 const width = Math.max(8, e.props.bodyColumns - 4)
265
266 return (
267 <Box flexDirection="column">
268 <Text>
269 <Text dimColor>• </Text>
270 Queued follow-up inputs
271 </Text>
272 {list.flatMap(one => {
273 const rows = wrapRows(one.text, width)
274 const shown = rows.slice(0, PREVIEW_LINE_LIMIT)
275
276 return [
277 ...shown.map((row, i) => (
278 <Text wrap="truncate-end">
279 <Text dimColor>{i === 0 ? ' ↳ ' : ' '}</Text>
280 <Text dimColor italic>
281 {row}
282 </Text>
283 </Text>
284 )),
285 ...(rows.length > PREVIEW_LINE_LIMIT
286 ? [
287 <Text dimColor italic>
288 {' …'}
289 </Text>,
290 ]
291 : []),
292 ]
293 })}
294 <Box flexDirection="row">
295 <Text>{' '}</Text>
296 <Button
297 key="edit"
298 plain
299 label={EDIT_KEY_LABEL}
300 action={EDIT_ACTION}
301 onPress={() => takeLast($)}
302 />
303 <Text dimColor> edit last queued message</Text>
304 </Box>
305 </Box>
306 )
307 })
308
309 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
310 if (!e.props.isWorking || !e.props.isDraft || queueKey === undefined) {
311 return next(e)
312 }
313 const key = keyLabel(queueKey)
314 const full = ` · ${key} to queue message`
315 const columns = e.viewport?.columns ?? 80
316 const tail = e.props.hint.length + full.length <= columns ? full : ` · ${key} to queue`
317
318 return next({ ...e, props: { ...e.props, tail } })
319 })
320}
321types/index.d.ts 12 lines1export type QueuedPrompt = { id: string; text: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'prompt-queue': {
6 items: QueuedPrompt[]
7 isBusy: boolean
8 releasing: string | null
9 }
10 }
11}
12