A prompt typed while Claude is working is held in a stack above the prompt instead of landing in the running turn, and sent once the turn ends. Reorder the…

/q <text> while Claude is working: the text does not land in the running turn. It waits in a stack drawn above the prompt box and goes out once the turn has ended, one after the other, in the order you queued them — or the order you put them in, the stack being a thing you can reorder, edit and prune while the turn runs. Enter alone is untouched: a line typed without /q goes into the turn the way it always did.
⏺ Reading hooks/register.ts …
╭──────────────────────────────────────────────────────────────────────────╮
│queued · 2 · sent when the turn ends │
│1 also update the README [ ↓ ] [ ▶ ] [ edit ] [ ✕ ] │
│2 then run the e2e suite [ ↑ ] [ ▶ ] [ edit ] [ ✕ ] │
│[ clear ] │
╰──────────────────────────────────────────────────────────────────────────╯
❯
Stock Claude Code delivers a mid-turn message into the turn, beside the next tool result, so the model reads it halfway through work it has not finished. A /q line is held instead: the turn ends on the thing it was asked, and your next thought starts a turn of its own. A turn that ends with work still running in the background (subagents, a backgrounded shell) is a pause, not an end: the stack waits for the notification that wakes the session, and goes out once that turn ends with nothing left running.
[ ▶ ] (and /q now <n>) is the way back in for the one that will not wait: the entry leaves the stack and rides the running turn's next tool result as context, the way a mid-turn message arrives. It waits in the band, marked ▶ … waiting for the next tool call, until a tool call carries it, with a line in the transcript at the press and another when it goes in; a turn that calls no tool sends it first of all when it ends. A turn that is only streaming text has no tool call coming: Esc ends it, and the entry goes out as the next prompt. With nothing running [ ▶ ] is what it always was: to the front, and out at once.
A mod: a plugin built on Claude Code function hooks, TypeScript that runs inside Claude Code's own process. Early access, so it needs the environment variable below and the API can change between releases.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.claude -p.~/.claude/settings.json: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
git clone https://github.com/galElmalah/claude-mods
cd claude-mods
claude --plugin-dir claude-queue
Or install it from the repository's marketplace, claude-mods:
claude plugin marketplace add galElmalah/claude-mods
claude plugin install claude-queue@claude-mods
/q and your next message and press Enter. It appears in the band instead of in the turn.| command | what it does | |
|---|---|---|
/q | lists what is held, and ends with the status line | |
/q <text> | holds text until the turn ends; sent at once when nothing runs | |
/q /<command> | holds a slash command the same way, and runs it when its turn comes | |
/q up <n> \ | down <n> | moves entry n one place |
/q mv <n> <m> | moves entry n to position m | |
/q now <n> | pushes it into the running turn at its next tool call, or sends it at once when nothing is running | |
/q rm <n> | takes entry n out | |
/q edit <n> | takes it out and puts its text back in the prompt box | |
/q clear | drops the lot | |
/q send | sends now, when the session is idle | |
/q status | turn idle · 2 held · waiting, with · 3 in the background while the drain waits on those |
/q runs while a turn is in flight, which is the only time the stack fills. The band does the same things under the mouse: [ ↑ ] and [ ↓ ] reorder (the row at either end keeps the column and drops the button it cannot use), [ ▶ ] pushes that one into the turn, [ edit ], [ ✕ ], and [ send ] and [ clear ] under the stack. No hotkeys: a digit or a letter would fire while you were typing the next message.
Only the fullscreen renderer sends the band the mouse: /tui fullscreen, or CLAUDE_CODE_NO_FLICKER=1. On the classic renderer (the default in most terminals) a click on a button lands nowhere, and the entry goes out when the turn ends as if nothing had been pressed; the band says so under its buttons. The keyboard reaches them on either renderer: ctrl+x tab moves the focus into the band, Tab walks the buttons, Enter presses one — or type /q rm <n>.
[ edit ] turns the row into a text field, prefilled, with Enter to keep what you typed. The band has to hold the keyboard for that, and only ctrl+x tab gives it: press that, Tab along to the row's [ edit ], then Enter. A click on [ edit ] presses it without moving the keyboard off the composer, so there the text goes back into the prompt box instead, exactly as /q edit <n> does.
Esc gives the keyboard back to the composer without telling the plugin, so the field stays open, blurred, holding what you typed; the [ ✕ ] beside it closes it and leaves the entry as it was. The field is one line: on an entry of several, it edits the first and keeps the rest.
Everything held goes out when the turn ends, an Esc-interrupted turn included — [ send ] and /q send are there for the rare stack that is still sitting there.
joined (off by default) sends the whole stack as one prompt when the turn ends, its entries separated by a blank line, instead of a turn each. Set it with /plugin configure claude-queue, or in settings.json:
{ "pluginConfigs": { "claude-queue": { "options": { "joined": true } } } }
hooks/register.ts is the whole plugin.
command.run on /q is the way in: immediate, so it runs while a turn is in flight, and anything after /q that is not a subcommand is pushed on the stack. prompt.submit is left alone but for reading the turn id off it, for a module reloaded mid-turn.turn.start and turn.complete track the running turn. Every ending of the main loop's turn drains — aborted too — the first entry (or, under joined, the lot) going out with $.prompt.submit, which runs once the session is idle; the next turn.complete sends the next. A subagent's turn.complete carries an agentId and is ignored — it ends inside the session's own turn./ is run with $.command.run when its turn comes, /q /compact for one: the engine refuses a prompt that begins with a slash. Under joined the stack goes out in runs, up to each command, which goes alone. A command that started no turn lets the rest go on at once.classic.Stop is where the engine says whether that ending is the work's end: the classic Stop hook's input lists the session's in-flight background tasks, and the module reads it as a function-hooks event. A count above zero holds the drain — the band says sent when the background work ends — until a later turn (the one the task's notification starts) ends with the list empty. [ send ] and /q send go regardless.tool.call is the steer route. [ ▶ ] moves the entry to a list of its own; the hook awaits next(e), and on a main-loop call (no e.agentId) that was not denied it returns the object it got with the framed text appended to context — what the model reads after the tool's result and the user never sees. Returning that object keeps core's ref, so the tool's own messages are used verbatim. The pushed text is not a user message: nothing of it enters the transcript history, so $.ui.log writes the one line queue: pushed into the turn · … for the person instead.ui.render of AbovePrompt draws the band inside a rounded dim Box, sized to e.props.bodyColumns and yielding to a survey. The Buttons' onPress closures edit the stack and $.ui.invalidate("ui.render"). The same hook is the backstop: a stack drawn while no turn is running by the plugin's own count arms a drain, so nothing sits there. It trusts that count over e.props.isWorking, which reads false while a tool runs under the fullscreen renderer. A submit from inside a hook's dispatch is refused, so every drain goes through $.clock.after(0, …).[ edit ] swaps the row for an Input and asks for the band's focus ring with $.ui.focus. The ring only lands on an element already on screen, so the call is retried a few times over the frame that draws the field; the engine also skips the ui.focus event for a plugin's own call, so the answer to $.ui.focus, not the event, is what says the field has it. A deny (the band is not holding the keyboard) falls back to $.prompt.fill. Esc raises nothing at all, which is why the open field carries its own [ ✕ ].$.prompt.submit as "The claude-queue plugin sent a message: …", says so to the model, and draws the framing in the transcript row. The text is yours and reaches the model whole; the framing is the engine's, and the plugin cannot redraw that row either — the engine skips a plugin's hooks on anything its own prompt produced.❯ row. It rides a tool result, which the transcript does not show as a message; the plugin writes a dim line at the press (❯ … · into the turn at its next tool call) and one when a tool call carries it. Between the two, the band's ▶ row is where it is.joined sends the lot as one prompt and pays the wait once.$.prompt.submit takes no context.[ ✕ ] or /q clear if the interrupt changed your mind about it too./q takes a line of text; an image goes in the turn as it always did./q rm <n>, or switch with /tui fullscreen.npm run test:e2e # the plugin inside a real Claude Code (below)
npm run typecheck # against ../.claude/types (run /plugin-types first)
npm run validate # what the engine sees the module hook and call
Typechecking needs the declarations of your Claude Code build: open a session in the repository root with function hooks on and run /plugin-types, which writes the git-ignored .claude/types/.
tests/e2e drives a real interactive Claude Code in a tmux pane, its replies scripted by aimock and paced so a turn takes about fifteen seconds, which is the room the tests type into. It covers an idle prompt passing through, a plain line typed mid-turn left to the engine, one and two /q lines held over a turn and the order they come back in, /q rm, /q edit, /q up, /q down, /q mv, a held /context run as a command with a line behind it, /q now on a text-only turn and on one that calls a tool (a fixture that answers only when the pushed text is in the request), /q status, the field a row's [ edit ] opens, clicks on [ ✕ ] and [ ↓ ], a turn ended with Esc draining anyway, joined, and a held line waiting out a backgrounded shell and a background subagent. Needs tmux and claude on PATH, and the checkout to be a folder Claude Code trusts; skipped otherwise. About four minutes.
Mouse reports only reach the band under the fullscreen renderer (CLAUDE_CODE_NO_FLICKER=1), so the click tests have a session of their own. The keyboard reaches it in either: ctrl+x tab, then Tab along the row.
Edits to hooks/ hot-reload into a running --plugin-dir session, which empties the stack. Start Claude with --debug-file /tmp/q.log to see what the engine refused.
MIT.
hooks/register.ts 550 lines1import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
2
3// `/q <text>` while a turn is running holds the text in a stack drawn above
4// the prompt box, sent once the turn has ended, in order, one turn each. The
5// band's buttons and /q reorder, edit, remove, flush. `[ ▶ ]` is the way into
6// the running turn: the entry rides its next tool result as context, which is
7// how the engine delivers a mid-turn message. Enter alone is left as stock.
8
9const COMMAND = 'q'
10/** `[ ↑ ] [ ↓ ] [ ▶ ] [ edit ] [ ✕ ]`, each with the gap before it, and the one before the text */
11const CONTROLS = 34
12/** what a steered row says while it waits, and `[ ✕ ]` after it, gaps and all */
13const MARK = 'waiting for the next tool call'
14const STEER_CONTROLS = MARK.length + 8
15/** the two columns the band's frame takes off `bodyColumns` */
16const BORDER = 2
17/** the band's one colour, spent on the steer route alone: `▶` and a stack in flight */
18const ACCENT = 'cyan'
19const NO_MOUSE = 'clicks need /tui fullscreen · here: ctrl+x tab, Tab to a button, Enter'
20/** the engine refuses a plugin's prompt within 5s of its last: wait that out */
21const GAP_MS = 5200
22/** the engine caps a tool result's context: a push past it waits for the turn's end instead */
23const CONTEXT_MAX = 32000
24/** refusals in a row before the drain stops and waits for /q send */
25const RETRIES = 3
26/** tries, and the wait between them, for the ring to reach a field just drawn */
27const FOCUS_TRIES = 6
28const FOCUS_MS = 60
29
30type Entry = { id: string; text: string }
31
32let stack: Entry[] = []
33/** taken out of the stack by `[ ▶ ]` to ride the running turn's next tool result */
34let steer: Entry[] = []
35/** the plugin's `joined` option: the whole stack as one prompt */
36let joined = false
37/** the running main-loop turn, so the band and /q send know idle from busy */
38let turnId: string | undefined
39/** one drain per turn, whatever else raises turn.complete for it */
40let drainedTurn: string | undefined
41let counter = 0
42/** when the last prompt of ours went out, and the timer waiting out the gap */
43let sentAt = 0
44let pending: { cancel: () => void } | null = null
45let failures = 0
46/** the row being typed in: the entry's id and the field's text so far */
47let editing: { id: string; text: string } | null = null
48/** the ring reached the field: only then does it leaving mean Esc */
49let editFocused = false
50/** the band's instance, for `$.ui.focus`; the band draws under one id */
51let bandRequest: string | undefined
52/** read off /q: the classic renderer sends the band no mouse, so the band says how else */
53let fullscreen: boolean | undefined
54/** the engine's count of background work (subagents, shells, monitors) still running at the last Stop */
55let inFlight = 0
56
57const firstLine = (text: string) => text.trim().split('\n')[0]?.trim() ?? ''
58
59/** the raw first line, which the field edits, and the rest it leaves alone */
60const head = (text: string) => text.split('\n')[0] ?? ''
61const withHead = (text: string, line: string) => [line, ...text.split('\n').slice(1)].join('\n')
62
63const fit = (text: string, room: number) => {
64 const chars = [...text]
65 return chars.length <= room ? text : `${chars.slice(0, Math.max(1, room - 1)).join('')}…`
66}
67
68/** what the header says, and whether that is a stack on the move: the accent's cue */
69const state = (): [word: string, moving: boolean] =>
70 steer.length > 0 && turnId
71 ? ['going into the turn', true]
72 : stack.length === 0
73 ? ['nothing held', false]
74 : turnId
75 ? ['sent when the turn ends', false]
76 : inFlight > 0 && !pending
77 ? [`sent when the background work ends · ${inFlight} running`, false]
78 : pending
79 ? ['going out', true]
80 : ['sending', true]
81
82/** one line for a "it did not send" report: every flag the drain reads */
83const statusLine = () =>
84 `turn ${turnId ? 'running' : 'idle'}${inFlight > 0 ? ` · ${inFlight} in the background` : ''} · ${stack.length} held${steer.length > 0 ? ` · ${steer.length} going into the turn` : ''} · ${pending && !turnId ? 'going out' : 'waiting'}`
85
86const indexOfId = (id: string) => stack.findIndex(entry => entry.id === id)
87
88const removeAt = (index: number): Entry | undefined => {
89 const [gone] = stack.splice(index, 1)
90 return gone
91}
92
93// a press acts on the drawing it was drawn in, which the stack may have moved under
94const removeId = (id: string): Entry | undefined => {
95 const index = indexOfId(id)
96 return index < 0 ? undefined : removeAt(index)
97}
98
99/** entry `from` lands at `to`, the rest closing up behind it */
100const moveAt = (from: number, to: number) => {
101 if (from < 0 || to < 0 || to >= stack.length || from === to) return false
102 const [entry] = stack.splice(from, 1)
103 stack.splice(to, 0, entry!)
104 return true
105}
106
107// the engine refuses a prompt that starts with a slash: the person meant a
108// command, and it is run as one when its turn comes
109const slash = (text: string): { command: string; args: string } | undefined => {
110 const match = /^\/(\S+)\s*([^]*)$/.exec(text)
111 return match ? { command: match[1]!, args: match[2]! } : undefined
112}
113
114/** the next prompt out: the first entry, or under `joined` the stack up to its first command */
115const takeDue = (): Entry[] => {
116 if (!joined || slash(stack[0]!.text)) return stack.splice(0, 1)
117 const upTo = stack.findIndex(entry => slash(entry.text))
118 return stack.splice(0, upTo < 0 ? stack.length : upTo)
119}
120
121// Synchronous up to the submit itself, so two sends landing together (a
122// turn's end and a press) cannot both take an entry: the second one sees
123// sentAt and waits the gap out. The submit is not awaited: it resolves only
124// once the prompt entered, which is after the hook that asked for it answered.
125const flush = ($: EngineInterface) => {
126 pending = null
127 if (stack.length === 0) return
128 // a turn started under the timer: nothing is sent into one, and its own end
129 // arms the next drain
130 if (turnId) return
131 const wait = GAP_MS - (Date.now() - sentAt)
132 if (wait > 0) {
133 pending = $.clock.after(wait, () => flush($))
134 $.ui.invalidate('ui.render')
135 return
136 }
137 const going = takeDue()
138 sentAt = Date.now()
139 if (editing && indexOfId(editing.id) < 0) editing = null
140 $.ui.invalidate('ui.render')
141 const run = slash(going[0]!.text)
142 if (run) {
143 $.command
144 .run(run)
145 // an unknown name, or one refused: nothing a retry would change
146 .catch(err => $.ui.log(`queue: /${run.command} did not run: ${err}`))
147 // a turn the command started drains at its end; none started leaves the rest to go now
148 .then(() => {
149 failures = 0
150 if (stack.length > 0 && !turnId) send($)
151 })
152 return
153 }
154 $.prompt
155 .submit({ text: going.map(entry => entry.text).join('\n\n') })
156 .then(() => void (failures = 0))
157 .catch(err => {
158 // back as they were, ids and all, so the band's buttons still find them
159 stack.unshift(...going)
160 $.ui.log(`queue: the held prompt did not go out: ${err}`)
161 // a refusal is usually the gap: try once it has passed; several in a row
162 // (the session's cap on a plugin's prompts) leave the stack to /q send
163 if (++failures < RETRIES) pending = $.clock.after(GAP_MS, () => flush($))
164 $.ui.invalidate('ui.render')
165 })
166}
167
168// Off the raising hook's dispatch: a submit from inside command.run, ui.press
169// or ui.render is refused, as it would wait on the turn that hook holds.
170const send = ($: EngineInterface) => {
171 failures = 0
172 if (!pending) pending = $.clock.after(0, () => flush($))
173}
174
175// out of the stack only once the box has it: a fill the box turned down (a
176// dialog holds the keys) would otherwise lose the text
177const fillFrom = ($: EngineInterface, entry: Entry) =>
178 $.prompt
179 .fill({ text: entry.text })
180 .then(({ isFilled }) => {
181 if (!isFilled) return false
182 // the fill's round trip is long enough for a drain to have taken it
183 const kept = removeId(entry.id) !== undefined
184 $.ui.invalidate('ui.render')
185 return kept
186 })
187 .catch(() => false)
188
189// The row becomes a field, and the band's ring moves onto it — which the
190// engine allows only while the band holds the keyboard (a click on it, ctrl+x
191// tab). Where it does not, the composer takes the text as it always did.
192const startEdit = ($: EngineInterface, entry: Entry) => {
193 editing = { id: entry.id, text: head(entry.text) }
194 editFocused = false
195 $.ui.invalidate('ui.render')
196 takeField($, entry, FOCUS_TRIES)
197}
198
199// The ring only lands on an element the band has already drawn, so the first
200// try, a tick after the invalidate, is usually too early for the frame.
201const takeField = ($: EngineInterface, entry: Entry, tries: number) => {
202 const open = editing
203 if (!open || !bandRequest) return
204 void $.ui.focus({ requestId: bandRequest, key: `input:${open.id}` }).then(({ deny }) => {
205 if (editing !== open) return
206 // the engine skips the ui.focus a plugin raised itself (re-entry), so the
207 // ring reaching the field is this answer and not the event
208 if (!deny) return void (editFocused = true)
209 if (tries > 0) return void $.clock.after(FOCUS_MS, () => takeField($, entry, tries - 1))
210 // the ordinary case (a click does not give the band the keyboard): the
211 // fill says what happened by itself, so nothing is written to the transcript
212 editing = null
213 $.ui.invalidate('ui.render')
214 void fillFrom($, entry)
215 })
216}
217
218const commitEdit = ($: EngineInterface, value: string) => {
219 const open = editing
220 editing = null
221 $.ui.invalidate('ui.render')
222 if (!open) return
223 const entry = stack[indexOfId(open.id)]
224 // an emptied field is a cancel: the entry keeps the text it had
225 if (entry && value.trim() !== '') entry.text = withHead(entry.text, value)
226}
227
228/** what the model reads beside the tool result, the message framed as the user's */
229const frame = (entry: Entry) =>
230 `The user sent this message while you were working (delivered by the claude-queue plugin as a steering message, the way a mid-turn message normally arrives). Address it as you continue:\n\n${entry.text}`
231
232const removeSteer = (id: string) => {
233 const index = steer.findIndex(entry => entry.id === id)
234 if (index >= 0) steer.splice(index, 1)
235}
236
237/** mid-turn it rides the turn's next tool result; idle it goes to the front and out */
238const sendNow = ($: EngineInterface, id: string): 'pushed' | 'sending' | undefined => {
239 const index = indexOfId(id)
240 if (index < 0) return
241 if (!turnId) {
242 moveAt(index, 0)
243 send($)
244 return 'sending'
245 }
246 if (editing?.id === id) editing = null
247 const entry = removeAt(index)!
248 steer.push(entry)
249 // the push has no row of its own until a tool call carries it: say so now
250 $.ui.log(`❯ ${entry.text} · into the turn at its next tool call`)
251 return 'pushed'
252}
253
254const bandOf = ($: EngineInterface, e: RenderInput<'AbovePrompt'> & { surface: 'terminal' }): RenderElement => {
255 const { Box, Text, Button, Input } = $.ui.resolve(e)
256 bandRequest = e.requestId
257 const gutter = String(stack.length).length + 1
258 const room = Math.max(8, e.props.bodyColumns - BORDER - gutter - CONTROLS)
259 const steerRoom = Math.max(8, e.props.bodyColumns - BORDER - gutter - STEER_CONTROLS)
260 const press = (run: () => void) => () => {
261 run()
262 $.ui.invalidate('ui.render')
263 }
264 // the open row's own number is the only mark a field being typed in gets
265 const number = (index: number, open = false) => {
266 const n = String(index + 1).padStart(gutter - 1)
267 return Text(open ? { bold: true, children: n } : { dimColor: true, children: n })
268 }
269 // dim at rest so the rows read as text; the pointer over a row brings that
270 // row's controls up to full strength, the keyed row Box being the scope
271 const quiet = { dimColor: true, hover: { dimColor: false } } as const
272 // the two that move things along keep full strength, and take the accent on hover
273 const go = { hover: { color: ACCENT } } as const
274 // an end of the stack keeps the column, so the buttons beside it stay in line
275 const move = (entry: Entry, index: number, delta: number, key: string, label: string) =>
276 index + delta < 0 || index + delta >= stack.length
277 ? Box({ width: 5 })
278 : Button({
279 key: `${key}:${entry.id}`,
280 label,
281 ...quiet,
282 // by id and by step: the stack may have moved under the drawing
283 onPress: press(() => void moveAt(indexOfId(entry.id), indexOfId(entry.id) + delta)),
284 })
285 const [word, moving] = state()
286 return Box({
287 flexDirection: 'column',
288 borderStyle: 'round',
289 borderDimColor: true,
290 children: [
291 // the count is what the eye goes to; the rest of the line is chrome
292 Box({
293 flexDirection: 'row',
294 gap: 1,
295 children: [
296 Text({ dimColor: true, children: 'queued' }),
297 Text({ dimColor: true, children: '·' }),
298 Text({ bold: true, children: String(stack.length + steer.length) }),
299 Text({ dimColor: true, children: '·' }),
300 Text(moving ? { color: ACCENT, children: word } : { dimColor: true, children: word }),
301 ],
302 }),
303 // first out either way: into the next tool result, or ahead of the stack
304 ...steer.map(entry =>
305 Box({
306 key: `steer:${entry.id}`,
307 flexDirection: 'row',
308 gap: 1,
309 children: [
310 Text({ color: ACCENT, children: '▶'.padStart(gutter - 1) }),
311 Box({ width: steerRoom, children: Text({ wrap: 'truncate', children: fit(firstLine(entry.text), steerRoom) }) }),
312 Text({ dimColor: true, children: MARK }),
313 Button({ key: `unsteer:${entry.id}`, label: '✕', ...quiet, onPress: press(() => removeSteer(entry.id)) }),
314 ],
315 }),
316 ),
317 ...stack.map((entry, index) =>
318 editing?.id === entry.id
319 ? Box({
320 key: `row:${entry.id}`,
321 flexDirection: 'row',
322 gap: 1,
323 children: [
324 number(index, true),
325 Input({
326 key: `input:${entry.id}`,
327 value: editing.text,
328 autoFocus: true,
329 submitLabel: 'keep',
330 onInput: value => void (editing && (editing.text = value)),
331 onSubmit: value => commitEdit($, value),
332 }),
333 // Esc gives the keys back to the composer without a word to the
334 // plugin: this is the way out that leaves the entry as it was
335 Button({ key: `stop:${entry.id}`, label: '✕', ...quiet, onPress: press(() => void (editing = null)) }),
336 ],
337 })
338 : Box({
339 key: `row:${entry.id}`,
340 flexDirection: 'row',
341 gap: 1,
342 children: [
343 number(index),
344 Box({ width: room, children: Text({ wrap: 'truncate', children: fit(firstLine(entry.text), room) }) }),
345 move(entry, index, -1, 'up', '↑'),
346 move(entry, index, 1, 'down', '↓'),
347 Button({ key: `now:${entry.id}`, label: '▶', ...go, onPress: press(() => sendNow($, entry.id)) }),
348 Button({ key: `edit:${entry.id}`, label: 'edit', ...quiet, onPress: () => startEdit($, entry) }),
349 Button({ key: `rm:${entry.id}`, label: '✕', ...quiet, onPress: press(() => void removeId(entry.id)) }),
350 ],
351 }),
352 ),
353 // the stack's own controls start where a row's do, so the whole right
354 // side of the band is one column of buttons
355 Box({
356 key: 'all',
357 flexDirection: 'row',
358 paddingLeft: gutter + room + 1,
359 gap: 1,
360 children: [
361 ...(turnId ? [] : [Button({ key: 'send', label: 'send', ...go, onPress: press(() => send($)) })]),
362 Button({ key: 'clear', label: 'clear', ...quiet, onPress: press(() => void ((stack = []), (steer = []), (editing = null))) }),
363 ],
364 }),
365 // a click that lands nowhere reads as a button that did nothing
366 ...(fullscreen === false ? [Box({ paddingLeft: gutter, children: Text({ dimColor: true, children: NO_MOUSE }) })] : []),
367 ],
368 })
369}
370
371const listed = () => {
372 const lines = [
373 ...steer.map(entry => `▶ ${fit(firstLine(entry.text), 72)} · ${MARK}`),
374 ...stack.map((entry, i) => `${i + 1}. ${fit(firstLine(entry.text), 72)}`),
375 ]
376 return [...(lines.length === 0 ? ['queue: nothing held'] : lines), statusLine()].join('\n')
377}
378
379const indexArg = (word: string): number | null => {
380 const n = Number(word)
381 return Number.isInteger(n) && n >= 1 && n <= stack.length ? n - 1 : null
382}
383
384export const register: Register = (on, options) => {
385 joined = options.joined === true
386
387 on('session.start', async ($, e, next) => {
388 const r = await next(e)
389 await $.command
390 .register({
391 name: COMMAND,
392 description: 'Hold a prompt until the turn ends: /q <text>; list, up/down/mv, now, rm, edit, clear, send, status (claude-queue)',
393 argumentHint: '[<text> | up <n> | down <n> | mv <n> <m> | now <n> | rm <n> | edit <n> | clear | send | status]',
394 // the queue is worked on while a turn runs, which is the only time it fills
395 immediate: true,
396 })
397 .catch(err => $.ui.log(`queue: /${COMMAND} not registered: ${err}`))
398 return r
399 })
400
401 on('turn.start', async ($, e, next) => {
402 turnId = e.turnId
403 // the band reads the running turn: without this it keeps saying `sending`,
404 // [ send ] and all, over the turn the queue itself started
405 if (stack.length > 0) $.ui.invalidate('ui.render')
406 return next(e)
407 })
408
409 on('turn.complete', async ($, e, next) => {
410 const r = await next(e)
411 // a subagent's turn ends inside the session's own: not a moment to send
412 if (e.agentId) return r
413 turnId = undefined
414 if (drainedTurn === e.turnId) return r
415 drainedTurn = e.turnId
416 // the turn called no tool after all: what was pushed into it leads the drain
417 if (steer.length > 0) stack.unshift(...steer.splice(0))
418 // a turn that left work running in the background is a pause, not an end:
419 // the notification that wakes it starts the turn whose end drains
420 if (inFlight > 0) {
421 $.ui.invalidate('ui.render')
422 return r
423 }
424 // every ending drains, an Esc included: what was typed was asked for
425 send($)
426 return r
427 })
428
429 // The engine's own list of what is still running in the background, on the
430 // classic Stop hook's input: the one place that says whether a turn's end
431 // is the work's end. Stop runs before turn.complete.
432 on('classic.Stop', async ($, e, next) => {
433 inFlight = e.background_tasks?.length ?? 0
434 return next(e)
435 })
436
437 // The steer route: a mid-turn message rides the next tool result's context,
438 // which is how the engine delivers one. The object `next` gave us goes back
439 // whole, `ref` and all, so the tool's own messages are used verbatim.
440 on('tool.call', async ($, e, next) => {
441 const r = await next(e)
442 // a subagent's call runs inside the turn but is not it
443 if (steer.length === 0 || e.agentId || r.deny !== undefined) return r
444 const context = [...(r.context ?? [])]
445 let used = context.reduce((n, text) => n + text.length, 0)
446 const going = steer.splice(0)
447 for (const entry of going) {
448 const framed = frame(entry)
449 if (used + framed.length > CONTEXT_MAX) {
450 steer.push(entry)
451 continue
452 }
453 used += framed.length
454 context.push(framed)
455 $.ui.log(`pushed into the turn · ${fit(firstLine(entry.text), 72)}`)
456 }
457 if (steer.length > 0) $.ui.log(`queue: ${steer.length} too long for a tool result · goes when the turn ends`)
458 $.ui.invalidate('ui.render')
459 return context.length === (r.context?.length ?? 0) ? r : { ...r, context }
460 })
461
462 // Enter is left to the engine. Only the turn id is read off it: a reload
463 // mid-turn missed turn.start, and this Enter carries the id it lost.
464 on('prompt.submit', async ($, e, next) => {
465 if (e.turnId) turnId = e.turnId
466 return next(e)
467 })
468
469 // Esc, or the ring moving on, leaves the row as it was: the field is only
470 // the person's while they are in it. Not before it has had the ring — a
471 // click on [ edit ] lands the ring on the button first.
472 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
473 const r = await next(e)
474 if (!editing) return r
475 if (e.element === `input:${editing.id}`) editFocused = true
476 else if (editFocused) {
477 editing = null
478 $.ui.invalidate('ui.render')
479 }
480 return r
481 })
482
483 on('command.run', { command: COMMAND }, async ($, e) => {
484 fullscreen = e.presentation.isFullscreen
485 const [word = '', value = '', target = ''] = e.args.trim().split(/\s+/)
486 if (word === '') return { text: listed() }
487 if (word === 'status') return { text: statusLine() }
488 if (word === 'clear') {
489 const n = stack.length + steer.length
490 stack = []
491 steer = []
492 editing = null
493 $.ui.invalidate('ui.render')
494 return { text: `queue: ${n} cleared` }
495 }
496 if (word === 'up' || word === 'down' || word === 'mv' || word === 'now' || word === 'rm' || word === 'edit') {
497 if (stack.length === 0) return { text: 'queue: nothing held' }
498 const index = indexArg(value)
499 if (index === null) return { text: `queue: /q ${word} <n>${word === 'mv' ? ' <m>' : ''}, 1 to ${stack.length}` }
500 if (word === 'rm') {
501 removeAt(index)
502 $.ui.invalidate('ui.render')
503 return { text: `queue: ${index + 1} removed · ${stack.length} left` }
504 }
505 if (word === 'now') {
506 const entry = stack[index]!
507 const how = sendNow($, entry.id)
508 $.ui.invalidate('ui.render')
509 return { text: how === 'pushed' ? `queue: ${index + 1} goes into the turn` : `queue: ${index + 1} is first · sending` }
510 }
511 if (word !== 'edit') {
512 const to = word === 'mv' ? indexArg(target) : index + (word === 'up' ? -1 : 1)
513 if (to === null) return { text: `queue: /q mv <n> <m>, 1 to ${stack.length}` }
514 if (!moveAt(index, to)) return { text: `queue: ${index + 1} is already ${to === index ? 'there' : to < 0 ? 'first' : 'last'}` }
515 $.ui.invalidate('ui.render')
516 return { text: `queue: ${index + 1} is now ${to + 1}` }
517 }
518 const entry = stack[index]!
519 // the await is long enough for another prompt to have moved the entry
520 // under the index, so the fill takes it out by id
521 if (!(await fillFrom($, entry))) return { text: `queue: the prompt box is busy · ${index + 1} is still held` }
522 return { text: `queue: ${index + 1} is in the prompt box` }
523 }
524 if (word === 'send') {
525 if (stack.length === 0) return { text: 'queue: nothing held' }
526 if (turnId) return { text: `queue: ${stack.length} waiting · they go when the turn ends` }
527 send($)
528 return { text: `queue: sending ${joined ? `all ${stack.length}` : `the first of ${stack.length}`}` }
529 }
530 if (word === 'help') return { text: 'queue: /q <text> · up <n> · down <n> · mv <n> <m> · now <n> · rm <n> · edit <n> · clear · send · status' }
531 // anything else is the prompt to hold
532 stack.push({ id: `e${++counter}`, text: e.args.trim() })
533 $.ui.invalidate('ui.render')
534 if (turnId) return { text: `queue: held · ${stack.length} waiting · sent when the turn ends` }
535 if (inFlight > 0) return { text: `queue: held · ${stack.length} waiting · sent when the background work ends` }
536 send($)
537 return { text: `queue: nothing is running · sending${stack.length > 1 ? ` · ${stack.length} waiting` : ''}` }
538 })
539
540 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
541 if (e.surface !== 'terminal' || e.props.hasSurvey || stack.length + steer.length === 0) return next(e)
542 // The backstop: a stack found idle goes out rather than sitting here. By
543 // our own count of the turn alone: `isWorking` reads false while a tool
544 // runs under the fullscreen renderer, so it cannot clear `turnId`. Deferred
545 // past this dispatch, from which a submit would be refused.
546 if (!turnId && inFlight === 0 && !e.props.isWorking && !pending && failures < RETRIES) pending = $.clock.after(0, () => flush($))
547 return bandOf($, e)
548 })
549}
550