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.
[ ▶ ] (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-queue-plugin
cd claude-queue-plugin
claude --plugin-dir .
Or install it, the repository being its own marketplace:
claude plugin marketplace add galElmalah/claude-queue-plugin
claude plugin install claude-queue@claude-queue-plugin
/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 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 |
/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.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, /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, and joined. 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 507 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
55const firstLine = (text: string) => text.trim().split('\n')[0]?.trim() ?? ''
56
57/** the raw first line, which the field edits, and the rest it leaves alone */
58const head = (text: string) => text.split('\n')[0] ?? ''
59const withHead = (text: string, line: string) => [line, ...text.split('\n').slice(1)].join('\n')
60
61const fit = (text: string, room: number) => {
62 const chars = [...text]
63 return chars.length <= room ? text : `${chars.slice(0, Math.max(1, room - 1)).join('')}…`
64}
65
66/** what the header says, and whether that is a stack on the move: the accent's cue */
67const state = (): [word: string, moving: boolean] =>
68 steer.length > 0 && turnId
69 ? ['going into the turn', true]
70 : stack.length === 0
71 ? ['nothing held', false]
72 : turnId
73 ? ['sent when the turn ends', false]
74 : pending
75 ? ['going out', true]
76 : ['sending', true]
77
78/** one line for a "it did not send" report: every flag the drain reads */
79const statusLine = () =>
80 `turn ${turnId ? 'running' : 'idle'} · ${stack.length} held${steer.length > 0 ? ` · ${steer.length} going into the turn` : ''} · ${pending && !turnId ? 'going out' : 'waiting'}`
81
82const indexOfId = (id: string) => stack.findIndex(entry => entry.id === id)
83
84const removeAt = (index: number): Entry | undefined => {
85 const [gone] = stack.splice(index, 1)
86 return gone
87}
88
89// a press acts on the drawing it was drawn in, which the stack may have moved under
90const removeId = (id: string): Entry | undefined => {
91 const index = indexOfId(id)
92 return index < 0 ? undefined : removeAt(index)
93}
94
95/** entry `from` lands at `to`, the rest closing up behind it */
96const moveAt = (from: number, to: number) => {
97 if (from < 0 || to < 0 || to >= stack.length || from === to) return false
98 const [entry] = stack.splice(from, 1)
99 stack.splice(to, 0, entry!)
100 return true
101}
102
103/** the next prompt out: the first entry, or the whole stack under `joined` */
104const takeDue = (): Entry[] => (joined ? stack.splice(0) : stack.splice(0, 1))
105
106// Synchronous up to the submit itself, so two sends landing together (a
107// turn's end and a press) cannot both take an entry: the second one sees
108// sentAt and waits the gap out. The submit is not awaited: it resolves only
109// once the prompt entered, which is after the hook that asked for it answered.
110const flush = ($: EngineInterface) => {
111 pending = null
112 if (stack.length === 0) return
113 // a turn started under the timer: nothing is sent into one, and its own end
114 // arms the next drain
115 if (turnId) return
116 const wait = GAP_MS - (Date.now() - sentAt)
117 if (wait > 0) {
118 pending = $.clock.after(wait, () => flush($))
119 $.ui.invalidate('ui.render')
120 return
121 }
122 const going = takeDue()
123 sentAt = Date.now()
124 if (editing && indexOfId(editing.id) < 0) editing = null
125 $.ui.invalidate('ui.render')
126 $.prompt
127 .submit({ text: going.map(entry => entry.text).join('\n\n') })
128 .then(() => void (failures = 0))
129 .catch(err => {
130 // back as they were, ids and all, so the band's buttons still find them
131 stack.unshift(...going)
132 $.ui.log(`queue: the held prompt did not go out: ${err}`)
133 // a refusal is usually the gap: try once it has passed; several in a row
134 // (the session's cap on a plugin's prompts) leave the stack to /q send
135 if (++failures < RETRIES) pending = $.clock.after(GAP_MS, () => flush($))
136 $.ui.invalidate('ui.render')
137 })
138}
139
140// Off the raising hook's dispatch: a submit from inside command.run, ui.press
141// or ui.render is refused, as it would wait on the turn that hook holds.
142const send = ($: EngineInterface) => {
143 failures = 0
144 if (!pending) pending = $.clock.after(0, () => flush($))
145}
146
147// out of the stack only once the box has it: a fill the box turned down (a
148// dialog holds the keys) would otherwise lose the text
149const fillFrom = ($: EngineInterface, entry: Entry) =>
150 $.prompt
151 .fill({ text: entry.text })
152 .then(({ isFilled }) => {
153 if (!isFilled) return false
154 // the fill's round trip is long enough for a drain to have taken it
155 const kept = removeId(entry.id) !== undefined
156 $.ui.invalidate('ui.render')
157 return kept
158 })
159 .catch(() => false)
160
161// The row becomes a field, and the band's ring moves onto it — which the
162// engine allows only while the band holds the keyboard (a click on it, ctrl+x
163// tab). Where it does not, the composer takes the text as it always did.
164const startEdit = ($: EngineInterface, entry: Entry) => {
165 editing = { id: entry.id, text: head(entry.text) }
166 editFocused = false
167 $.ui.invalidate('ui.render')
168 takeField($, entry, FOCUS_TRIES)
169}
170
171// The ring only lands on an element the band has already drawn, so the first
172// try, a tick after the invalidate, is usually too early for the frame.
173const takeField = ($: EngineInterface, entry: Entry, tries: number) => {
174 const open = editing
175 if (!open || !bandRequest) return
176 void $.ui.focus({ requestId: bandRequest, key: `input:${open.id}` }).then(({ deny }) => {
177 if (editing !== open) return
178 // the engine skips the ui.focus a plugin raised itself (re-entry), so the
179 // ring reaching the field is this answer and not the event
180 if (!deny) return void (editFocused = true)
181 if (tries > 0) return void $.clock.after(FOCUS_MS, () => takeField($, entry, tries - 1))
182 // the ordinary case (a click does not give the band the keyboard): the
183 // fill says what happened by itself, so nothing is written to the transcript
184 editing = null
185 $.ui.invalidate('ui.render')
186 void fillFrom($, entry)
187 })
188}
189
190const commitEdit = ($: EngineInterface, value: string) => {
191 const open = editing
192 editing = null
193 $.ui.invalidate('ui.render')
194 if (!open) return
195 const entry = stack[indexOfId(open.id)]
196 // an emptied field is a cancel: the entry keeps the text it had
197 if (entry && value.trim() !== '') entry.text = withHead(entry.text, value)
198}
199
200/** what the model reads beside the tool result, the message framed as the user's */
201const frame = (entry: Entry) =>
202 `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}`
203
204const removeSteer = (id: string) => {
205 const index = steer.findIndex(entry => entry.id === id)
206 if (index >= 0) steer.splice(index, 1)
207}
208
209/** mid-turn it rides the turn's next tool result; idle it goes to the front and out */
210const sendNow = ($: EngineInterface, id: string): 'pushed' | 'sending' | undefined => {
211 const index = indexOfId(id)
212 if (index < 0) return
213 if (!turnId) {
214 moveAt(index, 0)
215 send($)
216 return 'sending'
217 }
218 if (editing?.id === id) editing = null
219 const entry = removeAt(index)!
220 steer.push(entry)
221 // the push has no row of its own until a tool call carries it: say so now
222 $.ui.log(`❯ ${entry.text} · into the turn at its next tool call`)
223 return 'pushed'
224}
225
226const bandOf = ($: EngineInterface, e: RenderInput<'AbovePrompt'> & { surface: 'terminal' }): RenderElement => {
227 const { Box, Text, Button, Input } = $.ui.resolve(e)
228 bandRequest = e.requestId
229 const gutter = String(stack.length).length + 1
230 const room = Math.max(8, e.props.bodyColumns - BORDER - gutter - CONTROLS)
231 const steerRoom = Math.max(8, e.props.bodyColumns - BORDER - gutter - STEER_CONTROLS)
232 const press = (run: () => void) => () => {
233 run()
234 $.ui.invalidate('ui.render')
235 }
236 // the open row's own number is the only mark a field being typed in gets
237 const number = (index: number, open = false) => {
238 const n = String(index + 1).padStart(gutter - 1)
239 return Text(open ? { bold: true, children: n } : { dimColor: true, children: n })
240 }
241 // dim at rest so the rows read as text; the pointer over a row brings that
242 // row's controls up to full strength, the keyed row Box being the scope
243 const quiet = { dimColor: true, hover: { dimColor: false } } as const
244 // the two that move things along keep full strength, and take the accent on hover
245 const go = { hover: { color: ACCENT } } as const
246 // an end of the stack keeps the column, so the buttons beside it stay in line
247 const move = (entry: Entry, index: number, delta: number, key: string, label: string) =>
248 index + delta < 0 || index + delta >= stack.length
249 ? Box({ width: 5 })
250 : Button({
251 key: `${key}:${entry.id}`,
252 label,
253 ...quiet,
254 // by id and by step: the stack may have moved under the drawing
255 onPress: press(() => void moveAt(indexOfId(entry.id), indexOfId(entry.id) + delta)),
256 })
257 const [word, moving] = state()
258 return Box({
259 flexDirection: 'column',
260 borderStyle: 'round',
261 borderDimColor: true,
262 children: [
263 // the count is what the eye goes to; the rest of the line is chrome
264 Box({
265 flexDirection: 'row',
266 gap: 1,
267 children: [
268 Text({ dimColor: true, children: 'queued' }),
269 Text({ dimColor: true, children: '·' }),
270 Text({ bold: true, children: String(stack.length + steer.length) }),
271 Text({ dimColor: true, children: '·' }),
272 Text(moving ? { color: ACCENT, children: word } : { dimColor: true, children: word }),
273 ],
274 }),
275 // first out either way: into the next tool result, or ahead of the stack
276 ...steer.map(entry =>
277 Box({
278 key: `steer:${entry.id}`,
279 flexDirection: 'row',
280 gap: 1,
281 children: [
282 Text({ color: ACCENT, children: '▶'.padStart(gutter - 1) }),
283 Box({ width: steerRoom, children: Text({ wrap: 'truncate', children: fit(firstLine(entry.text), steerRoom) }) }),
284 Text({ dimColor: true, children: MARK }),
285 Button({ key: `unsteer:${entry.id}`, label: '✕', ...quiet, onPress: press(() => removeSteer(entry.id)) }),
286 ],
287 }),
288 ),
289 ...stack.map((entry, index) =>
290 editing?.id === entry.id
291 ? Box({
292 key: `row:${entry.id}`,
293 flexDirection: 'row',
294 gap: 1,
295 children: [
296 number(index, true),
297 Input({
298 key: `input:${entry.id}`,
299 value: editing.text,
300 autoFocus: true,
301 submitLabel: 'keep',
302 onInput: value => void (editing && (editing.text = value)),
303 onSubmit: value => commitEdit($, value),
304 }),
305 // Esc gives the keys back to the composer without a word to the
306 // plugin: this is the way out that leaves the entry as it was
307 Button({ key: `stop:${entry.id}`, label: '✕', ...quiet, onPress: press(() => void (editing = null)) }),
308 ],
309 })
310 : Box({
311 key: `row:${entry.id}`,
312 flexDirection: 'row',
313 gap: 1,
314 children: [
315 number(index),
316 Box({ width: room, children: Text({ wrap: 'truncate', children: fit(firstLine(entry.text), room) }) }),
317 move(entry, index, -1, 'up', '↑'),
318 move(entry, index, 1, 'down', '↓'),
319 Button({ key: `now:${entry.id}`, label: '▶', ...go, onPress: press(() => sendNow($, entry.id)) }),
320 Button({ key: `edit:${entry.id}`, label: 'edit', ...quiet, onPress: () => startEdit($, entry) }),
321 Button({ key: `rm:${entry.id}`, label: '✕', ...quiet, onPress: press(() => void removeId(entry.id)) }),
322 ],
323 }),
324 ),
325 // the stack's own controls start where a row's do, so the whole right
326 // side of the band is one column of buttons
327 Box({
328 key: 'all',
329 flexDirection: 'row',
330 paddingLeft: gutter + room + 1,
331 gap: 1,
332 children: [
333 ...(turnId ? [] : [Button({ key: 'send', label: 'send', ...go, onPress: press(() => send($)) })]),
334 Button({ key: 'clear', label: 'clear', ...quiet, onPress: press(() => void ((stack = []), (steer = []), (editing = null))) }),
335 ],
336 }),
337 // a click that lands nowhere reads as a button that did nothing
338 ...(fullscreen === false ? [Box({ paddingLeft: gutter, children: Text({ dimColor: true, children: NO_MOUSE }) })] : []),
339 ],
340 })
341}
342
343const listed = () => {
344 const lines = [
345 ...steer.map(entry => `▶ ${fit(firstLine(entry.text), 72)} · ${MARK}`),
346 ...stack.map((entry, i) => `${i + 1}. ${fit(firstLine(entry.text), 72)}`),
347 ]
348 return [...(lines.length === 0 ? ['queue: nothing held'] : lines), statusLine()].join('\n')
349}
350
351const indexArg = (word: string): number | null => {
352 const n = Number(word)
353 return Number.isInteger(n) && n >= 1 && n <= stack.length ? n - 1 : null
354}
355
356export const register: Register = (on, options) => {
357 joined = options.joined === true
358
359 on('session.start', async ($, e, next) => {
360 const r = await next(e)
361 await $.command
362 .register({
363 name: COMMAND,
364 description: 'Hold a prompt until the turn ends: /q <text>; list, up/down/mv, now, rm, edit, clear, send, status (claude-queue)',
365 argumentHint: '[<text> | up <n> | down <n> | mv <n> <m> | now <n> | rm <n> | edit <n> | clear | send | status]',
366 // the queue is worked on while a turn runs, which is the only time it fills
367 immediate: true,
368 })
369 .catch(err => $.ui.log(`queue: /${COMMAND} not registered: ${err}`))
370 return r
371 })
372
373 on('turn.start', async ($, e, next) => {
374 turnId = e.turnId
375 // the band reads the running turn: without this it keeps saying `sending`,
376 // [ send ] and all, over the turn the queue itself started
377 if (stack.length > 0) $.ui.invalidate('ui.render')
378 return next(e)
379 })
380
381 on('turn.complete', async ($, e, next) => {
382 const r = await next(e)
383 // a subagent's turn ends inside the session's own: not a moment to send
384 if (e.agentId) return r
385 turnId = undefined
386 if (drainedTurn === e.turnId) return r
387 drainedTurn = e.turnId
388 // the turn called no tool after all: what was pushed into it leads the drain
389 if (steer.length > 0) stack.unshift(...steer.splice(0))
390 // every ending drains, an Esc included: what was typed was asked for
391 send($)
392 return r
393 })
394
395 // The steer route: a mid-turn message rides the next tool result's context,
396 // which is how the engine delivers one. The object `next` gave us goes back
397 // whole, `ref` and all, so the tool's own messages are used verbatim.
398 on('tool.call', async ($, e, next) => {
399 const r = await next(e)
400 // a subagent's call runs inside the turn but is not it
401 if (steer.length === 0 || e.agentId || r.deny !== undefined) return r
402 const context = [...(r.context ?? [])]
403 let used = context.reduce((n, text) => n + text.length, 0)
404 const going = steer.splice(0)
405 for (const entry of going) {
406 const framed = frame(entry)
407 if (used + framed.length > CONTEXT_MAX) {
408 steer.push(entry)
409 continue
410 }
411 used += framed.length
412 context.push(framed)
413 $.ui.log(`pushed into the turn · ${fit(firstLine(entry.text), 72)}`)
414 }
415 if (steer.length > 0) $.ui.log(`queue: ${steer.length} too long for a tool result · goes when the turn ends`)
416 $.ui.invalidate('ui.render')
417 return context.length === (r.context?.length ?? 0) ? r : { ...r, context }
418 })
419
420 // Enter is left to the engine. Only the turn id is read off it: a reload
421 // mid-turn missed turn.start, and this Enter carries the id it lost.
422 on('prompt.submit', async ($, e, next) => {
423 if (e.turnId) turnId = e.turnId
424 return next(e)
425 })
426
427 // Esc, or the ring moving on, leaves the row as it was: the field is only
428 // the person's while they are in it. Not before it has had the ring — a
429 // click on [ edit ] lands the ring on the button first.
430 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
431 const r = await next(e)
432 if (!editing) return r
433 if (e.element === `input:${editing.id}`) editFocused = true
434 else if (editFocused) {
435 editing = null
436 $.ui.invalidate('ui.render')
437 }
438 return r
439 })
440
441 on('command.run', { command: COMMAND }, async ($, e) => {
442 fullscreen = e.presentation.isFullscreen
443 const [word = '', value = '', target = ''] = e.args.trim().split(/\s+/)
444 if (word === '') return { text: listed() }
445 if (word === 'status') return { text: statusLine() }
446 if (word === 'clear') {
447 const n = stack.length + steer.length
448 stack = []
449 steer = []
450 editing = null
451 $.ui.invalidate('ui.render')
452 return { text: `queue: ${n} cleared` }
453 }
454 if (word === 'up' || word === 'down' || word === 'mv' || word === 'now' || word === 'rm' || word === 'edit') {
455 if (stack.length === 0) return { text: 'queue: nothing held' }
456 const index = indexArg(value)
457 if (index === null) return { text: `queue: /q ${word} <n>${word === 'mv' ? ' <m>' : ''}, 1 to ${stack.length}` }
458 if (word === 'rm') {
459 removeAt(index)
460 $.ui.invalidate('ui.render')
461 return { text: `queue: ${index + 1} removed · ${stack.length} left` }
462 }
463 if (word === 'now') {
464 const entry = stack[index]!
465 const how = sendNow($, entry.id)
466 $.ui.invalidate('ui.render')
467 return { text: how === 'pushed' ? `queue: ${index + 1} goes into the turn` : `queue: ${index + 1} is first · sending` }
468 }
469 if (word !== 'edit') {
470 const to = word === 'mv' ? indexArg(target) : index + (word === 'up' ? -1 : 1)
471 if (to === null) return { text: `queue: /q mv <n> <m>, 1 to ${stack.length}` }
472 if (!moveAt(index, to)) return { text: `queue: ${index + 1} is already ${to === index ? 'there' : to < 0 ? 'first' : 'last'}` }
473 $.ui.invalidate('ui.render')
474 return { text: `queue: ${index + 1} is now ${to + 1}` }
475 }
476 const entry = stack[index]!
477 // the await is long enough for another prompt to have moved the entry
478 // under the index, so the fill takes it out by id
479 if (!(await fillFrom($, entry))) return { text: `queue: the prompt box is busy · ${index + 1} is still held` }
480 return { text: `queue: ${index + 1} is in the prompt box` }
481 }
482 if (word === 'send') {
483 if (stack.length === 0) return { text: 'queue: nothing held' }
484 if (turnId) return { text: `queue: ${stack.length} waiting · they go when the turn ends` }
485 send($)
486 return { text: `queue: sending ${joined ? `all ${stack.length}` : `the first of ${stack.length}`}` }
487 }
488 if (word === 'help') return { text: 'queue: /q <text> · up <n> · down <n> · mv <n> <m> · now <n> · rm <n> · edit <n> · clear · send · status' }
489 // anything else is the prompt to hold
490 stack.push({ id: `e${++counter}`, text: e.args.trim() })
491 $.ui.invalidate('ui.render')
492 if (turnId) return { text: `queue: held · ${stack.length} waiting · sent when the turn ends` }
493 send($)
494 return { text: `queue: nothing is running · sending${stack.length > 1 ? ` · ${stack.length} waiting` : ''}` }
495 })
496
497 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
498 if (e.surface !== 'terminal' || e.props.hasSurvey || stack.length + steer.length === 0) return next(e)
499 // The backstop: a stack found idle goes out rather than sitting here. By
500 // our own count of the turn alone: `isWorking` reads false while a tool
501 // runs under the fullscreen renderer, so it cannot clear `turnId`. Deferred
502 // past this dispatch, from which a submit would be refused.
503 if (!turnId && !e.props.isWorking && !pending && failures < RETRIES) pending = $.clock.after(0, () => flush($))
504 return bandOf($, e)
505 })
506}
507