SLOPSHOPPER

prompt-queue

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…

newbandspinnertoastprompttimer
A shopper browsing a rack in a slop shop
README

prompt-queue

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

Install

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.

Keybindings

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.
  • The 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.

Keys

KeyWhile Claude worksWhile idle
TabQueue the prompt for after the turnSend it now, like Enter
EnterSteer the running turn (Claude Code's default)Send
alt+↑ (Option+↑ on macOS)Take the last queued prompt back into the input box, replacing the draftSame, while anything is queued
EscInterrupt; every queued prompt returns to the input box, oldest first, ahead of the draft

Behaviour

  • One queued prompt per turn, oldest first. A turn that ends in an error or refusal still sends the next one.
  • An interrupted turn sends nothing. The queue moves back into the input box so you can edit or resend it.
  • The hint under the input box shows · tab to queue message while Claude works and the input box has text.

Limits

  • A prompt with pasted images or files can't be queued; it is sent right away, with a notice.
  • @file mentions in a queued prompt aren't expanded. Claude sees the path as text.
  • Each queued prompt leaves a dim Queued follow-up input line in the transcript. Claude Code requires a reason when a mod holds back a prompt.
  • The edit key is alt+↑, not Codex's shift+←: Claude Code doesn't hand a mod the input box's cursor keys.
  • The edit key works through the engine action app:diffFileListUp, the one that scrolls the /diff panel's file list. While that panel is open, the key scrolls the panel instead.

Develop

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.

Source 2 files
hooks/register.tsx 321 lines
1// 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}
321
types/index.d.ts 12 lines
1export 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