/q <prompt> holds prompts until the running turn ends and sends them one per turn; a band above the prompt lists, edits, reorders and pauses them

A Claude Code mod that queues prompts until Claude's running turn ends.
When you send a message while Claude is working, Claude Code delivers it into the running turn, between tool calls. Sometimes you want the opposite: a follow-up that waits until Claude has finished, then goes out as its own turn.
/q now write tests for what you just changed
/q <prompt>, or Tab once bound (below), queues a prompt. While a turn is running it confirms in a toast (Queued: 2 waiting.); while idle it sends straight away. /q leaves nothing in the transcript.Mods cannot see keys typed in the prompt box, so the two shortcuts come from Claude Code's own actions. Add them to ~/.claude/keybindings.json:
{
"bindings": [
{
"context": "Chat",
"bindings": {
"tab": "chat:queueSubmit",
"alt+up": "abovePrompt:focus"
}
},
{
"context": "Autocomplete",
"bindings": {
"tab": "autocomplete:accept"
}
}
]
}
Autocomplete entry keeps Tab accepting file, command and skill suggestions while their menu is open: without it, a Chat binding for Tab wins over the built-in autocomplete one and submits the prompt. The dim suggestion in an empty prompt box is taken with → instead: Claude Code drops an empty submit before any mod sees it, so Tab cannot do both./diff's file-list key, which this binding takes over while the prompt box has focus.In the band:
| Key | Does |
|---|---|
| ↑ / ↓ | move to the prompt before / after |
| Enter | steer the focused prompt into the running turn (send it, while idle) |
e | edit the focused prompt (Enter saves; saving it empty removes it) |
k / j | move the focused prompt up / down the queue |
x | remove the focused prompt |
p | pause / resume sending |
| Esc | back to the prompt box |
From inside Claude Code:
/plugin marketplace add gergesh/claude-queue-mod
/plugin install wait-queue@claude-queue-mod
Or run it from a checkout without installing:
git clone https://github.com/gergesh/claude-queue-mod
claude --plugin-dir ./claude-queue-mod
Mods (function-hook plugins) are an early-access Claude Code feature; the API may change between releases.
claude plugin validate . # what the engine would load or refuse
claude plugin test . # hooks/register.test.ts against the engine
tsc -p . # after Claude Code has loaded the mod once
Claude Code writes the API's type declarations into .claude-plugin/types/ each time it loads the mod, and tsconfig.json extends the config it writes there, so type-checking works once the mod has been loaded (with --plugin-dir, or as an installed plugin). That folder is not committed.
hooks/register.tsx: the modhooks/register.test.ts: its tests, on the terminal and desktop surfacestypes/index.d.ts: the session state the mod keeps ($.state)hooks/register.tsx 359 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { QueueItem } from '../types'
5
6// The engine delivers a prompt typed mid-turn into that turn even when the
7// person asked it to wait (`e.wait`, chat:queueSubmit). This mod holds such
8// prompts, and those given to `/q <prompt>`, sends them one per turn once the
9// session is idle, and lists them in a band above the prompt to steer into the
10// running turn, edit, reorder, remove or pause.
11
12const items = atom({ plugin: 'wait-queue', key: 'items' } as const, [])
13const editingId = atom({ plugin: 'wait-queue', key: 'editingId' } as const, null)
14const isPaused = atom({ plugin: 'wait-queue', key: 'isPaused' } as const, false)
15const steered = atom({ plugin: 'wait-queue', key: 'steered' } as const, [])
16const runningTurnId = atom({ plugin: 'wait-queue', key: 'runningTurnId' } as const, null)
17const isReleasing = atom({ plugin: 'wait-queue', key: 'isReleasing' } as const, false)
18
19const ROW = 'row-'
20const EDIT = 'edit-'
21const STEER_FRAME = '[The user sent this while you were working. Apply it to the current task now.]'
22const STEER_NUDGE = 'Continue the task, applying the instruction I just sent.'
23
24function isQueueCommand(text: string): boolean {
25 return /^\/q(\s|$)/.test(text.trim())
26}
27
28function truncate(text: string, width: number): string {
29 const line = text.replace(/\s+/g, ' ')
30 return line.length <= width ? line : `${line.slice(0, width - 1)}…`
31}
32
33function moveItem(list: QueueItem[], id: string, offset: -1 | 1): QueueItem[] {
34 const from = list.findIndex(item => item.id === id)
35 const item = list[from]
36 const to = from + offset
37 if (item === undefined || to < 0 || to >= list.length) return list
38 const rest = list.filter(other => other.id !== id)
39 return [...rest.slice(0, to), item, ...rest.slice(to)]
40}
41
42function showCount($: EngineInterface, count: number) {
43 $.ui.status(count === 0 ? undefined : `${count} queued`)
44}
45
46async function enqueue($: EngineInterface, text: string): Promise<number> {
47 const item: QueueItem = { id: crypto.randomUUID(), text }
48 const next = await update($, items, list => [...list, item])
49 showCount($, next.length)
50 return next.length
51}
52
53async function take($: EngineInterface, id: string): Promise<QueueItem | undefined> {
54 const item = (await read($, items)).find(one => one.id === id)
55 if (item === undefined) return undefined
56 const rest = await update($, items, list => list.filter(one => one.id !== id))
57 showCount($, rest.length)
58 return item
59}
60
61// Puts a prompt that did not get through back at the front of the queue.
62async function restore($: EngineInterface, item: QueueItem, reason: string) {
63 await update($, isReleasing, () => false)
64 const list = await update($, items, current => [item, ...current.filter(one => one.id !== item.id)])
65 showCount($, list.length)
66 $.ui.toast(`${reason}; it is back at the front of the queue.`)
67}
68
69// Sends a prompt as the person's own; one the engine refuses goes back in the queue.
70function submit($: EngineInterface, item: QueueItem) {
71 void $.prompt
72 .submit({ text: item.text, asUser: true })
73 .then(result => (result.drop === undefined ? undefined : restore($, item, `Not sent (${result.drop})`)))
74 .catch(() => restore($, item, 'Could not send'))
75}
76
77// Sends the head of the queue, unless the queue is paused, a prompt in it is
78// being edited, or one already went and its turn has not started.
79async function sendNext($: EngineInterface) {
80 if ((await read($, isPaused)) || (await read($, editingId)) !== null || (await read($, isReleasing))) return
81 const [first] = await read($, items)
82 if (first === undefined) return
83
84 await update($, isReleasing, () => true)
85 const item = await take($, first.id)
86 if (item !== undefined) submit($, item)
87}
88
89// Sends one queued prompt into the running turn, read at the model's next step;
90// while idle it goes as a turn of its own.
91async function steer($: EngineInterface, id: string) {
92 const isWorking = (await read($, runningTurnId)) !== null
93 const item = await take($, id)
94 if (item === undefined) return
95 if (!isWorking) {
96 submit($, item)
97 return
98 }
99
100 const appended = await $.session
101 .append({ message: { type: 'user', content: [{ type: 'text', text: `${STEER_FRAME}\n\n${item.text}` }] } })
102 .catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
103 if (appended.deny !== undefined) {
104 await restore($, item, `Not steered (${appended.deny})`)
105 return
106 }
107
108 await update($, steered, list => [...list, { id: item.id, text: item.text, status: 'pending' as const }])
109 $.ui.toast('Steered into the current turn.')
110}
111
112export const register: Register = on => {
113 on('session.start', async ($, e, next) => {
114 // A reload or a new process starts with nothing in flight.
115 await update($, isReleasing, () => false)
116 await $.command.register({
117 name: 'q',
118 description: 'Queue a prompt to send after the current turn ends',
119 argumentHint: '<prompt>',
120 immediate: true,
121 })
122 return next(e)
123 })
124
125 on('command.run', { command: 'q' }, async ($, e) => {
126 // Answers in a toast, not text: /q leaves nothing in the transcript (see the
127 // CommandOutput and UserMessage hooks below).
128 const text = e.args.trim()
129 if (text === '') {
130 $.ui.toast('Usage: /q <prompt>. The queue panel above the prompt manages what is queued.')
131 return {}
132 }
133
134 const count = await enqueue($, text)
135 if ((await read($, runningTurnId)) === null) {
136 // A submit can't start inside this hook (it would wait on the command's own
137 // dispatch), so it goes out on the clock's next tick, once the command is done.
138 $.clock.after(0, () => void sendNext($))
139 $.ui.toast('Sending now.')
140 return {}
141 }
142 $.ui.toast(`Queued: ${count} waiting.`)
143 return {}
144 })
145
146 on('prompt.submit', async ($, e, next) => {
147 const isWaitingRequest =
148 e.wait && e.turnId !== undefined && e.origin.kind === 'composer' && e.attachments === undefined
149 if (!isWaitingRequest) return next(e)
150
151 const count = await enqueue($, e.text)
152 return { drop: `Queued (${count} waiting).` }
153 })
154
155 on('turn.start', async ($, e, next) => {
156 const result = await next(e)
157 await update($, runningTurnId, () => result.turnId)
158 await update($, isReleasing, () => false)
159 return result
160 })
161
162 on('turn.step', async function* ($, e, next) {
163 // The step after a steer carries it to the model.
164 if (e.turnId === (await read($, runningTurnId)) && (await read($, steered)).some(one => one.status === 'pending')) {
165 await update($, steered, list => list.map(one => ({ ...one, status: 'read' as const })))
166 }
167 return yield* next(e)
168 })
169
170 on('turn.complete', async ($, e, next) => {
171 const result = await next(e)
172 if (e.agentId !== undefined) return result
173 if (e.turnId === (await read($, runningTurnId))) await update($, runningTurnId, () => null)
174
175 const hasUnreadSteer = (await read($, steered)).some(one => one.status === 'pending')
176 await update($, steered, () => [])
177
178 if (e.isAborted) {
179 // An interrupted turn pauses the queue; the person resumes or edits it in the band.
180 const waiting = (await read($, items)).length
181 if (waiting > 0) {
182 await update($, isPaused, () => true)
183 $.ui.toast(`Queue paused: turn interrupted (${waiting} waiting). Resume it from the panel.`)
184 }
185 return result
186 }
187
188 if (hasUnreadSteer) {
189 // The turn ended before the model read the steer: ask it to pick it up
190 // first; the queue goes on after that turn.
191 void $.prompt.submit({ text: STEER_NUDGE, asUser: true }).catch(() => $.ui.toast('Could not ask Claude to continue.'))
192 return result
193 }
194
195 await sendNext($)
196 return result
197 })
198
199 // /q runs are kept out of the transcript's drawing (the model still reads them).
200 on('ui.render', { component: 'CommandOutput' }, ($, e, next) =>
201 e.props.command === 'q' ? $.ui.resolve(e).Box({}) : next(e),
202 )
203
204 on('ui.render', { component: 'UserMessage' }, ($, e, next) =>
205 isQueueCommand(e.props.text) ? $.ui.resolve(e).Box({}) : next(e),
206 )
207
208 // The row the band's focus ring is on, so e / k / j / x act on it.
209 let focusedId: string | undefined
210
211 on('ui.focus', { component: 'AbovePrompt' }, ($, e, next) => {
212 if (e.plugin === $.plugin.name && e.element?.startsWith(ROW) === true) focusedId = e.element.slice(ROW.length)
213 return next(e)
214 })
215
216 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
217 const list = await read($, items)
218 const steering = await read($, steered)
219 if (e.props.hasSurvey || (list.length === 0 && steering.length === 0)) return next(e)
220
221 const paused = await read($, isPaused)
222 const editing = await read($, editingId)
223 const elements = $.ui.resolve(e)
224 const { Box, Button, Text } = elements
225 // A surface without text fields lists, steers, reorders and removes, but does not edit.
226 const Input = 'Input' in elements ? elements.Input : undefined
227
228 // Sending resumes on its own at the next turn's end; while idle it is nudged here.
229 const resumeIfIdle = async () => {
230 if ((await read($, runningTurnId)) === null) await sendNext($)
231 }
232 // Read at press time: moving the focus ring does not redraw the band.
233 const focusedRow = async () => {
234 const current = await read($, items)
235 return (current.find(item => item.id === focusedId) ?? current[current.length - 1])?.id
236 }
237 const moveFocused = async (offset: -1 | 1) => {
238 const id = await focusedRow()
239 if (id !== undefined) await update($, items, current => moveItem(current, id, offset))
240 }
241 const state = paused ? 'paused' : editing !== null ? 'paused while editing' : 'sends after this turn'
242 const width = Math.max(20, e.props.bodyColumns - 8)
243
244 return (
245 <Box flexDirection="column">
246 {steering.length > 0 && (
247 <Box key="steering" flexDirection="column">
248 {steering.map(one => (
249 <Text key={`steered-${one.id}`} color="cyan" wrap="truncate-end">
250 {one.status === 'pending' ? '↪ steering' : '✓ read'} · {truncate(one.text, width - 12)}
251 </Text>
252 ))}
253 </Box>
254 )}
255 {list.length > 0 && (
256 <Box flexDirection="row" gap={1}>
257 <Text bold>Queue</Text>
258 <Text dimColor>
259 {list.length} waiting · {state}
260 </Text>
261 <Button
262 key="pause"
263 label={paused ? 'Resume' : 'Pause'}
264 hotkey="p"
265 plain
266 dimColor
267 onPress={async () => {
268 await update($, isPaused, value => !value)
269 if (paused) await resumeIfIdle()
270 }}
271 />
272 <Button
273 key="clear"
274 label="Clear"
275 dimColor
276 onPress={async () => {
277 await update($, editingId, () => null)
278 await update($, items, () => [])
279 showCount($, 0)
280 }}
281 />
282 </Box>
283 )}
284 {list.map((item, index) =>
285 item.id === editing && Input !== undefined ? (
286 <Box key={`editing-${item.id}`} flexDirection="row" gap={1}>
287 <Input
288 key={`${EDIT}${item.id}`}
289 label={`${index + 1}.`}
290 value={item.text}
291 submitLabel="save"
292 autoFocus
293 onSubmit={async (value: string) => {
294 const text = value.trim()
295 await update($, items, current =>
296 text === ''
297 ? current.filter(one => one.id !== item.id)
298 : current.map(one => (one.id === item.id ? { ...one, text } : one)),
299 )
300 await update($, editingId, () => null)
301 await resumeIfIdle()
302 }}
303 />
304 <Button
305 key={`cancel-${item.id}`}
306 label="Cancel"
307 dimColor
308 onPress={async () => {
309 await update($, editingId, () => null)
310 await resumeIfIdle()
311 }}
312 />
313 </Box>
314 ) : (
315 <Button
316 key={`${ROW}${item.id}`}
317 label={truncate(`${index + 1}. ${item.text}`, width)}
318 plain
319 autoFocus={editing === null && index === list.length - 1 ? true : undefined}
320 onPress={() => steer($, item.id)}
321 />
322 ),
323 )}
324 {list.length > 0 && editing === null && (
325 <Box flexDirection="row" gap={2}>
326 <Text dimColor>enter: {e.props.isWorking ? 'steer now' : 'send now'}</Text>
327 {Input !== undefined && (
328 <Button
329 key="edit"
330 label="edit"
331 hotkey="e"
332 plain
333 dimColor
334 onPress={async () => {
335 const id = await focusedRow()
336 if (id !== undefined) await update($, editingId, () => id)
337 }}
338 />
339 )}
340 <Button key="move-up" label="move up" hotkey="k" plain dimColor onPress={() => moveFocused(-1)} />
341 <Button key="move-down" label="move down" hotkey="j" plain dimColor onPress={() => moveFocused(1)} />
342 <Button
343 key="remove"
344 label="remove"
345 hotkey="x"
346 plain
347 dimColor
348 onPress={async () => {
349 const id = await focusedRow()
350 if (id !== undefined) await take($, id)
351 }}
352 />
353 </Box>
354 )}
355 </Box>
356 )
357 })
358}
359types/index.d.ts 20 lines1export type QueueItem = { id: string; text: string }
2
3/** A queued prompt steered into the running turn: `pending` until the model's next step reads it. */
4export type SteeredItem = { id: string; text: string; status: 'pending' | 'read' }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'wait-queue': {
9 items: QueueItem[]
10 editingId: string | null
11 isPaused: boolean
12 steered: SteeredItem[]
13 /** The main loop's running turn, or null while idle. */
14 runningTurnId: string | null
15 /** True from releasing a queued prompt until its turn starts, so only one goes at a time. */
16 isReleasing: boolean
17 }
18 }
19}
20