Adds /queue, which holds messages until Claude is completely finished (turn, subagents, background tasks) and then sends them in order.

Send a message after Claude finishes what it is doing.
A message you type while Claude is working goes into the turn that is running. /queue <message> holds the message instead and sends it only when Claude is done, including any subagents and background tasks still working. You can line up the next step, or several, while a long task runs.
In action:

The band above the prompt, with the row buttons revealed on hover:

A longer message in the band:

While Claude works and nothing is queued, the band is just the message field:

At the prompt of a terminal session:
/plugin install queue --marketplace kraevski/claude-mods
Answer y to add the marketplace, then pick a scope. The mod is active at once, with no restart.
From a shell:
claude plugin install queue --marketplace kraevski/claude-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with kraevski/claude-mods, then Add plugin.
/queue run the tests and summarize any failures
Under your /queue line, a colored Queued #1 pill shows the message's place and what it waits for. Once the message goes out, the pill turns to ✓ Sent. It is sent as your own words. /queue with no message shows its usage line.
The band above the prompt shows how many messages wait and what they wait for. It lists the first three, and counts the rest. Each row has Send now and Remove. Point at a row to get ↑, ↓ and Edit as well. Pointing at a Queued #n pill gives Send now, Edit and Remove too. The band is hidden when nothing waits, unless you paused the queue. Other plugins' bands stay in view, under the queue's rows.
In the desktop app, the band and the pill are drawn in the app's own type and colors, light or dark: the band's header shows a slot per waiting message, and each pill shows its message's slot among them. A border frames the band: accent while messages wait, amber while paused.
In the desktop app, text typed while Claude works is held until the turn ends, /queue included. So the band shows a "Queue a message" field while Claude works, alone when nothing is queued. It grows from one line to two as you type. Enter there queues the message at once. Edit there opens the message in place, in its band row or its reply row, instead of filling the prompt box.
Queue as many messages as you like. They go out one at a time, in the order you queued them.
<n> is a message's position among the waiting messages. @<id> names it by its id instead, which stays the same as the positions change. /queue edit uses ids.
/queue list shows the waiting messages, numbered./queue rm <n> removes a message./queue clear removes every waiting message./queue pause and /queue resume hold and release the queue./queue send <n> sends a message now, out of turn. See below./queue edit <n> [text] replaces a message's text. Without text, it puts /queue edit @<id> <text> in the prompt box, for you to change and press Enter./queue up <n> and /queue down <n> move a message one place.To queue a word that is also a command, put -- before it: /queue -- list queues the text "list". Without --, /queue rm with no number, or /queue list now, queues its text as a message.
/queue send <n>, or the band's Send now, sends a message out of turn.
In the prompt box until you press Enter, then ✓ Sent./queue resume sends it again; /queue rm drops it./queue /name args queues a slash command. It runs in its place in the queue, and its turn holds the messages behind it until that turn ends. A command that does not start a turn, such as /compact, runs and the queue goes on. An unknown command, and /queue /queue, are refused when you queue them, and nothing is queued.
The joined option sends the leading waiting messages as one message, separated by blank lines. It stops at the first queued slash command. It is off by default.
Turn it on in /config, in the row Join queued messages under this plugin. A change reloads the plugin, and waiting messages stay queued.
The queue pauses when:
/clear brings messages back (see How it works);/queue pause.The band says why, and shows Resume while paused. /queue resume or Resume continues. Emptying the queue ends a pause from an interrupt, an error, a refusal, a failed command, a message Claude Code did not take, or a restore. It does not end a /queue pause.
A queued message goes out only when Claude is completely finished: the main turn, every subagent still listed as running, and every background task (shell commands and the like) that the Stop event reports.
rm drops it.claude --resume or claude --continue of the same conversation, or /clear in the same window, the waiting messages come back paused. A message that was mid-send is flagged "may have been sent". Saved queues not written for 7 days are dropped.claude starts with an empty queue./reload-plugins keeps the live queue as it is./queue, and sends it to Claude in the same session, as your own words. A queued slash command is run instead./queue rows, queued text included. The plugin tells it not to act on that text until it's sent. In live checks it didn't act on it early, but it may still mention queued messages, and it can't see removals made from the band. A plugin can't hide the rows.claude plugin validate lists the Stop hook as a gating hook without a .catch. Validation still passes, and this is harmless.✓ Sent, even if you changed or replaced its text in the box first.Load the folder for one session:
claude --plugin-dir .
Check and test it from the repository root:
claude plugin validate .
claude plugin test .
Type-check with the declarations Claude Code writes beside the mod when it loads from a folder:
npx -p typescript tsc -p .
MIT. See LICENSE.
hooks/register.tsx 910 lines1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { InlineEdit, QueueItem, QueueState } from '../types'
5import type { Ref } from './command'
6import { parse, refText } from './command'
7import type { Pill, Tone } from './draw'
8import { HEADER_H, PILL_H, ROW_H, headerSvg, pillSvg, rowSvg } from './draw'
9import {
10 add,
11 claimBatch,
12 claimNow,
13 clearQueued,
14 droppedIds,
15 dropItem,
16 editItem,
17 findQueued,
18 initialState,
19 isOpen,
20 isQueued,
21 isSaved,
22 isStale,
23 markCommandDone,
24 markCommandFailed,
25 markCommandTurn,
26 markDropped,
27 markFailed,
28 markSent,
29 moveItem,
30 moveToBox,
31 newId,
32 nextItem,
33 onPersonSubmit,
34 onStop,
35 onTurnComplete,
36 onTurnStart,
37 queuedItems,
38 resume,
39 restoredState,
40 setPaused,
41 splitCommand,
42 toSaved,
43} from './queue'
44
45const USAGE = 'Usage: /queue <message>. The message is sent after the current turn finishes.'
46
47// The engine has no way to hide a command's row from the model, so this context note is the only control.
48// Without it, the model reads queued text and may act on it before the queue releases it.
49export const QUEUE_NOTE =
50 "The /queue command above is the person's message queue, handled by a plugin. Queued text is held, not a request: do not act on it, answer it, or mention it. Each queued message arrives later as its own user message, when the queue releases it."
51
52// Every /queue answer carries the note, so Claude does not act on the queued text it reports.
53const answer = (text: string) => ({ text, context: [QUEUE_NOTE] })
54
55// How many queued messages the band lists; the rest are counted.
56const BAND_ROWS = 3
57
58// The queue's color, from the person's theme: the band's pill and numbers, and the reply row's pill.
59const ACCENT = 'suggestion'
60
61// About 8 CSS px per reported column on a desktop. A row's drawing leaves room for all its buttons, the ones the
62// pointer reveals included, so revealing them never wraps the row.
63const PX_PER_COLUMN = 8
64const ROW_BUTTONS_PX = 300
65const RESUME_PX = 96
66// The band's border and its padding, on each side together.
67const FRAME_PX = 32
68
69// The `joined` option, read in register: the drain sends the waiting prompts as one message.
70let joined = false
71
72const stateRef = { plugin: 'queue', key: 'queue' } as const
73
74// The whole queue is one value, so one compare-and-set write moves items and gate together.
75const queue = atom(stateRef, initialState)
76
77// How many messages the desktop band's field has queued. The field's key carries it, so each one queued draws a
78// fresh, empty field. The field's text is never drawn back from state: a redraw arriving after newer keystrokes would
79// otherwise put older text back.
80const fieldUses = atom({ plugin: 'queue', key: 'fieldUses' } as const, 0)
81
82// The desktop draws a one-line field at a fixed short width and a taller one across the band, so the field starts on
83// one line and grows to two once its text passes GROW_AT characters. This holds the text it had when it grew, drawn
84// once into the taller field; null until then, and again once the field queues its message.
85const GROW_AT = 20
86const fieldGrown = atom({ plugin: 'queue', key: 'fieldGrown' } as const, null as string | null)
87
88// The message being edited in place on the desktop, if any. Null when no field is open.
89const editingInitial: InlineEdit | null = null
90const editing = atom({ plugin: 'queue', key: 'editing' } as const, editingInitial)
91
92// Applies `change` to the stored state. A write that lost a race is retried on the newer state.
93// Returns false when `change` declined (undefined), so no write happened.
94const modify = async ($: EngineInterface, change: (state: QueueState) => QueueState | undefined): Promise<boolean> => {
95 for (;;) {
96 const held = await $.state.get(stateRef)
97 const next = change(held.value ?? initialState)
98 if (next === undefined) return false
99
100 const written = await $.state.set(stateRef, next, { ifVersion: held.version })
101 if (written.isSet) {
102 void save($)
103 return true
104 }
105 }
106}
107
108// The queue is also saved under the session id after each write, so a restart or /clear can bring it back.
109// Saves run one at a time, so an older snapshot cannot land after a newer one.
110let saves: Promise<void> = Promise.resolve()
111
112// A hook does not wait for its save, so a slow store never holds up the write that made it.
113const save = ($: EngineInterface): void => {
114 saves = saves.then(() => writeSaved($))
115}
116// Reads the state again rather than taking the one just written, so the newest write is the one saved.
117// A failed store write must not break the queue: only the copy for a restart is lost.
118const writeSaved = async ($: EngineInterface): Promise<void> => {
119 try {
120 const key = sessionKey(await $.session.id())
121 const saved = toSaved(await read($, queue), Date.now())
122 if (saved === undefined) await $.store.delete(key)
123 else await $.store.set(key, saved)
124 } catch {
125 // The live queue is already written; only the saved copy is missing.
126 }
127}
128
129const SESSION_PREFIX = 'session:'
130const sessionKey = (id: string): string => `${SESSION_PREFIX}${id}`
131
132// A /clear or /resume ends the session, and the process switches to the new id after that. Probed: a timer this
133// long after a /clear sees the new id; a shorter wait is untested. While the id is still the old one, it waits again.
134const SWITCH_WAIT_MS = 1000
135
136// How often the timer waits again before it gives up. An id that never changes (a /resume of the open conversation)
137// means $.state already holds the right queue, so there is nothing to restore.
138const SWITCH_TRIES = 5
139
140// The session a /clear or /resume just ended, until its restore runs. The timer and the fallbacks (the first /queue,
141// prompt.submit, turn.start, session.start) all read it, so the restore happens once. A worker respawn loses the
142// timer and this value both; the saved copy then stays where it is. Accepted.
143let pendingSwitch: { from: string; isClear: boolean; tries: number } | undefined
144
145// Brings the queue saved under `key` into this session's queue, paused. Only an empty queue takes it, and a queue
146// that already holds messages leaves the saved copy alone. Resolves whether it restored.
147const restoreFrom = async ($: EngineInterface, key: string): Promise<boolean> => {
148 if ((await read($, queue)).items.length > 0) return false
149
150 try {
151 const saved = await $.store.get(key)
152 if (!isSaved(saved)) return false
153 if (isStale(saved, Date.now())) {
154 await $.store.delete(key)
155 return false
156 }
157
158 const restored = restoredState(saved)
159 if (restored === undefined) return false
160
161 return await modify($, s => (s.items.length === 0 ? restored : undefined))
162 } catch {
163 return false
164 }
165}
166
167// Restores the queue after a /clear or a /resume has switched the session, once: the first trigger that sees the
168// switch takes `pendingSwitch`, so a later one finds nothing to do. While the id is still the old one, nothing is
169// taken and the timer runs again, so the restore never runs against the old $.state. Only the timer waits again: a
170// fallback that sees the old id just returns, so timers never pile up.
171const restoreAfterSwitch = async ($: EngineInterface, isTimer = false): Promise<void> => {
172 const pending = pendingSwitch
173 if (pending === undefined) return
174
175 const current = await $.session.id()
176 if (current === pending.from) {
177 if (!isTimer) return
178 pending.tries += 1
179 if (pending.tries >= SWITCH_TRIES) pendingSwitch = undefined
180 else armSwitchTimer($)
181 return
182 }
183 pendingSwitch = undefined
184
185 // A /resume keeps the conversation's own key, under the id it now has, and nothing is deleted. A /clear's queue
186 // was saved under the old id, and that key goes once its messages are in this session.
187 if (!pending.isClear) {
188 await restoreFrom($, sessionKey(current))
189 return
190 }
191 const oldKey = sessionKey(pending.from)
192 const isRestored = await restoreFrom($, oldKey)
193 if (isRestored) await $.store.delete(oldKey).catch(() => undefined)
194}
195
196const armSwitchTimer = ($: EngineInterface): void => {
197 void $.clock.after(SWITCH_WAIT_MS, () => void restoreAfterSwitch($, true))
198}
199
200// Drops saved queues not written for 7 days, so a session that ended long ago leaves nothing behind.
201const pruneSaved = async ($: EngineInterface): Promise<void> => {
202 try {
203 for (const key of await $.store.keys()) {
204 if (!key.startsWith(SESSION_PREFIX)) continue
205
206 const saved = await $.store.get(key)
207 if (!isSaved(saved) || isStale(saved, Date.now())) await $.store.delete(key)
208 }
209 } catch {
210 // An entry left behind costs little; the next session start prunes again.
211 }
212}
213
214const isAgentBusy = async ($: EngineInterface): Promise<boolean> =>
215 (await $.agent.list()).some(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting')
216
217// Runs the drain on a timer, never inline: a hook may not call prompt.submit.
218const scheduleDrain = ($: EngineInterface): void => {
219 void $.clock.after(0, () => void drain($))
220}
221
222// Sends a batch: one command alone, or one or more prompts as the person's own words.
223const deliver = ($: EngineInterface, batch: QueueItem[]): Promise<void> => {
224 const [first] = batch
225 return first?.kind === 'command' ? runCommand($, first) : sendPrompt($, batch)
226}
227
228// A batch's prompts go out as one message, separated by blank lines. The batch's Stop releases the next one.
229// A submit can resolve as a `drop`: the prompt did not enter and no turn starts, so the items stay queued.
230const sendPrompt = ($: EngineInterface, batch: QueueItem[]): Promise<void> =>
231 $.prompt.submit({ text: batch.map(x => x.text).join('\n\n'), asUser: true }).then(
232 async result => {
233 if (result.drop !== undefined) {
234 await modify($, s => markDropped(s, batch.map(x => x.id)))
235 void $.ui.toast(`Claude Code did not take the queued message (${result.drop}). The queue is paused.`)
236 return
237 }
238 await modify($, s => batch.reduce((acc, x) => markSent(acc, x.id), s))
239 },
240 () => {
241 void modify($, s => batch.reduce((acc, x) => markFailed(acc, x.id), s))
242 void $.ui.toast('The queued message could not be sent. The queue is paused.')
243 },
244 )
245
246// A command's turn, if it starts one, ends at a Stop as a prompt's does. A command that starts none has no Stop
247// coming, so the drain goes on when it resolves; a refused command pauses the queue. The prompt.submit hook has
248// already marked a command that started a turn, and it can run before this resolve, so the mark is read here.
249const runCommand = async ($: EngineInterface, item: QueueItem): Promise<void> => {
250 const { command, args } = splitCommand(item.text)
251 try {
252 await $.command.run({ command, args })
253 } catch {
254 void $.ui.toast('The queued command could not run. The queue is paused.')
255 await modify($, s => markCommandFailed(s, item.id))
256 return
257 }
258
259 await modify($, s => markCommandDone(s, item.id))
260 // A drain while the send was still waiting found the gate closed, so it runs again now.
261 scheduleDrain($)
262}
263
264// A command item must name a command this session has now, so a typo is refused at queue time, not at its turn.
265const refuseCommand = async ($: EngineInterface, text: string): Promise<string | undefined> => {
266 const { command } = splitCommand(text)
267 if (command === 'queue') return 'The queue cannot hold a /queue command. The message was not queued.'
268
269 const isKnown = (await $.command.list()).some(x => x.name === command)
270 return isKnown ? undefined : `No command /${command}. The message was not queued.`
271}
272
273// Queues `text` at the end, as `/queue <text>` does, for the command and the band's field alike. A refused command
274// is not queued, and the refusal says why.
275const enqueue = async ($: EngineInterface, text: string): Promise<{ id: string } | { refusal: string }> => {
276 // Text starting with `/` is a slash command, sent to the command rather than as the person's words.
277 const kind = text.startsWith('/') ? 'command' : 'prompt'
278 if (kind === 'command') {
279 const refusal = await refuseCommand($, text)
280 if (refusal !== undefined) return { refusal }
281 }
282
283 // The reply names the message by id, so the row can tell it apart from one with the same text.
284 // Commands run one at a time, so no other message can take the id between this read and the write.
285 const id = newId(await read($, queue))
286 await modify($, s => add(s, { id, text, kind, status: 'waiting', createdAt: Date.now() }))
287 scheduleDrain($)
288 return { id }
289}
290
291// Releases the next item if the gate is open. Called from events only, never from a poll.
292const drain = async ($: EngineInterface): Promise<void> => {
293 const state = await read($, queue)
294 const item = nextItem(state)
295 if (item === undefined || !isOpen(state)) return
296
297 // The claim re-checks the gate, so a send happens at most once even if two drains race.
298 const isBusy = await isAgentBusy($)
299 // The batch is built from the state the claim wrote, so nothing can change between the claim and the send.
300 let batch: QueueItem[] = []
301 const isClaimed = await modify($, s => {
302 const claimed = claimBatch(s, item.id, isBusy, joined)
303 batch = claimed ? claimed.state.items.filter(x => claimed.ids.includes(x.id)) : []
304 return claimed?.state
305 })
306 if (!isClaimed) return
307
308 await deliver($, batch)
309}
310
311// Puts text in the prompt box. A draft already there is left alone, so the caller keeps the item queued.
312const fillBox = async ($: EngineInterface, text: string): Promise<'filled' | 'draft' | 'no-box'> => {
313 const box = await $.prompt.read()
314 if (box.text !== '') return 'draft'
315
316 const filled = await $.prompt.fill({ text, mode: 'replace' })
317 return filled.isFilled ? 'filled' : 'no-box'
318}
319
320const refuseFill = ($: EngineInterface, outcome: 'draft' | 'no-box'): string => {
321 if (outcome === 'draft') {
322 void $.ui.toast('The prompt box has a draft. The message stays queued.')
323 return 'The prompt box has a draft.'
324 }
325 void $.ui.toast('The prompt box is not available. The message stays queued.')
326 return 'The prompt box is not available.'
327}
328
329// `send` by reference: while idle, submits now; while running, puts the text in the prompt box.
330// Shared by `/queue send` and the band's Send button.
331const sendQueued = async ($: EngineInterface, ref: Ref): Promise<string> => {
332 const item = findQueued(await read($, queue), ref)
333 if (item === undefined) return outOfRange(ref)
334 if (item.status !== 'waiting') {
335 return `Message ${refText(ref)} may already have been sent. /queue resume sends it again; /queue rm drops it.`
336 }
337
338 // Idle: submit now, from a timer (a hook may not call prompt.submit).
339 if ((await read($, queue)).gate.mainTurn === 'idle') {
340 const isClaimed = await modify($, s => claimNow(s, item.id))
341 if (!isClaimed) return `Claude has not finished the last message yet. ${item.text} stays queued.`
342
343 void $.clock.after(0, () => void deliver($, [item]))
344 return `Sending: ${item.text}`
345 }
346
347 // Running: the text goes into the box, and the person's Enter feeds it into the running turn.
348 const outcome = await fillBox($, item.text)
349 if (outcome !== 'filled') return `${refuseFill($, outcome)} ${item.text} stays queued.`
350
351 await modify($, s => moveToBox(s, item.id))
352 return `In the prompt box, press Enter to send: ${item.text}`
353}
354
355const outOfRange = (ref: Ref): string => `No queued message ${refText(ref)}. /queue list shows them.`
356
357const PAUSE_REASON = {
358 aborted: 'you interrupted Claude',
359 command: 'a queued command failed',
360 dropped: 'Claude Code did not take the last message',
361 error: 'an API error stopped the last turn',
362 refusal: 'Claude refused the last message',
363 restored: 'kept from before a restart or /clear',
364 user: 'you paused it',
365} as const
366
367const listText = (state: QueueState): string => {
368 const queued = queuedItems(state)
369 const lines = queued.map((x, i) => `${i + 1}. ${x.text}${x.maybeSent ? ' (may have been sent)' : ''}`)
370 if (state.gate.paused) lines.unshift(`Paused: ${PAUSE_REASON[state.gate.paused]}. /queue resume continues.`)
371 if (queued.length === 0) lines.push('The queue is empty.')
372 return lines.join('\n')
373}
374
375// What the band says the queue is waiting on. Reads only the state: no engine calls while drawing.
376const waitingOn = (state: QueueState): string => {
377 const { gate } = state
378 if (gate.paused) return `paused: ${PAUSE_REASON[gate.paused]}`
379 if (gate.mainTurn === 'running') return 'waiting for Claude'
380 if (gate.awaitingStop) return 'sending'
381 if (gate.backgroundTasks.length > 0) {
382 const count = gate.backgroundTasks.length
383 return `waiting for ${count} background ${count === 1 ? 'task' : 'tasks'}`
384 }
385 return 'next when Claude is done'
386}
387
388export const register: Register = (on, options) => {
389 joined = options.joined === true
390
391 on('session.start', async ($, e, next) => {
392 await $.command.register({
393 name: 'queue',
394 description: 'Send a message to Claude after the current turn finishes',
395 argumentHint: '<message>',
396 immediate: true,
397 })
398
399 // A fresh process restores its own saved copy, but only into a $.state with no value at all. A hot reload holds
400 // the live queue, and a reload between a write and its save would bring back an older copy, so it is left alone.
401 await pruneSaved($)
402 if (pendingSwitch !== undefined) await restoreAfterSwitch($)
403 else if ((await $.state.get(stateRef)).value === undefined) await restoreFrom($, sessionKey(await $.session.id()))
404
405 // Restarts the drain: timers do not survive a reload, so the queue would sit otherwise.
406 scheduleDrain($)
407 return next(e)
408 })
409
410 // A /clear ends the session and the process goes on under a new id, with $.state empty; the queue was saved under
411 // the old id. A /resume in the same process switches to another conversation the same way, with no session.start.
412 // Saves do not block their writes, so the last ones may still be running: they land first, under the old id, or
413 // an exit would lose them and a /clear would save them under the new session. The engine bounds this wait.
414 on('session.end', async ($, e, next) => {
415 await saves
416 if (e.reason === 'clear' || e.reason === 'resume') {
417 pendingSwitch = { from: e.sessionId, isClear: e.reason === 'clear', tries: 0 }
418 armSwitchTimer($)
419 }
420 return next(e)
421 })
422
423 // A command that raises a prompt.submit starts a turn, so its send waits for that turn's Stop. Raised before the
424 // command resolves, so the mark is in place by then.
425 // The person's own submission also sends a message a mid-turn Send put in the box. A /queue command is not one,
426 // and nor is a prompt the queue, another plugin or a notification submitted.
427 on('prompt.submit', async ($, e, next) => {
428 await restoreAfterSwitch($)
429 await modify($, s => markCommandTurn(s, e.command?.name))
430 const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
431 if (isPerson && e.command?.name !== 'queue') await modify($, s => onPersonSubmit(s))
432 return next(e)
433 })
434
435 // Main loop only: a subagent's turns do not change the queue's gate.
436 on('turn.start', async ($, e, next) => {
437 if (e.agentId === undefined) {
438 await restoreAfterSwitch($)
439 await modify($, s => onTurnStart(s))
440 }
441 return next(e)
442 })
443
444 on('turn.complete', async ($, e, next) => {
445 if (e.agentId === undefined) {
446 await modify($, s => onTurnComplete(s, e))
447 } else {
448 // A subagent ended. A Stop that came while it was still listed was refused, so try again now.
449 scheduleDrain($)
450 }
451 return next(e)
452 })
453
454 // A Stop another hook blocks is not the end of the turn, so the drain waits for the next Stop.
455 on('classic.Stop', async ($, e, next) => {
456 const result = await next(e)
457 if (result.block !== undefined || result.preventContinuation) return result
458
459 await modify($, s => onStop(s, e.background_tasks ?? []))
460 scheduleDrain($)
461 return result
462 })
463
464 on('command.run', { command: 'queue' }, async ($, e) => {
465 await restoreAfterSwitch($)
466 const command = parse(e.args)
467
468 switch (command.kind) {
469 case 'message': {
470 if (command.text === '') return answer(USAGE)
471
472 const queued = await enqueue($, command.text)
473 if ('refusal' in queued) return answer(queued.refusal)
474 return answer(`Queued @${queued.id}: ${command.text}`)
475 }
476
477 case 'list':
478 return answer(listText(await read($, queue)))
479
480 case 'clear': {
481 const count = queuedItems(await read($, queue)).length
482 await modify($, s => clearQueued(s))
483 return answer(`Cleared ${count} queued ${count === 1 ? 'message' : 'messages'}.`)
484 }
485
486 case 'pause':
487 await modify($, s => setPaused(s, 'user'))
488 return answer('Paused. Queued messages wait until /queue resume.')
489
490 case 'resume':
491 await modify($, s => resume(s))
492 scheduleDrain($)
493 return answer('Resumed.')
494
495 case 'rm': {
496 const item = findQueued(await read($, queue), command.ref)
497 if (item === undefined) return answer(outOfRange(command.ref))
498
499 await modify($, s => dropItem(s, item.id))
500 return answer(`Removed: ${item.text}`)
501 }
502
503 case 'up':
504 case 'down': {
505 const by = command.kind === 'up' ? -1 : 1
506 const before = await read($, queue)
507 const item = findQueued(before, command.ref)
508 if (item === undefined) return answer(outOfRange(command.ref))
509
510 const isMoved = await modify($, s => moveItem(s, item.id, by))
511 if (!isMoved) return answer(`${item.text} is already ${by < 0 ? 'first' : 'last'}.`)
512
513 const position = queuedItems(before).findIndex(x => x.id === item.id) + 1
514 return answer(`Moved to ${position + by}: ${item.text}`)
515 }
516
517 case 'edit': {
518 const item = findQueued(await read($, queue), command.ref)
519 if (item === undefined) return answer(outOfRange(command.ref))
520
521 // The pre-fill names the message by id, so an earlier message being sent meanwhile cannot shift it.
522 if (command.text === undefined) {
523 const outcome = await fillBox($, `/queue edit @${item.id} ${item.text}`)
524 if (outcome === 'filled') return answer('Edit the message in the prompt box, then press Enter.')
525 return answer(refuseFill($, outcome))
526 }
527
528 const text = command.text
529 await modify($, s => editItem(s, item.id, text))
530 return answer(`Edited: ${text}`)
531 }
532
533 case 'send':
534 return answer(await sendQueued($, command.ref))
535 }
536 })
537
538 // The band above the prompt: a summary, the first few messages, and Resume while paused. Each row keeps Send now
539 // and Remove in view; the pointer on a row adds Up, Down and Edit. It reads the state only, so a redraw costs no
540 // engine calls. Other plugins' bands are drawn beneath it.
541 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
542 // A survey holds the band; the queue yields to it.
543 if (e.props.hasSurvey) return next(e)
544
545 const state = await read($, queue)
546 const queued = queuedItems(state)
547 const ui = $.ui.resolve(e)
548 // The desktop's text field, where the element table has one. The terminal and mobile have none.
549 const Field = e.surface !== 'terminal' && 'Input' in ui && 'Svg' in ui ? ui.Input : undefined
550 // While Claude works the band shows even when empty, so the field is there to type into.
551 const isRunning = state.gate.mainTurn === 'running'
552 // Empty, the band stays hidden unless the person paused on purpose, or Claude works on a surface with the field.
553 if (queued.length === 0 && state.gate.paused !== 'user' && !(Field !== undefined && isRunning)) return next(e)
554
555 // Claude Code's own placeholder ({ type: 'engine' }) is never nested: the desktop drops a band that nests it.
556 const drawn = await Promise.resolve(next(e)).catch(() => null)
557 const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
558
559 const { Box, Button, Text } = ui
560 const shown = queued.slice(0, BAND_ROWS)
561 const summary = `${queued.length} waiting · ${waitingOn(state)}`
562 const editingNow = await read($, editing)
563 const isEditingHere = (id: string): boolean =>
564 Field !== undefined && editingNow !== null && editingNow.id === id && editingNow.site === 'band'
565 const resumeButton =
566 state.gate.paused !== undefined ? (
567 <Button key="resume" label="Resume" variant="primary" onPress={() => void resumeFromBand($)} />
568 ) : null
569 const rowButtons = (x: QueueItem, i: number) => (
570 <Box flexShrink={0} gap={1}>
571 <Box display="none" hover={{ display: 'flex' }} gap={1}>
572 {i > 0 ? <Button key={`up-${x.id}`} label="↑" dimColor onPress={() => void moveFromBand($, x.id, -1)} /> : null}
573 {i < queued.length - 1 ? (
574 <Button key={`down-${x.id}`} label="↓" dimColor onPress={() => void moveFromBand($, x.id, 1)} />
575 ) : null}
576 <Button
577 key={`edit-${x.id}`}
578 label="Edit"
579 dimColor
580 onPress={() => void startEdit($, x.id, 'band', Field !== undefined)}
581 />
582 </Box>
583 <Button
584 key={`send-${x.id}`}
585 label="Send now"
586 variant={i === 0 ? 'primary' : undefined}
587 onPress={() => void sendFromBand($, x.id)}
588 />
589 <Button key={`remove-${x.id}`} label="Remove" dimColor onPress={() => void removeFromBand($, x.id)} />
590 </Box>
591 )
592
593 // The terminal's element table is completed with an `Svg` that draws nothing: test the surface too.
594 if (e.surface !== 'terminal' && 'Svg' in ui) {
595 const { Svg } = ui
596 const slot = Math.max(240, Math.min(1600, (e.props.bodyColumns || 100) * PX_PER_COLUMN))
597 const tone: Tone = state.gate.paused !== undefined ? 'w' : 'a'
598 const isLive =
599 state.gate.paused === undefined &&
600 (state.gate.mainTurn === 'running' || state.gate.awaitingStop || state.gate.backgroundTasks.length > 0)
601 const uses = await read($, fieldUses)
602 const grown = await read($, fieldGrown)
603 // With nothing queued and no pause, the band is the field alone: a header would only say "0 waiting", and a
604 // border around one field would only add weight. Otherwise a border in the state's color frames the queue.
605 const hasHeader = queued.length > 0 || state.gate.paused !== undefined
606 const inner = slot - (hasHeader ? FRAME_PX : 0)
607 const headerW = inner - (resumeButton ? RESUME_PX : 0)
608 const rowW = Math.max(160, inner - ROW_BUTTONS_PX)
609 return (
610 <Box flexDirection="column" gap={1}>
611 <Box
612 flexDirection="column"
613 gap={1}
614 borderStyle={hasHeader ? 'round' : undefined}
615 borderColor={tone === 'w' ? 'warning' : ACCENT}
616 paddingX={hasHeader ? 1 : 0}
617 >
618 {hasHeader ? (
619 <Box alignItems="center" justifyContent="space-between" gap={1}>
620 <Svg
621 source={headerSvg(headerW, queued.length, summary, tone, isLive)}
622 alt={`Queue: ${summary}`}
623 width={headerW}
624 height={HEADER_H}
625 />
626 {resumeButton}
627 </Box>
628 ) : null}
629 {shown.map((x, i) =>
630 Field !== undefined && isEditingHere(x.id) ? (
631 // Edited in place: the text field replaces the row's drawing, Cancel its buttons.
632 <Box key={x.id} alignItems="center" gap={1}>
633 <Box flexGrow={1} minWidth={0}>
634 <Field
635 key={`edit-field-${x.id}`}
636 value={x.text}
637 submitLabel="Save"
638 rows={2}
639 autoFocus
640 onSubmit={value => void saveEdit($, x.id, value)}
641 />
642 </Box>
643 <Button key={`edit-cancel-${x.id}`} label="Cancel" dimColor onPress={() => void closeEdit($)} />
644 </Box>
645 ) : (
646 <Box key={x.id} alignItems="center" justifyContent="space-between" gap={1}>
647 <Svg
648 source={rowSvg(rowW, i + 1, x.text, {
649 isCommand: x.kind === 'command',
650 isDim: x.status !== 'waiting',
651 maybeSent: x.maybeSent === true,
652 })}
653 alt={`${i + 1}. ${x.text}${x.maybeSent ? ' (may have been sent)' : ''}`}
654 width={rowW}
655 height={ROW_H}
656 />
657 {rowButtons(x, i)}
658 </Box>
659 ),
660 )}
661 {queued.length > BAND_ROWS ? <Text dimColor>+{queued.length - BAND_ROWS} more</Text> : null}
662 {Field !== undefined ? (
663 // The last row: a message typed here is queued at once, so it can wait out the turn Claude is running.
664 <Field
665 key={`queue-add-${uses}`}
666 placeholder="Queue a message…"
667 submitLabel="Queue"
668 rows={grown === null ? undefined : 2}
669 value={grown ?? undefined}
670 onInput={value => {
671 if (grown === null && value.length > GROW_AT) void update($, fieldGrown, prev => prev ?? value)
672 }}
673 onSubmit={value => void submitField($, value)}
674 />
675 ) : null}
676 </Box>
677 {below}
678 </Box>
679 )
680 }
681
682 // Each row: the number, the text cut at the edge, then the buttons, which keep their width. The text's Box
683 // may shrink below its text's width, so a long message never pushes the buttons off the edge.
684 return (
685 <Box flexDirection="column">
686 <Box gap={1}>
687 <Box flexShrink={0}>
688 <Text backgroundColor={ACCENT} color="inverseText" weight="semibold">
689 {' Queue '}
690 </Text>
691 </Box>
692 <Box flexGrow={1} flexShrink={1} minWidth={0}>
693 <Text wrap="truncate-end" dimColor>
694 {summary}
695 </Text>
696 </Box>
697 {resumeButton}
698 </Box>
699 {shown.map((x, i) => (
700 <Box key={x.id} gap={1}>
701 <Box width={3} flexShrink={0}>
702 <Text color={ACCENT} weight="semibold" tabular>
703 {i + 1}
704 </Text>
705 </Box>
706 <Box flexGrow={1} flexShrink={1} minWidth={0}>
707 <Text wrap="truncate-end" dimColor={x.status !== 'waiting'}>
708 {x.text}
709 </Text>
710 </Box>
711 {x.maybeSent ? (
712 <Box flexShrink={0}>
713 <Text dimColor>(may have been sent)</Text>
714 </Box>
715 ) : null}
716 {rowButtons(x, i)}
717 </Box>
718 ))}
719 {queued.length > BAND_ROWS ? <Text dimColor>+{queued.length - BAND_ROWS} more</Text> : null}
720 {below}
721 </Box>
722 )
723 })
724
725 // The reply row under `/queue <text>`: a colored status pill that follows its message as the queue changes. Its
726 // number moves up as earlier ones go; once the message is gone it reads Removed when the person dropped it, In the
727 // prompt box after a mid-turn Send until the person sends it, and Sent otherwise. The echo above already shows the text, so the row repeats it only once the text was edited.
728 // While queued, the pointer on the row adds Send now, Edit and Remove. The reply names the message by id, so this
729 // finds the right one even when two share a text. The engine may prefix the text with the command's name; the
730 // anchored match takes that prefix and no more.
731 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
732 if (e.props.command !== 'queue') return next(e)
733
734 const match = /^(?:queue: )?Queued @([a-z0-9]{4}): ([\s\S]*)$/.exec(e.props.text)
735 if (match === null) return next(e)
736
737 const id = match[1] ?? ''
738 const queuedText = match[2] ?? ''
739 const state = await read($, queue)
740 const entry = state.items.find(x => x.id === id)
741 const ui = $.ui.resolve(e)
742 const { Box, Button, Text } = ui
743 const editedText = entry !== undefined && entry.text !== queuedText ? entry.text : undefined
744 const Field = e.surface !== 'terminal' && 'Input' in ui && 'Svg' in ui ? ui.Input : undefined
745 const editingNow = await read($, editing)
746 const isEditingHere = editingNow?.id === id && editingNow.site === 'reply'
747
748 // What the row says, whichever surface draws it. `hover` is the buttons the pointer reveals while queued.
749 let row: Pill
750 let hover: JSX.Element | null = null
751 if (entry === undefined) {
752 if (droppedIds(state).includes(id)) row = { label: 'Removed from the queue', tone: 'm', mark: 'none' }
753 else if (state.boxedId === id) row = { label: 'In the prompt box', tone: 'w', mark: 'dot', status: 'press Enter to send' }
754 else row = { label: 'Sent', tone: 's', mark: 'check' }
755 } else if (entry.status === 'sent') {
756 row = { label: 'Sent', tone: 's', mark: 'check', note: editedText }
757 } else if (!isQueued(entry)) {
758 row = { label: 'Sending…', tone: 'w', mark: 'live', note: editedText }
759 } else {
760 const queued = queuedItems(state)
761 const position = queued.findIndex(x => x.id === id) + 1
762 const status = position === 1 ? waitingOn(state) : `after ${position - 1} more`
763 row = {
764 label: `Queued #${position}`,
765 tone: 'a',
766 mark: 'none',
767 status: `${status}${entry.maybeSent ? ' (may have been sent)' : ''}`,
768 place: { at: position, of: queued.length },
769 note: editedText,
770 }
771 hover = (
772 <Box display="none" hover={{ display: 'flex' }} gap={1} flexShrink={0}>
773 <Button key={`reply-send-${id}`} label="Send now" onPress={() => void sendFromBand($, id)} />
774 <Button
775 key={`reply-edit-${id}`}
776 label="Edit"
777 dimColor
778 onPress={() => void startEdit($, id, 'reply', Field !== undefined)}
779 />
780 <Button key={`reply-remove-${id}`} label="Remove" dimColor onPress={() => void removeFromBand($, id)} />
781 </Box>
782 )
783 }
784 const note = row.note === undefined ? '' : `now: ${row.note}`
785 const label = row.mark === 'check' ? `✓ ${row.label}` : row.label
786
787 // The terminal's element table is completed with an `Svg` that draws nothing: test the surface too.
788 if (e.surface !== 'terminal' && 'Svg' in ui) {
789 const { Svg } = ui
790 const drawing = pillSvg({ ...row, note })
791 const alt = [label, row.status, note].filter(Boolean).join(', ')
792 // Edited in place: the field and Cancel replace the pill while the message is still queued.
793 if (Field !== undefined && isEditingHere && entry !== undefined && isQueued(entry)) {
794 return (
795 <Box key={`reply-${id}`} gap={1} alignItems="center" justifyContent="flex-end">
796 <Box flexGrow={1} minWidth={0}>
797 <Field
798 key={`edit-field-${id}`}
799 value={entry.text}
800 submitLabel="Save"
801 rows={2}
802 autoFocus
803 onSubmit={value => void saveEdit($, id, value)}
804 />
805 </Box>
806 <Button key={`edit-cancel-${id}`} label="Cancel" dimColor onPress={() => void closeEdit($)} />
807 </Box>
808 )
809 }
810 return (
811 <Box key={`reply-${id}`} gap={1} alignItems="center" justifyContent="flex-end">
812 <Svg source={drawing.source} alt={alt} width={drawing.width} height={PILL_H} />
813 {hover}
814 </Box>
815 )
816 }
817
818 // The terminal: the pill as inverse text in the tone's theme color, the rest dim.
819 const TONE_COLOR = { a: ACCENT, s: 'success', w: 'warning', m: '' } as const
820 if (row.tone === 'm') {
821 return (
822 <Box justifyContent="flex-end">
823 <Text dimColor>{label}</Text>
824 </Box>
825 )
826 }
827 return (
828 <Box key={`reply-${id}`} gap={1} justifyContent="flex-end">
829 <Box flexShrink={0}>
830 <Text backgroundColor={TONE_COLOR[row.tone]} color="inverseText" weight="semibold">
831 {` ${label} `}
832 </Text>
833 </Box>
834 {row.status !== undefined ? (
835 <Box flexShrink={0}>
836 <Text dimColor>{row.status}</Text>
837 </Box>
838 ) : null}
839 {note !== '' ? (
840 <Box flexGrow={1} flexShrink={1} minWidth={0}>
841 <Text wrap="truncate-end" dimColor>
842 {note}
843 </Text>
844 </Box>
845 ) : null}
846 {hover}
847 </Box>
848 )
849 })
850}
851
852// The band's buttons act by id, through the same paths as the commands.
853const sendFromBand = async ($: EngineInterface, id: string): Promise<void> => {
854 void $.ui.toast(await sendQueued($, { id }))
855}
856
857const moveFromBand = async ($: EngineInterface, id: string, by: -1 | 1): Promise<void> => {
858 await modify($, s => moveItem(s, id, by))
859}
860
861// Edit pre-fills the prompt box with `/queue edit @id <text>`, as `/queue edit` does.
862const editFromBand = async ($: EngineInterface, id: string): Promise<void> => {
863 const item = findQueued(await read($, queue), { id })
864 if (item === undefined) return
865
866 const outcome = await fillBox($, `/queue edit @${item.id} ${item.text}`)
867 if (outcome !== 'filled') refuseFill($, outcome)
868}
869
870// Edit on the desktop opens the message in its own row; the terminal and mobile fill the prompt box instead.
871const startEdit = async ($: EngineInterface, id: string, site: InlineEdit['site'], inline: boolean): Promise<void> => {
872 if (!inline) return editFromBand($, id)
873 await update($, editing, () => ({ id, site }))
874}
875
876// Saves an inline edit: the trimmed text when it is non-empty and changed. The field closes either way.
877const saveEdit = async ($: EngineInterface, id: string, value: string): Promise<void> => {
878 const text = value.trim()
879 const item = findQueued(await read($, queue), { id })
880 if (item !== undefined && text !== '' && text !== item.text) await modify($, s => editItem(s, id, text))
881 await closeEdit($)
882}
883
884const closeEdit = async ($: EngineInterface): Promise<void> => {
885 await update($, editing, () => null)
886}
887
888// The band's field: a trimmed message is queued as `/queue <message>` queues it. A refusal keeps the field as typed.
889const submitField = async ($: EngineInterface, value: string): Promise<void> => {
890 const text = value.trim()
891 if (text === '') return
892
893 const queued = await enqueue($, text)
894 if ('refusal' in queued) {
895 void $.ui.toast(queued.refusal)
896 return
897 }
898 await update($, fieldUses, n => n + 1)
899 await update($, fieldGrown, () => null)
900}
901
902const removeFromBand = async ($: EngineInterface, id: string): Promise<void> => {
903 await modify($, s => dropItem(s, id))
904}
905
906const resumeFromBand = async ($: EngineInterface): Promise<void> => {
907 await modify($, s => resume(s))
908 scheduleDrain($)
909}
910hooks/command.ts 38 lines1// Which queued message a command means: its position (`3`), or its id (`@k3j2h1`). The id is what
2// `edit` pre-fills, so an edit still reaches its message after an earlier one is sent.
3export type Ref = { n: number } | { id: string }
4
5// What `/queue <args>` asks for. A control word counts only when its arguments fit, so
6// `/queue rm` with no number, or `/queue list now`, queues the text as a message.
7export type Command =
8 | { kind: 'message'; text: string }
9 | { kind: 'list' | 'clear' | 'pause' | 'resume' }
10 | { kind: 'rm' | 'send' | 'up' | 'down'; ref: Ref }
11 | { kind: 'edit'; ref: Ref; text?: string }
12
13const REF = /^(\d+|@[a-z0-9]+)$/
14
15const toRef = (token: string): Ref => (token.startsWith('@') ? { id: token.slice(1) } : { n: Number(token) })
16
17// The reference as the person typed it, for replies and the edit pre-fill.
18export const refText = (ref: Ref): string => ('id' in ref ? `@${ref.id}` : String(ref.n))
19
20export const parse = (args: string): Command => {
21 const text = args.trim()
22
23 // `--` makes the rest literal: `/queue -- list` queues the word "list".
24 if (text === '--' || text.startsWith('-- ')) return { kind: 'message', text: text.slice(2).trim() }
25
26 if (/^(list|clear|pause|resume)$/.test(text)) return { kind: text as 'list' | 'clear' | 'pause' | 'resume' }
27
28 const targeted = /^(rm|send|up|down)\s+(\S+)$/.exec(text)
29 if (targeted && REF.test(targeted[2] ?? '')) {
30 return { kind: targeted[1] as 'rm' | 'send' | 'up' | 'down', ref: toRef(targeted[2] ?? '') }
31 }
32
33 const edit = /^edit\s+(\S+)(?:\s+([\s\S]+))?$/.exec(text)
34 if (edit && REF.test(edit[1] ?? '')) return { kind: 'edit', ref: toRef(edit[1] ?? ''), text: edit[2] }
35
36 return { kind: 'message', text }
37}
38hooks/draw.ts 170 lines1// The desktop drawings: the band's rows and the reply row's pill, each one SVG. The desktop wraps sibling elements
2// onto new lines, so a row's marks live in one drawing and only its buttons are elements beside it.
3
4export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
5const MONO = "ui-monospace,'SF Mono',Menlo,monospace"
6
7// The tones, as the terminal's theme names them: accent (`suggestion`), success, warning, muted.
8export type Tone = 'a' | 's' | 'w' | 'm'
9
10// Drawn as an image, the SVG reads the theme from the color scheme. `f*` fills, `c*` text, `b*` a tinted ground.
11const CSS = `<style>
12.t{fill:#1f1f1f}.m{fill:#8a8a86}.k{fill:#e4e4e2}.inv{fill:#ffffff}
13.fa,.ca{fill:#5769f7}.fs,.cs{fill:#2c7a39}.fw,.cw{fill:#966c1e}.fm,.cm{fill:#8a8a86}
14.ba{fill:#5769f7;fill-opacity:.13}.bs{fill:#2c7a39;fill-opacity:.13}.bw{fill:#966c1e;fill-opacity:.14}.bm{fill:#8a8a86;fill-opacity:.14}
15.ss{stroke:#2c7a39}
16@media (prefers-color-scheme: dark){
17.t{fill:#ececec}.m{fill:#9a9a96}.k{fill:#343432}.inv{fill:#1f1f1f}
18.fa,.ca{fill:#b1b9f9}.fs,.cs{fill:#4eba65}.fw,.cw{fill:#e0b04a}.fm,.cm{fill:#9a9a96}
19.ba{fill:#b1b9f9;fill-opacity:.16}.bs{fill:#4eba65;fill-opacity:.16}.bw{fill:#e0b04a;fill-opacity:.16}.bm{fill:#9a9a96;fill-opacity:.16}
20.ss{stroke:#4eba65}}
21.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
22@media (prefers-reduced-motion: reduce){.live{animation:none}}
23</style>`
24
25const xml = (s: string): string =>
26 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
27
28// CJK, fullwidth forms and emoji advance about 1em.
29const WIDE =
30 /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA960-\uA97F\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF01-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
31
32// Rough advance of system UI text, in em; good enough to size a slot. Monospace is 0.6em throughout.
33const charEm = (ch: string): number =>
34 WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
35
36export const textWidth = (s: string, size: number, isMono = false): number =>
37 [...s].reduce((w, ch) => w + (isMono && !WIDE.test(ch) ? 0.6 : charEm(ch)) * size, 0)
38
39// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut. Line breaks read as spaces.
40export const fitText = (s: string, size: number, maxW: number, isMono = false): string => {
41 const flat = s.replace(/\s+/g, ' ').trim()
42 if (textWidth(flat, size, isMono) <= maxW) return flat
43 let out = ''
44 for (const ch of flat) {
45 if (textWidth(out + ch + '…', size, isMono) > maxW) break
46 out += ch
47 }
48 return out + '…'
49}
50
51const svg = (W: number, H: number, body: string): string =>
52 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${body}</svg>`
53
54const text = (x: number, y: number, cls: string, size: number, s: string, extra = ''): string =>
55 `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${size}"${extra}>${xml(s)}</text>`
56
57// A strip of slots, one per waiting message, `lit` the one drawn in the accent; `pulse` animates it.
58const slots = (x: number, y: number, count: number, lit: number, pulse: boolean): string =>
59 Array.from({ length: count }, (_, i) => {
60 const cls = i === lit ? `fa${pulse ? ' live' : ''}` : 'k'
61 return `<rect class="${cls}" x="${x + i * (SLOT_W + SLOT_GAP)}" y="${y}" width="${SLOT_W}" height="6" rx="3"/>`
62 }).join('')
63
64const SLOT_W = 12
65const SLOT_GAP = 3
66const MAX_SLOTS = 10
67const slotsWidth = (count: number): number => (count > 0 ? count * SLOT_W + (count - 1) * SLOT_GAP : 0)
68
69export const HEADER_H = 22
70
71// The band's first row: the state dot, "Queue", the summary, and a slot per waiting message at the right edge.
72export const headerSvg = (W: number, count: number, summary: string, tone: Tone, isLive: boolean): string => {
73 const shown = Math.min(count, MAX_SLOTS)
74 const more = count > shown ? `+${count - shown}` : ''
75 const moreW = more ? textWidth(more, 11) + 6 : 0
76 const stripX = W - slotsWidth(shown) - moreW
77 const titleW = textWidth('Queue', 13)
78 const summaryX = Math.round(16 + titleW + 10)
79 const summaryText = fitText(summary, 12.5, Math.max(0, stripX - summaryX - 14))
80 const y = HEADER_H / 2 + 4.5
81 return svg(
82 W,
83 HEADER_H,
84 `<circle class="f${tone}${isLive ? ' live' : ''}" cx="5" cy="${HEADER_H / 2}" r="4"/>
85${text(16, y, 't', 13, 'Queue', ' font-weight="600"')}
86${text(summaryX, y, 'm', 12.5, summaryText)}
87${slots(stripX, HEADER_H / 2 - 3, shown, 0, isLive && tone === 'a')}
88${more ? text(W, y - 0.5, 'm', 11, more, ' text-anchor="end" font-variant-numeric="tabular-nums"') : ''}`,
89 )
90}
91
92export const ROW_H = 26
93
94// A waiting message: its number in a badge (solid for the next one out), the text, and a tag when it may have gone.
95export const rowSvg = (W: number, n: number, body: string, opts: { isCommand: boolean; isDim: boolean; maybeSent: boolean }): string => {
96 const cy = ROW_H / 2
97 const tag = opts.maybeSent ? 'may have been sent' : ''
98 const tagW = tag ? Math.round(textWidth(tag, 11) + 14) : 0
99 const textX = 30
100 const maxW = Math.max(20, W - textX - (tag ? tagW + 8 : 0) - 4)
101 const shown = fitText(body, 13, maxW, opts.isCommand)
102 const font = opts.isCommand ? MONO : FONT
103 const size = opts.isCommand ? 12.5 : 13
104 const cls = opts.isDim ? 'm' : 't'
105 const number = String(n)
106 return svg(
107 W,
108 ROW_H,
109 `<circle class="${n === 1 ? 'fa' : 'ba'}" cx="11" cy="${cy}" r="9.5"/>
110<text class="${n === 1 ? 'inv' : 'ca'}" x="11" y="${cy + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" font-variant-numeric="tabular-nums">${number}</text>
111<text class="${cls}" x="${textX}" y="${cy + 4.5}" font-family="${font}" font-size="${size}">${xml(shown)}</text>
112${
113 tag
114 ? `<rect class="bw" x="${W - tagW}" y="${cy - 9}" width="${tagW}" height="18" rx="9"/>${text(W - tagW / 2, cy + 4, 'cw', 11, tag, ' text-anchor="middle" font-weight="500"')}`
115 : ''
116}`,
117 )
118}
119
120export const PILL_H = 22
121
122// The reply row's pill: a tinted chip with its mark, then the status, then the slot strip, then an edit note.
123export type Pill = {
124 label: string
125 tone: Tone
126 mark: 'dot' | 'live' | 'check' | 'none'
127 status?: string
128 // The message's place among `of` waiting messages, drawn as the band's strip.
129 place?: { at: number; of: number }
130 note?: string
131}
132
133const NOTE_MAX = 300
134
135export const pillSvg = (p: Pill): { source: string; width: number } => {
136 const cy = PILL_H / 2
137 const markW = p.mark === 'none' ? 0 : 14
138 const chipW = Math.round(10 + markW + textWidth(p.label, 11.5) + 10)
139 const parts: string[] = [`<rect class="b${p.tone}" x="0" y="1" width="${chipW}" height="${PILL_H - 2}" rx="${(PILL_H - 2) / 2}"/>`]
140
141 if (p.mark === 'dot' || p.mark === 'live') {
142 parts.push(`<circle class="f${p.tone}${p.mark === 'live' ? ' live' : ''}" cx="14" cy="${cy}" r="3.5"/>`)
143 } else if (p.mark === 'check') {
144 parts.push(
145 `<path class="ss" d="M10 ${cy}l3 3 5.5-6" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`,
146 )
147 }
148 parts.push(text(10 + markW, cy + 4, `c${p.tone}`, 11.5, p.label, ' font-weight="600" font-variant-numeric="tabular-nums"'))
149
150 let x = chipW + 8
151 if (p.status) {
152 parts.push(text(x, cy + 4.5, 'm', 12, p.status))
153 x += Math.round(textWidth(p.status, 12)) + 10
154 }
155 if (p.place && p.place.of > 1) {
156 const shown = Math.min(p.place.of, MAX_SLOTS)
157 const lit = Math.min(p.place.at, shown) - 1
158 parts.push(slots(x, cy - 3, shown, lit, false))
159 x += slotsWidth(shown) + 10
160 }
161 if (p.note) {
162 const note = fitText(p.note, 12, NOTE_MAX)
163 parts.push(text(x, cy + 4.5, 'm', 12, note, ' font-style="italic"'))
164 x += Math.round(textWidth(note, 12)) + 4
165 }
166
167 const width = Math.max(chipW, x - 6)
168 return { source: svg(width, PILL_H, parts.join('\n')), width }
169}
170hooks/queue.ts 318 lines1import type { QueueGate, QueueItem, QueueState } from '../types'
2import type { Ref } from './command'
3
4// Sent items kept for the list; older ones are dropped.
5const SENT_HISTORY = 5
6
7// Dropped ids kept so their reply rows can say Removed; older ones are forgotten.
8const REMOVED_HISTORY = 200
9
10export const initialState: QueueState = {
11 items: [],
12 gate: { mainTurn: 'idle', backgroundTasks: [], awaitingStop: false },
13 removedIds: [],
14}
15
16// Items the person can see and act on by number: waiting, or flagged as possibly sent.
17// A message being sent right now is not numbered, so nothing can act on it mid-send.
18export const isQueued = (item: QueueItem): boolean => item.status === 'waiting' || item.maybeSent === true
19
20export const queuedItems = (state: QueueState): QueueItem[] => state.items.filter(isQueued)
21
22// The item at position n (1-based) among the queued items.
23export const queuedAt = (state: QueueState, n: number): QueueItem | undefined => queuedItems(state)[n - 1]
24
25// The queued item a reference means. An id that is no longer queued (already sent) finds nothing.
26export const findQueued = (state: QueueState, ref: Ref): QueueItem | undefined =>
27 'id' in ref ? queuedItems(state).find(x => x.id === ref.id) : queuedAt(state, ref.n)
28
29// The item the drain would release next. Only this one can go, so the order holds.
30export const nextItem = (state: QueueState): QueueItem | undefined => state.items.find(x => x.status !== 'sent')
31
32// Pure transitions: each takes the state and returns the new one. Only claim, claimNow and move can refuse, with undefined.
33
34export const add = (state: QueueState, item: QueueItem): QueueState => trim({ ...state, items: [...state.items, item] })
35
36// Claims the next item for sending only if the gate is open and it is still waiting.
37// Claiming sets awaitingStop, so the next item waits for the Stop that ends this one.
38export const claim = (state: QueueState, id: string, isAgentBusy: boolean): QueueState | undefined =>
39 claimBatch(state, id, isAgentBusy, false)?.state
40
41// Like claim, but with `joined` the waiting prompts after the item go out in the same claim, up to the first
42// command. Returns the ids claimed, so the drain can send exactly those.
43export const claimBatch = (
44 state: QueueState,
45 id: string,
46 isAgentBusy: boolean,
47 joined: boolean,
48): { state: QueueState; ids: string[] } | undefined => {
49 const item = nextItem(state)
50 if (item?.id !== id || item.status !== 'waiting') return undefined
51 if (!isOpen(state) || isAgentBusy) return undefined
52
53 const ids = batchIds(state, item, joined)
54 return { state: claimed(state, ids), ids }
55}
56
57// A command, or any item when not joined, goes alone. A joined prompt takes the waiting prompts after it.
58const batchIds = (state: QueueState, first: QueueItem, joined: boolean): string[] => {
59 if (!joined || first.kind !== 'prompt') return [first.id]
60
61 const ids = [first.id]
62 for (const x of state.items.slice(state.items.indexOf(first) + 1)) {
63 // A sent item sent out of order by `send <n>` is no longer in the queue, so it does not end the batch.
64 if (x.status === 'sent') continue
65 if (x.status !== 'waiting' || x.kind !== 'prompt') break
66 ids.push(x.id)
67 }
68 return ids
69}
70
71// Claims one item to send now, as `send <n>` does while the main turn is idle. Ignores the background
72// tasks and the pause, since the person asked for it; still refuses while a send is waiting for its Stop.
73// Always one item, even in joined mode.
74export const claimNow = (state: QueueState, id: string): QueueState | undefined => {
75 const item = state.items.find(x => x.id === id)
76 if (item?.status !== 'waiting') return undefined
77 if (state.gate.mainTurn !== 'idle' || state.gate.awaitingStop) return undefined
78
79 return claimed(state, [id])
80}
81
82const claimed = (state: QueueState, ids: string[]): QueueState => {
83 const items = state.items.map(x => (ids.includes(x.id) ? { ...x, status: 'sending' as const } : x))
84 return { ...state, items, gate: { ...state.gate, awaitingStop: true } }
85}
86
87export const markSent = (state: QueueState, id: string): QueueState => {
88 const items = state.items.map(x => (x.id === id ? { ...x, status: 'sent' as const } : x))
89 return trim({ ...state, items })
90}
91
92// A failed send may have reached Claude, so the item stays flagged and the queue pauses on it.
93export const markFailed = (state: QueueState, id: string): QueueState => {
94 const items = state.items.map(x => (x.id === id ? { ...x, status: 'sending' as const, maybeSent: true as const } : x))
95 return { ...state, items, gate: { ...state.gate, paused: 'error', awaitingStop: false } }
96}
97
98// A send Claude Code refused (a `drop`) never entered, so the items go back to waiting, unflagged, and the queue
99// pauses on them. No turn starts, so no Stop is coming: the wait for one ends here.
100export const markDropped = (state: QueueState, ids: string[]): QueueState => {
101 const items = state.items.map(x => (ids.includes(x.id) ? { ...x, status: 'waiting' as const } : x))
102 return { ...state, items, gate: { ...state.gate, paused: 'dropped', awaitingStop: false } }
103}
104
105// A command that failed to run never ran, so the item goes back to waiting, first in line, and the queue pauses on it.
106// Resuming retries it; removing it drops it.
107export const markCommandFailed = (state: QueueState, id: string): QueueState => {
108 const items = state.items.map(x => (x.id === id ? { ...x, status: 'waiting' as const } : x))
109 return { ...state, items, gate: { ...state.gate, paused: 'command', awaitingStop: false, commandTurn: undefined } }
110}
111
112// `/name args` as the command runs it: the name, and the rest as its arguments.
113export const splitCommand = (text: string): { command: string; args: string } => {
114 const match = /^\/(\S+)\s*([\s\S]*)$/.exec(text)
115 return { command: match?.[1] ?? '', args: match?.[2] ?? '' }
116}
117
118// A prompt.submit for a command, raised while the sending command item runs, means that command started a turn.
119// Only the item being sent, by its name, counts; any other submission leaves the state alone.
120export const markCommandTurn = (state: QueueState, name: string | undefined): QueueState | undefined => {
121 const item = nextItem(state)
122 if (name === undefined || item?.status !== 'sending' || item.kind !== 'command') return undefined
123 if (splitCommand(item.text).command !== name) return undefined
124
125 return { ...state, gate: { ...state.gate, commandTurn: true } }
126}
127
128export const onTurnStart = (state: QueueState): QueueState => ({
129 ...state,
130 gate: { ...state.gate, mainTurn: 'running' },
131})
132
133// An aborted, errored or refused turn pauses the queue, but only when something is waiting: with nothing queued
134// there is nothing to hold, and a pause would start the next message paused. Neither ends with a Stop,
135// so the wait for one is dropped either way.
136export const onTurnComplete = (
137 state: QueueState,
138 turn: { isAborted: boolean; reason: 'answer' | 'aborted' | 'refusal' | 'error' },
139): QueueState => {
140 const isInterrupted = turn.isAborted || turn.reason === 'error' || turn.reason === 'refusal'
141 const gate = { ...state.gate, mainTurn: 'idle' as const }
142 if (!isInterrupted) return { ...state, gate }
143
144 const isHolding = queuedItems(state).length > 0
145 const paused = isHolding ? interruptReason(turn.reason) : gate.paused
146 return { ...state, gate: { ...gate, paused, awaitingStop: false } }
147}
148
149const interruptReason = (reason: 'answer' | 'aborted' | 'refusal' | 'error'): 'aborted' | 'error' | 'refusal' => {
150 if (reason === 'error') return 'error'
151 if (reason === 'refusal') return 'refusal'
152 return 'aborted'
153}
154
155// A command ran and finished. If it started a turn, that turn's Stop still ends the send, as a prompt's does. If it
156// started none, no Stop is coming, so the send is done here and the gate opens.
157export const markCommandDone = (state: QueueState, id: string): QueueState => {
158 const marked = markSent(state, id)
159 const gate = { ...marked.gate, commandTurn: undefined }
160 return { ...marked, gate: marked.gate.commandTurn ? gate : { ...gate, awaitingStop: false } }
161}
162
163// A short id for a message, unique within the queue. Ids are typed after `@`, so they stay short.
164export const newId = (state: QueueState, random: () => number = Math.random): string => {
165 const taken = new Set([...state.items.map(x => x.id), ...droppedIds(state)])
166 for (;;) {
167 const id = Math.floor(random() * 36 ** 4)
168 .toString(36)
169 .padStart(4, '0')
170 if (!taken.has(id)) return id
171 }
172}
173
174export const onStop = (state: QueueState, backgroundTasks: QueueGate['backgroundTasks']): QueueState => ({
175 ...state,
176 gate: { ...state.gate, mainTurn: 'idle', backgroundTasks, awaitingStop: false, commandTurn: undefined },
177})
178
179export const isOpen = (state: QueueState): boolean =>
180 state.gate.paused === undefined &&
181 state.gate.mainTurn === 'idle' &&
182 state.gate.backgroundTasks.length === 0 &&
183 !state.gate.awaitingStop
184
185// Emptying the queue ends a pause that held it for a reason the person has now seen off: an interrupt or an
186// error. A pause the person chose with `/queue pause` stays until they resume.
187const releaseIfEmptied = (state: QueueState): QueueState => {
188 const isEmptied = queuedItems(state).length === 0
189 const isReleasable =
190 state.gate.paused === 'aborted' ||
191 state.gate.paused === 'command' ||
192 state.gate.paused === 'dropped' ||
193 state.gate.paused === 'error' ||
194 state.gate.paused === 'refusal' ||
195 state.gate.paused === 'restored'
196 if (!isEmptied || !isReleasable) return state
197
198 return { ...state, gate: { ...state.gate, paused: undefined } }
199}
200
201// Takes an item out without recording it as dropped: the send to the prompt box, where the text goes on to be sent.
202export const removeItem = (state: QueueState, id: string): QueueState =>
203 releaseIfEmptied({ ...state, items: state.items.filter(x => x.id !== id) })
204
205// A mid-turn Send: the item leaves the queue for the prompt box, and its reply row reads In the prompt box until the
206// person sends it. A message still marked from an earlier Send left the box without being sent, so it reads Removed.
207export const moveToBox = (state: QueueState, id: string): QueueState => {
208 const moved = { ...removeItem(state, id), boxedId: id }
209 return state.boxedId === undefined ? moved : { ...moved, removedIds: remembered(droppedIds(state), [state.boxedId]) }
210}
211
212// The person's next submission sends what the box held, so the boxed message now reads Sent. The plugin cannot see
213// whether they changed the text first; an edited message still counts as sent.
214export const onPersonSubmit = (state: QueueState): QueueState | undefined =>
215 state.boxedId === undefined ? undefined : { ...state, boxedId: undefined }
216
217// Drops an item the person chose to remove (rm, the band's Remove), so its reply row reads Removed.
218export const dropItem = (state: QueueState, id: string): QueueState => ({
219 ...removeItem(state, id),
220 removedIds: remembered(droppedIds(state), [id]),
221})
222
223export const clearQueued = (state: QueueState): QueueState => {
224 const cleared = queuedItems(state).map(x => x.id)
225 return releaseIfEmptied({
226 ...state,
227 items: state.items.filter(x => !isQueued(x)),
228 removedIds: remembered(droppedIds(state), cleared),
229 })
230}
231
232const remembered = (ids: string[], added: string[]): string[] => [...ids, ...added].slice(-REMOVED_HISTORY)
233
234// The ids of dropped messages. State written before the field existed has none.
235export const droppedIds = (state: QueueState): string[] => state.removedIds ?? []
236
237export const editItem = (state: QueueState, id: string, text: string): QueueState => ({
238 ...state,
239 items: state.items.map(x => (x.id === id ? { ...x, text } : x)),
240})
241
242// Swaps the item with its neighbour among the queued items: by -1 moves it up, by 1 moves it down.
243export const moveItem = (state: QueueState, id: string, by: -1 | 1): QueueState | undefined => {
244 const queued = queuedItems(state)
245 const from = queued.findIndex(x => x.id === id)
246 const to = from + by
247 const a = queued[from]
248 const b = queued[to]
249 if (a === undefined || b === undefined) return undefined
250
251 const items = state.items.map(x => (x.id === a.id ? b : x.id === b.id ? a : x))
252 return { ...state, items }
253}
254
255export const setPaused = (state: QueueState, paused: QueueGate['paused']): QueueState => ({
256 ...state,
257 gate: { ...state.gate, paused },
258})
259
260// Resuming is the person accepting the risk: a message flagged as possibly sent goes out again.
261export const resume = (state: QueueState): QueueState => ({
262 ...state,
263 items: state.items.map(x => (x.maybeSent ? { ...x, status: 'waiting' as const, maybeSent: undefined } : x)),
264 gate: { ...state.gate, paused: undefined },
265})
266
267// Keeps every item still to go and the last few sent ones.
268const trim = (state: QueueState): QueueState => {
269 const sent = state.items.filter(x => x.status === 'sent')
270 const keep = new Set(sent.slice(-SENT_HISTORY).map(x => x.id))
271 return { ...state, items: state.items.filter(x => x.status !== 'sent' || keep.has(x.id)) }
272}
273
274// What a restart or a /clear brings back, saved under the session's id after each write.
275export type SavedQueue = {
276 savedAt: number
277 items: QueueItem[]
278 removedIds?: string[]
279 paused?: QueueGate['paused']
280}
281
282// A saved queue this old is dropped, not restored.
283const SAVED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
284
285// Reads a stored value as a saved queue. The store holds plain JSON, so a value of another shape is not one.
286export const isSaved = (value: unknown): value is SavedQueue => {
287 const saved = value as Partial<SavedQueue> | null
288 return typeof saved === 'object' && saved !== null && Array.isArray(saved.items) && typeof saved.savedAt === 'number'
289}
290
291export const isStale = (saved: SavedQueue, now: number): boolean => now - saved.savedAt > SAVED_MAX_AGE_MS
292
293// What to keep of the queue: the messages still to go, the dropped ids, and a pause. With nothing queued and no
294// pause there is nothing to keep, so undefined, and the caller deletes the key.
295export const toSaved = (state: QueueState, savedAt: number): SavedQueue | undefined => {
296 const items = state.items.filter(x => x.status !== 'sent')
297 if (items.length === 0 && state.gate.paused === undefined) return undefined
298
299 return { savedAt, items, removedIds: droppedIds(state), paused: state.gate.paused }
300}
301
302// The queue as a restart or a /clear leaves it, or undefined when nothing is queued. Nothing is mid-turn any more,
303// so the gate starts clear. A message that was mid-send may have reached Claude, so it is flagged. The queue waits
304// paused until the person resumes it: `restored`, or the `user` pause they chose.
305export const restoredState = (saved: SavedQueue): QueueState | undefined => {
306 const items = saved.items
307 .filter(x => x.status !== 'sent')
308 .map(x => (x.status === 'sending' ? { ...x, maybeSent: true as const } : x))
309 if (items.length === 0) return undefined
310
311 const paused = saved.paused === 'user' ? 'user' : 'restored'
312 return {
313 items,
314 gate: { mainTurn: 'idle', backgroundTasks: [], awaitingStop: false, paused },
315 removedIds: saved.removedIds ?? [],
316 }
317}
318types/index.d.ts 44 lines1// One queued message: a prompt sent as the user's own words, or a slash command.
2export type QueueItem = {
3 id: string
4 text: string
5 kind: 'prompt' | 'command'
6 status: 'waiting' | 'sending' | 'sent'
7 createdAt: number
8 // Set when a send failed after it started, so the message may already have reached Claude.
9 maybeSent?: true
10}
11
12// What the drain waits on before it releases the next item.
13export type QueueGate = {
14 mainTurn: 'running' | 'idle'
15 backgroundTasks: { type: string; description: string }[]
16 paused?: 'aborted' | 'command' | 'dropped' | 'error' | 'refusal' | 'restored' | 'user'
17 // True from a send until the Stop that ends its turn: one item per Stop.
18 awaitingStop: boolean
19 // Set while the command being sent has raised a prompt.submit, so its turn's Stop ends the send.
20 commandTurn?: true
21}
22
23// The queue: the messages, the gate, and the ids of messages the person dropped (rm, clear, the band's Remove),
24// so their reply rows can say so after the message is gone. Only the latest are kept. Optional: state written
25// before the field existed has none, and the readers treat that as no ids. `boxedId` is the message a mid-turn Send
26// put in the prompt box, until the person's next submission sends it.
27export type QueueState = { items: QueueItem[]; gate: QueueGate; removedIds?: string[]; boxedId?: string }
28
29// The message being edited in place on the desktop: its id, and whether the band or the reply row holds the field.
30export type InlineEdit = { id: string; site: 'band' | 'reply' }
31
32declare module 'claude-code' {
33 interface PluginState {
34 queue: {
35 queue: QueueState
36 // How many messages the desktop band's field has queued: its key, so each one queued draws a fresh field.
37 fieldUses: number
38 // The text the desktop band's field had when it grew from one line to two; null while it is one line.
39 fieldGrown: string | null
40 editing: InlineEdit | null
41 }
42 }
43}
44